Packages
Multi-surface application runtime for Elixir. One TEA module renders to terminal, browser (LiveView), SSH, and MCP (agents). 30+ widgets, flexbox + CSS grid, AI agent runtime, distributed swarm with CRDTs, time-travel debugging, session recording, sandboxed REPL, and agentic commerce.
Current section
Files
Jump to
Current section
Files
lib/raxol/terminal/character_handling.ex
defmodule Raxol.Terminal.CharacterHandling do
@moduledoc """
Handles wide character and bidirectional text support for the terminal emulator.
This module provides functions for:
- Determining character width (single, double, or variable width)
- Handling bidirectional text rendering
- Managing character combining
- Supporting Unicode character properties
"""
require Raxol.Core.Runtime.Log
@doc """
Determines if a character is a wide character (takes up two cells).
"""
@spec wide_char?(char()) :: boolean()
def wide_char?(char) do
wide_ranges = [
# CJK Unified Ideographs
{0x4E00, 0x9FFF},
# CJK Unified Ideographs Extension A
{0x3400, 0x4DBF},
# CJK Unified Ideographs Extension B
{0x20000, 0x2A6DF},
# CJK Unified Ideographs Extension C
{0x2A700, 0x2B73F},
# CJK Unified Ideographs Extension D
{0x2B740, 0x2B81F},
# CJK Unified Ideographs Extension E
{0x2B820, 0x2CEAF},
# CJK Unified Ideographs Extension F
{0x2CEB0, 0x2EBEF},
# CJK Unified Ideographs Extension G
{0x30000, 0x3134F},
# CJK Compatibility Ideographs
{0xF900, 0xFAFF},
# Hangul Syllables
{0xAC00, 0xD7AF},
# Fullwidth ASCII variants
{0xFF01, 0xFF60},
# Fullwidth symbols
{0xFFE0, 0xFFE6},
# Miscellaneous Symbols and Pictographs
{0x1F300, 0x1FAFF}
]
Enum.any?(wide_ranges, fn {start, finish} ->
char >= start and char <= finish
end)
end
@doc """
Determine the display width of a given character code point or string.
"""
@spec get_char_width(codepoint :: integer() | String.t()) :: 1 | 2
def get_char_width(codepoint) when is_integer(codepoint) do
case wide_char?(codepoint) do
true -> 2
false -> 1
end
end
def get_char_width(str) when is_binary(str) do
case String.to_charlist(str) do
[cp | _] -> get_char_width(cp)
[] -> 1
end
end
@doc """
Determines if a character is a combining character.
"""
@spec combining_char?(char()) :: boolean()
def combining_char?(char) do
combining_ranges = [
# Combining Diacritical Marks
{0x0300, 0x036F},
# Combining Diacritical Marks Extended
{0x1AB0, 0x1AFF},
# Combining Diacritical Marks Supplement
{0x1DC0, 0x1DFF},
# Combining Diacritical Marks for Symbols
{0x20D0, 0x20FF},
# Combining Half Marks
{0xFE20, 0xFE2F}
]
Enum.any?(combining_ranges, fn {start, finish} ->
char >= start and char <= finish
end)
end
@doc """
Determines the bidirectional character type.
Returns :LTR, :RTL, :NEUTRAL, or :COMBINING.
"""
@dialyzer {:nowarn_function, get_bidi_type: 1}
def get_bidi_type(char) do
bidi_checks = [
{&combining_char?/1, :COMBINING},
{fn c -> char_in_ranges(c, rtl_ranges()) end, :RTL},
{fn c -> char_in_ranges(c, ltr_ranges()) end, :LTR}
]
Enum.find_value(bidi_checks, :NEUTRAL, fn {check, type} ->
case check.(char) do
true -> type
false -> nil
end
end)
end
defp rtl_ranges do
[
# Hebrew
{0x0590, 0x05FF},
# Arabic
{0x0600, 0x06FF},
# Arabic Supplement
{0x0750, 0x077F},
# Arabic Extended-A
{0x08A0, 0x08FF},
# Arabic Presentation Forms-A
{0xFB50, 0xFDFF},
# Arabic Presentation Forms-B
{0xFE70, 0xFEFF},
# Unicode control characters for RTL
# Right-to-Left Override (RLO)
{0x202E, 0x202E},
# Left-to-Right Override (LRO)
{0x202D, 0x202D},
# Right-to-Left Embedding (RLE)
{0x202B, 0x202B},
# Left-to-Right Embedding (LRE)
{0x202A, 0x202A}
]
end
defp ltr_ranges do
[
# Basic Latin Uppercase
{0x0041, 0x005A},
# Basic Latin Lowercase
{0x0061, 0x007A},
# Latin-1 Supplement
{0x00C0, 0x00FF},
# Latin Extended-A & B
{0x0100, 0x024F},
# Digits
{0x0030, 0x0039},
# Space
{0x0020, 0x0020}
]
end
defp char_in_ranges(char, ranges) do
Enum.any?(ranges, fn {start, finish} -> char >= start and char <= finish end)
end
@doc """
Processes a string for bidirectional text rendering.
Returns a list of segments with their rendering order.
"""
@spec process_bidi_text(String.t()) ::
list({:LTR | :RTL | :NEUTRAL, String.t()})
@dialyzer {:nowarn_function, process_bidi_text: 1}
def process_bidi_text(nil), do: []
def process_bidi_text(""), do: []
def process_bidi_text(string) when is_binary(string) do
string
|> String.to_charlist()
|> Enum.reduce([], &handle_bidi_segment/2)
|> Enum.reverse()
end
defp handle_bidi_segment(char, acc) do
bidi_type = get_bidi_type(char)
char_str = <<char::utf8>>
case acc do
[] ->
[{bidi_type, char_str}]
[{prev_type, prev_str} | rest] ->
case prev_type == bidi_type do
true -> [{prev_type, prev_str <> char_str} | rest]
false -> [{bidi_type, char_str}, {prev_type, prev_str} | rest]
end
end
end
@doc """
Gets the effective width of a string, taking into account wide characters
and ignoring combining characters.
"""
@spec get_string_width(String.t()) :: non_neg_integer()
def get_string_width(string) do
string
|> String.graphemes()
|> Enum.map(&get_char_width/1)
|> Enum.sum()
end
@doc """
Splits a string at a given width, respecting wide characters.
"""
@spec split_at_width(String.t(), non_neg_integer()) ::
{String.t(), String.t()}
def split_at_width(string, width) do
{before_text, remaining} = do_split_at_width(string, width, 0, "")
{before_text, remaining}
end
defp do_split_at_width("", _width, _current_width, acc) do
{acc, ""}
end
defp do_split_at_width(
<<char::utf8, rest::binary>>,
width,
current_width,
acc
) do
char_width = get_char_width(char)
case current_width + char_width <= width do
true ->
do_split_at_width(
rest,
width,
current_width + char_width,
acc <> <<char::utf8>>
)
false ->
{acc, <<char::utf8, rest::binary>>}
end
end
end