From 5fe4f562bcfbf69255742623c3f332941928684e Mon Sep 17 00:00:00 2001 From: Mike Hostetler <84222+mikehostetler@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:15:30 -0500 Subject: [PATCH 01/44] feat: harden runtime for Jido Console --- README.md | 7 +- lib/term_ui/backend/raw.ex | 7 +- lib/term_ui/component_supervisor.ex | 2 - lib/term_ui/dev/state_inspector.ex | 18 +- lib/term_ui/elm.ex | 22 +- lib/term_ui/focus_manager.ex | 4 - lib/term_ui/markdown.ex | 1220 +++++++++-------- lib/term_ui/parser.ex | 492 ------- lib/term_ui/parser/events.ex | 87 -- lib/term_ui/persistent_terms.ex | 7 +- lib/term_ui/renderer/buffer_manager.ex | 12 +- lib/term_ui/runtime.ex | 242 ++-- lib/term_ui/terminal/escape_parser.ex | 11 + lib/term_ui/widgets/log_viewer.ex | 12 +- lib/term_ui/widgets/stream_widget/consumer.ex | 178 +-- lib/term_ui/widgets/text_input.ex | 59 + mix.exs | 10 +- test/integration/cross_platform_test.exs | 18 +- test/integration/round_trip_test.exs | 96 +- test/support/integration_helpers.ex | 5 +- test/term_ui/input/line_reader_test.exs | 1 + test/term_ui/input/tty_test.exs | 15 +- test/term_ui/parser_test.exs | 615 --------- test/term_ui/renderer/buffer_manager_test.exs | 11 + test/term_ui/runtime/shutdown_test.exs | 52 +- test/term_ui/runtime_test.exs | 224 ++- test/term_ui/term_utils_test.exs | 1 + test/term_ui/terminal/escape_parser_test.exs | 22 + .../widgets/supervision_tree_viewer_test.exs | 17 +- test/term_ui/widgets/text_input_test.exs | 45 + 30 files changed, 1440 insertions(+), 2072 deletions(-) delete mode 100644 lib/term_ui/parser.ex delete mode 100644 lib/term_ui/parser/events.ex delete mode 100644 test/term_ui/parser_test.exs diff --git a/README.md b/README.md index 27c170f9..0fa25910 100644 --- a/README.md +++ b/README.md @@ -112,11 +112,16 @@ Add `term_ui` to your dependencies in `mix.exs`: ```elixir def deps do [ - {:term_ui, "~> 0.2.0"} + {:term_ui, github: "mikehostetler/term_ui"} ] end ``` +The core runtime has no required package dependencies. Add `:gen_stage` only +when you use the StreamWidget GenStage adapter. Add `:mdex`, `:makeup`, and +`:makeup_elixir` only when you need full Markdown rendering. Without them, the +Markdown viewer uses plain text. These features stay in the same TermUI package. + ## Quick Start ```elixir diff --git a/lib/term_ui/backend/raw.ex b/lib/term_ui/backend/raw.ex index d4f3382e..0884e203 100644 --- a/lib/term_ui/backend/raw.ex +++ b/lib/term_ui/backend/raw.ex @@ -319,11 +319,8 @@ defmodule TermUI.Backend.Raw do # ignored, so enabling mouse tracking leads to escape code leaks if mouse_tracking != :none and not TerminalOutput.needs_hard_reset?() do ansi_mode = mouse_mode_to_ansi(mouse_tracking) - - if ansi_mode do - write_to_terminal(ANSI.enable_mouse_tracking(ansi_mode)) - write_to_terminal(ANSI.enable_sgr_mouse()) - end + write_to_terminal(ANSI.enable_mouse_tracking(ansi_mode)) + write_to_terminal(ANSI.enable_sgr_mouse()) end # Clear screen and home cursor diff --git a/lib/term_ui/component_supervisor.ex b/lib/term_ui/component_supervisor.ex index 6db14947..ef27ce06 100644 --- a/lib/term_ui/component_supervisor.ex +++ b/lib/term_ui/component_supervisor.ex @@ -36,8 +36,6 @@ defmodule TermUI.ComponentSupervisor do use DynamicSupervisor - require Logger - alias TermUI.Component.StatePersistence alias TermUI.ComponentRegistry diff --git a/lib/term_ui/dev/state_inspector.ex b/lib/term_ui/dev/state_inspector.ex index fc8ee319..a879a84c 100644 --- a/lib/term_ui/dev/state_inspector.ex +++ b/lib/term_ui/dev/state_inspector.ex @@ -87,6 +87,10 @@ defmodule TermUI.Dev.StateInspector do render_value_by_type(value, depth, String.duplicate(" ", depth)) end + defp render_value_by_type(%{__struct__: _} = value, depth, _indent) do + render_struct_tree(value, depth) + end + defp render_value_by_type(value, depth, indent) when is_map(value) do render_map_value(value, depth, indent) end @@ -100,13 +104,7 @@ defmodule TermUI.Dev.StateInspector do |> ensure_indent_applied(indent) end - defp render_value_by_type(value, depth, indent) do - if struct_value?(value) do - render_struct_tree(value, depth) - else - [indent <> format_value(value)] - end - end + defp render_value_by_type(value, _depth, indent), do: [indent <> format_value(value)] defp render_map_value(value, _depth, indent) when map_size(value) == 0 do [indent <> "%{}"] @@ -225,12 +223,8 @@ defmodule TermUI.Dev.StateInspector do end end - defp struct_value?(%{__struct__: _}), do: true - defp struct_value?(_), do: false - defp simple_value?(value) do - is_atom(value) or is_number(value) or is_binary(value) or - is_boolean(value) or is_nil(value) or is_pid(value) or is_reference(value) + is_atom(value) or is_number(value) or is_binary(value) or is_pid(value) or is_reference(value) end defp format_key(key) when is_atom(key), do: to_string(key) diff --git a/lib/term_ui/elm.ex b/lib/term_ui/elm.ex index da9084bc..f0d8810d 100644 --- a/lib/term_ui/elm.ex +++ b/lib/term_ui/elm.ex @@ -111,6 +111,18 @@ defmodule TermUI.Elm do """ @callback update(msg(), state()) :: update_result() + @doc """ + Handles application messages sent to the runtime process. + + This callback uses the same result contract as `update/2`. It is useful for + subscriptions, task results, and other OTP messages that do not come from + terminal input. + """ + @callback handle_info(message :: term(), state()) :: update_result() + + @doc "Handles final application cleanup before the runtime stops." + @callback terminate(reason :: term(), state()) :: term() + @doc """ Renders the current state to a render tree. @@ -150,7 +162,7 @@ defmodule TermUI.Elm do """ @callback init(opts :: keyword()) :: init_result() - @optional_callbacks [init: 1] + @optional_callbacks [init: 1, handle_info: 2, terminate: 2] defmacro __using__(_opts) do quote do @@ -172,7 +184,13 @@ defmodule TermUI.Elm do @doc false def event_to_msg(_event, _state), do: :ignore - defoverridable init: 1, event_to_msg: 2 + @doc false + def handle_info(_message, _state), do: :noreply + + @doc false + def terminate(_reason, _state), do: :ok + + defoverridable init: 1, event_to_msg: 2, handle_info: 2, terminate: 2 end end diff --git a/lib/term_ui/focus_manager.ex b/lib/term_ui/focus_manager.ex index f6fcb7a8..330189a9 100644 --- a/lib/term_ui/focus_manager.ex +++ b/lib/term_ui/focus_manager.ex @@ -496,8 +496,6 @@ defmodule TermUI.FocusManager do nil end - defp find_next([], _current), do: nil - defp find_next(list, nil) do # No current focus, return first List.first(list) @@ -514,8 +512,6 @@ defmodule TermUI.FocusManager do end end - defp find_prev([], _current), do: nil - defp find_prev(list, nil) do # No current focus, return last List.last(list) diff --git a/lib/term_ui/markdown.ex b/lib/term_ui/markdown.ex index ed61f980..a14483f7 100644 --- a/lib/term_ui/markdown.ex +++ b/lib/term_ui/markdown.ex @@ -1,724 +1,780 @@ -defmodule TermUI.Markdown do - @moduledoc """ - Markdown processor for rendering styled text in TermUI. - - Converts markdown content to styled segments that can be rendered - by TermUI components. - - ## Usage - - iex> lines = TermUI.Markdown.render("**bold** and *italic*", 80) - - iex> result = TermUI.Markdown.render_with_elements("```elixir\\ndef hello, do: :world\\n```", 80) - """ - - alias TermUI.Component.RenderNode - alias TermUI.Renderer.Style - - @type styled_segment :: {String.t(), Style.t() | nil} - @type styled_line :: [styled_segment] - - @type interactive_element :: %{ - id: String.t(), - type: :code_block, - content: String.t(), - language: String.t() | nil, - start_line: non_neg_integer(), - end_line: non_neg_integer() - } - - @type render_result :: %{ - lines: [styled_line()], - elements: [interactive_element()], - content_height: non_neg_integer() - } - - # Style definitions - @header1_style Style.new(fg: :cyan, attrs: [:bold]) - @header2_style Style.new(fg: :cyan, attrs: [:bold]) - @header3_style Style.new(fg: :white, attrs: [:bold]) - @bold_style Style.new(attrs: [:bold]) - @italic_style Style.new(attrs: [:italic]) - @code_style Style.new(fg: :yellow) - @code_block_style Style.new(fg: :yellow) - @code_border_style Style.new(fg: :bright_black) - @code_border_focused_style Style.new(fg: :cyan, attrs: [:bold]) - @blockquote_style Style.new(fg: :bright_black) - @link_style Style.new(fg: :blue, attrs: [:underline]) - @list_bullet_style Style.new(fg: :cyan) - @hr_style Style.new(fg: :bright_black) - - # Dialyzer: Pattern match coverage warnings - @dialyzer {:nowarn_function, - render: 2, - render_with_elements: 3, - render_line_to_node: 1, - process_document: 1, - process_document_with_elements: 2} - - # Syntax highlighting token styles - @token_styles %{ - keyword: Style.new(fg: :magenta, attrs: [:bold]), - keyword_namespace: Style.new(fg: :magenta, attrs: [:bold]), - keyword_pseudo: Style.new(fg: :magenta, attrs: [:bold]), - keyword_reserved: Style.new(fg: :magenta, attrs: [:bold]), - keyword_constant: Style.new(fg: :magenta, attrs: [:bold]), - keyword_declaration: Style.new(fg: :magenta, attrs: [:bold]), - keyword_type: Style.new(fg: :magenta, attrs: [:bold]), - string: Style.new(fg: :green), - string_char: Style.new(fg: :green), - string_doc: Style.new(fg: :green), - string_double: Style.new(fg: :green), - string_single: Style.new(fg: :green), - string_sigil: Style.new(fg: :green), - string_regex: Style.new(fg: :green), - string_interpol: Style.new(fg: :red), - string_escape: Style.new(fg: :cyan), - string_symbol: Style.new(fg: :cyan), - comment: Style.new(fg: :bright_black), - comment_single: Style.new(fg: :bright_black), - comment_multiline: Style.new(fg: :bright_black), - comment_doc: Style.new(fg: :bright_black), - atom: Style.new(fg: :cyan), - number: Style.new(fg: :yellow), - number_integer: Style.new(fg: :yellow), - number_float: Style.new(fg: :yellow), - number_bin: Style.new(fg: :yellow), - number_oct: Style.new(fg: :yellow), - number_hex: Style.new(fg: :yellow), - operator: Style.new(fg: :yellow), - operator_word: Style.new(fg: :magenta, attrs: [:bold]), - name: Style.new(fg: :white), - name_function: Style.new(fg: :blue), - name_class: Style.new(fg: :yellow, attrs: [:bold]), - name_builtin: Style.new(fg: :cyan), - name_builtin_pseudo: Style.new(fg: :cyan), - name_attribute: Style.new(fg: :cyan), - name_label: Style.new(fg: :cyan), - name_constant: Style.new(fg: :yellow, attrs: [:bold]), - name_exception: Style.new(fg: :red), - name_tag: Style.new(fg: :blue), - name_decorator: Style.new(fg: :cyan), - name_namespace: Style.new(fg: :yellow, attrs: [:bold]), - punctuation: Style.new(fg: :white), - whitespace: nil, - text: nil - } - - @supported_lexers %{ - "elixir" => Makeup.Lexers.ElixirLexer, - "ex" => Makeup.Lexers.ElixirLexer, - "exs" => Makeup.Lexers.ElixirLexer, - "iex" => Makeup.Lexers.ElixirLexer, - "erlang" => Makeup.Lexers.ErlangLexer, - "erl" => Makeup.Lexers.ErlangLexer, - "hrl" => Makeup.Lexers.ErlangLexer - } - - @doc """ - Renders markdown content as a list of styled lines. - """ - @spec render(String.t(), pos_integer()) :: [styled_line()] - def render("", _max_width), do: [[{"", nil}]] - def render(nil, _max_width), do: [[{"", nil}]] - - def render(content, max_width) when is_binary(content) and max_width > 0 do - case MDEx.parse_document(content) do - {:ok, document} -> - document - |> process_document() - |> wrap_styled_lines(max_width) - - {:error, _reason} -> - content - |> String.split("\n") - |> Enum.map(fn line -> [{line, nil}] end) - |> wrap_styled_lines(max_width) - end - end - - def render(content, _max_width) when is_binary(content), do: render(content, 80) - - @doc """ - Renders markdown content with interactive element tracking. - """ - @spec render_with_elements(String.t(), pos_integer(), keyword()) :: render_result() - def render_with_elements("", _max_width, _opts) do - %{lines: [[{"", nil}]], elements: [], content_height: 1} - end - - def render_with_elements(nil, _max_width, _opts) do - %{lines: [[{"", nil}]], elements: [], content_height: 1} - end +if Code.ensure_loaded?(MDEx) and Code.ensure_loaded?(Makeup) and + Code.ensure_loaded?(Makeup.Lexers.ElixirLexer) do + defmodule TermUI.Markdown do + @moduledoc """ + Markdown processor for rendering styled text in TermUI. + + Converts markdown content to styled segments that can be rendered + by TermUI components. + + ## Usage + + iex> lines = TermUI.Markdown.render("**bold** and *italic*", 80) + + iex> result = TermUI.Markdown.render_with_elements("```elixir\\ndef hello, do: :world\\n```", 80) + """ + + alias TermUI.Component.RenderNode + alias TermUI.Renderer.Style + + @type styled_segment :: {String.t(), Style.t() | nil} + @type styled_line :: [styled_segment] + + @type interactive_element :: %{ + id: String.t(), + type: :code_block, + content: String.t(), + language: String.t() | nil, + start_line: non_neg_integer(), + end_line: non_neg_integer() + } + + @type render_result :: %{ + lines: [styled_line()], + elements: [interactive_element()], + content_height: non_neg_integer() + } + + @doc "Returns true when full Markdown rendering is available." + @spec available?() :: boolean() + def available?, do: true + + # Style definitions + @header1_style Style.new(fg: :cyan, attrs: [:bold]) + @header2_style Style.new(fg: :cyan, attrs: [:bold]) + @header3_style Style.new(fg: :white, attrs: [:bold]) + @bold_style Style.new(attrs: [:bold]) + @italic_style Style.new(attrs: [:italic]) + @code_style Style.new(fg: :yellow) + @code_block_style Style.new(fg: :yellow) + @code_border_style Style.new(fg: :bright_black) + @code_border_focused_style Style.new(fg: :cyan, attrs: [:bold]) + @blockquote_style Style.new(fg: :bright_black) + @link_style Style.new(fg: :blue, attrs: [:underline]) + @list_bullet_style Style.new(fg: :cyan) + @hr_style Style.new(fg: :bright_black) + + # Dialyzer: Pattern match coverage warnings + @dialyzer {:nowarn_function, + render: 2, + render_with_elements: 3, + render_line_to_node: 1, + process_document: 1, + process_document_with_elements: 2} + + # Syntax highlighting token styles + @token_styles %{ + keyword: Style.new(fg: :magenta, attrs: [:bold]), + keyword_namespace: Style.new(fg: :magenta, attrs: [:bold]), + keyword_pseudo: Style.new(fg: :magenta, attrs: [:bold]), + keyword_reserved: Style.new(fg: :magenta, attrs: [:bold]), + keyword_constant: Style.new(fg: :magenta, attrs: [:bold]), + keyword_declaration: Style.new(fg: :magenta, attrs: [:bold]), + keyword_type: Style.new(fg: :magenta, attrs: [:bold]), + string: Style.new(fg: :green), + string_char: Style.new(fg: :green), + string_doc: Style.new(fg: :green), + string_double: Style.new(fg: :green), + string_single: Style.new(fg: :green), + string_sigil: Style.new(fg: :green), + string_regex: Style.new(fg: :green), + string_interpol: Style.new(fg: :red), + string_escape: Style.new(fg: :cyan), + string_symbol: Style.new(fg: :cyan), + comment: Style.new(fg: :bright_black), + comment_single: Style.new(fg: :bright_black), + comment_multiline: Style.new(fg: :bright_black), + comment_doc: Style.new(fg: :bright_black), + atom: Style.new(fg: :cyan), + number: Style.new(fg: :yellow), + number_integer: Style.new(fg: :yellow), + number_float: Style.new(fg: :yellow), + number_bin: Style.new(fg: :yellow), + number_oct: Style.new(fg: :yellow), + number_hex: Style.new(fg: :yellow), + operator: Style.new(fg: :yellow), + operator_word: Style.new(fg: :magenta, attrs: [:bold]), + name: Style.new(fg: :white), + name_function: Style.new(fg: :blue), + name_class: Style.new(fg: :yellow, attrs: [:bold]), + name_builtin: Style.new(fg: :cyan), + name_builtin_pseudo: Style.new(fg: :cyan), + name_attribute: Style.new(fg: :cyan), + name_label: Style.new(fg: :cyan), + name_constant: Style.new(fg: :yellow, attrs: [:bold]), + name_exception: Style.new(fg: :red), + name_tag: Style.new(fg: :blue), + name_decorator: Style.new(fg: :cyan), + name_namespace: Style.new(fg: :yellow, attrs: [:bold]), + punctuation: Style.new(fg: :white), + whitespace: nil, + text: nil + } - def render_with_elements(content, max_width, opts) when is_binary(content) and max_width > 0 do - focused_id = Keyword.get(opts, :focused_element_id) + @supported_lexers %{ + "elixir" => Makeup.Lexers.ElixirLexer, + "ex" => Makeup.Lexers.ElixirLexer, + "exs" => Makeup.Lexers.ElixirLexer, + "iex" => Makeup.Lexers.ElixirLexer, + "erlang" => Makeup.Lexers.ErlangLexer, + "erl" => Makeup.Lexers.ErlangLexer, + "hrl" => Makeup.Lexers.ErlangLexer + } - case MDEx.parse_document(content) do - {:ok, document} -> - {raw_lines, elements} = process_document_with_elements(document, focused_id) - wrapped_lines = wrap_styled_lines(raw_lines, max_width) - %{lines: wrapped_lines, elements: elements, content_height: length(wrapped_lines)} + @doc """ + Renders markdown content as a list of styled lines. + """ + @spec render(String.t(), pos_integer()) :: [styled_line()] + def render("", _max_width), do: [[{"", nil}]] + def render(nil, _max_width), do: [[{"", nil}]] + + def render(content, max_width) when is_binary(content) and max_width > 0 do + case MDEx.parse_document(content) do + {:ok, document} -> + document + |> process_document() + |> wrap_styled_lines(max_width) - {:error, _reason} -> - lines = + {:error, _reason} -> content |> String.split("\n") |> Enum.map(fn line -> [{line, nil}] end) |> wrap_styled_lines(max_width) + end + end + + def render(content, _max_width) when is_binary(content), do: render(content, 80) - %{lines: lines, elements: [], content_height: length(lines)} + @doc """ + Renders markdown content with interactive element tracking. + """ + @spec render_with_elements(String.t(), pos_integer(), keyword()) :: render_result() + def render_with_elements("", _max_width, _opts) do + %{lines: [[{"", nil}]], elements: [], content_height: 1} end - end - def render_with_elements(content, _max_width, opts) when is_binary(content) do - render_with_elements(content, 80, opts) - end + def render_with_elements(nil, _max_width, _opts) do + %{lines: [[{"", nil}]], elements: [], content_height: 1} + end - @doc """ - Converts a styled line to a TermUI render node. - """ - @spec render_line_to_node(styled_line()) :: RenderNode.t() - def render_line_to_node([]), do: RenderNode.text("", nil) + def render_with_elements(content, max_width, opts) + when is_binary(content) and max_width > 0 do + focused_id = Keyword.get(opts, :focused_element_id) - def render_line_to_node([{text, style}]) do - RenderNode.text(text, style) - end + case MDEx.parse_document(content) do + {:ok, document} -> + {raw_lines, elements} = process_document_with_elements(document, focused_id) + wrapped_lines = wrap_styled_lines(raw_lines, max_width) + %{lines: wrapped_lines, elements: elements, content_height: length(wrapped_lines)} - def render_line_to_node(segments) when is_list(segments) do - nodes = - Enum.map(segments, fn {text, style} -> - RenderNode.text(text, style) - end) + {:error, _reason} -> + lines = + content + |> String.split("\n") + |> Enum.map(fn line -> [{line, nil}] end) + |> wrap_styled_lines(max_width) - RenderNode.stack(:horizontal, nodes) - end + %{lines: lines, elements: [], content_height: length(lines)} + end + end - # Document Processing - defp process_document(%MDEx.Document{nodes: nodes}) do - Enum.flat_map(nodes, &process_node/1) - end + def render_with_elements(content, _max_width, opts) when is_binary(content) do + render_with_elements(content, 80, opts) + end - defp process_document(_), do: [[{"", nil}]] + @doc """ + Converts a styled line to a TermUI render node. + """ + @spec render_line_to_node(styled_line()) :: RenderNode.t() + def render_line_to_node([]), do: RenderNode.text("", nil) - defp process_document_with_elements(%MDEx.Document{nodes: nodes}, focused_id) do - {lines, elements, _line_idx} = - Enum.reduce(nodes, {[], [], 0}, fn node, {acc_lines, acc_elements, line_idx} -> - {node_lines, node_elements} = process_node_with_elements(node, line_idx, focused_id) - new_line_idx = line_idx + length(node_lines) - {acc_lines ++ node_lines, acc_elements ++ node_elements, new_line_idx} - end) + def render_line_to_node([{text, style}]) do + RenderNode.text(text, style) + end - {lines, elements} - end + def render_line_to_node(segments) when is_list(segments) do + nodes = + Enum.map(segments, fn {text, style} -> + RenderNode.text(text, style) + end) + + RenderNode.stack(:horizontal, nodes) + end + + # Document Processing + defp process_document(%MDEx.Document{nodes: nodes}) do + Enum.flat_map(nodes, &process_node/1) + end + + defp process_document(_), do: [[{"", nil}]] + + defp process_document_with_elements(%MDEx.Document{nodes: nodes}, focused_id) do + {lines, elements, _line_idx} = + Enum.reduce(nodes, {[], [], 0}, fn node, {acc_lines, acc_elements, line_idx} -> + {node_lines, node_elements} = process_node_with_elements(node, line_idx, focused_id) + new_line_idx = line_idx + length(node_lines) + {acc_lines ++ node_lines, acc_elements ++ node_elements, new_line_idx} + end) + + {lines, elements} + end - defp process_document_with_elements(_, _focused_id), do: {[[{"", nil}]], []} + defp process_document_with_elements(_, _focused_id), do: {[[{"", nil}]], []} - defp process_node_with_elements( - %MDEx.CodeBlock{literal: code, info: info}, - line_idx, - focused_id - ) do - lang = if info && info != "", do: String.downcase(String.trim(info)), else: nil - element_id = generate_element_id(code, line_idx) - is_focused = element_id == focused_id - border_style = if is_focused, do: @code_border_focused_style, else: @code_border_style + defp process_node_with_elements( + %MDEx.CodeBlock{literal: code, info: info}, + line_idx, + focused_id + ) do + lang = if info && info != "", do: String.downcase(String.trim(info)), else: nil + element_id = generate_element_id(code, line_idx) + is_focused = element_id == focused_id + border_style = if is_focused, do: @code_border_focused_style, else: @code_border_style - header = - if lang do - focus_hint = if is_focused, do: " [c]", else: "" + header = + if lang do + focus_hint = if is_focused, do: " [c]", else: "" - [ [ - {"┌─ " <> lang <> focus_hint <> " ", @code_block_style}, - {String.duplicate("─", 40 - String.length(focus_hint)), border_style} + [ + {"┌─ " <> lang <> focus_hint <> " ", @code_block_style}, + {String.duplicate("─", 40 - String.length(focus_hint)), border_style} + ] ] - ] - else - focus_hint = if is_focused, do: " [c]", else: "" + else + focus_hint = if is_focused, do: " [c]", else: "" - [ [ - {"┌" <> focus_hint, @code_block_style}, - {String.duplicate("─", 44 - String.length(focus_hint)), border_style} + [ + {"┌" <> focus_hint, @code_block_style}, + {String.duplicate("─", 44 - String.length(focus_hint)), border_style} + ] ] - ] - end + end - code_lines = render_code_block(code, lang) - footer = [[{"└", @code_block_style}, {String.duplicate("─", 44), border_style}], [{"", nil}]] + code_lines = render_code_block(code, lang) - lines = header ++ code_lines ++ footer + footer = [ + [{"└", @code_block_style}, {String.duplicate("─", 44), border_style}], + [{"", nil}] + ] - element = %{ - id: element_id, - type: :code_block, - content: String.trim_trailing(code), - language: lang, - start_line: line_idx, - end_line: line_idx + length(lines) - 1 - } + lines = header ++ code_lines ++ footer - {lines, [element]} - end + element = %{ + id: element_id, + type: :code_block, + content: String.trim_trailing(code), + language: lang, + start_line: line_idx, + end_line: line_idx + length(lines) - 1 + } - defp process_node_with_elements(node, _line_idx, _focused_id) do - lines = process_node(node) - {lines, []} - end + {lines, [element]} + end - defp generate_element_id(content, line_idx) do - :crypto.hash(:md5, "#{line_idx}:#{content}") - |> Base.encode16(case: :lower) - |> String.slice(0, 16) - end + defp process_node_with_elements(node, _line_idx, _focused_id) do + lines = process_node(node) + {lines, []} + end - # Node Processing - defp process_node(%MDEx.Heading{level: 1, nodes: children}) do - content = extract_text(children) - [[{content, @header1_style}], [{"", nil}]] - end + defp generate_element_id(content, line_idx) do + :crypto.hash(:md5, "#{line_idx}:#{content}") + |> Base.encode16(case: :lower) + |> String.slice(0, 16) + end - defp process_node(%MDEx.Heading{level: 2, nodes: children}) do - content = extract_text(children) - [[{content, @header2_style}], [{"", nil}]] - end + # Node Processing + defp process_node(%MDEx.Heading{level: 1, nodes: children}) do + content = extract_text(children) + [[{content, @header1_style}], [{"", nil}]] + end - defp process_node(%MDEx.Heading{level: level, nodes: children}) when level >= 3 do - content = extract_text(children) - [[{content, @header3_style}], [{"", nil}]] - end + defp process_node(%MDEx.Heading{level: 2, nodes: children}) do + content = extract_text(children) + [[{content, @header2_style}], [{"", nil}]] + end - defp process_node(%MDEx.Paragraph{nodes: children}) do - segments = process_inline_nodes(children) - [segments, [{"", nil}]] - end + defp process_node(%MDEx.Heading{level: level, nodes: children}) when level >= 3 do + content = extract_text(children) + [[{content, @header3_style}], [{"", nil}]] + end + + defp process_node(%MDEx.Paragraph{nodes: children}) do + segments = process_inline_nodes(children) + [segments, [{"", nil}]] + end - defp process_node(%MDEx.CodeBlock{literal: code, info: info}) do - lang = if info && info != "", do: String.downcase(String.trim(info)), else: nil + defp process_node(%MDEx.CodeBlock{literal: code, info: info}) do + lang = if info && info != "", do: String.downcase(String.trim(info)), else: nil - header = - if lang do - [ + header = + if lang do [ - {"┌─ " <> lang <> " ", @code_block_style}, - {String.duplicate("─", 40), @code_border_style} + [ + {"┌─ " <> lang <> " ", @code_block_style}, + {String.duplicate("─", 40), @code_border_style} + ] ] - ] - else - [[{"┌", @code_block_style}, {String.duplicate("─", 44), @code_border_style}]] - end + else + [[{"┌", @code_block_style}, {String.duplicate("─", 44), @code_border_style}]] + end - code_lines = render_code_block(code, lang) + code_lines = render_code_block(code, lang) - footer = [ - [{"└", @code_block_style}, {String.duplicate("─", 44), @code_border_style}], - [{"", nil}] - ] + footer = [ + [{"└", @code_block_style}, {String.duplicate("─", 44), @code_border_style}], + [{"", nil}] + ] - header ++ code_lines ++ footer - end + header ++ code_lines ++ footer + end - defp process_node(%MDEx.Code{literal: code}) do - [[{"`" <> code <> "`", @code_style}]] - end + defp process_node(%MDEx.Code{literal: code}) do + [[{"`" <> code <> "`", @code_style}]] + end - defp process_node(%MDEx.BlockQuote{nodes: children}) do - children - |> Enum.flat_map(&process_node/1) - |> Enum.map(fn segments -> - case segments do - [{text, _style} | rest] -> - [{"│ " <> text, @blockquote_style} | rest] + defp process_node(%MDEx.BlockQuote{nodes: children}) do + children + |> Enum.flat_map(&process_node/1) + |> Enum.map(fn segments -> + case segments do + [{text, _style} | rest] -> + [{"│ " <> text, @blockquote_style} | rest] - [] -> - [{"│ ", @blockquote_style}] - end - end) - end + [] -> + [{"│ ", @blockquote_style}] + end + end) + end - defp process_node(%MDEx.List{list_type: :bullet, nodes: items}) do - items - |> Enum.flat_map(fn item -> - process_list_item(item, "• ") - end) - |> Kernel.++([[{"", nil}]]) - end + defp process_node(%MDEx.List{list_type: :bullet, nodes: items}) do + items + |> Enum.flat_map(fn item -> + process_list_item(item, "• ") + end) + |> Kernel.++([[{"", nil}]]) + end - defp process_node(%MDEx.List{list_type: :ordered, nodes: items, start: start}) do - items - |> Enum.with_index(start || 1) - |> Enum.flat_map(fn {item, idx} -> - process_list_item(item, "#{idx}. ") - end) - |> Kernel.++([[{"", nil}]]) - end + defp process_node(%MDEx.List{list_type: :ordered, nodes: items, start: start}) do + items + |> Enum.with_index(start || 1) + |> Enum.flat_map(fn {item, idx} -> + process_list_item(item, "#{idx}. ") + end) + |> Kernel.++([[{"", nil}]]) + end - defp process_node(%MDEx.ListItem{nodes: children}) do - Enum.flat_map(children, &process_node/1) - end + defp process_node(%MDEx.ListItem{nodes: children}) do + Enum.flat_map(children, &process_node/1) + end - defp process_node(%MDEx.ThematicBreak{}) do - [[{"───────────────────────────────────────", @hr_style}], [{"", nil}]] - end + defp process_node(%MDEx.ThematicBreak{}) do + [[{"───────────────────────────────────────", @hr_style}], [{"", nil}]] + end - defp process_node(%MDEx.SoftBreak{}), do: [] - defp process_node(%MDEx.LineBreak{}), do: [[{"", nil}]] + defp process_node(%MDEx.SoftBreak{}), do: [] + defp process_node(%MDEx.LineBreak{}), do: [[{"", nil}]] - defp process_node(node) when is_map(node) do - case Map.get(node, :nodes) do - nil -> - case Map.get(node, :literal) do - nil -> [] - text -> [[{text, nil}]] - end + defp process_node(node) when is_map(node) do + case Map.get(node, :nodes) do + nil -> + case Map.get(node, :literal) do + nil -> [] + text -> [[{text, nil}]] + end - children -> - Enum.flat_map(children, &process_node/1) + children -> + Enum.flat_map(children, &process_node/1) + end end - end - defp process_node(_), do: [] + defp process_node(_), do: [] - # Code Block Rendering - defp render_code_block(code, lang) do - case Map.get(@supported_lexers, lang) do - nil -> - plain_code_lines(code) + # Code Block Rendering + defp render_code_block(code, lang) do + case Map.get(@supported_lexers, lang) do + nil -> + plain_code_lines(code) - lexer -> - try do - highlighted_code_lines(code, lexer) - rescue - _ -> plain_code_lines(code) - end + lexer -> + try do + highlighted_code_lines(code, lexer) + rescue + _ -> plain_code_lines(code) + end + end end - end - defp plain_code_lines(code) do - code - |> String.trim_trailing() - |> String.split("\n") - |> Enum.map(fn line -> [{"│ " <> line, @code_block_style}] end) - end + defp plain_code_lines(code) do + code + |> String.trim_trailing() + |> String.split("\n") + |> Enum.map(fn line -> [{"│ " <> line, @code_block_style}] end) + end - defp highlighted_code_lines(code, lexer) do - tokens = lexer.lex(code |> String.trim_trailing()) + defp highlighted_code_lines(code, lexer) do + tokens = lexer.lex(code |> String.trim_trailing()) - {lines, current_line} = - Enum.reduce(tokens, {[], []}, fn {type, _meta, text}, {lines, current} -> - style = Map.get(@token_styles, type) || @code_block_style - text_str = normalize_token_text(text) - add_token_to_lines(text_str, style, lines, current) - end) + {lines, current_line} = + Enum.reduce(tokens, {[], []}, fn {type, _meta, text}, {lines, current} -> + style = Map.get(@token_styles, type) || @code_block_style + text_str = normalize_token_text(text) + add_token_to_lines(text_str, style, lines, current) + end) - all_lines = finalize_code_lines(lines, current_line) + all_lines = finalize_code_lines(lines, current_line) - Enum.map(all_lines, fn segments -> - [{"│ ", @code_block_style} | segments] - end) - end + Enum.map(all_lines, fn segments -> + [{"│ ", @code_block_style} | segments] + end) + end - defp add_token_to_lines(text, style, lines, current) do - parts = String.split(text, "\n") + defp add_token_to_lines(text, style, lines, current) do + parts = String.split(text, "\n") - case parts do - [single] -> - {lines, current ++ [{single, style}]} + case parts do + [single] -> + {lines, current ++ [{single, style}]} - [first | rest] -> - finished_line = current ++ [{first, style}] - {middle_parts, [last]} = Enum.split(rest, -1) - middle_lines = Enum.map(middle_parts, fn part -> [{part, style}] end) - {lines ++ [finished_line] ++ middle_lines, [{last, style}]} + [first | rest] -> + finished_line = current ++ [{first, style}] + {middle_parts, [last]} = Enum.split(rest, -1) + middle_lines = Enum.map(middle_parts, fn part -> [{part, style}] end) + {lines ++ [finished_line] ++ middle_lines, [{last, style}]} + end end - end - defp finalize_code_lines(lines, []), do: lines - defp finalize_code_lines(lines, current), do: lines ++ [current] + defp finalize_code_lines(lines, []), do: lines + defp finalize_code_lines(lines, current), do: lines ++ [current] - defp normalize_token_text(text) when is_binary(text), do: text + defp normalize_token_text(text) when is_binary(text), do: text - defp normalize_token_text(text) when is_list(text) do - text - |> List.flatten() - |> Enum.map_join(fn - char when is_integer(char) -> <> - str when is_binary(str) -> str - end) - end + defp normalize_token_text(text) when is_list(text) do + text + |> List.flatten() + |> Enum.map_join(fn + char when is_integer(char) -> <> + str when is_binary(str) -> str + end) + end - defp normalize_token_text(text), do: to_string(text) + defp normalize_token_text(text), do: to_string(text) - # Inline Node Processing - defp process_inline_nodes(nodes) when is_list(nodes) do - nodes - |> Enum.flat_map(&process_inline_node/1) - |> merge_adjacent_segments() - end + # Inline Node Processing + defp process_inline_nodes(nodes) when is_list(nodes) do + nodes + |> Enum.flat_map(&process_inline_node/1) + |> merge_adjacent_segments() + end - defp process_inline_node(%MDEx.Text{literal: text}), do: [{text, nil}] + defp process_inline_node(%MDEx.Text{literal: text}), do: [{text, nil}] - defp process_inline_node(%MDEx.Strong{nodes: children}) do - text = extract_text(children) - [{text, @bold_style}] - end + defp process_inline_node(%MDEx.Strong{nodes: children}) do + text = extract_text(children) + [{text, @bold_style}] + end - defp process_inline_node(%MDEx.Emph{nodes: children}) do - text = extract_text(children) - [{text, @italic_style}] - end + defp process_inline_node(%MDEx.Emph{nodes: children}) do + text = extract_text(children) + [{text, @italic_style}] + end - defp process_inline_node(%MDEx.Code{literal: code}) do - [{"`" <> code <> "`", @code_style}] - end + defp process_inline_node(%MDEx.Code{literal: code}) do + [{"`" <> code <> "`", @code_style}] + end - defp process_inline_node(%MDEx.Link{url: url, nodes: children}) do - text = extract_text(children) + defp process_inline_node(%MDEx.Link{url: url, nodes: children}) do + text = extract_text(children) - if text == url do - [{text, @link_style}] - else - [{text, @link_style}, {" (#{url})", Style.new(fg: :bright_black)}] + if text == url do + [{text, @link_style}] + else + [{text, @link_style}, {" (#{url})", Style.new(fg: :bright_black)}] + end end - end - defp process_inline_node(%MDEx.SoftBreak{}), do: [{" ", nil}] - defp process_inline_node(%MDEx.LineBreak{}), do: [{"\n", nil}] + defp process_inline_node(%MDEx.SoftBreak{}), do: [{" ", nil}] + defp process_inline_node(%MDEx.LineBreak{}), do: [{"\n", nil}] - defp process_inline_node(node) when is_map(node) do - case Map.get(node, :literal) do - nil -> - case Map.get(node, :nodes) do - nil -> [] - children -> process_inline_nodes(children) - end + defp process_inline_node(node) when is_map(node) do + case Map.get(node, :literal) do + nil -> + case Map.get(node, :nodes) do + nil -> [] + children -> process_inline_nodes(children) + end - text -> - [{text, nil}] + text -> + [{text, nil}] + end end - end - defp process_inline_node(_), do: [] - - # List Processing - defp process_list_item(%MDEx.ListItem{nodes: children}, prefix) do - children - |> Enum.flat_map(&process_node/1) - |> Enum.with_index() - |> Enum.map(fn {segments, idx} -> - process_list_line(segments, idx, prefix) - end) - |> Enum.reject(fn segments -> - segments == [{"", nil}] - end) - end + defp process_inline_node(_), do: [] + + # List Processing + defp process_list_item(%MDEx.ListItem{nodes: children}, prefix) do + children + |> Enum.flat_map(&process_node/1) + |> Enum.with_index() + |> Enum.map(fn {segments, idx} -> + process_list_line(segments, idx, prefix) + end) + |> Enum.reject(fn segments -> + segments == [{"", nil}] + end) + end - defp process_list_line(segments, 0, prefix) do - case segments do - [{text, style} | rest] -> - [{prefix, @list_bullet_style}, {text, style} | rest] + defp process_list_line(segments, 0, prefix) do + case segments do + [{text, style} | rest] -> + [{prefix, @list_bullet_style}, {text, style} | rest] - [] -> - [{prefix, @list_bullet_style}] + [] -> + [{prefix, @list_bullet_style}] + end end - end - defp process_list_line(segments, _idx, prefix) do - indent = String.duplicate(" ", String.length(prefix)) + defp process_list_line(segments, _idx, prefix) do + indent = String.duplicate(" ", String.length(prefix)) - case segments do - [{text, style} | rest] -> - [{indent <> text, style} | rest] + case segments do + [{text, style} | rest] -> + [{indent <> text, style} | rest] - [] -> - segments + [] -> + segments + end end - end - # Text Extraction - defp extract_text(nodes) when is_list(nodes) do - Enum.map_join(nodes, &extract_text/1) - end + # Text Extraction + defp extract_text(nodes) when is_list(nodes) do + Enum.map_join(nodes, &extract_text/1) + end - defp extract_text(%{literal: text}) when is_binary(text), do: text - defp extract_text(%{nodes: children}), do: extract_text(children) - defp extract_text(_), do: "" + defp extract_text(%{literal: text}) when is_binary(text), do: text + defp extract_text(%{nodes: children}), do: extract_text(children) + defp extract_text(_), do: "" - # Segment Merging - defp merge_adjacent_segments([]), do: [] + # Segment Merging + defp merge_adjacent_segments([]), do: [] - defp merge_adjacent_segments(segments) do - segments - |> Enum.reduce([], fn {text, style}, acc -> - case acc do - [{prev_text, ^style} | rest] -> - [{prev_text <> text, style} | rest] + defp merge_adjacent_segments(segments) do + segments + |> Enum.reduce([], fn {text, style}, acc -> + case acc do + [{prev_text, ^style} | rest] -> + [{prev_text <> text, style} | rest] - _ -> - [{text, style} | acc] - end - end) - |> Enum.reverse() - end + _ -> + [{text, style} | acc] + end + end) + |> Enum.reverse() + end - # Line Wrapping - @spec wrap_styled_lines([styled_line()], pos_integer()) :: [styled_line()] - def wrap_styled_lines(lines, max_width) do - lines - |> Enum.flat_map(fn line -> - wrap_styled_line(line, max_width) - end) - end + # Line Wrapping + @spec wrap_styled_lines([styled_line()], pos_integer()) :: [styled_line()] + def wrap_styled_lines(lines, max_width) do + lines + |> Enum.flat_map(fn line -> + wrap_styled_line(line, max_width) + end) + end + + defp wrap_styled_line([], _max_width), do: [[]] + + defp wrap_styled_line(segments, max_width) do + expanded_segments = expand_newlines_in_segments(segments) - defp wrap_styled_line([], _max_width), do: [[]] + {current, wrapped} = + Enum.reduce(expanded_segments, {[], []}, fn + :newline, {current, acc} -> + {[], acc ++ [Enum.reverse(current)]} - defp wrap_styled_line(segments, max_width) do - expanded_segments = expand_newlines_in_segments(segments) + segment, {current, acc} -> + {[segment | current], acc} + end) - {current, wrapped} = - Enum.reduce(expanded_segments, {[], []}, fn - :newline, {current, acc} -> - {[], acc ++ [Enum.reverse(current)]} + lines_from_newlines = wrapped ++ [Enum.reverse(current)] - segment, {current, acc} -> - {[segment | current], acc} + lines_from_newlines + |> Enum.flat_map(fn line_segments -> + wrap_segments_for_width(line_segments, max_width) end) + end - lines_from_newlines = wrapped ++ [Enum.reverse(current)] + defp expand_newlines_in_segments(segments) do + Enum.flat_map(segments, fn {text, style} -> + expand_segment_newlines(text, style) + end) + end - lines_from_newlines - |> Enum.flat_map(fn line_segments -> - wrap_segments_for_width(line_segments, max_width) - end) - end + defp expand_segment_newlines(text, style) do + if String.contains?(text, "\n") do + text + |> String.split("\n") + |> Enum.intersperse(:newline) + |> Enum.map(fn + :newline -> :newline + t -> {t, style} + end) + else + [{text, style}] + end + end - defp expand_newlines_in_segments(segments) do - Enum.flat_map(segments, fn {text, style} -> - expand_segment_newlines(text, style) - end) - end + defp wrap_segments_for_width([], _max_width), do: [[]] - defp expand_segment_newlines(text, style) do - if String.contains?(text, "\n") do - text - |> String.split("\n") - |> Enum.intersperse(:newline) - |> Enum.map(fn - :newline -> :newline - t -> {t, style} + defp wrap_segments_for_width(segments, max_width) do + {lines, current_line, _current_width} = + Enum.reduce(segments, {[], [], 0}, fn {text, style}, {lines, current, width} -> + wrap_segment({text, style}, lines, current, width, max_width) + end) + + all_lines = lines ++ [current_line] + + all_lines + |> Enum.map(fn line -> + case line do + [] -> [{"", nil}] + segments -> segments + end end) - else - [{text, style}] end - end - defp wrap_segments_for_width([], _max_width), do: [[]] + defp wrap_segment({text, style}, lines, current, width, max_width) do + text_len = String.length(text) - defp wrap_segments_for_width(segments, max_width) do - {lines, current_line, _current_width} = - Enum.reduce(segments, {[], [], 0}, fn {text, style}, {lines, current, width} -> - wrap_segment({text, style}, lines, current, width, max_width) - end) + cond do + text == "" -> + {lines, current ++ [{text, style}], width} - all_lines = lines ++ [current_line] + width + text_len <= max_width -> + {lines, current ++ [{text, style}], width + text_len} - all_lines - |> Enum.map(fn line -> - case line do - [] -> [{"", nil}] - segments -> segments + true -> + wrap_text_at_words(text, style, lines, current, width, max_width) end - end) - end + end - defp wrap_segment({text, style}, lines, current, width, max_width) do - text_len = String.length(text) + defp wrap_text_at_words(text, style, lines, current, width, max_width) do + words = String.split(text, ~r/(\s+)/, include_captures: true) - cond do - text == "" -> - {lines, current ++ [{text, style}], width} + Enum.reduce(words, {lines, current, width}, fn word, acc -> + handle_wrap_word(word, style, acc, max_width) + end) + end - width + text_len <= max_width -> - {lines, current ++ [{text, style}], width + text_len} + defp handle_wrap_word("", _style, acc, _max_width), do: acc - true -> - wrap_text_at_words(text, style, lines, current, width, max_width) + defp handle_wrap_word(word, style, {ls, cur, w}, max_width) do + word_len = String.length(word) + + cond do + w + word_len <= max_width -> + {ls, cur ++ [{word, style}], w + word_len} + + word_len > max_width -> + handle_long_word(word, style, ls, cur, w, max_width) + + String.trim(word) == "" -> + {ls, cur, w} + + true -> + {ls ++ [cur], [{word, style}], word_len} + end end - end - defp wrap_text_at_words(text, style, lines, current, width, max_width) do - words = String.split(text, ~r/(\s+)/, include_captures: true) + defp handle_long_word(word, style, ls, cur, w, max_width) do + {new_lines, remainder} = break_long_word(word, style, max_width - w, max_width) - Enum.reduce(words, {lines, current, width}, fn word, acc -> - handle_wrap_word(word, style, acc, max_width) - end) - end + if cur == [] do + {ls ++ new_lines, [{remainder, style}], String.length(remainder)} + else + {ls ++ [cur] ++ new_lines, [{remainder, style}], String.length(remainder)} + end + end + + defp break_long_word(word, style, first_chunk_size, max_width) do + first_chunk_size = max(first_chunk_size, 1) - defp handle_wrap_word("", _style, acc, _max_width), do: acc + chunks = + word + |> String.graphemes() + |> Enum.chunk_every(max_width) + |> Enum.map(&Enum.join/1) - defp handle_wrap_word(word, style, {ls, cur, w}, max_width) do - word_len = String.length(word) + case chunks do + [] -> + {[], ""} - cond do - w + word_len <= max_width -> - {ls, cur ++ [{word, style}], w + word_len} + [only] -> + {[], only} - word_len > max_width -> - handle_long_word(word, style, ls, cur, w, max_width) + [first | rest] -> + first_part = String.slice(first, 0, first_chunk_size) + remainder_of_first = String.slice(first, first_chunk_size..-1//1) - String.trim(word) == "" -> - {ls, cur, w} + all_parts = [remainder_of_first | rest] - true -> - {ls ++ [cur], [{word, style}], word_len} - end - end + lines = + all_parts + |> Enum.slice(0..-2//1) + |> Enum.map(fn part -> [{part, style}] end) - defp handle_long_word(word, style, ls, cur, w, max_width) do - {new_lines, remainder} = break_long_word(word, style, max_width - w, max_width) + last = List.last(all_parts) || "" - if cur == [] do - {ls ++ new_lines, [{remainder, style}], String.length(remainder)} - else - {ls ++ [cur] ++ new_lines, [{remainder, style}], String.length(remainder)} + if first_part == "" do + {lines, last} + else + {[[{first_part, style}]] ++ lines, last} + end + end end end +else + defmodule TermUI.Markdown do + @moduledoc """ + Markdown support for TermUI. - defp break_long_word(word, style, first_chunk_size, max_width) do - first_chunk_size = max(first_chunk_size, 1) - - chunks = - word - |> String.graphemes() - |> Enum.chunk_every(max_width) - |> Enum.map(&Enum.join/1) + Add `:mdex`, `:makeup`, and `:makeup_elixir` to the host application to + enable this optional feature. + """ - case chunks do - [] -> - {[], ""} + alias TermUI.Component.RenderNode - [only] -> - {[], only} + @doc "Returns false when TermUI uses its plain-text fallback." + @spec available?() :: boolean() + def available?, do: false - [first | rest] -> - first_part = String.slice(first, 0, first_chunk_size) - remainder_of_first = String.slice(first, first_chunk_size..-1//1) + @doc "Renders content as plain text when optional Markdown dependencies are absent." + @spec render(String.t() | nil, pos_integer()) :: [[{String.t(), nil}]] + def render(content, _max_width) do + content + |> to_string() + |> String.split("\n") + |> Enum.map(&[{&1, nil}]) + end - all_parts = [remainder_of_first | rest] + @doc "Renders content as plain text without interactive Markdown elements." + @spec render_with_elements(String.t() | nil, pos_integer(), keyword()) :: map() + def render_with_elements(content, max_width, _opts) do + lines = render(content, max_width) + %{lines: lines, elements: [], content_height: length(lines)} + end - lines = - all_parts - |> Enum.slice(0..-2//1) - |> Enum.map(fn part -> [{part, style}] end) + @doc "Converts a plain-text fallback line to a render node." + @spec render_line_to_node(list()) :: RenderNode.t() + def render_line_to_node([]), do: RenderNode.text("") - last = List.last(all_parts) || "" + def render_line_to_node([{text, style}]) do + RenderNode.text(text, style) + end - if first_part == "" do - {lines, last} - else - {[[{first_part, style}]] ++ lines, last} - end + def render_line_to_node(segments) do + nodes = Enum.map(segments, fn {text, style} -> RenderNode.text(text, style) end) + RenderNode.stack(:horizontal, nodes) end end end diff --git a/lib/term_ui/parser.ex b/lib/term_ui/parser.ex deleted file mode 100644 index 42ba6294..00000000 --- a/lib/term_ui/parser.ex +++ /dev/null @@ -1,492 +0,0 @@ -defmodule TermUI.Parser do - @moduledoc """ - Escape sequence parser for terminal input. - - Transforms raw terminal input bytes into structured events (key presses, - mouse actions, paste content, focus changes). - """ - - import Bitwise - - alias TermUI.Parser.Events.{FocusEvent, KeyEvent, MouseEvent, PasteEvent} - - # Dialyzer: Functions return specific map types - @dialyzer {:nowarn_function, new: 0} - - @type event :: KeyEvent.t() | MouseEvent.t() | PasteEvent.t() | FocusEvent.t() - - # Map control characters to key events - @control_chars %{ - 0x00 => {" ", [:ctrl]}, - 0x01 => {"a", [:ctrl]}, - 0x02 => {"b", [:ctrl]}, - 0x03 => {"c", [:ctrl]}, - 0x04 => {"d", [:ctrl]}, - 0x05 => {"e", [:ctrl]}, - 0x06 => {"f", [:ctrl]}, - 0x07 => {"g", [:ctrl]}, - 0x08 => {:backspace, []}, - 0x09 => {:tab, []}, - 0x0A => {:enter, []}, - 0x0B => {"k", [:ctrl]}, - 0x0C => {"l", [:ctrl]}, - 0x0D => {:enter, []}, - 0x0E => {"n", [:ctrl]}, - 0x0F => {"o", [:ctrl]}, - 0x10 => {"p", [:ctrl]}, - 0x11 => {"q", [:ctrl]}, - 0x12 => {"r", [:ctrl]}, - 0x13 => {"s", [:ctrl]}, - 0x14 => {"t", [:ctrl]}, - 0x15 => {"u", [:ctrl]}, - 0x16 => {"v", [:ctrl]}, - 0x17 => {"w", [:ctrl]}, - 0x18 => {"x", [:ctrl]}, - 0x19 => {"y", [:ctrl]}, - 0x1A => {"z", [:ctrl]} - } - - # Map CSI tilde codes to keys - @csi_tilde_keys %{ - 1 => :home, - 2 => :insert, - 3 => :delete, - 4 => :end, - 5 => :page_up, - 6 => :page_down, - 15 => :f5, - 17 => :f6, - 18 => :f7, - 19 => :f8, - 20 => :f9, - 21 => :f10, - 23 => :f11, - 24 => :f12, - 200 => :paste_start, - 201 => :paste_end - } - - # Map CSI letter codes to keys - @csi_letter_keys %{ - ?A => :up, - ?B => :down, - ?C => :right, - ?D => :left, - ?H => :home, - ?F => :end, - ?I => :focus_in, - ?O => :focus_out - } - - # Map SS3 codes to keys - @ss3_keys %{ - ?A => :up, - ?B => :down, - ?C => :right, - ?D => :left, - ?H => :home, - ?F => :end, - ?P => :f1, - ?Q => :f2, - ?R => :f3, - ?S => :f4 - } - - @type state :: %{ - mode: atom(), - buffer: binary(), - params: [integer()], - paste_buffer: binary() - } - - @doc """ - Creates a new parser state. - """ - @spec new() :: state() - def new do - %{ - mode: :ground, - buffer: <<>>, - params: [], - paste_buffer: <<>> - } - end - - @doc """ - Parses input bytes into events. - - Returns `{events, remaining_bytes, new_state}` where: - - `events` - List of parsed events - - `remaining_bytes` - Bytes that couldn't be parsed yet (incomplete sequences) - - `new_state` - Parser state for next call - - ## Examples - - iex> {events, "", _state} = TermUI.Parser.parse("a", TermUI.Parser.new()) - iex> [%TermUI.Parser.Events.KeyEvent{key: "a"}] = events - """ - @spec parse(binary(), state()) :: {[event()], binary(), state()} - def parse(input, state) do - parse_bytes(input, state, []) - end - - @doc """ - Resets parser state while preserving configuration. - """ - @spec reset(state()) :: state() - def reset(_state) do - new() - end - - @doc """ - Flushes any pending escape sequence as an ESC key event. - - Call this after a timeout when parser is in :escape state. - """ - @spec flush_escape(state()) :: {[event()], state()} - def flush_escape(%{mode: :escape} = state) do - event = %KeyEvent{key: :escape, modifiers: []} - {[event], %{state | mode: :ground, buffer: <<>>}} - end - - def flush_escape(state), do: {[], state} - - # Main parsing loop - defp parse_bytes(<<>>, state, events) do - {Enum.reverse(events), <<>>, state} - end - - defp parse_bytes(input, %{mode: :ground} = state, events) do - <> = input - - case byte do - 0x1B -> - parse_bytes(rest, %{state | mode: :escape, buffer: <<0x1B>>}, events) - - b when b in 0x00..0x1F -> - event = parse_control_char(b) - parse_bytes(rest, state, [event | events]) - - b when b in 0x20..0x7E -> - event = %KeyEvent{key: <>, modifiers: []} - parse_bytes(rest, state, [event | events]) - - 0x7F -> - event = %KeyEvent{key: :backspace, modifiers: []} - parse_bytes(rest, state, [event | events]) - - _ -> - parse_utf8(input, state, events) - end - end - - defp parse_bytes(input, %{mode: :escape} = state, events) do - case input do - <<>> -> - {Enum.reverse(events), state.buffer, state} - - <<"[", rest::binary>> -> - parse_bytes(rest, %{state | mode: :csi, buffer: <<>>, params: []}, events) - - <<"O", rest::binary>> -> - parse_bytes(rest, %{state | mode: :ss3, buffer: <<>>}, events) - - <> when b in ?a..?z or b in ?A..?Z -> - event = %KeyEvent{key: <>, modifiers: [:alt]} - parse_bytes(rest, %{state | mode: :ground, buffer: <<>>}, [event | events]) - - <<_b, _rest::binary>> -> - event = %KeyEvent{key: :escape, modifiers: []} - parse_bytes(input, %{state | mode: :ground, buffer: <<>>}, [event | events]) - end - end - - defp parse_bytes(input, %{mode: :csi} = state, events) do - case input do - <<>> -> - {Enum.reverse(events), <<0x1B, ?[, state.buffer::binary>>, state} - - <<"<", rest::binary>> -> - parse_bytes(rest, %{state | mode: :sgr_mouse, buffer: <<>>, params: []}, events) - - <<"M", rest::binary>> when state.buffer == <<>> and state.params == [] -> - parse_x10_mouse(rest, state, events) - - <> when b in ?0..?9 -> - parse_bytes(rest, %{state | buffer: <>}, events) - - <<";", rest::binary>> -> - param = parse_param(state.buffer) - parse_bytes(rest, %{state | buffer: <<>>, params: state.params ++ [param]}, events) - - <> -> - parse_csi_terminator(b, rest, state, events) - end - end - - defp parse_bytes(input, %{mode: :ss3} = state, events) do - case input do - <<>> -> - {Enum.reverse(events), <<0x1B, ?O>>, state} - - <> when b in ?A..?Z or b in ?a..?z -> - event = handle_ss3_key(b) - parse_bytes(rest, %{state | mode: :ground, buffer: <<>>}, [event | events]) - - <<_b, rest::binary>> -> - parse_bytes(rest, %{state | mode: :ground, buffer: <<>>}, events) - end - end - - defp parse_bytes(input, %{mode: :sgr_mouse} = state, events) do - case input do - <<>> -> - {Enum.reverse(events), <<0x1B, ?[, ?<, state.buffer::binary>>, state} - - <> when b in ?0..?9 -> - parse_bytes(rest, %{state | buffer: <>}, events) - - <<";", rest::binary>> -> - param = parse_param(state.buffer) - parse_bytes(rest, %{state | buffer: <<>>, params: state.params ++ [param]}, events) - - <> when term in [?M, ?m] -> - param = parse_param(state.buffer) - params = state.params ++ [param] - event = parse_sgr_mouse_event(params, term) - parse_bytes(rest, %{state | mode: :ground, buffer: <<>>, params: []}, [event | events]) - - <<_b, rest::binary>> -> - parse_bytes(rest, %{state | mode: :ground, buffer: <<>>, params: []}, events) - end - end - - defp parse_bytes(input, %{mode: :paste} = state, events) do - case :binary.match(input, <<0x1B, ?[, ?2, ?0, ?1, ?~>>) do - {pos, 6} -> - content = binary_part(input, 0, pos) - rest = binary_part(input, pos + 6, byte_size(input) - pos - 6) - full_content = <> - event = %PasteEvent{content: full_content} - - parse_bytes(rest, %{state | mode: :ground, paste_buffer: <<>>}, [event | events]) - - :nomatch -> - {Enum.reverse(events), <<>>, - %{state | paste_buffer: <>}} - end - end - - defp parse_bytes(input, state, events) do - <<_byte, rest::binary>> = input - parse_bytes(rest, %{state | mode: :ground}, events) - end - - # Handle CSI terminator characters - defp parse_csi_terminator(?~, rest, state, events) do - {event, new_state} = handle_csi_tilde(state) - - events = - if event == nil do - events - else - [event | events] - end - - parse_bytes(rest, new_state, events) - end - - defp parse_csi_terminator(b, rest, state, events) when b in ?A..?Z do - {event, new_state} = handle_csi_letter(b, state) - parse_bytes(rest, new_state, [event | events]) - end - - defp parse_csi_terminator(_b, rest, state, events) do - parse_bytes(rest, %{state | mode: :ground, buffer: <<>>, params: []}, events) - end - - # Parse UTF-8 characters - defp parse_utf8(input, state, events) do - case input do - <> -> - event = %KeyEvent{key: <>, modifiers: []} - parse_bytes(rest, state, [event | events]) - - _ -> - <<_byte, rest::binary>> = input - parse_bytes(rest, state, events) - end - end - - # Parse control characters (Ctrl+key) - defp parse_control_char(byte) do - case Map.get(@control_chars, byte) do - {key, modifiers} -> %KeyEvent{key: key, modifiers: modifiers} - nil -> %KeyEvent{key: :unknown, modifiers: []} - end - end - - # Handle CSI sequences ending with ~ - defp handle_csi_tilde(state) do - param = parse_param(state.buffer) - params = state.params ++ [param] - key = Map.get(@csi_tilde_keys, hd(params), :unknown) - modifiers = extract_modifiers(params) - - case key do - :paste_start -> - {nil, %{state | mode: :paste, buffer: <<>>, params: [], paste_buffer: <<>>}} - - :paste_end -> - {nil, %{state | mode: :ground, buffer: <<>>, params: []}} - - _ -> - event = %KeyEvent{key: key, modifiers: modifiers} - {event, %{state | mode: :ground, buffer: <<>>, params: []}} - end - end - - # Handle CSI sequences ending with a letter - defp handle_csi_letter(letter, state) do - param = if state.buffer == <<>>, do: 0, else: parse_param(state.buffer) - params = if param == 0 and state.params == [], do: [], else: state.params ++ [param] - key = Map.get(@csi_letter_keys, letter, :unknown) - modifiers = extract_modifiers(params) - - case key do - :focus_in -> - event = %FocusEvent{focused: true} - {event, %{state | mode: :ground, buffer: <<>>, params: []}} - - :focus_out -> - event = %FocusEvent{focused: false} - {event, %{state | mode: :ground, buffer: <<>>, params: []}} - - _ -> - event = %KeyEvent{key: key, modifiers: modifiers} - {event, %{state | mode: :ground, buffer: <<>>, params: []}} - end - end - - # Handle SS3 key sequences (F1-F4, arrow keys in application mode) - defp handle_ss3_key(byte) do - key = Map.get(@ss3_keys, byte, :unknown) - %KeyEvent{key: key, modifiers: []} - end - - # Parse X10 mouse event - defp parse_x10_mouse(input, state, events) do - case input do - <> -> - event = parse_x10_mouse_event(button - 32, col - 32, row - 32) - parse_bytes(rest, %{state | mode: :ground, buffer: <<>>, params: []}, [event | events]) - - _ -> - {Enum.reverse(events), <<0x1B, ?[, ?M, input::binary>>, state} - end - end - - defp parse_x10_mouse_event(button_byte, col, row) do - {button, action} = decode_x10_button(button_byte) - modifiers = decode_mouse_modifiers(button_byte) - - %MouseEvent{ - action: action, - button: button, - x: max(1, col), - y: max(1, row), - modifiers: modifiers - } - end - - defp decode_x10_button(byte) do - base = byte &&& 0x03 - motion = (byte &&& 0x20) != 0 - wheel = (byte &&& 0x40) != 0 - - cond do - wheel and base == 0 -> {:wheel_up, :press} - wheel and base == 1 -> {:wheel_down, :press} - motion -> {decode_button_base(base), :motion} - base == 3 -> {:none, :release} - true -> {decode_button_base(base), :press} - end - end - - defp decode_button_base(base) do - case base do - 0 -> :left - 1 -> :middle - 2 -> :right - _ -> :none - end - end - - # Parse SGR mouse event - defp parse_sgr_mouse_event(params, terminator) do - [button_byte, col, row] = - case params do - [b, c, r] -> [b, c, r] - _ -> [0, 1, 1] - end - - action = if terminator == ?M, do: :press, else: :release - {button, action} = decode_sgr_button(button_byte, action) - modifiers = decode_mouse_modifiers(button_byte) - - %MouseEvent{ - action: action, - button: button, - x: max(1, col), - y: max(1, row), - modifiers: modifiers - } - end - - defp decode_sgr_button(byte, default_action) do - base = byte &&& 0x03 - motion = (byte &&& 0x20) != 0 - wheel = (byte &&& 0x40) != 0 - - cond do - wheel and base == 0 -> {:wheel_up, :press} - wheel and base == 1 -> {:wheel_down, :press} - motion -> {decode_button_base(base), :motion} - true -> {decode_button_base(base), default_action} - end - end - - defp decode_mouse_modifiers(byte) do - modifiers = [] - modifiers = if (byte &&& 0x04) != 0, do: [:shift | modifiers], else: modifiers - modifiers = if (byte &&& 0x08) != 0, do: [:alt | modifiers], else: modifiers - modifiers = if (byte &&& 0x10) != 0, do: [:ctrl | modifiers], else: modifiers - modifiers - end - - # Extract keyboard modifiers from CSI parameters - defp extract_modifiers(params) do - modifier_param = - case params do - [_, m | _] -> m - _ -> 1 - end - - modifiers = [] - modifier_value = modifier_param - 1 - modifiers = if (modifier_value &&& 1) != 0, do: [:shift | modifiers], else: modifiers - modifiers = if (modifier_value &&& 2) != 0, do: [:alt | modifiers], else: modifiers - modifiers = if (modifier_value &&& 4) != 0, do: [:ctrl | modifiers], else: modifiers - modifiers = if (modifier_value &&& 8) != 0, do: [:meta | modifiers], else: modifiers - modifiers - end - - defp parse_param(<<>>), do: 0 - - defp parse_param(buffer) do - case Integer.parse(buffer) do - {n, ""} -> n - _ -> 0 - end - end -end diff --git a/lib/term_ui/parser/events.ex b/lib/term_ui/parser/events.ex deleted file mode 100644 index 23cf9d01..00000000 --- a/lib/term_ui/parser/events.ex +++ /dev/null @@ -1,87 +0,0 @@ -defmodule TermUI.Parser.Events do - @moduledoc """ - Event struct definitions for parsed terminal input. - """ - - defmodule KeyEvent do - @moduledoc """ - Represents a keyboard input event. - - ## Fields - - `key` - The key pressed (atom for special keys, string for characters) - - `modifiers` - List of modifiers held (`:ctrl`, `:alt`, `:shift`, `:meta`) - """ - @type t :: %__MODULE__{ - key: atom() | String.t(), - modifiers: [atom()] - } - - defstruct key: nil, modifiers: [] - end - - defmodule MouseEvent do - @moduledoc """ - Represents a mouse input event. - - ## Fields - - `action` - `:press`, `:release`, or `:motion` - - `button` - `:left`, `:middle`, `:right`, `:wheel_up`, `:wheel_down`, or `:none` - - `x` - Column (1-indexed) - - `y` - Row (1-indexed) - - `modifiers` - List of modifiers held - """ - @type t :: %__MODULE__{ - action: :press | :release | :motion, - button: atom(), - x: pos_integer(), - y: pos_integer(), - modifiers: [atom()] - } - - defstruct action: :press, button: :left, x: 1, y: 1, modifiers: [] - end - - defmodule PasteEvent do - @moduledoc """ - Represents bracketed paste content. - - ## Fields - - `content` - The pasted text - """ - @type t :: %__MODULE__{ - content: String.t() - } - - defstruct content: "" - end - - defmodule FocusEvent do - @moduledoc """ - Represents a focus change event. - - ## Fields - - `focused` - `true` if terminal gained focus, `false` if lost - """ - @type t :: %__MODULE__{ - focused: boolean() - } - - defstruct focused: true - end - - defmodule ResizeEvent do - @moduledoc """ - Represents a terminal resize event. - - ## Fields - - `rows` - New row count - - `cols` - New column count - """ - @type t :: %__MODULE__{ - rows: pos_integer(), - cols: pos_integer() - } - - defstruct rows: 24, cols: 80 - end -end diff --git a/lib/term_ui/persistent_terms.ex b/lib/term_ui/persistent_terms.ex index 47e653da..26a7425a 100644 --- a/lib/term_ui/persistent_terms.ex +++ b/lib/term_ui/persistent_terms.ex @@ -37,7 +37,6 @@ defmodule TermUI.PersistentTerms do """ alias TermUI.Backend.Selector - require Logger # Dialyzer: Pattern match coverage warnings @dialyzer {:nowarn_function, @@ -159,11 +158,7 @@ defmodule TermUI.PersistentTerms do # Private Functions defp detect_capabilities do - # Defer to Backend.Selector for capability detection - case Selector.detect_capabilities() do - caps when is_map(caps) -> caps - _ -> %{} - end + Selector.detect_capabilities() rescue _ -> %{} end diff --git a/lib/term_ui/renderer/buffer_manager.ex b/lib/term_ui/renderer/buffer_manager.ex index 0662d9cc..42bfe63b 100644 --- a/lib/term_ui/renderer/buffer_manager.ex +++ b/lib/term_ui/renderer/buffer_manager.ex @@ -82,7 +82,7 @@ defmodule TermUI.Renderer.BufferManager do alias TermUI.Renderer.Buffer @type t :: %__MODULE__{ - name: atom(), + name: atom() | pid(), current: Buffer.t(), previous: Buffer.t(), dirty: :atomics.atomics_ref() @@ -102,7 +102,7 @@ defmodule TermUI.Renderer.BufferManager do * `:rows` - Number of rows (required) * `:cols` - Number of columns (required) - * `:name` - GenServer name (default: `__MODULE__`) + * `:name` - GenServer name (default: `__MODULE__`); use `nil` for an unnamed manager ## Examples @@ -110,8 +110,10 @@ defmodule TermUI.Renderer.BufferManager do """ @spec start_link(keyword()) :: GenServer.on_start() def start_link(opts) do - name = Keyword.get(opts, :name, __MODULE__) - GenServer.start_link(__MODULE__, opts, name: name) + case Keyword.get(opts, :name, __MODULE__) do + nil -> GenServer.start_link(__MODULE__, opts) + name -> GenServer.start_link(__MODULE__, opts, name: name) + end end @doc """ @@ -320,7 +322,7 @@ defmodule TermUI.Renderer.BufferManager do def init(opts) do rows = Keyword.fetch!(opts, :rows) cols = Keyword.fetch!(opts, :cols) - name = Keyword.get(opts, :name, __MODULE__) + name = Keyword.get(opts, :name, __MODULE__) || self() {:ok, current} = Buffer.new(rows, cols) {:ok, previous} = Buffer.new(rows, cols) diff --git a/lib/term_ui/runtime.ex b/lib/term_ui/runtime.ex index 3e52f9d6..44f1199c 100644 --- a/lib/term_ui/runtime.ex +++ b/lib/term_ui/runtime.ex @@ -126,6 +126,16 @@ defmodule TermUI.Runtime do end end + defp start(opts) do + {name, opts} = Keyword.pop(opts, :name) + + if name do + GenServer.start(__MODULE__, opts, name: name) + else + GenServer.start(__MODULE__, opts) + end + end + @doc """ Returns a child specification for starting the runtime in a supervisor. @@ -275,14 +285,17 @@ defmodule TermUI.Runtime do """ @spec run([option()]) :: :ok | {:error, term()} def run(opts) do - case start_link(opts) do + case start(opts) do {:ok, runtime} -> # Monitor the runtime process and block until it exits ref = Process.monitor(runtime) receive do - {:DOWN, ^ref, :process, ^runtime, _reason} -> + {:DOWN, ^ref, :process, ^runtime, reason} when reason in [:normal, :shutdown] -> :ok + + {:DOWN, ^ref, :process, ^runtime, reason} -> + {:error, reason} end {:error, reason} -> @@ -324,8 +337,10 @@ defmodule TermUI.Runtime do {:ok, command_executor} = Executor.start_link() # Initialize root component state and any startup commands. + root_opts = Keyword.put_new(opts, :dimensions, dimensions || {80, 24}) + {root_state, init_commands} = - opts + root_opts |> root_module.init() |> Elm.normalize_init_result() @@ -501,7 +516,8 @@ defmodule TermUI.Runtime do defp init_tty_backend(capabilities) do backend = TermUI.Backend.TTY {:ok, backend_state} = backend.init(capabilities: capabilities, alternate_screen: true) - {:tty, backend, backend_state, capabilities, false, nil, nil} + {rows, cols} = backend_state.size + {:tty, backend, backend_state, capabilities, false, nil, {cols, rows}} end defp init_explicit_backend(TermUI.Backend.Raw, _opts) do @@ -517,11 +533,7 @@ defmodule TermUI.Runtime do {:ok, {rows, cols}} = module.size(backend_state) # Start BufferManager for the custom backend - buffer_pid = - case BufferManager.start_link(rows: rows, cols: cols) do - {:ok, pid} -> pid - {:error, {:already_started, pid}} -> pid - end + {:ok, buffer_pid} = BufferManager.start_link(rows: rows, cols: cols, name: nil) {:custom, module, backend_state, nil, false, buffer_pid, {cols, rows}} end @@ -736,17 +748,18 @@ defmodule TermUI.Runtime do end end + defp handle_root_info(_msg, %{shutting_down: true} = state), do: {:noreply, state} + defp handle_root_info(msg, state) do case state.root_module.handle_info(msg, state.root_state) do - {new_root_state, commands} -> - state = update_root_state(state, new_root_state) - tagged_commands = Enum.map(commands, fn cmd -> {:root, cmd} end) - state = execute_commands(tagged_commands, state) + :noreply -> {:noreply, state} - new_root_state -> + result -> + {new_root_state, commands} = Elm.normalize_update_result(result, state.root_state) state = update_root_state(state, new_root_state) - {:noreply, state} + tagged_commands = Enum.map(commands, fn command -> {:root, command} end) + {:noreply, execute_commands(tagged_commands, state)} end end @@ -772,7 +785,12 @@ defmodule TermUI.Runtime do end @impl true - def terminate(_reason, state) do + def terminate(reason, state) do + # Let the root release application workers before terminal teardown. + terminate_root(reason, state) + cleanup_command_executor(state) + cleanup_buffer_manager(state) + # Step 1: Restore logger FIRST (before any other cleanup that might log) terminate_logger_restore(state) @@ -789,7 +807,7 @@ defmodule TermUI.Runtime do cleanup_terminal_restore(state) # Step 5: Defensive cleanup (catches anything missed above) - terminate_defensive_cleanup() + terminate_defensive_cleanup(state) # Step 6: Persistent terms and echo cleanup_persistent_terms() @@ -798,6 +816,18 @@ defmodule TermUI.Runtime do :ok end + defp terminate_root(reason, state) do + if function_exported?(state.root_module, :terminate, 2) do + state.root_module.terminate(reason, state.root_state) + end + + :ok + rescue + _ -> :ok + catch + _, _ -> :ok + end + defp cleanup_input_reader(state) do if state.input_reader do InputReader.stop(state.input_reader) @@ -814,7 +844,7 @@ defmodule TermUI.Runtime do end # Then stop the handler (restores IO opts for TTY, etc.) - if state.input_handler and state.input_state do + if not is_nil(state.input_handler) and not is_nil(state.input_state) do state.input_handler.stop(state.input_state) end rescue @@ -830,7 +860,7 @@ defmodule TermUI.Runtime do end defp cleanup_backend(state) do - if state.backend and state.backend_state do + if not is_nil(state.backend) and not is_nil(state.backend_state) do state.backend.shutdown(state.backend_state) end rescue @@ -861,22 +891,51 @@ defmodule TermUI.Runtime do _ -> :ok end - defp ensure_echo_enabled(state) do - # Only restore echo on local terminals, not custom backends (SSH) - if state.backend_mode in [:raw, :tty, :skip] do - :io.setopts(echo: true) + defp cleanup_command_executor(state) do + if is_pid(state.command_executor) and Process.alive?(state.command_executor) do + GenServer.stop(state.command_executor, :normal) + end + + :ok + rescue + _ -> :ok + catch + :exit, _ -> :ok + end + + defp cleanup_buffer_manager(%{backend_mode: :custom, buffer_manager: buffer_manager}) + when is_pid(buffer_manager) do + if Process.alive?(buffer_manager) do + GenServer.stop(buffer_manager, :normal) end + + :ok + rescue + _ -> :ok + catch + :exit, _ -> :ok + end + + defp cleanup_buffer_manager(_state), do: :ok + + defp ensure_echo_enabled(%{backend_mode: backend_mode}) + when backend_mode in [:raw, :tty] do + # Only restore echo on local terminals, not custom backends (SSH) + :io.setopts(echo: true) rescue _ -> :ok end + defp ensure_echo_enabled(_state), do: :ok + defp terminate_logger_restore(state) do restore_logger(state.logger_handler_config) rescue _ -> :ok end - defp terminate_defensive_cleanup do + defp terminate_defensive_cleanup(%{backend_mode: backend_mode}) + when backend_mode in [:raw, :tty] do # Crash-safe logger restore from persistent_term restore_logger_from_persistent_term() @@ -894,6 +953,8 @@ defmodule TermUI.Runtime do _ -> :ok end + defp terminate_defensive_cleanup(_state), do: :ok + defp suppress_logger do case :logger.get_handler_config(:default) do {:ok, config} -> @@ -1218,42 +1279,45 @@ defmodule TermUI.Runtime do state end + defp do_render(%{backend: nil} = state), do: %{state | dirty: false} + defp do_render(state) do - # Render if backend is available (TTY backend works even without terminal_started) - if state.backend do - # Call view on root component with error handling - %{module: module, state: component_state} = Map.get(state.components, :root) + render_tree = root_view(state) + {cells, backend_state} = render_cells(render_tree, state) - render_tree = - try do - module.view(component_state) - rescue - error -> - require Logger - Logger.error("Component :root crashed in view: #{inspect(error)}") - # Return a simple error indicator - {:text, "[Render Error]"} - end + state.backend + |> draw_and_flush(backend_state, cells) + |> then(&%{state | dirty: false, backend_state: &1}) + end - # Different rendering paths for Raw vs TTY backends - {cells, new_backend_state} = - if state.buffer_manager do - # Raw backend: use double buffering with diffing - render_with_buffer_manager(render_tree, state) - else - # TTY backend: create temporary buffer, render all cells - render_to_tty_backend(render_tree, state) - end + defp root_view(state) do + %{module: module, state: component_state} = Map.fetch!(state.components, :root) + + module.view(component_state) + rescue + error -> + require Logger + Logger.error("Component :root crashed in view: #{inspect(error)}") + {:text, "[Render Error]"} + end - # Delegate rendering to backend - {:ok, new_backend_state} = state.backend.draw_cells(new_backend_state, cells) + defp render_cells(render_tree, %{buffer_manager: buffer_manager} = state) + when not is_nil(buffer_manager), + do: render_with_buffer_manager(render_tree, state) - # Flush any pending output - {:ok, ^new_backend_state} = state.backend.flush(new_backend_state) + defp render_cells(render_tree, state), do: render_to_tty_backend(render_tree, state) - %{state | dirty: false, backend_state: new_backend_state} - else - %{state | dirty: false} + defp draw_and_flush(backend, backend_state, cells) do + case backend.draw_cells(backend_state, cells) do + {:ok, drawn_backend_state} -> flush_backend(backend, drawn_backend_state) + {:error, reason} -> exit({:shutdown, {:backend_draw_failed, reason}}) + end + end + + defp flush_backend(backend, backend_state) do + case backend.flush(backend_state) do + {:ok, flushed_backend_state} -> flushed_backend_state + {:error, reason} -> exit({:shutdown, {:backend_flush_failed, reason}}) end end @@ -1318,10 +1382,7 @@ defmodule TermUI.Runtime do cells_in_row = buffer_row |> Enum.with_index(1) - |> Enum.filter(fn {%TermUI.Renderer.Cell{} = cell, _col} -> - # Include non-space characters OR spaces with non-default background - cell.char != " " or (cell.bg != nil and cell.bg != :default) - end) + |> Enum.filter(fn {%TermUI.Renderer.Cell{} = cell, _col} -> displayable_cell?(cell) end) |> Enum.flat_map(fn {cell, col} -> cell_to_backend_tuple(cell, row, col) end) cells_in_row ++ acc @@ -1350,36 +1411,7 @@ defmodule TermUI.Runtime do defp diff_row_cells([], [], _row, _col, acc), do: acc defp diff_row_cells([cur | cur_rest], [prev | prev_rest], row, col, acc) do - acc = - if Cell.equal?(cur, prev) do - # Identical — skip - acc - else - # Cells differ — check what to emit - cur_displayable = displayable_cell?(cur) - prev_displayable = displayable_cell?(prev) - - acc = - if cur_displayable do - # New content to draw - cell_to_backend_tuple(cur, row, col) ++ acc - else - acc - end - - if prev_displayable and not cur_displayable do - # Previous had content, current is empty — need to clear - [{{row, col}, {" ", :default, :default, []}} | acc] - else - if prev_displayable and cur_displayable and - prev.bg != nil and prev.bg != :default and cur.char == " " do - # Previous had colored bg, current is space — clear to remove bg - [{{row, col}, {" ", :default, :default, []}} | acc] - else - acc - end - end - end + acc = diff_cell(cur, prev, row, col, acc) diff_row_cells(cur_rest, prev_rest, row, col + 1, acc) end @@ -1407,8 +1439,40 @@ defmodule TermUI.Runtime do diff_row_cells([], prev_rest, row, col + 1, acc) end + defp diff_cell(cur, prev, row, col, acc) do + if Cell.equal?(cur, prev) do + acc + else + diff_changed_cell(cur, prev, row, col, acc) + end + end + + defp diff_changed_cell(cur, prev, row, col, acc) do + cur_displayable? = displayable_cell?(cur) + prev_displayable? = displayable_cell?(prev) + acc = maybe_draw_cell(cur, row, col, acc, cur_displayable?) + + if clear_previous_cell?(cur, prev, cur_displayable?, prev_displayable?) do + [{{row, col}, {" ", :default, :default, []}} | acc] + else + acc + end + end + + defp maybe_draw_cell(cell, row, col, acc, true), + do: cell_to_backend_tuple(cell, row, col) ++ acc + + defp maybe_draw_cell(_cell, _row, _col, acc, false), do: acc + + defp clear_previous_cell?(_cur, _prev, false, true), do: true + + defp clear_previous_cell?(cur, prev, true, true), + do: prev.bg not in [nil, :default] and cur.char == " " + + defp clear_previous_cell?(_cur, _prev, _cur_displayable?, _prev_displayable?), do: false + defp displayable_cell?(%Cell{} = cell) do - cell.char != " " or (cell.bg != nil and cell.bg != :default) + cell.char != " " or (cell.bg != nil and cell.bg != :default) or MapSet.size(cell.attrs) > 0 end # Converts a Cell struct to the backend format: {{row, col}, {char, fg, bg, attrs}} diff --git a/lib/term_ui/terminal/escape_parser.ex b/lib/term_ui/terminal/escape_parser.ex index 61fc8c5b..ad9840c9 100644 --- a/lib/term_ui/terminal/escape_parser.ex +++ b/lib/term_ui/terminal/escape_parser.ex @@ -191,6 +191,17 @@ defmodule TermUI.Terminal.EscapeParser do defp parse_csi_sequence(<<"H", rest::binary>>), do: {:ok, Event.key(:home), rest} defp parse_csi_sequence(<<"F", rest::binary>>), do: {:ok, Event.key(:end), rest} + # Terminal focus tracking + defp parse_csi_sequence(<<"I", rest::binary>>), do: {:ok, Event.focus(:gained), rest} + defp parse_csi_sequence(<<"O", rest::binary>>), do: {:ok, Event.focus(:lost), rest} + + # X10 mouse events: ESC [ M Cb Cx Cy. Each value has an offset of 32. + defp parse_csi_sequence(<<"M", cb, cx, cy, rest::binary>>) + when cb >= 32 and cx >= 33 and cy >= 33 do + event = decode_mouse_event(cb - 32, cx - 32, cy - 32, :press) + {:ok, event, rest} + end + # Tilde sequences: ESC [ number ~ defp parse_csi_sequence(<<"1~", rest::binary>>), do: {:ok, Event.key(:home), rest} defp parse_csi_sequence(<<"2~", rest::binary>>), do: {:ok, Event.key(:insert), rest} diff --git a/lib/term_ui/widgets/log_viewer.ex b/lib/term_ui/widgets/log_viewer.ex index bc89d827..4cf71a9c 100644 --- a/lib/term_ui/widgets/log_viewer.ex +++ b/lib/term_ui/widgets/log_viewer.ex @@ -375,14 +375,10 @@ defmodule TermUI.Widgets.LogViewer do line_idx = get_actual_line_index(state, state.cursor) state = - cond do - state.selection_start == nil -> - # Start selection - %{state | selection_start: line_idx, selection_end: line_idx} - - state.selection_start != nil -> - # Extend selection - %{state | selection_end: line_idx} + if is_nil(state.selection_start) do + %{state | selection_start: line_idx, selection_end: line_idx} + else + %{state | selection_end: line_idx} end {:ok, state} diff --git a/lib/term_ui/widgets/stream_widget/consumer.ex b/lib/term_ui/widgets/stream_widget/consumer.ex index c68c0045..abb4265f 100644 --- a/lib/term_ui/widgets/stream_widget/consumer.ex +++ b/lib/term_ui/widgets/stream_widget/consumer.ex @@ -1,108 +1,128 @@ -defmodule TermUI.Widgets.StreamWidget.Consumer do - @moduledoc """ - GenStage consumer for StreamWidget. +if Code.ensure_loaded?(GenStage) do + defmodule TermUI.Widgets.StreamWidget.Consumer do + @moduledoc """ + GenStage consumer for StreamWidget. - This module provides a GenStage consumer that forwards events to a StreamWidget. - It handles backpressure by managing demand based on the widget's buffer state. + This module provides a GenStage consumer that forwards events to a StreamWidget. + It handles backpressure by managing demand based on the widget's buffer state. - ## Usage + ## Usage - # Start the consumer linked to a widget process - {:ok, consumer} = StreamWidget.Consumer.start_link(widget_pid) + # Start the consumer linked to a widget process + {:ok, consumer} = StreamWidget.Consumer.start_link(widget_pid) - # Subscribe to a producer - GenStage.sync_subscribe(consumer, to: producer) + # Subscribe to a producer + GenStage.sync_subscribe(consumer, to: producer) - # Or subscribe with options - GenStage.sync_subscribe(consumer, to: producer, max_demand: 100, min_demand: 50) - """ + # Or subscribe with options + GenStage.sync_subscribe(consumer, to: producer, max_demand: 100, min_demand: 50) + """ - use GenStage + use GenStage - defstruct [:widget_pid, :widget_ref, :paused, :demand, :pending_demand] + defstruct [:widget_pid, :widget_ref, :paused, :demand, :pending_demand] - @default_demand 10 + @default_demand 10 - @doc """ - Starts a consumer linked to a widget process. + @doc """ + Starts a consumer linked to a widget process. - ## Options + ## Options - - `:demand` - How many items to request at a time (default: 10) - """ - @spec start_link(pid(), keyword()) :: GenServer.on_start() - def start_link(widget_pid, opts \\ []) do - GenStage.start_link(__MODULE__, {widget_pid, opts}) - end + - `:demand` - How many items to request at a time (default: 10) + """ + @spec start_link(pid(), keyword()) :: GenServer.on_start() + def start_link(widget_pid, opts \\ []) do + GenStage.start_link(__MODULE__, {widget_pid, opts}) + end - @doc """ - Subscribe to a producer. - """ - @spec subscribe(GenServer.server(), GenStage.stage(), keyword()) :: - {:ok, reference()} | {:error, term()} - def subscribe(consumer, producer, opts \\ []) do - GenStage.sync_subscribe(consumer, [{:to, producer} | opts]) - end + @doc """ + Subscribe to a producer. + """ + @spec subscribe(GenServer.server(), GenStage.stage(), keyword()) :: + {:ok, reference()} | {:error, term()} + def subscribe(consumer, producer, opts \\ []) do + GenStage.sync_subscribe(consumer, [{:to, producer} | opts]) + end - # ---------------------------------------------------------------------------- - # GenStage Callbacks - # ---------------------------------------------------------------------------- + # ---------------------------------------------------------------------------- + # GenStage Callbacks + # ---------------------------------------------------------------------------- - @impl true - def init({widget_pid, opts}) do - # Monitor the widget - ref = Process.monitor(widget_pid) + @impl true + def init({widget_pid, opts}) do + # Monitor the widget + ref = Process.monitor(widget_pid) - # Notify widget that consumer started - send(widget_pid, {:consumer_started, self()}) + # Notify widget that consumer started + send(widget_pid, {:consumer_started, self()}) - state = %__MODULE__{ - widget_pid: widget_pid, - widget_ref: ref, - paused: false, - demand: Keyword.get(opts, :demand, @default_demand), - pending_demand: 0 - } + state = %__MODULE__{ + widget_pid: widget_pid, + widget_ref: ref, + paused: false, + demand: Keyword.get(opts, :demand, @default_demand), + pending_demand: 0 + } - {:consumer, state} - end + {:consumer, state} + end - @impl true - def handle_events(events, _from, state) do - unless state.paused do - # Forward events to widget - send(state.widget_pid, {:stream_items, events}) + @impl true + def handle_events(events, _from, state) do + unless state.paused do + # Forward events to widget + send(state.widget_pid, {:stream_items, events}) + end + + {:noreply, [], state} end - {:noreply, [], state} - end + @impl true + def handle_info(:pause, state) do + {:noreply, [], %{state | paused: true}} + end - @impl true - def handle_info(:pause, state) do - {:noreply, [], %{state | paused: true}} - end + def handle_info(:resume, state) do + {:noreply, [], %{state | paused: false}} + end - def handle_info(:resume, state) do - {:noreply, [], %{state | paused: false}} - end + def handle_info({:set_demand, _demand}, state) do + # Widget is telling us how much demand is available + # This is handled by GenStage's built-in demand management + {:noreply, [], state} + end - def handle_info({:set_demand, _demand}, state) do - # Widget is telling us how much demand is available - # This is handled by GenStage's built-in demand management - {:noreply, [], state} - end + def handle_info({:DOWN, ref, :process, _pid, reason}, %{widget_ref: ref} = state) do + {:stop, {:widget_down, reason}, state} + end - def handle_info({:DOWN, ref, :process, _pid, reason}, %{widget_ref: ref} = state) do - {:stop, {:widget_down, reason}, state} - end + def handle_info(_msg, state) do + {:noreply, [], state} + end - def handle_info(_msg, state) do - {:noreply, [], state} + @impl true + def terminate(reason, state) do + send(state.widget_pid, {:consumer_stopped, reason}) + :ok + end end +else + defmodule TermUI.Widgets.StreamWidget.Consumer do + @moduledoc """ + Optional GenStage adapter for `TermUI.Widgets.StreamWidget`. + + Add `:gen_stage` to the host application to enable this adapter. The + StreamWidget direct push API does not require GenStage. + """ + + @error {:missing_dependency, :gen_stage} + + @spec start_link(pid(), keyword()) :: {:error, {:missing_dependency, :gen_stage}} + def start_link(_widget_pid, _opts \\ []), do: {:error, @error} - @impl true - def terminate(reason, state) do - send(state.widget_pid, {:consumer_stopped, reason}) - :ok + @spec subscribe(GenServer.server(), GenServer.server(), keyword()) :: + {:error, {:missing_dependency, :gen_stage}} + def subscribe(_consumer, _producer, _opts \\ []), do: {:error, @error} end end diff --git a/lib/term_ui/widgets/text_input.ex b/lib/term_ui/widgets/text_input.ex index 24ee95ed..d36c6af2 100644 --- a/lib/term_ui/widgets/text_input.ex +++ b/lib/term_ui/widgets/text_input.ex @@ -261,6 +261,13 @@ defmodule TermUI.Widgets.TextInput do {:ok, state} end + # Bracketed paste is one edit and one change notification. + def handle_event(%Event.Paste{content: content}, state) do + state = insert_paste(state, content) + notify_change(state) + {:ok, state} + end + # Focus events def handle_event(%Event.Focus{action: :gained}, state) do {:ok, %{state | focused: true}} @@ -409,6 +416,58 @@ defmodule TermUI.Widgets.TextInput do %{state | lines: lines, cursor_col: state.cursor_col + String.length(char)} end + defp insert_paste(%{multiline: false} = state, content) do + content + |> normalize_line_endings() + |> String.replace("\n", " ") + |> then(&insert_char(state, &1)) + end + + defp insert_paste(state, content) do + pasted_lines = + content + |> normalize_line_endings() + |> split_pasted_lines(state) + + line = current_line(state) + {before_cursor, after_cursor} = String.split_at(line, state.cursor_col) + [first_line | remaining_lines] = pasted_lines + inserted_lines = [before_cursor <> first_line | remaining_lines] + last_index = length(inserted_lines) - 1 + last_line = List.last(inserted_lines) + + inserted_lines = + List.replace_at(inserted_lines, last_index, last_line <> after_cursor) + + {lines_before, [_current_line | lines_after]} = Enum.split(state.lines, state.cursor_row) + lines = lines_before ++ inserted_lines ++ lines_after + + %{ + state + | lines: lines, + cursor_row: state.cursor_row + last_index, + cursor_col: String.length(last_line) + } + |> adjust_scroll() + end + + defp normalize_line_endings(content) do + content + |> String.replace("\r\n", "\n") + |> String.replace("\r", "\n") + end + + defp split_pasted_lines(content, %{max_lines: nil}), do: String.split(content, "\n") + + defp split_pasted_lines(content, state) do + available = max(1, state.max_lines - line_count(state) + 1) + + content + |> String.splitter("\n") + |> Enum.take(available) + |> Enum.map(&:binary.copy/1) + end + defp insert_newline(state) do # Check max_lines constraint if state.max_lines && line_count(state) >= state.max_lines do diff --git a/mix.exs b/mix.exs index 389225d9..d3f36dd5 100644 --- a/mix.exs +++ b/mix.exs @@ -2,7 +2,7 @@ defmodule TermUI.MixProject do use Mix.Project @version "1.0.0-rc" - @source_url "https://github.com/pcharbon70/term_ui" + @source_url "https://github.com/mikehostetler/term_ui" def project do [ @@ -71,14 +71,14 @@ defmodule TermUI.MixProject do {:stream_data, "~> 1.0", only: :test}, # Streaming - {:gen_stage, "~> 1.2"}, + {:gen_stage, "~> 1.2", optional: true}, # Markdown processing - {:mdex, "~> 0.10"}, + {:mdex, "~> 0.10", optional: true}, # Syntax highlighting for code blocks - {:makeup, "~> 1.1"}, - {:makeup_elixir, "~> 1.0"}, + {:makeup, "~> 1.1", optional: true}, + {:makeup_elixir, "~> 1.0", optional: true}, # LLM usage rules {:usage_rules, "~> 0.1", only: :dev, runtime: false} diff --git a/test/integration/cross_platform_test.exs b/test/integration/cross_platform_test.exs index 542d89e7..2fd9ba0b 100644 --- a/test/integration/cross_platform_test.exs +++ b/test/integration/cross_platform_test.exs @@ -8,8 +8,8 @@ defmodule TermUI.Integration.CrossPlatformTest do use ExUnit.Case, async: false + alias TermUI.Event.Key alias TermUI.IntegrationHelpers - alias TermUI.Parser.Events.KeyEvent alias TermUI.Platform alias TermUI.Platform.Unix alias TermUI.Platform.Windows @@ -94,36 +94,36 @@ defmodule TermUI.Integration.CrossPlatformTest do {events, ""} = parse("abc") assert [ - %KeyEvent{key: "a", modifiers: []}, - %KeyEvent{key: "b", modifiers: []}, - %KeyEvent{key: "c", modifiers: []} + %Key{key: "a", modifiers: []}, + %Key{key: "b", modifiers: []}, + %Key{key: "c", modifiers: []} ] = events end test "control characters are platform-agnostic" do # Ctrl+C is ASCII 3 everywhere {events, ""} = parse(<<3>>) - assert [%KeyEvent{key: "c", modifiers: [:ctrl]}] = events + assert [%Key{key: "c", modifiers: [:ctrl]}] = events end test "escape sequences follow VT100 standard" do # Arrow keys use same sequences on all platforms {events, ""} = parse("\e[A") - assert [%KeyEvent{key: :up, modifiers: []}] = events + assert [%Key{key: :up, modifiers: []}] = events {events, ""} = parse("\e[B") - assert [%KeyEvent{key: :down, modifiers: []}] = events + assert [%Key{key: :down, modifiers: []}] = events end test "enter key is consistent" do # Enter is carriage return (13) on all platforms {events, ""} = parse(<<13>>) - assert [%KeyEvent{key: :enter, modifiers: []}] = events + assert [%Key{key: :enter, modifiers: []}] = events end test "tab key is consistent" do {events, ""} = parse(<<9>>) - assert [%KeyEvent{key: :tab, modifiers: []}] = events + assert [%Key{key: :tab, modifiers: []}] = events end test "backspace handling" do diff --git a/test/integration/round_trip_test.exs b/test/integration/round_trip_test.exs index 5c66702e..ee272214 100644 --- a/test/integration/round_trip_test.exs +++ b/test/integration/round_trip_test.exs @@ -22,8 +22,8 @@ defmodule TermUI.Integration.RoundTripTest do use ExUnit.Case, async: false alias TermUI.ANSI + alias TermUI.Event.{Focus, Key, Mouse, Paste} alias TermUI.IntegrationHelpers - alias TermUI.Parser.Events.{FocusEvent, KeyEvent, MouseEvent, PasteEvent} import IntegrationHelpers, only: [parse: 1] @@ -74,100 +74,100 @@ defmodule TermUI.Integration.RoundTripTest do describe "1.6.2.2 key event round-trip" do test "simple characters parse correctly" do {events, ""} = parse("a") - assert [%KeyEvent{key: "a", modifiers: []}] = events + assert [%Key{key: "a", modifiers: []}] = events {events, ""} = parse("Z") - assert [%KeyEvent{key: "Z", modifiers: []}] = events + assert [%Key{key: "Z", modifiers: []}] = events {events, ""} = parse("5") - assert [%KeyEvent{key: "5", modifiers: []}] = events + assert [%Key{key: "5", modifiers: []}] = events end test "control characters parse correctly" do {events, ""} = parse(<<1>>) - assert [%KeyEvent{key: "a", modifiers: [:ctrl]}] = events + assert [%Key{key: "a", modifiers: [:ctrl]}] = events {events, ""} = parse(<<3>>) - assert [%KeyEvent{key: "c", modifiers: [:ctrl]}] = events + assert [%Key{key: "c", modifiers: [:ctrl]}] = events {events, ""} = parse(<<26>>) - assert [%KeyEvent{key: "z", modifiers: [:ctrl]}] = events + assert [%Key{key: "z", modifiers: [:ctrl]}] = events end test "special keys parse correctly" do # Enter {events, ""} = parse(<<13>>) - assert [%KeyEvent{key: :enter, modifiers: []}] = events + assert [%Key{key: :enter, modifiers: []}] = events # Tab {events, ""} = parse(<<9>>) - assert [%KeyEvent{key: :tab, modifiers: []}] = events + assert [%Key{key: :tab, modifiers: []}] = events # Backspace {events, ""} = parse(<<127>>) - assert [%KeyEvent{key: :backspace, modifiers: []}] = events + assert [%Key{key: :backspace, modifiers: []}] = events end test "arrow keys parse correctly" do {events, ""} = parse("\e[A") - assert [%KeyEvent{key: :up, modifiers: []}] = events + assert [%Key{key: :up, modifiers: []}] = events {events, ""} = parse("\e[B") - assert [%KeyEvent{key: :down, modifiers: []}] = events + assert [%Key{key: :down, modifiers: []}] = events {events, ""} = parse("\e[C") - assert [%KeyEvent{key: :right, modifiers: []}] = events + assert [%Key{key: :right, modifiers: []}] = events {events, ""} = parse("\e[D") - assert [%KeyEvent{key: :left, modifiers: []}] = events + assert [%Key{key: :left, modifiers: []}] = events end test "function keys parse correctly" do {events, ""} = parse("\eOP") - assert [%KeyEvent{key: :f1, modifiers: []}] = events + assert [%Key{key: :f1, modifiers: []}] = events {events, ""} = parse("\eOQ") - assert [%KeyEvent{key: :f2, modifiers: []}] = events + assert [%Key{key: :f2, modifiers: []}] = events {events, ""} = parse("\eOR") - assert [%KeyEvent{key: :f3, modifiers: []}] = events + assert [%Key{key: :f3, modifiers: []}] = events {events, ""} = parse("\eOS") - assert [%KeyEvent{key: :f4, modifiers: []}] = events + assert [%Key{key: :f4, modifiers: []}] = events {events, ""} = parse("\e[15~") - assert [%KeyEvent{key: :f5, modifiers: []}] = events + assert [%Key{key: :f5, modifiers: []}] = events end test "home/end/page keys parse correctly" do {events, ""} = parse("\e[H") - assert [%KeyEvent{key: :home, modifiers: []}] = events + assert [%Key{key: :home, modifiers: []}] = events {events, ""} = parse("\e[F") - assert [%KeyEvent{key: :end, modifiers: []}] = events + assert [%Key{key: :end, modifiers: []}] = events {events, ""} = parse("\e[5~") - assert [%KeyEvent{key: :page_up, modifiers: []}] = events + assert [%Key{key: :page_up, modifiers: []}] = events {events, ""} = parse("\e[6~") - assert [%KeyEvent{key: :page_down, modifiers: []}] = events + assert [%Key{key: :page_down, modifiers: []}] = events end test "multiple events parse in sequence" do {events, ""} = parse("abc") assert [ - %KeyEvent{key: "a", modifiers: []}, - %KeyEvent{key: "b", modifiers: []}, - %KeyEvent{key: "c", modifiers: []} + %Key{key: "a", modifiers: []}, + %Key{key: "b", modifiers: []}, + %Key{key: "c", modifiers: []} ] = events {events, ""} = parse("a\e[Ab") assert [ - %KeyEvent{key: "a", modifiers: []}, - %KeyEvent{key: :up, modifiers: []}, - %KeyEvent{key: "b", modifiers: []} + %Key{key: "a", modifiers: []}, + %Key{key: :up, modifiers: []}, + %Key{key: "b", modifiers: []} ] = events end @@ -191,18 +191,18 @@ defmodule TermUI.Integration.RoundTripTest do # X10 needs 3 characters after M {events, ""} = parse("\e[M !!") assert length(events) == 1 - [%MouseEvent{} = event] = events - assert event.button in [:left, :none] + [%Mouse{} = event] = events + assert event.button == :left end test "SGR mouse events parse correctly" do {events, ""} = parse("\e[<0;5;10M") assert length(events) == 1 - [%MouseEvent{} = event] = events + [%Mouse{} = event] = events assert event.button == :left - assert event.x == 5 - assert event.y == 10 + assert event.x == 4 + assert event.y == 9 assert event.action == :press end @@ -210,45 +210,47 @@ defmodule TermUI.Integration.RoundTripTest do {events, ""} = parse("\e[<0;5;10m") assert length(events) == 1 - [%MouseEvent{} = event] = events + [%Mouse{} = event] = events assert event.action == :release end test "mouse button types parse correctly" do {events, ""} = parse("\e[<1;1;1M") - [%MouseEvent{} = event] = events + [%Mouse{} = event] = events assert event.button == :middle {events, ""} = parse("\e[<2;1;1M") - [%MouseEvent{} = event] = events + [%Mouse{} = event] = events assert event.button == :right end test "mouse modifier keys parse correctly" do # Shift modifier (4) {events, ""} = parse("\e[<4;1;1M") - [%MouseEvent{} = event] = events + [%Mouse{} = event] = events assert :shift in event.modifiers # Ctrl modifier (16) {events, ""} = parse("\e[<16;1;1M") - [%MouseEvent{} = event] = events + [%Mouse{} = event] = events assert :ctrl in event.modifiers # Alt modifier (8) {events, ""} = parse("\e[<8;1;1M") - [%MouseEvent{} = event] = events + [%Mouse{} = event] = events assert :alt in event.modifiers end test "scroll events parse correctly" do {events, ""} = parse("\e[<64;1;1M") - [%MouseEvent{} = event] = events - assert event.button == :wheel_up + [%Mouse{} = event] = events + assert event.action == :scroll_up + assert event.button == nil {events, ""} = parse("\e[<65;1;1M") - [%MouseEvent{} = event] = events - assert event.button == :wheel_down + [%Mouse{} = event] = events + assert event.action == :scroll_down + assert event.button == nil end end @@ -330,16 +332,16 @@ defmodule TermUI.Integration.RoundTripTest do {events, ""} = parse("\e[200~pasted text\e[201~") assert length(events) == 1 - [%PasteEvent{content: content}] = events + [%Paste{content: content}] = events assert content == "pasted text" end test "focus events parse correctly" do {events, ""} = parse("\e[I") - assert [%FocusEvent{focused: true}] = events + assert [%Focus{action: :gained}] = events {events, ""} = parse("\e[O") - assert [%FocusEvent{focused: false}] = events + assert [%Focus{action: :lost}] = events end end diff --git a/test/support/integration_helpers.ex b/test/support/integration_helpers.ex index d369aac6..31f8d320 100644 --- a/test/support/integration_helpers.ex +++ b/test/support/integration_helpers.ex @@ -6,8 +6,8 @@ defmodule TermUI.IntegrationHelpers do capturing output, and simulating input. """ - alias TermUI.Parser alias TermUI.Terminal + alias TermUI.Terminal.EscapeParser @doc """ Starts the Terminal GenServer for integration tests. @@ -325,7 +325,6 @@ defmodule TermUI.IntegrationHelpers do """ @spec parse(binary()) :: {list(), binary()} def parse(input) do - {events, remaining, _state} = Parser.parse(input, Parser.new()) - {events, remaining} + EscapeParser.parse(input) end end diff --git a/test/term_ui/input/line_reader_test.exs b/test/term_ui/input/line_reader_test.exs index 5db6582c..910471de 100644 --- a/test/term_ui/input/line_reader_test.exs +++ b/test/term_ui/input/line_reader_test.exs @@ -20,6 +20,7 @@ defmodule TermUI.Input.LineReaderTest do describe "read_line/1" do test "function exists with arity 0 and 1" do + assert Code.ensure_loaded?(LineReader) assert function_exported?(LineReader, :read_line, 0) assert function_exported?(LineReader, :read_line, 1) end diff --git a/test/term_ui/input/tty_test.exs b/test/term_ui/input/tty_test.exs index 5b4c61e5..31971f3c 100644 --- a/test/term_ui/input/tty_test.exs +++ b/test/term_ui/input/tty_test.exs @@ -33,13 +33,14 @@ defmodule TermUI.Input.TTYTest do test "state struct has buffer, event_queue, and IO opts fields" do state = TTY.new() # Verify the struct has the expected fields (including IO opts fields) - assert Map.keys(state) -- [:__struct__] == [ - :buffer, - :event_queue, - :io_opts_restored, - :io_opts_set, - :original_opts - ] + assert state |> Map.keys() |> List.delete(:__struct__) |> MapSet.new() == + MapSet.new([ + :buffer, + :event_queue, + :io_opts_restored, + :io_opts_set, + :original_opts + ]) end end diff --git a/test/term_ui/parser_test.exs b/test/term_ui/parser_test.exs deleted file mode 100644 index 1a687499..00000000 --- a/test/term_ui/parser_test.exs +++ /dev/null @@ -1,615 +0,0 @@ -defmodule TermUI.ParserTest do - use ExUnit.Case, async: true - - alias TermUI.Parser - alias TermUI.Parser.Events.{FocusEvent, KeyEvent, MouseEvent, PasteEvent} - - describe "new/0" do - test "returns initial parser state" do - state = Parser.new() - - assert state.mode == :ground - assert state.buffer == <<>> - assert state.params == [] - assert state.paste_buffer == <<>> - end - end - - describe "reset/1" do - test "resets parser state to initial" do - state = %{mode: :csi, buffer: "123", params: [1, 2], paste_buffer: "text"} - reset_state = Parser.reset(state) - - assert reset_state.mode == :ground - assert reset_state.buffer == <<>> - assert reset_state.params == [] - assert reset_state.paste_buffer == <<>> - end - end - - describe "parse/2 - single characters" do - test "parses printable ASCII characters" do - state = Parser.new() - - {[event], "", _state} = Parser.parse("a", state) - assert %KeyEvent{key: "a", modifiers: []} = event - - {[event], "", _state} = Parser.parse("Z", state) - assert %KeyEvent{key: "Z", modifiers: []} = event - - {[event], "", _state} = Parser.parse("5", state) - assert %KeyEvent{key: "5", modifiers: []} = event - - {[event], "", _state} = Parser.parse(" ", state) - assert %KeyEvent{key: " ", modifiers: []} = event - end - - test "parses multiple characters" do - state = Parser.new() - {events, "", _state} = Parser.parse("abc", state) - - assert length(events) == 3 - assert [%KeyEvent{key: "a"}, %KeyEvent{key: "b"}, %KeyEvent{key: "c"}] = events - end - - test "parses backspace (DEL)" do - state = Parser.new() - {[event], "", _state} = Parser.parse(<<0x7F>>, state) - - assert %KeyEvent{key: :backspace, modifiers: []} = event - end - - test "parses UTF-8 characters" do - state = Parser.new() - - {[event], "", _state} = Parser.parse("é", state) - assert %KeyEvent{key: "é", modifiers: []} = event - - {[event], "", _state} = Parser.parse("日", state) - assert %KeyEvent{key: "日", modifiers: []} = event - end - end - - describe "parse/2 - control characters" do - test "parses Ctrl+letter combinations" do - state = Parser.new() - - # Ctrl+A (0x01) - {[event], "", _state} = Parser.parse(<<0x01>>, state) - assert %KeyEvent{key: "a", modifiers: [:ctrl]} = event - - # Ctrl+C (0x03) - {[event], "", _state} = Parser.parse(<<0x03>>, state) - assert %KeyEvent{key: "c", modifiers: [:ctrl]} = event - - # Ctrl+Z (0x1A) - {[event], "", _state} = Parser.parse(<<0x1A>>, state) - assert %KeyEvent{key: "z", modifiers: [:ctrl]} = event - end - - test "parses Enter key" do - state = Parser.new() - - # Carriage return (0x0D) - {[event], "", _state} = Parser.parse(<<0x0D>>, state) - assert %KeyEvent{key: :enter, modifiers: []} = event - - # Line feed (0x0A) - {[event], "", _state} = Parser.parse(<<0x0A>>, state) - assert %KeyEvent{key: :enter, modifiers: []} = event - end - - test "parses Tab key" do - state = Parser.new() - {[event], "", _state} = Parser.parse(<<0x09>>, state) - - assert %KeyEvent{key: :tab, modifiers: []} = event - end - - test "parses Backspace (0x08)" do - state = Parser.new() - {[event], "", _state} = Parser.parse(<<0x08>>, state) - - assert %KeyEvent{key: :backspace, modifiers: []} = event - end - - test "parses Ctrl+Space" do - state = Parser.new() - {[event], "", _state} = Parser.parse(<<0x00>>, state) - - assert %KeyEvent{key: " ", modifiers: [:ctrl]} = event - end - end - - describe "parse/2 - escape sequences" do - test "parses standalone ESC with flush" do - state = Parser.new() - {[], _remaining, new_state} = Parser.parse(<<0x1B>>, state) - - assert new_state.mode == :escape - - {[event], reset_state} = Parser.flush_escape(new_state) - assert %KeyEvent{key: :escape, modifiers: []} = event - assert reset_state.mode == :ground - end - - test "parses Alt+letter combinations" do - state = Parser.new() - - # Alt+a (ESC a) - {[event], "", _state} = Parser.parse(<<0x1B, ?a>>, state) - assert %KeyEvent{key: "a", modifiers: [:alt]} = event - - # Alt+Z (ESC Z) - {[event], "", _state} = Parser.parse(<<0x1B, ?Z>>, state) - assert %KeyEvent{key: "Z", modifiers: [:alt]} = event - end - end - - describe "parse/2 - CSI arrow keys" do - test "parses basic arrow keys" do - state = Parser.new() - - # Up arrow: ESC[A - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?A>>, state) - assert %KeyEvent{key: :up, modifiers: []} = event - - # Down arrow: ESC[B - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?B>>, state) - assert %KeyEvent{key: :down, modifiers: []} = event - - # Right arrow: ESC[C - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?C>>, state) - assert %KeyEvent{key: :right, modifiers: []} = event - - # Left arrow: ESC[D - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?D>>, state) - assert %KeyEvent{key: :left, modifiers: []} = event - end - - test "parses arrow keys with modifiers" do - state = Parser.new() - - # Shift+Up: ESC[1;2A - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?1, ?;, ?2, ?A>>, state) - assert %KeyEvent{key: :up, modifiers: [:shift]} = event - - # Alt+Down: ESC[1;3B - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?1, ?;, ?3, ?B>>, state) - assert %KeyEvent{key: :down, modifiers: [:alt]} = event - - # Ctrl+Right: ESC[1;5C - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?1, ?;, ?5, ?C>>, state) - assert %KeyEvent{key: :right, modifiers: [:ctrl]} = event - - # Ctrl+Alt+Left: ESC[1;7D - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?1, ?;, ?7, ?D>>, state) - assert %KeyEvent{key: :left} = event - assert Enum.sort(event.modifiers) == [:alt, :ctrl] - end - end - - describe "parse/2 - CSI special keys" do - test "parses Home and End" do - state = Parser.new() - - # Home: ESC[H - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?H>>, state) - assert %KeyEvent{key: :home, modifiers: []} = event - - # End: ESC[F - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?F>>, state) - assert %KeyEvent{key: :end, modifiers: []} = event - end - - test "parses special keys with tilde terminator" do - state = Parser.new() - - # Home: ESC[1~ - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?1, ?~>>, state) - assert %KeyEvent{key: :home, modifiers: []} = event - - # Insert: ESC[2~ - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?2, ?~>>, state) - assert %KeyEvent{key: :insert, modifiers: []} = event - - # Delete: ESC[3~ - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?3, ?~>>, state) - assert %KeyEvent{key: :delete, modifiers: []} = event - - # End: ESC[4~ - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?4, ?~>>, state) - assert %KeyEvent{key: :end, modifiers: []} = event - - # Page Up: ESC[5~ - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?5, ?~>>, state) - assert %KeyEvent{key: :page_up, modifiers: []} = event - - # Page Down: ESC[6~ - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?6, ?~>>, state) - assert %KeyEvent{key: :page_down, modifiers: []} = event - end - - test "parses special keys with modifiers" do - state = Parser.new() - - # Ctrl+Delete: ESC[3;5~ - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?3, ?;, ?5, ?~>>, state) - assert %KeyEvent{key: :delete, modifiers: [:ctrl]} = event - - # Shift+Page Up: ESC[5;2~ - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?5, ?;, ?2, ?~>>, state) - assert %KeyEvent{key: :page_up, modifiers: [:shift]} = event - end - end - - describe "parse/2 - function keys" do - test "parses F1-F4 (SS3 format)" do - state = Parser.new() - - # F1: ESC O P - {[event], "", _state} = Parser.parse(<<0x1B, ?O, ?P>>, state) - assert %KeyEvent{key: :f1, modifiers: []} = event - - # F2: ESC O Q - {[event], "", _state} = Parser.parse(<<0x1B, ?O, ?Q>>, state) - assert %KeyEvent{key: :f2, modifiers: []} = event - - # F3: ESC O R - {[event], "", _state} = Parser.parse(<<0x1B, ?O, ?R>>, state) - assert %KeyEvent{key: :f3, modifiers: []} = event - - # F4: ESC O S - {[event], "", _state} = Parser.parse(<<0x1B, ?O, ?S>>, state) - assert %KeyEvent{key: :f4, modifiers: []} = event - end - - test "parses F5-F12 (CSI format)" do - state = Parser.new() - - # F5: ESC[15~ - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?1, ?5, ?~>>, state) - assert %KeyEvent{key: :f5, modifiers: []} = event - - # F6: ESC[17~ - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?1, ?7, ?~>>, state) - assert %KeyEvent{key: :f6, modifiers: []} = event - - # F7: ESC[18~ - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?1, ?8, ?~>>, state) - assert %KeyEvent{key: :f7, modifiers: []} = event - - # F8: ESC[19~ - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?1, ?9, ?~>>, state) - assert %KeyEvent{key: :f8, modifiers: []} = event - - # F9: ESC[20~ - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?2, ?0, ?~>>, state) - assert %KeyEvent{key: :f9, modifiers: []} = event - - # F10: ESC[21~ - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?2, ?1, ?~>>, state) - assert %KeyEvent{key: :f10, modifiers: []} = event - - # F11: ESC[23~ - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?2, ?3, ?~>>, state) - assert %KeyEvent{key: :f11, modifiers: []} = event - - # F12: ESC[24~ - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?2, ?4, ?~>>, state) - assert %KeyEvent{key: :f12, modifiers: []} = event - end - - test "parses SS3 arrow keys (application mode)" do - state = Parser.new() - - # Up: ESC O A - {[event], "", _state} = Parser.parse(<<0x1B, ?O, ?A>>, state) - assert %KeyEvent{key: :up, modifiers: []} = event - - # Down: ESC O B - {[event], "", _state} = Parser.parse(<<0x1B, ?O, ?B>>, state) - assert %KeyEvent{key: :down, modifiers: []} = event - - # Right: ESC O C - {[event], "", _state} = Parser.parse(<<0x1B, ?O, ?C>>, state) - assert %KeyEvent{key: :right, modifiers: []} = event - - # Left: ESC O D - {[event], "", _state} = Parser.parse(<<0x1B, ?O, ?D>>, state) - assert %KeyEvent{key: :left, modifiers: []} = event - end - end - - describe "parse/2 - mouse events (X10)" do - test "parses left button press" do - state = Parser.new() - # ESC[M + button(0+32) + col(10+32) + row(5+32) - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?M, 32, 42, 37>>, state) - - assert %MouseEvent{action: :press, button: :left, x: 10, y: 5, modifiers: []} = event - end - - test "parses middle button press" do - state = Parser.new() - # ESC[M + button(1+32) + col(20+32) + row(10+32) - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?M, 33, 52, 42>>, state) - - assert %MouseEvent{action: :press, button: :middle, x: 20, y: 10, modifiers: []} = event - end - - test "parses right button press" do - state = Parser.new() - # ESC[M + button(2+32) + col(1+32) + row(1+32) - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?M, 34, 33, 33>>, state) - - assert %MouseEvent{action: :press, button: :right, x: 1, y: 1, modifiers: []} = event - end - - test "parses button release" do - state = Parser.new() - # ESC[M + button(3+32) + col(5+32) + row(5+32) - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?M, 35, 37, 37>>, state) - - assert %MouseEvent{action: :release, button: :none, x: 5, y: 5, modifiers: []} = event - end - - test "parses wheel up" do - state = Parser.new() - # ESC[M + button(64+32) + col(10+32) + row(10+32) - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?M, 96, 42, 42>>, state) - - assert %MouseEvent{action: :press, button: :wheel_up, x: 10, y: 10, modifiers: []} = event - end - - test "parses wheel down" do - state = Parser.new() - # ESC[M + button(65+32) + col(10+32) + row(10+32) - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?M, 97, 42, 42>>, state) - - assert %MouseEvent{action: :press, button: :wheel_down, x: 10, y: 10, modifiers: []} = event - end - - test "parses motion event" do - state = Parser.new() - # ESC[M + button(32+32) + col(15+32) + row(20+32) - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?M, 64, 47, 52>>, state) - - assert %MouseEvent{action: :motion, button: :left, x: 15, y: 20, modifiers: []} = event - end - - test "parses mouse with modifiers" do - state = Parser.new() - # Shift+click: button(0+4+32) + col(10+32) + row(10+32) - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?M, 36, 42, 42>>, state) - - assert %MouseEvent{action: :press, button: :left, modifiers: [:shift]} = event - - # Alt+click: button(0+8+32) + col(10+32) + row(10+32) - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?M, 40, 42, 42>>, state) - - assert %MouseEvent{action: :press, button: :left, modifiers: [:alt]} = event - - # Ctrl+click: button(0+16+32) + col(10+32) + row(10+32) - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?M, 48, 42, 42>>, state) - - assert %MouseEvent{action: :press, button: :left, modifiers: [:ctrl]} = event - end - end - - describe "parse/2 - mouse events (SGR)" do - test "parses left button press" do - state = Parser.new() - # ESC[<0;10;5M - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?<, ?0, ?;, ?1, ?0, ?;, ?5, ?M>>, state) - - assert %MouseEvent{action: :press, button: :left, x: 10, y: 5, modifiers: []} = event - end - - test "parses left button release" do - state = Parser.new() - # ESC[<0;10;5m - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?<, ?0, ?;, ?1, ?0, ?;, ?5, ?m>>, state) - - assert %MouseEvent{action: :release, button: :left, x: 10, y: 5, modifiers: []} = event - end - - test "parses middle button" do - state = Parser.new() - # ESC[<1;20;10M - {[event], "", _state} = - Parser.parse(<<0x1B, ?[, ?<, ?1, ?;, ?2, ?0, ?;, ?1, ?0, ?M>>, state) - - assert %MouseEvent{action: :press, button: :middle, x: 20, y: 10, modifiers: []} = event - end - - test "parses right button" do - state = Parser.new() - # ESC[<2;1;1M - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?<, ?2, ?;, ?1, ?;, ?1, ?M>>, state) - - assert %MouseEvent{action: :press, button: :right, x: 1, y: 1, modifiers: []} = event - end - - test "parses wheel events" do - state = Parser.new() - - # Wheel up: ESC[<64;10;10M - {[event], "", _state} = - Parser.parse(<<0x1B, ?[, ?<, ?6, ?4, ?;, ?1, ?0, ?;, ?1, ?0, ?M>>, state) - - assert %MouseEvent{action: :press, button: :wheel_up, x: 10, y: 10, modifiers: []} = event - - # Wheel down: ESC[<65;10;10M - {[event], "", _state} = - Parser.parse(<<0x1B, ?[, ?<, ?6, ?5, ?;, ?1, ?0, ?;, ?1, ?0, ?M>>, state) - - assert %MouseEvent{action: :press, button: :wheel_down, x: 10, y: 10, modifiers: []} = event - end - - test "parses motion event" do - state = Parser.new() - # ESC[<32;15;20M - {[event], "", _state} = - Parser.parse(<<0x1B, ?[, ?<, ?3, ?2, ?;, ?1, ?5, ?;, ?2, ?0, ?M>>, state) - - assert %MouseEvent{action: :motion, button: :left, x: 15, y: 20, modifiers: []} = event - end - - test "parses large coordinates" do - state = Parser.new() - # ESC[<0;200;150M - {[event], "", _state} = - Parser.parse(<<0x1B, ?[, ?<, ?0, ?;, ?2, ?0, ?0, ?;, ?1, ?5, ?0, ?M>>, state) - - assert %MouseEvent{action: :press, button: :left, x: 200, y: 150, modifiers: []} = event - end - end - - describe "parse/2 - focus events" do - test "parses focus gained" do - state = Parser.new() - # ESC[I - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?I>>, state) - - assert %FocusEvent{focused: true} = event - end - - test "parses focus lost" do - state = Parser.new() - # ESC[O - {[event], "", _state} = Parser.parse(<<0x1B, ?[, ?O>>, state) - - assert %FocusEvent{focused: false} = event - end - end - - describe "parse/2 - bracketed paste" do - test "parses simple paste content" do - state = Parser.new() - # ESC[200~ + content + ESC[201~ - input = <<0x1B, ?[, ?2, ?0, ?0, ?~, "hello", 0x1B, ?[, ?2, ?0, ?1, ?~>> - {[event], "", _state} = Parser.parse(input, state) - - assert %PasteEvent{content: "hello"} = event - end - - test "parses paste with special characters" do - state = Parser.new() - input = <<0x1B, ?[, ?2, ?0, ?0, ?~, "test\n\twith\nspecial", 0x1B, ?[, ?2, ?0, ?1, ?~>> - {[event], "", _state} = Parser.parse(input, state) - - assert %PasteEvent{content: "test\n\twith\nspecial"} = event - end - - test "parses empty paste" do - state = Parser.new() - input = <<0x1B, ?[, ?2, ?0, ?0, ?~, 0x1B, ?[, ?2, ?0, ?1, ?~>> - {[event], "", _state} = Parser.parse(input, state) - - assert %PasteEvent{content: ""} = event - end - - test "accumulates paste across multiple parse calls" do - state = Parser.new() - - # First call: paste start + partial content - {[], "", state} = Parser.parse(<<0x1B, ?[, ?2, ?0, ?0, ?~, "part1">>, state) - assert state.mode == :paste - - # Second call: more content + paste end - {[event], "", _state} = Parser.parse(<<"part2", 0x1B, ?[, ?2, ?0, ?1, ?~>>, state) - assert %PasteEvent{content: "part1part2"} = event - end - end - - describe "parse/2 - incremental parsing" do - test "handles split escape sequence" do - state = Parser.new() - - # Send ESC alone - {[], _remaining, state} = Parser.parse(<<0x1B>>, state) - assert state.mode == :escape - - # Send rest of sequence - {[event], "", _state} = Parser.parse(<>, state) - assert %KeyEvent{key: :up, modifiers: []} = event - end - - test "handles split CSI sequence" do - state = Parser.new() - - # Send ESC[ - {[], _remaining, state} = Parser.parse(<<0x1B, ?[>>, state) - assert state.mode == :csi - - # Send parameter and terminator - {[event], "", _state} = Parser.parse(<<"1;5A">>, state) - assert %KeyEvent{key: :up, modifiers: [:ctrl]} = event - end - - test "handles split SGR mouse sequence" do - state = Parser.new() - - # Send partial sequence - {[], _remaining, state} = Parser.parse(<<0x1B, ?[, ?<, ?0, ?;>>, state) - assert state.mode == :sgr_mouse - - # Send rest - {[event], "", _state} = Parser.parse(<<"10;5M">>, state) - assert %MouseEvent{action: :press, button: :left, x: 10, y: 5} = event - end - end - - describe "flush_escape/1" do - test "flushes pending escape as key event" do - state = %{mode: :escape, buffer: <<0x1B>>, params: [], paste_buffer: <<>>} - {[event], new_state} = Parser.flush_escape(state) - - assert %KeyEvent{key: :escape, modifiers: []} = event - assert new_state.mode == :ground - end - - test "returns empty list for non-escape state" do - state = Parser.new() - {events, new_state} = Parser.flush_escape(state) - - assert events == [] - assert new_state == state - end - end - - describe "parse/2 - mixed input" do - test "parses multiple events in sequence" do - state = Parser.new() - - # "a" + Up arrow + "b" - input = <<"a", 0x1B, ?[, ?A, "b">> - {events, "", _state} = Parser.parse(input, state) - - assert length(events) == 3 - - assert [ - %KeyEvent{key: "a"}, - %KeyEvent{key: :up}, - %KeyEvent{key: "b"} - ] = events - end - - test "parses Ctrl+C followed by other input" do - state = Parser.new() - - input = <<0x03, "abc">> - {events, "", _state} = Parser.parse(input, state) - - assert length(events) == 4 - - assert [ - %KeyEvent{key: "c", modifiers: [:ctrl]}, - %KeyEvent{key: "a"}, - %KeyEvent{key: "b"}, - %KeyEvent{key: "c"} - ] = events - end - end -end diff --git a/test/term_ui/renderer/buffer_manager_test.exs b/test/term_ui/renderer/buffer_manager_test.exs index ddd3802a..6a0076c2 100644 --- a/test/term_ui/renderer/buffer_manager_test.exs +++ b/test/term_ui/renderer/buffer_manager_test.exs @@ -24,6 +24,17 @@ defmodule TermUI.Renderer.BufferManagerTest do Process.flag(:trap_exit, false) end + + test "supports independent unnamed managers" do + {:ok, first} = BufferManager.start_link(rows: 2, cols: 3, name: nil) + {:ok, second} = BufferManager.start_link(rows: 4, cols: 5, name: nil) + + assert BufferManager.dimensions(first) == {2, 3} + assert BufferManager.dimensions(second) == {4, 5} + + GenServer.stop(first) + GenServer.stop(second) + end end describe "get_current_buffer/1" do diff --git a/test/term_ui/runtime/shutdown_test.exs b/test/term_ui/runtime/shutdown_test.exs index 93423c19..7f2700fe 100644 --- a/test/term_ui/runtime/shutdown_test.exs +++ b/test/term_ui/runtime/shutdown_test.exs @@ -29,6 +29,19 @@ defmodule TermUI.Runtime.ShutdownTest do def event_to_msg(_event, _state), do: :ignore end + defmodule LifecycleComponent do + use TermUI.Elm + + def init(opts), do: %{owner: Keyword.fetch!(opts, :owner), dimensions: opts[:dimensions]} + def update(_message, state), do: {state, []} + def view(_state), do: {:text, "lifecycle component"} + + def terminate(reason, state) do + send(state.owner, {:root_terminated, reason, state.dimensions}) + :ok + end + end + describe "quit command" do test "Command.quit/0 creates quit command" do cmd = Command.quit() @@ -167,6 +180,34 @@ defmodule TermUI.Runtime.ShutdownTest do Process.sleep(100) refute Process.alive?(runtime) end + + test "calls the root cleanup with its final state" do + {:ok, runtime} = + Runtime.start_link( + root: LifecycleComponent, + owner: self(), + dimensions: {100, 40}, + skip_terminal: true + ) + + Runtime.shutdown(runtime) + + assert_receive {:root_terminated, :normal, {100, 40}}, 1_000 + end + + test "stops the command executor with the runtime" do + {:ok, runtime} = + Runtime.start_link(root: LifecycleComponent, owner: self(), skip_terminal: true) + + executor = Runtime.get_state(runtime).command_executor + runtime_ref = Process.monitor(runtime) + executor_ref = Process.monitor(executor) + + Runtime.shutdown(runtime) + + assert_receive {:DOWN, ^runtime_ref, :process, ^runtime, :normal}, 1_000 + assert_receive {:DOWN, ^executor_ref, :process, ^executor, :normal}, 1_000 + end end describe "trap_exit" do @@ -202,14 +243,15 @@ defmodule TermUI.Runtime.ShutdownTest do end test "messages are ignored during shutdown" do - {:ok, runtime} = Runtime.start_link(root: QuitComponent, skip_terminal: true) + {:ok, runtime} = + Runtime.start_link(root: LifecycleComponent, owner: self(), skip_terminal: true) - Runtime.shutdown(runtime) + ref = Process.monitor(runtime) - # Try to send a message - should not crash - Runtime.send_message(runtime, :root, :some_message) + Runtime.shutdown(runtime) + send(runtime, :late_application_message) - Process.sleep(100) + assert_receive {:DOWN, ^ref, :process, ^runtime, :normal}, 1_000 end end diff --git a/test/term_ui/runtime_test.exs b/test/term_ui/runtime_test.exs index 3fbb3322..9633b30d 100644 --- a/test/term_ui/runtime_test.exs +++ b/test/term_ui/runtime_test.exs @@ -64,6 +64,165 @@ defmodule TermUI.RuntimeTest do def view(_state), do: {:text, "No init"} end + defmodule SessionRoot do + use TermUI.Elm + + def init(opts) do + %{owner: Keyword.fetch!(opts, :owner), view: nil} + end + + def handle_info({:session_view, view}, state) do + {%{state | view: view}, [{:send, state.owner, {:session_view_applied, view}}]} + end + + def handle_info(:noop, _state), do: :noreply + + def update(_message, state), do: {state, []} + def view(state), do: {:text, inspect(state.view)} + end + + defmodule StyledBlankRoot do + use TermUI.Elm + + alias TermUI.Component.RenderNode + alias TermUI.Renderer.Cell + + def init(_opts), do: %{} + def update(_message, state), do: {state, []} + + def view(_state) do + RenderNode.cells([%{x: 0, y: 0, cell: Cell.new(" ", attrs: [:reverse])}], + width: 2, + height: 2 + ) + end + end + + defmodule CaptureBackend do + @behaviour TermUI.Backend + + def init(opts), do: {:ok, %{owner: Keyword.fetch!(opts, :owner), size: {2, 2}}} + def shutdown(_state), do: :ok + def size(state), do: {:ok, state.size} + def move_cursor(state, _position), do: {:ok, state} + def hide_cursor(state), do: {:ok, state} + def show_cursor(state), do: {:ok, state} + def clear(state), do: {:ok, state} + + def draw_cells(state, cells) do + send(state.owner, {:draw_cells, cells}) + {:ok, state} + end + + def flush(state), do: {:ok, state} + def poll_event(state, _timeout), do: {:timeout, state} + end + + defmodule RunFailureRoot do + use TermUI.Elm + + def init(opts) do + owner = Keyword.fetch!(opts, :owner) + marker = Keyword.fetch!(opts, :marker) + send(owner, {:run_root_initialized, self(), marker}) + %{} + end + + def update(_message, state), do: {state, []} + def view(_state), do: {:text, "failure test"} + end + + defmodule RunFailureBackend do + @behaviour TermUI.Backend + + def init(opts) do + {:ok, + %{ + owner: Keyword.fetch!(opts, :owner), + failure: Keyword.fetch!(opts, :failure), + size: {2, 2} + }} + end + + def shutdown(_state), do: :ok + def size(state), do: {:ok, state.size} + def move_cursor(state, _position), do: {:ok, state} + def hide_cursor(state), do: {:ok, state} + def show_cursor(state), do: {:ok, state} + def clear(state), do: {:ok, state} + + def draw_cells(%{failure: :draw} = state, _cells) do + fail_when_released(state, :draw, :draw_failed) + end + + def draw_cells(state, _cells), do: {:ok, state} + + def flush(%{failure: :flush} = state) do + fail_when_released(state, :flush, :flush_failed) + end + + def flush(state), do: {:ok, state} + def poll_event(state, _timeout), do: {:timeout, state} + + defp fail_when_released(state, stage, reason) do + send(state.owner, {:backend_failure_ready, self(), stage}) + + receive do + {:release_backend_failure, ^stage} -> {:error, reason} + end + end + end + + defp assert_run_failure(stage, expected_reason) do + owner = self() + marker = make_ref() + runtime_name = :"run_failure_#{System.unique_integer([:positive])}" + + caller = + spawn(fn -> + previous_trap_exit = Process.flag(:trap_exit, false) + send(owner, {:run_caller_ready, self(), previous_trap_exit}) + + result = + Runtime.run( + root: RunFailureRoot, + owner: owner, + marker: marker, + name: runtime_name, + backend: {RunFailureBackend, [owner: owner, failure: stage]}, + render_interval: 60_000 + ) + + send(owner, {:run_result, self(), result}) + end) + + caller_ref = Process.monitor(caller) + assert_receive {:run_caller_ready, ^caller, false}, 1_000 + assert_receive {:run_root_initialized, runtime, ^marker}, 1_000 + + state = Runtime.get_state(runtime) + buffer_manager = state.buffer_manager + command_executor = state.command_executor + runtime_ref = Process.monitor(runtime) + buffer_ref = Process.monitor(buffer_manager) + executor_ref = Process.monitor(command_executor) + + assert Process.whereis(runtime_name) == runtime + + Runtime.force_render(runtime) + assert_receive {:backend_failure_ready, ^runtime, ^stage}, 1_000 + assert caller in elem(Process.info(runtime, :monitored_by), 1) + + send(runtime, {:release_backend_failure, stage}) + + assert_receive {:run_result, ^caller, {:error, ^expected_reason}}, 1_000 + assert_receive {:DOWN, ^runtime_ref, :process, ^runtime, ^expected_reason}, 1_000 + assert_receive {:DOWN, ^buffer_ref, :process, ^buffer_manager, :normal}, 1_000 + assert_receive {:DOWN, ^executor_ref, :process, ^command_executor, :normal}, 1_000 + assert_receive {:DOWN, ^caller_ref, :process, ^caller, :normal}, 1_000 + refute Process.whereis(runtime_name) + end + describe "start_link/1" do test "starts runtime with root component" do {:ok, runtime} = start_test_runtime(root: Counter) @@ -105,6 +264,16 @@ defmodule TermUI.RuntimeTest do end end + describe "run/1" do + test "returns a draw failure and stops runtime-owned processes" do + assert_run_failure(:draw, {:shutdown, {:backend_draw_failed, :draw_failed}}) + end + + test "returns a flush failure and stops runtime-owned processes" do + assert_run_failure(:flush, {:shutdown, {:backend_flush_failed, :flush_failed}}) + end + end + describe "send_event/2" do test "dispatches keyboard event to focused component" do {:ok, runtime} = start_test_runtime(root: Counter) @@ -199,7 +368,48 @@ defmodule TermUI.RuntimeTest do end end + describe "application messages" do + test "routes external messages through the Elm result contract" do + {:ok, runtime} = start_test_runtime(root: SessionRoot, owner: self()) + view = %{revision: 2, status: :running} + + send(runtime, {:session_view, view}) + assert :ok = Runtime.sync(runtime) + + assert Runtime.get_state(runtime).root_state.view == view + assert_receive {:session_view_applied, ^view} + end + + test "keeps application state when handle_info returns noreply" do + {:ok, runtime} = start_test_runtime(root: SessionRoot, owner: self()) + Process.sleep(20) + original = Runtime.get_state(runtime).root_state + refute Runtime.get_state(runtime).dirty + + send(runtime, :noop) + assert :ok = Runtime.sync(runtime) + + assert Runtime.get_state(runtime).root_state == original + refute Runtime.get_state(runtime).dirty + end + end + describe "dirty flag and rendering" do + test "renders attributes on a blank cell" do + {:ok, runtime} = + Runtime.start_link( + root: StyledBlankRoot, + backend: {CaptureBackend, [owner: self()]}, + render_interval: 1 + ) + + assert_receive {:draw_cells, cells}, 1_000 + assert {{1, 1}, {" ", :default, :default, attributes}} = List.keyfind(cells, {1, 1}, 0) + assert :reverse in attributes + + Runtime.shutdown(runtime) + end + test "marks dirty when state changes" do {:ok, runtime} = start_test_runtime(root: Counter, render_interval: 10) @@ -296,6 +506,16 @@ defmodule TermUI.RuntimeTest do # Process should have stopped after cleanup refute Process.alive?(runtime) end + + test "headless shutdown does not run terminal cleanup" do + log = + capture_log(fn -> + {:ok, runtime} = start_test_runtime(root: Counter) + GenServer.stop(runtime) + end) + + refute log =~ "stty" + end end describe "event dispatch routing" do @@ -461,13 +681,13 @@ defmodule TermUI.RuntimeTest do end test "stores backend mode in persistent_term" do - {:ok, runtime} = start_test_runtime(root: Counter) + {:ok, _runtime} = start_test_runtime(root: Counter) assert Runtime.backend_mode() == :skip end test "stores capabilities in persistent_term" do - {:ok, runtime} = start_test_runtime(root: Counter) + {:ok, _runtime} = start_test_runtime(root: Counter) # skip_terminal mode doesn't set capabilities assert Runtime.capabilities() == nil diff --git a/test/term_ui/term_utils_test.exs b/test/term_ui/term_utils_test.exs index 7e087d86..f5f7deaa 100644 --- a/test/term_ui/term_utils_test.exs +++ b/test/term_ui/term_utils_test.exs @@ -152,6 +152,7 @@ defmodule TermUI.TermUtilsTest do describe "integration - command execution" do @tag :external + @tag :requires_terminal test "safe_stty can save and restore settings" do # Save current settings case TermUtils.safe_stty(["-g"]) do diff --git a/test/term_ui/terminal/escape_parser_test.exs b/test/term_ui/terminal/escape_parser_test.exs index f19741f1..3f5e383b 100644 --- a/test/term_ui/terminal/escape_parser_test.exs +++ b/test/term_ui/terminal/escape_parser_test.exs @@ -415,4 +415,26 @@ defmodule TermUI.Terminal.EscapeParserTest do assert remaining == "" end end + + describe "parse/1 - focus tracking" do + test "parses focus gain and focus loss" do + {events, remaining} = EscapeParser.parse("\e[I\e[O") + + assert [ + %TermUI.Event.Focus{action: :gained}, + %TermUI.Event.Focus{action: :lost} + ] = events + + assert remaining == "" + end + end + + describe "parse/1 - X10 mouse input" do + test "parses an X10 left-button press with zero-based coordinates" do + {events, remaining} = EscapeParser.parse("\e[M !!") + + assert [%TermUI.Event.Mouse{action: :press, button: :left, x: 0, y: 0}] = events + assert remaining == "" + end + end end diff --git a/test/term_ui/widgets/supervision_tree_viewer_test.exs b/test/term_ui/widgets/supervision_tree_viewer_test.exs index 505d92f2..2b3e48b3 100644 --- a/test/term_ui/widgets/supervision_tree_viewer_test.exs +++ b/test/term_ui/widgets/supervision_tree_viewer_test.exs @@ -31,8 +31,10 @@ defmodule TermUI.Widgets.SupervisionTreeViewerTest do use Supervisor def start_link(opts) do - name = Keyword.get(opts, :name, __MODULE__) - Supervisor.start_link(__MODULE__, opts, name: name) + case Keyword.get(opts, :name) do + nil -> Supervisor.start_link(__MODULE__, opts) + name -> Supervisor.start_link(__MODULE__, opts, name: name) + end end @impl true @@ -55,8 +57,10 @@ defmodule TermUI.Widgets.SupervisionTreeViewerTest do use Supervisor def start_link(opts) do - name = Keyword.get(opts, :name, __MODULE__) - Supervisor.start_link(__MODULE__, opts, name: name) + case Keyword.get(opts, :name) do + nil -> Supervisor.start_link(__MODULE__, opts) + name -> Supervisor.start_link(__MODULE__, opts, name: name) + end end @impl true @@ -79,7 +83,10 @@ defmodule TermUI.Widgets.SupervisionTreeViewerTest do setup do # Start Theme server for color support - {:ok, _theme_pid} = Theme.start_link(theme: :dark) + case Theme.start_link(theme: :dark) do + {:ok, _theme_pid} -> :ok + {:error, {:already_started, _theme_pid}} -> :ok + end on_exit(fn -> # Theme server will be automatically stopped when test process exits diff --git a/test/term_ui/widgets/text_input_test.exs b/test/term_ui/widgets/text_input_test.exs index f69dc82e..1c4d8b9b 100644 --- a/test/term_ui/widgets/text_input_test.exs +++ b/test/term_ui/widgets/text_input_test.exs @@ -100,6 +100,51 @@ defmodule TermUI.Widgets.TextInputTest do end end + describe "bracketed paste" do + test "inserts multiline content at the cursor as one edit" do + test_pid = self() + + props = + TextInput.new( + value: "Hello world", + multiline: true, + on_change: fn value -> send(test_pid, {:changed, value}) end + ) + + {:ok, state} = TextInput.init(props) + state = %{state | cursor_col: 6} + + {:ok, state} = TextInput.handle_event(Event.paste("first\r\nsecond\n"), state) + + assert TextInput.get_value(state) == "Hello first\nsecond\nworld" + assert TextInput.get_cursor(state) == {2, 0} + assert_receive {:changed, "Hello first\nsecond\nworld"} + refute_receive {:changed, _value} + end + + test "replaces newlines with spaces in single-line mode" do + props = TextInput.new(value: "ab") + {:ok, state} = TextInput.init(props) + state = %{state | cursor_col: 1} + + {:ok, state} = TextInput.handle_event(Event.paste("one\r\ntwo"), state) + + assert TextInput.get_value(state) == "aone twob" + assert TextInput.get_cursor(state) == {0, 8} + end + + test "does not exceed the multiline line limit" do + props = TextInput.new(value: "start\nend", multiline: true, max_lines: 3) + {:ok, state} = TextInput.init(props) + state = %{state | cursor_col: 5} + + {:ok, state} = TextInput.handle_event(Event.paste(" one\ntwo\nthree"), state) + + assert TextInput.get_value(state) == "start one\ntwo\nend" + assert TextInput.get_cursor(state) == {1, 3} + end + end + describe "backspace" do test "deletes character before cursor" do props = TextInput.new(value: "Hello") From 4314b8302c5fce7f2ac486ff319731bb1ddf147f Mon Sep 17 00:00:00 2001 From: Mike Hostetler <84222+mikehostetler@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:41:08 -0500 Subject: [PATCH 02/44] feat: complete TermUI 1.0 runtime and widgets Replace the process-heavy component system with one Elm runtime and canonical frame boundary. Restore pure widgets, MDEx Markdown, terminal diffs, Zoi schemas, clipboard, Unicode selection, and mouse interaction. Harden terminal input, rendering, resize, error, and shutdown behavior for Jido Console. --- .dialyzer_ignore.exs | 4 + .formatter.exs | 7 +- CHANGELOG.md | 45 +- CLAUDE.md | 41 +- README.md | 311 +- docs/phase-05/task-5.5.2-summary.md | 217 - docs/widget-compatibility.md | 359 -- examples/README.md | 120 +- examples/alert_dialog/README.md | 114 - examples/alert_dialog/lib/alert_dialog/app.ex | 194 - .../lib/alert_dialog/application.ex | 12 - examples/alert_dialog/mix.exs | 26 - examples/alert_dialog/mix.lock | 12 - examples/alert_dialog/run.exs | 1 - examples/bar_chart/README.md | 110 - examples/bar_chart/lib/bar_chart/app.ex | 206 - .../bar_chart/lib/bar_chart/application.ex | 12 - examples/bar_chart/mix.exs | 26 - examples/bar_chart/mix.lock | 12 - examples/bar_chart/run.exs | 1 - examples/canvas/README.md | 140 - examples/canvas/lib/canvas/app.ex | 239 - examples/canvas/lib/canvas/application.ex | 12 - examples/canvas/mix.exs | 26 - examples/canvas/mix.lock | 12 - examples/canvas/run.exs | 1 - examples/cluster_dashboard/README.md | 167 - .../lib/cluster_dashboard/app.ex | 278 - .../lib/cluster_dashboard/application.ex | 12 - examples/cluster_dashboard/mix.exs | 26 - examples/cluster_dashboard/mix.lock | 12 - examples/cluster_dashboard/run.exs | 1 - examples/command_palette/README.md | 136 - .../command_palette/lib/command_palette.ex | 7 - .../lib/command_palette/app.ex | 158 - examples/command_palette/mix.exs | 25 - examples/command_palette/mix.lock | 12 - examples/command_palette/run.exs | 1 - examples/context_menu/README.md | 108 - examples/context_menu/lib/context_menu/app.ex | 208 - .../lib/context_menu/application.ex | 12 - examples/context_menu/mix.exs | 26 - examples/context_menu/mix.lock | 12 - examples/context_menu/run.exs | 1 - examples/dashboard/.tool-versions | 1 - examples/dashboard/README.md | 121 - examples/dashboard/lib/dashboard.ex | 66 - examples/dashboard/lib/dashboard/app.ex | 383 -- .../dashboard/lib/dashboard/application.ex | 15 - .../dashboard/lib/dashboard/data/metrics.ex | 255 - examples/dashboard/mix.exs | 26 - examples/dashboard/mix.lock | 13 - examples/dashboard/run.exs | 1 - examples/dialog/README.md | 144 - examples/dialog/lib/dialog/app.ex | 190 - examples/dialog/lib/dialog/application.ex | 12 - examples/dialog/mix.exs | 26 - examples/dialog/mix.lock | 12 - examples/dialog/run.exs | 1 - examples/form_builder/README.md | 207 - examples/form_builder/lib/form_builder.ex | 7 - examples/form_builder/lib/form_builder/app.ex | 268 - examples/form_builder/mix.exs | 25 - examples/form_builder/mix.lock | 12 - examples/form_builder/run.exs | 1 - examples/gauge/README.md | 193 - examples/gauge/lib/gauge/app.ex | 168 - examples/gauge/lib/gauge/application.ex | 12 - examples/gauge/mix.exs | 26 - examples/gauge/mix.lock | 12 - examples/gauge/run.exs | 1 - examples/iex_counter/README.md | 62 +- examples/iex_counter/lib/iex_counter/app.ex | 157 +- examples/line_chart/README.md | 131 - examples/line_chart/lib/line_chart/app.ex | 195 - .../line_chart/lib/line_chart/application.ex | 12 - examples/line_chart/mix.exs | 26 - examples/line_chart/mix.lock | 12 - examples/line_chart/run.exs | 1 - examples/log_viewer/README.md | 170 - examples/log_viewer/lib/log_viewer/app.ex | 287 - .../log_viewer/lib/log_viewer/application.ex | 12 - examples/log_viewer/mix.exs | 26 - examples/log_viewer/mix.lock | 12 - examples/log_viewer/run.exs | 1 - examples/markdown_viewer/README.md | 52 - .../lib/markdown_viewer/app.ex | 300 -- .../lib/markdown_viewer/application.ex | 12 - examples/markdown_viewer/mix.exs | 26 - examples/markdown_viewer/mix.lock | 14 - examples/markdown_viewer/run.exs | 1 - examples/menu/README.md | 174 - examples/menu/lib/menu/app.ex | 195 - examples/menu/lib/menu/application.ex | 12 - examples/menu/mix.exs | 26 - examples/menu/mix.lock | 12 - examples/menu/run.exs | 1 - examples/multi_renderer/README.md | 215 - examples/multi_renderer/basic.ex | 162 - examples/multi_renderer/capabilities.ex | 400 -- examples/multi_renderer/text_input.ex | 251 - examples/pick_list/README.md | 162 - examples/pick_list/lib/pick_list/app.ex | 304 -- .../pick_list/lib/pick_list/application.ex | 12 - examples/pick_list/mix.exs | 26 - examples/pick_list/mix.lock | 12 - examples/pick_list/run.exs | 1 - examples/process_monitor/README.md | 211 - .../lib/process_monitor/app.ex | 282 - .../lib/process_monitor/application.ex | 12 - examples/process_monitor/mix.exs | 26 - examples/process_monitor/mix.lock | 12 - examples/process_monitor/run.exs | 1 - examples/sparkline/README.md | 181 - examples/sparkline/lib/sparkline/app.ex | 201 - .../sparkline/lib/sparkline/application.ex | 12 - examples/sparkline/mix.exs | 26 - examples/sparkline/mix.lock | 12 - examples/sparkline/run.exs | 1 - examples/split_pane/README.md | 130 - examples/split_pane/lib/split_pane/app.ex | 295 -- .../split_pane/lib/split_pane/application.ex | 12 - examples/split_pane/mix.exs | 26 - examples/split_pane/mix.lock | 12 - examples/split_pane/run.exs | 1 - examples/stream_widget/README.md | 139 - .../stream_widget/lib/stream_widget/app.ex | 218 - .../lib/stream_widget/application.ex | 12 - .../lib/stream_widget/producer.ex | 127 - examples/stream_widget/mix.exs | 27 - examples/stream_widget/mix.lock | 12 - examples/stream_widget/run.exs | 1 - examples/supervision_tree_viewer/README.md | 170 - .../lib/supervision_tree_viewer/app.ex | 190 - .../supervision_tree_viewer/application.ex | 16 - .../supervision_tree_viewer/sample_tree.ex | 171 - examples/supervision_tree_viewer/mix.exs | 26 - examples/supervision_tree_viewer/mix.lock | 12 - examples/supervision_tree_viewer/run.exs | 1 - examples/table/README.md | 230 - examples/table/lib/table/app.ex | 257 - examples/table/lib/table/application.ex | 12 - examples/table/mix.exs | 26 - examples/table/mix.lock | 12 - examples/table/run.exs | 1 - examples/tabs/README.md | 143 - examples/tabs/lib/tabs/app.ex | 291 -- examples/tabs/lib/tabs/application.ex | 12 - examples/tabs/mix.exs | 26 - examples/tabs/mix.lock | 12 - examples/tabs/run.exs | 1 - examples/text_input/README.md | 208 - examples/text_input/lib/text_input.ex | 7 - examples/text_input/lib/text_input/app.ex | 412 -- examples/text_input/mix.exs | 25 - examples/text_input/mix.lock | 12 - examples/text_input/run.exs | 1 - examples/toast/README.md | 144 - examples/toast/lib/toast/app.ex | 225 - examples/toast/lib/toast/application.ex | 12 - examples/toast/mix.exs | 26 - examples/toast/mix.lock | 12 - examples/toast/run.exs | 1 - examples/tree_view/README.md | 275 - examples/tree_view/lib/tree_view/app.ex | 274 - .../tree_view/lib/tree_view/application.ex | 12 - examples/tree_view/mix.exs | 26 - examples/tree_view/mix.lock | 12 - examples/tree_view/run.exs | 1 - examples/viewport/README.md | 157 - examples/viewport/lib/viewport/app.ex | 208 - examples/viewport/lib/viewport/application.ex | 12 - examples/viewport/mix.exs | 26 - examples/viewport/mix.lock | 12 - examples/viewport/run.exs | 1 - guides/api_reference.md | 355 -- guides/architecture.md | 83 + guides/backend.md | 41 + guides/component_system.md | 546 -- guides/developer/01-architecture-overview.md | 327 -- guides/developer/02-runtime-internals.md | 408 -- guides/developer/03-rendering-pipeline.md | 440 -- guides/developer/04-event-system.md | 406 -- guides/developer/05-buffer-management.md | 423 -- guides/developer/06-terminal-layer.md | 515 -- guides/developer/07-elm-implementation.md | 602 --- guides/developer/08-creating-widgets.md | 409 -- guides/developer/09-testing-framework.md | 472 -- guides/developer/README.md | 69 - guides/interaction.md | 113 + guides/markdown-and-diffs.md | 42 + guides/migration-1.0.md | 48 + guides/removed-and-deferred.md | 60 + guides/user/01-overview.md | 139 - guides/user/02-getting-started.md | 293 -- guides/user/03-elm-architecture.md | 352 -- guides/user/04-events.md | 362 -- guides/user/05-styling.md | 342 -- guides/user/06-layout.md | 414 -- guides/user/07-widgets.md | 583 --- guides/user/08-terminal.md | 310 -- guides/user/09-commands.md | 377 -- guides/user/10-advanced-widgets.md | 992 ---- guides/user/README.md | 51 - guides/widgets.md | 73 + lib/term_ui.ex | 165 +- lib/term_ui/ansi.ex | 8 +- lib/term_ui/app.ex | 388 -- lib/term_ui/backend.ex | 290 +- lib/term_ui/backend/config.ex | 422 -- lib/term_ui/backend/event_stream.ex | 111 + lib/term_ui/backend/input_buffer.ex | 210 +- lib/term_ui/backend/input_reader.ex | 90 + lib/term_ui/backend/manager.ex | 426 ++ lib/term_ui/backend/raw.ex | 1599 +----- lib/term_ui/backend/renderer.ex | 144 + lib/term_ui/backend/selector.ex | 90 +- lib/term_ui/backend/ssh.ex | 500 -- lib/term_ui/backend/state.ex | 312 -- lib/term_ui/backend/tty.ex | 1308 +---- lib/term_ui/capabilities.ex | 404 -- lib/term_ui/capabilities/fallbacks.ex | 249 - lib/term_ui/{renderer => }/cell.ex | 48 +- lib/term_ui/character_set.ex | 14 +- lib/term_ui/clipboard.ex | 290 +- lib/term_ui/clipboard/operation.ex | 25 + lib/term_ui/clipboard/selection.ex | 329 -- lib/term_ui/command.ex | 290 +- lib/term_ui/command/executor.ex | 362 -- lib/term_ui/component.ex | 179 - lib/term_ui/component/helpers.ex | 332 -- lib/term_ui/component/introspection.ex | 338 -- lib/term_ui/component/render_node.ex | 256 - lib/term_ui/component/state_persistence.ex | 305 -- lib/term_ui/component_registry.ex | 309 -- lib/term_ui/component_server.ex | 485 -- lib/term_ui/component_supervisor.ex | 420 -- lib/term_ui/config.ex | 242 - lib/term_ui/container.ex | 333 -- .../{renderer => }/cursor_optimizer.ex | 41 +- lib/term_ui/dev/dev_mode.ex | 464 -- lib/term_ui/dev/hot_reload.ex | 367 -- lib/term_ui/dev/perf_monitor.ex | 235 - lib/term_ui/dev/state_inspector.ex | 292 -- lib/term_ui/dev/ui_inspector.ex | 180 - lib/term_ui/{renderer => }/display_width.ex | 2 +- lib/term_ui/elm.ex | 302 +- lib/term_ui/error.ex | 184 - lib/term_ui/event.ex | 539 +- lib/term_ui/event/propagation.ex | 208 - lib/term_ui/event/transformation.ex | 231 - lib/term_ui/event_queue.ex | 282 - lib/term_ui/event_router.ex | 308 -- lib/term_ui/focus.ex | 371 -- lib/term_ui/focus/indicator.ex | 199 - lib/term_ui/focus/traversal.ex | 184 - lib/term_ui/focus_manager.ex | 561 -- lib/term_ui/frame.ex | 342 ++ lib/term_ui/helpers/border_helper.ex | 317 -- lib/term_ui/helpers/cursor_helper.ex | 269 - lib/term_ui/input.ex | 227 - lib/term_ui/input/line_reader.ex | 265 - lib/term_ui/input/raw.ex | 377 -- lib/term_ui/input/selector.ex | 181 - lib/term_ui/input/tty.ex | 455 -- lib/term_ui/input/tty_server.ex | 339 -- lib/term_ui/layout/alignment.ex | 339 -- lib/term_ui/layout/cache.ex | 341 -- lib/term_ui/layout/constraint.ex | 518 -- lib/term_ui/layout/solver.ex | 521 -- lib/term_ui/markdown.ex | 961 +--- lib/term_ui/message.ex | 141 - lib/term_ui/message_queue.ex | 189 - lib/term_ui/mouse.ex | 352 +- lib/term_ui/mouse/router.ex | 131 - lib/term_ui/mouse/tracker.ex | 209 - lib/term_ui/persistent_terms.ex | 178 - lib/term_ui/platform.ex | 204 - lib/term_ui/platform/unix.ex | 128 - lib/term_ui/platform/windows.ex | 159 - lib/term_ui/renderer/buffer.ex | 431 -- lib/term_ui/renderer/buffer_manager.ex | 390 -- lib/term_ui/renderer/diff.ex | 319 -- lib/term_ui/renderer/framerate_limiter.ex | 497 -- lib/term_ui/renderer/sequence_buffer.ex | 284 - lib/term_ui/renderer/style.ex | 346 -- lib/term_ui/runtime.ex | 1795 ++----- lib/term_ui/runtime/node_renderer.ex | 474 -- lib/term_ui/runtime/state.ex | 93 - lib/term_ui/sanitize.ex | 229 - lib/term_ui/selection.ex | 206 + lib/term_ui/sgr.ex | 360 -- lib/term_ui/shortcut.ex | 341 -- lib/term_ui/spatial_index.ex | 191 - lib/term_ui/stateful_component.ex | 321 -- lib/term_ui/style.ex | 54 +- lib/term_ui/term_utils.ex | 4 +- lib/term_ui/terminal.ex | 677 --- lib/term_ui/terminal/escape_parser.ex | 34 +- lib/term_ui/terminal/input_reader.ex | 206 - lib/term_ui/terminal/size_detector.ex | 34 +- lib/term_ui/terminal/state.ex | 47 - lib/term_ui/terminal_output.ex | 7 +- lib/term_ui/test/assertions.ex | 529 -- lib/term_ui/test/component_harness.ex | 337 -- lib/term_ui/test/event_simulator.ex | 258 - lib/term_ui/test/test_renderer.ex | 314 -- lib/term_ui/theme.ex | 737 --- lib/term_ui/view_cache.ex | 189 - lib/term_ui/widget.ex | 56 + lib/term_ui/widget/alert_dialog.ex | 51 + lib/term_ui/widget/bar_chart.ex | 77 + lib/term_ui/widget/block.ex | 325 +- lib/term_ui/widget/button.ex | 253 +- lib/term_ui/widget/canvas.ex | 184 + lib/term_ui/widget/chart_helpers.ex | 23 + lib/term_ui/widget/cluster_dashboard.ex | 78 + lib/term_ui/widget/command_palette.ex | 92 + lib/term_ui/widget/context_menu.ex | 50 + lib/term_ui/widget/dialog.ex | 163 + lib/term_ui/widget/diff_viewer.ex | 446 ++ lib/term_ui/widget/form_builder.ex | 225 + lib/term_ui/widget/gauge.ex | 83 + lib/term_ui/widget/helpers.ex | 129 + lib/term_ui/widget/label.ex | 196 +- lib/term_ui/widget/line_chart.ex | 92 + lib/term_ui/widget/line_input.ex | 98 + lib/term_ui/widget/list.ex | 349 +- lib/term_ui/widget/log_viewer.ex | 131 + lib/term_ui/widget/markdown_viewer.ex | 127 + lib/term_ui/widget/menu.ex | 196 + lib/term_ui/widget/pick_list.ex | 574 +- lib/term_ui/widget/process_monitor.ex | 97 + lib/term_ui/widget/progress.ex | 216 +- lib/term_ui/widget/scroll_bar.ex | 134 + lib/term_ui/widget/sparkline.ex | 64 + lib/term_ui/widget/split_pane.ex | 121 + lib/term_ui/widget/stream.ex | 91 + lib/term_ui/widget/stream_widget.ex | 10 + lib/term_ui/widget/supervision_tree.ex | 37 + lib/term_ui/widget/supervision_tree_viewer.ex | 10 + lib/term_ui/widget/table.ex | 178 + lib/term_ui/widget/table/column.ex | 36 + lib/term_ui/widget/tabs.ex | 157 + lib/term_ui/widget/text_area.ex | 387 ++ lib/term_ui/widget/text_input.ex | 455 +- lib/term_ui/widget/text_input/line.ex | 10 + lib/term_ui/widget/toast.ex | 110 + lib/term_ui/widget/tree_view.ex | 230 + lib/term_ui/widget/viewport.ex | 140 + lib/term_ui/widgets/alert_dialog.ex | 634 --- lib/term_ui/widgets/bar_chart.ex | 271 - lib/term_ui/widgets/canvas.ex | 529 -- lib/term_ui/widgets/cluster_dashboard.ex | 1123 ---- lib/term_ui/widgets/command_palette.ex | 262 - lib/term_ui/widgets/context_menu.ex | 342 -- lib/term_ui/widgets/context_menu/behavior.ex | 275 - lib/term_ui/widgets/context_menu/factory.ex | 241 - lib/term_ui/widgets/context_menu/inline.ex | 358 -- lib/term_ui/widgets/dialog.ex | 524 -- lib/term_ui/widgets/form_builder.ex | 896 ---- lib/term_ui/widgets/gauge.ex | 329 -- lib/term_ui/widgets/line_chart.ex | 308 -- lib/term_ui/widgets/log_viewer.ex | 1102 ---- lib/term_ui/widgets/markdown_viewer.ex | 319 -- lib/term_ui/widgets/menu.ex | 488 -- lib/term_ui/widgets/process_monitor.ex | 1063 ---- lib/term_ui/widgets/scroll_bar.ex | 346 -- lib/term_ui/widgets/sparkline.ex | 235 - lib/term_ui/widgets/split_pane.ex | 969 ---- lib/term_ui/widgets/stream_widget.ex | 693 --- lib/term_ui/widgets/stream_widget/consumer.ex | 128 - .../widgets/supervision_tree_viewer.ex | 1194 ----- lib/term_ui/widgets/table.ex | 583 --- lib/term_ui/widgets/table/column.ex | 149 - lib/term_ui/widgets/tabs.ex | 328 -- lib/term_ui/widgets/text_input.ex | 791 --- lib/term_ui/widgets/text_input/line.ex | 696 --- lib/term_ui/widgets/toast.ex | 407 -- lib/term_ui/widgets/tree_view.ex | 994 ---- lib/term_ui/widgets/viewport.ex | 574 -- lib/term_ui/widgets/visualization_helper.ex | 495 -- lib/term_ui/widgets/widget_helpers.ex | 156 - mix.exs | 145 +- mix.lock | 11 +- mix/tasks/termui.run.ex | 10 +- test/docs/widget_compatibility_test.exs | 199 - test/integration/backend_selection_test.exs | 435 -- test/integration/capability_accuracy_test.exs | 358 -- test/integration/cross_platform_test.exs | 408 -- test/integration/input_abstraction_test.exs | 516 -- .../keyboard_navigation_integration_test.exs | 618 --- .../mouse_fallback_integration_test.exs | 480 -- test/integration/multi_renderer_test.exs | 624 --- test/integration/round_trip_test.exs | 447 -- test/integration/runtime_contract_test.exs | 276 + test/integration/terminal_lifecycle_test.exs | 362 -- .../visual_degradation_integration_test.exs | 611 --- test/manual/linux.md | 32 - test/manual/mac.md | 32 - test/manual/windows.md | 32 - test/manual/wsl.md | 32 - test/support/context_menu_test_helpers.ex | 57 - test/support/deterministic_backend.ex | 71 + test/support/integration_helpers.ex | 330 -- test/support/runtime_test_case.ex | 60 - test/support/test_components.ex | 87 - test/support/test_factories.ex | 83 - test/term_ui/app_test.exs | 466 -- test/term_ui/backend/config_test.exs | 601 --- test/term_ui/backend/frame_boundary_test.exs | 201 + test/term_ui/backend/input_buffer_test.exs | 68 + test/term_ui/backend/manager_test.exs | 49 + test/term_ui/backend/raw_integration_test.exs | 405 -- test/term_ui/backend/raw_test.exs | 2488 --------- test/term_ui/backend/renderer_test.exs | 71 + test/term_ui/backend/selector_test.exs | 35 +- test/term_ui/backend/ssh_test.exs | 528 -- test/term_ui/backend/state_test.exs | 734 --- test/term_ui/backend/tty_test.exs | 4593 ----------------- test/term_ui/backend_test.exs | 242 - test/term_ui/capabilities/fallbacks_test.exs | 254 - test/term_ui/capabilities_test.exs | 305 -- test/term_ui/clipboard_test.exs | 473 +- test/term_ui/command_test.exs | 388 -- test/term_ui/component/helpers_test.exs | 364 -- test/term_ui/component/introspection_test.exs | 228 - test/term_ui/component/render_node_test.exs | 168 - .../component/state_persistence_test.exs | 192 - test/term_ui/component_registry_test.exs | 185 - test/term_ui/component_server_test.exs | 417 -- test/term_ui/component_supervisor_test.exs | 294 -- test/term_ui/component_test.exs | 199 - test/term_ui/config_test.exs | 258 - test/term_ui/container_test.exs | 417 -- test/term_ui/dev/dev_mode_test.exs | 211 - test/term_ui/dev/hot_reload_test.exs | 69 - test/term_ui/dev/perf_monitor_test.exs | 123 - test/term_ui/dev/state_inspector_test.exs | 135 - test/term_ui/dev/ui_inspector_test.exs | 151 - test/term_ui/elm_test.exs | 194 - test/term_ui/error_test.exs | 112 - test/term_ui/event/propagation_test.exs | 312 -- test/term_ui/event/transformation_test.exs | 238 - test/term_ui/event_queue_test.exs | 210 - test/term_ui/event_router_test.exs | 271 - test/term_ui/event_test.exs | 283 - test/term_ui/focus/indicator_test.exs | 170 - test/term_ui/focus/traversal_test.exs | 184 - test/term_ui/focus_manager_test.exs | 365 -- test/term_ui/focus_test.exs | 315 -- test/term_ui/frame_contract_test.exs | 57 + test/term_ui/helpers/border_helper_test.exs | 192 - test/term_ui/helpers/cursor_helper_test.exs | 171 - test/term_ui/input/line_reader_test.exs | 332 -- test/term_ui/input/raw_test.exs | 493 -- test/term_ui/input/selector_test.exs | 231 - test/term_ui/input/tty_test.exs | 501 -- test/term_ui/input_test.exs | 218 - .../integration/advanced_widgets_test.exs | 391 -- .../integration/component_hierarchy_test.exs | 442 -- test/term_ui/integration/cross_mode_test.exs | 608 --- test/term_ui/integration/dashboard_test.exs | 334 -- .../term_ui/integration/dev_workflow_test.exs | 289 -- test/term_ui/integration/end_to_end_test.exs | 513 -- test/term_ui/integration/event_flow_test.exs | 352 -- .../term_ui/integration/event_system_test.exs | 656 --- .../integration/fault_tolerance_test.exs | 432 -- .../integration/focus_integration_test.exs | 408 -- .../integration/iex_lifecycle_test.exs | 475 -- .../integration/multi_component_test.exs | 374 -- test/term_ui/integration/property_test.exs | 332 -- .../integration/testing_framework_test.exs | 358 -- test/term_ui/layout/alignment_test.exs | 422 -- test/term_ui/layout/cache_test.exs | 368 -- test/term_ui/layout/constraint_test.exs | 522 -- test/term_ui/layout/integration_test.exs | 531 -- test/term_ui/layout/solver_test.exs | 459 -- test/term_ui/message_queue_test.exs | 184 - test/term_ui/message_test.exs | 112 - test/term_ui/mouse_test.exs | 447 +- test/term_ui/performance_test.exs | 533 -- test/term_ui/persistent_terms_test.exs | 212 - test/term_ui/platform_test.exs | 410 -- test/term_ui/public_contract_test.exs | 36 + test/term_ui/renderer/buffer_manager_test.exs | 493 -- test/term_ui/renderer/buffer_test.exs | 517 -- test/term_ui/renderer/cell_test.exs | 12 +- .../renderer/cursor_optimizer_test.exs | 4 +- test/term_ui/renderer/diff_test.exs | 587 --- test/term_ui/renderer/display_width_test.exs | 4 +- .../renderer/framerate_limiter_test.exs | 401 -- test/term_ui/renderer/integration_test.exs | 615 --- .../term_ui/renderer/sequence_buffer_test.exs | 655 --- test/term_ui/renderer/style_test.exs | 375 -- test/term_ui/runtime/node_renderer_test.exs | 172 - test/term_ui/runtime/resize_test.exs | 75 - test/term_ui/runtime/shutdown_test.exs | 272 - test/term_ui/runtime_test.exs | 1004 ---- test/term_ui/sanitize_test.exs | 238 - test/term_ui/selection_test.exs | 42 + test/term_ui/shortcut_test.exs | 418 -- test/term_ui/source_conventions_test.exs | 60 + test/term_ui/spatial_index_test.exs | 198 - test/term_ui/stateful_component_test.exs | 311 -- test/term_ui/style_integration_test.exs | 375 -- test/term_ui/terminal/escape_parser_test.exs | 29 +- test/term_ui/terminal/input_reader_test.exs | 118 - test/term_ui/terminal/mouse_test.exs | 149 - test/term_ui/terminal/raw_mode_test.exs | 224 - test/term_ui/terminal/state_test.exs | 35 - test/term_ui/terminal_test.exs | 292 -- test/term_ui/test/assertions_test.exs | 315 -- test/term_ui/test/component_harness_test.exs | 293 -- test/term_ui/test/event_simulator_test.exs | 221 - test/term_ui/test/test_renderer_test.exs | 248 - test/term_ui/theme_integration_test.exs | 311 -- test/term_ui/theme_test.exs | 471 -- test/term_ui/view_cache_test.exs | 186 - test/term_ui/widget/block_test.exs | 272 - test/term_ui/widget/button_test.exs | 202 - test/term_ui/widget/catalog_test.exs | 187 + test/term_ui/widget/diff_viewer_test.exs | 51 + test/term_ui/widget/label_test.exs | 146 - test/term_ui/widget/list_test.exs | 277 - test/term_ui/widget/markdown_viewer_test.exs | 64 + .../term_ui/widget/mouse_interaction_test.exs | 175 + test/term_ui/widget/pick_list_test.exs | 324 -- test/term_ui/widget/progress_test.exs | 200 - test/term_ui/widget/text_area_test.exs | 46 + test/term_ui/widget/text_input_test.exs | 294 +- test/term_ui/widgets/alert_dialog_test.exs | 346 -- test/term_ui/widgets/ascii_fallback_test.exs | 433 -- test/term_ui/widgets/bar_chart_test.exs | 250 - test/term_ui/widgets/canvas_test.exs | 383 -- .../widgets/cluster_dashboard_test.exs | 475 -- test/term_ui/widgets/command_palette_test.exs | 359 -- .../widgets/context_menu/behavior_test.exs | 385 -- .../widgets/context_menu/factory_test.exs | 337 -- .../widgets/context_menu/inline_test.exs | 679 --- test/term_ui/widgets/context_menu_test.exs | 331 -- test/term_ui/widgets/dialog_test.exs | 272 - test/term_ui/widgets/form_builder_test.exs | 900 ---- test/term_ui/widgets/gauge_test.exs | 301 -- test/term_ui/widgets/line_chart_test.exs | 272 - test/term_ui/widgets/log_viewer_test.exs | 680 --- test/term_ui/widgets/menu_test.exs | 360 -- test/term_ui/widgets/process_monitor_test.exs | 488 -- test/term_ui/widgets/scroll_bar_test.exs | 310 -- test/term_ui/widgets/sparkline_test.exs | 204 - test/term_ui/widgets/split_pane_test.exs | 1336 ----- .../widgets/stream_widget/consumer_test.exs | 104 - test/term_ui/widgets/stream_widget_test.exs | 514 -- .../widgets/supervision_tree_viewer_test.exs | 864 ---- test/term_ui/widgets/table/column_test.exs | 132 - test/term_ui/widgets/table_test.exs | 493 -- test/term_ui/widgets/tabs_test.exs | 262 - test/term_ui/widgets/text_input/line_test.exs | 728 --- test/term_ui/widgets/text_input_test.exs | 693 --- test/term_ui/widgets/toast_test.exs | 302 -- test/term_ui/widgets/tree_view_test.exs | 888 ---- test/term_ui/widgets/viewport_test.exs | 313 -- .../widgets/visualization_helper_test.exs | 385 -- test/term_ui_test.exs | 206 - usage-rules.md | 941 +--- 565 files changed, 10448 insertions(+), 135946 deletions(-) create mode 100644 .dialyzer_ignore.exs delete mode 100644 docs/phase-05/task-5.5.2-summary.md delete mode 100644 docs/widget-compatibility.md delete mode 100644 examples/alert_dialog/README.md delete mode 100644 examples/alert_dialog/lib/alert_dialog/app.ex delete mode 100644 examples/alert_dialog/lib/alert_dialog/application.ex delete mode 100644 examples/alert_dialog/mix.exs delete mode 100644 examples/alert_dialog/mix.lock delete mode 100644 examples/alert_dialog/run.exs delete mode 100644 examples/bar_chart/README.md delete mode 100644 examples/bar_chart/lib/bar_chart/app.ex delete mode 100644 examples/bar_chart/lib/bar_chart/application.ex delete mode 100644 examples/bar_chart/mix.exs delete mode 100644 examples/bar_chart/mix.lock delete mode 100644 examples/bar_chart/run.exs delete mode 100644 examples/canvas/README.md delete mode 100644 examples/canvas/lib/canvas/app.ex delete mode 100644 examples/canvas/lib/canvas/application.ex delete mode 100644 examples/canvas/mix.exs delete mode 100644 examples/canvas/mix.lock delete mode 100644 examples/canvas/run.exs delete mode 100644 examples/cluster_dashboard/README.md delete mode 100644 examples/cluster_dashboard/lib/cluster_dashboard/app.ex delete mode 100644 examples/cluster_dashboard/lib/cluster_dashboard/application.ex delete mode 100644 examples/cluster_dashboard/mix.exs delete mode 100644 examples/cluster_dashboard/mix.lock delete mode 100644 examples/cluster_dashboard/run.exs delete mode 100644 examples/command_palette/README.md delete mode 100644 examples/command_palette/lib/command_palette.ex delete mode 100644 examples/command_palette/lib/command_palette/app.ex delete mode 100644 examples/command_palette/mix.exs delete mode 100644 examples/command_palette/mix.lock delete mode 100644 examples/command_palette/run.exs delete mode 100644 examples/context_menu/README.md delete mode 100644 examples/context_menu/lib/context_menu/app.ex delete mode 100644 examples/context_menu/lib/context_menu/application.ex delete mode 100644 examples/context_menu/mix.exs delete mode 100644 examples/context_menu/mix.lock delete mode 100644 examples/context_menu/run.exs delete mode 100644 examples/dashboard/.tool-versions delete mode 100644 examples/dashboard/README.md delete mode 100644 examples/dashboard/lib/dashboard.ex delete mode 100644 examples/dashboard/lib/dashboard/app.ex delete mode 100644 examples/dashboard/lib/dashboard/application.ex delete mode 100644 examples/dashboard/lib/dashboard/data/metrics.ex delete mode 100644 examples/dashboard/mix.exs delete mode 100644 examples/dashboard/mix.lock delete mode 100644 examples/dashboard/run.exs delete mode 100644 examples/dialog/README.md delete mode 100644 examples/dialog/lib/dialog/app.ex delete mode 100644 examples/dialog/lib/dialog/application.ex delete mode 100644 examples/dialog/mix.exs delete mode 100644 examples/dialog/mix.lock delete mode 100644 examples/dialog/run.exs delete mode 100644 examples/form_builder/README.md delete mode 100644 examples/form_builder/lib/form_builder.ex delete mode 100644 examples/form_builder/lib/form_builder/app.ex delete mode 100644 examples/form_builder/mix.exs delete mode 100644 examples/form_builder/mix.lock delete mode 100644 examples/form_builder/run.exs delete mode 100644 examples/gauge/README.md delete mode 100644 examples/gauge/lib/gauge/app.ex delete mode 100644 examples/gauge/lib/gauge/application.ex delete mode 100644 examples/gauge/mix.exs delete mode 100644 examples/gauge/mix.lock delete mode 100644 examples/gauge/run.exs delete mode 100644 examples/line_chart/README.md delete mode 100644 examples/line_chart/lib/line_chart/app.ex delete mode 100644 examples/line_chart/lib/line_chart/application.ex delete mode 100644 examples/line_chart/mix.exs delete mode 100644 examples/line_chart/mix.lock delete mode 100644 examples/line_chart/run.exs delete mode 100644 examples/log_viewer/README.md delete mode 100644 examples/log_viewer/lib/log_viewer/app.ex delete mode 100644 examples/log_viewer/lib/log_viewer/application.ex delete mode 100644 examples/log_viewer/mix.exs delete mode 100644 examples/log_viewer/mix.lock delete mode 100644 examples/log_viewer/run.exs delete mode 100644 examples/markdown_viewer/README.md delete mode 100644 examples/markdown_viewer/lib/markdown_viewer/app.ex delete mode 100644 examples/markdown_viewer/lib/markdown_viewer/application.ex delete mode 100644 examples/markdown_viewer/mix.exs delete mode 100644 examples/markdown_viewer/mix.lock delete mode 100644 examples/markdown_viewer/run.exs delete mode 100644 examples/menu/README.md delete mode 100644 examples/menu/lib/menu/app.ex delete mode 100644 examples/menu/lib/menu/application.ex delete mode 100644 examples/menu/mix.exs delete mode 100644 examples/menu/mix.lock delete mode 100644 examples/menu/run.exs delete mode 100644 examples/multi_renderer/README.md delete mode 100644 examples/multi_renderer/basic.ex delete mode 100644 examples/multi_renderer/capabilities.ex delete mode 100644 examples/multi_renderer/text_input.ex delete mode 100644 examples/pick_list/README.md delete mode 100644 examples/pick_list/lib/pick_list/app.ex delete mode 100644 examples/pick_list/lib/pick_list/application.ex delete mode 100644 examples/pick_list/mix.exs delete mode 100644 examples/pick_list/mix.lock delete mode 100644 examples/pick_list/run.exs delete mode 100644 examples/process_monitor/README.md delete mode 100644 examples/process_monitor/lib/process_monitor/app.ex delete mode 100644 examples/process_monitor/lib/process_monitor/application.ex delete mode 100644 examples/process_monitor/mix.exs delete mode 100644 examples/process_monitor/mix.lock delete mode 100644 examples/process_monitor/run.exs delete mode 100644 examples/sparkline/README.md delete mode 100644 examples/sparkline/lib/sparkline/app.ex delete mode 100644 examples/sparkline/lib/sparkline/application.ex delete mode 100644 examples/sparkline/mix.exs delete mode 100644 examples/sparkline/mix.lock delete mode 100644 examples/sparkline/run.exs delete mode 100644 examples/split_pane/README.md delete mode 100644 examples/split_pane/lib/split_pane/app.ex delete mode 100644 examples/split_pane/lib/split_pane/application.ex delete mode 100644 examples/split_pane/mix.exs delete mode 100644 examples/split_pane/mix.lock delete mode 100644 examples/split_pane/run.exs delete mode 100644 examples/stream_widget/README.md delete mode 100644 examples/stream_widget/lib/stream_widget/app.ex delete mode 100644 examples/stream_widget/lib/stream_widget/application.ex delete mode 100644 examples/stream_widget/lib/stream_widget/producer.ex delete mode 100644 examples/stream_widget/mix.exs delete mode 100644 examples/stream_widget/mix.lock delete mode 100644 examples/stream_widget/run.exs delete mode 100644 examples/supervision_tree_viewer/README.md delete mode 100644 examples/supervision_tree_viewer/lib/supervision_tree_viewer/app.ex delete mode 100644 examples/supervision_tree_viewer/lib/supervision_tree_viewer/application.ex delete mode 100644 examples/supervision_tree_viewer/lib/supervision_tree_viewer/sample_tree.ex delete mode 100644 examples/supervision_tree_viewer/mix.exs delete mode 100644 examples/supervision_tree_viewer/mix.lock delete mode 100644 examples/supervision_tree_viewer/run.exs delete mode 100644 examples/table/README.md delete mode 100644 examples/table/lib/table/app.ex delete mode 100644 examples/table/lib/table/application.ex delete mode 100644 examples/table/mix.exs delete mode 100644 examples/table/mix.lock delete mode 100644 examples/table/run.exs delete mode 100644 examples/tabs/README.md delete mode 100644 examples/tabs/lib/tabs/app.ex delete mode 100644 examples/tabs/lib/tabs/application.ex delete mode 100644 examples/tabs/mix.exs delete mode 100644 examples/tabs/mix.lock delete mode 100644 examples/tabs/run.exs delete mode 100644 examples/text_input/README.md delete mode 100644 examples/text_input/lib/text_input.ex delete mode 100644 examples/text_input/lib/text_input/app.ex delete mode 100644 examples/text_input/mix.exs delete mode 100644 examples/text_input/mix.lock delete mode 100644 examples/text_input/run.exs delete mode 100644 examples/toast/README.md delete mode 100644 examples/toast/lib/toast/app.ex delete mode 100644 examples/toast/lib/toast/application.ex delete mode 100644 examples/toast/mix.exs delete mode 100644 examples/toast/mix.lock delete mode 100644 examples/toast/run.exs delete mode 100644 examples/tree_view/README.md delete mode 100644 examples/tree_view/lib/tree_view/app.ex delete mode 100644 examples/tree_view/lib/tree_view/application.ex delete mode 100644 examples/tree_view/mix.exs delete mode 100644 examples/tree_view/mix.lock delete mode 100644 examples/tree_view/run.exs delete mode 100644 examples/viewport/README.md delete mode 100644 examples/viewport/lib/viewport/app.ex delete mode 100644 examples/viewport/lib/viewport/application.ex delete mode 100644 examples/viewport/mix.exs delete mode 100644 examples/viewport/mix.lock delete mode 100644 examples/viewport/run.exs delete mode 100644 guides/api_reference.md create mode 100644 guides/architecture.md create mode 100644 guides/backend.md delete mode 100644 guides/component_system.md delete mode 100644 guides/developer/01-architecture-overview.md delete mode 100644 guides/developer/02-runtime-internals.md delete mode 100644 guides/developer/03-rendering-pipeline.md delete mode 100644 guides/developer/04-event-system.md delete mode 100644 guides/developer/05-buffer-management.md delete mode 100644 guides/developer/06-terminal-layer.md delete mode 100644 guides/developer/07-elm-implementation.md delete mode 100644 guides/developer/08-creating-widgets.md delete mode 100644 guides/developer/09-testing-framework.md delete mode 100644 guides/developer/README.md create mode 100644 guides/interaction.md create mode 100644 guides/markdown-and-diffs.md create mode 100644 guides/migration-1.0.md create mode 100644 guides/removed-and-deferred.md delete mode 100644 guides/user/01-overview.md delete mode 100644 guides/user/02-getting-started.md delete mode 100644 guides/user/03-elm-architecture.md delete mode 100644 guides/user/04-events.md delete mode 100644 guides/user/05-styling.md delete mode 100644 guides/user/06-layout.md delete mode 100644 guides/user/07-widgets.md delete mode 100644 guides/user/08-terminal.md delete mode 100644 guides/user/09-commands.md delete mode 100644 guides/user/10-advanced-widgets.md delete mode 100644 guides/user/README.md create mode 100644 guides/widgets.md delete mode 100644 lib/term_ui/app.ex delete mode 100644 lib/term_ui/backend/config.ex create mode 100644 lib/term_ui/backend/event_stream.ex create mode 100644 lib/term_ui/backend/input_reader.ex create mode 100644 lib/term_ui/backend/manager.ex create mode 100644 lib/term_ui/backend/renderer.ex delete mode 100644 lib/term_ui/backend/ssh.ex delete mode 100644 lib/term_ui/backend/state.ex delete mode 100644 lib/term_ui/capabilities.ex delete mode 100644 lib/term_ui/capabilities/fallbacks.ex rename lib/term_ui/{renderer => }/cell.ex (90%) create mode 100644 lib/term_ui/clipboard/operation.ex delete mode 100644 lib/term_ui/clipboard/selection.ex delete mode 100644 lib/term_ui/command/executor.ex delete mode 100644 lib/term_ui/component.ex delete mode 100644 lib/term_ui/component/helpers.ex delete mode 100644 lib/term_ui/component/introspection.ex delete mode 100644 lib/term_ui/component/render_node.ex delete mode 100644 lib/term_ui/component/state_persistence.ex delete mode 100644 lib/term_ui/component_registry.ex delete mode 100644 lib/term_ui/component_server.ex delete mode 100644 lib/term_ui/component_supervisor.ex delete mode 100644 lib/term_ui/config.ex delete mode 100644 lib/term_ui/container.ex rename lib/term_ui/{renderer => }/cursor_optimizer.ex (90%) delete mode 100644 lib/term_ui/dev/dev_mode.ex delete mode 100644 lib/term_ui/dev/hot_reload.ex delete mode 100644 lib/term_ui/dev/perf_monitor.ex delete mode 100644 lib/term_ui/dev/state_inspector.ex delete mode 100644 lib/term_ui/dev/ui_inspector.ex rename lib/term_ui/{renderer => }/display_width.ex (99%) delete mode 100644 lib/term_ui/error.ex delete mode 100644 lib/term_ui/event/propagation.ex delete mode 100644 lib/term_ui/event/transformation.ex delete mode 100644 lib/term_ui/event_queue.ex delete mode 100644 lib/term_ui/event_router.ex delete mode 100644 lib/term_ui/focus.ex delete mode 100644 lib/term_ui/focus/indicator.ex delete mode 100644 lib/term_ui/focus/traversal.ex delete mode 100644 lib/term_ui/focus_manager.ex create mode 100644 lib/term_ui/frame.ex delete mode 100644 lib/term_ui/helpers/border_helper.ex delete mode 100644 lib/term_ui/helpers/cursor_helper.ex delete mode 100644 lib/term_ui/input.ex delete mode 100644 lib/term_ui/input/line_reader.ex delete mode 100644 lib/term_ui/input/raw.ex delete mode 100644 lib/term_ui/input/selector.ex delete mode 100644 lib/term_ui/input/tty.ex delete mode 100644 lib/term_ui/input/tty_server.ex delete mode 100644 lib/term_ui/layout/alignment.ex delete mode 100644 lib/term_ui/layout/cache.ex delete mode 100644 lib/term_ui/layout/constraint.ex delete mode 100644 lib/term_ui/layout/solver.ex delete mode 100644 lib/term_ui/message.ex delete mode 100644 lib/term_ui/message_queue.ex delete mode 100644 lib/term_ui/mouse/router.ex delete mode 100644 lib/term_ui/mouse/tracker.ex delete mode 100644 lib/term_ui/persistent_terms.ex delete mode 100644 lib/term_ui/platform.ex delete mode 100644 lib/term_ui/platform/unix.ex delete mode 100644 lib/term_ui/platform/windows.ex delete mode 100644 lib/term_ui/renderer/buffer.ex delete mode 100644 lib/term_ui/renderer/buffer_manager.ex delete mode 100644 lib/term_ui/renderer/diff.ex delete mode 100644 lib/term_ui/renderer/framerate_limiter.ex delete mode 100644 lib/term_ui/renderer/sequence_buffer.ex delete mode 100644 lib/term_ui/renderer/style.ex delete mode 100644 lib/term_ui/runtime/node_renderer.ex delete mode 100644 lib/term_ui/runtime/state.ex delete mode 100644 lib/term_ui/sanitize.ex create mode 100644 lib/term_ui/selection.ex delete mode 100644 lib/term_ui/sgr.ex delete mode 100644 lib/term_ui/shortcut.ex delete mode 100644 lib/term_ui/spatial_index.ex delete mode 100644 lib/term_ui/stateful_component.ex delete mode 100644 lib/term_ui/terminal.ex delete mode 100644 lib/term_ui/terminal/input_reader.ex delete mode 100644 lib/term_ui/terminal/state.ex delete mode 100644 lib/term_ui/test/assertions.ex delete mode 100644 lib/term_ui/test/component_harness.ex delete mode 100644 lib/term_ui/test/event_simulator.ex delete mode 100644 lib/term_ui/test/test_renderer.ex delete mode 100644 lib/term_ui/theme.ex delete mode 100644 lib/term_ui/view_cache.ex create mode 100644 lib/term_ui/widget.ex create mode 100644 lib/term_ui/widget/alert_dialog.ex create mode 100644 lib/term_ui/widget/bar_chart.ex create mode 100644 lib/term_ui/widget/canvas.ex create mode 100644 lib/term_ui/widget/chart_helpers.ex create mode 100644 lib/term_ui/widget/cluster_dashboard.ex create mode 100644 lib/term_ui/widget/command_palette.ex create mode 100644 lib/term_ui/widget/context_menu.ex create mode 100644 lib/term_ui/widget/dialog.ex create mode 100644 lib/term_ui/widget/diff_viewer.ex create mode 100644 lib/term_ui/widget/form_builder.ex create mode 100644 lib/term_ui/widget/gauge.ex create mode 100644 lib/term_ui/widget/helpers.ex create mode 100644 lib/term_ui/widget/line_chart.ex create mode 100644 lib/term_ui/widget/line_input.ex create mode 100644 lib/term_ui/widget/log_viewer.ex create mode 100644 lib/term_ui/widget/markdown_viewer.ex create mode 100644 lib/term_ui/widget/menu.ex create mode 100644 lib/term_ui/widget/process_monitor.ex create mode 100644 lib/term_ui/widget/scroll_bar.ex create mode 100644 lib/term_ui/widget/sparkline.ex create mode 100644 lib/term_ui/widget/split_pane.ex create mode 100644 lib/term_ui/widget/stream.ex create mode 100644 lib/term_ui/widget/stream_widget.ex create mode 100644 lib/term_ui/widget/supervision_tree.ex create mode 100644 lib/term_ui/widget/supervision_tree_viewer.ex create mode 100644 lib/term_ui/widget/table.ex create mode 100644 lib/term_ui/widget/table/column.ex create mode 100644 lib/term_ui/widget/tabs.ex create mode 100644 lib/term_ui/widget/text_area.ex create mode 100644 lib/term_ui/widget/text_input/line.ex create mode 100644 lib/term_ui/widget/toast.ex create mode 100644 lib/term_ui/widget/tree_view.ex create mode 100644 lib/term_ui/widget/viewport.ex delete mode 100644 lib/term_ui/widgets/alert_dialog.ex delete mode 100644 lib/term_ui/widgets/bar_chart.ex delete mode 100644 lib/term_ui/widgets/canvas.ex delete mode 100644 lib/term_ui/widgets/cluster_dashboard.ex delete mode 100644 lib/term_ui/widgets/command_palette.ex delete mode 100644 lib/term_ui/widgets/context_menu.ex delete mode 100644 lib/term_ui/widgets/context_menu/behavior.ex delete mode 100644 lib/term_ui/widgets/context_menu/factory.ex delete mode 100644 lib/term_ui/widgets/context_menu/inline.ex delete mode 100644 lib/term_ui/widgets/dialog.ex delete mode 100644 lib/term_ui/widgets/form_builder.ex delete mode 100644 lib/term_ui/widgets/gauge.ex delete mode 100644 lib/term_ui/widgets/line_chart.ex delete mode 100644 lib/term_ui/widgets/log_viewer.ex delete mode 100644 lib/term_ui/widgets/markdown_viewer.ex delete mode 100644 lib/term_ui/widgets/menu.ex delete mode 100644 lib/term_ui/widgets/process_monitor.ex delete mode 100644 lib/term_ui/widgets/scroll_bar.ex delete mode 100644 lib/term_ui/widgets/sparkline.ex delete mode 100644 lib/term_ui/widgets/split_pane.ex delete mode 100644 lib/term_ui/widgets/stream_widget.ex delete mode 100644 lib/term_ui/widgets/stream_widget/consumer.ex delete mode 100644 lib/term_ui/widgets/supervision_tree_viewer.ex delete mode 100644 lib/term_ui/widgets/table.ex delete mode 100644 lib/term_ui/widgets/table/column.ex delete mode 100644 lib/term_ui/widgets/tabs.ex delete mode 100644 lib/term_ui/widgets/text_input.ex delete mode 100644 lib/term_ui/widgets/text_input/line.ex delete mode 100644 lib/term_ui/widgets/toast.ex delete mode 100644 lib/term_ui/widgets/tree_view.ex delete mode 100644 lib/term_ui/widgets/viewport.ex delete mode 100644 lib/term_ui/widgets/visualization_helper.ex delete mode 100644 lib/term_ui/widgets/widget_helpers.ex delete mode 100644 test/docs/widget_compatibility_test.exs delete mode 100644 test/integration/backend_selection_test.exs delete mode 100644 test/integration/capability_accuracy_test.exs delete mode 100644 test/integration/cross_platform_test.exs delete mode 100644 test/integration/input_abstraction_test.exs delete mode 100644 test/integration/keyboard_navigation_integration_test.exs delete mode 100644 test/integration/mouse_fallback_integration_test.exs delete mode 100644 test/integration/multi_renderer_test.exs delete mode 100644 test/integration/round_trip_test.exs create mode 100644 test/integration/runtime_contract_test.exs delete mode 100644 test/integration/terminal_lifecycle_test.exs delete mode 100644 test/integration/visual_degradation_integration_test.exs delete mode 100644 test/manual/linux.md delete mode 100644 test/manual/mac.md delete mode 100644 test/manual/windows.md delete mode 100644 test/manual/wsl.md delete mode 100644 test/support/context_menu_test_helpers.ex create mode 100644 test/support/deterministic_backend.ex delete mode 100644 test/support/integration_helpers.ex delete mode 100644 test/support/runtime_test_case.ex delete mode 100644 test/support/test_components.ex delete mode 100644 test/support/test_factories.ex delete mode 100644 test/term_ui/app_test.exs delete mode 100644 test/term_ui/backend/config_test.exs create mode 100644 test/term_ui/backend/frame_boundary_test.exs create mode 100644 test/term_ui/backend/manager_test.exs delete mode 100644 test/term_ui/backend/raw_integration_test.exs delete mode 100644 test/term_ui/backend/raw_test.exs create mode 100644 test/term_ui/backend/renderer_test.exs delete mode 100644 test/term_ui/backend/ssh_test.exs delete mode 100644 test/term_ui/backend/state_test.exs delete mode 100644 test/term_ui/backend/tty_test.exs delete mode 100644 test/term_ui/backend_test.exs delete mode 100644 test/term_ui/capabilities/fallbacks_test.exs delete mode 100644 test/term_ui/capabilities_test.exs delete mode 100644 test/term_ui/command_test.exs delete mode 100644 test/term_ui/component/helpers_test.exs delete mode 100644 test/term_ui/component/introspection_test.exs delete mode 100644 test/term_ui/component/render_node_test.exs delete mode 100644 test/term_ui/component/state_persistence_test.exs delete mode 100644 test/term_ui/component_registry_test.exs delete mode 100644 test/term_ui/component_server_test.exs delete mode 100644 test/term_ui/component_supervisor_test.exs delete mode 100644 test/term_ui/component_test.exs delete mode 100644 test/term_ui/config_test.exs delete mode 100644 test/term_ui/container_test.exs delete mode 100644 test/term_ui/dev/dev_mode_test.exs delete mode 100644 test/term_ui/dev/hot_reload_test.exs delete mode 100644 test/term_ui/dev/perf_monitor_test.exs delete mode 100644 test/term_ui/dev/state_inspector_test.exs delete mode 100644 test/term_ui/dev/ui_inspector_test.exs delete mode 100644 test/term_ui/elm_test.exs delete mode 100644 test/term_ui/error_test.exs delete mode 100644 test/term_ui/event/propagation_test.exs delete mode 100644 test/term_ui/event/transformation_test.exs delete mode 100644 test/term_ui/event_queue_test.exs delete mode 100644 test/term_ui/event_router_test.exs delete mode 100644 test/term_ui/event_test.exs delete mode 100644 test/term_ui/focus/indicator_test.exs delete mode 100644 test/term_ui/focus/traversal_test.exs delete mode 100644 test/term_ui/focus_manager_test.exs delete mode 100644 test/term_ui/focus_test.exs create mode 100644 test/term_ui/frame_contract_test.exs delete mode 100644 test/term_ui/helpers/border_helper_test.exs delete mode 100644 test/term_ui/helpers/cursor_helper_test.exs delete mode 100644 test/term_ui/input/line_reader_test.exs delete mode 100644 test/term_ui/input/raw_test.exs delete mode 100644 test/term_ui/input/selector_test.exs delete mode 100644 test/term_ui/input/tty_test.exs delete mode 100644 test/term_ui/input_test.exs delete mode 100644 test/term_ui/integration/advanced_widgets_test.exs delete mode 100644 test/term_ui/integration/component_hierarchy_test.exs delete mode 100644 test/term_ui/integration/cross_mode_test.exs delete mode 100644 test/term_ui/integration/dashboard_test.exs delete mode 100644 test/term_ui/integration/dev_workflow_test.exs delete mode 100644 test/term_ui/integration/end_to_end_test.exs delete mode 100644 test/term_ui/integration/event_flow_test.exs delete mode 100644 test/term_ui/integration/event_system_test.exs delete mode 100644 test/term_ui/integration/fault_tolerance_test.exs delete mode 100644 test/term_ui/integration/focus_integration_test.exs delete mode 100644 test/term_ui/integration/iex_lifecycle_test.exs delete mode 100644 test/term_ui/integration/multi_component_test.exs delete mode 100644 test/term_ui/integration/property_test.exs delete mode 100644 test/term_ui/integration/testing_framework_test.exs delete mode 100644 test/term_ui/layout/alignment_test.exs delete mode 100644 test/term_ui/layout/cache_test.exs delete mode 100644 test/term_ui/layout/constraint_test.exs delete mode 100644 test/term_ui/layout/integration_test.exs delete mode 100644 test/term_ui/layout/solver_test.exs delete mode 100644 test/term_ui/message_queue_test.exs delete mode 100644 test/term_ui/message_test.exs delete mode 100644 test/term_ui/performance_test.exs delete mode 100644 test/term_ui/persistent_terms_test.exs delete mode 100644 test/term_ui/platform_test.exs create mode 100644 test/term_ui/public_contract_test.exs delete mode 100644 test/term_ui/renderer/buffer_manager_test.exs delete mode 100644 test/term_ui/renderer/buffer_test.exs delete mode 100644 test/term_ui/renderer/diff_test.exs delete mode 100644 test/term_ui/renderer/framerate_limiter_test.exs delete mode 100644 test/term_ui/renderer/integration_test.exs delete mode 100644 test/term_ui/renderer/sequence_buffer_test.exs delete mode 100644 test/term_ui/renderer/style_test.exs delete mode 100644 test/term_ui/runtime/node_renderer_test.exs delete mode 100644 test/term_ui/runtime/resize_test.exs delete mode 100644 test/term_ui/runtime/shutdown_test.exs delete mode 100644 test/term_ui/runtime_test.exs delete mode 100644 test/term_ui/sanitize_test.exs create mode 100644 test/term_ui/selection_test.exs delete mode 100644 test/term_ui/shortcut_test.exs create mode 100644 test/term_ui/source_conventions_test.exs delete mode 100644 test/term_ui/spatial_index_test.exs delete mode 100644 test/term_ui/stateful_component_test.exs delete mode 100644 test/term_ui/style_integration_test.exs delete mode 100644 test/term_ui/terminal/input_reader_test.exs delete mode 100644 test/term_ui/terminal/mouse_test.exs delete mode 100644 test/term_ui/terminal/raw_mode_test.exs delete mode 100644 test/term_ui/terminal/state_test.exs delete mode 100644 test/term_ui/terminal_test.exs delete mode 100644 test/term_ui/test/assertions_test.exs delete mode 100644 test/term_ui/test/component_harness_test.exs delete mode 100644 test/term_ui/test/event_simulator_test.exs delete mode 100644 test/term_ui/test/test_renderer_test.exs delete mode 100644 test/term_ui/theme_integration_test.exs delete mode 100644 test/term_ui/theme_test.exs delete mode 100644 test/term_ui/view_cache_test.exs delete mode 100644 test/term_ui/widget/block_test.exs delete mode 100644 test/term_ui/widget/button_test.exs create mode 100644 test/term_ui/widget/catalog_test.exs create mode 100644 test/term_ui/widget/diff_viewer_test.exs delete mode 100644 test/term_ui/widget/label_test.exs delete mode 100644 test/term_ui/widget/list_test.exs create mode 100644 test/term_ui/widget/markdown_viewer_test.exs create mode 100644 test/term_ui/widget/mouse_interaction_test.exs delete mode 100644 test/term_ui/widget/pick_list_test.exs delete mode 100644 test/term_ui/widget/progress_test.exs create mode 100644 test/term_ui/widget/text_area_test.exs delete mode 100644 test/term_ui/widgets/alert_dialog_test.exs delete mode 100644 test/term_ui/widgets/ascii_fallback_test.exs delete mode 100644 test/term_ui/widgets/bar_chart_test.exs delete mode 100644 test/term_ui/widgets/canvas_test.exs delete mode 100644 test/term_ui/widgets/cluster_dashboard_test.exs delete mode 100644 test/term_ui/widgets/command_palette_test.exs delete mode 100644 test/term_ui/widgets/context_menu/behavior_test.exs delete mode 100644 test/term_ui/widgets/context_menu/factory_test.exs delete mode 100644 test/term_ui/widgets/context_menu/inline_test.exs delete mode 100644 test/term_ui/widgets/context_menu_test.exs delete mode 100644 test/term_ui/widgets/dialog_test.exs delete mode 100644 test/term_ui/widgets/form_builder_test.exs delete mode 100644 test/term_ui/widgets/gauge_test.exs delete mode 100644 test/term_ui/widgets/line_chart_test.exs delete mode 100644 test/term_ui/widgets/log_viewer_test.exs delete mode 100644 test/term_ui/widgets/menu_test.exs delete mode 100644 test/term_ui/widgets/process_monitor_test.exs delete mode 100644 test/term_ui/widgets/scroll_bar_test.exs delete mode 100644 test/term_ui/widgets/sparkline_test.exs delete mode 100644 test/term_ui/widgets/split_pane_test.exs delete mode 100644 test/term_ui/widgets/stream_widget/consumer_test.exs delete mode 100644 test/term_ui/widgets/stream_widget_test.exs delete mode 100644 test/term_ui/widgets/supervision_tree_viewer_test.exs delete mode 100644 test/term_ui/widgets/table/column_test.exs delete mode 100644 test/term_ui/widgets/table_test.exs delete mode 100644 test/term_ui/widgets/tabs_test.exs delete mode 100644 test/term_ui/widgets/text_input/line_test.exs delete mode 100644 test/term_ui/widgets/text_input_test.exs delete mode 100644 test/term_ui/widgets/toast_test.exs delete mode 100644 test/term_ui/widgets/tree_view_test.exs delete mode 100644 test/term_ui/widgets/viewport_test.exs delete mode 100644 test/term_ui/widgets/visualization_helper_test.exs delete mode 100644 test/term_ui_test.exs diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs new file mode 100644 index 00000000..1e4163db --- /dev/null +++ b/.dialyzer_ignore.exs @@ -0,0 +1,4 @@ +[ + # MDEx 0.13.5 refers to the type of its optional Lumis dependency. + {"lib/mdex/document.ex", "Unknown type: Lumis.options/0."} +] diff --git a/.formatter.exs b/.formatter.exs index d2cda26e..7648c757 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,4 +1,9 @@ # Used by "mix format" [ - inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] + inputs: [ + "{mix,.formatter}.exs", + "{config,lib,test}/**/*.{ex,exs}", + "examples/iex_counter/mix.exs", + "examples/iex_counter/**/*.{ex,exs}" + ] ] diff --git a/CHANGELOG.md b/CHANGELOG.md index b7e59041..9a0a84b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Restored the general widget feature set as parent-owned pure widgets under + `TermUI.Widget`. +- Added an MDEx Markdown renderer and scrollable Markdown viewer. +- Added unified and side-by-side terminal diff views. +- Added frame overlay composition for widget frames. +- Added Zoi schemas as the source for all production struct fields and defaults. +- Added public schemas for cells, styles, frames, events, commands, and table columns. +- Added bounded clipboard commands that run through the serialized backend owner. +- Added pure Unicode grapheme selection for single-line and multiline text input. +- Added pure mouse regions, local-coordinate routing, hover state, and drag tracking. +- Added local mouse behavior for interactive widgets, scrollbars, and split panes. + +### Changed + +- Process, stream, supervision, and cluster widgets now render data snapshots + supplied by the parent application. They do not own effect processes. + +## [1.0.0-rc] - 2026-08-19 + +### Changed + +- Replaced the component process system with one Elm application runtime. +- Made `TermUI.Frame` the only application render value. +- Moved terminal lifecycle, input, output, size, cursor, and capabilities into backends. +- Split printable text from named and modified key events. +- Replaced effect tuples with `TermUI.Command` data. +- Replaced process widgets with parent-owned pure widgets. + +### Removed + +- Removed component servers, registries, supervisors, event routers, and focus managers. +- Removed legacy input handlers, render nodes, renderer buffers, and duplicate widget namespaces. +- Removed the SSH backend until it can own a complete terminal session lifecycle. + +See `guides/migration-1.0.md` for the replacement map. + ## [0.2.0] - 2024-12-01 ### Added @@ -95,6 +133,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Developer guides (architecture, runtime, rendering, events, buffers, terminal, creating widgets) - Widget examples with READMEs -[Unreleased]: https://github.com/pcharbon70/term_ui/compare/v0.2.0...HEAD -[0.2.0]: https://github.com/pcharbon70/term_ui/compare/v0.1.0...v0.2.0 -[0.1.0]: https://github.com/pcharbon70/term_ui/releases/tag/v0.1.0 +[Unreleased]: https://github.com/mikehostetler/term_ui/compare/v1.0.0-rc...HEAD +[1.0.0-rc]: https://github.com/mikehostetler/term_ui/compare/v0.2.0...v1.0.0-rc +[0.2.0]: https://github.com/mikehostetler/term_ui/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/mikehostetler/term_ui/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md index 63da7178..c24a63f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,23 +4,23 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -TermUI is a direct-mode Terminal UI framework for Elixir/BEAM, currently in the research and design phase. The goal is to build a world-class TUI framework that leverages BEAM's unique strengths (fault tolerance, actor model, hot code reloading, distribution) while adopting proven patterns from modern TUI frameworks like BubbleTea (Go) and Ratatui (Rust). +TermUI is a small Elm-style terminal runtime for Elixir and the BEAM. ## Target Architecture -The framework uses The Elm Architecture adapted for OTP with three abstraction layers: +The runtime has three clear boundaries: -1. **Port layer** - Low-level terminal interface (raw mode, escape sequences, capability detection) -2. **Renderer layer** - Virtual screen buffer with differential updates (ETS-based double buffering) -3. **Widget layer** - OTP-based component system with supervision +1. **Application** - One runtime process owns application state and serializes updates. +2. **Frame** - Pure views return one canonical `TermUI.Frame`. +3. **Backend** - One backend owner serializes input, output, size, capabilities, and cleanup. ### Key Design Decisions - **OTP 28+ only** - Uses native raw mode via `shell.start_interactive({:noshell, :raw})` -- **Process-per-component** for interactive widgets, shared state for static display elements -- **Framerate-limited rendering** (60 FPS default) with intelligent diffing -- **Cassowary constraint solver** for layouts with LRU caching -- **Commands pattern** for side effects (async operations return messages to update loop) +- **Pure widgets** with state owned by the parent application +- **Coalesced frame scheduling** with one final meaningful render +- **One normalized event model** for keys, text, paste, mouse, resize, and focus +- **Data commands** for messages, timers, asynchronous work, and shutdown ### Platform Targets @@ -30,21 +30,16 @@ The framework uses The Elm Architecture adapted for OTP with three abstraction l ## Project Status -Currently in research phase. The `notes/research/state_of_tui.md` contains comprehensive analysis of: -- Historical terminal architecture (terminfo, curses, VT100) -- Modern TUI frameworks (BubbleTea, Ratatui, Textual, FTXUI, etc.) -- BEAM-specific patterns (GenServer, Supervisors, GenStage, Ports vs NIFs) -- Direct mode programming requirements -- Proposed architecture and implementation roadmap +The `1.0.0-rc` design is implemented. Files under `notes/` are historical research and are not current architecture guidance. ## Development Notes -When implementation begins, follow these patterns: +Follow these patterns: -- Use **GenServer** for stateful widgets with clear message-based APIs -- Use **Supervisors** to mirror UI component hierarchies for fault isolation -- Prefer **Ports over NIFs** for terminal I/O (crash isolation) -- Use **ETS tables** for render buffers (`:screen_current`, `:screen_previous`) -- Implement **cursor optimization** (compare cost of absolute vs relative positioning) -- Support graceful degradation for terminal features (true color → 256 → 16 → mono) -- IMPORTANT you must NEVER mention Claude or any AI assistant in your commit messages! \ No newline at end of file +- Keep application and widget transitions pure. +- Keep terminal implementation logic in backends. +- Return `TermUI.Frame` directly from application views. +- Keep backend callback state under one serialized owner. +- Use `TermUI.Command` values for effects. +- Preserve graceful degradation for terminal features. +- IMPORTANT you must NEVER mention Claude or any AI assistant in your commit messages! diff --git a/README.md b/README.md index 0fa25910..bdce7963 100644 --- a/README.md +++ b/README.md @@ -1,238 +1,153 @@ # TermUI -[![Hex.pm](https://img.shields.io/hexpm/v/term_ui.svg)](https://hex.pm/packages/term_ui) -[![Docs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/term_ui) -[![License](https://img.shields.io/hexpm/l/term_ui.svg)](https://github.com/pcharbon70/term_ui/blob/main/LICENSE) +TermUI is a small terminal runtime for Elixir and the BEAM. It uses the Elm +architecture and has one render value: `TermUI.Frame`. -A direct-mode Terminal UI framework for Elixir/BEAM, inspired by [BubbleTea](https://github.com/charmbracelet/bubbletea) (Go) and [Ratatui](https://github.com/ratatui-org/ratatui) (Rust). +The runtime owns application state, command execution, frame timing, and +shutdown. A backend owns terminal setup, input, output, size, cursor state, +capabilities, and cleanup. -TermUI leverages BEAM's unique strengths—fault tolerance, actor model, hot code reloading—to build robust terminal applications using The Elm Architecture. +## Install -

- Blue Theme -    - Yellow Theme -

+Add the release candidate to `mix.exs`: -## Features +```elixir +def deps do + [ + {:term_ui, "~> 1.0.0-rc"} + ] +end +``` -- **Elm Architecture** - Predictable state management with `init/update/view` -- **Rich Widget Library** - Gauges, tables, menus, charts, dialogs, and more -- **Efficient Rendering** - Double-buffered differential updates at 60 FPS -- **Themable** - True color RGB support (16 million colors) -- **Cross-Platform** - Linux, macOS, Windows 10+ terminal support -- **OTP Integration** - Supervision trees, fault tolerance, hot code reload -- **IEx Compatible** - Run TUI applications directly in IEx for interactive development +TermUI uses MDEx to parse Markdown for terminal display. It uses Zoi schemas +as the source for production struct fields and defaults. -## IEx Compatibility +## Application contract -TermUI applications work directly in IEx with no code changes. This is perfect for: -- Interactive debugging and development -- Admin tools and dashboards in production IEx sessions -- Prototyping and testing TUI interfaces +```elixir +defmodule Counter do + use TermUI.Elm -### Running in IEx + alias TermUI.{Command, Event, Frame, Style} -```elixir -# In your IEx session -iex> TermUI.Runtime.run(root: MyApp.Counter) -# Use arrow keys, press Q to quit, returns to IEx prompt -``` + def init(opts) do + %{count: 0, dimensions: Keyword.fetch!(opts, :dimensions)} + end -### How It Works + def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :increment} + def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :decrement} + def event_to_msg(%Event.Text{text: text}, _state) when text in ["q", "Q"], + do: {:msg, :quit} -TermUI uses Erlang's `:io.get_chars/2` for input instead of Elixir's `IO` module wrapper. This bypasses IEx's input interception, allowing TUI applications to receive keyboard input directly. + def event_to_msg(%Event.Resize{width: width, height: height}, _state), + do: {:msg, {:resize, width, height}} -### Detection and Configuration + def event_to_msg(_event, _state), do: :ignore -You can detect if your application is running in IEx: + def update(:increment, state), do: %{state | count: state.count + 1} + def update(:decrement, state), do: %{state | count: state.count - 1} + def update(:quit, state), do: {state, [Command.shutdown()]} + def update({:resize, width, height}, state), do: %{state | dimensions: {width, height}} -```elixir -iex> TermUI.iex_mode?() -true + def view(%{count: count, dimensions: {width, height}}) do + heading = Style.new(fg: :cyan, attrs: [:bold]) -iex> TermUI.running_mode() -:iex + Frame.from_rows( + [[{"Counter", heading}], "", "Count: #{count}", "", "Up/Down: change Q: quit"], + width, + height + ) + end +end + +TermUI.run(Counter) ``` -Force IEx-compatible mode via configuration: +`init/1` receives `:dimensions` as `{columns, rows}`. Printable input arrives +as `Event.Text`. Named and modified keys arrive as `Event.Key`. Paste, mouse, +resize, and focus input have separate event types. -```elixir -# config/config.exs -config :term_ui, - iex_compatible: true -``` +`view/1` must return one complete `TermUI.Frame`. A frame contains its size, +cells, and optional cursor. The cursor is `{column, row}` and is one-based. -Or via environment variable: +## Effects -```bash -export TERM_UI_IEX_MODE=true -``` +`update/2` returns new state or `{new_state, commands}`. Commands are data: + +- `Command.message/1` queues an application message. +- `Command.send/2` sends data to another process. +- `Command.timer/2` queues a later application message. +- `Command.async/2` runs work outside the runtime process. The function can + return any term. The runtime wraps a normal return as `{:ok, value}` and a + raised, thrown, or exited function as `{:error, reason}`. Its mapper always + receives this one runtime-produced result. For example, a function return of + `{:ok, value}` reaches the mapper as `{:ok, {:ok, value}}`. +- `TermUI.Clipboard.copy/2` and `TermUI.Clipboard.clear/1` request bounded, + serialized OSC 52 clipboard output. +- `Command.shutdown/1` requests one final render and cleanup. + +## Pure widgets -### Important Notes - -- **Arrow keys work immediately** - No need to press Enter for navigation -- **All keyboard shortcuts work** - Including Tab, Enter, Escape, function keys -- **Clean shutdown** - Terminal state is restored when the app exits -- **IEx remains responsive** - The TUI app can be exited to return to IEx prompt - -## Widgets - -| Widget | Description | -|--------|-------------| -| **Gauge** | Progress bar with color zones | -| **Sparkline** | Compact inline trend graph | -| **Table** | Scrollable data table with selection and sorting | -| **Menu** | Hierarchical menu with submenus | -| **TextInput** | Single-line and multi-line text input | -| **Dialog** | Modal dialog with buttons | -| **PickList** | Modal selection with type-ahead filtering | -| **Tabs** | Tabbed interface for switchable panels | -| **AlertDialog** | Modal dialog for confirmations with standard button configurations | -| **ContextMenu** | Right-click context menu with keyboard and mouse support | -| **Toast** | Auto-dismissing notifications with stacking | -| **Viewport** | Scrollable view with keyboard and mouse support | -| **SplitPane** | Resizable multi-pane layouts for IDE-style interfaces | -| **TreeView** | Hierarchical data display with expand/collapse | -| **FormBuilder** | Structured forms with validation and multiple field types | -| **CommandPalette** | VS Code-style command discovery with fuzzy search | -| **BarChart** | Horizontal/vertical bar charts for categorical data | -| **LineChart** | Line charts using Braille characters for sub-character resolution | -| **Canvas** | Direct drawing surface for custom visualizations | -| **LogViewer** | High-performance log viewer with virtual scrolling and filtering | -| **StreamWidget** | GenStage-integrated widget with backpressure support | -| **ProcessMonitor** | Live BEAM process inspection with sorting and filtering | -| **SupervisionTreeViewer** | OTP supervision hierarchy visualization | -| **ClusterDashboard** | Distributed Erlang cluster monitoring | - -## Installation - -Add `term_ui` to your dependencies in `mix.exs`: +A widget is not a process. The parent application owns widget state. It sends +events to the widget and composes the returned frame into its application frame. ```elixir -def deps do - [ - {:term_ui, github: "mikehostetler/term_ui"} - ] -end +list = TermUI.Widget.List.init(items: ["one", "two"]) +{list, messages} = TermUI.Widget.List.update(event, list) +list_frame = TermUI.Widget.List.view(list, {30, 10}) +frame = TermUI.Frame.overlay(frame, list_frame, 2, 3) ``` -The core runtime has no required package dependencies. Add `:gen_stage` only -when you use the StreamWidget GenStage adapter. Add `:mdex`, `:makeup`, and -`:makeup_elixir` only when you need full Markdown rendering. Without them, the -Markdown viewer uses plain text. These features stay in the same TermUI package. +Use `TermUI.Mouse` to route global events to local widget coordinates. Use +`TermUI.Widget.mouse/4` to apply the local event. `TextInput` and `TextArea` +support keyboard and mouse selection. Copy and cut return `{:copy, text}` to +the parent, which can return a `TermUI.Clipboard.copy/2` command. -## Quick Start +The supplied pure widgets include: -```elixir -defmodule Counter do - use TermUI.Elm +- Text: label, single-line input, validated line input, multiline text area, + Markdown viewer, log viewer, stream view, and diff viewer. +- Selection: button, list, pick list, menu, context menu, command palette, + tabs, table, tree view, and forms. +- Layout: block, dialog, alert dialog, split pane, viewport, scrollbar, and toast. +- Data views: progress, gauge, sparkline, bar chart, line chart, canvas, + process snapshots, supervision trees, and cluster snapshots. - alias TermUI.Event - alias TermUI.Renderer.Style +System views accept data snapshots from the parent. They do not start polling +processes or perform RPC. - def init(_opts), do: %{count: 0} +## Markdown and diffs - def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :increment} - def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :decrement} - def event_to_msg(%Event.Key{key: "q"}, _state), do: {:msg, :quit} - def event_to_msg(_, _), do: :ignore - - def update(:increment, state), do: {%{state | count: state.count + 1}, []} - def update(:decrement, state), do: {%{state | count: state.count - 1}, []} - def update(:quit, state), do: {state, [:quit]} - - def view(state) do - stack(:vertical, [ - text("Counter Example", Style.new(fg: :cyan, attrs: [:bold])), - text("", nil), - text("Count: #{state.count}", nil), - text("", nil), - text("↑/↓ to change, Q to quit", Style.new(fg: :bright_black)) - ]) - end -end +`TermUI.Widget.MarkdownViewer` uses MDEx and supports CommonMark headings, +emphasis, links, quotes, lists, tasks, code blocks, rules, and tables. -# Run the application -TermUI.Runtime.run(root: Counter) -``` +`TermUI.Widget.DiffViewer` accepts `:before` and `:after` text or a +`:unified_diff`. It supports unified and side-by-side terminal views. -## Documentation - -### User Guides - -| Guide | Description | -|-------|-------------| -| [Overview](https://github.com/pcharbon70/term_ui/blob/main/guides/user/01-overview.md) | Introduction to TermUI concepts | -| [Getting Started](https://github.com/pcharbon70/term_ui/blob/main/guides/user/02-getting-started.md) | First steps and setup | -| [Elm Architecture](https://github.com/pcharbon70/term_ui/blob/main/guides/user/03-elm-architecture.md) | Understanding init/update/view | -| [Events](https://github.com/pcharbon70/term_ui/blob/main/guides/user/04-events.md) | Handling keyboard and mouse input | -| [Styling](https://github.com/pcharbon70/term_ui/blob/main/guides/user/05-styling.md) | Colors, attributes, and themes | -| [Layout](https://github.com/pcharbon70/term_ui/blob/main/guides/user/06-layout.md) | Arranging components on screen | -| [Widgets](https://github.com/pcharbon70/term_ui/blob/main/guides/user/07-widgets.md) | Using built-in widgets | -| [Terminal](https://github.com/pcharbon70/term_ui/blob/main/guides/user/08-terminal.md) | Terminal capabilities and modes | -| [Commands](https://github.com/pcharbon70/term_ui/blob/main/guides/user/09-commands.md) | Side effects and async operations | -| [Advanced Widgets](https://github.com/pcharbon70/term_ui/blob/main/guides/user/10-advanced-widgets.md) | Navigation, visualization, streaming, and BEAM introspection widgets | - -### Developer Guides - -| Guide | Description | -|-------|-------------| -| [Architecture Overview](https://github.com/pcharbon70/term_ui/blob/main/guides/developer/01-architecture-overview.md) | System layers and design | -| [Runtime Internals](https://github.com/pcharbon70/term_ui/blob/main/guides/developer/02-runtime-internals.md) | GenServer event loop and state | -| [Rendering Pipeline](https://github.com/pcharbon70/term_ui/blob/main/guides/developer/03-rendering-pipeline.md) | View to terminal output stages | -| [Event System](https://github.com/pcharbon70/term_ui/blob/main/guides/developer/04-event-system.md) | Input parsing and dispatch | -| [Buffer Management](https://github.com/pcharbon70/term_ui/blob/main/guides/developer/05-buffer-management.md) | ETS double buffering | -| [Terminal Layer](https://github.com/pcharbon70/term_ui/blob/main/guides/developer/06-terminal-layer.md) | Raw mode and ANSI sequences | -| [Elm Implementation](https://github.com/pcharbon70/term_ui/blob/main/guides/developer/07-elm-implementation.md) | Elm Architecture for OTP | -| [Creating Widgets](https://github.com/pcharbon70/term_ui/blob/main/guides/developer/08-creating-widgets.md) | How to build and contribute widgets | -| [Testing Framework](https://github.com/pcharbon70/term_ui/blob/main/guides/developer/09-testing-framework.md) | Component and widget testing | - -## Examples - -The `examples/` directory contains standalone applications demonstrating each widget: - -| Example | Description | -|---------|-------------| -| [alert_dialog](https://github.com/pcharbon70/term_ui/tree/main/examples/alert_dialog) | Confirmation dialogs with standard buttons | -| [bar_chart](https://github.com/pcharbon70/term_ui/tree/main/examples/bar_chart) | Horizontal and vertical bar charts | -| [canvas](https://github.com/pcharbon70/term_ui/tree/main/examples/canvas) | Free-form drawing with box/braille characters | -| [cluster_dashboard](https://github.com/pcharbon70/term_ui/tree/main/examples/cluster_dashboard) | Distributed Erlang cluster monitoring | -| [command_palette](https://github.com/pcharbon70/term_ui/tree/main/examples/command_palette) | VS Code-style command discovery | -| [context_menu](https://github.com/pcharbon70/term_ui/tree/main/examples/context_menu) | Right-click context menus | -| [dashboard](https://github.com/pcharbon70/term_ui/tree/main/examples/dashboard) | System monitoring dashboard with multiple widgets | -| [dialog](https://github.com/pcharbon70/term_ui/tree/main/examples/dialog) | Modal dialogs with buttons | -| [form_builder](https://github.com/pcharbon70/term_ui/tree/main/examples/form_builder) | Structured forms with validation | -| [gauge](https://github.com/pcharbon70/term_ui/tree/main/examples/gauge) | Progress bars and percentage indicators | -| [line_chart](https://github.com/pcharbon70/term_ui/tree/main/examples/line_chart) | Braille-based line charts | -| [log_viewer](https://github.com/pcharbon70/term_ui/tree/main/examples/log_viewer) | Real-time log display with filtering | -| [menu](https://github.com/pcharbon70/term_ui/tree/main/examples/menu) | Nested menus with keyboard navigation | -| [pick_list](https://github.com/pcharbon70/term_ui/tree/main/examples/pick_list) | Modal selection with type-ahead | -| [process_monitor](https://github.com/pcharbon70/term_ui/tree/main/examples/process_monitor) | Live BEAM process inspection | -| [sparkline](https://github.com/pcharbon70/term_ui/tree/main/examples/sparkline) | Inline data visualization | -| [split_pane](https://github.com/pcharbon70/term_ui/tree/main/examples/split_pane) | Resizable multi-pane layouts | -| [stream_widget](https://github.com/pcharbon70/term_ui/tree/main/examples/stream_widget) | Backpressure-aware data streaming | -| [supervision_tree_viewer](https://github.com/pcharbon70/term_ui/tree/main/examples/supervision_tree_viewer) | OTP supervision hierarchy | -| [table](https://github.com/pcharbon70/term_ui/tree/main/examples/table) | Scrollable data tables with selection | -| [tabs](https://github.com/pcharbon70/term_ui/tree/main/examples/tabs) | Tab-based navigation | -| [text_input](https://github.com/pcharbon70/term_ui/tree/main/examples/text_input) | Single and multi-line text input | -| [toast](https://github.com/pcharbon70/term_ui/tree/main/examples/toast) | Auto-dismissing notifications | -| [tree_view](https://github.com/pcharbon70/term_ui/tree/main/examples/tree_view) | Hierarchical data with expand/collapse | -| [viewport](https://github.com/pcharbon70/term_ui/tree/main/examples/viewport) | Scrollable content areas | - -```bash -# Run any example -cd examples/dashboard -mix deps.get -mix termui.run +## Backends + +Use `:auto`, `:raw`, or `:tty` with the `:backend` option. Tests can inject a +module that implements `TermUI.Backend`. + +```elixir +TermUI.start_link(Counter, backend: {MyTestBackend, owner: self()}) ``` -## Requirements +Raw mode needs OTP 28 or later. TTY mode is the fallback when raw mode is not +available. The SSH backend from the pre-1.0 design is not part of this release +candidate because its input was owned outside the backend contract. + +## Documents -- Elixir 1.15+ -- OTP 28+ (required for native raw terminal mode) -- Terminal with Unicode support +- [Architecture](guides/architecture.md) +- [Backend contract](guides/backend.md) +- [Pure widgets](guides/widgets.md) +- [Clipboard, selection, and mouse](guides/interaction.md) +- [Markdown and diff viewers](guides/markdown-and-diffs.md) +- [Removed and deferred features](guides/removed-and-deferred.md) +- [Migration to 1.0](guides/migration-1.0.md) +- [Counter example](https://github.com/mikehostetler/term_ui/tree/main/examples/iex_counter) ## License -MIT License - see [LICENSE](https://github.com/pcharbon70/term_ui/blob/main/LICENSE) for details. +TermUI uses the MIT License. The repository includes the license text. diff --git a/docs/phase-05/task-5.5.2-summary.md b/docs/phase-05/task-5.5.2-summary.md deleted file mode 100644 index b01ebf72..00000000 --- a/docs/phase-05/task-5.5.2-summary.md +++ /dev/null @@ -1,217 +0,0 @@ -# Task 5.5.2 Summary: CharacterSet Integration for ASCII Fallback - -## Task Overview - -Integrate the existing CharacterSet module into all TermUI widgets to enable graceful ASCII fallback for terminals that don't support Unicode characters. - -## Completion Status - -**✅ COMPLETE** - All 20 widgets successfully integrated with CharacterSet - -## Implementation Details - -### Widgets Integrated - -#### P0 Widgets (Critical - Box Drawing) -1. **Dialog** (66 tests) - Box-drawing characters for borders -2. **AlertDialog** (37 tests) - Box-drawing characters for borders -3. **Table** (28 tests) - Box-drawing for grid lines and borders -4. **TreeView** (22 tests) - Tree branch characters and expand/collapse indicators - -#### P1 Widgets (High Priority) -5. **Menu** (31 tests) - Submenu arrows and separators -6. **FormBuilder** (50 tests) - Group expand/collapse arrows -7. **SupervisionTreeViewer** (43 tests) - Status/type icons and tree indicators - -#### P2 Widgets (Medium Priority - Visualization & Interaction) -8. **Gauge** (24 tests) - Bar characters for progress visualization -9. **Sparkline** (24 tests) - 8-level bar characters for mini charts -10. **BarChart** (24 tests) - Bar characters for chart rendering -11. **ScrollBar** (31 tests) - Track and thumb characters -12. **Canvas** (30 tests) - Line and box-drawing primitives -13. **ContextMenu** (28 tests) - Separator lines -14. **TextInput** (58 tests) - Scroll indicator arrows -15. **ProcessMonitor** (44 tests) - Sort arrows and help text arrows -16. **SplitPane** (67 tests) - Divider characters -17. **Toast** (30 tests) - Box-drawing for notification borders -18. **Viewport** (29 tests) - Scrollbar characters - -#### P3 Widgets (Special Cases) -19. **ClusterDashboard** (41 tests) - Help text navigation arrows -20. **LineChart** (22 tests) - Axis box-drawing characters - -### Total Test Coverage - -- **19 widgets tested**: 688 tests passing -- **1 widget** (ClusterDashboard): Pre-existing test setup issues unrelated to changes -- **Overall impact**: All widgets now support ASCII fallback - -## Implementation Pattern - -Consistent 4-step pattern applied across all widgets: - -```elixir -# 1. Add CharacterSet alias -alias TermUI.CharacterSet - -# 2. Get charset in render function -chars = CharacterSet.current_charset() - -# 3. Replace hardcoded Unicode with charset lookups -# Before: "─" -# After: chars.h_line - -# 4. Update function signatures to pass charset through -defp render_border(state, width, chars) do - # Use chars.tl, chars.tr, chars.bl, chars.br, etc. -end -``` - -## Character Mappings Used - -| Category | Unicode | ASCII | CharacterSet Field | -|----------|---------|-------|-------------------| -| **Box Drawing** | | | | -| Horizontal line | `─` | `-` | `h_line` | -| Vertical line | `│` | `\|` | `v_line` | -| Top-left corner | `┌` | `+` | `tl` | -| Top-right corner | `┐` | `+` | `tr` | -| Bottom-left corner | `└` | `+` | `bl` | -| Bottom-right corner | `┘` | `+` | `br` | -| **Arrows** | | | | -| Up arrow | `↑` | `^` | `arrow_up` | -| Down arrow | `↓` | `v` | `arrow_down` | -| Left arrow | `←` | `<` | `arrow_left` | -| Right arrow | `→` | `>` | `arrow_right` | -| **Bar Characters** | | | | -| Full block | `█` | `#` | `bar_full` | -| Empty block | `░` | `.` | `bar_empty` | -| Bar levels (8) | `▁▂▃▄▅▆▇█` | `▏▎▍▌▋▊▉█` (5) | `bar_levels` | - -## Notable Implementations - -### Module Attribute Conversion - -Some widgets had module attributes converted to runtime functions: - -**SupervisionTreeViewer:** -```elixir -# Before: -@status_icons %{running: "○", restarting: "↻", ...} - -# After: -defp get_status_icons do - %{running: "o", restarting: "~", ...} -end -``` - -### Variable Shadowing Prevention - -**LineChart:** -```elixir -# Renamed inner loop variable to avoid shadowing charset -chars_row = # Was: chars - for x <- 0..(width - 1) do - pattern = get_cell_pattern(canvas, x, y) - <<@braille_base + pattern::utf8>> - end -``` - -### Bi-directional Arrows - -**TextInput scroll indicators:** -```elixir -# Unicode: "↕" (single bi-directional character) -# ASCII: "^v" (concatenated up + down arrows) -indicator = "#{chars.arrow_up}#{chars.arrow_down}" -``` - -### Test Updates - -**Sparkline tests** made charset-agnostic: -```elixir -# Before: assert result == "▁" -# After: -bars = Sparkline.bar_characters() -assert result == List.first(bars) -``` - -**Toast test** fixed to match implementation: -```elixir -# ToastManager.render returns list of overlays, not stack node -assert is_list(result) -assert length(result) == 2 -assert Enum.all?(result, fn overlay -> overlay.type == :overlay end) -``` - -## Git Commit History - -1. **P0 widgets** (4 widgets, 153 tests) - `1b103a8` -2. **P1 widgets** (3 widgets, 124 tests) - `fb5c0e1` -3. **P2 batch 1** (3 widgets, 72 tests) - `aa62b8c` -4. **P2 batch 2** (3 widgets, 89 tests) - `bf6a49d` -5. **P2 batch 3** (3 widgets, 169 tests) - `edcfe7e` -6. **P2 batch 4** (2 widgets, 59 tests) - `b1a8a0f` -7. **P3 widgets** (2 widgets, 63 tests) - `74ede29` - -## Benefits - -### 1. Terminal Compatibility -- Widgets now work correctly in ASCII-only terminals -- Graceful degradation for limited character sets -- No visual corruption from unsupported Unicode - -### 2. Consistent Implementation -- Single source of truth for character mappings -- Easy to add new character sets (e.g., different box-drawing styles) -- Centralized configuration through CharacterSet module - -### 3. Future Extensibility -- Foundation for theme-based character set selection -- Support for custom character sets per user preference -- Easy to add locale-specific characters - -## Testing - -All modified widgets maintain 100% test pass rate: -- No test regressions introduced -- Existing functionality preserved -- Character rendering logic validated through existing tests - -## Next Steps - -Task 5.5.2 is complete. Ready to proceed with: -- Task 5.5.3: Implement ASCII renderer backend (if applicable) -- Or continue with next phase of multi-renderer architecture - -## Files Modified - -### Widget Files (20) -- `lib/term_ui/widgets/alert_dialog.ex` -- `lib/term_ui/widgets/bar_chart.ex` -- `lib/term_ui/widgets/canvas.ex` -- `lib/term_ui/widgets/cluster_dashboard.ex` -- `lib/term_ui/widgets/context_menu.ex` -- `lib/term_ui/widgets/dialog.ex` -- `lib/term_ui/widgets/form_builder.ex` -- `lib/term_ui/widgets/gauge.ex` -- `lib/term_ui/widgets/line_chart.ex` -- `lib/term_ui/widgets/menu.ex` -- `lib/term_ui/widgets/process_monitor.ex` -- `lib/term_ui/widgets/scroll_bar.ex` -- `lib/term_ui/widgets/sparkline.ex` -- `lib/term_ui/widgets/split_pane.ex` -- `lib/term_ui/widgets/supervision_tree_viewer.ex` -- `lib/term_ui/widgets/table.ex` -- `lib/term_ui/widgets/text_input.ex` -- `lib/term_ui/widgets/toast.ex` -- `lib/term_ui/widgets/tree_view.ex` -- `lib/term_ui/widgets/viewport.ex` - -### Test Files (2) -- `test/term_ui/widgets/sparkline_test.exs` - Made charset-agnostic -- `test/term_ui/widgets/toast_test.exs` - Fixed to match implementation - -## Conclusion - -Task 5.5.2 successfully integrated CharacterSet into all TermUI widgets, enabling graceful ASCII fallback for terminals without Unicode support. The implementation was systematic, well-tested, and maintains backward compatibility while adding new functionality. diff --git a/docs/widget-compatibility.md b/docs/widget-compatibility.md deleted file mode 100644 index 66e4dbe8..00000000 --- a/docs/widget-compatibility.md +++ /dev/null @@ -1,359 +0,0 @@ -# Widget Compatibility Guide - -This document describes widget behavior across different terminal backends (Raw Mode and TTY Mode) and provides best practices for building compatible widgets. - -## Overview - -TermUI supports two terminal backends: - -- **Raw Mode**: Full terminal control with mouse support, 60 FPS rendering, and immediate key handling. Requires OTP 28+ with native raw mode support. -- **TTY Mode**: Compatible mode using standard I/O operations. Works on all systems but with limited features. - -Most widgets work identically in both modes because keyboard navigation (arrows, Tab, Enter) uses `IO.getn/2` which provides character-by-character input regardless of terminal mode. - -## Widget Compatibility Matrix - -| Widget | Raw Mode | TTY Mode | Notes | -|--------|----------|----------|-------| -| **Navigation & Selection** | -| Menu | Full | Full | Keyboard navigation works identically | -| Tabs | Full | Full | Tab switching via keyboard | -| Table | Full | Full | Arrow keys for navigation, sorting | -| TreeView | Full | Full | Expand/collapse via arrow keys | -| CommandPalette | Full | Full | Fuzzy search and selection | -| **Input** | -| TextInput | Full | Full | Character-by-character input | -| TextInput.Line | Full | Full | Shell line editing via `IO.gets/1` | -| FormBuilder | Full | Full | Tab navigation between fields | -| **Feedback** | -| Dialog | Full | Full | Modal with button navigation | -| AlertDialog | Full | Full | Type-based icons and styling | -| Toast | Full | Full | Auto-dismissing notifications | -| **Layout** | -| SplitPane | Full | Keyboard | Mouse drag unavailable; use Ctrl+arrows | -| Viewport | Full | Full | Keyboard scrolling | -| ScrollBar | Full | Keyboard | Click unavailable; use arrow keys | -| **Data Visualization** | -| Gauge | Full | Full | Progress bars | -| BarChart | Full | Full | Vertical bar charts | -| LineChart | Full | Full | Line graphs | -| Sparkline | Full | Full | Inline mini charts | -| Canvas | Full | Full | Pixel/character drawing | -| **Context Menus** | -| ContextMenu | Full | Position N/A | Use ContextMenu.Inline for TTY | -| ContextMenu.Inline | Full | Full | Numbered selection, no positioning | -| **Monitoring** | -| ProcessMonitor | Full | Full | Process list display | -| SupervisionTreeViewer | Full | Full | Tree visualization | -| ClusterDashboard | Full | Full | Cluster status | -| LogViewer | Full | Full | Log streaming | - -### Legend - -- **Full**: All features work as expected -- **Keyboard**: Mouse features unavailable; keyboard alternatives provided -- **Position N/A**: Requires mouse positioning; use inline variant instead - -## Widget Variants - -Some widgets have variants optimized for different backends: - -### TextInput vs TextInput.Line - -| Feature | TextInput | TextInput.Line | -|---------|-----------|----------------| -| Input Method | Character-by-character | Line-based (`IO.gets/1`) | -| Shell Editing | No | Yes (history, readline) | -| Real-time Validation | Yes | On submit only | -| Cursor Control | Full | Shell-controlled | -| Blocking | No (event-driven) | Yes (blocks during read) | -| Best For | Real-time input, search | Free-form text entry | - -> **Note:** `TextInput.Line` uses blocking I/O. When `read/1` is called, the -> process blocks until the user presses Enter. This is intentional to enable -> shell line editing features. For non-blocking input, use `TextInput`. - -**Usage:** -```elixir -# Real-time input (e.g., search) -TextInput.new(placeholder: "Search...") - -# Line-based input with shell editing -alias TermUI.Widgets.TextInput.Line -Line.new(prompt: "> ", label: "Enter command") -``` - -### ContextMenu vs ContextMenu.Inline - -| Feature | ContextMenu | ContextMenu.Inline | -|---------|-------------|-------------------| -| Positioning | Mouse cursor | Below current focus | -| Selection | Click or arrows | Numbers or arrows | -| Best For | Right-click menus | Keyboard-only environments | - -**Usage:** -```elixir -alias TermUI.Widgets.ContextMenu -alias TermUI.Widgets.ContextMenu.Inline, as: InlineMenu - -# Create menu items (same for both variants) -items = [ - ContextMenu.action(:copy, "Copy"), - ContextMenu.action(:paste, "Paste"), - ContextMenu.separator(), - ContextMenu.action(:delete, "Delete") -] - -# Positioned context menu (requires mouse) -ContextMenu.new(items: items, position: {x, y}) - -# Inline menu with number keys -InlineMenu.new(items: items) -# Renders: [1] Copy [2] Paste [3] Delete -``` - -## Features with Keyboard Alternatives - -### SplitPane Resize - -Mouse dragging is unavailable in TTY mode. Use keyboard shortcuts: - -| Action | Shortcut | -|--------|----------| -| Decrease left/top pane | Ctrl+Left / Ctrl+Up | -| Increase left/top pane | Ctrl+Right / Ctrl+Down | - -```elixir -alias TermUI.Widgets.SplitPane - -# SplitPane uses :panes list for pane definitions -SplitPane.new( - orientation: :horizontal, - panes: [ - %{id: :left, content: left_panel, size: 0.5}, - %{id: :right, content: right_panel, size: 0.5} - ], - ctrl_resize_step: 0.05, # 5% per keystroke - min_ratio: 0.1, # Minimum 10% - max_ratio: 0.9 # Maximum 90% -) -``` - -### ScrollBar Interaction - -Click-to-scroll is unavailable in TTY mode. Scrolling via: -- Arrow keys (line by line) -- Page Up/Page Down (page by page) -- Home/End (jump to start/end) - ---- - -## Best Practices for Widget Development - -### 1. Always Use Theme for Colors - -Never hardcode color values. Use the Theme system for automatic degradation: - -```elixir -# Bad - hardcoded colors -style = Style.new() |> Style.fg({255, 0, 0}) - -# Good - theme-based colors -style = Style.new() |> Style.fg(Theme.get_semantic(:error)) - -# Good - component styles with monochrome fallback -style = Theme.get_component_style(:list, :selected) -``` - -The Theme system automatically: -- Converts RGB to 256-color when needed -- Converts to 16-color palette when needed -- Provides monochrome fallbacks (reverse, bold, underline) - -### 2. Always Use CharacterSet for Special Characters - -Never hardcode Unicode characters. Use CharacterSet for automatic ASCII fallback: - -```elixir -# Bad - hardcoded Unicode -border = "┌" <> String.duplicate("─", width) <> "┐" - -# Good - CharacterSet-based -chars = CharacterSet.current_charset() -border = chars.tl <> String.duplicate(chars.h_line, width) <> chars.tr -``` - -Available character categories: -- **Box drawing**: `tl`, `tr`, `bl`, `br`, `h_line`, `v_line`, `cross`, etc. -- **Arrows**: `arrow_up`, `arrow_down`, `arrow_left`, `arrow_right` -- **Indicators**: `check`, `cross_mark`, `bullet`, `pointer` -- **Progress**: `bar_full`, `bar_empty`, `bar_levels`, `sparkline_levels` -- **Icons**: `info`, `warning`, `loading` - -### 3. Provide Keyboard Alternatives for Mouse Features - -Every mouse interaction should have a keyboard equivalent: - -```elixir -# Handle both mouse and keyboard for selection -def handle_event(%Event.Mouse{action: :click, y: y}, state) do - select_item_at(state, y) -end - -def handle_event(%Event.Key{key: :enter}, state) do - select_current_item(state) -end - -def handle_event(%Event.Key{key: :down}, state) do - move_cursor(state, 1) -end -``` - -Common keyboard patterns: -| Mouse Action | Keyboard Alternative | -|--------------|---------------------| -| Click to select | Enter/Space | -| Drag to resize | Ctrl+Arrow keys | -| Scroll wheel | Arrow keys, Page Up/Down | -| Right-click menu | Context key, Shift+F10 | -| Hover tooltip | Focus + delay | - -### 4. Test with Both Backends - -Always test widgets in both Raw and TTY modes: - -```elixir -# In tests, configure backend explicitly -defmodule MyWidgetTest do - use ExUnit.Case - - describe "keyboard navigation" do - test "works in raw mode" do - Application.put_env(:term_ui, :backend, :raw) - # Test keyboard navigation - end - - test "works in tty mode" do - Application.put_env(:term_ui, :backend, :tty) - # Same navigation should work identically - end - end -end -``` - -### 5. Use Appropriate Widget State Patterns - -Widgets should use the StatefulComponent pattern: - -```elixir -defmodule MyWidget do - use TermUI.StatefulComponent - - @impl true - def init(props) do - state = %{ - # Initialize state from props - } - {:ok, state} - end - - @impl true - def handle_event(event, state) do - # Handle keyboard and mouse events - {:ok, new_state} - end - - @impl true - def render(state, area) do - # Return render nodes - stack(:vertical, [...]) - end -end -``` - -### 6. Support Capability Degradation - -Check capabilities at runtime when needed: - -```elixir -defp render_with_fallback(state) do - chars = CharacterSet.current_charset() - - # CharacterSet automatically provides ASCII fallback - # based on :term_ui, :character_set config - - border = chars.tl <> String.duplicate(chars.h_line, width) <> chars.tr - # In Unicode mode: ┌────────┐ - # In ASCII mode: +--------+ -end -``` - ---- - -## Color Mode Reference - -The Theme system supports multiple color modes: - -| Mode | Colors | Use Case | -|------|--------|----------| -| `true_color` | 16M (RGB) | Modern terminals | -| `color_256` | 256 | Most terminals | -| `color_16` | 16 | Basic terminals | -| `monochrome` | 2 | No color support | - -Monochrome fallbacks: -- **Selected items**: Reverse video -- **Focused items**: Bold -- **Error states**: Underline -- **Disabled items**: Dim - ---- - -## Character Set Reference - -Two character sets are available: - -| Character | Unicode | ASCII | -|-----------|---------|-------| -| Corners | `┌┐└┘` | `+` | -| Lines | `─│` | `-\|` | -| Arrows | `↑↓←→` | `^v<>` | -| Triangles | `▲▼◀▶` | `^v<>` | -| Progress | `█░` | `#.` | -| Check | `✓` | `x` | -| Cross | `✗` | `X` | -| Bullet | `●○` | `*o` | - -Configure at runtime: -```elixir -# In config/config.exs -config :term_ui, :character_set, :unicode # or :ascii - -# Or at runtime -Application.put_env(:term_ui, :character_set, :ascii) -``` - ---- - -## Quick Reference - -### Creating a Compatible Widget - -1. Use `TermUI.StatefulComponent` -2. Handle keyboard events for all interactions -3. Use `Theme.get_*` for colors -4. Use `CharacterSet.current_charset()` for special characters -5. Test in both Raw and TTY modes - -### Checking Current Mode - -```elixir -# Get current backend -backend = Application.get_env(:term_ui, :backend, :raw) - -# Get current character set -charset = CharacterSet.current() # :unicode or :ascii - -# Get current color capabilities -color_mode = Theme.get_color_mode() # :true_color, :color_256, etc. -``` diff --git a/examples/README.md b/examples/README.md index 3bea6521..71b890d7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,118 +1,4 @@ -# TermUI Examples +# Example -This directory contains example applications demonstrating TermUI widgets and patterns. - -## Examples Overview - -| Example | Description | Key Features | -|---------|-------------|--------------| -| [dashboard](./dashboard/) | System monitoring dashboard | Multiple widgets, real-time updates, themes | -| [gauge](./gauge/) | Progress indicators | Color zones, bar/arc styles, labels | -| [sparkline](./sparkline/) | Time series visualization | Value-based colors, min/max tracking | -| [bar_chart](./bar_chart/) | Bar chart visualizations | Horizontal/vertical, colors, labels | -| [table](./table/) | Data tables | Columns, selection, scrolling, constraints | -| [line_chart](./line_chart/) | Line chart with Braille graphics | Multiple series, auto-scaling, legends | -| [menu](./menu/) | Hierarchical menus | Actions, submenus, checkboxes, radio groups | -| [tabs](./tabs/) | Tabbed interfaces | Tab switching, dynamic tabs, content panels | -| [dialog](./dialog/) | Modal dialogs | Confirmation, info, warning, error dialogs | -| [viewport](./viewport/) | Scrollable content areas | Keyboard/mouse scrolling, scrollbars | -| [canvas](./canvas/) | Custom drawing | Primitives, rectangles, Braille graphics | - -## Running Examples - -Each example is a standalone Mix project. To run an example: - -```bash -# Navigate to the example directory -cd examples/ - -# Install dependencies -mix deps.get - -# Run the example -mix termui.run -``` - -## Requirements - -- Elixir 1.15+ -- OTP 28+ -- Terminal with Unicode support - -## Example Structure - -Each example follows a consistent structure: - -``` -example_name/ -├── mix.exs # Mix project file -├── run.exs # Script to run the example -├── README.md # Example documentation -└── lib/ - └── example_name/ - ├── application.ex # OTP application module - └── app.ex # Main component implementation -``` - -## The Elm Architecture - -All examples use TermUI's Elm Architecture pattern with four callbacks: - -```elixir -@behaviour TermUI.Component - -# Initialize component state -@impl true -def init(_opts), do: %{...} - -# Convert events to messages -@impl true -def event_to_msg(event, state), do: {:msg, message} | :ignore - -# Update state based on messages -@impl true -def update(message, state), do: {new_state, commands} - -# Render state to UI tree -@impl true -def view(state), do: stack(:vertical, [...]) -``` - -## Widget Categories - -### Data Display -- **Gauge** - Show progress or values with visual feedback -- **Sparkline** - Compact time series visualization -- **BarChart** - Categorical data comparison -- **LineChart** - Trend visualization with multiple series -- **Table** - Structured data with selection - -### Navigation -- **Menu** - Hierarchical command menus -- **Tabs** - Organize content into switchable panels - -### Interaction -- **Dialog** - Modal prompts and confirmations -- **Viewport** - Scrollable content containers - -### Drawing -- **Canvas** - Custom graphics with drawing primitives - -## Learning Path - -For beginners, we recommend exploring examples in this order: - -1. **gauge** - Simple widget with basic event handling -2. **sparkline** - Working with data collections -3. **table** - Selection and navigation patterns -4. **menu** - Complex widget interactions -5. **dashboard** - Combining multiple widgets - -## Contributing - -When adding new examples: - -1. Follow the existing directory structure -2. Include a comprehensive README.md -3. Add well-commented code explaining widget usage -4. Update this README with the new example +The `iex_counter` directory contains the supported TermUI example. It shows +the complete application contract without an application-specific adapter. diff --git a/examples/alert_dialog/README.md b/examples/alert_dialog/README.md deleted file mode 100644 index a65717b0..00000000 --- a/examples/alert_dialog/README.md +++ /dev/null @@ -1,114 +0,0 @@ -# AlertDialog Widget Example - -This example demonstrates the TermUI AlertDialog widget, which provides standardized message dialogs and confirmations with predefined button configurations and visual icons. - -## Widget Overview - -The AlertDialog widget is designed for displaying modal dialogs that require user attention or confirmation. It provides six predefined alert types, each with appropriate icons and button configurations: - -- **Info** - General information messages -- **Success** - Operation success confirmations -- **Warning** - Caution messages requiring attention -- **Error** - Error notifications -- **Confirm** - Yes/No decision dialogs -- **OK/Cancel** - Cancellable action dialogs - -Use AlertDialog when you need to interrupt the user's workflow to display important messages or request confirmation before proceeding with an action. - -## Widget Options - -The `AlertDialog.new/1` function accepts the following options: - -- `:type` - Alert type (required): `:info`, `:success`, `:warning`, `:error`, `:confirm`, `:ok_cancel` -- `:title` - Dialog title (required) -- `:message` - Message to display (required) -- `:on_result` - Callback function to handle result (`:ok`, `:cancel`, `:yes`, `:no`) -- `:width` - Dialog width in characters (default: 50) -- `:icon_style` - Custom style for the icon -- `:message_style` - Custom style for the message text -- `:button_style` - Custom style for buttons -- `:focused_button_style` - Custom style for the focused button - -## Example Structure - -The example consists of: - -- `lib/alert_dialog/app.ex` - Main application demonstrating all alert types - - Handles number keys (1-6) to trigger different alert types - - Manages alert state and captures user responses - - Displays the result of the last closed dialog - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/alert_dialog -mix termui.run -``` - -Or manually: - -```bash -cd examples/alert_dialog -mix run -e "AlertDialog.App.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/alert_dialog -iex -S mix -``` - -Then in IEx: - -```elixir -AlertDialog.App.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -**When no alert is visible:** -- `1` - Show Info Alert (informational message) -- `2` - Show Success Alert (operation succeeded) -- `3` - Show Warning Alert (caution message) -- `4` - Show Error Alert (error message) -- `5` - Show Confirm Dialog (Yes/No choice) -- `6` - Show OK/Cancel Dialog (OK/Cancel choice) -- `Q` - Quit application - -**When alert is visible:** -- `Tab` / `←` / `→` - Navigate between buttons -- `Enter` - Select focused button -- `Y` / `N` - Quick select (in confirm dialogs only) -- `Escape` - Cancel/Close alert - -## Implementation Notes - -The example demonstrates: -- Creating different alert types with appropriate messages -- Handling alert events and button selection -- Capturing and displaying dialog results -- Conditional rendering based on alert visibility -- The difference between message alerts (OK only) and decision dialogs (Yes/No, OK/Cancel) - -### Alert Types Reference - -| Type | Icon | Buttons | Use Case | -|------|------|---------|----------| -| info | ℹ | OK | Informational messages | -| success | ✓ | OK | Operation succeeded | -| warning | ⚠ | OK | Caution messages | -| error | ✗ | OK | Error messages | -| confirm | ? | No, Yes | Yes/No decisions | -| ok_cancel | ? | Cancel, OK | OK/Cancel decisions | diff --git a/examples/alert_dialog/lib/alert_dialog/app.ex b/examples/alert_dialog/lib/alert_dialog/app.ex deleted file mode 100644 index 0770958f..00000000 --- a/examples/alert_dialog/lib/alert_dialog/app.ex +++ /dev/null @@ -1,194 +0,0 @@ -defmodule AlertDialog.App do - @moduledoc """ - Alert Dialog Widget Example - - This example demonstrates how to use the TermUI.Widgets.AlertDialog widget - for displaying standardized message dialogs. - - Features demonstrated: - - Info alert (informational message) - - Success alert (operation succeeded) - - Warning alert (caution message) - - Error alert (error message) - - Confirm dialog (Yes/No choice) - - OK/Cancel dialog (OK/Cancel choice) - - Keyboard shortcuts (Y/N for confirm) - - Controls: - - 1: Show Info Alert - - 2: Show Success Alert - - 3: Show Warning Alert - - 4: Show Error Alert - - 5: Show Confirm Dialog - - 6: Show OK/Cancel Dialog - - Tab/Arrow: Navigate buttons (when alert open) - - Enter: Select button (when alert open) - - Y/N: Quick select (in confirm dialogs) - - Escape: Cancel/Close alert - - Q: Quit the application - """ - - use TermUI.Elm - - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Widgets.AlertDialog - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- - - @doc """ - Initialize the component state. - """ - def init(_opts) do - %{ - # Current alert dialog state (nil when no alert visible) - alert: nil, - # Result tracking - last_result: nil, - last_alert_type: nil - } - end - - @doc """ - Convert keyboard events to messages. - """ - # When no alert is visible, number keys show alerts - def event_to_msg(%Event.Key{key: "1"}, %{alert: nil}), do: {:msg, :show_info} - def event_to_msg(%Event.Key{key: "2"}, %{alert: nil}), do: {:msg, :show_success} - def event_to_msg(%Event.Key{key: "3"}, %{alert: nil}), do: {:msg, :show_warning} - def event_to_msg(%Event.Key{key: "4"}, %{alert: nil}), do: {:msg, :show_error} - def event_to_msg(%Event.Key{key: "5"}, %{alert: nil}), do: {:msg, :show_confirm} - def event_to_msg(%Event.Key{key: "6"}, %{alert: nil}), do: {:msg, :show_ok_cancel} - - # When alert is visible, forward events to the alert widget - def event_to_msg(event, %{alert: alert}) when alert != nil, do: {:msg, {:alert_event, event}} - - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit} - def event_to_msg(_event, _state), do: :ignore - - @doc """ - Update state based on messages. - """ - def update(:show_info, state), do: {show_alert(state, :info, "Information", "This is an informational message."), []} - def update(:show_success, state), do: {show_alert(state, :success, "Success", "Operation completed successfully!"), []} - def update(:show_warning, state), do: {show_alert(state, :warning, "Warning", "Please proceed with caution."), []} - def update(:show_error, state), do: {show_alert(state, :error, "Error", "An error occurred during the operation."), []} - def update(:show_confirm, state), do: {show_alert(state, :confirm, "Confirm Action", "Are you sure you want to proceed?"), []} - def update(:show_ok_cancel, state), do: {show_alert(state, :ok_cancel, "Save Changes", "Do you want to save your changes?"), []} - - def update({:alert_event, event}, state) do - case AlertDialog.handle_event(event, state.alert) do - {:ok, new_alert} -> - if AlertDialog.visible?(new_alert) do - {%{state | alert: new_alert}, []} - else - # Alert was closed - capture result - result = AlertDialog.get_focused_button(new_alert) - alert_type = AlertDialog.get_type(new_alert) - {%{state | alert: nil, last_result: result, last_alert_type: alert_type}, []} - end - end - end - - def update(:quit, state) do - {state, [:quit]} - end - - # Helper to create and initialize an alert dialog - defp show_alert(state, type, title, message) do - props = AlertDialog.new(type: type, title: title, message: message) - {:ok, alert} = AlertDialog.init(props) - - # Set terminal area for accurate mouse click detection - # (In a full implementation, this would be obtained from the runtime) - alert = AlertDialog.update_area(alert, %{width: 80, height: 24}) - - %{state | alert: alert} - end - - @doc """ - Render the current state to a render tree. - - When an alert is visible, return the alert overlay directly (not stacked). - This allows the overlay to be positioned absolutely over the main content. - """ - def view(state) do - main_content = render_main_content(state) - - if state.alert != nil do - # Standard terminal size for this example - area = %{width: 80, height: 24} - - # Render the overlay directly - it will be positioned absolutely - {:overlay, main_content, AlertDialog.render(state.alert, area)} - else - main_content - end - end - - # ---------------------------------------------------------------------------- - # Private Helpers - # ---------------------------------------------------------------------------- - - defp render_main_content(state) do - stack(:vertical, [ - # Title - text("Alert Dialog Widget Example", Style.new(fg: :cyan, attrs: [:bold])), - text("", nil), - - # Instructions - text("Press a number key to show different alert types:", nil), - text("", nil), - text(" 1 - Info Alert (informational message)", nil), - text(" 2 - Success Alert (operation succeeded)", nil), - text(" 3 - Warning Alert (caution message)", nil), - text(" 4 - Error Alert (error message)", nil), - text(" 5 - Confirm Dialog (Yes/No choice)", nil), - text(" 6 - OK/Cancel (OK/Cancel choice)", nil), - text("", nil), - - # Controls - render_controls(state) - ]) - end - - defp render_controls(state) do - box_width = 55 - inner_width = box_width - 2 - - top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐" - bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘" - - result_text = format_result(state.last_result, state.last_alert_type) - - stack(:vertical, [ - text("", nil), - text(top_border, Style.new(fg: :yellow)), - text("│" <> String.pad_trailing(" 1-6 Show alert type", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Tab/←/→ Navigate buttons (in alert)", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Enter Select button", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Y/N Quick select (confirm only)", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Escape Cancel/Close alert", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil), - text("│" <> String.pad_trailing("", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Last result: #{result_text}", inner_width) <> "│", nil), - text(bottom_border, Style.new(fg: :yellow)) - ]) - end - - defp format_result(nil, _type), do: "(none)" - defp format_result(result, type), do: "#{type} -> #{result}" - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Run the alert dialog example application. - """ - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/alert_dialog/lib/alert_dialog/application.ex b/examples/alert_dialog/lib/alert_dialog/application.ex deleted file mode 100644 index 5953914d..00000000 --- a/examples/alert_dialog/lib/alert_dialog/application.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule AlertDialog.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [] - opts = [strategy: :one_for_one, name: AlertDialog.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/examples/alert_dialog/mix.exs b/examples/alert_dialog/mix.exs deleted file mode 100644 index 8950a2e1..00000000 --- a/examples/alert_dialog/mix.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule AlertDialog.MixProject do - use Mix.Project - - def project do - [ - app: :alert_dialog, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {AlertDialog.Application, []} - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/alert_dialog/mix.lock b/examples/alert_dialog/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/alert_dialog/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/alert_dialog/run.exs b/examples/alert_dialog/run.exs deleted file mode 100644 index fdb6a0f2..00000000 --- a/examples/alert_dialog/run.exs +++ /dev/null @@ -1 +0,0 @@ -AlertDialog.App.run() diff --git a/examples/bar_chart/README.md b/examples/bar_chart/README.md deleted file mode 100644 index e49c9295..00000000 --- a/examples/bar_chart/README.md +++ /dev/null @@ -1,110 +0,0 @@ -# BarChart Widget Example - -This example demonstrates the TermUI BarChart widget for displaying comparative values as horizontal or vertical bars with labels and values. - -## Widget Overview - -The BarChart widget renders visual representations of numeric data as bars, making it easy to compare values at a glance. It supports: - -- **Horizontal bars** - Traditional left-to-right bars with labels -- **Vertical bars** - Column-style charts for different visualization needs -- **Value display** - Show numeric values alongside bars -- **Label display** - Identify each bar with text labels -- **Color coding** - Apply custom colors to individual bars -- **Simple bars** - Single-value progress bars - -Use BarChart when you need to visualize comparative data, show progress, or display statistical information in your TUI application. - -## Widget Options - -The `BarChart.render/1` function accepts the following options: - -- `:data` - List of data points (required), each with: - - `:label` - Bar label (string) - - `:value` - Numeric value -- `:direction` - `:horizontal` or `:vertical` (default: `:horizontal`) -- `:width` - Chart width in characters (default: 40, max: configurable) -- `:height` - Chart height for vertical charts (default: 10, max: configurable) -- `:show_values` - Display numeric values (default: `true`) -- `:show_labels` - Display bar labels (default: `true`) -- `:bar_char` - Character for filled bars (default: `"█"`) -- `:empty_char` - Character for empty space (default: `" "`) -- `:colors` - List of `Style` structs for bar colors (cycles through list) -- `:style` - Overall chart style - -The `BarChart.bar/1` function for simple single bars accepts: - -- `:value` - Current value (required) -- `:max` - Maximum value (required) -- `:width` - Bar width (default: 20) -- `:bar_char` - Filled character (default: `"█"`) -- `:empty_char` - Empty character (default: `"░"`) - -## Example Structure - -The example consists of: - -- `lib/bar_chart/app.ex` - Main application demonstrating: - - Dynamic direction switching (horizontal/vertical) - - Toggle value and label display - - Data randomization for live updates - - Multiple chart configurations: - - Main interactive chart - - Simple single-bar progress indicator - - Colored multi-bar chart - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/bar_chart -mix termui.run -``` - -Or manually: - -```bash -cd examples/bar_chart -mix run -e "BarChart.App.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/bar_chart -iex -S mix -``` - -Then in IEx: - -```elixir -BarChart.App.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -- `D` - Toggle chart direction (horizontal/vertical) -- `V` - Toggle value display (ON/OFF) -- `L` - Toggle label display (ON/OFF) -- `R` - Randomize data values -- `Q` - Quit application - -## Implementation Notes - -The example demonstrates: -- Rendering horizontal bar charts with labels and values -- Rendering vertical column charts with proper scaling -- Dynamic chart reconfiguration based on user input -- Using custom colors for different bars -- Creating simple single-value progress bars -- Proper data formatting and scaling to fit available space diff --git a/examples/bar_chart/lib/bar_chart/app.ex b/examples/bar_chart/lib/bar_chart/app.ex deleted file mode 100644 index 6c291000..00000000 --- a/examples/bar_chart/lib/bar_chart/app.ex +++ /dev/null @@ -1,206 +0,0 @@ -defmodule BarChart.App do - @moduledoc """ - Bar Chart Widget Example - - This example demonstrates how to use the TermUI.Widgets.BarChart widget - for displaying comparative values as horizontal or vertical bars. - - Features demonstrated: - - Horizontal bar charts - - Vertical bar charts - - Custom colors per bar - - Value and label display options - - Simple single bar helper - - Controls: - - D: Toggle chart direction (horizontal/vertical) - - V: Toggle value display - - L: Toggle label display - - R: Randomize data - - Q: Quit the application - """ - - use TermUI.Elm - - alias TermUI.Widgets.BarChart - alias TermUI.Event - alias TermUI.Renderer.Style - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- - - @doc """ - Initialize the component state. - """ - def init(_opts) do - %{ - data: sample_data(), - direction: :horizontal, - show_values: true, - show_labels: true - } - end - - defp sample_data do - [ - %{label: "Sales", value: 150}, - %{label: "Marketing", value: 85}, - %{label: "Engineering", value: 200}, - %{label: "Support", value: 120}, - %{label: "HR", value: 45} - ] - end - - @doc """ - Convert keyboard events to messages. - """ - def event_to_msg(%Event.Key{key: key}, _state) when key in ["d", "D"], do: {:msg, :toggle_direction} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["v", "V"], do: {:msg, :toggle_values} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["l", "L"], do: {:msg, :toggle_labels} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["r", "R"], do: {:msg, :randomize} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit} - def event_to_msg(_event, _state), do: :ignore - - @doc """ - Update state based on messages. - """ - def update(:toggle_direction, state) do - new_direction = if state.direction == :horizontal, do: :vertical, else: :horizontal - {%{state | direction: new_direction}, []} - end - - def update(:toggle_values, state) do - {%{state | show_values: not state.show_values}, []} - end - - def update(:toggle_labels, state) do - {%{state | show_labels: not state.show_labels}, []} - end - - def update(:randomize, state) do - new_data = - state.data - |> Enum.map(fn item -> - %{item | value: :rand.uniform(200) + 20} - end) - - {%{state | data: new_data}, []} - end - - def update(:quit, state) do - {state, [:quit]} - end - - @doc """ - Render the current state to a render tree. - """ - def view(state) do - stack(:vertical, [ - # Title - text("Bar Chart Widget Example", Style.new(fg: :cyan, attrs: [:bold])), - text("", nil), - - # Main chart based on current direction - render_main_chart(state), - text("", nil), - - # Simple bar example - text("Simple single bar:", nil), - BarChart.bar( - value: 75, - max: 100, - width: 30 - ), - text("", nil), - - # Colored bar chart example - text("Bar chart with colors:", nil), - BarChart.render( - data: [ - %{label: "Red", value: 80}, - %{label: "Green", value: 60}, - %{label: "Blue", value: 90} - ], - direction: :horizontal, - width: 40, - show_values: true, - # Colors cycle through this list - colors: [ - Style.new(fg: :red), - Style.new(fg: :green), - Style.new(fg: :blue) - ] - ), - text("", nil), - - # Controls - render_controls(state) - ]) - end - - defp render_controls(state) do - box_width = 50 - inner_width = box_width - 2 - - top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐" - bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘" - - stack(:vertical, [ - text("", nil), - text(top_border, Style.new(fg: :yellow)), - text("│" <> String.pad_trailing(" D Toggle direction (#{state.direction})", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" V Toggle values (#{if state.show_values, do: "ON", else: "OFF"})", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" L Toggle labels (#{if state.show_labels, do: "ON", else: "OFF"})", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" R Randomize data", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil), - text(bottom_border, Style.new(fg: :yellow)) - ]) - end - - # ---------------------------------------------------------------------------- - # Private Helpers - # ---------------------------------------------------------------------------- - - defp render_main_chart(state) do - case state.direction do - :horizontal -> - stack(:vertical, [ - text("Horizontal Bar Chart:", nil), - BarChart.render( - data: state.data, - direction: :horizontal, - width: 50, - show_values: state.show_values, - show_labels: state.show_labels, - bar_char: "█" - ) - ]) - - :vertical -> - stack(:vertical, [ - text("Vertical Bar Chart:", nil), - BarChart.render( - data: state.data, - direction: :vertical, - width: 30, - height: 8, - show_values: state.show_values, - show_labels: state.show_labels, - bar_char: "█" - ) - ]) - end - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Run the bar chart example application. - """ - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/bar_chart/lib/bar_chart/application.ex b/examples/bar_chart/lib/bar_chart/application.ex deleted file mode 100644 index 650bec62..00000000 --- a/examples/bar_chart/lib/bar_chart/application.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule BarChart.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [] - opts = [strategy: :one_for_one, name: BarChart.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/examples/bar_chart/mix.exs b/examples/bar_chart/mix.exs deleted file mode 100644 index a834650c..00000000 --- a/examples/bar_chart/mix.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule BarChart.MixProject do - use Mix.Project - - def project do - [ - app: :bar_chart, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {BarChart.Application, []} - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/bar_chart/mix.lock b/examples/bar_chart/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/bar_chart/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/bar_chart/run.exs b/examples/bar_chart/run.exs deleted file mode 100644 index 725b44b0..00000000 --- a/examples/bar_chart/run.exs +++ /dev/null @@ -1 +0,0 @@ -BarChart.App.run() diff --git a/examples/canvas/README.md b/examples/canvas/README.md deleted file mode 100644 index c3494561..00000000 --- a/examples/canvas/README.md +++ /dev/null @@ -1,140 +0,0 @@ -# Canvas Widget Example - -This example demonstrates the TermUI Canvas widget, which provides a direct character buffer for custom drawing with primitives for lines, rectangles, text, and Braille graphics. - -## Widget Overview - -The Canvas widget offers a low-level drawing surface for creating custom visualizations, diagrams, charts, and graphics that don't fit standard widget patterns. It provides: - -- **Direct buffer access** - Set individual characters at any position -- **Drawing primitives** - Lines (horizontal, vertical, diagonal), rectangles, text -- **Braille graphics** - Sub-character resolution (2x4 dots per character cell) -- **Flexible rendering** - Use callback functions or direct manipulation -- **Clear and fill operations** - Reset or fill entire canvas - -Use Canvas when you need complete control over rendering, want to create custom visualizations, or need higher resolution than standard character-based rendering. - -## Widget Options - -The `Canvas.new/1` function accepts the following options: - -- `:width` - Canvas width in characters (default: 40) -- `:height` - Canvas height in characters (default: 20) -- `:default_char` - Character to fill canvas initially (default: `" "`) -- `:on_draw` - Callback function `fn(state) -> state` to draw on canvas - -The `Canvas.draw/3` utility function creates a canvas inline: - -```elixir -Canvas.draw(width, height, fn state -> - # Draw operations here -end) -``` - -## Drawing Functions - -**Character buffer operations:** -- `clear/1` - Clear canvas with default character -- `fill/2` - Fill canvas with specific character -- `set_char/4` - Set character at (x, y) position -- `get_char/3` - Get character at (x, y) position -- `draw_text/4` - Draw text string at position - -**Line primitives:** -- `draw_hline/5` - Horizontal line at (x, y) with length -- `draw_vline/5` - Vertical line at (x, y) with length -- `draw_line/6` - Arbitrary line between two points (Bresenham's algorithm) - -**Rectangle primitives:** -- `draw_rect/6` - Rectangle outline with customizable border characters -- `fill_rect/6` - Filled rectangle - -**Braille graphics (sub-character resolution):** -- `set_dot/3` - Set dot at (x, y) in dot space (width*2, height*4) -- `clear_dot/3` - Clear dot at position -- `draw_braille_line/5` - Line with sub-character precision -- `dots_to_braille/1` - Convert dot coordinates to Braille character -- `braille_resolution/1` - Get canvas resolution in dots - -## Example Structure - -The example consists of: - -- `lib/canvas/app.ex` - Main application with three demos: - - **Shapes demo** - Basic lines, points, and text - - **Boxes demo** - Rectangle drawing with different border styles - - **Braille demo** - Sub-character resolution explanation and patterns - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/canvas -mix termui.run -``` - -Or manually: - -```bash -cd examples/canvas -mix run -e "Canvas.App.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/canvas -iex -S mix -``` - -Then in IEx: - -```elixir -Canvas.App.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -- `1` - Show basic shapes demo -- `2` - Show box drawing demo -- `3` - Show Braille drawing demo -- `C` - Clear canvas -- `Q` - Quit application - -## Implementation Notes - -The example demonstrates: -- Creating and drawing on a canvas using the `Canvas.draw/3` function -- Drawing horizontal and vertical lines -- Drawing diagonal lines with Bresenham's algorithm -- Drawing rectangles with different border styles (single-line, double-line, rounded) -- Nested rectangles -- Text rendering at arbitrary positions -- Braille graphics for sub-character resolution (each character = 2x4 dots) -- Converting canvas state to string lines for rendering - -### Braille Graphics - -Braille characters provide 2x4 dot resolution per character cell: -- Canvas character resolution: width × height -- Canvas dot resolution: (width × 2) × (height × 4) - -Each Braille dot position is numbered: -``` -1 4 -2 5 -3 6 -7 8 -``` - -This enables smooth curves and higher-resolution graphics within the character grid. diff --git a/examples/canvas/lib/canvas/app.ex b/examples/canvas/lib/canvas/app.ex deleted file mode 100644 index 4228e076..00000000 --- a/examples/canvas/lib/canvas/app.ex +++ /dev/null @@ -1,239 +0,0 @@ -defmodule Canvas.App do - @moduledoc """ - Canvas Widget Example - - This example demonstrates how to use the TermUI.Widgets.Canvas widget - for custom drawing with direct buffer access. - - Features demonstrated: - - Basic canvas creation - - Drawing text at positions - - Drawing lines (horizontal, vertical, diagonal) - - Drawing rectangles - - Drawing with Braille characters for sub-character resolution - - Controls: - - 1: Show basic shapes demo - - 2: Show box drawing demo - - 3: Show Braille drawing demo - - C: Clear canvas - - Q: Quit the application - """ - - use TermUI.Elm - - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Widgets.Canvas - - # Canvas dimensions - @canvas_width 50 - @canvas_height 15 - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- - - @doc """ - Initialize the component state. - """ - def init(_opts) do - %{ - demo: :shapes, - canvas: create_canvas(:shapes) - } - end - - defp create_canvas(demo) do - # Use Canvas.draw/3 to create and draw on the canvas - Canvas.draw(@canvas_width, @canvas_height, fn state -> - case demo do - :shapes -> draw_shapes_demo(state) - :boxes -> draw_boxes_demo(state) - :braille -> draw_braille_demo(state) - end - end) - end - - @doc """ - Convert keyboard events to messages. - """ - def event_to_msg(%Event.Key{key: "1"}, _state), do: {:msg, {:set_demo, :shapes}} - def event_to_msg(%Event.Key{key: "2"}, _state), do: {:msg, {:set_demo, :boxes}} - def event_to_msg(%Event.Key{key: "3"}, _state), do: {:msg, {:set_demo, :braille}} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["c", "C"], do: {:msg, :clear} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit} - def event_to_msg(_event, _state), do: :ignore - - @doc """ - Update state based on messages. - """ - def update({:set_demo, demo}, state) do - {%{state | demo: demo, canvas: create_canvas(demo)}, []} - end - - def update(:clear, state) do - {%{state | canvas: Canvas.clear(state.canvas)}, []} - end - - def update(:quit, state) do - {state, [:quit]} - end - - @doc """ - Render the current state to a render tree. - """ - def view(state) do - stack(:vertical, [ - # Title - text("Canvas Widget Example", Style.new(fg: :cyan, attrs: [:bold])), - text("", nil), - - # Canvas area with border - render_canvas(state.canvas), - text("", nil), - - text("", nil), - - # Controls - render_controls(state) - ]) - end - - defp render_controls(state) do - box_width = 36 - inner_width = box_width - 2 - - top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐" - bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘" - - stack(:vertical, [ - text("", nil), - text(top_border, Style.new(fg: :yellow)), - text("│" <> String.pad_trailing(" 1 Basic shapes demo", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" 2 Box drawing demo", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" 3 Braille drawing demo", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" C Clear canvas", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil), - text("│" <> String.pad_trailing("", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Demo: #{state.demo}", inner_width) <> "│", nil), - text(bottom_border, Style.new(fg: :yellow)) - ]) - end - - # ---------------------------------------------------------------------------- - # Canvas Rendering - # ---------------------------------------------------------------------------- - - defp render_canvas(canvas) do - # Use Canvas.to_strings/1 to convert buffer to lines - lines = - canvas - |> Canvas.to_strings() - |> Enum.map(fn row -> text("│" <> row <> "│", nil) end) - - # Add borders - top_border = text("┌" <> String.duplicate("─", canvas.width) <> "┐", nil) - bottom_border = text("└" <> String.duplicate("─", canvas.width) <> "┘", nil) - - stack(:vertical, [top_border | lines] ++ [bottom_border]) - end - - # ---------------------------------------------------------------------------- - # Demo Drawing Functions - # ---------------------------------------------------------------------------- - - defp draw_shapes_demo(state) do - state - # Draw title text - |> Canvas.draw_text(2, 1, "Basic Shapes Demo") - # Draw horizontal line - |> Canvas.draw_hline(2, 3, 20, "─") - # Draw vertical line - |> Canvas.draw_vline(25, 3, 8, "│") - # Draw diagonal line using dots - |> Canvas.draw_line(30, 3, 45, 10, "•") - # Draw some points - |> Canvas.draw_text(2, 5, "Points: ") - |> Canvas.set_char(10, 5, "●") - |> Canvas.set_char(12, 5, "○") - |> Canvas.set_char(14, 5, "◆") - |> Canvas.set_char(16, 5, "◇") - # Draw labels - |> Canvas.draw_text(2, 8, "H-Line above") - |> Canvas.draw_text(27, 6, "V") - |> Canvas.draw_text(32, 12, "Diagonal") - end - - defp draw_boxes_demo(state) do - state - # Draw title - |> Canvas.draw_text(2, 1, "Box Drawing Demo") - # Draw a simple box - |> Canvas.draw_rect(2, 3, 15, 5) - |> Canvas.draw_text(4, 5, "Box 1") - # Draw another box with double lines - |> Canvas.draw_rect(20, 3, 15, 5, %{ - h: "═", - v: "║", - tl: "╔", - tr: "╗", - bl: "╚", - br: "╝" - }) - |> Canvas.draw_text(22, 5, "Box 2") - # Draw a box with rounded corners - |> Canvas.draw_rect(2, 9, 15, 5, %{ - h: "─", - v: "│", - tl: "╭", - tr: "╮", - bl: "╰", - br: "╯" - }) - |> Canvas.draw_text(4, 11, "Rounded") - # Draw nested boxes - |> Canvas.draw_rect(20, 9, 20, 5) - |> Canvas.draw_rect(22, 10, 16, 3) - |> Canvas.draw_text(25, 11, "Nested") - end - - defp draw_braille_demo(state) do - # For the braille demo, we need to use braille_buffer - # Each character cell is 2 dots wide x 4 dots high - - state - |> Canvas.draw_text(2, 1, "Braille Drawing Demo") - |> Canvas.draw_text(2, 3, "Sub-character resolution using Braille patterns:") - # Show the braille characters - |> Canvas.draw_text(2, 5, "Empty: " <> Canvas.empty_braille()) - |> Canvas.draw_text(12, 5, "Full: " <> Canvas.full_braille()) - # Show individual dot positions - |> Canvas.draw_text(2, 7, "Dot positions in a cell:") - |> Canvas.draw_text(2, 8, "1 4") - |> Canvas.draw_text(2, 9, "2 5") - |> Canvas.draw_text(2, 10, "3 6") - |> Canvas.draw_text(2, 11, "7 8") - # Draw some braille patterns - |> Canvas.draw_text(10, 7, "Patterns:") - |> Canvas.draw_text(10, 8, Canvas.dots_to_braille([{0, 0}])) - |> Canvas.draw_text(12, 8, Canvas.dots_to_braille([{1, 0}])) - |> Canvas.draw_text(14, 8, Canvas.dots_to_braille([{0, 1}])) - |> Canvas.draw_text(16, 8, Canvas.dots_to_braille([{0, 0}, {1, 1}])) - |> Canvas.draw_text(18, 8, Canvas.dots_to_braille([{0, 0}, {0, 1}, {0, 2}, {0, 3}])) - |> Canvas.draw_text(20, 8, Canvas.dots_to_braille([{0, 0}, {1, 0}, {0, 1}, {1, 1}])) - # Resolution info - |> Canvas.draw_text(2, 13, "Canvas: #{@canvas_width}x#{@canvas_height} chars = #{@canvas_width * 2}x#{@canvas_height * 4} braille dots") - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Run the canvas example application. - """ - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/canvas/lib/canvas/application.ex b/examples/canvas/lib/canvas/application.ex deleted file mode 100644 index 64e5b796..00000000 --- a/examples/canvas/lib/canvas/application.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule Canvas.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [] - opts = [strategy: :one_for_one, name: Canvas.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/examples/canvas/mix.exs b/examples/canvas/mix.exs deleted file mode 100644 index d5e080a7..00000000 --- a/examples/canvas/mix.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule Canvas.MixProject do - use Mix.Project - - def project do - [ - app: :canvas, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {Canvas.Application, []} - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/canvas/mix.lock b/examples/canvas/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/canvas/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/canvas/run.exs b/examples/canvas/run.exs deleted file mode 100644 index 405f624e..00000000 --- a/examples/canvas/run.exs +++ /dev/null @@ -1 +0,0 @@ -Canvas.App.run() diff --git a/examples/cluster_dashboard/README.md b/examples/cluster_dashboard/README.md deleted file mode 100644 index ab78a470..00000000 --- a/examples/cluster_dashboard/README.md +++ /dev/null @@ -1,167 +0,0 @@ -# ClusterDashboard Widget Example - -This example demonstrates the TermUI ClusterDashboard widget for visualizing and monitoring distributed Erlang/BEAM clusters. - -## Widget Overview - -The ClusterDashboard widget provides comprehensive cluster monitoring and debugging capabilities for distributed BEAM applications. It displays: - -- **Nodes view** - Connected nodes with status indicators and health metrics -- **Global names** - Cross-node process registry (`:global` module) -- **PG groups** - Process group membership (`:pg` module) -- **Events log** - Node connection/disconnection history -- **Network partition detection** - Alerts when multiple nodes disconnect -- **Remote inspection** - RPC-based node details - -Use ClusterDashboard when building distributed applications that need visibility into cluster topology, node health, process distribution, and connection stability. - -## Widget Options - -The `ClusterDashboard.new/1` function accepts the following options: - -- `:update_interval` - Refresh interval in milliseconds (default: 2000) -- `:show_health_metrics` - Fetch and display CPU/memory/load (default: `true`) -- `:show_pg_groups` - Display `:pg` process groups (default: `true`) -- `:show_global_names` - Display `:global` registered names (default: `true`) -- `:on_node_select` - Callback function when node is selected - -## Example Structure - -The example consists of: - -- `lib/cluster_dashboard/app.ex` - Main application demonstrating: - - Cluster monitoring with automatic refresh - - View switching between nodes, globals, PG groups, and events - - Interactive navigation and details panels - - Test functions for spawning global processes and joining PG groups - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/cluster_dashboard -mix termui.run -``` - -Or manually: - -```bash -cd examples/cluster_dashboard -mix run -e "ClusterDashboardExample.App.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/cluster_dashboard -iex -S mix -``` - -Then in IEx: - -```elixir -ClusterDashboardExample.App.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -### Multiple Nodes (Distributed) - -To see the full cluster capabilities, start multiple nodes: - -**Terminal 1:** -```bash -iex --sname node1 -S mix -``` -```elixir -ClusterDashboardExample.App.run() -``` - -**Terminal 2:** -```bash -iex --sname node2 -S mix -``` -```elixir -Node.connect(:node1@hostname) # Replace hostname with your machine name -``` - -**Terminal 3:** -```bash -iex --sname node3 -S mix -``` -```elixir -Node.connect(:node1@hostname) -``` - -The dashboard on node1 will show all connected nodes with their metrics. - -## Controls - -**View switching:** -- `n` - Switch to Nodes view -- `g` - Switch to Global names view -- `p` - Switch to PG groups view -- `e` - Switch to Events view - -**Navigation:** -- `↑` / `↓` - Navigate through list items -- `PageUp` / `PageDown` - Scroll by page -- `Home` - Jump to first item -- `End` - Jump to last item - -**Actions:** -- `Enter` - Toggle details panel for selected item -- `i` - Inspect selected node (in Nodes view) -- `r` - Refresh data now -- `Escape` - Close details panel / clear alerts -- `q` - Quit application - -**Testing:** -- `G` - Register a test global process -- `P` - Join a test PG group - -## Implementation Notes - -The example demonstrates: - -- **Real-time monitoring** - Automatic data refresh at configurable intervals -- **Node monitoring** - Subscribe to `:nodeup` and `:nodedown` events -- **Health metrics** - Fetch process count, memory usage, scheduler info via RPC -- **Multiple views** - Switch between different cluster aspects -- **Scrollable lists** - Handle large datasets with viewport scrolling -- **Details panels** - Show expanded information for selected items -- **Network partition detection** - Alert when multiple nodes disconnect rapidly -- **Event logging** - Track connection/disconnection history with timestamps - -### Node Health Metrics - -The dashboard displays: -- **Process count** - Number of running processes -- **Memory usage** - Total and process memory (formatted as B/KB/MB/GB) -- **Scheduler count** - Number of online schedulers -- **Uptime** - Node runtime duration -- **OTP release** - OTP version - -### Distributed Features - -- **:global names** - Shows processes registered globally across the cluster -- **:pg groups** - Shows process groups and their membership across nodes -- **RPC calls** - Remote procedure calls with timeout protection -- **Partition alerts** - Detects when 2+ nodes disconnect within 5 seconds - -## Use Cases - -- Monitor cluster health in production -- Debug distributed system issues -- Visualize process distribution across nodes -- Track node connectivity stability -- Inspect cross-node process registries -- Detect network partitions early diff --git a/examples/cluster_dashboard/lib/cluster_dashboard/app.ex b/examples/cluster_dashboard/lib/cluster_dashboard/app.ex deleted file mode 100644 index 6ce0e8e5..00000000 --- a/examples/cluster_dashboard/lib/cluster_dashboard/app.ex +++ /dev/null @@ -1,278 +0,0 @@ -defmodule ClusterDashboardExample.App do - @moduledoc """ - Example application demonstrating the ClusterDashboard widget. - - This example shows: - - Connected nodes display with status - - Node health metrics (processes, memory) - - Global registered names - - PG process groups - - Connection events log - - Network partition detection - - ## Running - - To test with a single node (non-distributed): - - cd examples/cluster_dashboard - mix deps.get - iex -S mix - ClusterDashboardExample.App.run() - - To test with multiple nodes (distributed): - - Terminal 1: - iex --sname node1 -S mix - ClusterDashboardExample.App.run() - - Terminal 2: - iex --sname node2 -S mix - Node.connect(:node1@hostname) - - Terminal 3: - iex --sname node3 -S mix - Node.connect(:node1@hostname) - - ## Controls - - - Up/Down: Navigate list - - PageUp/PageDown: Scroll by page - - Enter: Toggle details panel - - r: Refresh now - - n: Show nodes view - - g: Show global names view - - p: Show :pg groups view - - e: Show events view - - i: Inspect selected node (in nodes view) - - Escape: Close details / clear alerts - - G: Register a test global process - - P: Join a test PG group - - q: Quit - """ - - use TermUI.Elm - - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Widgets.ClusterDashboard - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- - - @doc """ - Initialize the component state. - """ - def init(_opts) do - props = - ClusterDashboard.new( - update_interval: 2000, - show_health_metrics: true, - show_pg_groups: true, - show_global_names: true - ) - - {:ok, dashboard_state} = ClusterDashboard.init(props) - - %{ - dashboard: dashboard_state, - message: "ClusterDashboard Example - Views: [n]odes [g]lobals [p]g [e]vents" - } - end - - @doc """ - Convert events to messages. - """ - # Navigation keys - forward to dashboard - def event_to_msg(%Event.Key{key: key}, _state) - when key in [:up, :down, :page_up, :page_down, :home, :end, :enter, :escape] do - {:msg, {:dashboard_event, %Event.Key{key: key}}} - end - - # View mode switches - def event_to_msg(%Event.Key{key: "n"}, _state), do: {:msg, {:view_mode, :nodes}} - def event_to_msg(%Event.Key{key: "g"}, _state), do: {:msg, {:view_mode, :globals}} - def event_to_msg(%Event.Key{key: "p"}, _state), do: {:msg, {:view_mode, :pg}} - def event_to_msg(%Event.Key{key: "e"}, _state), do: {:msg, {:view_mode, :events}} - def event_to_msg(%Event.Key{key: "i"}, _state), do: {:msg, :inspect_node} - def event_to_msg(%Event.Key{key: "r"}, _state), do: {:msg, :refresh} - - # Test actions - def event_to_msg(%Event.Key{key: "G"}, _state), do: {:msg, :spawn_global} - def event_to_msg(%Event.Key{key: "P"}, _state), do: {:msg, :join_pg} - - # Quit - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit} - - # Tick for auto-refresh - def event_to_msg(%Event.Tick{}, _state), do: {:msg, :tick} - - def event_to_msg(_event, _state), do: :ignore - - @doc """ - Update state based on messages. - """ - def update({:dashboard_event, event}, state) do - {:ok, dashboard} = ClusterDashboard.handle_event(event, state.dashboard) - {%{state | dashboard: dashboard}, []} - end - - def update({:view_mode, mode}, state) do - # Create a key event to switch view mode - key = case mode do - :nodes -> "n" - :globals -> "g" - :pg -> "p" - :events -> "e" - end - event = %Event.Key{key: key} - {:ok, dashboard} = ClusterDashboard.handle_event(event, state.dashboard) - message = case mode do - :nodes -> "Nodes view" - :globals -> "Global names view" - :pg -> "PG groups view" - :events -> "Events view" - end - {%{state | dashboard: dashboard, message: message}, []} - end - - def update(:inspect_node, state) do - event = %Event.Key{key: "i"} - {:ok, dashboard} = ClusterDashboard.handle_event(event, state.dashboard) - {%{state | dashboard: dashboard, message: "Inspecting node..."}, []} - end - - def update(:refresh, state) do - {:ok, dashboard} = ClusterDashboard.refresh(state.dashboard) - {%{state | dashboard: dashboard, message: "Refreshed"}, []} - end - - def update(:spawn_global, state) do - spawn_global_process() - {:ok, dashboard} = ClusterDashboard.refresh(state.dashboard) - {%{state | dashboard: dashboard, message: "Registered global process"}, []} - end - - def update(:join_pg, state) do - join_pg_group() - {:ok, dashboard} = ClusterDashboard.refresh(state.dashboard) - {%{state | dashboard: dashboard, message: "Joined :pg group"}, []} - end - - def update(:tick, state) do - # Check if dashboard needs refresh based on its update interval - {:ok, dashboard} = ClusterDashboard.handle_info(:refresh, state.dashboard) - {%{state | dashboard: dashboard}, []} - end - - def update(:quit, state) do - {state, [:quit]} - end - - @doc """ - Render the current state to a render tree. - """ - def view(state) do - area = %{x: 0, y: 0, width: 100, height: 25} - dashboard_view = ClusterDashboard.render(state.dashboard, area) - - # Pad message to ensure full display (avoid truncation) - padded_message = String.pad_trailing(state.message, 120) - - stack(:vertical, [ - text("ClusterDashboard Widget Example", Style.new(fg: :cyan, attrs: [:bold])), - text(padded_message, Style.new(fg: :yellow)), - text("", nil), - dashboard_view, - text("", nil), - render_controls() - ]) - end - - # ---------------------------------------------------------------------------- - # Private Helpers - # ---------------------------------------------------------------------------- - - defp render_controls do - box_width = 55 - inner_width = box_width - 2 - - top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐" - bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘" - - stack(:vertical, [ - text(top_border, Style.new(fg: :yellow)), - text("│" <> String.pad_trailing(" n/g/p/e Switch views", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Up/Down Navigate list", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Enter Toggle details panel", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" i Inspect selected node", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" r Refresh now", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" G Register test global process", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" P Join test PG group", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Escape Close details / clear alerts", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" q Quit", inner_width) <> "│", nil), - text(bottom_border, Style.new(fg: :yellow)) - ]) - end - - # Helper to spawn a test globally registered process - defp spawn_global_process do - name = :"test_global_#{System.unique_integer([:positive])}" - - pid = - spawn(fn -> - receive do - :stop -> :ok - end - end) - - case :global.register_name(name, pid) do - :yes -> :ok - :no -> :error - end - rescue - _ -> :error - end - - # Helper to join a PG group - defp join_pg_group do - group = :test_group - - # Ensure :pg is started - case :pg.start_link() do - {:ok, _} -> :ok - {:error, {:already_started, _}} -> :ok - _ -> :ok - end - - pid = - spawn(fn -> - receive do - :stop -> :ok - end - end) - - :pg.join(group, pid) - rescue - _ -> :error - catch - _, _ -> :error - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Run the example application. - - ## Examples - - # Run interactively - ClusterDashboardExample.App.run() - - """ - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/cluster_dashboard/lib/cluster_dashboard/application.ex b/examples/cluster_dashboard/lib/cluster_dashboard/application.ex deleted file mode 100644 index 35ed0d5f..00000000 --- a/examples/cluster_dashboard/lib/cluster_dashboard/application.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule ClusterDashboardExample.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [] - opts = [strategy: :one_for_one, name: ClusterDashboardExample.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/examples/cluster_dashboard/mix.exs b/examples/cluster_dashboard/mix.exs deleted file mode 100644 index a2da6fe1..00000000 --- a/examples/cluster_dashboard/mix.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule ClusterDashboardExample.MixProject do - use Mix.Project - - def project do - [ - app: :cluster_dashboard_example, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {ClusterDashboardExample.Application, []} - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/cluster_dashboard/mix.lock b/examples/cluster_dashboard/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/cluster_dashboard/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/cluster_dashboard/run.exs b/examples/cluster_dashboard/run.exs deleted file mode 100644 index 4a675b2a..00000000 --- a/examples/cluster_dashboard/run.exs +++ /dev/null @@ -1 +0,0 @@ -ClusterDashboardExample.App.run() diff --git a/examples/command_palette/README.md b/examples/command_palette/README.md deleted file mode 100644 index f1c7d906..00000000 --- a/examples/command_palette/README.md +++ /dev/null @@ -1,136 +0,0 @@ -# CommandPalette Widget Example - -This example demonstrates the TermUI CommandPalette widget, a simple command dropdown for filtering and selecting commands with keyboard input. - -## Widget Overview - -The CommandPalette widget provides a searchable command menu similar to typing `/` in applications like Claude Code, Slack, or Discord to see available commands. It features: - -- **Prefix filtering** - Type to narrow down command list -- **Keyboard navigation** - Arrow keys to select commands -- **Quick execution** - Enter to select command -- **Visible/hidden states** - Toggle dropdown display -- **Scrollable results** - Handle many commands with viewport scrolling - -Use CommandPalette when you want to provide a quick-access command menu, implement slash commands, or create a searchable action list without cluttering the UI with buttons or menus. - -## Widget Options - -The `CommandPalette.new/1` function accepts the following options: - -- `:commands` - List of command maps (required), each with: - - `:id` - Unique identifier (atom) - - `:label` - Display text shown in dropdown (string) - - `:action` - Function to execute when selected (0-arity function) -- `:max_visible` - Maximum visible results in dropdown (default: 8) - -## Example Structure - -The example consists of: - -- `lib/command_palette/app.ex` - Main application demonstrating: - - Opening palette with `/` key - - Filtering commands as user types - - Selecting and "executing" commands - - Displaying execution results - - Managing palette visibility state - -The example includes sample commands like `/help`, `/save`, `/quit`, `/settings`, etc. - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/command_palette -mix termui.run -``` - -Or manually: - -```bash -cd examples/command_palette -mix run -e "CommandPalette.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/command_palette -iex -S mix -``` - -Then in IEx: - -```elixir -CommandPalette.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -**When palette is closed:** -- `/` - Open command dropdown - -**When palette is open:** -- Type any character - Add to search query and filter commands -- `Backspace` - Remove last character from query -- `↑` / `↓` - Navigate through filtered results -- `Enter` - Select command (closes palette and sets query) -- `Escape` - Close palette without selecting - -**General:** -- `Q` - Quit application (when palette closed) - -## Implementation Notes - -The example demonstrates: - -- **Dynamic filtering** - Commands are filtered in real-time as the user types -- **State management** - Tracking query, filtered results, selection, and visibility -- **Keyboard handling** - Different event handling based on palette state -- **Scroll management** - Keeping selected item visible in viewport -- **Result display** - Showing last executed command - -### Implementation Pattern - -The example shows a common pattern for command palettes: - -1. User presses trigger key (`/`) -2. Palette opens with all commands visible -3. User types to filter commands -4. Arrow keys navigate filtered results -5. Enter selects command (in this example, it populates the query rather than executing) -6. Application handles the selected command - -### Extending the Example - -To make commands executable immediately (instead of just populating the query): - -```elixir -def update({:palette_event, %Event.Key{key: :enter}}, state) do - case CommandPalette.get_selected(state.palette) do - nil -> - {state, []} - command -> - command.action.() # Execute the action - {state, []} - end -end -``` - -## Use Cases - -- Slash command interfaces (like Slack, Discord) -- Quick command launchers -- Action menus without permanent UI elements -- Searchable function lists -- Keyboard-driven navigation systems diff --git a/examples/command_palette/lib/command_palette.ex b/examples/command_palette/lib/command_palette.ex deleted file mode 100644 index 756d5d5e..00000000 --- a/examples/command_palette/lib/command_palette.ex +++ /dev/null @@ -1,7 +0,0 @@ -defmodule CommandPalette do - @moduledoc """ - CommandPalette example entry point. - """ - - defdelegate run, to: CommandPalette.App -end diff --git a/examples/command_palette/lib/command_palette/app.ex b/examples/command_palette/lib/command_palette/app.ex deleted file mode 100644 index 388b6e93..00000000 --- a/examples/command_palette/lib/command_palette/app.ex +++ /dev/null @@ -1,158 +0,0 @@ -defmodule CommandPalette.App do - @moduledoc """ - Command Palette Widget Example - - Demonstrates a simple command dropdown triggered by typing `/`. - Similar to how Claude Code shows available slash commands. - - Controls: - - `/` opens the command dropdown - - Type to filter commands - - Up/Down to navigate - - Enter to execute selected command - - Escape to close - - Q (when dropdown closed) to quit - """ - - use TermUI.Elm - - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Widgets.CommandPalette - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- - - def init(_opts) do - # Create command palette (initially hidden) - palette_props = CommandPalette.new(commands: available_commands()) - {:ok, palette} = CommandPalette.init(palette_props) - palette = CommandPalette.hide(palette) - - %{ - palette: palette, - message: nil - } - end - - defp available_commands do - [ - %{id: :help, label: "/help", action: fn -> :ok end}, - %{id: :clear, label: "/clear", action: fn -> :ok end}, - %{id: :save, label: "/save", action: fn -> :ok end}, - %{id: :open, label: "/open", action: fn -> :ok end}, - %{id: :new, label: "/new", action: fn -> :ok end}, - %{id: :quit, label: "/quit", action: fn -> :ok end}, - %{id: :settings, label: "/settings", action: fn -> :ok end}, - %{id: :theme, label: "/theme", action: fn -> :ok end}, - %{id: :format, label: "/format", action: fn -> :ok end}, - %{id: :search, label: "/search", action: fn -> :ok end} - ] - end - - def event_to_msg(%Event.Key{key: key}, %{palette: palette}) do - if CommandPalette.visible?(palette) do - {:msg, {:palette_event, %Event.Key{key: key}}} - else - query = CommandPalette.get_query(palette) - - case key do - "/" -> {:msg, :open_palette} - :enter when query != "" -> {:msg, :execute_command} - "q" -> {:msg, :quit} - "Q" -> {:msg, :quit} - _ -> :ignore - end - end - end - - def event_to_msg(_event, _state), do: :ignore - - def update(:open_palette, state) do - palette = CommandPalette.show(state.palette) - {%{state | palette: palette, message: nil}, []} - end - - def update({:palette_event, event}, state) do - {:ok, palette} = CommandPalette.handle_event(event, state.palette) - {%{state | palette: palette}, []} - end - - def update(:execute_command, state) do - query = CommandPalette.get_query(state.palette) - # Find matching command - cmd = Enum.find(available_commands(), fn c -> c.label == query end) - - message = - if cmd do - if is_function(cmd.action, 0), do: cmd.action.() - "Executed: #{cmd.label}" - else - "Unknown command: #{query}" - end - - # Reset palette - palette = CommandPalette.show(state.palette) - palette = CommandPalette.hide(palette) - - {%{state | palette: palette, message: message}, []} - end - - def update(:quit, state) do - {state, [:quit]} - end - - def view(state) do - stack(:vertical, [ - text("Command Palette Example", Style.new(fg: :cyan, attrs: [:bold])), - text("", nil), - text("Press / to open the command dropdown", nil), - text("", nil), - render_message(state.message), - render_palette(state), - text("", nil), - render_controls() - ]) - end - - defp render_message(nil), do: text("", nil) - defp render_message(msg), do: text(msg, Style.new(fg: :green)) - - defp render_palette(state) do - query = CommandPalette.get_query(state.palette) - - if CommandPalette.visible?(state.palette) do - stack(:vertical, [ - text("/" <> query, Style.new(fg: :yellow)), - CommandPalette.render(state.palette, %{}) - ]) - else - if query != "" do - text(query <> " (press Enter to execute)", Style.new(fg: :yellow)) - else - text("", nil) - end - end - end - - defp render_controls do - stack(:vertical, [ - text("Controls:", Style.new(fg: :yellow)), - text(" / Open command dropdown", nil), - text(" Type Filter commands", nil), - text(" Up/Down Navigate", nil), - text(" Enter Execute command", nil), - text(" Escape Close dropdown", nil), - text(" Q Quit", nil) - ]) - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/command_palette/mix.exs b/examples/command_palette/mix.exs deleted file mode 100644 index 9cf18812..00000000 --- a/examples/command_palette/mix.exs +++ /dev/null @@ -1,25 +0,0 @@ -defmodule CommandPalette.MixProject do - use Mix.Project - - def project do - [ - app: :command_palette, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger] - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/command_palette/mix.lock b/examples/command_palette/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/command_palette/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/command_palette/run.exs b/examples/command_palette/run.exs deleted file mode 100644 index 3c1f50b0..00000000 --- a/examples/command_palette/run.exs +++ /dev/null @@ -1 +0,0 @@ -CommandPalette.App.run() diff --git a/examples/context_menu/README.md b/examples/context_menu/README.md deleted file mode 100644 index 76b87b35..00000000 --- a/examples/context_menu/README.md +++ /dev/null @@ -1,108 +0,0 @@ -# ContextMenu Widget Example - -This example demonstrates the ContextMenu widget for displaying floating menus at cursor position, typically triggered by right-click or keyboard shortcuts. - -## Widget Overview - -The ContextMenu widget provides context-sensitive menus that appear at a specific screen position. It's ideal for: - -- Right-click context menus -- Location-specific action lists -- Dropdown menus at arbitrary positions -- Quick action palettes - -**Key Features:** -- Floating overlay positioned at exact coordinates -- Keyboard navigation (Up/Down/Enter/Escape) -- Mouse hover highlighting and click selection -- Automatic closure on selection or outside click -- Support for separators and disabled items -- Shortcut hints display -- Custom styling for different item states - -## Widget Options - -The `ContextMenu.new/1` function accepts the following options: - -- `:items` (required) - List of menu items created with `ContextMenu.action/3` or `ContextMenu.separator/0` -- `:position` (required) - `{x, y}` tuple for menu position on screen -- `:on_select` - Callback function `(item_id -> any)` when item is selected -- `:on_close` - Callback function `(() -> any)` when menu is closed -- `:item_style` - Style for normal items -- `:selected_style` - Style for focused/hovered item -- `:disabled_style` - Style for disabled items - -**Menu Item Helpers:** -- `ContextMenu.action(id, label, opts)` - Create an action item - - `:shortcut` - Display shortcut hint (e.g., "Ctrl+X") - - `:disabled` - Whether item is disabled -- `ContextMenu.separator()` - Create a separator line - -## Example Structure - -``` -context_menu/ -├── lib/ -│ └── context_menu/ -│ └── app.ex # Main application component -├── mix.exs # Project configuration -└── README.md # This file -``` - -**app.ex** - Implements the Elm Architecture pattern: -- Maintains menu state (position, visibility, selection) -- Handles right-click events to show menu at mouse position -- Forwards keyboard/mouse events to menu widget when visible -- Tracks last selected action for demonstration - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/context_menu -mix termui.run -``` - -Or manually: - -```bash -cd examples/context_menu -mix run -e "ContextMenu.App.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/context_menu -iex -S mix -``` - -Then in IEx: - -```elixir -ContextMenu.App.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -- **Right-click** - Show context menu at click position -- **1/2/3** - Show context menu at preset positions (top-left, center, right) -- **Up/Down** - Navigate menu items (when menu visible) -- **Enter/Space** - Select highlighted item -- **Escape** - Close menu without selecting -- **Q** - Quit the application - -**Mouse Support:** -- Hover over items to highlight them -- Click on item to select it -- Click outside menu to close without selecting diff --git a/examples/context_menu/lib/context_menu/app.ex b/examples/context_menu/lib/context_menu/app.ex deleted file mode 100644 index b81e32d6..00000000 --- a/examples/context_menu/lib/context_menu/app.ex +++ /dev/null @@ -1,208 +0,0 @@ -defmodule ContextMenu.App do - @moduledoc """ - Context Menu Widget Example - - This example demonstrates how to use the TermUI.Widgets.ContextMenu widget - for displaying floating menus at cursor position. - - Features demonstrated: - - Right-click to show context menu - - Keyboard navigation (Up/Down) - - Selection with Enter/Space - - Close on Escape or outside click - - Different menu positions - - Disabled items - - Controls: - - Right-click: Show context menu at click position - - 1/2/3: Show context menu at different positions - - Up/Down: Navigate menu items (when menu visible) - - Enter/Space: Select item (when menu visible) - - Escape: Close menu - - Q: Quit the application - """ - - use TermUI.Elm - - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Widgets.ContextMenu - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- - - @doc """ - Initialize the component state. - """ - def init(_opts) do - %{ - # Context menu state (nil when no menu visible) - menu: nil, - # Result tracking - last_action: nil - } - end - - @doc """ - Convert keyboard events to messages. - """ - # Menu closed - show menu at different positions - def event_to_msg(%Event.Key{key: "1"}, %{menu: nil}), do: {:msg, {:show_menu, {5, 5}}} - def event_to_msg(%Event.Key{key: "2"}, %{menu: nil}), do: {:msg, {:show_menu, {20, 8}}} - def event_to_msg(%Event.Key{key: "3"}, %{menu: nil}), do: {:msg, {:show_menu, {35, 5}}} - - # Mouse events - show menu on right-click (action is :press from terminal) - def event_to_msg(%Event.Mouse{action: :press, button: :right, x: x, y: y}, %{menu: nil}) do - {:msg, {:show_menu, {x, y}}} - end - - # When menu is visible, forward events to the menu widget - def event_to_msg(event, %{menu: menu}) when menu != nil, do: {:msg, {:menu_event, event}} - - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit} - def event_to_msg(_event, _state), do: :ignore - - @doc """ - Update state based on messages. - """ - def update({:show_menu, position}, state) do - {show_menu(state, position), []} - end - - def update({:menu_event, event}, state) do - case ContextMenu.handle_event(event, state.menu) do - {:ok, new_menu} -> - if ContextMenu.visible?(new_menu) do - {%{state | menu: new_menu}, []} - else - # Menu was closed - capture result if item was selected - result = ContextMenu.get_cursor(new_menu) - {%{state | menu: nil, last_action: format_action(result)}, []} - end - end - end - - def update(:quit, state) do - {state, [:quit]} - end - - # Helper to create and initialize a context menu - defp show_menu(state, position) do - props = ContextMenu.new( - items: menu_items(), - position: position, - selected_style: Style.new(fg: :black, bg: :cyan), - disabled_style: Style.new(fg: :bright_black) - ) - {:ok, menu} = ContextMenu.init(props) - %{state | menu: menu} - end - - defp menu_items do - [ - ContextMenu.action(:cut, "Cut", shortcut: "Ctrl+X"), - ContextMenu.action(:copy, "Copy", shortcut: "Ctrl+C"), - ContextMenu.action(:paste, "Paste", shortcut: "Ctrl+V"), - ContextMenu.separator(), - ContextMenu.action(:select_all, "Select All", shortcut: "Ctrl+A"), - ContextMenu.separator(), - ContextMenu.action(:disabled_item, "Disabled Item", disabled: true), - ContextMenu.action(:delete, "Delete", shortcut: "Del") - ] - end - - defp format_action(nil), do: "Cancelled" - defp format_action(action), do: "Selected: #{action}" - - @doc """ - Render the current state to a render tree. - """ - def view(state) do - main_content = render_main_content(state) - - if state.menu != nil do - stack(:vertical, [ - main_content, - text("", nil), - ContextMenu.render(state.menu, %{width: 80, height: 24}) - ]) - else - main_content - end - end - - # ---------------------------------------------------------------------------- - # Private Helpers - # ---------------------------------------------------------------------------- - - defp render_main_content(state) do - stack(:vertical, [ - # Title - text("Context Menu Widget Example", Style.new(fg: :cyan, attrs: [:bold])), - text("", nil), - - # Instructions - render_instructions(), - - # Controls - render_controls(state) - ]) - end - - defp render_instructions do - stack(:vertical, [ - text("Right-click anywhere or press 1/2/3 to show context menu", nil), - text("", nil), - text(" Position 1: Top-left area (key: 1)", nil), - text(" Position 2: Center area (key: 2)", nil), - text(" Position 3: Right area (key: 3)", nil), - text("", nil), - # Large click area for right-click testing - text("┌" <> String.duplicate("─", 58) <> "┐", Style.new(fg: :bright_black)), - text("│" <> String.pad_trailing("", 58) <> "│", Style.new(fg: :bright_black)), - text("│" <> String.pad_trailing(" Right-click in this area to open context menu", 58) <> "│", Style.new(fg: :bright_black)), - text("│" <> String.pad_trailing("", 58) <> "│", Style.new(fg: :bright_black)), - text("│" <> String.pad_trailing("", 58) <> "│", Style.new(fg: :bright_black)), - text("│" <> String.pad_trailing("", 58) <> "│", Style.new(fg: :bright_black)), - text("│" <> String.pad_trailing("", 58) <> "│", Style.new(fg: :bright_black)), - text("│" <> String.pad_trailing("", 58) <> "│", Style.new(fg: :bright_black)), - text("└" <> String.duplicate("─", 58) <> "┘", Style.new(fg: :bright_black)), - text("", nil) - ]) - end - - defp render_controls(state) do - box_width = 50 - inner_width = box_width - 2 - - top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐" - bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘" - - stack(:vertical, [ - text("", nil), - text(top_border, Style.new(fg: :yellow)), - text("│" <> String.pad_trailing(" Right-click Show context menu", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" 1/2/3 Show at preset positions", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" ↑/↓ Navigate items (menu open)", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Enter/Space Select item", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Escape Close menu", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil), - text("│" <> String.pad_trailing("", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Last action: #{state.last_action || "(none)"}", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Menu visible: #{state.menu != nil}", inner_width) <> "│", nil), - text(bottom_border, Style.new(fg: :yellow)) - ]) - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Run the context menu example application. - """ - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/context_menu/lib/context_menu/application.ex b/examples/context_menu/lib/context_menu/application.ex deleted file mode 100644 index f1e9849d..00000000 --- a/examples/context_menu/lib/context_menu/application.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule ContextMenu.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [] - opts = [strategy: :one_for_one, name: ContextMenu.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/examples/context_menu/mix.exs b/examples/context_menu/mix.exs deleted file mode 100644 index 639cab55..00000000 --- a/examples/context_menu/mix.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule ContextMenu.MixProject do - use Mix.Project - - def project do - [ - app: :context_menu, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {ContextMenu.Application, []} - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/context_menu/mix.lock b/examples/context_menu/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/context_menu/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/context_menu/run.exs b/examples/context_menu/run.exs deleted file mode 100644 index 20e70df9..00000000 --- a/examples/context_menu/run.exs +++ /dev/null @@ -1 +0,0 @@ -ContextMenu.App.run() diff --git a/examples/dashboard/.tool-versions b/examples/dashboard/.tool-versions deleted file mode 100644 index 21aee5bb..00000000 --- a/examples/dashboard/.tool-versions +++ /dev/null @@ -1 +0,0 @@ -erlang 28.1.1 diff --git a/examples/dashboard/README.md b/examples/dashboard/README.md deleted file mode 100644 index 3612a316..00000000 --- a/examples/dashboard/README.md +++ /dev/null @@ -1,121 +0,0 @@ -# Dashboard Example - -This example demonstrates building a comprehensive system monitoring dashboard using multiple TermUI widgets including Gauge, Sparkline, and Table components. - -## Overview - -The dashboard displays real-time system metrics in a terminal-based interface. While this example uses the Dashboard namespace rather than a single widget, it showcases how to compose multiple widgets into a cohesive application. - -**Key Features:** -- CPU and memory usage gauges with color zones -- Network traffic sparklines (RX/TX) -- Process table with selection -- System information display -- Theme switching (dark/light) -- Responsive layout with bordered sections - -**Widgets Demonstrated:** -- `TermUI.Widgets.Gauge` - CPU and memory percentage bars -- `TermUI.Widgets.Sparkline` - Network traffic history -- `TermUI.Widgets.Table.Column` - Process table formatting - -## Example Structure - -``` -dashboard/ -├── lib/ -│ ├── dashboard/ -│ │ ├── app.ex # Main dashboard component -│ │ ├── application.ex # OTP application -│ │ └── data/ -│ │ └── metrics.ex # Mock metrics generator -│ └── dashboard.ex # Application entry point -├── mix.exs # Project configuration -└── README.md # This file -``` - -**app.ex** - Main dashboard implementation: -- Implements Elm Architecture (init/update/view) -- Composes gauges, sparklines, and tables -- Handles theme switching -- Manages process selection - -**metrics.ex** - Provides simulated system metrics: -- CPU and memory percentages -- Network RX/TX data streams -- Process list with stats -- System info (hostname, uptime, load average) - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/dashboard -mix termui.run -``` - -Or manually: - -```bash -cd examples/dashboard -mix run -e "Dashboard.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/dashboard -iex -S mix -``` - -Then in IEx: - -```elixir -Dashboard.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -- **Q** - Quit the application -- **R** - Refresh display (triggers re-render) -- **T** - Toggle theme between dark and light -- **Up/Down** - Navigate through process list - -## Layout Details - -The dashboard uses a fixed-width layout (58 characters) with these sections: - -1. **Header** - Title with decorative border -2. **Gauges Row** - CPU and Memory gauges side-by-side with color zones -3. **System Info** - Hostname, uptime, and load averages -4. **Network Section** - RX/TX sparklines showing traffic history -5. **Process Table** - Sortable process list with PID, name, CPU%, and memory -6. **Controls Bar** - Help text with keyboard shortcuts - -**Color Zones:** -- CPU Gauge: Green (0-59%), Yellow (60-79%), Red (80-100%) -- Memory Gauge: Green (0-69%), Yellow (70-84%), Red (85-100%) - -## Themes - -**Dark Theme:** -- Cyan borders and headers -- White text on black background -- Green/blue sparklines -- Cyan selection highlight - -**Light Theme:** -- Yellow borders and headers -- Bright white text -- Bright green/cyan sparklines -- Yellow selection highlight diff --git a/examples/dashboard/lib/dashboard.ex b/examples/dashboard/lib/dashboard.ex deleted file mode 100644 index df982885..00000000 --- a/examples/dashboard/lib/dashboard.ex +++ /dev/null @@ -1,66 +0,0 @@ -defmodule Dashboard do - @moduledoc """ - A system monitoring dashboard example for TermUI. - - This application demonstrates: - - Multiple widget types (gauges, charts, tables) - - Layout system with nested constraints - - Real-time updates using commands - - Keyboard navigation and shortcuts - - Theme switching - - ## Running - - cd examples/dashboard - mix deps.get - mix run --no-halt - - ## Controls - - - `q` - Quit the application - - `r` - Force refresh data - - `t` - Toggle theme (dark/light) - - `Tab` - Navigate between focusable widgets - - `↑/↓` - Scroll process table - """ - - @doc """ - Run the dashboard example application. - - This is the main entry point for both IEx and command line use. - - ## From IEx - - iex> Dashboard.run() - # Dashboard takes over terminal, press Q to quit - - ## From command line - - mix termui.run - """ - def run do - TermUI.Runtime.run(root: Dashboard.App) - end - - @doc """ - Starts the dashboard interactively, blocking until the user quits. - - This is an alias for `run/0` for backward compatibility. - """ - def start do - run() - end - - @doc """ - Starts the dashboard as a linked process (non-blocking). - - Returns `{:ok, pid}` immediately. Useful for embedding in supervision - trees or programmatic control. Note: keyboard input will NOT work when - called from IEx because IEx's prompt competes for terminal input. - - For interactive use from IEx, use `start/0` instead. - """ - def start_link do - TermUI.Runtime.start_link(root: Dashboard.App) - end -end diff --git a/examples/dashboard/lib/dashboard/app.ex b/examples/dashboard/lib/dashboard/app.ex deleted file mode 100644 index 0de42d72..00000000 --- a/examples/dashboard/lib/dashboard/app.ex +++ /dev/null @@ -1,383 +0,0 @@ -defmodule Dashboard.App do - @moduledoc """ - Main dashboard application component. - - Displays system metrics including CPU, memory, network, and processes - in a terminal-based dashboard layout. - - ## Running - - cd examples/dashboard - mix deps.get - mix termui.run - - ## Controls - - - `q` - Quit the application - - `r` - Force refresh data - - `t` - Toggle theme (dark/light) - - `Tab` - Navigate between focusable widgets - - `↑/↓` - Scroll process table - """ - - use TermUI.Elm - - @doc """ - Run the dashboard example application. - """ - def run do - TermUI.Runtime.run(root: __MODULE__) - end - - alias Dashboard.Data.Metrics - alias TermUI.Event - alias TermUI.Layout.Constraint - alias TermUI.Renderer.Style - alias TermUI.Widgets.Gauge - alias TermUI.Widgets.Sparkline - alias TermUI.Widgets.Table.Column - - # Elm callbacks - - def init(_opts) do - %{ - theme: :dark, - selected_process: 0 - } - end - - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["r", "R"], do: {:msg, :refresh} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["t", "T"], do: {:msg, :toggle_theme} - def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :select_next} - def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :select_prev} - def event_to_msg(_, _state), do: :ignore - - def update(:quit, state) do - # Return :quit command to trigger runtime shutdown - {state, [:quit]} - end - - def update(:refresh, state) do - # Manual refresh just triggers a re-render - {state, []} - end - - def update(:toggle_theme, state) do - new_theme = if state.theme == :dark, do: :light, else: :dark - {%{state | theme: new_theme}, []} - end - - def update(:select_next, state) do - metrics = Metrics.get_metrics() - process_count = length(metrics.processes) - new_selected = min(state.selected_process + 1, max(0, process_count - 1)) - {%{state | selected_process: new_selected}, []} - end - - def update(:select_prev, state) do - new_selected = max(state.selected_process - 1, 0) - {%{state | selected_process: new_selected}, []} - end - - def update(_msg, state), do: {state, []} - - def view(state) do - theme = get_theme(state.theme) - # Fetch fresh metrics on each render - metrics = Metrics.get_metrics() - render_dashboard(state, metrics, theme) - end - - # Render helpers - - defp render_dashboard(state, metrics, theme) do - - # Build dashboard as vertical stack - stack(:vertical, [ - # Header - render_header(theme), - - # Top row with gauges and system info - stack(:horizontal, [ - render_cpu_gauge(metrics.cpu, theme), - render_memory_gauge(metrics.memory, theme), - render_system_info(theme) - ]), - - # Network section - render_network(metrics, theme), - - # Process table - render_processes(metrics.processes, state.selected_process, theme), - - # Help bar - render_help(theme) - ]) - end - - @dashboard_width 58 - - defp render_header(theme) do - title = " System Dashboard " - title_len = String.length(title) - total_padding = @dashboard_width - title_len - left_padding = div(total_padding, 2) - right_padding = total_padding - left_padding - - line = String.duplicate("═", left_padding) <> title <> String.duplicate("═", right_padding) - text(line, theme.header) - end - - defp render_cpu_gauge(cpu_value, theme) do - gauge_width = 12 - inner_width = gauge_width + 2 - - top_border = "┌─ CPU " <> String.duplicate("─", inner_width - 7) <> "─┐" - bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘" - - stack(:vertical, [ - text(top_border, theme.border), - stack(:horizontal, [ - text("│ ", theme.border), - Gauge.render( - value: cpu_value, - min: 0, - max: 100, - width: gauge_width, - show_value: false, - show_range: false, - zones: [ - {0, Style.new(fg: :green)}, - {60, Style.new(fg: :yellow)}, - {80, Style.new(fg: :red)} - ] - ), - text(" │", theme.border) - ]), - text("│" <> String.pad_trailing(format_percent(cpu_value), inner_width) <> "│", theme.text), - text(bottom_border, theme.border) - ]) - end - - defp render_memory_gauge(memory_value, theme) do - gauge_width = 12 - inner_width = gauge_width + 2 - - top_border = "┌─ Memory " <> String.duplicate("─", inner_width - 10) <> "─┐" - bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘" - - stack(:vertical, [ - text(top_border, theme.border), - stack(:horizontal, [ - text("│ ", theme.border), - Gauge.render( - value: memory_value, - min: 0, - max: 100, - width: gauge_width, - show_value: false, - show_range: false, - zones: [ - {0, Style.new(fg: :green)}, - {70, Style.new(fg: :yellow)}, - {85, Style.new(fg: :red)} - ] - ), - text(" │", theme.border) - ]), - text("│" <> String.pad_trailing(format_percent(memory_value), inner_width) <> "│", theme.text), - text(bottom_border, theme.border) - ]) - end - - defp render_system_info(theme) do - info = Metrics.get_system_info() - {load1, load2, load3} = info.load_avg - - # Calculate content to determine box width - host_line = " Host: #{info.hostname}" - up_line = " Up: #{info.uptime}" - load_line = " Load: #{load1} #{load2} #{load3}" - - # Find the widest content line and add padding - content_width = Enum.max([String.length(host_line), String.length(up_line), String.length(load_line)]) + 1 - - # Box width includes the border characters - box_width = content_width + 2 - - # Build the box - title = "─ System Info ─" - top_padding = box_width - String.length(title) - 2 - top_border = "┌" <> title <> String.duplicate("─", top_padding) <> "┐" - bottom_border = "└" <> String.duplicate("─", box_width - 2) <> "┘" - - stack(:vertical, [ - text(top_border, theme.border), - text(String.pad_trailing(host_line, content_width), theme.text), - text(String.pad_trailing(up_line, content_width), theme.text), - text(String.pad_trailing(load_line, content_width), theme.text), - text(bottom_border, theme.border) - ]) - end - - defp render_network(metrics, theme) do - # Match process table width - label_width = 6 # " RX: " or " TX: " - sparkline_width = @dashboard_width - label_width - 4 # 4 for borders and padding - - top_border = "┌─ Network " <> String.duplicate("─", @dashboard_width - 12) <> "┐" - bottom_border = "└" <> String.duplicate("─", @dashboard_width - 2) <> "┘" - - stack(:vertical, [ - text(top_border, theme.border), - stack(:horizontal, [ - text(" RX: ", theme.label), - Sparkline.render( - values: Enum.reverse(metrics.network_rx), - min: 0, - max: 100, - width: sparkline_width, - style: theme.sparkline_rx - ), - text(" ", nil) - ]), - stack(:horizontal, [ - text(" TX: ", theme.label), - Sparkline.render( - values: Enum.reverse(metrics.network_tx), - min: 0, - max: 100, - width: sparkline_width, - style: theme.sparkline_tx - ), - text(" ", nil) - ]), - text(bottom_border, theme.border) - ]) - end - - defp render_processes(processes, selected, theme) do - # Define columns using the Table.Column helpers - columns = [ - Column.new(:pid, "PID", width: Constraint.length(7)), - Column.new(:name, "Name", width: Constraint.length(20)), - Column.new(:cpu, "CPU%", width: Constraint.length(8), align: :right, render: &format_cpu/1), - Column.new(:memory, "Memory", width: Constraint.length(12), align: :right, render: &format_memory/1) - ] - - # Render header using Column alignment - header_text = - Enum.map_join(columns, " ", fn col -> - Column.align_text(col.header, get_column_width(col), col.align) - end) - - header = " " <> header_text - - # Build separator based on column widths - separator = - " " <> - Enum.map_join(columns, " ", fn col -> - String.duplicate("─", get_column_width(col)) - end) - - # Render rows using Column.render_cell - rows = - processes - |> Enum.with_index() - |> Enum.map(fn {proc, idx} -> - row_text = - " " <> - Enum.map_join(columns, " ", fn col -> - cell_value = Column.render_cell(col, proc) - Column.align_text(cell_value, get_column_width(col), col.align) - end) - - if idx == selected do - text(row_text, theme.table_selected) - else - text(row_text, theme.table_row) - end - end) - - stack(:vertical, [ - text("┌─ Processes ────────────────────────────────────────────┐", theme.border), - text(header, theme.table_header), - text(separator, theme.border) - | rows - ] ++ [text("└────────────────────────────────────────────────────────┘", theme.border)]) - end - - # Helper to extract column width from constraint - defp get_column_width(%Column{width: %Constraint.Length{value: v}}), do: v - defp get_column_width(_), do: 10 - - defp render_help(theme) do - controls = " [Q] Quit [R] Refresh [T] Theme [↑/↓] Navigate" - inner_width = @dashboard_width - 2 - - top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐" - bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘" - - stack(:vertical, [ - text("", nil), - text(top_border, theme.border), - text("│" <> String.pad_trailing(controls, inner_width) <> "│", theme.help), - text(bottom_border, theme.border) - ]) - end - - # Formatting helpers - - defp format_percent(value) do - value - |> Float.round(1) - |> to_string() - |> String.pad_leading(5) - |> Kernel.<>("%") - end - - defp format_cpu(value) do - "#{Float.round(value, 1)}%" - end - - defp format_memory(mb) do - if mb >= 1024 do - "#{Float.round(mb / 1024, 1)} GB" - else - "#{mb} MB" - end - end - - # Themes - - defp get_theme(:dark) do - %{ - header: Style.new(fg: :cyan, attrs: [:bold]), - border: Style.new(fg: :cyan), - text: Style.new(fg: :white), - label: Style.new(fg: :bright_black), - help: Style.new(fg: :bright_black), - sparkline_rx: Style.new(fg: :green), - sparkline_tx: Style.new(fg: :blue), - table_header: Style.new(fg: :cyan, attrs: [:bold]), - table_row: Style.new(fg: :white), - table_selected: Style.new(fg: :black, bg: :cyan) - } - end - - defp get_theme(:light) do - %{ - header: Style.new(fg: :yellow, attrs: [:bold]), - border: Style.new(fg: :yellow), - text: Style.new(fg: :bright_white), - label: Style.new(fg: :bright_black), - help: Style.new(fg: :bright_black), - sparkline_rx: Style.new(fg: :bright_green), - sparkline_tx: Style.new(fg: :bright_cyan), - table_header: Style.new(fg: :yellow, attrs: [:bold]), - table_row: Style.new(fg: :bright_white), - table_selected: Style.new(fg: :black, bg: :yellow) - } - end -end diff --git a/examples/dashboard/lib/dashboard/application.ex b/examples/dashboard/lib/dashboard/application.ex deleted file mode 100644 index 122a6efb..00000000 --- a/examples/dashboard/lib/dashboard/application.ex +++ /dev/null @@ -1,15 +0,0 @@ -defmodule Dashboard.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [ - Dashboard.Data.Metrics - ] - - opts = [strategy: :one_for_one, name: Dashboard.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/examples/dashboard/lib/dashboard/data/metrics.ex b/examples/dashboard/lib/dashboard/data/metrics.ex deleted file mode 100644 index 0a7dda34..00000000 --- a/examples/dashboard/lib/dashboard/data/metrics.ex +++ /dev/null @@ -1,255 +0,0 @@ -defmodule Dashboard.Data.Metrics do - @moduledoc """ - Generates simulated system metrics with realistic patterns. - - Metrics follow realistic patterns: - - CPU varies smoothly with occasional spikes - - Memory gradually increases then drops (simulating GC) - - Network has bursty patterns - - Processes have stable resource usage with slight variations - """ - - use GenServer - - @update_interval 1000 - - # State structure - defstruct [ - :cpu_history, - :memory_history, - :network_rx_history, - :network_tx_history, - :processes, - :uptime_seconds, - :cpu_base, - :memory_base, - :tick - ] - - # Public API - - def start_link(_opts) do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - def get_metrics do - GenServer.call(__MODULE__, :get_metrics) - end - - def get_cpu do - GenServer.call(__MODULE__, :get_cpu) - end - - def get_memory do - GenServer.call(__MODULE__, :get_memory) - end - - def get_network do - GenServer.call(__MODULE__, :get_network) - end - - def get_processes do - GenServer.call(__MODULE__, :get_processes) - end - - def get_system_info do - GenServer.call(__MODULE__, :get_system_info) - end - - # GenServer callbacks - - @impl true - def init(_opts) do - state = %__MODULE__{ - cpu_history: List.duplicate(25.0, 60), - memory_history: List.duplicate(45.0, 60), - network_rx_history: List.duplicate(0.0, 30), - network_tx_history: List.duplicate(0.0, 30), - processes: generate_initial_processes(), - uptime_seconds: :rand.uniform(86400 * 7), - cpu_base: 25.0, - memory_base: 45.0, - tick: 0 - } - - schedule_update() - {:ok, state} - end - - @impl true - def handle_call(:get_metrics, _from, state) do - metrics = %{ - cpu: current_cpu(state), - memory: current_memory(state), - network_rx: state.network_rx_history, - network_tx: state.network_tx_history, - processes: state.processes, - uptime: state.uptime_seconds - } - - {:reply, metrics, state} - end - - def handle_call(:get_cpu, _from, state) do - {:reply, %{current: current_cpu(state), history: state.cpu_history}, state} - end - - def handle_call(:get_memory, _from, state) do - {:reply, %{current: current_memory(state), history: state.memory_history}, state} - end - - def handle_call(:get_network, _from, state) do - {:reply, %{rx: state.network_rx_history, tx: state.network_tx_history}, state} - end - - def handle_call(:get_processes, _from, state) do - {:reply, state.processes, state} - end - - def handle_call(:get_system_info, _from, state) do - info = %{ - hostname: "localhost", - kernel: "Linux 6.8.0", - uptime: format_uptime(state.uptime_seconds), - load_avg: generate_load_avg(state) - } - - {:reply, info, state} - end - - @impl true - def handle_info(:update, state) do - new_state = update_metrics(state) - schedule_update() - {:noreply, new_state} - end - - # Private functions - - defp schedule_update do - Process.send_after(self(), :update, @update_interval) - end - - defp current_cpu(state), do: hd(state.cpu_history) - defp current_memory(state), do: hd(state.memory_history) - - defp update_metrics(state) do - tick = state.tick + 1 - - # Update CPU with smooth variations and occasional spikes - cpu_base = update_cpu_base(state.cpu_base, tick) - cpu_value = cpu_base + :rand.uniform() * 5 - 2.5 + spike_factor(tick, 0.05) * 30 - cpu_value = clamp(cpu_value, 5.0, 95.0) - - # Update memory with gradual increase and periodic drops (GC simulation) - memory_base = update_memory_base(state.memory_base, tick) - memory_value = memory_base + :rand.uniform() * 3 - 1.5 - memory_value = clamp(memory_value, 20.0, 85.0) - - # Update network with bursty patterns - rx_value = generate_network_value(tick, 0) - tx_value = generate_network_value(tick, 100) - - # Update processes with slight variations - processes = update_processes(state.processes) - - %{ - state - | cpu_history: [cpu_value | Enum.take(state.cpu_history, 59)], - memory_history: [memory_value | Enum.take(state.memory_history, 59)], - network_rx_history: [rx_value | Enum.take(state.network_rx_history, 29)], - network_tx_history: [tx_value | Enum.take(state.network_tx_history, 29)], - processes: processes, - uptime_seconds: state.uptime_seconds + 1, - cpu_base: cpu_base, - memory_base: memory_base, - tick: tick - } - end - - defp update_cpu_base(base, tick) do - # Slow sinusoidal variation - adjustment = :math.sin(tick / 30) * 5 - new_base = base + adjustment * 0.1 + (:rand.uniform() - 0.5) * 2 - clamp(new_base, 15.0, 60.0) - end - - defp update_memory_base(base, tick) do - # Gradual increase with periodic drops - if rem(tick, 60) == 0 do - # Simulate GC - drop memory - clamp(base - 10, 35.0, 75.0) - else - # Gradual increase - clamp(base + 0.2, 35.0, 75.0) - end - end - - defp spike_factor(_tick, probability) do - if :rand.uniform() < probability do - 1.0 - else - 0.0 - end - end - - defp generate_network_value(tick, offset) do - # Bursty network pattern - base = :math.sin((tick + offset) / 5) * 30 + 40 - burst = if :rand.uniform() < 0.1, do: :rand.uniform() * 50, else: 0 - clamp(base + burst + :rand.uniform() * 10, 0.0, 100.0) - end - - defp generate_initial_processes do - [ - %{pid: 1, name: "systemd", cpu: 0.1, memory: 12}, - %{pid: 234, name: "beam.smp", cpu: 8.5, memory: 256}, - %{pid: 456, name: "postgres", cpu: 3.2, memory: 128}, - %{pid: 789, name: "nginx", cpu: 1.1, memory: 48}, - %{pid: 1012, name: "redis-server", cpu: 2.4, memory: 64}, - %{pid: 1234, name: "node", cpu: 5.6, memory: 192}, - %{pid: 1456, name: "docker", cpu: 1.8, memory: 96}, - %{pid: 1678, name: "sshd", cpu: 0.2, memory: 8}, - %{pid: 1890, name: "cron", cpu: 0.0, memory: 4}, - %{pid: 2012, name: "rsyslogd", cpu: 0.3, memory: 16} - ] - end - - defp update_processes(processes) do - Enum.map(processes, fn proc -> - %{ - proc - | cpu: clamp(proc.cpu + (:rand.uniform() - 0.5) * 1.0, 0.0, 100.0), - memory: max(proc.memory + round((:rand.uniform() - 0.5) * 4), 1) - } - end) - |> Enum.sort_by(& &1.cpu, :desc) - end - - defp generate_load_avg(state) do - base = current_cpu(state) / 25 - { - Float.round(base + :rand.uniform() * 0.3, 2), - Float.round(base * 0.8 + :rand.uniform() * 0.2, 2), - Float.round(base * 0.6 + :rand.uniform() * 0.1, 2) - } - end - - defp format_uptime(seconds) do - days = div(seconds, 86400) - hours = div(rem(seconds, 86400), 3600) - minutes = div(rem(seconds, 3600), 60) - - cond do - days > 0 -> "#{days}d #{hours}h #{minutes}m" - hours > 0 -> "#{hours}h #{minutes}m" - true -> "#{minutes}m" - end - end - - defp clamp(value, min, max) do - value - |> max(min) - |> min(max) - end -end diff --git a/examples/dashboard/mix.exs b/examples/dashboard/mix.exs deleted file mode 100644 index 1ca4555a..00000000 --- a/examples/dashboard/mix.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule Dashboard.MixProject do - use Mix.Project - - def project do - [ - app: :dashboard, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {Dashboard.Application, []} - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/dashboard/mix.lock b/examples/dashboard/mix.lock deleted file mode 100644 index 1c3df94b..00000000 --- a/examples/dashboard/mix.lock +++ /dev/null @@ -1,13 +0,0 @@ -%{ - "autumn": {:hex, :autumn, "0.6.0", "56cba6145da885262ef705e6e7a83d981e1f756d629a6d0e10b79a79243b702b", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: false]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "d9f7bad90b462e2e3ae3ce3a6d0dcd128230fec2a276cba0af18ce26165b54ce"}, - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.2", "7b01f784f38b0dfea92af164b8d1dae6f31f77e344da821b852be7bd8cd67484", [:mix], [{:autumn, ">= 0.6.0", [hex: :autumn, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "3a9d3f7049be6e37793cbe533bc6eea2e4df572aca32a67a857a2e8921964c00"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler": {:hex, :rustler, "0.37.1", "721434020c7f6f8e1cdc57f44f75c490435b01de96384f8ccb96043f12e8a7e0", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "24547e9b8640cf00e6a2071acb710f3e12ce0346692e45098d84d45cdb54fd79"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/dashboard/run.exs b/examples/dashboard/run.exs deleted file mode 100644 index fc67ea2a..00000000 --- a/examples/dashboard/run.exs +++ /dev/null @@ -1 +0,0 @@ -Dashboard.App.run() diff --git a/examples/dialog/README.md b/examples/dialog/README.md deleted file mode 100644 index 590d8bb0..00000000 --- a/examples/dialog/README.md +++ /dev/null @@ -1,144 +0,0 @@ -# Dialog Widget Example - -This example demonstrates the Dialog widget for displaying modal dialogs with customizable buttons and content. - -## Widget Overview - -The Dialog widget provides modal overlays that appear centered on screen with focus trapping. It's ideal for: - -- Confirmation dialogs (Yes/No, OK/Cancel) -- Information alerts (single OK button) -- Warning messages with multiple options -- Simple forms or prompts - -**Key Features:** -- Centered modal display with backdrop -- Customizable width and content -- Multiple button configurations -- Button navigation with keyboard and mouse -- Focus trapping (Tab cycles within dialog) -- Escape to close (configurable) -- Default button selection -- Button highlighting for focused state - -## Widget Options - -The `Dialog.new/1` function accepts the following options: - -- `:title` (required) - Dialog title displayed in header -- `:content` - Dialog body content (render node, default: empty) -- `:buttons` - List of button definitions (default: single OK button) - - Each button: `%{id: atom, label: string, default: boolean}` -- `:width` - Dialog width in characters (default: 40) -- `:on_close` - Callback function `(() -> any)` when dialog closes -- `:on_confirm` - Callback function `(button_id -> any)` when button is activated -- `:closeable` - Whether Escape closes dialog (default: true) -- `:title_style` - Style for title bar -- `:content_style` - Style for content area -- `:button_style` - Style for buttons -- `:focused_button_style` - Style for focused button - -## Example Structure - -``` -dialog/ -├── lib/ -│ └── dialog/ -│ └── app.ex # Main application component -├── mix.exs # Project configuration -└── README.md # This file -``` - -**app.ex** - Implements the Elm Architecture pattern: -- Maintains dialog state (visibility, button focus, result) -- Shows different dialog types (info, confirm, warning) -- Forwards keyboard events to dialog widget when visible -- Tracks last selected button for demonstration - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/dialog -mix termui.run -``` - -Or manually: - -```bash -cd examples/dialog -mix run -e "Dialog.App.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/dialog -iex -S mix -``` - -Then in IEx: - -```elixir -Dialog.App.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -- **1** - Show Info Dialog (single "OK" button) -- **2** - Show Confirm Dialog (Cancel/Confirm buttons) -- **3** - Show Warning Dialog (Don't Save/Cancel/Save buttons with default) -- **Tab / Shift+Tab** - Navigate between buttons (when dialog open) -- **Left/Right** - Navigate between buttons (when dialog open) -- **Enter** - Select focused button -- **Space** - Select focused button -- **Escape** - Close dialog (calls on_close callback) -- **Q** - Quit the application - -## Dialog Types Demonstrated - -**Info Dialog:** -```elixir -Dialog.new( - title: "Information", - content: text("This is an informational message.\nPress OK to continue.", nil), - buttons: [%{id: :ok, label: "OK"}] -) -``` - -**Confirm Dialog:** -```elixir -Dialog.new( - title: "Confirm Action", - content: text("Are you sure you want to proceed?", nil), - buttons: [ - %{id: :cancel, label: "Cancel"}, - %{id: :confirm, label: "Confirm"} - ] -) -``` - -**Warning Dialog with Default:** -```elixir -Dialog.new( - title: "Warning", - content: text("Unsaved changes will be lost!", nil), - buttons: [ - %{id: :dont_save, label: "Don't Save"}, - %{id: :cancel, label: "Cancel"}, - %{id: :save, label: "Save", default: true} - ] -) -``` - -The `default: true` option sets initial focus to that button. diff --git a/examples/dialog/lib/dialog/app.ex b/examples/dialog/lib/dialog/app.ex deleted file mode 100644 index bb992702..00000000 --- a/examples/dialog/lib/dialog/app.ex +++ /dev/null @@ -1,190 +0,0 @@ -defmodule Dialog.App do - @moduledoc """ - Dialog Widget Example - - This example demonstrates how to use the TermUI.Widgets.Dialog widget - for displaying modal dialogs with buttons. - - Features demonstrated: - - Basic dialog with title and content - - Multiple button options - - Button navigation - - Dialog open/close states - - Different dialog types (info, confirm, warning) - - Controls: - - 1: Show Info Dialog - - 2: Show Confirm Dialog - - 3: Show Warning Dialog - - Tab/Arrow: Navigate buttons (when dialog open) - - Enter: Select button (when dialog open) - - Escape: Close dialog - - Q: Quit the application - """ - - use TermUI.Elm - - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Widgets.Dialog - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- - - @doc """ - Initialize the component state. - """ - def init(_opts) do - %{ - # Current dialog state (nil when no dialog visible) - dialog: nil, - # Result tracking - last_result: nil - } - end - - @doc """ - Convert keyboard events to messages. - """ - # When no dialog is visible, number keys show dialogs - def event_to_msg(%Event.Key{key: "1"}, %{dialog: nil}), do: {:msg, :show_info} - def event_to_msg(%Event.Key{key: "2"}, %{dialog: nil}), do: {:msg, :show_confirm} - def event_to_msg(%Event.Key{key: "3"}, %{dialog: nil}), do: {:msg, :show_warning} - - # When dialog is visible, forward events to the dialog widget - def event_to_msg(event, %{dialog: dialog}) when dialog != nil, do: {:msg, {:dialog_event, event}} - - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit} - def event_to_msg(_event, _state), do: :ignore - - @doc """ - Update state based on messages. - """ - def update(:show_info, state) do - {show_dialog(state, "Information", "This is an informational message.\nPress OK to continue.", [ - %{id: :ok, label: "OK"} - ]), []} - end - - def update(:show_confirm, state) do - {show_dialog(state, "Confirm Action", "Are you sure you want to proceed?\nThis action cannot be undone.", [ - %{id: :cancel, label: "Cancel"}, - %{id: :confirm, label: "Confirm"} - ]), []} - end - - def update(:show_warning, state) do - {show_dialog(state, "Warning", "Unsaved changes will be lost!\nDo you want to save before closing?", [ - %{id: :dont_save, label: "Don't Save"}, - %{id: :cancel, label: "Cancel"}, - %{id: :save, label: "Save", default: true} - ]), []} - end - - def update({:dialog_event, event}, state) do - case Dialog.handle_event(event, state.dialog) do - {:ok, new_dialog} -> - if Dialog.visible?(new_dialog) do - {%{state | dialog: new_dialog}, []} - else - # Dialog was closed - capture result - result = Dialog.get_focused_button(new_dialog) - {%{state | dialog: nil, last_result: format_result(result)}, []} - end - end - end - - def update(:quit, state) do - {state, [:quit]} - end - - # Helper to create and initialize a dialog - defp show_dialog(state, title, content, buttons) do - props = Dialog.new( - title: title, - content: text(content, nil), - buttons: buttons, - width: 45 - ) - {:ok, dialog} = Dialog.init(props) - %{state | dialog: dialog} - end - - defp format_result(nil), do: "Cancelled" - defp format_result(result), do: "Selected: #{result}" - - @doc """ - Render the current state to a render tree. - """ - def view(state) do - main_content = render_main_content(state) - - if state.dialog != nil do - stack(:vertical, [ - main_content, - text("", nil), - Dialog.render(state.dialog, %{width: 80, height: 24}) - ]) - else - main_content - end - end - - # ---------------------------------------------------------------------------- - # Private Helpers - # ---------------------------------------------------------------------------- - - defp render_main_content(state) do - stack(:vertical, [ - # Title - text("Dialog Widget Example", Style.new(fg: :cyan, attrs: [:bold])), - text("", nil), - - # Instructions - text("Press a number key to show different dialog types:", nil), - text("", nil), - text(" 1 - Info Dialog (single button)", nil), - text(" 2 - Confirm Dialog (two buttons)", nil), - text(" 3 - Warning Dialog (three buttons)", nil), - text("", nil), - - text("", nil), - - # Controls - render_controls(state) - ]) - end - - defp render_controls(state) do - box_width = 50 - inner_width = box_width - 2 - - top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐" - bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘" - - stack(:vertical, [ - text("", nil), - text(top_border, Style.new(fg: :yellow)), - text("│" <> String.pad_trailing(" 1/2/3 Show dialog", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Tab/←/→ Navigate buttons (in dialog)", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Enter Select button", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Escape Close dialog", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil), - text("│" <> String.pad_trailing("", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Last result: #{state.last_result || "(none)"}", inner_width) <> "│", nil), - text(bottom_border, Style.new(fg: :yellow)) - ]) - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Run the dialog example application. - """ - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/dialog/lib/dialog/application.ex b/examples/dialog/lib/dialog/application.ex deleted file mode 100644 index 1619bc34..00000000 --- a/examples/dialog/lib/dialog/application.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule Dialog.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [] - opts = [strategy: :one_for_one, name: Dialog.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/examples/dialog/mix.exs b/examples/dialog/mix.exs deleted file mode 100644 index 62be0537..00000000 --- a/examples/dialog/mix.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule Dialog.MixProject do - use Mix.Project - - def project do - [ - app: :dialog, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {Dialog.Application, []} - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/dialog/mix.lock b/examples/dialog/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/dialog/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/dialog/run.exs b/examples/dialog/run.exs deleted file mode 100644 index 4ffec9bb..00000000 --- a/examples/dialog/run.exs +++ /dev/null @@ -1 +0,0 @@ -Dialog.App.run() diff --git a/examples/form_builder/README.md b/examples/form_builder/README.md deleted file mode 100644 index e24caeae..00000000 --- a/examples/form_builder/README.md +++ /dev/null @@ -1,207 +0,0 @@ -# FormBuilder Widget Example - -This example demonstrates the FormBuilder widget for creating structured forms with multiple field types, validation, and conditional fields. - -## Widget Overview - -The FormBuilder widget provides comprehensive form handling with automatic layout, validation, and navigation. It's ideal for: - -- Registration and login forms -- Settings and configuration panels -- Data entry interfaces -- Multi-step wizards -- Survey forms - -**Key Features:** -- Multiple field types (text, password, checkbox, radio, select, multi-select) -- Built-in validation with custom validators -- Conditional field visibility -- Field grouping and organization -- Automatic keyboard navigation -- Required field indicators -- Error message display -- Submit button with validation - -## Widget Options - -The `FormBuilder.new/1` function accepts the following options: - -- `:fields` (required) - List of field definitions -- `:groups` - List of group definitions for organizing fields -- `:on_submit` - Callback function `(values -> any)` when form is submitted -- `:on_change` - Callback function `(field_id, value -> any)` when any field value changes -- `:values` - Map of initial field values -- `:show_submit_button` - Whether to show submit button (default: true) -- `:submit_label` - Label for submit button (default: "Submit") -- `:validate_on_blur` - Validate when field loses focus (default: true) -- `:label_width` - Width for field labels (default: 15) -- `:field_width` - Width for field inputs (default: 30) - -**Field Definition:** - -Each field is a map with: -- `:id` (required) - Unique atom identifier -- `:type` (required) - Field type (see below) -- `:label` (required) - Display label -- `:required` - Boolean, whether field is required (default: false) -- `:validators` - List of validator functions -- `:visible_when` - Function `(values -> boolean)` for conditional visibility -- `:placeholder` - Placeholder text for text/password fields -- `:default` - Default value -- `:options` - List of `{value, label}` tuples for select/radio/multi-select - -**Field Types:** -- `:text` - Single line text input -- `:password` - Masked text input -- `:checkbox` - Boolean toggle -- `:radio` - Single selection from options -- `:select` - Dropdown single selection -- `:multi_select` - Multiple selection from options - -## Example Structure - -``` -form_builder/ -├── lib/ -│ └── form_builder/ -│ └── app.ex # Main application component -├── mix.exs # Project configuration -└── README.md # This file -``` - -**app.ex** - Demonstrates comprehensive form features: -- Text and password fields with validation -- Checkbox with conditional field (email frequency) -- Radio buttons for options -- Select dropdown for country -- Multi-select for interests -- Custom validators (password strength, email format) -- Form submission with validation -- Display of submitted data - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/form_builder -mix termui.run -``` - -Or manually: - -```bash -cd examples/form_builder -mix run -e "FormBuilder.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/form_builder -iex -S mix -``` - -Then in IEx: - -```elixir -FormBuilder.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -- **Tab / Shift+Tab** - Navigate between fields and submit button -- **Up/Down** - Navigate options (radio/select/multi-select fields) -- **Space** - Toggle checkbox or select option -- **Enter** - Submit form (when on submit button) -- **Backspace** - Delete character (text/password fields) -- **Type characters** - Enter text (text/password fields) -- **Q** - Quit the application - -## Field Behavior - -**Text/Password Fields:** -- Type to enter text -- Backspace to delete -- Displays placeholder when empty -- Password fields show asterisks - -**Checkbox:** -- Space to toggle -- Shows [x] when checked, [ ] when unchecked - -**Radio Buttons:** -- Up/Down or Space to select option -- Shows (o) for selected, ( ) for unselected -- Options displayed horizontally - -**Select Dropdown:** -- Shows selected value with dropdown indicator -- Expands when focused to show all options -- Up/Down to navigate, Space/Enter to select - -**Multi-Select:** -- Shows all options with checkboxes -- Up/Down to navigate -- Space to toggle selection -- Multiple options can be selected - -## Validation - -The example demonstrates custom validators: - -**Password Validator:** -```elixir -defp validate_password(value) do - cond do - String.length(value) < 6 -> - {:error, "Password must be at least 6 characters"} - not String.match?(value, ~r/[0-9]/) -> - {:error, "Password must contain at least one number"} - true -> - :ok - end -end -``` - -**Email Validator:** -```elixir -defp validate_email(value) do - if value == "" or String.match?(value, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/) do - :ok - else - {:error, "Please enter a valid email address"} - end -end -``` - -Errors are displayed in red below the field. - -## Conditional Fields - -The email frequency field demonstrates conditional visibility: - -```elixir -%{ - id: :frequency, - type: :radio, - label: "Email frequency", - visible_when: fn values -> values[:newsletter] end, - options: [ - {"daily", "Daily"}, - {"weekly", "Weekly"}, - {"monthly", "Monthly"} - ] -} -``` - -This field only appears when the newsletter checkbox is checked. diff --git a/examples/form_builder/lib/form_builder.ex b/examples/form_builder/lib/form_builder.ex deleted file mode 100644 index 4e0a735f..00000000 --- a/examples/form_builder/lib/form_builder.ex +++ /dev/null @@ -1,7 +0,0 @@ -defmodule FormBuilder do - @moduledoc """ - FormBuilder example entry point. - """ - - defdelegate run, to: FormBuilder.App -end diff --git a/examples/form_builder/lib/form_builder/app.ex b/examples/form_builder/lib/form_builder/app.ex deleted file mode 100644 index 03184467..00000000 --- a/examples/form_builder/lib/form_builder/app.ex +++ /dev/null @@ -1,268 +0,0 @@ -defmodule FormBuilder.App do - @moduledoc """ - FormBuilder Widget Example - - This example demonstrates how to use the TermUI.Widgets.FormBuilder widget - for creating structured forms with multiple field types. - - Features demonstrated: - - Text and password fields - - Checkbox fields - - Radio button groups - - Select dropdowns - - Multi-select fields - - Field validation - - Conditional fields - - Form submission - - Controls: - - Tab/Shift+Tab: Navigate between fields - - Up/Down: Navigate options (radio/select) - - Space: Toggle checkbox, select option - - Enter: Submit form (on submit button) - - Q: Quit the application - """ - - use TermUI.Elm - - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Widgets.FormBuilder - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- - - @doc """ - Initialize the component state. - """ - def init(_opts) do - props = - FormBuilder.new( - fields: [ - # Basic text fields - %{id: :username, type: :text, label: "Username", required: true, - placeholder: "Enter username"}, - %{id: :password, type: :password, label: "Password", required: true, - validators: [&validate_password/1]}, - %{id: :email, type: :text, label: "Email", - validators: [&validate_email/1]}, - - # Checkbox - %{id: :newsletter, type: :checkbox, label: "Subscribe to newsletter"}, - - # Conditional field - only shown when newsletter is checked - %{id: :frequency, type: :radio, label: "Email frequency", - visible_when: fn values -> values[:newsletter] end, - options: [ - {"daily", "Daily"}, - {"weekly", "Weekly"}, - {"monthly", "Monthly"} - ]}, - - # Select dropdown - %{id: :country, type: :select, label: "Country", - options: [ - {"us", "United States"}, - {"uk", "United Kingdom"}, - {"ca", "Canada"}, - {"au", "Australia"}, - {"de", "Germany"} - ]}, - - # Multi-select - %{id: :interests, type: :multi_select, label: "Interests", - options: [ - {"tech", "Technology"}, - {"sports", "Sports"}, - {"music", "Music"}, - {"art", "Art"}, - {"travel", "Travel"} - ]} - ], - submit_label: "Register", - label_width: 18, - field_width: 25 - ) - - {:ok, form_state} = FormBuilder.init(props) - - %{ - form: form_state, - submitted_data: nil, - message: nil - } - end - - # Custom validators - defp validate_password(value) do - cond do - String.length(value) < 6 -> - {:error, "Password must be at least 6 characters"} - not String.match?(value, ~r/[0-9]/) -> - {:error, "Password must contain at least one number"} - true -> - :ok - end - end - - defp validate_email(value) do - if value == "" or String.match?(value, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/) do - :ok - else - {:error, "Please enter a valid email address"} - end - end - - @doc """ - Convert keyboard events to messages. - """ - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"] do - {:msg, :quit} - end - - def event_to_msg(%Event.Key{key: :enter} = event, state) do - # Check if submit button is focused - if so, this is a form submission - if state.form.submit_focused do - {:msg, :submit_form} - else - {:msg, {:form_event, event}} - end - end - - def event_to_msg(event, _state) do - # Pass all other events to the form - {:msg, {:form_event, event}} - end - - @doc """ - Update state based on messages. - """ - def update(:quit, state) do - {state, [:quit]} - end - - def update(:submit_form, state) do - # Let the form handle the enter key (which runs validation) - {:ok, new_form} = FormBuilder.handle_event(%Event.Key{key: :enter}, state.form) - - # Check if there are validation errors - has_errors = Enum.any?(new_form.errors, fn {_field_id, errors} -> errors != [] end) - - if has_errors do - # Form has errors, just update the form state - {%{state | form: new_form, message: "Please fix the errors above"}, []} - else - # Form is valid, get the values and mark as submitted - values = FormBuilder.get_values(new_form) - {%{state | form: new_form, submitted_data: values, message: "Form submitted successfully!"}, []} - end - end - - def update({:form_event, event}, state) do - {:ok, new_form} = FormBuilder.handle_event(event, state.form) - {%{state | form: new_form}, []} - end - - def update(_msg, state) do - {state, []} - end - - @doc """ - Render the current state to a render tree. - """ - def view(state) do - stack(:vertical, [ - # Title - text("FormBuilder Widget Example", Style.new(fg: :cyan, attrs: [:bold])), - text(""), - - # Instructions - render_instructions(), - text(""), - - # Form - render_form_section(state), - text(""), - - # Submitted data (if any) - render_submitted_data(state), - - # Status message - render_message(state) - ]) - end - - # ---------------------------------------------------------------------------- - # Private Helpers - # ---------------------------------------------------------------------------- - - defp render_instructions do - stack(:vertical, [ - text("Controls:", Style.new(fg: :yellow)), - text(" Tab/Shift+Tab Navigate between fields"), - text(" Up/Down Navigate options (radio/select)"), - text(" Space Toggle checkbox, select option"), - text(" Enter Submit form (on submit button)"), - text(" Q Quit") - ]) - end - - defp render_form_section(state) do - box_style = Style.new(fg: :white) - - stack(:vertical, [ - text("--- Registration Form ---", box_style), - text(""), - FormBuilder.render(state.form, %{width: 70, height: 20}) - ]) - end - - defp render_submitted_data(state) do - case state.submitted_data do - nil -> - empty() - - data -> - stack(:vertical, [ - text("--- Submitted Data ---", Style.new(fg: :green, attrs: [:bold])), - text(""), - render_data_row("Username", data[:username]), - render_data_row("Password", String.duplicate("*", String.length(data[:password] || ""))), - render_data_row("Email", data[:email]), - render_data_row("Newsletter", if(data[:newsletter], do: "Yes", else: "No")), - if data[:newsletter] do - render_data_row("Frequency", data[:frequency] || "(not set)") - else - empty() - end, - render_data_row("Country", data[:country]), - render_data_row("Interests", Enum.join(data[:interests] || [], ", ")) - ]) - end - end - - defp render_data_row(label, value) do - text(" #{String.pad_trailing(label <> ":", 15)} #{value}") - end - - defp render_message(state) do - case state.message do - nil -> empty() - msg -> - style = if String.contains?(msg, "error"), do: Style.new(fg: :red), else: Style.new(fg: :green, attrs: [:bold]) - text(msg, style) - end - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Run the form builder example application. - """ - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/form_builder/mix.exs b/examples/form_builder/mix.exs deleted file mode 100644 index 0f2ad652..00000000 --- a/examples/form_builder/mix.exs +++ /dev/null @@ -1,25 +0,0 @@ -defmodule FormBuilder.MixProject do - use Mix.Project - - def project do - [ - app: :form_builder, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger] - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/form_builder/mix.lock b/examples/form_builder/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/form_builder/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/form_builder/run.exs b/examples/form_builder/run.exs deleted file mode 100644 index c3478be3..00000000 --- a/examples/form_builder/run.exs +++ /dev/null @@ -1 +0,0 @@ -FormBuilder.App.run() diff --git a/examples/gauge/README.md b/examples/gauge/README.md deleted file mode 100644 index 8a740ae4..00000000 --- a/examples/gauge/README.md +++ /dev/null @@ -1,193 +0,0 @@ -# Gauge Widget Example - -This example demonstrates the Gauge widget for displaying numeric values within a range using visual bars or arcs. - -## Widget Overview - -The Gauge widget provides visual representation of values with support for color zones and multiple display styles. It's ideal for: - -- Progress indicators -- Resource usage displays (CPU, memory, disk) -- Percentage visualizations -- Status meters -- Loading indicators - -**Key Features:** -- Bar style (horizontal filled bar) -- Arc style (semi-circular arc) -- Color zones for visual feedback -- Customizable characters for bar display -- Min/max range labels -- Value display -- Custom labeling - -## Widget Options - -The `Gauge.render/1` function accepts the following options: - -- `:value` (required) - Current numeric value to display -- `:min` - Minimum value (default: 0) -- `:max` - Maximum value (default: 100) -- `:width` - Gauge width in characters (default: 40) -- `:type` - Display type, `:bar` or `:arc` (default: `:bar`) -- `:show_value` - Show numeric value below gauge (default: true) -- `:show_range` - Show min/max labels (default: true) -- `:zones` - List of `{threshold, style}` tuples for color zones -- `:label` - Label text displayed above gauge -- `:bar_char` - Character for filled portion (default: "█") -- `:empty_char` - Character for empty portion (default: "░") - -**Helper Functions:** - -- `Gauge.percentage(value, opts)` - Quick percentage gauge (0-100 range) -- `Gauge.traffic_light(opts)` - Gauge with green/yellow/red zones - -**Color Zones:** - -Zones define style changes at thresholds: -```elixir -zones: [ - {0, Style.new(fg: :green)}, # Green from 0-59 - {60, Style.new(fg: :yellow)}, # Yellow from 60-79 - {80, Style.new(fg: :red)} # Red from 80-100 -] -``` - -## Example Structure - -``` -gauge/ -├── lib/ -│ └── gauge/ -│ └── app.ex # Main application component -├── mix.exs # Project configuration -└── README.md # This file -``` - -**app.ex** - Demonstrates various gauge configurations: -- Simple percentage gauge using helper -- Gauge with color zones (green/yellow/red) -- Gauge with custom characters -- Interactive value adjustment -- Style switching (bar/arc) - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/gauge -mix termui.run -``` - -Or manually: - -```bash -cd examples/gauge -mix run -e "Gauge.App.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/gauge -iex -S mix -``` - -Then in IEx: - -```elixir -Gauge.App.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -- **Up Arrow** - Increase value by 5 -- **Down Arrow** - Decrease value by 5 -- **Right Arrow** - Increase value by 10 -- **Left Arrow** - Decrease value by 10 -- **S** - Toggle between bar and arc display styles -- **Q** - Quit the application - -The value is automatically clamped between 0 and 100. - -## Display Styles - -**Bar Style:** -``` -Simple Percentage Gauge: -████████████████████░░░░░░░░░░ - 50 -``` - -**Arc Style:** -``` -╭────────────────────────────╮ -│ ▼ │ -╰────────────────────────────╯ - 50 -``` - -## Gauge Examples - -**Simple Percentage:** -```elixir -Gauge.percentage(75, width: 30) -``` - -**With Color Zones:** -```elixir -Gauge.render( - value: 75, - min: 0, - max: 100, - width: 30, - zones: [ - {0, Style.new(fg: :green)}, - {60, Style.new(fg: :yellow)}, - {80, Style.new(fg: :red)} - ], - label: "CPU Usage" -) -``` - -**Custom Characters:** -```elixir -Gauge.render( - value: 75, - min: 0, - max: 100, - width: 30, - bar_char: "▓", - empty_char: "░" -) -``` - -**Arc Style:** -```elixir -Gauge.render( - value: 75, - min: 0, - max: 100, - width: 30, - type: :arc, - show_value: true -) -``` - -## Use Cases - -- **System Monitoring:** Display CPU, memory, or disk usage -- **Progress Tracking:** Show download/upload progress -- **Resource Limits:** Visualize quota usage -- **Performance Metrics:** Display response times or throughput -- **Health Indicators:** Show service health status diff --git a/examples/gauge/lib/gauge/app.ex b/examples/gauge/lib/gauge/app.ex deleted file mode 100644 index fe9793fe..00000000 --- a/examples/gauge/lib/gauge/app.ex +++ /dev/null @@ -1,168 +0,0 @@ -defmodule Gauge.App do - @moduledoc """ - Gauge Widget Example - - This example demonstrates how to use the TermUI.Widgets.Gauge widget - for displaying values within a range. The gauge supports: - - - Bar style (horizontal bar) - - Arc style (semi-circular arc) - - Color zones for visual feedback - - Custom characters for the bar - - Controls: - - Up/Down arrows: Increase/decrease value - - Left/Right arrows: Adjust by larger increments - - S: Toggle between bar and arc styles - - Q: Quit the application - """ - - use TermUI.Elm - - # Import the Gauge widget - alias TermUI.Widgets.Gauge - alias TermUI.Event - alias TermUI.Renderer.Style - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- - - @doc """ - Initialize the component state. - - We store: - - value: Current gauge value (0-100) - - gauge_type: :bar or :arc display style - """ - def init(_opts) do - %{ - value: 50, - gauge_type: :bar - } - end - - @doc """ - Convert keyboard events to messages. - - This is where we map user input to application messages. - """ - def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, {:change_value, 5}} - def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, {:change_value, -5}} - def event_to_msg(%Event.Key{key: :right}, _state), do: {:msg, {:change_value, 10}} - def event_to_msg(%Event.Key{key: :left}, _state), do: {:msg, {:change_value, -10}} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["s", "S"], do: {:msg, :toggle_style} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit} - def event_to_msg(_event, _state), do: :ignore - - @doc """ - Update state based on messages. - - Returns {new_state, commands} where commands is a list of side effects. - """ - def update({:change_value, delta}, state) do - # Clamp value between 0 and 100 - new_value = max(0, min(100, state.value + delta)) - {%{state | value: new_value}, []} - end - - def update(:toggle_style, state) do - # Toggle between :bar and :arc styles - new_style = if state.gauge_type == :bar, do: :arc, else: :bar - {%{state | gauge_type: new_style}, []} - end - - def update(:quit, state) do - # Return :quit command to exit the application - {state, [:quit]} - end - - @doc """ - Render the current state to a render tree. - - This is called every frame to produce the UI. - """ - def view(state) do - stack(:vertical, [ - # Title - text("Gauge Widget Example", Style.new(fg: :cyan, attrs: [:bold])), - text("", nil), - - # Simple percentage gauge - # The Gauge.percentage/2 helper creates a 0-100 gauge with value display - text("Simple Percentage Gauge:", nil), - Gauge.percentage(state.value, width: 30), - text("", nil), - - # Gauge with color zones - # Zones are {threshold, style} tuples - the color applies when value >= threshold - text("Gauge with Color Zones:", nil), - Gauge.render( - value: state.value, - min: 0, - max: 100, - width: 30, - type: state.gauge_type, - show_value: true, - show_range: true, - # Define color zones: green (0-59), yellow (60-79), red (80-100) - zones: [ - {0, Style.new(fg: :green)}, - {60, Style.new(fg: :yellow)}, - {80, Style.new(fg: :red)} - ], - label: "CPU Usage" - ), - text("", nil), - - # Gauge with custom characters - text("Gauge with Custom Characters:", nil), - Gauge.render( - value: state.value, - min: 0, - max: 100, - width: 30, - # Use custom characters instead of default █ and ░ - bar_char: "▓", - empty_char: "░", - show_value: true, - show_range: false - ), - text("", nil), - - # Controls - render_controls(state) - ]) - end - - defp render_controls(state) do - box_width = 40 - inner_width = box_width - 2 - - top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐" - bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘" - - stack(:vertical, [ - text("", nil), - text(top_border, Style.new(fg: :yellow)), - text("│" <> String.pad_trailing(" ↑/↓ Adjust value by 5", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" ←/→ Adjust value by 10", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" S Toggle bar/arc style", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil), - text("│" <> String.pad_trailing("", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Current style: #{state.gauge_type}", inner_width) <> "│", nil), - text(bottom_border, Style.new(fg: :yellow)) - ]) - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Run the gauge example application. - """ - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/gauge/lib/gauge/application.ex b/examples/gauge/lib/gauge/application.ex deleted file mode 100644 index 4c533c65..00000000 --- a/examples/gauge/lib/gauge/application.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule Gauge.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [] - opts = [strategy: :one_for_one, name: Gauge.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/examples/gauge/mix.exs b/examples/gauge/mix.exs deleted file mode 100644 index abd34d1d..00000000 --- a/examples/gauge/mix.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule Gauge.MixProject do - use Mix.Project - - def project do - [ - app: :gauge, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {Gauge.Application, []} - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/gauge/mix.lock b/examples/gauge/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/gauge/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/gauge/run.exs b/examples/gauge/run.exs deleted file mode 100644 index 86aa26b5..00000000 --- a/examples/gauge/run.exs +++ /dev/null @@ -1 +0,0 @@ -Gauge.App.run() diff --git a/examples/iex_counter/README.md b/examples/iex_counter/README.md index 82963706..f1786b2d 100644 --- a/examples/iex_counter/README.md +++ b/examples/iex_counter/README.md @@ -1,59 +1,13 @@ -# IEx Counter Example +# Counter example -A simple counter example demonstrating TermUI's IEx compatibility. +This is the one supported TermUI example. It uses one Elm application, typed +events, data commands, and one `TermUI.Frame` render value. -## Running +Run it with: -### TTY Mode (IEx Compatible) - -This example is designed to be run directly in IEx: - -```bash -cd examples/iex_counter -iex -S mix -``` - -Once in IEx, run the counter: - -```elixir -iex> IExCounter.App.run() -``` - -### Raw Mode (Full TUI Experience) - -You can also run this as a standalone application with full terminal control: - -```bash -cd examples/iex_counter -mix termui.run +```sh +mix deps.get +mix run run.exs ``` -## Controls - -| Key | Action | -|-----|--------| -| ↑ | Increment counter | -| ↓ | Decrement counter | -| R | Reset counter to 0 | -| Q | Quit (returns to IEx prompt) | - -## What This Demonstrates - -1. **No code changes needed** - The same app works in IEx and standalone -2. **Keyboard input works** - Arrow keys, Q, R all work correctly -3. **Clean shutdown** - Terminal state is restored when you quit -4. **Return to IEx** - You're back at the IEx prompt, ready for more commands - -## Detection - -The app displays whether it's running in IEx or standalone mode at the top. - -You can also check programmatically: - -```elixir -iex> TermUI.iex_mode?() -true - -iex> TermUI.running_mode() -:iex -``` +Use Up and Down to change the value. Use R to reset it. Use Q to stop it. diff --git a/examples/iex_counter/lib/iex_counter/app.ex b/examples/iex_counter/lib/iex_counter/app.ex index 1781b789..234e883d 100644 --- a/examples/iex_counter/lib/iex_counter/app.ex +++ b/examples/iex_counter/lib/iex_counter/app.ex @@ -1,148 +1,49 @@ defmodule IExCounter.App do - @moduledoc """ - Simple counter example for demonstrating IEx compatibility. - - This example demonstrates that TermUI applications work directly - in IEx with no code changes required. - - ## Running in IEx - - From the project root: - - cd examples/iex_counter - iex -S mix - - Then in IEx: - - iex> IExCounter.App.run() - - Controls: - - Up arrow: Increment counter - - Down arrow: Decrement counter - - R: Reset counter - - Q: Quit (returns to IEx prompt) - - ## Running Standalone - - mix termui.run - - ## What Works in IEx - - - All keyboard input is received by the TUI application - - Arrow keys work immediately (no Enter required) - - Terminal state is restored when you quit - - You return to the IEx prompt ready for next command - - ## IEx Detection - - In your component code, you can detect if running in IEx: - - if TermUI.iex_mode?() do - # IEx-specific behavior - end - """ + @moduledoc "A small counter that uses the complete TermUI public contract." use TermUI.Elm - alias TermUI.Event - alias TermUI.Renderer.Style - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- + alias TermUI.{Command, Event, Frame, Style} @impl true - def init(_opts) do - %{ - count: 0, - mode: :normal - } + def init(opts) do + %{count: 0, dimensions: Keyword.fetch!(opts, :dimensions)} end @impl true - def event_to_msg(%Event.Key{key: :up}, _state) do - {:msg, :increment} - end - - def event_to_msg(%Event.Key{key: :down}, _state) do - {:msg, :decrement} - end - - def event_to_msg(%Event.Key{key: "r"}, _state) do - {:msg, :reset} - end + def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :increment} + def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :decrement} + def event_to_msg(%Event.Text{text: text}, _state) when text in ["r", "R"], do: {:msg, :reset} + def event_to_msg(%Event.Text{text: text}, _state) when text in ["q", "Q"], do: {:msg, :quit} - def event_to_msg(%Event.Key{key: "q"}, _state) do - {:msg, :quit} - end + def event_to_msg(%Event.Resize{width: width, height: height}, _state), + do: {:msg, {:resize, width, height}} - def event_to_msg(%Event.Key{key: "Q"}, _state) do - {:msg, :quit} - end - - def event_to_msg(_, _state), do: :ignore + def event_to_msg(_event, _state), do: :ignore @impl true - def update(:increment, state) do - {%{state | count: state.count + 1}, []} - end - - def update(:decrement, state) do - {%{state | count: state.count - 1}, []} - end - - def update(:reset, state) do - {%{state | count: 0}, []} - end - - def update(:quit, state) do - {state, [:quit]} - end + def update(:increment, state), do: %{state | count: state.count + 1} + def update(:decrement, state), do: %{state | count: state.count - 1} + def update(:reset, state), do: %{state | count: 0} + def update(:quit, state), do: {state, [Command.shutdown()]} + def update({:resize, width, height}, state), do: %{state | dimensions: {width, height}} @impl true - def view(state) do - mode_str = if TermUI.iex_mode?(), do: "IEx", else: "Standalone" - - stack(:vertical, [ - # Title - text("IEx Counter Example", Style.new(fg: :cyan, attrs: [:bold])), - text("", nil), - - # Mode indicator - text("Running in: #{mode_str} mode", Style.new(fg: :bright_black)), - text("", nil), + def view(%{count: count, dimensions: {width, height}}) do + title = Style.new(fg: :cyan, attrs: [:bold]) + value = Style.new(fg: :green, attrs: [:bold]) - # Counter display - text("Count: #{state.count}", Style.new(fg: :green, attrs: [:bold])), - text("", nil), + rows = [ + [{"TermUI counter", title}], + "", + [{"Count: #{count}", value}], + "", + "Up/Down: change R: reset Q: quit" + ] - # Instructions - text("Controls:", Style.new(fg: :yellow, attrs: [:bold])), - text(" ↑/↓ : Increment/Decrement", nil), - text(" R : Reset", nil), - text(" Q : Quit to IEx prompt", nil), - ]) + Frame.from_rows(rows, width, height) end - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Run the counter application. - - This is the main entry point for running the application. - Use `TermUI.App.run/1` which provides the proper runtime setup. - - ## Examples - - iex> IExCounter.App.run() - # ... interact with the TUI app ... - # Press Q to quit, returns to IEx - {:ok, :exited_normally} - - """ - def run(opts \\ []) do - TermUI.App.run(__MODULE__, opts) - end + @doc "Runs the example in the current terminal." + def run(opts \\ []), do: TermUI.run(__MODULE__, opts) end diff --git a/examples/line_chart/README.md b/examples/line_chart/README.md deleted file mode 100644 index 425d63f1..00000000 --- a/examples/line_chart/README.md +++ /dev/null @@ -1,131 +0,0 @@ -# LineChart Example - -A demonstration of the LineChart widget for time series visualization using Braille patterns. - -## Widget Overview - -The LineChart widget renders line graphs in the terminal using Unicode Braille characters (U+2800-U+28FF), which provide 2x4 dot resolution per character cell. This enables smooth line rendering with sub-character precision, perfect for visualizing metrics, sensor data, and time series. - -### Key Features - -- Single and multi-series line charts -- Braille patterns for smooth line rendering (2x4 dots per character) -- Custom min/max scaling -- Optional axis display -- Dynamic data updates -- Automatic scaling based on data range - -### When to Use - -Use LineChart when you need to visualize: -- Time series data (CPU/memory usage, metrics) -- Trends and patterns in numerical data -- Multiple data series for comparison -- Real-time data streams - -## Widget Options - -The LineChart widget accepts the following options in its `render/1` function: - -- `:data` - Single series data (list of numbers), alternative to `:series` -- `:series` - List of series maps with `:data` and optional `:color` keys -- `:width` - Chart width in characters (default: 40) -- `:height` - Chart height in characters (default: 10) -- `:min` - Minimum Y value (default: auto-calculated from data) -- `:max` - Maximum Y value (default: auto-calculated from data) -- `:show_axis` - Show axis lines (default: false) -- `:style` - Style for the chart - -### Example Usage - -```elixir -# Single series -LineChart.render( - data: [1, 3, 5, 2, 8], - width: 40, - height: 8, - min: 0, - max: 100, - show_axis: true -) - -# Multiple series -LineChart.render( - series: [ - %{data: [1, 3, 5, 2, 8], color: Style.new(fg: :cyan)}, - %{data: [2, 4, 3, 6, 4], color: Style.new(fg: :magenta)} - ], - width: 40, - height: 8 -) -``` - -## Example Structure - -This example contains: - -- `lib/line_chart/app.ex` - Main application demonstrating the LineChart widget - - Simulates CPU and memory usage data - - Demonstrates single and multi-series charts - - Shows how to update data dynamically - - Includes Braille pattern demonstration - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/line_chart -mix termui.run -``` - -Or manually: - -```bash -cd examples/line_chart -mix run -e "LineChart.App.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/line_chart -iex -S mix -``` - -Then in IEx: - -```elixir -LineChart.App.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -- **Space** - Add new data point to both series (sliding window) -- **R** - Reset/randomize data with new values -- **A** - Toggle axis display on/off -- **Q** - Quit the application - -## Features Demonstrated - -1. **Single Series Chart** - Shows CPU usage over time with green line -2. **Multi-Series Chart** - Displays CPU (cyan) and memory (magenta) together -3. **Braille Pattern Demo** - Shows various Braille characters used for rendering -4. **Dynamic Updates** - Data can be added in real-time with sliding window -5. **Axis Control** - Toggle axis display to see coordinate frame - -## Implementation Notes - -- Data is generated using a random walk algorithm to simulate realistic metrics -- Each series maintains a maximum of 25 points (sliding window) -- Values are bounded between 10 and 90 to keep them visible -- Braille patterns provide 2x horizontal and 4x vertical resolution per character diff --git a/examples/line_chart/lib/line_chart/app.ex b/examples/line_chart/lib/line_chart/app.ex deleted file mode 100644 index 97c83d89..00000000 --- a/examples/line_chart/lib/line_chart/app.ex +++ /dev/null @@ -1,195 +0,0 @@ -defmodule LineChart.App do - @moduledoc """ - Line Chart Widget Example - - This example demonstrates how to use the TermUI.Widgets.LineChart widget - for time series visualization using Braille patterns. - - The line chart uses Unicode Braille characters (U+2800-U+28FF) which provide - 2x4 dot resolution per character cell, enabling smooth line rendering. - - Features demonstrated: - - Single series line chart - - Multiple series comparison - - Custom min/max scaling - - Axis display - - Dynamic data updates - - Controls: - - Space: Add new data point - - R: Reset/randomize data - - A: Toggle axis display - - Q: Quit the application - """ - - use TermUI.Elm - - alias TermUI.Widgets.LineChart - alias TermUI.Event - alias TermUI.Renderer.Style - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- - - @doc """ - Initialize the component state. - """ - def init(_opts) do - %{ - # Single series data (simulating CPU usage over time) - cpu_data: generate_random_series(20), - # Second series (simulating memory usage) - memory_data: generate_random_series(20), - # Display options - show_axis: true - } - end - - defp generate_random_series(count) do - # Generate semi-realistic looking data with some continuity - Enum.reduce(1..count, [], fn _, acc -> - last = List.last(acc) || 50 - # Random walk with bounds - delta = :rand.uniform(21) - 11 - new_value = max(10, min(90, last + delta)) - acc ++ [new_value] - end) - end - - @doc """ - Convert keyboard events to messages. - """ - def event_to_msg(%Event.Key{key: " "}, _state), do: {:msg, :add_point} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["r", "R"], do: {:msg, :reset} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["a", "A"], do: {:msg, :toggle_axis} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit} - def event_to_msg(_event, _state), do: :ignore - - @doc """ - Update state based on messages. - """ - def update(:add_point, state) do - # Add a new point to both series (sliding window) - cpu_last = List.last(state.cpu_data) || 50 - cpu_new = max(10, min(90, cpu_last + :rand.uniform(21) - 11)) - cpu_data = (state.cpu_data ++ [cpu_new]) |> Enum.take(-25) - - mem_last = List.last(state.memory_data) || 50 - mem_new = max(10, min(90, mem_last + :rand.uniform(15) - 8)) - memory_data = (state.memory_data ++ [mem_new]) |> Enum.take(-25) - - {%{state | cpu_data: cpu_data, memory_data: memory_data}, []} - end - - def update(:reset, state) do - {%{state | cpu_data: generate_random_series(20), memory_data: generate_random_series(20)}, []} - end - - def update(:toggle_axis, state) do - {%{state | show_axis: not state.show_axis}, []} - end - - def update(:quit, state) do - {state, [:quit]} - end - - @doc """ - Render the current state to a render tree. - """ - def view(state) do - stack(:vertical, [ - # Title - text("Line Chart Widget Example", Style.new(fg: :cyan, attrs: [:bold])), - text("", nil), - - # Single series chart - text("Single Series (CPU Usage):", nil), - LineChart.render( - data: state.cpu_data, - width: 40, - height: 8, - min: 0, - max: 100, - show_axis: state.show_axis, - style: Style.new(fg: :green) - ), - text("", nil), - - # Multi-series chart - text("Multi Series (CPU + Memory):", nil), - LineChart.render( - series: [ - %{data: state.cpu_data, color: Style.new(fg: :cyan)}, - %{data: state.memory_data, color: Style.new(fg: :magenta)} - ], - width: 40, - height: 8, - min: 0, - max: 100, - show_axis: state.show_axis - ), - text(" Cyan = CPU, Magenta = Memory", nil), - text("", nil), - - # Braille pattern demo - text("Braille characters for line drawing:", nil), - render_braille_demo(), - text("", nil), - - # Controls - render_controls(state) - ]) - end - - defp render_controls(state) do - box_width = 48 - inner_width = box_width - 2 - - top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐" - bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘" - - stack(:vertical, [ - text("", nil), - text(top_border, Style.new(fg: :yellow)), - text("│" <> String.pad_trailing(" Space Add new data point", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" R Reset/randomize data", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" A Toggle axis (#{if state.show_axis, do: "ON", else: "OFF"})", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil), - text("│" <> String.pad_trailing("", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Data points: #{length(state.cpu_data)}", inner_width) <> "│", nil), - text(bottom_border, Style.new(fg: :yellow)) - ]) - end - - # ---------------------------------------------------------------------------- - # Private Helpers - # ---------------------------------------------------------------------------- - - defp render_braille_demo do - # Show some example Braille patterns - patterns = [ - LineChart.empty_braille(), - LineChart.dots_to_braille([{0, 3}]), - LineChart.dots_to_braille([{0, 2}]), - LineChart.dots_to_braille([{0, 1}]), - LineChart.dots_to_braille([{0, 0}]), - LineChart.dots_to_braille([{0, 0}, {1, 0}]), - LineChart.dots_to_braille([{0, 0}, {0, 1}]), - LineChart.full_braille() - ] - - text(" " <> Enum.join(patterns, " "), nil) - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Run the line chart example application. - """ - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/line_chart/lib/line_chart/application.ex b/examples/line_chart/lib/line_chart/application.ex deleted file mode 100644 index db15adcb..00000000 --- a/examples/line_chart/lib/line_chart/application.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule LineChart.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [] - opts = [strategy: :one_for_one, name: LineChart.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/examples/line_chart/mix.exs b/examples/line_chart/mix.exs deleted file mode 100644 index df554a4a..00000000 --- a/examples/line_chart/mix.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule LineChart.MixProject do - use Mix.Project - - def project do - [ - app: :line_chart, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {LineChart.Application, []} - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/line_chart/mix.lock b/examples/line_chart/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/line_chart/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/line_chart/run.exs b/examples/line_chart/run.exs deleted file mode 100644 index ced896a9..00000000 --- a/examples/line_chart/run.exs +++ /dev/null @@ -1 +0,0 @@ -LineChart.App.run() diff --git a/examples/log_viewer/README.md b/examples/log_viewer/README.md deleted file mode 100644 index e583664f..00000000 --- a/examples/log_viewer/README.md +++ /dev/null @@ -1,170 +0,0 @@ -# LogViewer Example - -A demonstration of the LogViewer widget for displaying and analyzing log data with virtual scrolling. - -## Widget Overview - -The LogViewer widget efficiently displays large log files (millions of lines) using virtual scrolling. It provides powerful features for searching, filtering, and analyzing logs in real-time. - -### Key Features - -- Virtual scrolling for efficient rendering of large datasets -- Tail mode for live log monitoring -- Search with regex support and match highlighting -- Syntax highlighting for log levels and timestamps -- Filtering by pattern with regex support -- Line bookmarking for marking important entries -- Selection for copy operations -- Wrap/truncate toggle for long lines -- Automatic log parsing (timestamp, level, source) - -### When to Use - -Use LogViewer when you need to: -- Monitor application logs in real-time -- Search through large log files efficiently -- Debug issues by filtering specific patterns -- Track important log entries with bookmarks -- Analyze log levels and patterns - -## Widget Options - -The LogViewer widget accepts the following options in its `new/1` function: - -- `:lines` - Initial log lines (strings or log entries) -- `:max_lines` - Maximum lines to keep in buffer (default: 100,000) -- `:tail_mode` - Auto-scroll to new lines (default: true) -- `:wrap_lines` - Wrap long lines instead of truncating (default: false) -- `:show_line_numbers` - Display line numbers (default: true) -- `:show_timestamps` - Display timestamps column (default: false) -- `:show_levels` - Display level column (default: true) -- `:highlight_levels` - Color-code by level (default: true) -- `:on_select` - Callback when lines are selected -- `:on_copy` - Callback when copy is requested -- `:parser` - Custom log parser function - -### Example Usage - -```elixir -LogViewer.new( - lines: log_lines, - tail_mode: true, - highlight_levels: true, - show_line_numbers: true, - max_lines: 10_000 -) -``` - -## Example Structure - -This example contains: - -- `lib/log_viewer/app.ex` - Main application demonstrating the LogViewer widget - - Generates simulated log entries from multiple modules - - Demonstrates various log levels (debug, info, warning, error) - - Shows dynamic log addition and clearing - - Integrates all LogViewer features - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/log_viewer -mix termui.run -``` - -Or manually: - -```bash -cd examples/log_viewer -mix run -e "LogViewer.App.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/log_viewer -iex -S mix -``` - -Then in IEx: - -```elixir -LogViewer.App.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -### Navigation -- **Up/Down** - Navigate between lines -- **PageUp/PageDown** - Scroll by page (20 lines) -- **Home/End** - Jump to first/last line - -### Search -- **/** - Start search (supports regex) -- **n/N** - Next/previous search match -- **Escape** - Clear search - -### Filtering -- **f** - Toggle filter mode (or start filter input) -- **Escape** - Clear filter - -### Bookmarks -- **b** - Toggle bookmark on current line -- **B** - Jump to next bookmark - -### Display Modes -- **t** - Toggle tail mode (auto-scroll to new entries) -- **w** - Toggle wrap mode (wrap vs truncate long lines) - -### Selection -- **Space** - Start or extend selection -- **Escape** - Clear selection - -### Data Management -- **A** - Add 5 simulated log entries -- **C** - Clear all logs -- **Q** - Quit the application - -## Features Demonstrated - -1. **Automatic Parsing** - Extracts timestamps, log levels, and module names -2. **Level Highlighting** - Color codes by severity (debug=cyan, info=green, warning=yellow, error=red) -3. **Virtual Scrolling** - Efficiently renders only visible lines -4. **Search & Highlight** - Find patterns with regex and highlight matches -5. **Filtering** - Show only lines matching a pattern -6. **Tail Mode** - Automatically scrolls to new entries -7. **Bookmarks** - Mark and jump between important lines -8. **Status Bar** - Shows current line, filter status, search results - -## Log Format - -The example generates logs in this format: - -``` -2024-01-15T10:30:45.123Z [MyApp.Server] INFO: Request processed successfully -``` - -The parser automatically extracts: -- Timestamp (ISO8601 format) -- Source module (in brackets) -- Log level (DEBUG, INFO, WARNING, ERROR) -- Message text - -## Implementation Notes - -- Initial dataset contains 50 log entries -- Each "Add logs" action adds 5 new entries -- Logs are kept in a circular buffer (max 10,000 lines by default) -- Virtual scrolling renders only visible lines for performance -- Search and filter use regex patterns (case-insensitive) diff --git a/examples/log_viewer/lib/log_viewer/app.ex b/examples/log_viewer/lib/log_viewer/app.ex deleted file mode 100644 index 22007473..00000000 --- a/examples/log_viewer/lib/log_viewer/app.ex +++ /dev/null @@ -1,287 +0,0 @@ -defmodule LogViewer.App do - @moduledoc """ - LogViewer Widget Example - - This example demonstrates how to use the TermUI.Widgets.LogViewer widget - for displaying and analyzing log data with virtual scrolling. - - Features demonstrated: - - Virtual scrolling for large log datasets - - Tail mode for live log monitoring - - Search with regex support - - Syntax highlighting for log levels - - Filtering by pattern - - Line bookmarking - - Selection for copy operations - - Wrap/truncate toggle - - Controls: - - Up/Down: Navigate between lines - - PageUp/PageDown: Scroll by page - - Home/End: Jump to first/last line - - /: Start search - - n/N: Next/previous search match - - f: Toggle filter mode - - b: Toggle bookmark on current line - - B: Jump to next bookmark - - t: Toggle tail mode - - w: Toggle wrap mode - - Space: Start/extend selection - - Escape: Clear search/filter/selection - - A: Add simulated log entries - - C: Clear all logs - - Q: Quit the application - """ - - use TermUI.Elm - - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Widgets.LogViewer, as: LV - - @modules ["MyApp.Server", "MyApp.Handler", "MyApp.Database", "MyApp.Cache", "MyApp.Auth"] - @levels [:debug, :info, :warning, :error] - @messages [ - "Request processed successfully", - "Connection established", - "Cache hit for key: user_123", - "Slow query detected: 250ms", - "Authentication failed for user", - "Database connection pool at 80%", - "Memory usage: 512MB", - "Rate limit exceeded", - "Session expired", - "Config reloaded" - ] - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- - - @doc """ - Initialize the component state. - """ - def init(_opts) do - initial_logs = generate_initial_logs(50) - - %{ - log_state: nil, - initial_logs: initial_logs, - log_counter: 50, - status_message: "Use / to search, f to filter, t for tail mode" - } - end - - defp build_log_state(logs) do - props = - LV.new( - lines: logs, - tail_mode: true, - highlight_levels: true, - show_line_numbers: true, - show_levels: true, - max_lines: 10_000 - ) - - {:ok, state} = LV.init(props) - state - end - - defp generate_initial_logs(count) do - base_time = DateTime.utc_now() - - for i <- 0..(count - 1) do - generate_log_line(base_time, i) - end - end - - defp generate_log_line(base_time, offset) do - timestamp = DateTime.add(base_time, offset, :second) - module = Enum.random(@modules) - level = Enum.random(@levels) - message = Enum.random(@messages) - - level_str = level |> Atom.to_string() |> String.upcase() - ts_str = DateTime.to_iso8601(timestamp) - - "#{ts_str} [#{module}] #{level_str}: #{message}" - end - - @doc """ - Convert keyboard events to messages. - """ - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["a", "A"], do: {:msg, :add_logs} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["c", "C"], do: {:msg, :clear_logs} - - def event_to_msg(event, _state) do - {:msg, {:log_event, event}} - end - - @doc """ - Update state based on messages. - """ - def update(:quit, state) do - {state, [:quit]} - end - - def update(:add_logs, state) do - log_state = ensure_log_state(state) - base_time = DateTime.utc_now() - - new_logs = - for i <- 0..4 do - generate_log_line(base_time, state.log_counter + i) - end - - log_state = LV.add_lines(log_state, new_logs) - message = "Added 5 log entries (total: #{LV.line_count(log_state)})" - - {%{state | log_state: log_state, log_counter: state.log_counter + 5, status_message: message}, []} - end - - def update(:clear_logs, state) do - log_state = ensure_log_state(state) - log_state = LV.clear(log_state) - {%{state | log_state: log_state, log_counter: 0, status_message: "Logs cleared"}, []} - end - - def update({:log_event, event}, state) do - log_state = ensure_log_state(state) - {:ok, log_state} = LV.handle_event(event, log_state) - - message = get_status_message(log_state) - {%{state | log_state: log_state, status_message: message}, []} - end - - defp ensure_log_state(state) do - state.log_state || build_log_state(state.initial_logs) - end - - defp get_status_message(log_state) do - parts = [] - - parts = - if log_state.search do - match_count = length(log_state.search.matches) - current = log_state.search.current_match + 1 - parts ++ ["Search: #{current}/#{match_count}"] - else - parts - end - - parts = - if log_state.filter do - visible = LV.visible_line_count(log_state) - total = LV.line_count(log_state) - parts ++ ["Filtered: #{visible}/#{total}"] - else - parts - end - - parts = - if MapSet.size(log_state.bookmarks) > 0 do - parts ++ ["Bookmarks: #{MapSet.size(log_state.bookmarks)}"] - else - parts - end - - parts = - if log_state.tail_mode do - parts ++ ["TAIL"] - else - parts - end - - if length(parts) > 0 do - Enum.join(parts, " | ") - else - "Use / to search, f to filter, t for tail mode" - end - end - - @doc """ - Render the current state to a render tree. - """ - def view(state) do - log_state = ensure_log_state(state) - - stack(:vertical, [ - # Title - text("LogViewer Widget Example", Style.new(fg: :cyan, attrs: [:bold])), - text("", nil), - - # Log viewer - render_log_container(log_state), - - # Status - text("", nil), - text(state.status_message, Style.new(fg: :yellow)), - - # Controls - render_controls(log_state) - ]) - end - - defp render_log_container(log_state) do - log_render = LV.render(log_state, %{x: 0, y: 0, width: 75, height: 15}) - - box_width = 77 - inner_width = box_width - 2 - - line_info = "Lines: #{LV.line_count(log_state)}" - top_border = "+" <> String.duplicate("-", 3) <> " Log Output " <> String.duplicate("-", inner_width - 16) <> " #{line_info} " <> "+" - bottom_border = "+" <> String.duplicate("-", inner_width) <> "+" - - stack(:vertical, [ - text(top_border, Style.new(fg: :blue)), - stack(:horizontal, [ - text("| ", nil), - log_render, - text(" |", nil) - ]), - text(bottom_border, Style.new(fg: :blue)) - ]) - end - - defp render_controls(log_state) do - box_width = 60 - inner_width = box_width - 2 - - tail_str = if log_state.tail_mode, do: "ON", else: "OFF" - wrap_str = if log_state.wrap_lines, do: "ON", else: "OFF" - - top_border = "+" <> String.duplicate("-", inner_width - 10) <> " Controls " <> "+" - bottom_border = "+" <> String.duplicate("-", inner_width) <> "+" - - stack(:vertical, [ - text("", nil), - text(top_border, Style.new(fg: :yellow)), - text("|" <> String.pad_trailing(" Up/Down Navigate lines", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" PgUp/PgDn Scroll by page", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" Home/End First/last line", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" / Start search", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" n/N Next/prev match", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" f Toggle filter", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" b/B Bookmark / Jump to next", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" t Toggle tail mode (#{tail_str})", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" w Toggle wrap (#{wrap_str})", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" Space Start/extend selection", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" A/C Add logs / Clear logs", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" Escape Clear search/filter/selection", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" Q Quit", inner_width) <> "|", nil), - text(bottom_border, Style.new(fg: :yellow)) - ]) - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Run the log viewer example application. - """ - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/log_viewer/lib/log_viewer/application.ex b/examples/log_viewer/lib/log_viewer/application.ex deleted file mode 100644 index 8be2dcf1..00000000 --- a/examples/log_viewer/lib/log_viewer/application.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule LogViewer.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [] - opts = [strategy: :one_for_one, name: LogViewer.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/examples/log_viewer/mix.exs b/examples/log_viewer/mix.exs deleted file mode 100644 index 2713d00c..00000000 --- a/examples/log_viewer/mix.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule LogViewer.MixProject do - use Mix.Project - - def project do - [ - app: :log_viewer, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {LogViewer.Application, []} - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/log_viewer/mix.lock b/examples/log_viewer/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/log_viewer/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/log_viewer/run.exs b/examples/log_viewer/run.exs deleted file mode 100644 index d0aa571b..00000000 --- a/examples/log_viewer/run.exs +++ /dev/null @@ -1 +0,0 @@ -LogViewer.App.run() diff --git a/examples/markdown_viewer/README.md b/examples/markdown_viewer/README.md deleted file mode 100644 index 9fcdc0c2..00000000 --- a/examples/markdown_viewer/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Markdown Viewer Example - -Demonstration of the `TermUI.Widgets.MarkdownViewer` widget. - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/markdown_viewer -mix termui.run -``` - -Or manually: - -```bash -cd examples/markdown_viewer -mix run -e "MarkdownViewer.App.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/markdown_viewer -iex -S mix -``` - -Then in IEx: - -```elixir -MarkdownViewer.App.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -| Key | Action | -|-----|--------| -| `↑` / `↓` | Scroll up/down | -| `Page Up` / `Page Down` | Scroll by page | -| `Home` / `End` | Jump to top/bottom | -| `Tab` | Cycle focus through code blocks | -| `Enter` / `c` | Copy focused code block | -| `Q` | Quit | diff --git a/examples/markdown_viewer/lib/markdown_viewer/app.ex b/examples/markdown_viewer/lib/markdown_viewer/app.ex deleted file mode 100644 index 8873a524..00000000 --- a/examples/markdown_viewer/lib/markdown_viewer/app.ex +++ /dev/null @@ -1,300 +0,0 @@ -defmodule MarkdownViewer.App do - @moduledoc """ - Markdown Viewer Widget Example - - Demonstrates the TermUI.Widgets.MarkdownViewer widget. - """ - - use TermUI.Elm - - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Widgets.MarkdownViewer - - @sample_markdown """ - # Markdown Viewer Demo - - Welcome to the **Markdown Viewer** widget demonstration. This component - renders *markdown* content with `syntax highlighting` for code blocks. - - ## Features - - - Full CommonMark support via MDEx - - Syntax highlighting for Elixir and Erlang - - Keyboard navigation and scrolling - - Focusable code blocks with copy support - - ## Code Examples - - ### Pattern Matching - - ```elixir - defmodule Calculator do - def compute({:add, a, b}), do: a + b - def compute({:subtract, a, b}), do: a - b - def compute({:multiply, a, b}), do: a * b - def compute({:divide, _a, 0}), do: {:error, :division_by_zero} - def compute({:divide, a, b}), do: a / b - end - - # Usage - Calculator.compute({:add, 10, 5}) - Calculator.compute({:multiply, 3, 7}) - ``` - - ### Working with GenServer - - ```elixir - defmodule KeyValueStore do - use GenServer - - # Client API - def start_link(opts \\\\ []) do - GenServer.start_link(__MODULE__, opts, name: __MODULE__) - end - - def get(key) do - GenServer.call(__MODULE__, {:get, key}) - end - - def put(key, value) do - GenServer.cast(__MODULE__, {:put, key, value}) - end - - # Server Callbacks - @impl true - def init(_opts) do - {:ok, %{}} - end - - @impl true - def handle_call({:get, key}, _from, state) do - {:reply, Map.get(state, key), state} - end - - @impl true - def handle_cast({:put, key, value}, state) do - {:noreply, Map.put(state, key, value)} - end - end - ``` - - ### Enum and List Comprehensions - - ```elixir - # Get all even numbers from 1 to 100 - evens = for n <- 1..100, rem(n, 2) == 0, do: n - - # Parse a list of strings into integers - numbers = ["1", "42", "7", "100"] - parsed = for str <- numbers, into: [] do - String.to_integer(str) - end - - # Filter and map in one pass - squared_evens = - 1..20 - |> Enum.filter(&(rem(&1, 2) == 0)) - |> Enum.map(&(&1 * &1)) - - # Using Enum.reduce - sum = Enum.reduce(1..10, 0, fn i, acc -> acc + i end) - ``` - - ### Structs and Protocols - - ```elixir - defmodule User do - @type t :: %__MODULE__{ - name: String.t(), - age: pos_integer(), - email: String.t() - } - - defstruct [:name, :age, :email] - - def new(name, age, email) do - %__MODULE__{ - name: name, - age: age, - email: email - } - end - end - - # Pattern matching on structs - def is_adult?(%User{age: age}) when age >= 18, do: true - def is_adult?(%User{}), do: false - ``` - - ### Erlang Example - - ```erlang - -module(sorter). - -export([quicksort/1]). - - %% QuickSort implementation in Erlang - quicksort([]) -> []; - quicksort([Pivot | Rest]) -> - {Smaller, Larger} = partition(Pivot, Rest, [], []), - quicksort(Smaller) ++ [Pivot] ++ quicksort(Larger). - - partition(_Pivot, [], Smaller, Larger) -> - {Smaller, Larger}; - partition(Pivot, [H | T], Smaller, Larger) when H =< Pivot -> - partition(Pivot, T, [H | Smaller], Larger); - partition(Pivot, [H | T], Smaller, Larger) -> - partition(Pivot, T, Smaller, [H | Larger]). - ``` - - ## Text Styling - - You can use **bold text**, *italic text*, or `inline code`. - Links are also supported: [TermUI](https://github.com/pcharbon70/term_ui) - - ## Lists - - ### Unordered List - - - First item - - Second item with **bold** - - Third item with `code` - - ### Ordered List - - 1. First step - 2. Second step - 3. Third step - - ## Blockquotes - - > The best way to predict the future is to invent it. - > — Alan Kay - - --- - - Enjoy using the Markdown Viewer! - """ - - def init(_opts) do - props = MarkdownViewer.new( - content: @sample_markdown, - width: 76, - height: 20 - ) - - {:ok, viewer_state} = MarkdownViewer.init(props) - - %{ - viewer_state: viewer_state, - scroll_pos: 0, - content_height: viewer_state.content_height - } - end - - def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :scroll_up} - def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :scroll_down} - def event_to_msg(%Event.Key{key: :page_up}, _state), do: {:msg, :page_up} - def event_to_msg(%Event.Key{key: :page_down}, _state), do: {:msg, :page_down} - def event_to_msg(%Event.Key{key: :home}, _state), do: {:msg, :scroll_top} - def event_to_msg(%Event.Key{key: :end}, _state), do: {:msg, :scroll_bottom} - def event_to_msg(%Event.Key{key: :tab, modifiers: []}, _state), do: {:msg, :next_code_block} - def event_to_msg(%Event.Key{key: :tab, modifiers: [:shift]}, _state), do: {:msg, :prev_code_block} - def event_to_msg(%Event.Key{key: :enter}, _state), do: {:msg, :copy_code} - def event_to_msg(%Event.Key{char: ?c}, _state), do: {:msg, :copy_code} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit} - def event_to_msg(_event, _state), do: :ignore - - def update(:scroll_up, state) do - {:ok, new_viewer} = MarkdownViewer.handle_event(%Event.Key{key: :up}, state.viewer_state) - {update_scroll_info(%{state | viewer_state: new_viewer}), []} - end - - def update(:scroll_down, state) do - {:ok, new_viewer} = MarkdownViewer.handle_event(%Event.Key{key: :down}, state.viewer_state) - {update_scroll_info(%{state | viewer_state: new_viewer}), []} - end - - def update(:page_up, state) do - {:ok, new_viewer} = MarkdownViewer.handle_event(%Event.Key{key: :page_up}, state.viewer_state) - {update_scroll_info(%{state | viewer_state: new_viewer}), []} - end - - def update(:page_down, state) do - {:ok, new_viewer} = MarkdownViewer.handle_event(%Event.Key{key: :page_down}, state.viewer_state) - {update_scroll_info(%{state | viewer_state: new_viewer}), []} - end - - def update(:scroll_top, state) do - {:ok, new_viewer} = MarkdownViewer.handle_event(%Event.Key{key: :home}, state.viewer_state) - {update_scroll_info(%{state | viewer_state: new_viewer}), []} - end - - def update(:scroll_bottom, state) do - {:ok, new_viewer} = MarkdownViewer.handle_event(%Event.Key{key: :end}, state.viewer_state) - {update_scroll_info(%{state | viewer_state: new_viewer}), []} - end - - def update(:next_code_block, state) do - {:ok, new_viewer} = MarkdownViewer.handle_event(%Event.Key{key: :tab}, state.viewer_state) - {update_scroll_info(%{state | viewer_state: new_viewer}), []} - end - - def update(:prev_code_block, state) do - {:ok, new_viewer} = MarkdownViewer.handle_event(%Event.Key{key: :tab, modifiers: [:shift]}, state.viewer_state) - {update_scroll_info(%{state | viewer_state: new_viewer}), []} - end - - def update(:copy_code, state) do - {:ok, new_viewer} = MarkdownViewer.handle_event(%Event.Key{key: :enter}, state.viewer_state) - {update_scroll_info(%{state | viewer_state: new_viewer}), []} - end - - def update(:quit, state) do - {state, [:quit]} - end - - defp update_scroll_info(state) do - scroll_y = state.viewer_state.scroll_y - content_height = state.viewer_state.content_height - %{state | scroll_pos: scroll_y, content_height: content_height} - end - - def view(state) do - stack(:vertical, [ - render_title_bar(), - MarkdownViewer.render(state.viewer_state, %{width: 76, height: 20}), - render_status_bar(state) - ]) - end - - defp render_title_bar do - title = " Markdown Viewer Demo " - padding = String.duplicate("─", div(76 - String.length(title), 2)) - text(padding <> title <> padding, Style.new(fg: :cyan, attrs: [:bold])) - end - - defp render_status_bar(state) do - scroll_text = - if state.content_height > 20 do - pct = min(100, round(state.scroll_pos / max(1, state.content_height - 20) * 100)) - "Line: #{state.scroll_pos + 1}/#{state.content_height} (#{pct}%)" - else - "Line: #{state.scroll_pos + 1}/#{state.content_height}" - end - - help = "↑↓:Scroll | PgUp/Dn:Page | Home/End:Top/Bot | Tab:Code | Enter/c:Copy | Q:Quit" - left_pad = String.pad_trailing(" " <> scroll_text, 54) - right = " " <> help - text(left_pad <> right, Style.new(fg: :bright_black)) - end - - def run do - TermUI.Runtime.run( - root: __MODULE__, - fps: 60, - mouse: true, - title: "Markdown Viewer Demo" - ) - end -end diff --git a/examples/markdown_viewer/lib/markdown_viewer/application.ex b/examples/markdown_viewer/lib/markdown_viewer/application.ex deleted file mode 100644 index 98db6271..00000000 --- a/examples/markdown_viewer/lib/markdown_viewer/application.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule MarkdownViewer.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [] - opts = [strategy: :one_for_one, name: MarkdownViewer.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/examples/markdown_viewer/mix.exs b/examples/markdown_viewer/mix.exs deleted file mode 100644 index b17c53d1..00000000 --- a/examples/markdown_viewer/mix.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule MarkdownViewer.MixProject do - use Mix.Project - - def project do - [ - app: :markdown_viewer, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {MarkdownViewer.Application, []} - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/markdown_viewer/mix.lock b/examples/markdown_viewer/mix.lock deleted file mode 100644 index fc785621..00000000 --- a/examples/markdown_viewer/mix.lock +++ /dev/null @@ -1,14 +0,0 @@ -%{ - "autumn": {:hex, :autumn, "0.5.7", "f6bfdc30d3f8d5e82ba5648489db7a7b6b7479d7be07a8288d4db2437434e26d", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "d272bfddeeea863420a8eb994d42af219ca5391191dd765bf045fbacf56a28d1"}, - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.10.0", "eae4d3bd4c0b77d6d959146a2d6faaec045686548ad1468630130095dbd93def", [:mix], [{:autumn, ">= 0.5.4", [hex: :autumn, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: false]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "6ad76e32056c44027fe985da7da506e033b07037896d1f130f7d5c332b0d0ac0"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler": {:hex, :rustler, "0.37.1", "721434020c7f6f8e1cdc57f44f75c490435b01de96384f8ccb96043f12e8a7e0", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "24547e9b8640cf00e6a2071acb710f3e12ce0346692e45098d84d45cdb54fd79"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, - "term_ui": {:path, "../.."}, -} diff --git a/examples/markdown_viewer/run.exs b/examples/markdown_viewer/run.exs deleted file mode 100644 index b6c67d72..00000000 --- a/examples/markdown_viewer/run.exs +++ /dev/null @@ -1 +0,0 @@ -MarkdownViewer.App.run() diff --git a/examples/menu/README.md b/examples/menu/README.md deleted file mode 100644 index d6c23e29..00000000 --- a/examples/menu/README.md +++ /dev/null @@ -1,174 +0,0 @@ -# Menu Example - -A demonstration of the Menu widget for displaying hierarchical menus with various item types. - -## Widget Overview - -The Menu widget displays a list of interactive items including actions, submenus, separators, and checkboxes. It supports keyboard navigation, shortcut display, and hierarchical organization. - -### Key Features - -- Multiple item types (actions, submenus, separators, checkboxes) -- Keyboard navigation with arrow keys -- Shortcut display (e.g., "Ctrl+N") -- Hierarchical submenus with expand/collapse -- Checkbox items with toggle state -- Disabled item support -- Customizable styling for normal, selected, and disabled states -- Mouse support with hover highlighting - -### When to Use - -Use Menu when you need to: -- Present a list of commands or actions -- Organize options hierarchically in submenus -- Display shortcuts alongside menu items -- Provide toggleable settings with checkboxes -- Create dropdown or context menus - -## Widget Options - -The Menu widget accepts the following options in its `new/1` function: - -- `:items` - List of menu items (required) -- `:on_select` - Callback when item is selected `fn id -> ... end` -- `:on_toggle` - Callback when checkbox is toggled `fn id, checked -> ... end` -- `:width` - Menu width (default: auto-calculated) -- `:item_style` - Style for normal items -- `:selected_style` - Style for focused item -- `:disabled_style` - Style for disabled items - -### Item Constructors - -```elixir -# Action item -Menu.action(:new, "New File", shortcut: "Ctrl+N") - -# Submenu with children -Menu.submenu(:recent, "Recent Files", [ - Menu.action(:file1, "document.txt"), - Menu.action(:file2, "notes.md") -]) - -# Separator (visual divider) -Menu.separator() - -# Checkbox item -Menu.checkbox(:autosave, "Auto Save", checked: true) -``` - -### Example Usage - -```elixir -Menu.new( - items: [ - Menu.action(:new, "New File", shortcut: "Ctrl+N"), - Menu.action(:open, "Open...", shortcut: "Ctrl+O"), - Menu.separator(), - Menu.submenu(:export, "Export As", [ - Menu.action(:pdf, "PDF"), - Menu.action(:html, "HTML") - ]), - Menu.checkbox(:autosave, "Auto Save", checked: true) - ], - selected_style: Style.new(fg: :black, bg: :cyan), - on_select: fn id -> handle_action(id) end -) -``` - -## Example Structure - -This example contains: - -- `lib/menu/app.ex` - Main application demonstrating the Menu widget - - File menu example with New, Open, Save actions - - Recent Files submenu - - Export As submenu - - Settings checkboxes (Auto Save, Dark Mode, Notifications) - - Displays last action and checkbox states - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/menu -mix termui.run -``` - -Or manually: - -```bash -cd examples/menu -mix run -e "Menu.App.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/menu -iex -S mix -``` - -Then in IEx: - -```elixir -Menu.App.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -### Navigation -- **Up/Down** - Navigate between items (skips separators) -- **Right** - Expand submenu -- **Left** - Collapse submenu - -### Selection -- **Enter/Space** - Select item or toggle checkbox -- **Q** - Quit the application - -### Mouse -- **Click** - Select item at position -- **Hover** - Highlights item under cursor - -## Features Demonstrated - -1. **Action Items** - New File, Open, Save with shortcuts -2. **Submenus** - Recent Files and Export As with nested items -3. **Separators** - Visual dividers between sections -4. **Checkboxes** - Auto Save, Dark Mode, Notifications with toggle state -5. **Shortcut Display** - Shows keyboard shortcuts aligned right -6. **State Tracking** - Displays last action and checkbox states -7. **Hierarchical Navigation** - Expand/collapse submenus - -## Item Types - -### Action -Selectable menu item that triggers an action. Displays label and optional shortcut. - -### Submenu -Item that contains child items. Shows expand/collapse arrow (▶/▼) based on state. - -### Separator -Visual divider (horizontal line) that cannot be selected. - -### Checkbox -Toggleable item showing checked state with [×] or [ ]. Can be toggled with Enter/Space. - -## Implementation Notes - -- The example tracks checkbox states in the widget state -- Last action is displayed when an action item is selected -- Submenus are collapsed by default -- Disabled items (if configured) cannot be selected -- Width auto-adjusts to longest item + shortcut -- Cursor wraps around at list ends diff --git a/examples/menu/lib/menu/app.ex b/examples/menu/lib/menu/app.ex deleted file mode 100644 index fffeaa25..00000000 --- a/examples/menu/lib/menu/app.ex +++ /dev/null @@ -1,195 +0,0 @@ -defmodule Menu.App do - @moduledoc """ - Menu Widget Example - - This example demonstrates how to use the TermUI.Widgets.Menu widget - for displaying hierarchical menus with various item types. - - Features demonstrated: - - Action items (selectable menu items) - - Submenus (nested menus) - - Separators (visual dividers) - - Checkboxes (toggleable items) - - Keyboard navigation - - Shortcut display - - Controls: - - Up/Down: Navigate between items - - Right: Expand submenu - - Left: Collapse submenu - - Enter/Space: Select item or toggle checkbox - - Q: Quit the application - """ - - use TermUI.Elm - - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Widgets.Menu - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- - - @doc """ - Initialize the component state. - """ - def init(_opts) do - props = - Menu.new( - items: menu_items(), - selected_style: Style.new(fg: :black, bg: :cyan), - disabled_style: Style.new(fg: :bright_black) - ) - - {:ok, menu_state} = Menu.init(props) - - %{ - menu: menu_state, - last_action: nil - } - end - - defp menu_items do - [ - Menu.action(:new, "New File", shortcut: "Ctrl+N"), - Menu.action(:open, "Open...", shortcut: "Ctrl+O"), - Menu.action(:save, "Save", shortcut: "Ctrl+S"), - Menu.separator(), - Menu.submenu(:recent, "Recent Files", [ - Menu.action(:file1, "document.txt"), - Menu.action(:file2, "notes.md"), - Menu.action(:file3, "config.json") - ]), - Menu.submenu(:export, "Export As", [ - Menu.action(:export_pdf, "PDF"), - Menu.action(:export_html, "HTML"), - Menu.action(:export_md, "Markdown") - ]), - Menu.separator(), - Menu.checkbox(:autosave, "Auto Save", checked: true), - Menu.checkbox(:dark_mode, "Dark Mode"), - Menu.checkbox(:notifications, "Notifications", checked: true), - Menu.separator(), - Menu.action(:settings, "Settings...", shortcut: "Ctrl+,"), - Menu.action(:exit, "Exit", shortcut: "Ctrl+Q") - ] - end - - @doc """ - Convert keyboard events to messages. - """ - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit} - - def event_to_msg(event, _state) do - {:msg, {:menu_event, event}} - end - - @doc """ - Update state based on messages. - """ - def update(:quit, state) do - {state, [:quit]} - end - - def update({:menu_event, %Event.Key{key: key} = event}, state) when key in [:enter, " "] do - # Track what item was selected before handling the event - cursor = Menu.get_cursor(state.menu) - {:ok, menu} = Menu.handle_event(event, state.menu) - - # Update last_action if it was an action item - last_action = - case get_item_type(state.menu, cursor) do - :action -> cursor - _ -> state.last_action - end - - {%{state | menu: menu, last_action: last_action}, []} - end - - def update({:menu_event, event}, state) do - {:ok, menu} = Menu.handle_event(event, state.menu) - {%{state | menu: menu}, []} - end - - defp get_item_type(menu, id) do - menu.items - |> find_item(id) - |> case do - %{type: type} -> type - _ -> nil - end - end - - defp find_item(items, id) do - Enum.find_value(items, fn item -> - cond do - item.id == id -> item - item.type == :submenu -> find_item(item.children, id) - true -> nil - end - end) - end - - @doc """ - Render the current state to a render tree. - """ - def view(state) do - stack(:vertical, [ - # Title - text("Menu Widget Example", Style.new(fg: :cyan, attrs: [:bold])), - text("", nil), - - # Render the menu - Menu.render(state.menu, %{width: 40, height: 20}), - - # Show last action - text("", nil), - - # Controls - render_controls(state) - ]) - end - - defp render_controls(state) do - box_width = 50 - inner_width = box_width - 2 - - top_border = "+" <> String.duplicate("-", inner_width - 10) <> " Controls " <> "+" - bottom_border = "+" <> String.duplicate("-", inner_width) <> "+" - - # Get checkbox states from the menu widget - autosave = Menu.checked?(state.menu, :autosave) - dark_mode = Menu.checked?(state.menu, :dark_mode) - notifications = Menu.checked?(state.menu, :notifications) - - stack(:vertical, [ - text("", nil), - text(top_border, Style.new(fg: :yellow)), - text("|" <> String.pad_trailing(" Up/Down Navigate", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" Right Expand submenu", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" Left Collapse submenu", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" Enter Select / Toggle", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" Q Quit", inner_width) <> "|", nil), - text("|" <> String.pad_trailing("", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" Last action: #{state.last_action || "(none)"}", inner_width) <> "|", nil), - text("|" <> String.pad_trailing("", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" Checkboxes:", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" Auto Save: #{autosave}", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" Dark Mode: #{dark_mode}", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" Notifications: #{notifications}", inner_width) <> "|", nil), - text(bottom_border, Style.new(fg: :yellow)) - ]) - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Run the menu example application. - """ - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/menu/lib/menu/application.ex b/examples/menu/lib/menu/application.ex deleted file mode 100644 index fc7e2b21..00000000 --- a/examples/menu/lib/menu/application.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule Menu.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [] - opts = [strategy: :one_for_one, name: Menu.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/examples/menu/mix.exs b/examples/menu/mix.exs deleted file mode 100644 index ccbb81de..00000000 --- a/examples/menu/mix.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule Menu.MixProject do - use Mix.Project - - def project do - [ - app: :menu, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {Menu.Application, []} - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/menu/mix.lock b/examples/menu/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/menu/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/menu/run.exs b/examples/menu/run.exs deleted file mode 100644 index 50d5d786..00000000 --- a/examples/menu/run.exs +++ /dev/null @@ -1 +0,0 @@ -Menu.App.run() diff --git a/examples/multi_renderer/README.md b/examples/multi_renderer/README.md deleted file mode 100644 index e28c444d..00000000 --- a/examples/multi_renderer/README.md +++ /dev/null @@ -1,215 +0,0 @@ -# TermUI Multi-Renderer Examples - -This directory contains example applications demonstrating TermUI's multi-renderer capabilities, including automatic backend selection (raw vs TTY mode) and graceful feature degradation. - -## Prerequisites - -- Elixir 1.15+ -- OTP 28+ recommended for full raw mode support -- A terminal emulator (Alacritty, Kitty, WezTerm, iTerm2, GNOME Terminal, etc.) - -## Examples - -### 1. Basic Example (`basic.ex`) - -A simple list navigation application that works identically in both raw and TTY modes. - -```bash -# Run with auto-detection -elixir -r examples/multi_renderer/basic.ex -e "Basic.run()" - -# Force TTY mode -elixir -r examples/multi_renderer/basic.ex -e "Basic.run(backend: :tty)" - -# Force raw mode (requires OTP 28+ and terminal support) -elixir -r examples/multi_renderer/basic.ex -e "Basic.run(backend: :raw)" -``` - -**Features:** -- Navigate a list using arrow keys or j/k -- Toggle details view with Enter -- Shows current backend mode -- Works in both raw and TTY modes - -**Controls:** -- `↑`/`↓` or `j`/`k` - Navigate list -- `Enter` - Toggle details view -- `q` - Quit - ---- - -### 2. Text Input Example (`text_input.ex`) - -Demonstrates text input behavior differences between raw and TTY modes. - -```bash -# Run with auto-detection -elixir -r examples/multi_renderer/text_input.ex -e "TextInputExample.run()" - -# Force TTY mode -elixir -r examples/multi_renderer/text_input.ex -e "TextInputExample.run(backend: :tty)" -``` - -**Features:** -- Shows how text input works in different modes -- Raw mode: Character-by-character input with live editing -- TTY mode: Line-based input (press Enter to submit) - -**Controls:** -- Type text and press Enter to submit -- `c` - Clear submitted values -- `h` - Toggle help -- `q` - Quit - ---- - -### 3. Capabilities Example (`capabilities.ex`) - -Displays detected terminal capabilities and backend mode. - -```bash -# Run with auto-detection -elixir -r examples/multi_renderer/capabilities.ex -e "CapabilitiesExample.run()" - -# Run in demo mode (no full UI) -elixir -r examples/multi_renderer/capabilities.ex -e "CapabilitiesExample.run(demo: true)" -``` - -**Features:** -- Shows detected backend mode (raw/tty) -- Displays color support level (true_color, color_256, color_16, monochrome) -- Shows Unicode support status -- Displays terminal dimensions -- Interactive tabs for different capability categories - -**Controls:** -- `Tab` - Switch between tabs -- `1`-`4` - Jump to specific tab -- `Enter` - Refresh capabilities -- `q` - Quit - ---- - -## Backend Modes - -### Raw Mode (OTP 28+) - -Full terminal control with: -- Character-by-character input -- Arrow key navigation -- Mouse support (when available) -- True color and Unicode -- Live UI updates - -### TTY Mode (Fallback) - -Graceful degradation with: -- Line-based input (type and press Enter) -- Single key commands -- Reduced but functional UI -- Works in non-terminal environments - -### Auto-Detection - -By default, TermUI automatically selects the appropriate backend: -1. Attempts raw mode first (OTP 28+) -2. Falls back to TTY mode if: - - OTP < 28 - - A shell is already running - - Raw mode activation fails - - Not in a terminal (piped input, etc.) - -## Running Examples in Different Environments - -### Local Terminal - -```bash -# Standard terminal (supports raw mode) -elixir -r examples/multi_renderer/basic.ex -e "Basic.run()" -``` - -### SSH Session - -```bash -# Should auto-detect and use appropriate mode -elixir -r examples/multi_renderer/basic.ex -e "Basic.run()" -``` - -### Within IEx - -```elixir -# In IEx, you can run examples directly -iex> Code.require_file("examples/multi_renderer/basic.ex") -iex> Basic.run() -``` - -### Forcing Specific Mode - -```bash -# Force TTY mode (useful for testing) -elixir -r examples/multi_renderer/basic.ex -e "Basic.run(backend: :tty)" - -# Force raw mode (will fail if unavailable) -elixir -r examples/multi_renderer/basic.ex -e "Basic.run(backend: :raw)" -``` - -## Configuration - -You can also configure the default backend in your `config/config.exs`: - -```elixir -# config/config.exs -config :term_ui, - backend: :auto # :auto, :raw, or :tty -``` - -## Troubleshooting - -### "Raw mode unavailable" message - -This is expected when: -- Running on OTP < 28 -- A shell is already running in the terminal -- The terminal doesn't support raw mode - -The system will automatically fall back to TTY mode. - -### No colors or wrong colors - -TermUI automatically detects color support. If colors aren't displaying correctly: -1. Check your terminal's color settings -2. Try setting `COLORTERM=truecolor` environment variable -3. Some terminals require explicit color enabling - -### Unicode characters not displaying - -TermUI detects UTF-8 support from locale variables: -- Ensure `LANG` or `LC_CTYPE` includes "UTF-8" -- The system will fall back to ASCII if Unicode isn't detected - -## Development - -### Example Structure - -Each example follows the same pattern: - -1. Uses `TermUI.Elm` for the Elm Architecture -2. Implements `init/1`, `event_to_msg/2`, `update/2`, and `view/1` -3. Provides a `run/1` function that accepts options -4. Includes comments explaining the code - -### Adapting Examples - -To create your own application: - -1. Copy an example file as a template -2. Modify the `init/1` function for your initial state -3. Implement your event handlers in `event_to_msg/2` -4. Add your state logic in `update/2` -5. Design your UI in `view/1` - -## See Also - -- [TermUI Documentation](../../README.md) -- [Multi-Renderer Planning Document](../../notes/planning/multi-renderer/) -- [Configuration Guide](../../lib/term_ui/config.ex) diff --git a/examples/multi_renderer/basic.ex b/examples/multi_renderer/basic.ex deleted file mode 100644 index 32f91b28..00000000 --- a/examples/multi_renderer/basic.ex +++ /dev/null @@ -1,162 +0,0 @@ -# Basic TermUI Example - List Navigation -# -# This example demonstrates a simple list navigation application -# that works identically in both raw mode (full terminal control) -# and TTY mode (line-based input with graceful degradation). -# -# Usage: -# elixir -r examples/multi_renderer/basic.ex -e "Basic.run()" -# -# Or run with specific backend: -# elixir -r examples/multi_renderer/basic.ex -e "Basic.run(backend: :tty)" - -defmodule Basic do - @moduledoc """ - A simple list navigation example that works in both raw and TTY modes. - - In raw mode: Use arrow keys to navigate, Enter to select - In TTY mode: Type single character commands (j/k, then Enter) - """ - - use TermUI.Elm - - # Sample list of items - @items [ - "Item 1: Learn TermUI", - "Item 2: Build TUI apps", - "Item 3: Master Elm Architecture", - "Item 4: Create widgets", - "Item 5: Test your apps" - ] - - # State structure - # %{ - # selected_index: integer(), - # show_details: boolean() - # } - - def init(_opts) do - %{selected_index: 0, show_details: false} - end - - # Event handling - works in both raw and TTY modes - def event_to_msg(%TermUI.Event.Key{key: :up}, _state), do: {:msg, :up} - def event_to_msg(%TermUI.Event.Key{key: :down}, _state), do: {:msg, :down} - def event_to_msg(%TermUI.Event.Key{key: :enter}, _state), do: {:msg, :toggle_details} - def event_to_msg(%TermUI.Event.Key{key: ?q}, _state), do: {:msg, :quit} - def event_to_msg(%TermUI.Event.Key{key: ?j}, _state), do: {:msg, :down} - def event_to_msg(%TermUI.Event.Key{key: ?k}, _state), do: {:msg, :up} - def event_to_msg(_event, _state), do: :ignore - - # State updates - def update(:up, state) do - new_index = max(0, state.selected_index - 1) - {%{state | selected_index: new_index}, []} - end - - def update(:down, state) do - new_index = min(length(@items) - 1, state.selected_index + 1) - {%{state | selected_index: new_index}, []} - end - - def update(:toggle_details, state) do - {%{state | show_details: not state.show_details}, []} - end - - def update(:quit, state) do - {state, [:quit]} - end - - # View rendering - def view(state) do - selected_item = Enum.at(@items, state.selected_index) - - box([ - text("TermUI Basic Example - List Navigation", - TermUI.Renderer.Style.new() - |> TermUI.Renderer.Style.fg(:green) - |> TermUI.Renderer.Style.bright() - ), - text(""), - text("Use ↑/↓ or j/k to navigate, Enter for details, q to quit", - TermUI.Renderer.Style.new() - |> TermUI.Renderer.Style.fg(:cyan) - ), - text(""), - text("─" |> String.duplicate(40), - TermUI.Renderer.Style.new() - |> TermUI.Renderer.Style.fg(:bright_black) - ), - text(""), - render_list(@items, state.selected_index), - text(""), - text("─" |> String.duplicate(40), - TermUI.Renderer.Style.new() - |> TermUI.Renderer.Style.fg(:bright_black) - ), - text(""), - render_details(selected_item, state.show_details), - text(""), - render_footer(state) - ]) - end - - # Render the list with selection indicator - defp render_list(items, selected_index) do - items - |> Enum.with_index() - |> Enum.map(fn {item, index} -> - prefix = if index == selected_index, do: "► ", else: " " - style = - if index == selected_index do - TermUI.Renderer.Style.new() - |> TermUI.Renderer.Style.fg(:yellow) - |> TermUI.Renderer.Style.bright() - else - TermUI.Renderer.Style.new() - end - - text(prefix <> item, style) - end) - end - - # Render details section - defp render_details(_item, false), do: empty() - - defp render_details(item, true) do - box([ - text("Selected:"), - text(" " <> item, - TermUI.Renderer.Style.new() - |> TermUI.Renderer.Style.fg(:yellow) - ) - ], - border: :single, - padding: {0, 1} - ) - end - - # Render footer with backend mode info - defp render_footer(state) do - mode = TermUI.App.backend_mode() || :unknown - mode_text = - case mode do - :raw -> "Raw Mode (full terminal control)" - :tty -> "TTY Mode (line-based input)" - :skip -> "Test Mode" - _ -> "Unknown Mode" - end - - text("Mode: " <> mode_text, - TermUI.Renderer.Style.new() - |> TermUI.Renderer.Style.fg(:bright_black) - ) - end - - # Run the application - def run(opts \\ []) do - # Combine with default options for example - all_opts = Keyword.put_new(opts, :name, :basic_example) - TermUI.App.run(__MODULE__, all_opts) - end -end diff --git a/examples/multi_renderer/capabilities.ex b/examples/multi_renderer/capabilities.ex deleted file mode 100644 index 64c90d57..00000000 --- a/examples/multi_renderer/capabilities.ex +++ /dev/null @@ -1,400 +0,0 @@ -# TermUI Capabilities Detection Example -# -# This example demonstrates how to query and display -# detected terminal capabilities. -# -# Usage: -# elixir -r examples/multi_renderer/capabilities.ex -e "CapabilitiesExample.run()" -# -# Or run with specific backend: -# elixir -r examples/multi_renderer/capabilities.ex -e "CapabilitiesExample.run(backend: :tty)" - -defmodule CapabilitiesExample do - @moduledoc """ - Example showing how to query and display terminal capabilities. - - Demonstrates: - - Backend mode detection (raw/tty) - - Color support (true_color, color_256, color_16, monochrome) - - Unicode support - - Terminal dimensions - - Mouse support - """ - - use TermUI.Elm - - # State structure - # %{ - # capabilities: map() | nil, - # current_tab: :overview | :colors | :unicode | :dimensions - # } - - def init(_opts) do - # Get capabilities at init - capabilities = get_capabilities() - %{capabilities: capabilities, current_tab: :overview} - end - - # Event handling - def event_to_msg(%TermUI.Event.Key{key: :tab}, state), do: {:msg, :next_tab} - def event_to_msg(%TermUI.Event.Key{key: ?1}, _state), do: {:msg, :show_overview} - def event_to_msg(%TermUI.Event.Key{key: ?2}, _state), do: {:msg, :show_colors} - def event_to_msg(%TermUI.Event.Key{key: ?3}, _state), do: {:msg, :show_unicode} - def event_to_msg(%TermUI.Event.Key{key: ?4}, _state), do: {:msg, :show_dimensions} - def event_to_msg(%TermUI.Event.Key{key: :enter}, _state), do: {:msg, :refresh} - def event_to_msg(%TermUI.Event.Key{key: ?q}, _state), do: {:msg, :quit} - def event_to_msg(_event, _state), do: :ignore - - # State updates - def update(:next_tab, state) do - tabs = [:overview, :colors, :unicode, :dimensions] - current_index = Enum.find_index(tabs, fn t -> t == state.current_tab end) - next_index = rem(current_index + 1, length(tabs)) - {%{state | current_tab: Enum.at(tabs, next_index)}, []} - end - - def update(:show_overview, state), do: {%{state | current_tab: :overview}, []} - def update(:show_colors, state), do: {%{state | current_tab: :colors}, []} - def update(:show_unicode, state), do: {%{state | current_tab: :unicode}, []} - def update(:show_dimensions, state), do: {%{state | current_tab: :dimensions}, []} - def update(:refresh, state), do: {%{state | capabilities: get_capabilities()}, []} - def update(:quit, state), do: {state, [:quit]} - - # View rendering - def view(state) do - box([ - header(), - text(""), - render_tab_content(state), - text(""), - render_tabs(state), - text(""), - footer() - ]) - end - - defp header do - box([ - text("TermUI Capabilities Detection", - TermUI.Renderer.Style.new() - |> TermUI.Renderer.Style.fg(:green) - |> TermUI.Renderer.Style.bright() - ), - text("Displays detected terminal features and backend mode") - ]) - end - - defp render_tab_content(%{current_tab: :overview, capabilities: caps}) do - box([ - text("Backend Mode: " <> format_backend_mode(caps), - text("Terminal: " <> format_terminal(caps)), - text("Color Support: " <> format_colors(caps), - text("Unicode: " <> format_unicode(caps)), - text("Dimensions: " <> format_dimensions(caps)), - text("Mouse: " <> format_mouse(caps)) - ]) - end - - defp render_tab_content(%{current_tab: :colors, capabilities: caps}) do - color_mode = get_in(caps, [:colors]) || :unknown - - box([ - text("Color Capabilities", - TermUI.Renderer.Style.new() - |> TermUI.Renderer.Style.fg(:green) - ), - text(""), - color_capability_row("Detected Mode", color_mode, get_color_style(color_mode)), - text(""), - text("Support Levels:"), - text(" • true_color - 24-bit RGB (16.7 million colors)"), - text(" • color_256 - 256-color palette"), - text(" • color_16 - 16 basic colors"), - text(" • monochrome - No color support"), - text(""), - color_examples(color_mode) - ]) - end - - defp render_tab_content(%{current_tab: :unicode, capabilities: caps}) do - unicode_supported = get_in(caps, [:unicode]) == true - - box([ - text("Unicode Support", - TermUI.Renderer.Style.new() - |> TermUI.Renderer.Style.fg(:green) - ), - text(""), - text("Detected: " <> if(unicode_supported, do: "Yes ✓", else: "No ✗"), - text(""), - if(unicode_supported, - do: text(" Box drawing: ┌─┐│└┘"), - else: text(" ASCII fallback: +-||") - ), - text(""), - text("Note: TermUI automatically falls back to"), - text(" ASCII when Unicode is not available.") - ]) - end - - defp render_tab_content(%{current_tab: :dimensions, capabilities: caps}) do - {rows, cols} = get_in(caps, [:dimensions]) || {nil, nil} - - box([ - text("Terminal Dimensions", - TermUI.Renderer.Style.new() - |> TermUI.Renderer.Style.fg(:green) - ), - text(""), - text("Rows: " <> format_value(rows)), - text("Columns: " <> format_value(cols)), - text(""), - text("Total Cells: " <> format_total(rows, cols)), - text(""), - if(rows && cols, - do: text("Terminal size: #{rows}×#{cols}"), - else: text("Dimensions not available") - ) - ]) - end - - defp render_tabs(state) do - tabs = [ - {"1", :overview, "Overview"}, - {"2", :colors, "Colors"}, - {"3", :unicode, "Unicode"}, - {"4", :dimensions, "Dimensions"} - ] - - tab_text = - tabs - |> Enum.map(fn {key, tab, label} -> - style = - if state.current_tab == tab do - TermUI.Renderer.Style.new() - |> TermUI.Renderer.Style.fg(:yellow) - |> TermUI.Renderer.Style.bright() - |> TermUI.Renderer.Style.underline() - else - TermUI.Renderer.Style.new() - |> TermUI.Renderer.Style.fg(:bright_black) - end - - text("[#{key}:#{label}] ", style) - end) - - stack(:horizontal, tab_text) - end - - defp footer do - text("Tab=switch | 1-4=jump | Enter=refresh | q=quit", - TermUI.Renderer.Style.new() - |> TermUI.Renderer.Style.fg(:bright_black) - ) - end - - # Color formatting helpers - defp get_color_style(:true_color), do: TermUI.Renderer.Style.new() |> TermUI.Renderer.Style.fg(:bright_green) - defp get_color_style(:color_256), do: TermUI.Renderer.Style.new() |> TermUI.Renderer.Style.fg(:green) - defp get_color_style(:color_16), do: TermUI.Renderer.Style.new() |> TermUI.Renderer.Style.fg(:yellow) - defp get_color_style(:monochrome), do: TermUI.Renderer.Style.new() |> TermUI.Renderer.Style.fg(:white) - defp get_color_style(_), do: TermUI.Renderer.Style.new() - - defp color_capability_row(label, value, style) do - stack(:horizontal, [ - text(label <> ": "), - text(inspect(value), style) - ]) - end - - defp color_examples(:true_color) do - box([ - text("True Color Gradient Example:"), - text(""), - rainbow_gradient("True Color (24-bit RGB)"), - text(""), - text("Your terminal supports over 16 million colors!") - ]) - end - - defp color_examples(:color_256) do - box([ - text("256-Color Palette Example:"), - text(""), - sample_palette_256(), - text(""), - text("Your terminal supports 256 colors.") - ]) - end - - defp color_examples(:color_16) do - box([ - text("16-Color Example:"), - text(""), - sample_colors_16(), - text(""), - text("Your terminal supports 16 basic colors.") - ]) - end - - defp color_examples(:monochrome) do - box([ - text("Monochrome Display"), - text(""), - text("Your terminal does not support color."), - text("All output will be in a single color.") - ]) - end - - defp color_examples(_), do: text("Color detection not available") - - # Sample color displays - defp rainbow_gradient(label) do - colors = [:red, :yellow, :green, :cyan, :blue, :magenta] - - colors - |> Enum.map(fn color -> - styled("■ ", TermUI.Renderer.Style.new() |> TermUI.Renderer.Style.fg(color)) - end) - |> prepend_text(label <> ": ") - end - - defp sample_palette_256 do - # Sample of the 256-color palette - indices = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] - - indices - |> Enum.map(fn i -> - style = TermUI.Renderer.Style.new() |> TermUI.Renderer.Style.bg(i) - styled(" ", style) - end) - end - - defp sample_colors_16 do - colors = [ - {:black, "K "}, - {:red, "R "}, - {:green, "G "}, - {:yellow, "Y "}, - {:blue, "B "}, - {:magenta, "M "}, - {:cyan, "C "}, - {:white, "W "} - ] - - colors - |> Enum.map(fn {color, label} -> - styled(label, TermUI.Renderer.Style.new() |> TermUI.Renderer.Style.bg(color)) - end) - end - - # Formatting helpers - defp format_backend_mode(%{backend_mode: mode}) when mode, do: inspect(mode) - defp format_backend_mode(_), do: "unknown" - - defp format_terminal(%{terminal: true}), do: "Yes (terminal detected)" - defp format_terminal(%{terminal: false}), do: "No (piped/file)" - defp format_terminal(_), do: "unknown" - - defp format_colors(%{colors: mode}) when mode, do: inspect(mode) - defp format_colors(_), do: "unknown" - - defp format_unicode(%{unicode: true}), do: "Yes ✓" - defp format_unicode(%{unicode: false}), do: "No (ASCII fallback)" - defp format_unicode(_), do: "unknown" - - defp format_dimensions(%{dimensions: {rows, cols}}) when rows and cols do - "#{rows} rows × #{cols} cols" - end - - defp format_dimensions(_), do: "unknown" - - defp format_mouse(%{mouse: true}), do: "Available" - defp format_mouse(%{mouse: false}), do: "Not available" - defp format_mouse(_), do: "unknown" - - defp format_value(nil), do: "N/A" - defp format_value(value), do: to_string(value) - - defp format_total(nil, _), do: "N/A" - defp format_total(_, nil), do: "N/A" - defp format_total(rows, cols), do: to_string(rows * cols) - - # Get capabilities from the running system - defp get_capabilities do - %{ - backend_mode: TermUI.App.backend_mode(), - colors: get_color_mode(), - unicode: TermUI.App.supports?(:unicode), - dimensions: get_dimensions(), - terminal: get_terminal(), - mouse: TermUI.App.supports?(:mouse) - } - end - - defp get_color_mode do - cond do - TermUI.App.supports?(:true_color) -> :true_color - TermUI.App.supports?(:color_256) -> :color_256 - TermUI.App.supports?(:color_16) -> :color_16 - TermUI.App.supports?(:monochrome) -> :monochrome - true -> nil - end - end - - defp get_dimensions do - case TermUI.App.capabilities() do - %{dimensions: dims} -> dims - _ -> nil - end - end - - defp get_terminal do - case TermUI.App.capabilities() do - %{terminal: term} when is_boolean(term) -> term - _ -> nil - end - end - - # Run the application - def run(opts \\ []) do - all_opts = Keyword.put_new(opts, :name, :capabilities_example) - - # Check if we should show a demo or run the full app - if Keyword.get(opts, :demo, false) do - run_demo() - else - try do - TermUI.App.run(__MODULE__, all_opts) - rescue - e -> - IO.puts("Could not start full UI: #{inspect(e)}") - IO.puts("\nRunning in demo mode instead...\n") - run_demo() - end - end - end - - # Demo mode that shows capabilities without full UI - defp run_demo do - caps = get_capabilities() - - IO.puts(""" - TermUI Capabilities Detection Demo - ================================= - - Backend Mode: #{format_backend_mode(caps)} - Terminal: #{format_terminal(caps)} - Color Support: #{format_colors(caps)} - Unicode: #{format_unicode(caps)} - Dimensions: #{format_dimensions(caps)} - Mouse: #{format_mouse(caps)} - - This demo shows the capabilities that would be detected - when running a full TermUI application. - - To run the full interactive example, use OTP 28+ and ensure - you're in a terminal that supports raw mode. - """) - end -end diff --git a/examples/multi_renderer/text_input.ex b/examples/multi_renderer/text_input.ex deleted file mode 100644 index 9c200a82..00000000 --- a/examples/multi_renderer/text_input.ex +++ /dev/null @@ -1,251 +0,0 @@ -# TermUI Text Input Example -# -# This example demonstrates text input that works in both modes: -# - Raw mode: Character-by-character input with live editing -# - TTY mode: Line-based input (press Enter after typing) -# -# Usage: -# elixir -r examples/multi_renderer/text_input.ex -e "TextInputExample.run()" -# -# Or run with specific backend: -# elixir -r examples/multi_renderer/text_input.ex -e "TextInputExample.run(backend: :tty)" - -defmodule TextInputExample do - @moduledoc """ - Text input example demonstrating character vs line input modes. - - In raw mode (OTP 28+): - - See characters appear as you type - - Use backspace to delete - - Press Enter to submit - - In TTY mode (fallback): - - Type your input - - Press Enter to see the result - - Line-by-line input (no live editing) - """ - - use TermUI.Elm - alias TermUI.Widget.TextInput - - # State structure - # %{ - # input_value: String.t(), - # submitted_values: [String.t()], - # show_help: boolean() - # } - - def init(_opts) do - %{input_value: "", submitted_values: [], show_help: true} - end - - # Event handling - def event_to_msg(%TermUI.Event.Key{key: :enter}, _state), do: {:msg, :submit} - def event_to_msg(%TermUI.Event.Key{key: ?c}, _state), do: {:msg, :clear} - def event_to_msg(%TermUI.Event.Key{key: ?h}, _state), do: {:msg, :toggle_help} - def event_to_msg(%TermUI.Event.Key{key: ?q}, _state), do: {:msg, :quit} - def event_to_msg(_event, _state), do: :ignore - - # Handle TextInput messages - def handle_info({:changed, value}, state) do - {state, []} - end - - def handle_info({:submit, value}, state) do - new_values = [value | state.submitted_values] - {%{state | submitted_values: new_values}, []} - end - - # State updates - def update(:submit, state) do - # Value is submitted via TextInput's on_submit - {state, []} - end - - def update(:clear, state) do - {%{state | submitted_values: []}, []} - end - - def update(:toggle_help, state) do - {%{state | show_help: not state.show_help}, []} - end - - def update(:quit, state) do - {state, [:quit]} - end - - # View rendering - def view(state) do - backend_mode = TermUI.App.backend_mode() - mode_label = mode_label(backend_mode) - - box([ - # Header - text("TermUI Text Input Example", - TermUI.Renderer.Style.new() - |> TermUI.Renderer.Style.fg(:green) - |> TermUI.Renderer.Style.bright() - ), - text(""), - text("Mode: " <> mode_label, - TermUI.Renderer.Style.new() - |> TermUI.Renderer.Style.fg(:cyan) - ), - text(""), - - # Instructions - render_help(state.show_help, backend_mode), - text(""), - - # Text input field - box([ - text("Enter text: "), - text(state.input_value || "", - TermUI.Renderer.Style.new() - |> TermUI.Renderer.Style.fg(:yellow) - ), - text("_" |> String.duplicate(30), - TermUI.Renderer.Style.new() - |> TermUI.Renderer.Style.fg(:bright_black) - ) - ], style: %{ - border: :none, - padding: {0, 0} - }), - text(""), - - # Submitted values - if(state.submitted_values == [], do: empty(), else: render_submitted(state.submitted_values)), - text(""), - - # Footer - text("c=clear | h=toggle help | q=quit", - TermUI.Renderer.Style.new() - |> TermUI.Renderer.Style.fg(:bright_black) - ) - ]) - end - - defp mode_label(:raw), do: "Raw Mode (character input with live editing)" - defp mode_label(:tty), do: "TTY Mode (line-based input)" - defp mode_label(:skip), do: "Test Mode" - defp mode_label(_), do: "Unknown" - - defp render_help(false, _mode), do: empty() - - defp render_help(true, :raw) do - box([ - text("Raw Mode Instructions:"), - text(" • Type to see characters appear"), - text(" • Press Enter to submit"), - text(" • Backspace deletes last character"), - text(" • Arrow keys move cursor") - ], border: :single) - end - - defp render_help(true, :tty) do - box([ - text("TTY Mode Instructions:"), - text(" • Type your text"), - text(" • Press Enter to submit"), - text(" • Line-based input (live editing not available)") - ], border: :single) - end - - defp render_help(true, _) do - box([text("Run without skip_terminal to see input modes")], border: :single) - end - - defp render_submitted(values) when length(values) > 5 do - render_submitted(Enum.take(values, 5)) - end - - defp render_submitted(values) do - box([ - text("Submitted Values:", - TermUI.Renderer.Style.new() - |> TermUI.Renderer.Style.fg(:green) - ) - | Enum.concat( - values - |> Enum.reverse() - |> Enum.map(fn v -> - text(" • " <> v, - TermUI.Renderer.Style.new() - |> TermUI.Renderer.Style.fg(:yellow) - ) - end) - ) - ], border: :single) - end - - # Run the application - def run(opts \\ []) do - # For this example, we'll use a simplified approach - # The full TextInput integration with StatefulComponent - # would require more setup - - all_opts = Keyword.put_new(opts, :name, :text_input_example) - - case Keyword.get(opts, :backend, TermUI.Config.get(:backend, :auto)) do - :tty -> - # In TTY mode, we demonstrate line-based input - run_tty_demo(all_opts) - - _ -> - # In raw mode or auto, try full example - run_full_example(all_opts) - end - end - - # Simplified demo for TTY mode - defp run_tty_demo(opts) do - IO.puts(""" - TermUI Text Input Example - TTY Mode - ==================================== - - In TTY mode, text input is line-based: - - Type your input and press Enter - - No live editing (submitted as-is) - - This is a simplified demo for TTY mode. - For full functionality, use raw mode (OTP 28+). - - Press Enter to continue... - """) - - IO.gets("> ") - - IO.puts(""" - - Thank you for trying the Text Input example! - - In raw mode, you would see: - - Live character-by-character input - - Cursor navigation with arrow keys - - Backspace/delete for editing - """) - - :ok - end - - # Full example for raw mode - defp run_full_example(opts) do - # This would use the full TextInput widget - # For now, return a simplified version - IO.puts(""" - TermUI Text Input Example - ========================== - - Starting with options: #{inspect(opts)} - - Note: This example demonstrates the structure. - The full TextInput widget integration is shown in the - widget documentation and test suite. - - Press q to quit... - """) - - :ok - end -end diff --git a/examples/pick_list/README.md b/examples/pick_list/README.md deleted file mode 100644 index e6f3a360..00000000 --- a/examples/pick_list/README.md +++ /dev/null @@ -1,162 +0,0 @@ -# PickList Example - -A demonstration of the PickList widget for modal selection dialogs with filtering support. - -## Widget Overview - -The PickList widget displays a centered modal overlay with a scrollable list of items. It provides keyboard navigation and type-ahead filtering, making it ideal for selection dialogs where users choose from a list of options. - -### Key Features - -- Modal overlay with centered positioning -- Scrollable list navigation -- Type-ahead filtering (incremental search) -- Keyboard navigation (arrows, page up/down, home/end) -- Selection and cancel callbacks -- Automatic scroll adjustment to keep selection visible -- Border and status line display -- Handles empty results gracefully - -### When to Use - -Use PickList when you need to: -- Present a searchable list of options -- Create file or item picker dialogs -- Allow users to select from a large dataset -- Provide quick filtering via typing -- Create modal selection interfaces - -## Widget Options - -The PickList widget accepts the following options in its `init/1` function (via props map): - -- `:items` - List of items to display (required) -- `:title` - Modal title (default: "Select") -- `:width` - Modal width in characters (default: 40) -- `:height` - Modal height in characters (default: 10) -- `:style` - Border/text style options (map) -- `:highlight_style` - Style for selected item (default: `%{fg: :black, bg: :white}`) -- `:on_select` - Callback when item selected (not used in this example) -- `:on_cancel` - Callback when cancelled (not used in this example) - -### Example Usage - -```elixir -props = %{ - items: ["Apple", "Banana", "Cherry"], - title: "Select Fruit", - width: 35, - height: 12 -} - -{:ok, picker_state} = PickList.init(props) -``` - -## Example Structure - -This example contains: - -- `lib/pick_list/app.ex` - Main application demonstrating the PickList widget - - Three different pickers: Fruits, Colors, and Countries - - Type-ahead filtering demonstration - - Selection handling with state updates - - Cancel handling - -The example maintains: -- Current picker state (which picker is open) -- Selected values for each picker -- Status messages for user feedback - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/pick_list -mix termui.run -``` - -Or manually: - -```bash -cd examples/pick_list -mix run -e "PickList.App.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/pick_list -iex -S mix -``` - -Then in IEx: - -```elixir -PickList.App.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -### Opening Pickers -- **1** - Open fruit picker (35 items) -- **2** - Open color picker (20 items) -- **3** - Open country picker (24 items) - -### When Picker is Open - -#### Navigation -- **Up/Down** - Navigate items -- **Page Up/Down** - Jump 10 items -- **Home/End** - Jump to first/last item - -#### Selection -- **Enter** - Confirm selection -- **Escape** - Cancel and close picker - -#### Filtering -- **Type any character** - Start/extend filter (case-insensitive) -- **Backspace** - Remove last filter character - -### General -- **Q** - Quit the application (only when picker is closed) - -## Features Demonstrated - -1. **Multiple Pickers** - Three different pickers with different data sets -2. **Type-Ahead Filtering** - Real-time filtering as you type -3. **Selection Tracking** - Shows current selections for each picker -4. **Status Updates** - Displays feedback for actions -5. **Modal Positioning** - Automatically centers picker in terminal -6. **Scroll Management** - Keeps selected item visible during navigation -7. **Empty Results** - Handles "no matches" gracefully - -## Sample Data - -### Fruit Picker (35 items) -Apple, Apricot, Avocado, Banana, Blackberry, Blueberry, Cherry, Coconut, and more - -### Color Picker (20 items) -Red, Orange, Yellow, Green, Blue, Indigo, Violet, Pink, Cyan, Magenta, and more - -### Country Picker (24 items) -Argentina, Australia, Brazil, Canada, China, Egypt, France, Germany, India, and more - -## Implementation Notes - -- Picker state is managed via commands pattern -- Selection sends `{:send, pid, {:select, item}}` command -- Cancel sends `{:send, pid, :cancel}` command -- Filter resets selection to first matching item -- Modal is rendered as a cell-based overlay -- Status line shows current position (e.g., "Item 5 of 20") -- Filter line appears when typing diff --git a/examples/pick_list/lib/pick_list/app.ex b/examples/pick_list/lib/pick_list/app.ex deleted file mode 100644 index 2399ab2e..00000000 --- a/examples/pick_list/lib/pick_list/app.ex +++ /dev/null @@ -1,304 +0,0 @@ -defmodule PickList.App do - @moduledoc """ - PickList Widget Example - - This example demonstrates how to use the TermUI.Widget.PickList widget - for modal selection dialogs with filtering support. - - Features demonstrated: - - Modal overlay with centered positioning - - Scrollable list navigation - - Type-ahead filtering - - Selection and cancel callbacks - - Multiple pick lists for different use cases - - Controls: - - Up/Down: Navigate items - - Page Up/Down: Jump 10 items - - Home/End: Jump to first/last item - - Enter: Confirm selection - - Escape: Cancel/close picker - - Typing: Filter items - - Backspace: Remove filter character - - 1/2/3: Open different pickers - - Q: Quit the application - """ - - use TermUI.Elm - - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Widget.PickList - - # Sample data for different pick lists - @fruits ["Apple", "Apricot", "Avocado", "Banana", "Blackberry", "Blueberry", - "Cherry", "Coconut", "Cranberry", "Date", "Dragon Fruit", "Fig", - "Grape", "Grapefruit", "Guava", "Honeydew", "Kiwi", "Lemon", - "Lime", "Lychee", "Mango", "Melon", "Nectarine", "Orange", - "Papaya", "Passion Fruit", "Peach", "Pear", "Pineapple", "Plum", - "Pomegranate", "Raspberry", "Strawberry", "Tangerine", "Watermelon"] - - @colors ["Red", "Orange", "Yellow", "Green", "Blue", "Indigo", "Violet", - "Pink", "Cyan", "Magenta", "Brown", "Black", "White", "Gray", - "Teal", "Navy", "Maroon", "Olive", "Coral", "Salmon"] - - @countries ["Argentina", "Australia", "Brazil", "Canada", "China", "Egypt", - "France", "Germany", "India", "Italy", "Japan", "Kenya", - "Mexico", "Netherlands", "Norway", "Portugal", "Russia", - "Spain", "Sweden", "Thailand", "United Kingdom", "United States", - "Vietnam", "Zimbabwe"] - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- - - @doc """ - Initialize the component state. - """ - def init(_opts) do - %{ - # Current picker state (nil when no picker open) - picker: nil, - picker_state: nil, - - # Selected values - selected_fruit: nil, - selected_color: nil, - selected_country: nil, - - # Status message - last_action: "Press 1, 2, or 3 to open a picker" - } - end - - @doc """ - Convert events to messages. - """ - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"] do - {:msg, :quit} - end - - def event_to_msg(%Event.Key{key: "1"}, %{picker: nil}) do - {:msg, :open_fruit_picker} - end - - def event_to_msg(%Event.Key{key: "2"}, %{picker: nil}) do - {:msg, :open_color_picker} - end - - def event_to_msg(%Event.Key{key: "3"}, %{picker: nil}) do - {:msg, :open_country_picker} - end - - def event_to_msg(event, %{picker: picker}) when picker != nil do - {:msg, {:picker_event, event}} - end - - def event_to_msg(_event, _state) do - :ignore - end - - @doc """ - Update state based on messages. - """ - def update(:quit, state) do - {state, [:quit]} - end - - def update(:open_fruit_picker, state) do - props = %{ - items: @fruits, - title: "Select a Fruit", - width: 35, - height: 12 - } - - {:ok, picker_state} = PickList.init(props) - - {%{state | - picker: :fruit, - picker_state: picker_state, - last_action: "Fruit picker opened - type to filter" - }, []} - end - - def update(:open_color_picker, state) do - props = %{ - items: @colors, - title: "Select a Color", - width: 30, - height: 10 - } - - {:ok, picker_state} = PickList.init(props) - - {%{state | - picker: :color, - picker_state: picker_state, - last_action: "Color picker opened - type to filter" - }, []} - end - - def update(:open_country_picker, state) do - props = %{ - items: @countries, - title: "Select a Country", - width: 40, - height: 15 - } - - {:ok, picker_state} = PickList.init(props) - - {%{state | - picker: :country, - picker_state: picker_state, - last_action: "Country picker opened - type to filter" - }, []} - end - - def update({:picker_event, event}, state) do - case PickList.handle_event(event, state.picker_state) do - {:ok, new_picker_state} -> - {%{state | picker_state: new_picker_state}, []} - - {:ok, new_picker_state, commands} -> - # Process commands from picker - process_picker_commands(state, new_picker_state, commands) - end - end - - def update(_msg, state) do - {state, []} - end - - defp process_picker_commands(state, new_picker_state, commands) do - Enum.reduce(commands, {%{state | picker_state: new_picker_state}, []}, fn cmd, {s, cmds} -> - case cmd do - {:send, _pid, {:select, item}} -> - # Handle selection - update the appropriate field and close picker - new_state = - case s.picker do - :fruit -> %{s | selected_fruit: item} - :color -> %{s | selected_color: item} - :country -> %{s | selected_country: item} - end - - {%{new_state | - picker: nil, - picker_state: nil, - last_action: "Selected: #{item}" - }, cmds} - - {:send, _pid, :cancel} -> - # Handle cancel - just close picker - {%{s | - picker: nil, - picker_state: nil, - last_action: "Selection cancelled" - }, cmds} - - _ -> - {s, cmds} - end - end) - end - - @doc """ - Render the current state. - """ - def view(state) do - stack(:vertical, [ - # Title - text("PickList Widget Example", Style.new(fg: :cyan, attrs: [:bold])), - text(""), - - # Instructions - render_instructions(), - text(""), - - # Current selections - render_selections(state), - text(""), - - # Status - render_status(state), - - # Picker overlay (if open) - render_picker(state) - ]) - end - - # ---------------------------------------------------------------------------- - # Private Helpers - # ---------------------------------------------------------------------------- - - defp render_instructions do - stack(:vertical, [ - text("Controls:", Style.new(fg: :yellow)), - text(" 1 Open fruit picker"), - text(" 2 Open color picker"), - text(" 3 Open country picker"), - text(""), - text("When picker is open:", Style.new(fg: :yellow)), - text(" Up/Down Navigate items"), - text(" PgUp/PgDn Jump 10 items"), - text(" Home/End Jump to first/last"), - text(" Enter Confirm selection"), - text(" Escape Cancel"), - text(" Typing Filter items"), - text(" Backspace Remove filter char"), - text(""), - text(" Q Quit") - ]) - end - - defp render_selections(state) do - stack(:vertical, [ - text("Current Selections:", Style.new(fg: :green, attrs: [:bold])), - text(""), - render_selection("Fruit", state.selected_fruit), - render_selection("Color", state.selected_color), - render_selection("Country", state.selected_country) - ]) - end - - defp render_selection(label, nil) do - stack(:horizontal, [ - text(" #{String.pad_trailing(label <> ":", 10)}", Style.new(fg: :white)), - text("(none)", Style.new(fg: :bright_black)) - ]) - end - - defp render_selection(label, value) do - stack(:horizontal, [ - text(" #{String.pad_trailing(label <> ":", 10)}", Style.new(fg: :white)), - text(value, Style.new(fg: :cyan, attrs: [:bold])) - ]) - end - - defp render_status(state) do - stack(:horizontal, [ - text("Status: ", Style.new(fg: :yellow)), - text(state.last_action, Style.new(fg: :white)) - ]) - end - - defp render_picker(%{picker: nil}), do: text("") - - defp render_picker(state) do - # Render the picker with a reasonable area - area = %{x: 0, y: 0, width: 80, height: 24} - PickList.render(state.picker_state, area) - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Run the pick list example application. - """ - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/pick_list/lib/pick_list/application.ex b/examples/pick_list/lib/pick_list/application.ex deleted file mode 100644 index 7de99f65..00000000 --- a/examples/pick_list/lib/pick_list/application.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule PickList.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [] - opts = [strategy: :one_for_one, name: PickList.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/examples/pick_list/mix.exs b/examples/pick_list/mix.exs deleted file mode 100644 index c15919cc..00000000 --- a/examples/pick_list/mix.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule PickList.MixProject do - use Mix.Project - - def project do - [ - app: :pick_list, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {PickList.Application, []} - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/pick_list/mix.lock b/examples/pick_list/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/pick_list/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/pick_list/run.exs b/examples/pick_list/run.exs deleted file mode 100644 index 76004e07..00000000 --- a/examples/pick_list/run.exs +++ /dev/null @@ -1 +0,0 @@ -PickList.App.run() diff --git a/examples/process_monitor/README.md b/examples/process_monitor/README.md deleted file mode 100644 index 585a52b0..00000000 --- a/examples/process_monitor/README.md +++ /dev/null @@ -1,211 +0,0 @@ -# ProcessMonitor Example - -A demonstration of the ProcessMonitor widget for live BEAM process inspection and management. - -## Widget Overview - -The ProcessMonitor widget provides real-time monitoring of BEAM processes with detailed information including PID, name, reductions, memory usage, and message queue depth. It includes powerful features for debugging and process management. - -### Key Features - -- Live process list with automatic updates -- Process information (PID, name, reductions, memory, queue length, status) -- Configurable update interval -- Sorting by any field (PID, name, reductions, memory, queue, status) -- Filtering by name or module (regex support) -- Process details panel with multiple views -- Process actions (kill, suspend, resume) with confirmation -- Stack trace visualization -- Links and monitors display -- Warning thresholds for queue depth and memory usage -- System process filtering - -### When to Use - -Use ProcessMonitor when you need to: -- Debug BEAM application performance -- Identify memory leaks or high CPU usage -- Monitor message queue buildup -- Inspect process relationships (links/monitors) -- Analyze process behavior and stack traces -- Manage running processes (kill/suspend/resume) -- Track system resource usage - -## Widget Options - -The ProcessMonitor widget accepts the following options in its `new/1` function: - -- `:update_interval` - Refresh interval in milliseconds (default: 1000) -- `:show_system_processes` - Include system processes (default: false) -- `:thresholds` - Warning thresholds map (default: see below) -- `:on_select` - Callback when process is selected `fn process -> ... end` -- `:on_action` - Callback when action is performed `fn action -> ... end` - -### Default Thresholds - -```elixir -%{ - queue_warning: 1000, # Yellow warning - queue_critical: 10_000, # Red alert - memory_warning: 50 * 1024 * 1024, # 50MB warning - memory_critical: 200 * 1024 * 1024 # 200MB alert -} -``` - -### Example Usage - -```elixir -ProcessMonitor.new( - update_interval: 1000, - show_system_processes: false, - thresholds: %{ - queue_warning: 500, - queue_critical: 5000 - } -) -``` - -## Example Structure - -This example contains: - -- `lib/process_monitor/app.ex` - Main application demonstrating the ProcessMonitor widget - - Spawns test worker processes - - Demonstrates various process states - - Shows all monitoring features - - Handles process actions and confirmations - -The example spawns test workers that: -- Generate reductions (simulate work) -- Build up message queues -- Allocate memory -- Can be filtered by name "Worker" - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/process_monitor -mix termui.run -``` - -Or manually: - -```bash -cd examples/process_monitor -mix run -e "ProcessMonitorExample.App.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/process_monitor -iex -S mix -``` - -Then in IEx: - -```elixir -ProcessMonitorExample.App.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -### Navigation -- **Up/Down** - Move selection between processes -- **PageUp/PageDown** - Scroll by page (20 processes) -- **Home/End** - Jump to first/last process - -### Display & Sorting -- **r** - Refresh process list immediately -- **s** - Cycle sort field (PID → name → reductions → memory → queue → status) -- **S** - Toggle sort direction (ascending/descending) -- **Enter** - Toggle details panel - -### Details Views -- **l** - Show links and monitors -- **t** - Show stack trace -- **Enter** - Toggle general info panel - -### Filtering -- **/** - Start filter input (supports regex) -- **Type** - Enter filter pattern -- **Enter** - Apply filter -- **Escape** - Clear filter - -### Process Actions -- **k** - Kill selected process (requires confirmation) -- **p** - Pause (suspend) or resume selected process -- **y** - Confirm action -- **n** - Cancel action - -### Example Actions -- **w** - Spawn 5 test worker processes -- **q** - Quit the application - -## Features Demonstrated - -1. **Live Updates** - Process list refreshes every second -2. **Sorting** - Sort by any column with direction toggle -3. **Filtering** - Filter processes by name/module (try "Worker") -4. **Color Coding** - Highlights processes with high queue/memory (yellow/red) -5. **Details Panel** - Shows comprehensive process information -6. **Stack Traces** - Displays current call stack -7. **Links/Monitors** - Shows process relationships -8. **Process Actions** - Kill, suspend, resume with confirmation -9. **Test Workers** - Spawn workers to see monitoring in action - -## Process Information Display - -### Main List Columns -- **PID** - Process identifier -- **Name** - Registered name or initial call -- **Reductions** - CPU work performed (formatted as K/M/B) -- **Memory** - Process memory usage (formatted as KB/MB/GB) -- **Queue** - Message queue length -- **Status** - Process status (running, waiting, suspended, etc.) - -### Details Panel Modes - -#### Info View (default) -- Full PID and registered name -- Current and initial function calls -- Process status -- Link and monitor counts - -#### Links View -- Lists linked processes (up to 5) -- Lists monitored processes (up to 5) -- Lists processes monitoring this one (up to 5) - -#### Trace View -- Current stack trace (up to 6 frames) -- Shows module, function, arity, file, and line number - -## Color Coding - -- **Blue background** - Selected process -- **Red** - Critical threshold exceeded (queue ≥ 10,000 or memory ≥ 200MB) -- **Yellow** - Warning threshold exceeded (queue ≥ 1,000 or memory ≥ 50MB) -- **Magenta** - Suspended process -- **White** - Normal process - -## Implementation Notes - -- System processes are filtered by default (kernel, code server, logger, etc.) -- Update interval can be changed dynamically -- Process list is fetched on each refresh -- Dead processes are automatically removed -- Actions are confirmed before execution -- Stack traces are fetched on demand -- The selected process is preserved across refreshes when possible diff --git a/examples/process_monitor/lib/process_monitor/app.ex b/examples/process_monitor/lib/process_monitor/app.ex deleted file mode 100644 index e66427ed..00000000 --- a/examples/process_monitor/lib/process_monitor/app.ex +++ /dev/null @@ -1,282 +0,0 @@ -defmodule ProcessMonitorExample.App do - @moduledoc """ - Example application demonstrating the ProcessMonitor widget. - - This example shows: - - Live BEAM process monitoring - - Process info (PID, name, reductions, memory, queue) - - Sorting and filtering - - Process details and stack traces - - Process actions (kill, suspend, resume) - - ## Controls - - - Up/Down: Move selection - - PageUp/PageDown: Scroll by page - - Enter: Toggle details panel - - r: Refresh now - - s: Cycle sort field - - S: Toggle sort direction - - /: Start filter input - - k: Kill selected process (with confirmation) - - p: Pause/resume selected process - - l: Show links/monitors - - t: Show stack trace - - w: Spawn worker processes - - Escape: Clear filter/close details - - q: Quit - """ - - use TermUI.Elm - - alias TermUI.Event - alias TermUI.Widgets.ProcessMonitor - alias TermUI.Renderer.Style - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- - - @doc """ - Initialize the component state. - """ - @impl true - def init(_args) do - props = - ProcessMonitor.new( - update_interval: 1000, - show_system_processes: false - ) - - {:ok, monitor_state} = ProcessMonitor.init(props) - - %{ - monitor_state: monitor_state, - message: "ProcessMonitor Example - Press w to spawn test workers", - worker_pids: [] - } - end - - @doc """ - Convert events to messages. - """ - @impl true - def event_to_msg(%Event.Key{char: "q"}, %{monitor_state: %{filter_input: nil}}) do - {:msg, :quit} - end - - def event_to_msg(%Event.Key{key: key}, _state) - when key in [:up, :down, :page_up, :page_down, :home, :end] do - {:msg, {:monitor_event, %Event.Key{key: key}}} - end - - def event_to_msg(%Event.Key{key: :enter}, _state) do - {:msg, {:monitor_event, %Event.Key{key: :enter}}} - end - - def event_to_msg(%Event.Key{char: "r"}, _state) do - {:msg, :refresh_monitor} - end - - def event_to_msg(%Event.Key{char: "s"}, _state) do - {:msg, {:monitor_event, %Event.Key{char: "s"}}} - end - - def event_to_msg(%Event.Key{char: "S"}, _state) do - {:msg, {:monitor_event, %Event.Key{char: "S"}}} - end - - def event_to_msg(%Event.Key{char: "/"}, _state) do - {:msg, {:monitor_event, %Event.Key{char: "/"}}} - end - - def event_to_msg(%Event.Key{char: "l"}, _state) do - {:msg, {:monitor_event, %Event.Key{char: "l"}}} - end - - def event_to_msg(%Event.Key{char: "t"}, _state) do - {:msg, {:monitor_event, %Event.Key{char: "t"}}} - end - - def event_to_msg(%Event.Key{char: "k"}, _state) do - {:msg, {:monitor_event, %Event.Key{char: "k"}}} - end - - def event_to_msg(%Event.Key{char: "p"}, _state) do - {:msg, {:monitor_event, %Event.Key{char: "p"}}} - end - - def event_to_msg(%Event.Key{char: "y"}, _state) do - {:msg, {:monitor_event, %Event.Key{char: "y"}}} - end - - def event_to_msg(%Event.Key{char: "n"}, _state) do - {:msg, {:monitor_event, %Event.Key{char: "n"}}} - end - - def event_to_msg(%Event.Key{key: :escape}, _state) do - {:msg, {:monitor_event, %Event.Key{key: :escape}}} - end - - def event_to_msg(%Event.Key{char: "w"}, _state) do - {:msg, :spawn_workers} - end - - def event_to_msg(%Event.Key{char: char}, %{monitor_state: %{filter_input: input}}) - when input != nil and char != nil do - {:msg, {:monitor_event, %Event.Key{char: char}}} - end - - def event_to_msg(%Event.Key{key: :backspace}, %{monitor_state: %{filter_input: input}}) - when input != nil do - {:msg, {:monitor_event, %Event.Key{key: :backspace}}} - end - - def event_to_msg(_event, _state) do - :ignore - end - - @doc """ - Update state based on messages. - """ - @impl true - def update(:quit, model) do - # Cleanup workers - Enum.each(model.worker_pids, fn pid -> - if Process.alive?(pid), do: Process.exit(pid, :shutdown) - end) - - {model, [:quit]} - end - - def update(:refresh_monitor, model) do - {:ok, monitor_state} = ProcessMonitor.refresh(model.monitor_state) - {%{model | monitor_state: monitor_state, message: "Refreshed"}, []} - end - - def update(:spawn_workers, model) do - new_pids = spawn_workers(5) - {:ok, monitor_state} = ProcessMonitor.refresh(model.monitor_state) - - {%{ - model - | monitor_state: monitor_state, - worker_pids: model.worker_pids ++ new_pids, - message: "Spawned 5 test workers (filter 'Worker' to see them)" - }, []} - end - - def update({:monitor_event, event}, model) do - {:ok, monitor_state} = ProcessMonitor.handle_event(event, model.monitor_state) - - # Update message based on event - message = - case event do - %Event.Key{char: "s"} -> - "Sort: #{monitor_state.sort_field}" - - %Event.Key{char: "S"} -> - dir = if monitor_state.sort_direction == :asc, do: "ascending", else: "descending" - "Sort direction: #{dir}" - - %Event.Key{char: "l"} -> - "Showing links/monitors" - - %Event.Key{char: "t"} -> - "Showing stack trace" - - %Event.Key{char: "y"} -> - "Action confirmed" - - %Event.Key{char: "n"} -> - "Action cancelled" - - _ -> - model.message - end - - {%{model | monitor_state: monitor_state, message: message}, []} - end - - def update(_msg, model) do - {model, []} - end - - @doc """ - Render the application view. - """ - @impl true - def view(model) do - area = %{x: 0, y: 0, width: 100, height: 25} - monitor_view = ProcessMonitor.render(model.monitor_state, area) - - stack(:vertical, [ - text("ProcessMonitor Widget Example", Style.new(fg: :cyan, attrs: [:bold])), - text(model.message, Style.new(fg: :yellow)), - text("", nil), - monitor_view, - text("", nil), - text("[w] Spawn workers | [q] Quit", Style.new(fg: :white, attrs: [:dim])) - ]) - end - - # ---------------------------------------------------------------------------- - # Helpers - # ---------------------------------------------------------------------------- - - # Spawn some test worker processes - defp spawn_workers(count) do - Enum.map(1..count, fn i -> - spawn(fn -> - Process.register(self(), :"Worker_#{System.unique_integer([:positive])}") - worker_loop(i) - end) - end) - end - - defp worker_loop(id) do - # Do some work to generate reductions - _ = Enum.reduce(1..1000, 0, &(&1 + &2)) - - # Randomly vary behavior - case rem(id, 3) do - 0 -> - # Normal worker - Process.sleep(100) - - 1 -> - # Worker with message queue buildup - Enum.each(1..50, fn _ -> send(self(), :work) end) - Process.sleep(200) - - 2 -> - # Worker with more memory - _data = :binary.copy(<<0>>, 10_000) - Process.sleep(150) - end - - # Clear messages - receive_all() - - worker_loop(id) - end - - defp receive_all do - receive do - _ -> receive_all() - after - 0 -> :ok - end - end - - # ---------------------------------------------------------------------------- - # Run - # ---------------------------------------------------------------------------- - - @doc """ - Run the process monitor example application. - """ - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/process_monitor/lib/process_monitor/application.ex b/examples/process_monitor/lib/process_monitor/application.ex deleted file mode 100644 index ba21dc33..00000000 --- a/examples/process_monitor/lib/process_monitor/application.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule ProcessMonitorExample.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [] - opts = [strategy: :one_for_one, name: ProcessMonitorExample.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/examples/process_monitor/mix.exs b/examples/process_monitor/mix.exs deleted file mode 100644 index cacca977..00000000 --- a/examples/process_monitor/mix.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule ProcessMonitorExample.MixProject do - use Mix.Project - - def project do - [ - app: :process_monitor_example, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {ProcessMonitorExample.Application, []} - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/process_monitor/mix.lock b/examples/process_monitor/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/process_monitor/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/process_monitor/run.exs b/examples/process_monitor/run.exs deleted file mode 100644 index 35842573..00000000 --- a/examples/process_monitor/run.exs +++ /dev/null @@ -1 +0,0 @@ -ProcessMonitorExample.App.run() diff --git a/examples/sparkline/README.md b/examples/sparkline/README.md deleted file mode 100644 index f4dc4957..00000000 --- a/examples/sparkline/README.md +++ /dev/null @@ -1,181 +0,0 @@ -# Sparkline Widget Example - -A demonstration of the TermUI Sparkline widget for compact inline trend visualization using vertical bar characters. - -## Widget Overview - -The Sparkline widget displays numeric data as compact inline charts using Unicode vertical bar characters (▁▂▃▄▅▆▇█). It's perfect for showing trends in minimal space, such as CPU usage, memory consumption, or any time-series data that needs quick visual representation without taking up much screen real estate. - -**Key Features:** -- Compact visualization using 8 levels of vertical bars -- Auto-scaling or fixed min/max ranges -- Labeled sparklines with min/max values -- Color-coded sparklines based on value thresholds -- Simple integration into any text-based layout - -**When to Use:** -- Dashboard displays with multiple metrics -- Inline trend indicators in tables or lists -- Resource monitoring (CPU, memory, disk I/O) -- Real-time data visualization in minimal space - -## Widget Options - -The `Sparkline.render/1` function accepts these options: - -- `:values` - List of numeric values (required) -- `:min` - Minimum value for scaling (default: auto-calculated from data) -- `:max` - Maximum value for scaling (default: auto-calculated from data) -- `:style` - Style for the entire sparkline -- `:color_ranges` - List of `{threshold, style}` tuples for value-based coloring - -The `Sparkline.render_labeled/1` function includes: - -- `:values` - List of numeric values (required) -- `:label` - Label text to display before the sparkline -- `:show_range` - Show min/max values (default: true) - -## Example Structure - -This example consists of: - -- `lib/sparkline/app.ex` - Main application demonstrating: - - Basic sparkline rendering - - Sparkline with fixed scale (0-100) - - Labeled sparkline with min/max values - - Styled sparkline with custom colors - - Color-coded sparkline based on value thresholds -- `mix.exs` - Mix project configuration -- `run.exs` - Helper script to run the example - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/sparkline -mix termui.run -``` - -Or manually: - -```bash -cd examples/sparkline -mix run -e "Sparkline.App.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/sparkline -iex -S mix -``` - -Then in IEx: - -```elixir -Sparkline.App.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -| Key | Action | -|-----|--------| -| Space | Add a random data point | -| R | Reset data to initial values | -| C | Toggle color mode | -| Q | Quit | - -## Code Examples - -### Basic Sparkline - -```elixir -# Just pass a list of values -Sparkline.render(values: [1, 3, 5, 2, 8, 4, 6]) -``` - -### Fixed Scale - -```elixir -# Set explicit min/max for consistent scaling -Sparkline.render( - values: [35, 42, 55, 48, 62], - min: 0, - max: 100 -) -``` - -### Labeled Sparkline - -```elixir -# Show label and min/max values -Sparkline.render_labeled( - values: data, - label: "CPU", - show_range: true -) -# Output: CPU 35 ▃▄▆▅▇ 62 -``` - -### Styled Sparkline - -```elixir -# Apply a single color to the entire sparkline -Sparkline.render( - values: data, - style: Style.new(fg: :green) -) -``` - -### Color-Coded by Value - -```elixir -# Different colors based on value thresholds -Sparkline.render( - values: data, - color_ranges: [ - {0, Style.new(fg: :green)}, # Green when value >= 0 - {50, Style.new(fg: :yellow)}, # Yellow when value >= 50 - {75, Style.new(fg: :red)} # Red when value >= 75 - ] -) -``` - -### Get Sparkline as String - -```elixir -# For embedding in other text -sparkline_str = Sparkline.to_sparkline([1, 3, 5, 2, 8]) -# Returns: "▁▃▅▂█" -``` - -## Bar Characters - -Sparklines use 8 levels of vertical bar characters: - -``` -▁ (1/8), ▂ (2/8), ▃ (3/8), ▄ (4/8), ▅ (5/8), ▆ (6/8), ▇ (7/8), █ (8/8) -``` - -## Color Ranges - -When color mode is enabled in the example, values are colored based on thresholds: -- Green: 0-49 (low values) -- Yellow: 50-74 (medium values) -- Red: 75+ (high values) - -This demonstrates how sparklines can use color to convey additional information about value ranges. - -## Widget API - -See `lib/term_ui/widgets/sparkline.ex` for the full API documentation. diff --git a/examples/sparkline/lib/sparkline/app.ex b/examples/sparkline/lib/sparkline/app.ex deleted file mode 100644 index cd22a4e8..00000000 --- a/examples/sparkline/lib/sparkline/app.ex +++ /dev/null @@ -1,201 +0,0 @@ -defmodule Sparkline.App do - @moduledoc """ - Sparkline Widget Example - - This example demonstrates how to use the TermUI.Widgets.Sparkline widget - for compact inline trend visualization. Sparklines use vertical bar - characters (▁▂▃▄▅▆▇█) to display values in minimal space. - - Features demonstrated: - - Basic sparkline rendering - - Labeled sparklines with min/max values - - Color-coded sparklines based on value ranges - - Auto-updating data simulation - - Controls: - - Space: Add a new random data point - - R: Reset data to initial values - - C: Toggle color mode - - Q: Quit the application - """ - - use TermUI.Elm - - alias TermUI.Widgets.Sparkline - alias TermUI.Event - alias TermUI.Renderer.Style - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- - - @doc """ - Initialize the component state. - - We maintain: - - values: List of data points for the sparkline - - colored: Whether to show color-coded sparkline - """ - def init(_opts) do - %{ - # Initial sample data simulating CPU usage over time - values: [35, 42, 38, 55, 48, 62, 58, 71, 65, 78, 72, 85, 79, 68, 55], - colored: false - } - end - - @doc """ - Convert keyboard events to messages. - """ - def event_to_msg(%Event.Key{key: " "}, _state), do: {:msg, :add_point} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["r", "R"], do: {:msg, :reset} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["c", "C"], do: {:msg, :toggle_color} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit} - def event_to_msg(_event, _state), do: :ignore - - @doc """ - Update state based on messages. - """ - def update(:add_point, state) do - # Add a random value between 10 and 100 - new_value = :rand.uniform(90) + 10 - - # Keep only the last 20 values (sliding window) - new_values = - (state.values ++ [new_value]) - |> Enum.take(-20) - - {%{state | values: new_values}, []} - end - - def update(:reset, state) do - # Reset to initial data - initial = [35, 42, 38, 55, 48, 62, 58, 71, 65, 78, 72, 85, 79, 68, 55] - {%{state | values: initial}, []} - end - - def update(:toggle_color, state) do - {%{state | colored: not state.colored}, []} - end - - def update(:quit, state) do - {state, [:quit]} - end - - @doc """ - Render the current state to a render tree. - """ - def view(state) do - stack(:vertical, [ - # Title - text("Sparkline Widget Example", Style.new(fg: :cyan, attrs: [:bold])), - text("", nil), - - # Basic sparkline - # The simplest usage - just pass a list of values - text("Basic Sparkline:", nil), - Sparkline.render(values: state.values), - text("", nil), - - # Sparkline with explicit min/max - # Useful when you want consistent scaling across multiple sparklines - text("Sparkline with fixed scale (0-100):", nil), - Sparkline.render( - values: state.values, - min: 0, - max: 100 - ), - text("", nil), - - # Labeled sparkline - # Shows label and min/max values alongside the sparkline - text("Labeled Sparkline:", nil), - Sparkline.render_labeled( - values: state.values, - label: "CPU", - show_range: true - ), - text("", nil), - - # Styled sparkline - # Apply a color to the entire sparkline - text("Styled Sparkline:", nil), - Sparkline.render( - values: state.values, - style: Style.new(fg: :green) - ), - text("", nil), - - # Color-coded sparkline (when enabled) - # Different colors based on value thresholds - render_colored_sparkline(state), - text("", nil), - - # Show the bar characters used - text("Sparkline bar characters:", nil), - text(Enum.join(Sparkline.bar_characters(), " "), nil), - text("", nil), - - # Controls - render_controls(state) - ]) - end - - defp render_controls(state) do - box_width = 56 - inner_width = box_width - 2 - - top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐" - bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘" - - stack(:vertical, [ - text("", nil), - text(top_border, Style.new(fg: :yellow)), - text("│" <> String.pad_trailing(" Space Add random data point", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" R Reset data", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" C Toggle color mode (#{if state.colored, do: "ON", else: "OFF"})", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil), - text("│" <> String.pad_trailing("", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Data points: #{length(state.values)}", inner_width) <> "│", nil), - text(bottom_border, Style.new(fg: :yellow)) - ]) - end - - # ---------------------------------------------------------------------------- - # Private Helpers - # ---------------------------------------------------------------------------- - - defp render_colored_sparkline(state) do - if state.colored do - stack(:vertical, [ - text("Color-coded Sparkline (green < 50 < yellow < 75 < red):", nil), - Sparkline.render( - values: state.values, - # Color ranges: {threshold, color} - # Colors apply when value >= threshold - color_ranges: [ - {0, Style.new(fg: :green)}, - {50, Style.new(fg: :yellow)}, - {75, Style.new(fg: :red)} - ] - ) - ]) - else - stack(:vertical, [ - text("Color-coded Sparkline (press C to enable):", nil), - text("(disabled)", nil) - ]) - end - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Run the sparkline example application. - """ - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/sparkline/lib/sparkline/application.ex b/examples/sparkline/lib/sparkline/application.ex deleted file mode 100644 index 0bb3f601..00000000 --- a/examples/sparkline/lib/sparkline/application.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule Sparkline.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [] - opts = [strategy: :one_for_one, name: Sparkline.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/examples/sparkline/mix.exs b/examples/sparkline/mix.exs deleted file mode 100644 index 3a7d19cd..00000000 --- a/examples/sparkline/mix.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule Sparkline.MixProject do - use Mix.Project - - def project do - [ - app: :sparkline, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {Sparkline.Application, []} - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/sparkline/mix.lock b/examples/sparkline/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/sparkline/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/sparkline/run.exs b/examples/sparkline/run.exs deleted file mode 100644 index a20a45ba..00000000 --- a/examples/sparkline/run.exs +++ /dev/null @@ -1 +0,0 @@ -Sparkline.App.run() diff --git a/examples/split_pane/README.md b/examples/split_pane/README.md deleted file mode 100644 index 11646d2a..00000000 --- a/examples/split_pane/README.md +++ /dev/null @@ -1,130 +0,0 @@ -# SplitPane Widget Example - -A demonstration of the TermUI SplitPane widget for creating resizable multi-pane layouts similar to IDE editors. - -## Widget Overview - -The SplitPane widget divides screen space between multiple panes with resizable dividers, enabling complex layouts like code editors with sidebars and bottom panels. Panes can be arranged horizontally (side-by-side) or vertically (stacked), and can be nested for sophisticated multi-section layouts. - -**Key Features:** -- Horizontal and vertical split orientations -- Keyboard and mouse-controlled divider resizing -- Min/max size constraints per pane -- Collapsible panes for maximizing workspace -- Nested splits for complex layouts (like IDEs) -- Layout state persistence - -**When to Use:** -- Multi-panel applications (editors, file browsers, terminals) -- IDE-style layouts with sidebars and panels -- Split-screen comparisons -- Any application requiring flexible, user-adjustable layouts - -## Widget Options - -The `SplitPane.new/1` function accepts these options: - -- `:orientation` - `:horizontal` (side by side) or `:vertical` (stacked) (default: `:horizontal`) -- `:panes` - List of pane specifications created with `SplitPane.pane/3` (required) -- `:divider_size` - Divider thickness in characters (default: 1) -- `:divider_style` - Style for unfocused dividers -- `:focused_divider_style` - Style for the focused divider -- `:resizable` - Whether dividers can be dragged (default: true) -- `:on_resize` - Callback function when panes are resized: `fn panes -> ... end` -- `:on_collapse` - Callback when pane is collapsed/expanded: `fn {id, collapsed} -> ... end` -- `:persist_key` - Key for layout persistence (optional) - -**Pane Specification** using `SplitPane.pane(id, content, opts)`: - -- `id` - Unique identifier for the pane -- `content` - Render tree or nested SplitPane state -- `:size` - Size as float (0.0-1.0 proportion) or integer (fixed chars/lines) (default: 1.0) -- `:min_size` - Minimum size in characters/lines -- `:max_size` - Maximum size in characters/lines -- `:collapsed` - Whether pane starts collapsed (default: false) - -## Example Structure - -This example consists of: - -- `lib/split_pane/app.ex` - Main application demonstrating: - - Horizontal layout (3 panes side-by-side) - - Vertical layout (3 panes stacked) - - Nested layout (IDE-style with sidebar and editor/terminal split) - - Keyboard-controlled divider resizing - - Min/max size constraints - - Layout save/restore functionality -- `mix.exs` - Mix project configuration -- `run.exs` - Helper script to run the example - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/split_pane -mix termui.run -``` - -Or manually: - -```bash -cd examples/split_pane -mix run -e "SplitPane.App.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/split_pane -iex -S mix -``` - -Then in IEx: - -```elixir -SplitPane.App.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -### Navigation -- **Tab** - Focus next divider -- **Shift+Tab** - Focus previous divider - -### Resizing -- **Left/Up** - Move focused divider left/up (1 unit) -- **Right/Down** - Move focused divider right/down (1 unit) -- **Shift+Arrow** - Move divider by larger step (5 units) -- **Home** - Move divider to minimum position -- **End** - Move divider to maximum position - -### Pane Operations -- **Enter** - Toggle collapse/expand pane after divider - -### Layout Management -- **H** - Switch to horizontal layout mode -- **V** - Switch to vertical layout mode -- **N** - Switch to nested IDE-style layout -- **S** - Save current layout (pane sizes and states) -- **R** - Restore previously saved layout - -### Application -- **Q** - Quit - -## Layout Modes - -The example demonstrates three layout modes: - -1. **Horizontal** - Three panes arranged side-by-side with adjustable dividers -2. **Vertical** - Three panes stacked vertically with adjustable dividers -3. **Nested (IDE)** - Two-level split with a sidebar and a main area that's further divided into editor and terminal sections diff --git a/examples/split_pane/lib/split_pane/app.ex b/examples/split_pane/lib/split_pane/app.ex deleted file mode 100644 index 52de07fb..00000000 --- a/examples/split_pane/lib/split_pane/app.ex +++ /dev/null @@ -1,295 +0,0 @@ -defmodule SplitPane.App do - @moduledoc """ - SplitPane Widget Example - - This example demonstrates how to use the TermUI.Widgets.SplitPane widget - for creating resizable multi-pane layouts like IDEs. - - Features demonstrated: - - Horizontal and vertical split orientations - - Nested splits for complex layouts - - Keyboard-controlled divider resizing - - Pane collapse/expand - - Min/max size constraints - - Layout persistence - - Controls: - - Tab: Focus next divider - - Shift+Tab: Focus previous divider - - Left/Up: Move divider left/up - - Right/Down: Move divider right/down - - Shift+Arrow: Move divider by larger step - - Enter: Toggle collapse pane after divider - - Home: Move divider to minimum - - End: Move divider to maximum - - H: Switch to horizontal layout - - V: Switch to vertical layout - - N: Switch to nested layout (IDE-style) - - S: Save layout - - R: Restore layout - - Q: Quit the application - """ - - use TermUI.Elm - - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Widgets.SplitPane, as: SP - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- - - @doc """ - Initialize the component state. - """ - def init(_opts) do - %{ - split_state: nil, - layout_mode: :horizontal, - saved_layout: nil, - status_message: "Tab to focus divider, arrows to resize" - } - end - - defp build_split_state(:horizontal) do - props = - SP.new( - orientation: :horizontal, - panes: [ - SP.pane(:left, build_pane_content("Left Pane", :blue), - size: 0.3, - min_size: 10, - max_size: 50 - ), - SP.pane(:middle, build_pane_content("Middle Pane", :green), size: 0.4), - SP.pane(:right, build_pane_content("Right Pane", :magenta), size: 0.3, min_size: 10) - ] - ) - - {:ok, state} = SP.init(props) - state - end - - defp build_split_state(:vertical) do - props = - SP.new( - orientation: :vertical, - panes: [ - SP.pane(:top, build_pane_content("Top Pane", :cyan), size: 0.4, min_size: 5), - SP.pane(:middle, build_pane_content("Middle Pane", :yellow), size: 0.3), - SP.pane(:bottom, build_pane_content("Bottom Pane", :red), size: 0.3, min_size: 3) - ] - ) - - {:ok, state} = SP.init(props) - state - end - - defp build_split_state(:nested) do - # Build an IDE-like layout with nested splits - # Left sidebar | Main area (top editor / bottom terminal) - - # Inner vertical split for main area - inner_props = - SP.new( - orientation: :vertical, - panes: [ - SP.pane(:editor, build_pane_content("Editor", :green), size: 0.7, min_size: 5), - SP.pane(:terminal, build_pane_content("Terminal", :white), size: 0.3, min_size: 3) - ] - ) - - {:ok, inner_state} = SP.init(inner_props) - - # Outer horizontal split - outer_props = - SP.new( - orientation: :horizontal, - panes: [ - SP.pane(:sidebar, build_pane_content("Sidebar", :blue), size: 0.2, min_size: 10), - SP.pane(:main, inner_state, size: 0.8) - ] - ) - - {:ok, outer_state} = SP.init(outer_props) - outer_state - end - - defp build_pane_content(title, color) do - lines = [ - title, - String.duplicate("-", String.length(title)), - "", - "Content area", - "Resize with arrows", - "Enter to collapse" - ] - - text(Enum.join(lines, "\n"), Style.new(fg: color)) - end - - @doc """ - Convert keyboard events to messages. - """ - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["h", "H"], do: {:msg, :horizontal} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["v", "V"], do: {:msg, :vertical} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["n", "N"], do: {:msg, :nested} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["s", "S"], do: {:msg, :save_layout} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["r", "R"], do: {:msg, :restore_layout} - - def event_to_msg(event, _state) do - {:msg, {:split_event, event}} - end - - @doc """ - Update state based on messages. - """ - def update(:quit, state) do - {state, [:quit]} - end - - def update(:horizontal, state) do - split_state = build_split_state(:horizontal) - message = "Switched to horizontal layout" - {%{state | layout_mode: :horizontal, split_state: split_state, status_message: message}, []} - end - - def update(:vertical, state) do - split_state = build_split_state(:vertical) - message = "Switched to vertical layout" - {%{state | layout_mode: :vertical, split_state: split_state, status_message: message}, []} - end - - def update(:nested, state) do - split_state = build_split_state(:nested) - message = "Switched to nested IDE layout" - {%{state | layout_mode: :nested, split_state: split_state, status_message: message}, []} - end - - def update(:save_layout, state) do - split_state = ensure_split_state(state) - layout = SP.get_layout(split_state) - message = "Layout saved!" - {%{state | saved_layout: layout, status_message: message}, []} - end - - def update(:restore_layout, state) do - split_state = ensure_split_state(state) - - if state.saved_layout do - split_state = SP.set_layout(split_state, state.saved_layout) - message = "Layout restored!" - {%{state | split_state: split_state, status_message: message}, []} - else - {%{state | status_message: "No saved layout to restore"}, []} - end - end - - def update({:split_event, event}, state) do - split_state = ensure_split_state(state) - {:ok, split_state} = SP.handle_event(event, split_state) - - message = get_status_message(split_state) - {%{state | split_state: split_state, status_message: message}, []} - end - - defp ensure_split_state(state) do - state.split_state || build_split_state(state.layout_mode) - end - - defp get_status_message(split_state) do - focused = SP.get_focused_divider(split_state) - - if focused != nil do - "Divider #{focused + 1} focused - arrows to resize, Enter to collapse" - else - "Tab to focus divider, arrows to resize" - end - end - - @doc """ - Render the current state to a render tree. - """ - def view(state) do - split_state = ensure_split_state(state) - - stack(:vertical, [ - # Title - text("SplitPane Widget Example", Style.new(fg: :cyan, attrs: [:bold])), - text("", nil), - - # Split pane - render_split_container(split_state), - - # Status - text("", nil), - text(state.status_message, Style.new(fg: :yellow)), - - # Controls - render_controls(state) - ]) - end - - defp render_split_container(split_state) do - # Render the split pane - split_render = SP.render(split_state, %{x: 0, y: 0, width: 70, height: 15}) - - box_width = 72 - inner_width = box_width - 2 - - top_border = "+" <> String.duplicate("-", inner_width) <> "+" - bottom_border = "+" <> String.duplicate("-", inner_width) <> "+" - - stack(:vertical, [ - text(top_border, Style.new(fg: :blue)), - stack(:horizontal, [ - text("| ", nil), - split_render, - text(" |", nil) - ]), - text(bottom_border, Style.new(fg: :blue)) - ]) - end - - defp render_controls(state) do - box_width = 55 - inner_width = box_width - 2 - - mode_str = - case state.layout_mode do - :horizontal -> "horizontal" - :vertical -> "vertical" - :nested -> "nested (IDE)" - end - - top_border = "+" <> String.duplicate("-", inner_width - 10) <> " Controls " <> "+" - bottom_border = "+" <> String.duplicate("-", inner_width) <> "+" - - stack(:vertical, [ - text("", nil), - text(top_border, Style.new(fg: :yellow)), - text("|" <> String.pad_trailing(" Tab/S-Tab Focus dividers", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" Arrows Resize focused divider", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" Shift+Arr Large resize step", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" Enter Collapse/expand pane", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" Home/End Min/max position", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" H/V/N Switch layout (#{mode_str})", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" S/R Save/Restore layout", inner_width) <> "|", nil), - text("|" <> String.pad_trailing(" Q Quit", inner_width) <> "|", nil), - text(bottom_border, Style.new(fg: :yellow)) - ]) - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Run the split pane example application. - """ - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/split_pane/lib/split_pane/application.ex b/examples/split_pane/lib/split_pane/application.ex deleted file mode 100644 index 3a4b08c9..00000000 --- a/examples/split_pane/lib/split_pane/application.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule SplitPane.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [] - opts = [strategy: :one_for_one, name: SplitPane.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/examples/split_pane/mix.exs b/examples/split_pane/mix.exs deleted file mode 100644 index a8068de6..00000000 --- a/examples/split_pane/mix.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule SplitPane.MixProject do - use Mix.Project - - def project do - [ - app: :split_pane, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {SplitPane.Application, []} - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/split_pane/mix.lock b/examples/split_pane/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/split_pane/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/split_pane/run.exs b/examples/split_pane/run.exs deleted file mode 100644 index dee2c6d1..00000000 --- a/examples/split_pane/run.exs +++ /dev/null @@ -1 +0,0 @@ -SplitPane.App.run() diff --git a/examples/stream_widget/README.md b/examples/stream_widget/README.md deleted file mode 100644 index e8b3b9b5..00000000 --- a/examples/stream_widget/README.md +++ /dev/null @@ -1,139 +0,0 @@ -# StreamWidget Example - -A demonstration of the TermUI StreamWidget for displaying backpressure-aware streaming data with GenStage integration. - -## Widget Overview - -The StreamWidget provides real-time display of streaming data with built-in buffer management and GenStage integration. It handles backpressure automatically and provides controls for stream management, making it ideal for applications that need to display continuous data flows like logs, events, or sensor readings. - -**Key Features:** -- GenStage integration for demand-based streaming -- Configurable buffer with overflow strategies -- Pause/resume controls -- Real-time statistics (items/sec, buffer usage) -- Scrollable buffer navigation -- Multiple overflow strategies (drop oldest, drop newest, block, sliding) - -**When to Use:** -- Log viewers and monitoring applications -- Real-time event streams -- Data pipeline visualization -- Any application displaying continuous data flows - -## Widget Options - -The `StreamWidget.new/1` function accepts these options: - -- `:buffer_size` - Maximum items in buffer (default: 1000) -- `:overflow_strategy` - What to do when buffer is full (default: `:drop_oldest`) - - `:drop_oldest` - Remove oldest items to make room - - `:drop_newest` - Discard new items when full - - `:block` - Stop accepting items until space is available - - `:sliding` - Same as `:drop_oldest` -- `:demand` - How many items to request at a time from producer (default: 10) -- `:show_stats` - Display statistics bar (default: true) -- `:render_rate_ms` - Minimum time between renders in ms (default: 100) -- `:item_renderer` - Function to render each item: `fn item -> String.t()` -- `:on_item` - Callback when item is received: `fn item -> ... end` -- `:on_error` - Callback when error occurs: `fn error -> ... end` - -## Example Structure - -This example consists of: - -- `lib/stream_widget/app.ex` - Main application demonstrating: - - StreamWidget initialization - - GenStage producer/consumer integration - - Pause/resume controls - - Buffer management - - Overflow strategy switching - - Real-time statistics display -- `lib/stream_widget/producer.ex` - GenStage producer that generates sample events -- `lib/stream_widget/application.ex` - Application supervisor -- `mix.exs` - Mix project configuration -- `run.exs` - Helper script to run the example - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/stream_widget -mix termui.run -``` - -Or manually: - -```bash -cd examples/stream_widget -mix run -e "StreamWidget.App.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/stream_widget -iex -S mix -``` - -Then in IEx: - -```elixir -StreamWidget.App.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -### Stream Control -- **Space** - Start/pause/resume streaming - -### Buffer Management -- **c** - Clear buffer -- **s** - Toggle statistics display - -### Overflow Strategy -- **1** - Set strategy to drop oldest items -- **2** - Set strategy to drop newest items -- **3** - Set strategy to block when full -- **4** - Set strategy to sliding window - -### Event Rate -- **+** - Increase event rate (decrease interval) -- **-** - Decrease event rate (increase interval) - -### Navigation -- **Up/Down** - Scroll through buffer items -- **Page Up/Page Down** - Scroll by page -- **Home** - Jump to first item -- **End** - Jump to last item - -### Application -- **Q** or **Escape** - Quit - -## Statistics Display - -When enabled, the widget shows: -- **Status** - Current stream state (IDLE, RUNNING, PAUSED) -- **Buffer** - Current items / maximum capacity -- **Strategy** - Active overflow strategy -- **Received** - Total items received -- **Dropped** - Total items dropped due to overflow -- **Rate** - Current items per second - -## GenStage Integration - -The example demonstrates proper GenStage integration: - -1. A Producer (`StreamWidgetExample.Producer`) generates events at a configurable interval -2. A Consumer (`StreamWidget.Consumer`) subscribes to the producer -3. The StreamWidget manages demand and backpressure -4. Events flow through the pipeline respecting the buffer capacity and overflow strategy diff --git a/examples/stream_widget/lib/stream_widget/app.ex b/examples/stream_widget/lib/stream_widget/app.ex deleted file mode 100644 index 9cdfaffa..00000000 --- a/examples/stream_widget/lib/stream_widget/app.ex +++ /dev/null @@ -1,218 +0,0 @@ -defmodule StreamWidget.App do - @moduledoc """ - Example application demonstrating the StreamWidget. - - This example shows: - - GenStage producer integration - - Real-time data streaming - - Pause/resume controls - - Buffer management - - Statistics display - - Overflow strategy switching - - ## Controls - - - Space: Pause/resume stream - - c: Clear buffer - - s: Toggle stats display - - 1-4: Change overflow strategy - - +/-: Increase/decrease event rate - - Up/Down: Scroll through buffer - - PageUp/PageDown: Scroll by page - - q/Escape: Quit - """ - - use TermUI.Elm - - alias TermUI.Widgets.StreamWidget - alias TermUI.Widgets.StreamWidget.Consumer - alias TermUI.Event - alias TermUI.Renderer.Style - alias StreamWidgetExample.Producer - - # TermUI.Elm Callbacks - - def init(_args) do - # Create stream widget props - props = - StreamWidget.new( - buffer_size: 500, - overflow_strategy: :drop_oldest, - show_stats: true, - item_renderer: &render_item/1 - ) - - {:ok, widget_state} = StreamWidget.init(props) - - %{ - widget_state: widget_state, - producer_pid: nil, - consumer_pid: nil, - interval_ms: 100, - message: "Press Space to start streaming, q to quit" - } - end - - def event_to_msg(%Event.Key{key: " "}, _state), do: {:msg, :toggle_stream} - def event_to_msg(%Event.Key{key: "c"}, _state), do: {:msg, :clear} - def event_to_msg(%Event.Key{key: "s"}, _state), do: {:msg, :toggle_stats} - def event_to_msg(%Event.Key{key: "1"}, _state), do: {:msg, {:strategy, :drop_oldest}} - def event_to_msg(%Event.Key{key: "2"}, _state), do: {:msg, {:strategy, :drop_newest}} - def event_to_msg(%Event.Key{key: "3"}, _state), do: {:msg, {:strategy, :block}} - def event_to_msg(%Event.Key{key: "4"}, _state), do: {:msg, {:strategy, :sliding}} - def event_to_msg(%Event.Key{key: "+"}, _state), do: {:msg, :faster} - def event_to_msg(%Event.Key{key: "-"}, _state), do: {:msg, :slower} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit} - def event_to_msg(%Event.Key{key: :escape}, _state), do: {:msg, :quit} - - def event_to_msg(%Event.Key{key: key}, _state) - when key in [:up, :down, :page_up, :page_down, :home, :end] do - {:msg, {:widget_event, %Event.Key{key: key}}} - end - - def event_to_msg(_event, _state), do: :ignore - - def update(:quit, state) do - # Stop producer and consumer - if state.producer_pid, do: GenStage.stop(state.producer_pid) - if state.consumer_pid, do: GenStage.stop(state.consumer_pid) - {state, [:quit]} - end - - def update(:toggle_stream, state) when state.producer_pid == nil do - # Start streaming - {:ok, producer} = Producer.start_link(interval_ms: state.interval_ms) - {:ok, consumer} = Consumer.start_link(self()) - Consumer.subscribe(consumer, producer) - - # Update widget state to reflect running - {:ok, widget_state} = - StreamWidget.handle_info({:consumer_started, consumer}, state.widget_state) - - {%{state | - producer_pid: producer, - consumer_pid: consumer, - widget_state: widget_state, - message: "Streaming... Space to pause, q to quit" - }, []} - end - - def update(:toggle_stream, state) do - # Pause/resume when streaming - if StreamWidget.paused?(state.widget_state) do - Producer.resume(state.producer_pid) - {:ok, widget_state} = StreamWidget.resume(state.widget_state) - {%{state | widget_state: widget_state, message: "Resumed streaming"}, []} - else - Producer.pause(state.producer_pid) - {:ok, widget_state} = StreamWidget.pause(state.widget_state) - {%{state | widget_state: widget_state, message: "Paused streaming"}, []} - end - end - - def update(:clear, state) do - {:ok, widget_state} = StreamWidget.clear(state.widget_state) - {%{state | widget_state: widget_state, message: "Buffer cleared"}, []} - end - - def update(:toggle_stats, state) do - {:ok, widget_state} = StreamWidget.handle_event(%Event.Key{key: "s"}, state.widget_state) - {%{state | widget_state: widget_state}, []} - end - - def update({:strategy, strategy}, state) do - {:ok, widget_state} = StreamWidget.set_overflow_strategy(state.widget_state, strategy) - {%{state | widget_state: widget_state, message: "Strategy: #{strategy}"}, []} - end - - def update(:faster, state) do - new_interval = max(10, state.interval_ms - 10) - if state.producer_pid, do: Producer.set_interval(state.producer_pid, new_interval) - {%{state | interval_ms: new_interval, message: "Interval: #{new_interval}ms"}, []} - end - - def update(:slower, state) do - new_interval = min(1000, state.interval_ms + 10) - if state.producer_pid, do: Producer.set_interval(state.producer_pid, new_interval) - {%{state | interval_ms: new_interval, message: "Interval: #{new_interval}ms"}, []} - end - - def update({:widget_event, event}, state) do - {:ok, widget_state} = StreamWidget.handle_event(event, state.widget_state) - {%{state | widget_state: widget_state}, []} - end - - def update(_msg, state) do - {state, []} - end - - # Handle info messages from the consumer - def handle_info({:stream_items, items}, state) do - {:ok, widget_state} = StreamWidget.handle_info({:stream_items, items}, state.widget_state) - {%{state | widget_state: widget_state}, []} - end - - def handle_info({:consumer_started, pid}, state) do - {:ok, widget_state} = StreamWidget.handle_info({:consumer_started, pid}, state.widget_state) - {%{state | widget_state: widget_state}, []} - end - - def handle_info(_msg, state) do - {state, []} - end - - def view(state) do - # Use fixed dimensions for the widget - area = %{x: 0, y: 0, width: 78, height: 15} - - widget_view = StreamWidget.render(state.widget_state, area) - - help_text = "[Space] Start/Pause | [c] Clear | [s] Stats | [1-4] Strategy | [+/-] Rate | [q] Quit" - - stack(:vertical, [ - text("StreamWidget Example", Style.new(fg: :cyan, attrs: [:bold])), - text(state.message, Style.new(fg: :yellow)), - text("", nil), - render_widget_container(widget_view, state), - text("", nil), - text(help_text, Style.new(fg: :white, attrs: [:dim])) - ]) - end - - defp render_widget_container(widget_view, state) do - box_width = 80 - inner_width = box_width - 2 - - stats = StreamWidget.get_stats(state.widget_state) - buffer_info = "Buffer: #{stats.buffer_size}/#{stats.buffer_capacity}" - - top_border = "+" <> String.duplicate("-", 3) <> " Stream " <> String.duplicate("-", inner_width - 14 - String.length(buffer_info)) <> " #{buffer_info} +" - bottom_border = "+" <> String.duplicate("-", inner_width) <> "+" - - stack(:vertical, [ - text(top_border, Style.new(fg: :blue)), - stack(:horizontal, [ - text("| ", nil), - widget_view, - text(" |", nil) - ]), - text(bottom_border, Style.new(fg: :blue)) - ]) - end - - # Custom item renderer for display - defp render_item(item) do - data = item.data - - cond do - is_binary(data) -> data - true -> inspect(data) - end - end - - # Public API - - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/stream_widget/lib/stream_widget/application.ex b/examples/stream_widget/lib/stream_widget/application.ex deleted file mode 100644 index e0ff368a..00000000 --- a/examples/stream_widget/lib/stream_widget/application.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule StreamWidgetExample.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [] - opts = [strategy: :one_for_one, name: StreamWidgetExample.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/examples/stream_widget/lib/stream_widget/producer.ex b/examples/stream_widget/lib/stream_widget/producer.ex deleted file mode 100644 index b93bafa5..00000000 --- a/examples/stream_widget/lib/stream_widget/producer.ex +++ /dev/null @@ -1,127 +0,0 @@ -defmodule StreamWidgetExample.Producer do - @moduledoc """ - A GenStage producer that generates streaming data events. - """ - - use GenStage - - defstruct [:counter, :interval_ms, :paused, :timer_ref] - - @doc """ - Start the producer. - - ## Options - - - `:interval_ms` - Time between events in milliseconds (default: 100) - """ - def start_link(opts \\ []) do - GenStage.start_link(__MODULE__, opts, name: __MODULE__) - end - - @doc """ - Set the event generation interval. - """ - def set_interval(producer \\ __MODULE__, interval_ms) do - GenStage.cast(producer, {:set_interval, interval_ms}) - end - - @doc """ - Pause event generation. - """ - def pause(producer \\ __MODULE__) do - GenStage.cast(producer, :pause) - end - - @doc """ - Resume event generation. - """ - def resume(producer \\ __MODULE__) do - GenStage.cast(producer, :resume) - end - - # GenStage Callbacks - - @impl true - def init(opts) do - interval_ms = Keyword.get(opts, :interval_ms, 100) - - state = %__MODULE__{ - counter: 0, - interval_ms: interval_ms, - paused: false, - timer_ref: nil - } - - # Schedule first tick - timer_ref = Process.send_after(self(), :tick, interval_ms) - - {:producer, %{state | timer_ref: timer_ref}} - end - - @impl true - def handle_demand(_demand, state) do - # We produce on timer, not on demand - {:noreply, [], state} - end - - @impl true - def handle_cast({:set_interval, interval_ms}, state) do - {:noreply, [], %{state | interval_ms: interval_ms}} - end - - def handle_cast(:pause, state) do - if state.timer_ref do - Process.cancel_timer(state.timer_ref) - end - - {:noreply, [], %{state | paused: true, timer_ref: nil}} - end - - def handle_cast(:resume, state) do - if state.paused do - timer_ref = Process.send_after(self(), :tick, state.interval_ms) - {:noreply, [], %{state | paused: false, timer_ref: timer_ref}} - else - {:noreply, [], state} - end - end - - @impl true - def handle_info(:tick, %{paused: true} = state) do - {:noreply, [], state} - end - - def handle_info(:tick, state) do - # Generate an event - event = generate_event(state.counter) - - # Schedule next tick - timer_ref = Process.send_after(self(), :tick, state.interval_ms) - - new_state = %{state | counter: state.counter + 1, timer_ref: timer_ref} - - {:noreply, [event], new_state} - end - - defp generate_event(counter) do - type = Enum.random([:info, :warning, :error, :debug, :data]) - - case type do - :info -> - "[INFO] Event ##{counter}: System status OK" - - :warning -> - "[WARN] Event ##{counter}: Memory usage at #{:rand.uniform(100)}%" - - :error -> - "[ERROR] Event ##{counter}: Connection timeout after #{:rand.uniform(5000)}ms" - - :debug -> - "[DEBUG] Event ##{counter}: Processing batch of #{:rand.uniform(100)} items" - - :data -> - value = :rand.uniform(1000) / 10 - "[DATA] Event ##{counter}: Metric value = #{value}" - end - end -end diff --git a/examples/stream_widget/mix.exs b/examples/stream_widget/mix.exs deleted file mode 100644 index 4015de00..00000000 --- a/examples/stream_widget/mix.exs +++ /dev/null @@ -1,27 +0,0 @@ -defmodule StreamWidgetExample.MixProject do - use Mix.Project - - def project do - [ - app: :stream_widget_example, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {StreamWidgetExample.Application, []} - ] - end - - defp deps do - [ - {:term_ui, path: "../.."}, - {:gen_stage, "~> 1.2"} - ] - end -end diff --git a/examples/stream_widget/mix.lock b/examples/stream_widget/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/stream_widget/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/stream_widget/run.exs b/examples/stream_widget/run.exs deleted file mode 100644 index ede45bfc..00000000 --- a/examples/stream_widget/run.exs +++ /dev/null @@ -1 +0,0 @@ -StreamWidget.App.run() diff --git a/examples/supervision_tree_viewer/README.md b/examples/supervision_tree_viewer/README.md deleted file mode 100644 index efc3746a..00000000 --- a/examples/supervision_tree_viewer/README.md +++ /dev/null @@ -1,170 +0,0 @@ -# SupervisionTreeViewer Widget Example - -A demonstration of the TermUI SupervisionTreeViewer widget for visualizing OTP supervision hierarchies in real-time. - -## Widget Overview - -The SupervisionTreeViewer displays live OTP supervision trees with status indicators, process information, and management controls. It provides an interactive view of your application's supervisor hierarchy, making it easy to understand process relationships and monitor system health. - -**Key Features:** -- Tree view of supervision hierarchy -- Live status indicators (running, restarting, terminated) -- Process information display (memory, reductions, message queue) -- Supervisor strategy visualization (one_for_one, one_for_all, etc.) -- Process restart/terminate controls with confirmation -- Tree filtering by process name -- Auto-refresh capability - -**When to Use:** -- Debugging OTP application structure -- Monitoring process health in development -- Understanding supervisor hierarchies -- Process management during development -- Educational demonstrations of OTP supervision - -## Widget Options - -The `SupervisionTreeViewer.new/1` function accepts these options: - -- `:root` - Root supervisor (pid, registered name, or module) (required) -- `:update_interval` - Refresh interval in milliseconds (default: 2000) -- `:on_select` - Callback when node is selected: `fn node -> ... end` -- `:on_action` - Callback when action is performed: `fn {:restarted | :terminated, pid} -> ... end` -- `:show_workers` - Show worker processes (default: true) -- `:auto_expand` - Expand all nodes initially (default: true) - -## Example Structure - -This example consists of: - -- `lib/supervision_tree_viewer/app.ex` - Main application demonstrating: - - SupervisionTreeViewer initialization - - Tree navigation and expansion - - Process information display - - Process restart/terminate operations - - Filter functionality -- `lib/supervision_tree_viewer/sample_tree.ex` - Sample supervision tree for demonstration -- `lib/supervision_tree_viewer/application.ex` - Application supervisor -- `mix.exs` - Mix project configuration -- `run.exs` - Helper script to run the example - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/supervision_tree_viewer -mix termui.run -``` - -Or manually: - -```bash -cd examples/supervision_tree_viewer -mix run -e "SupervisionTreeViewerExample.App.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/supervision_tree_viewer -iex -S mix -``` - -Then in IEx: - -```elixir -SupervisionTreeViewerExample.App.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -### Navigation -- **Up/Down** - Move selection up/down in tree -- **Left** - Collapse node or move to parent -- **Right** - Expand node or move to first child -- **Page Up/Page Down** - Scroll by page -- **Home** - Jump to first node -- **End** - Jump to last node - -### Tree Operations -- **Enter** - Toggle expand/collapse for selected node - -### Information -- **i** - Show/hide process info panel for selected process - -### Process Management (with confirmation) -- **r** - Restart selected process (prompts for confirmation) -- **k** - Terminate selected process (prompts for confirmation) -- **y** - Confirm pending action -- **n** - Cancel pending action - -### Filtering -- **/** - Start filter input mode -- Type to filter by process name -- **Enter** - Apply filter -- **Escape** - Clear filter or cancel input - -### Refresh -- **R** - Force refresh tree - -### Application -- **q** - Quit (only when not in filter input mode) -- **Escape** - Clear filter/close info panel/cancel action - -## Status Indicators - -The tree view uses color-coded icons to show process status: - -- **● (green)** - Process is running normally -- **◐ (yellow)** - Process is restarting -- **○ (red)** - Process is terminated -- **? (white)** - Process status is undefined - -## Node Types - -- **□** - Supervisor node -- **◇** - Worker node - -## Supervisor Strategies - -Supervisor strategies are displayed with compact indicators: - -- **[1:1]** - `:one_for_one` - Restart only the failed child -- **[1:*]** - `:one_for_all` - Restart all children when one fails -- **[1:→]** - `:rest_for_one` - Restart failed child and those started after it -- **[1:1+]** - `:simple_one_for_one` - Dynamically add children of the same type - -## Process Information Panel - -When opened with **i**, the panel displays: - -- **ID** - Process identifier -- **PID** - Process ID -- **Name** - Registered name (if any) -- **Type** - Supervisor or worker -- **Status** - Current process status -- **Strategy** - Supervisor strategy (supervisors only) -- **Max restarts** - Restart intensity and period (supervisors only) -- **Memory** - Current memory usage -- **Reductions** - Total reductions (execution steps) -- **Msg Queue** - Message queue length - -## Sample Tree - -The example includes a sample supervision tree that demonstrates: -- Multiple levels of supervisors -- Various supervisor strategies -- Worker processes -- Nested supervision hierarchies - -This provides a realistic example for exploring the widget's capabilities. diff --git a/examples/supervision_tree_viewer/lib/supervision_tree_viewer/app.ex b/examples/supervision_tree_viewer/lib/supervision_tree_viewer/app.ex deleted file mode 100644 index 5540e8dd..00000000 --- a/examples/supervision_tree_viewer/lib/supervision_tree_viewer/app.ex +++ /dev/null @@ -1,190 +0,0 @@ -defmodule SupervisionTreeViewerExample.App do - @moduledoc """ - Example application demonstrating the SupervisionTreeViewer widget. - - This example shows: - - Live supervision tree visualization - - Process status indicators (running/restarting/terminated) - - Supervisor strategy display (1:1, 1:*, 1:→) - - Process info panel - - Restart/terminate controls - - ## Controls - - - Up/Down: Navigate tree - - Left: Collapse or move to parent - - Right: Expand or move to first child - - Enter: Toggle expand/collapse - - i: Show process info panel - - r: Restart selected process (with confirmation) - - k: Terminate selected process (with confirmation) - - R: Refresh tree - - /: Filter by name - - Escape: Clear filter/close panel - - q: Quit - """ - - use TermUI.Elm - - alias TermUI.Event - alias TermUI.Widgets.SupervisionTreeViewer - alias TermUI.Renderer.Style - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- - - @doc """ - Initialize the component state. - """ - @impl true - def init(_args) do - # Start with the example application's sample tree - root = SupervisionTreeViewerExample.SampleTree - - props = - SupervisionTreeViewer.new( - root: root, - update_interval: 2000, - auto_expand: true - ) - - {:ok, viewer_state} = SupervisionTreeViewer.init(props) - - %{ - viewer_state: viewer_state, - message: "SupervisionTreeViewer Example - Press 'i' for process info" - } - end - - @doc """ - Convert events to messages. - """ - @impl true - def event_to_msg(%Event.Key{char: "q"}, %{viewer_state: %{filter_input: nil}}) do - {:msg, :quit} - end - - def event_to_msg(%Event.Key{key: key}, _state) - when key in [:up, :down, :left, :right, :page_up, :page_down, :home, :end] do - {:msg, {:viewer_event, %Event.Key{key: key}}} - end - - def event_to_msg(%Event.Key{key: :enter}, _state) do - {:msg, {:viewer_event, %Event.Key{key: :enter}}} - end - - def event_to_msg(%Event.Key{char: "i"}, _state) do - {:msg, {:viewer_event, %Event.Key{char: "i"}}} - end - - def event_to_msg(%Event.Key{char: "R"}, _state) do - {:msg, :refresh_tree} - end - - def event_to_msg(%Event.Key{char: "r"}, %{viewer_state: %{filter_input: nil}}) do - {:msg, {:viewer_event, %Event.Key{char: "r"}}} - end - - def event_to_msg(%Event.Key{char: "k"}, %{viewer_state: %{filter_input: nil}}) do - {:msg, {:viewer_event, %Event.Key{char: "k"}}} - end - - def event_to_msg(%Event.Key{char: "y"}, %{viewer_state: %{pending_action: action}}) - when action != nil do - {:msg, {:viewer_event, %Event.Key{char: "y"}}} - end - - def event_to_msg(%Event.Key{char: "n"}, %{viewer_state: %{pending_action: action}}) - when action != nil do - {:msg, {:viewer_event, %Event.Key{char: "n"}}} - end - - def event_to_msg(%Event.Key{char: "/"}, %{viewer_state: %{filter_input: nil}}) do - {:msg, {:viewer_event, %Event.Key{char: "/"}}} - end - - def event_to_msg(%Event.Key{char: char}, %{viewer_state: %{filter_input: input}}) - when input != nil and char != nil do - {:msg, {:viewer_event, %Event.Key{char: char}}} - end - - def event_to_msg(%Event.Key{key: :backspace}, %{viewer_state: %{filter_input: input}}) - when input != nil do - {:msg, {:viewer_event, %Event.Key{key: :backspace}}} - end - - def event_to_msg(%Event.Key{key: :escape}, _state) do - {:msg, {:viewer_event, %Event.Key{key: :escape}}} - end - - def event_to_msg(_event, _state) do - :ignore - end - - @doc """ - Update state based on messages. - """ - @impl true - def update(:quit, state) do - {state, [:quit]} - end - - def update(:refresh_tree, state) do - {:ok, viewer_state} = SupervisionTreeViewer.refresh(state.viewer_state) - {%{state | viewer_state: viewer_state, message: "Tree refreshed"}, []} - end - - def update({:viewer_event, event}, state) do - {:ok, viewer_state} = SupervisionTreeViewer.handle_event(event, state.viewer_state) - - # Update message based on viewer state changes - message = - cond do - viewer_state.show_info != state.viewer_state.show_info -> - if viewer_state.show_info, do: "Info panel opened", else: "Info panel closed" - - viewer_state.pending_action != state.viewer_state.pending_action and - viewer_state.pending_action == nil -> - "Action completed" - - true -> - state.message - end - - {%{state | viewer_state: viewer_state, message: message}, []} - end - - def update(_msg, state) do - {state, []} - end - - @doc """ - Render the application view. - """ - @impl true - def view(model) do - area = %{x: 0, y: 0, width: 100, height: 25} - viewer_view = SupervisionTreeViewer.render(model.viewer_state, area) - - stack(:vertical, [ - text("SupervisionTreeViewer Widget Example", Style.new(fg: :cyan, attrs: [:bold])), - text(model.message, Style.new(fg: :yellow)), - text("", nil), - viewer_view, - text("", nil), - text("[q] Quit", Style.new(fg: :white, attrs: [:dim])) - ]) - end - - # ---------------------------------------------------------------------------- - # Run - # ---------------------------------------------------------------------------- - - @doc """ - Run the supervision tree viewer example application. - """ - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/supervision_tree_viewer/lib/supervision_tree_viewer/application.ex b/examples/supervision_tree_viewer/lib/supervision_tree_viewer/application.ex deleted file mode 100644 index 5fea20f6..00000000 --- a/examples/supervision_tree_viewer/lib/supervision_tree_viewer/application.ex +++ /dev/null @@ -1,16 +0,0 @@ -defmodule SupervisionTreeViewerExample.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [ - # Sample supervision tree for demonstration - SupervisionTreeViewerExample.SampleTree - ] - - opts = [strategy: :one_for_one, name: SupervisionTreeViewerExample.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/examples/supervision_tree_viewer/lib/supervision_tree_viewer/sample_tree.ex b/examples/supervision_tree_viewer/lib/supervision_tree_viewer/sample_tree.ex deleted file mode 100644 index fee1cbc5..00000000 --- a/examples/supervision_tree_viewer/lib/supervision_tree_viewer/sample_tree.ex +++ /dev/null @@ -1,171 +0,0 @@ -defmodule SupervisionTreeViewerExample.SampleTree do - @moduledoc """ - Creates a sample supervision tree for demonstration purposes. - - Tree structure: - - SampleTree (supervisor, one_for_all) - ├── DatabasePool (supervisor, one_for_one) - │ ├── Connection1 (worker) - │ ├── Connection2 (worker) - │ └── Connection3 (worker) - ├── WebServer (supervisor, rest_for_one) - │ ├── Router (worker) - │ ├── Handler1 (worker) - │ └── Handler2 (worker) - └── BackgroundJobs (supervisor, one_for_one) - ├── JobRunner1 (worker) - └── JobRunner2 (worker) - """ - - use Supervisor - - def start_link(_opts) do - Supervisor.start_link(__MODULE__, [], name: __MODULE__) - end - - @impl true - def init(_opts) do - children = [ - {SupervisionTreeViewerExample.DatabasePool, []}, - {SupervisionTreeViewerExample.WebServer, []}, - {SupervisionTreeViewerExample.BackgroundJobs, []} - ] - - Supervisor.init(children, strategy: :one_for_all) - end -end - -defmodule SupervisionTreeViewerExample.DatabasePool do - use Supervisor - - def start_link(_opts) do - Supervisor.start_link(__MODULE__, [], name: __MODULE__) - end - - @impl true - def init(_opts) do - children = - for i <- 1..3 do - %{ - id: :"connection_#{i}", - start: {SupervisionTreeViewerExample.Worker, :start_link, [[name: :"Connection#{i}", type: :database]]} - } - end - - Supervisor.init(children, strategy: :one_for_one) - end -end - -defmodule SupervisionTreeViewerExample.WebServer do - use Supervisor - - def start_link(_opts) do - Supervisor.start_link(__MODULE__, [], name: __MODULE__) - end - - @impl true - def init(_opts) do - children = [ - %{ - id: :router, - start: {SupervisionTreeViewerExample.Worker, :start_link, [[name: :Router, type: :web]]} - }, - %{ - id: :handler_1, - start: {SupervisionTreeViewerExample.Worker, :start_link, [[name: :Handler1, type: :web]]} - }, - %{ - id: :handler_2, - start: {SupervisionTreeViewerExample.Worker, :start_link, [[name: :Handler2, type: :web]]} - } - ] - - Supervisor.init(children, strategy: :rest_for_one) - end -end - -defmodule SupervisionTreeViewerExample.BackgroundJobs do - use Supervisor - - def start_link(_opts) do - Supervisor.start_link(__MODULE__, [], name: __MODULE__) - end - - @impl true - def init(_opts) do - children = - for i <- 1..2 do - %{ - id: :"job_runner_#{i}", - start: {SupervisionTreeViewerExample.Worker, :start_link, [[name: :"JobRunner#{i}", type: :background]]} - } - end - - Supervisor.init(children, strategy: :one_for_one) - end -end - -defmodule SupervisionTreeViewerExample.Worker do - @moduledoc """ - A sample worker that simulates different workloads. - """ - - use GenServer - - def start_link(opts) do - name = Keyword.get(opts, :name) - GenServer.start_link(__MODULE__, opts, name: name) - end - - @impl true - def init(opts) do - type = Keyword.get(opts, :type, :generic) - - # Start work simulation - schedule_work(type) - - {:ok, - %{ - type: type, - work_count: 0, - started_at: DateTime.utc_now() - }} - end - - @impl true - def handle_info(:work, state) do - # Simulate work - work_intensity = - case state.type do - :database -> 1..100 - :web -> 1..500 - :background -> 1..1000 - _ -> 1..50 - end - - # Do some computation to generate reductions - _ = Enum.reduce(work_intensity, 0, &(&1 + &2)) - - # Schedule next work - schedule_work(state.type) - - {:noreply, %{state | work_count: state.work_count + 1}} - end - - @impl true - def handle_call(:get_stats, _from, state) do - {:reply, state, state} - end - - defp schedule_work(type) do - interval = - case type do - :database -> 200 - :web -> 100 - :background -> 500 - _ -> 300 - end - - Process.send_after(self(), :work, interval) - end -end diff --git a/examples/supervision_tree_viewer/mix.exs b/examples/supervision_tree_viewer/mix.exs deleted file mode 100644 index bbb6af6f..00000000 --- a/examples/supervision_tree_viewer/mix.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule SupervisionTreeViewerExample.MixProject do - use Mix.Project - - def project do - [ - app: :supervision_tree_viewer_example, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {SupervisionTreeViewerExample.Application, []} - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/supervision_tree_viewer/mix.lock b/examples/supervision_tree_viewer/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/supervision_tree_viewer/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/supervision_tree_viewer/run.exs b/examples/supervision_tree_viewer/run.exs deleted file mode 100644 index d7817b1c..00000000 --- a/examples/supervision_tree_viewer/run.exs +++ /dev/null @@ -1 +0,0 @@ -SupervisionTreeViewerExample.App.run() diff --git a/examples/table/README.md b/examples/table/README.md deleted file mode 100644 index 6c2dd03d..00000000 --- a/examples/table/README.md +++ /dev/null @@ -1,230 +0,0 @@ -# Table Widget Example - -A demonstration of the TermUI Table widget for displaying tabular data with selection, sorting, and scrolling. - -## Widget Overview - -The Table widget provides efficient display of structured data in a tabular format with virtual scrolling, making it suitable for both small datasets and large collections (10,000+ rows). It supports flexible column layouts, custom cell rendering, row selection, and keyboard/mouse navigation. - -**Key Features:** -- Virtual scrolling for large datasets -- Flexible column layout (fixed, proportional, percentage widths) -- Row selection (single or multi-select) -- Custom cell rendering functions -- Keyboard and mouse navigation -- Alternating row styles -- Header and row styling - -**When to Use:** -- Displaying lists of records -- Data browsers and explorers -- Log viewers -- Database query results -- Any structured data display - -## Widget Options - -The `Table.new/1` function accepts these options: - -- `:columns` - List of Column specifications (required) -- `:data` - List of row maps (required) -- `:selection_mode` - `:none`, `:single`, or `:multi` (default: `:single`) -- `:sortable` - Enable column sorting (default: true) -- `:on_select` - Callback when selection changes: `fn selected_rows -> ... end` -- `:on_sort` - Callback when sort changes: `fn {column, direction} -> ... end` -- `:header_style` - Style for header row -- `:row_style` - Style for data rows -- `:selected_style` - Style for selected rows -- `:alternating` - Alternating row backgrounds (default: false) - -**Column Specification** using `Column.new(key, header, opts)`: - -- `key` - Map key to extract value from row data -- `header` - Header text to display -- `:width` - Column width constraint: - - `Constraint.length(n)` - Fixed width in characters - - `Constraint.percentage(p)` - Percentage of total width - - `Constraint.fill()` - Fill remaining space - - `Constraint.ratio(r)` - Proportional width -- `:align` - Text alignment: `:left`, `:right`, or `:center` (default: `:left`) -- `:render` - Custom render function: `fn value -> String.t()` - -## Example Structure - -This example consists of: - -- `lib/table/app.ex` - Main application demonstrating: - - Basic table with multiple columns - - Mixed column widths (fixed and fill) - - Custom cell rendering (status with icons) - - Row selection and navigation - - Scrolling through data -- `mix.exs` - Mix project configuration -- `run.exs` - Helper script to run the example - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/table -mix termui.run -``` - -Or manually: - -```bash -cd examples/table -mix run -e "Table.App.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/table -iex -S mix -``` - -Then in IEx: - -```elixir -Table.App.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -| Key | Action | -|-----|--------| -| ↑/↓ | Move selection up/down | -| Page Up/Down | Scroll by 5 rows | -| Home/End | Jump to first/last row | -| Q | Quit | - -## Code Examples - -### Defining Columns - -```elixir -alias TermUI.Widgets.Table.Column -alias TermUI.Layout.Constraint - -columns = [ - # Fixed width column - Column.new(:id, "ID", width: Constraint.length(4), align: :right), - - # Fill remaining space - Column.new(:name, "Name", width: Constraint.fill()), - - # Custom render function - Column.new(:status, "Status", - width: Constraint.length(10), - render: fn - :active -> "● Active" - :inactive -> "○ Inactive" - _ -> "Unknown" - end - ) -] -``` - -### Column Options - -```elixir -Column.new(key, header, - width: Constraint.length(20), # Width constraint - align: :left, # :left, :center, or :right - render: &custom_formatter/1, # Custom render function - sortable: true # Enable sorting -) -``` - -### Width Constraints - -```elixir -# Fixed width -Constraint.length(20) - -# Proportional (ratio of available space) -Constraint.ratio(2) - -# Percentage of total width -Constraint.percentage(50) - -# Fill remaining space -Constraint.fill() -``` - -### Data Format - -Data is a list of maps where keys match column keys: - -```elixir -data = [ - %{id: 1, name: "Alice", email: "alice@example.com", status: :active}, - %{id: 2, name: "Bob", email: "bob@example.com", status: :inactive} -] -``` - -### Rendering a Cell - -```elixir -# Extract and format cell value from a row -cell_text = Column.render_cell(column, row) - -# Align text within column width -aligned = Column.align_text(cell_text, width, :left) -``` - -### Using the Full Table Widget - -For interactive tables with built-in selection and sorting: - -```elixir -Table.new( - columns: columns, - data: data, - selection_mode: :single, # :none, :single, or :multi - sortable: true, - header_style: Style.new(attrs: [:bold]), - selected_style: Style.new(bg: :blue) -) -``` - -## Column Layout - -The example demonstrates different column width strategies: - -1. **ID Column** - Fixed width (4 characters, right-aligned) -2. **Name Column** - Fills remaining space -3. **Email Column** - Fixed width (25 characters) -4. **Role Column** - Fixed width (12 characters) -5. **Status Column** - Fixed width (10 characters) with custom rendering - -## Custom Cell Rendering - -The Status column demonstrates custom rendering with icons: - -- **● Active** - Green indicator for active users -- **○ Inactive** - White indicator for inactive users -- **◐ Pending** - Half-filled indicator for pending users - -This shows how to transform data values into formatted display text with visual indicators. - -## Note on Implementation - -This example demonstrates a simplified approach where the Table widget is rendered as a static display with manual state management in the app. For production use with stateful components, the Table widget can be integrated as a StatefulComponent with automatic state handling for selection, sorting, and scrolling. - -## Widget API - -See the following files for full API documentation: -- `lib/term_ui/widgets/table.ex` - Main Table widget -- `lib/term_ui/widgets/table/column.ex` - Column specification diff --git a/examples/table/lib/table/app.ex b/examples/table/lib/table/app.ex deleted file mode 100644 index 9ed064fe..00000000 --- a/examples/table/lib/table/app.ex +++ /dev/null @@ -1,257 +0,0 @@ -defmodule Table.App do - @moduledoc """ - Table Widget Example - - This example demonstrates how to use the TermUI.Widgets.Table widget - for displaying tabular data with selection, sorting, and scrolling. - - Features demonstrated: - - Column definitions with different widths - - Row selection and navigation - - Custom cell rendering - - Header and row styling - - Note: The Table widget is a StatefulComponent, but in this example - we demonstrate the simpler approach of rendering it as a static display - with manual state management. - - Controls: - - Up/Down: Move selection - - Page Up/Down: Scroll by page - - Home/End: Jump to first/last row - - Q: Quit the application - """ - - use TermUI.Elm - - alias TermUI.Widgets.Table.Column - alias TermUI.Layout.Constraint - alias TermUI.Event - alias TermUI.Renderer.Style - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- - - @doc """ - Initialize the component state. - """ - def init(_opts) do - %{ - data: sample_data(), - selected: 0, - scroll_offset: 0, - visible_rows: 10 - } - end - - defp sample_data do - [ - %{id: 1, name: "Alice Johnson", email: "alice@example.com", role: "Admin", status: :active}, - %{id: 2, name: "Bob Smith", email: "bob@example.com", role: "User", status: :active}, - %{id: 3, name: "Charlie Brown", email: "charlie@example.com", role: "User", status: :inactive}, - %{id: 4, name: "Diana Prince", email: "diana@example.com", role: "Moderator", status: :active}, - %{id: 5, name: "Eve Wilson", email: "eve@example.com", role: "User", status: :pending}, - %{id: 6, name: "Frank Miller", email: "frank@example.com", role: "User", status: :active}, - %{id: 7, name: "Grace Lee", email: "grace@example.com", role: "Admin", status: :active}, - %{id: 8, name: "Henry Davis", email: "henry@example.com", role: "User", status: :inactive}, - %{id: 9, name: "Ivy Chen", email: "ivy@example.com", role: "Moderator", status: :active}, - %{id: 10, name: "Jack Taylor", email: "jack@example.com", role: "User", status: :pending}, - %{id: 11, name: "Kate Morgan", email: "kate@example.com", role: "User", status: :active}, - %{id: 12, name: "Leo Anderson", email: "leo@example.com", role: "User", status: :active} - ] - end - - @doc """ - Convert keyboard events to messages. - """ - def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, {:move, -1}} - def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, {:move, 1}} - def event_to_msg(%Event.Key{key: :page_up}, _state), do: {:msg, {:move, -5}} - def event_to_msg(%Event.Key{key: :page_down}, _state), do: {:msg, {:move, 5}} - def event_to_msg(%Event.Key{key: :home}, _state), do: {:msg, :home} - def event_to_msg(%Event.Key{key: :end}, _state), do: {:msg, :end} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit} - def event_to_msg(_event, _state), do: :ignore - - @doc """ - Update state based on messages. - """ - def update({:move, delta}, state) do - max_index = length(state.data) - 1 - new_selected = max(0, min(max_index, state.selected + delta)) - - # Adjust scroll offset to keep selection visible - new_offset = - cond do - new_selected < state.scroll_offset -> - new_selected - - new_selected >= state.scroll_offset + state.visible_rows -> - new_selected - state.visible_rows + 1 - - true -> - state.scroll_offset - end - - {%{state | selected: new_selected, scroll_offset: new_offset}, []} - end - - def update(:home, state) do - {%{state | selected: 0, scroll_offset: 0}, []} - end - - def update(:end, state) do - max_index = length(state.data) - 1 - new_offset = max(0, max_index - state.visible_rows + 1) - {%{state | selected: max_index, scroll_offset: new_offset}, []} - end - - def update(:quit, state) do - {state, [:quit]} - end - - @doc """ - Render the current state to a render tree. - """ - def view(state) do - # Define columns with different width constraints - columns = [ - # Fixed width column for ID - Column.new(:id, "ID", width: Constraint.length(4), align: :right), - - # Fill remaining space for name - Column.new(:name, "Name", width: Constraint.fill()), - - # Fixed width for email - Column.new(:email, "Email", width: Constraint.length(25)), - - # Fixed width for role - Column.new(:role, "Role", width: Constraint.length(12)), - - # Custom render function for status - Column.new(:status, "Status", - width: Constraint.length(10), - render: &format_status/1 - ) - ] - - stack(:vertical, [ - # Title - text("Table Widget Example", Style.new(fg: :cyan, attrs: [:bold])), - text("", nil), - - # Header row - render_header(columns), - - # Separator - text(String.duplicate("─", 80), nil), - - # Data rows - render_rows(state, columns), - - # Blank line before controls - text("", nil), - - # Controls - render_controls(state) - ]) - end - - defp render_controls(state) do - box_width = 44 - inner_width = box_width - 2 - - top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐" - bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘" - - stack(:vertical, [ - text("", nil), - text(top_border, Style.new(fg: :yellow)), - text("│" <> String.pad_trailing(" ↑/↓ Move selection", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Page Up/Down Scroll by 5", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Home/End Jump to first/last", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil), - text("│" <> String.pad_trailing("", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Row #{state.selected + 1} of #{length(state.data)}", inner_width) <> "│", nil), - text(bottom_border, Style.new(fg: :yellow)) - ]) - end - - # ---------------------------------------------------------------------------- - # Private Helpers - # ---------------------------------------------------------------------------- - - # Format status values with icons - defp format_status(:active), do: "● Active" - defp format_status(:inactive), do: "○ Inactive" - defp format_status(:pending), do: "◐ Pending" - defp format_status(other), do: to_string(other) - - # Render the header row - defp render_header(columns) do - header_text = - columns - |> Enum.map(fn col -> - width = get_column_width(col) - Column.align_text(col.header, width, col.align) - end) - |> Enum.join(" ") - - text(header_text, Style.new(fg: :white, attrs: [:bold])) - end - - # Render visible data rows - defp render_rows(state, columns) do - visible_data = - state.data - |> Enum.with_index() - |> Enum.slice(state.scroll_offset, state.visible_rows) - - rows = - Enum.map(visible_data, fn {row, index} -> - render_row(row, index, columns, state) - end) - - stack(:vertical, rows) - end - - # Render a single row - defp render_row(row, index, columns, state) do - row_text = - columns - |> Enum.map(fn col -> - width = get_column_width(col) - cell_text = Column.render_cell(col, row) - Column.align_text(cell_text, width, col.align) - end) - |> Enum.join(" ") - - # Highlight selected row - if index == state.selected do - text(row_text, Style.new(fg: :black, bg: :cyan)) - else - text(row_text, nil) - end - end - - # Get column width (simplified - in real usage would use Constraint.resolve) - defp get_column_width(col) do - case col.width do - %Constraint.Length{value: v} -> v - %Constraint.Fill{} -> 20 - _ -> 15 - end - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Run the table example application. - """ - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/table/lib/table/application.ex b/examples/table/lib/table/application.ex deleted file mode 100644 index 2de7f5b7..00000000 --- a/examples/table/lib/table/application.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule Table.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [] - opts = [strategy: :one_for_one, name: Table.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/examples/table/mix.exs b/examples/table/mix.exs deleted file mode 100644 index c6abba44..00000000 --- a/examples/table/mix.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule Table.MixProject do - use Mix.Project - - def project do - [ - app: :table, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {Table.Application, []} - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/table/mix.lock b/examples/table/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/table/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/table/run.exs b/examples/table/run.exs deleted file mode 100644 index 3f0f75e0..00000000 --- a/examples/table/run.exs +++ /dev/null @@ -1 +0,0 @@ -Table.App.run() diff --git a/examples/tabs/README.md b/examples/tabs/README.md deleted file mode 100644 index 1566dd27..00000000 --- a/examples/tabs/README.md +++ /dev/null @@ -1,143 +0,0 @@ -# Tabs Widget Example - -This example demonstrates how to use the `TermUI.Widgets.Tabs` widget for organizing content into switchable panels. - -## Features Demonstrated - -- Tab bar with multiple tabs -- Content switching on tab selection -- Disabled tabs -- Focus and selection states -- Dynamic tab addition and removal -- Keyboard navigation - -## Installation - -```bash -cd examples/tabs -mix deps.get -``` - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/tabs -mix termui.run -``` - -Or manually: - -```bash -cd examples/tabs -mix run -e "Tabs.App.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/tabs -iex -S mix -``` - -Then in IEx: - -```elixir -Tabs.App.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -| Key | Action | -|-----|--------| -| ←/→ | Navigate between tabs | -| Enter/Space | Select focused tab | -| Home/End | Jump to first/last tab | -| A | Add a new tab | -| D | Remove current tab | -| Q | Quit | - -## Code Overview - -### Creating Tabs - -```elixir -Tabs.new( - tabs: [ - %{id: :home, label: "Home", content: home_content()}, - %{id: :settings, label: "Settings", content: settings_content()}, - %{id: :about, label: "About", disabled: true} - ], - selected: :home, # Initially selected tab - on_change: fn tab_id -> - IO.puts("Selected: #{tab_id}") - end -) -``` - -### Tab Options - -```elixir -%{ - id: :home, # Unique identifier (required) - label: "Home", # Display text (required) - content: render_node, # Content when selected - disabled: false, # Whether tab is disabled - closeable: false # Whether tab shows close button -} -``` - -### Styling Options - -```elixir -Tabs.new( - tabs: tabs, - tab_style: Style.new(fg: :white), - selected_style: Style.new(fg: :cyan, attrs: [:bold]), - disabled_style: Style.new(fg: :bright_black) -) -``` - -### Tab API - -```elixir -# Get selected tab -Tabs.get_selected(state) - -# Select a tab programmatically -Tabs.select(state, :settings) - -# Add a new tab -Tabs.add_tab(state, %{id: :new, label: "New Tab"}) - -# Remove a tab -Tabs.remove_tab(state, :old_tab) - -# Get tab count -Tabs.tab_count(state) -``` - -## Visual States - -Tabs have three visual states: - -| State | Decoration | Description | -|-------|------------|-------------| -| Selected | `[Tab]` | Currently showing content | -| Focused | `(Tab)` | Keyboard focus but not selected | -| Normal | ` Tab ` | Neither selected nor focused | -| Disabled | ` Tab ` (dimmed) | Cannot be selected | - -## Widget API - -See `lib/term_ui/widgets/tabs.ex` for the full API documentation. diff --git a/examples/tabs/lib/tabs/app.ex b/examples/tabs/lib/tabs/app.ex deleted file mode 100644 index c5b2a569..00000000 --- a/examples/tabs/lib/tabs/app.ex +++ /dev/null @@ -1,291 +0,0 @@ -defmodule Tabs.App do - @moduledoc """ - Tabs Widget Example - - This example demonstrates how to use the TermUI.Widgets.Tabs widget - for organizing content into switchable panels. - - Features demonstrated: - - Tab bar with multiple tabs - - Content switching on tab selection - - Disabled tabs - - Keyboard navigation - - Dynamic tab management - - Controls: - - Left/Right: Navigate between tabs - - Enter/Space: Select focused tab - - Home/End: Jump to first/last tab - - A: Add a new tab - - D: Remove current tab - - Q: Quit the application - """ - - use TermUI.Elm - - alias TermUI.Event - alias TermUI.Renderer.Style - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- - - @doc """ - Initialize the component state. - """ - def init(_opts) do - %{ - tabs: initial_tabs(), - selected: :home, - focused: :home, - tab_counter: 0 - } - end - - defp initial_tabs do - [ - %{id: :home, label: "Home", disabled: false}, - %{id: :profile, label: "Profile", disabled: false}, - %{id: :settings, label: "Settings", disabled: false}, - %{id: :disabled, label: "Disabled", disabled: true} - ] - end - - @doc """ - Convert keyboard events to messages. - """ - def event_to_msg(%Event.Key{key: :left}, _state), do: {:msg, {:move_focus, -1}} - def event_to_msg(%Event.Key{key: :right}, _state), do: {:msg, {:move_focus, 1}} - def event_to_msg(%Event.Key{key: :home}, _state), do: {:msg, :focus_first} - def event_to_msg(%Event.Key{key: :end}, _state), do: {:msg, :focus_last} - def event_to_msg(%Event.Key{key: :enter}, _state), do: {:msg, :select_focused} - def event_to_msg(%Event.Key{key: " "}, _state), do: {:msg, :select_focused} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["a", "A"], do: {:msg, :add_tab} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["d", "D"], do: {:msg, :remove_tab} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit} - def event_to_msg(_event, _state), do: :ignore - - @doc """ - Update state based on messages. - """ - def update({:move_focus, delta}, state) do - enabled_tabs = Enum.filter(state.tabs, fn t -> not t.disabled end) - ids = Enum.map(enabled_tabs, & &1.id) - - case Enum.find_index(ids, &(&1 == state.focused)) do - nil -> - {state, []} - - current_idx -> - new_idx = rem(current_idx + delta + length(ids), length(ids)) - new_focused = Enum.at(ids, new_idx) - {%{state | focused: new_focused}, []} - end - end - - def update(:focus_first, state) do - first = - state.tabs - |> Enum.find(fn t -> not t.disabled end) - |> case do - nil -> state.focused - tab -> tab.id - end - - {%{state | focused: first}, []} - end - - def update(:focus_last, state) do - last = - state.tabs - |> Enum.filter(fn t -> not t.disabled end) - |> List.last() - |> case do - nil -> state.focused - tab -> tab.id - end - - {%{state | focused: last}, []} - end - - def update(:select_focused, state) do - tab = Enum.find(state.tabs, &(&1.id == state.focused)) - - if tab && not tab.disabled do - {%{state | selected: state.focused}, []} - else - {state, []} - end - end - - def update(:add_tab, state) do - counter = state.tab_counter + 1 - new_tab = %{id: :"tab_#{counter}", label: "Tab #{counter}", disabled: false} - tabs = state.tabs ++ [new_tab] - {%{state | tabs: tabs, tab_counter: counter}, []} - end - - def update(:remove_tab, state) do - # Don't remove if only one enabled tab left - enabled_count = Enum.count(state.tabs, fn t -> not t.disabled end) - - if enabled_count > 1 do - tabs = Enum.reject(state.tabs, &(&1.id == state.selected)) - - # Select a new tab if needed - {selected, focused} = - if Enum.any?(tabs, &(&1.id == state.selected)) do - {state.selected, state.focused} - else - first_enabled = Enum.find(tabs, fn t -> not t.disabled end) - id = if first_enabled, do: first_enabled.id, else: nil - {id, id} - end - - {%{state | tabs: tabs, selected: selected, focused: focused}, []} - else - {state, []} - end - end - - def update(:quit, state) do - {state, [:quit]} - end - - @doc """ - Render the current state to a render tree. - """ - def view(state) do - stack(:vertical, [ - # Title - text("Tabs Widget Example", Style.new(fg: :cyan, attrs: [:bold])), - text("", nil), - - # Tab bar - render_tab_bar(state), - - # Content area border - text("┌" <> String.duplicate("─", 50) <> "┐", nil), - - # Content for selected tab - render_content(state), - - # Content area border - text("└" <> String.duplicate("─", 50) <> "┘", nil), - text("", nil), - - # Status - text("", nil), - - # Controls - render_controls(state) - ]) - end - - defp render_controls(state) do - box_width = 44 - inner_width = box_width - 2 - - top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐" - bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘" - - stack(:vertical, [ - text("", nil), - text(top_border, Style.new(fg: :yellow)), - text("│" <> String.pad_trailing(" ←/→ Navigate tabs", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Enter Select focused tab", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Home/End Jump to first/last", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" A Add new tab", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" D Remove current tab", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil), - text("│" <> String.pad_trailing("", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Selected: #{state.selected} | Focused: #{state.focused}", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Tab count: #{length(state.tabs)}", inner_width) <> "│", nil), - text(bottom_border, Style.new(fg: :yellow)) - ]) - end - - # ---------------------------------------------------------------------------- - # Private Helpers - # ---------------------------------------------------------------------------- - - defp render_tab_bar(state) do - tabs = - Enum.map(state.tabs, fn tab -> - render_tab(tab, state) - end) - - stack(:horizontal, tabs) - end - - defp render_tab(tab, state) do - label = " #{tab.label} " - - {decorated, style} = - cond do - tab.disabled -> - {" #{label} ", Style.new(fg: :bright_black)} - - tab.id == state.selected -> - {"[#{label}]", Style.new(fg: :cyan, attrs: [:bold])} - - tab.id == state.focused -> - {"(#{label})", Style.new(fg: :white)} - - true -> - {" #{label} ", Style.new(fg: :white)} - end - - text(decorated, style) - end - - defp render_content(state) do - content_text = - case state.selected do - :home -> - [ - "│ Welcome to the Home tab! │", - "│ │", - "│ This example demonstrates the Tabs widget. │", - "│ Use arrow keys to navigate between tabs. │" - ] - - :profile -> - [ - "│ Profile Tab │", - "│ │", - "│ Username: demo_user │", - "│ Email: demo@example.com │" - ] - - :settings -> - [ - "│ Settings Tab │", - "│ │", - "│ Theme: Dark │", - "│ Language: English │" - ] - - other -> - [ - "│ #{String.pad_trailing("Content for #{other}", 48)} │", - "│ │", - "│ This is a dynamically created tab. │", - "│ │" - ] - end - - stack(:vertical, Enum.map(content_text, &text(&1, nil))) - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Run the tabs example application. - """ - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/tabs/lib/tabs/application.ex b/examples/tabs/lib/tabs/application.ex deleted file mode 100644 index 69a74218..00000000 --- a/examples/tabs/lib/tabs/application.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule Tabs.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [] - opts = [strategy: :one_for_one, name: Tabs.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/examples/tabs/mix.exs b/examples/tabs/mix.exs deleted file mode 100644 index a1893045..00000000 --- a/examples/tabs/mix.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule Tabs.MixProject do - use Mix.Project - - def project do - [ - app: :tabs, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {Tabs.Application, []} - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/tabs/mix.lock b/examples/tabs/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/tabs/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/tabs/run.exs b/examples/tabs/run.exs deleted file mode 100644 index 492b4347..00000000 --- a/examples/tabs/run.exs +++ /dev/null @@ -1 +0,0 @@ -Tabs.App.run() diff --git a/examples/text_input/README.md b/examples/text_input/README.md deleted file mode 100644 index 76088ea2..00000000 --- a/examples/text_input/README.md +++ /dev/null @@ -1,208 +0,0 @@ -# TextInput Widget Example - -This example demonstrates how to use the `TermUI.Widgets.TextInput` widget for single-line and multi-line text input. - -## Features Demonstrated - -- Single-line text input with Enter to submit -- Multi-line text input with auto-growing height -- Chat-style input with Enter to submit (Ctrl+Enter for newlines) -- Scrollable area after max_visible_lines -- Placeholder text -- Focus states with visual feedback -- Cursor positioning and movement -- Text editing operations - -## Installation - -```bash -cd examples/text_input -mix deps.get -``` - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/text_input -mix termui.run -``` - -Or manually: - -```bash -cd examples/text_input -mix run -e "TextInput.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/text_input -iex -S mix -``` - -Then in IEx: - -```elixir -TextInput.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -| Key | Action | -|-----|--------| -| Arrow keys | Move cursor | -| Home/End | Move to start/end of line | -| Ctrl+Home/End | Move to start/end of text (multiline) | -| Backspace/Delete | Delete characters | -| Ctrl+Enter | Insert newline (multiline mode) | -| Enter | Submit (single-line) or newline (multiline) | -| Tab | Switch between inputs | -| Escape | Blur input (remove focus) | -| Q | Quit (when input is empty) | - -## Code Overview - -### Creating a Single-Line Input - -```elixir -alias TermUI.Widgets.TextInput - -props = TextInput.new( - placeholder: "Enter your name...", - width: 40, - on_submit: fn value -> - IO.puts("Submitted: #{value}") - end -) - -{:ok, state} = TextInput.init(props) -``` - -### Creating a Multi-Line Input - -```elixir -props = TextInput.new( - placeholder: "Enter your message...", - width: 50, - multiline: true, - max_visible_lines: 5, - on_change: fn value -> - IO.puts("Current text: #{value}") - end -) - -{:ok, state} = TextInput.init(props) -``` - -### Chat-Style Input (Enter Submits) - -```elixir -props = TextInput.new( - placeholder: "Type a message and press Enter...", - width: 50, - multiline: true, - max_visible_lines: 3, - enter_submits: true, # Enter submits, Ctrl+Enter inserts newline - on_submit: fn value -> - send_message(value) - end -) - -{:ok, state} = TextInput.init(props) -``` - -### Widget Options - -```elixir -TextInput.new( - value: "", # Initial text value - placeholder: "Enter text...", # Placeholder when empty - width: 40, # Widget width in characters - multiline: false, # Enable multi-line mode - max_lines: nil, # Max lines allowed (nil = unlimited) - max_visible_lines: 5, # Lines visible before scrolling - enter_submits: false, # Enter submits instead of newline - disabled: false, # Disable input - style: nil, # Text style - focused_style: nil, # Style when focused - placeholder_style: nil, # Placeholder text style - on_change: fn value -> ... end, # Value change callback - on_submit: fn value -> ... end # Submit callback -) -``` - -## TextInput API - -```elixir -# Get current value -value = TextInput.get_value(state) - -# Set value programmatically -state = TextInput.set_value(state, "New text") - -# Clear the input -state = TextInput.clear(state) - -# Set focus state -state = TextInput.set_focused(state, true) - -# Get line count -lines = TextInput.get_line_count(state) - -# Get cursor position -{row, col} = TextInput.get_cursor(state) -``` - -## Features - -### Auto-Growing Height - -Multi-line inputs automatically grow their height as you type, up to `max_visible_lines`. After that, the content becomes scrollable with a scroll indicator showing position. - -### Scrolling - -When content exceeds `max_visible_lines`, a scroll indicator appears showing: -- Current position (e.g., "↓ 6-10/25") -- Scroll arrows (↑, ↓, or ↕) - -### Focus States - -Inputs have different visual states: -- **Focused**: Shows cursor and active style -- **Unfocused**: Shows content without cursor -- **Empty & Unfocused**: Shows placeholder text (dimmed) - -### Text Editing - -Supports standard text editing operations: -- Character insertion at cursor -- Backspace/Delete character removal -- Line joining on backspace at line start -- Newline insertion (multiline mode) -- Cursor movement with arrow keys - -## Example Modes - -The example demonstrates three different input configurations: - -1. **Single-line Input**: Traditional text field that submits on Enter -2. **Multi-line Input**: Text area with Ctrl+Enter for newlines, Enter also adds newlines -3. **Chat Input**: Chat-style with Enter to submit and Ctrl+Enter for newlines - -Use Tab to cycle between the three inputs and see how they behave differently. - -## Widget API - -See `lib/term_ui/widgets/text_input.ex` for the full API documentation. diff --git a/examples/text_input/lib/text_input.ex b/examples/text_input/lib/text_input.ex deleted file mode 100644 index 6c5f336c..00000000 --- a/examples/text_input/lib/text_input.ex +++ /dev/null @@ -1,7 +0,0 @@ -defmodule TextInput do - @moduledoc """ - TextInput example entry point. - """ - - defdelegate run, to: TextInput.App -end diff --git a/examples/text_input/lib/text_input/app.ex b/examples/text_input/lib/text_input/app.ex deleted file mode 100644 index 6b05b6f7..00000000 --- a/examples/text_input/lib/text_input/app.ex +++ /dev/null @@ -1,412 +0,0 @@ -defmodule TextInput.App do - @moduledoc """ - TextInput Widget Example - - This example demonstrates how to use the TermUI.Widgets.TextInput widget - for single-line and multi-line text input. - - Features demonstrated: - - Single-line text input - - Multi-line text input with Ctrl+Enter for newlines - - Auto-growing height - - Scrollable area after max_visible_lines - - Placeholder text - - Focus states - - Reading current value with get_value/1 - - Controls: - - Arrow keys: Move cursor - - Home/End: Move to start/end of line - - Ctrl+Home/End: Move to start/end of text (multiline) - - Backspace/Delete: Delete characters - - Ctrl+Enter: Insert newline (multiline mode) - - Enter: Submit (single-line) or newline (multiline) - - Tab: Switch between inputs - - Escape: Blur input - - Q: Quit the application - """ - - use TermUI.Elm - - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Widgets.TextInput, as: TI - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- - - @doc """ - Initialize the component state. - """ - def init(_opts) do - # Single-line input - single_props = - TI.new( - placeholder: "Enter your name...", - width: 40 - ) - - {:ok, single_state} = TI.init(single_props) - - # Multi-line input (with scrolling after 5 lines) - multi_props = - TI.new( - placeholder: "Enter your message...", - width: 50, - multiline: true, - max_visible_lines: 5 - ) - - {:ok, multi_state} = TI.init(multi_props) - - # Multi-line with enter_submits (like a chat input) - chat_props = - TI.new( - placeholder: "Type a message and press Enter...", - width: 50, - multiline: true, - max_visible_lines: 3, - enter_submits: true - ) - - {:ok, chat_state} = TI.init(chat_props) - - %{ - # Input states - single_input: TI.set_focused(single_state, true), - multi_input: multi_state, - chat_input: chat_state, - - # Track which input is focused - focused_input: :single, - - # Chat messages history - chat_messages: [], - last_action: "Ready" - } - end - - @doc """ - Convert keyboard events to messages. - """ - def event_to_msg(%Event.Key{key: key}, %{focused_input: nil}) when key in ["q", "Q"] do - {:msg, :quit} - end - - def event_to_msg(%Event.Key{key: key}, %{focused_input: :single}) when key in ["q", "Q"] do - # Only quit if input is empty - {:msg, :check_quit_single} - end - - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"] do - # In multi/chat, Q is just a character - {:msg, {:input_event, %Event.Key{key: key, char: key}}} - end - - def event_to_msg(%Event.Key{key: :tab}, _state) do - {:msg, :next_input} - end - - def event_to_msg(%Event.Key{key: :enter}, %{focused_input: :single} = state) do - # Submit single-line input - {:msg, {:submit_single, TI.get_value(state.single_input)}} - end - - def event_to_msg(%Event.Key{key: :enter}, %{focused_input: :chat} = state) do - # Submit chat message (enter_submits is true) - {:msg, {:submit_chat, TI.get_value(state.chat_input)}} - end - - def event_to_msg(event, _state) do - {:msg, {:input_event, event}} - end - - @doc """ - Update state based on messages. - """ - def update(:quit, state) do - {state, [:quit]} - end - - def update(:check_quit_single, state) do - # Only quit if single input is empty, otherwise treat as character - if TI.get_value(state.single_input) == "" do - {state, [:quit]} - else - # Pass Q as a character to the input - {:ok, new_input} = TI.handle_event(%Event.Key{key: "q", char: "q"}, state.single_input) - {%{state | single_input: new_input, last_action: "Typing..."}, []} - end - end - - def update(:next_input, state) do - # Cycle through inputs: single -> multi -> chat -> single - {next_focused, state} = - case state.focused_input do - :single -> - {:multi, - %{ - state - | single_input: TI.set_focused(state.single_input, false), - multi_input: TI.set_focused(state.multi_input, true) - }} - - :multi -> - {:chat, - %{ - state - | multi_input: TI.set_focused(state.multi_input, false), - chat_input: TI.set_focused(state.chat_input, true) - }} - - :chat -> - {:single, - %{ - state - | chat_input: TI.set_focused(state.chat_input, false), - single_input: TI.set_focused(state.single_input, true) - }} - end - - {%{state | focused_input: next_focused, last_action: "Switched to #{next_focused} input"}, []} - end - - def update({:submit_single, value}, state) do - action = - if value == "" do - "Single input: (empty - nothing to submit)" - else - "Submitted: \"#{value}\"" - end - - {%{state | last_action: action}, []} - end - - def update({:submit_chat, value}, state) do - if String.trim(value) != "" do - messages = state.chat_messages ++ [value] - # Clear the chat input - chat_input = TI.clear(state.chat_input) - - {%{ - state - | chat_messages: Enum.take(messages, -5), - chat_input: chat_input, - last_action: "Message sent: #{String.slice(value, 0, 20)}..." - }, []} - else - {%{state | last_action: "Chat: (empty - nothing to send)"}, []} - end - end - - def update({:input_event, event}, state) do - # Route event to focused input - case state.focused_input do - :single -> - {:ok, new_input} = TI.handle_event(event, state.single_input) - {%{state | single_input: new_input, last_action: "Typing..."}, []} - - :multi -> - {:ok, new_input} = TI.handle_event(event, state.multi_input) - {%{state | multi_input: new_input, last_action: "Typing..."}, []} - - :chat -> - {:ok, new_input} = TI.handle_event(event, state.chat_input) - {%{state | chat_input: new_input, last_action: "Typing..."}, []} - end - end - - def update(_msg, state) do - {state, []} - end - - @doc """ - Render the current state to a render tree. - """ - def view(state) do - stack(:vertical, [ - # Title - text("TextInput Widget Example", Style.new(fg: :cyan, attrs: [:bold])), - text(""), - - # Instructions - render_instructions(), - text(""), - - # Single-line input section - render_single_input(state), - text(""), - - # Multi-line input section - render_multi_input(state), - text(""), - - # Chat-style input section - render_chat_input(state), - text(""), - - # Status - render_status(state) - ]) - end - - # ---------------------------------------------------------------------------- - # Private Helpers - # ---------------------------------------------------------------------------- - - # Border character sets (matching TermUI.Widget.Block) - @border_rounded %{tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│"} - @border_single %{tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│"} - - defp render_instructions do - b = @border_rounded - inner_width = 53 - - label = " Controls " - left_pad = 2 - right_pad = inner_width - left_pad - String.length(label) - - top = b.tl <> String.duplicate(b.h, left_pad) <> label <> String.duplicate(b.h, right_pad) <> b.tr - bot = b.bl <> String.duplicate(b.h, inner_width) <> b.br - - stack(:vertical, [ - text(top, Style.new(fg: :yellow)), - text(b.v <> String.pad_trailing(" Arrow keys Move cursor", inner_width) <> b.v, nil), - text(b.v <> String.pad_trailing(" Home/End Move to start/end of line", inner_width) <> b.v, nil), - text(b.v <> String.pad_trailing(" Ctrl+Home/End Move to start/end of text", inner_width) <> b.v, nil), - text(b.v <> String.pad_trailing(" Backspace/Del Delete characters", inner_width) <> b.v, nil), - text(b.v <> String.pad_trailing(" Ctrl+Enter Insert newline (multiline)", inner_width) <> b.v, nil), - text(b.v <> String.pad_trailing(" Enter Submit (single/chat) or newline", inner_width) <> b.v, nil), - text(b.v <> String.pad_trailing(" Tab Switch between inputs", inner_width) <> b.v, nil), - text(b.v <> String.pad_trailing(" Q Quit (when input is empty)", inner_width) <> b.v, nil), - text(bot, Style.new(fg: :yellow)) - ]) - end - - defp render_single_input(state) do - focused = state.focused_input == :single - b = @border_single - border_style = if focused, do: Style.new(fg: :green), else: Style.new(fg: :blue) - - current_value = TI.get_value(state.single_input) - inner_width = 52 - - label = " Single-line Input (Enter to submit) " - left_pad = 2 - right_pad = inner_width - left_pad - String.length(label) - - top = b.tl <> String.duplicate(b.h, left_pad) <> label <> String.duplicate(b.h, right_pad) <> b.tr - bot = b.bl <> String.duplicate(b.h, inner_width) <> b.br - - stack(:vertical, [ - text(top, border_style), - stack(:horizontal, [ - text(b.v <> " ", border_style), - TI.render(state.single_input, %{width: 50, height: 1}), - text(" " <> b.v, border_style) - ]), - text(b.v <> String.pad_trailing(" Value: \"#{String.slice(current_value, 0, 40)}\"", inner_width) <> b.v, Style.new(fg: :bright_black)), - text(bot, border_style) - ]) - end - - defp render_multi_input(state) do - focused = state.focused_input == :multi - b = @border_single - border_style = if focused, do: Style.new(fg: :green), else: Style.new(fg: :blue) - - line_count = TI.get_line_count(state.multi_input) - {cursor_row, cursor_col} = TI.get_cursor(state.multi_input) - - inner_width = 62 - - label = " Multi-line Input (Ctrl+Enter for newline) " - left_pad = 2 - right_pad = inner_width - left_pad - String.length(label) - - top = b.tl <> String.duplicate(b.h, left_pad) <> label <> String.duplicate(b.h, right_pad) <> b.tr - bot = b.bl <> String.duplicate(b.h, inner_width) <> b.br - - input_view = TI.render(state.multi_input, %{width: 60, height: 5}) - - stack(:vertical, [ - text(top, border_style), - stack(:horizontal, [ - text(b.v <> " ", border_style), - input_view, - text(" " <> b.v, border_style) - ]), - text(b.v <> String.pad_trailing(" Lines: #{line_count}, Cursor: row #{cursor_row + 1}, col #{cursor_col + 1}", inner_width) <> b.v, Style.new(fg: :bright_black)), - text(bot, border_style) - ]) - end - - defp render_chat_input(state) do - focused = state.focused_input == :chat - b = @border_single - border_style = if focused, do: Style.new(fg: :green), else: Style.new(fg: :blue) - - inner_width = 62 - - label = " Chat Input (Enter submits, Ctrl+Enter for newline) " - left_pad = 2 - right_pad = inner_width - left_pad - String.length(label) - - top = b.tl <> String.duplicate(b.h, left_pad) <> label <> String.duplicate(b.h, right_pad) <> b.tr - bot = b.bl <> String.duplicate(b.h, inner_width) <> b.br - - input_view = TI.render(state.chat_input, %{width: 60, height: 3}) - - stack(:vertical, [ - text(top, border_style), - render_chat_messages(state.chat_messages, inner_width, b, border_style), - stack(:horizontal, [ - text(b.v <> " ", border_style), - input_view, - text(" " <> b.v, border_style) - ]), - text(bot, border_style) - ]) - end - - defp render_chat_messages([], inner_width, b, border_style) do - stack(:vertical, [ - text(b.v <> String.pad_trailing(" (no messages yet)", inner_width) <> b.v, border_style) - ]) - end - - defp render_chat_messages(messages, inner_width, b, _border_style) do - message_nodes = - Enum.map(messages, fn msg -> - # Truncate long messages - display_msg = - if String.length(msg) > 50, - do: String.slice(msg, 0, 47) <> "...", - else: msg - - content = " > #{display_msg}" - text(b.v <> String.pad_trailing(content, inner_width) <> b.v, Style.new(fg: :cyan)) - end) - - stack(:vertical, message_nodes) - end - - defp render_status(state) do - stack(:horizontal, [ - text("Status: ", Style.new(fg: :yellow)), - text(state.last_action, Style.new(fg: :white)) - ]) - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Run the text input example application. - """ - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/text_input/mix.exs b/examples/text_input/mix.exs deleted file mode 100644 index bf53fae6..00000000 --- a/examples/text_input/mix.exs +++ /dev/null @@ -1,25 +0,0 @@ -defmodule TextInput.MixProject do - use Mix.Project - - def project do - [ - app: :text_input, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger] - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/text_input/mix.lock b/examples/text_input/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/text_input/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/text_input/run.exs b/examples/text_input/run.exs deleted file mode 100644 index 1bd513e6..00000000 --- a/examples/text_input/run.exs +++ /dev/null @@ -1 +0,0 @@ -TextInput.App.run() diff --git a/examples/toast/README.md b/examples/toast/README.md deleted file mode 100644 index 7568a972..00000000 --- a/examples/toast/README.md +++ /dev/null @@ -1,144 +0,0 @@ -# Toast Widget Example - -This example demonstrates the `TermUI.Widgets.Toast` and `TermUI.Widgets.ToastManager` widgets for displaying auto-dismissing notifications. - -## Features Demonstrated - -- Info, Success, Warning, Error toast types -- Different screen positions (6 positions) -- Auto-dismiss after configurable duration (3 seconds) -- Toast stacking when multiple appear -- Click or Escape to dismiss manually -- ToastManager for handling multiple toasts - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/toast -mix termui.run -``` - -Or manually: - -```bash -cd examples/toast -mix run -e "Toast.App.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/toast -iex -S mix -``` - -Then in IEx: - -```elixir -Toast.App.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -| Key | Action | -|-----|--------| -| 1 | Show Info Toast | -| 2 | Show Success Toast | -| 3 | Show Warning Toast | -| 4 | Show Error Toast | -| 5 | Show Multiple Toasts (stacking demo) | -| P | Cycle through positions | -| C | Clear all toasts | -| Q | Quit | - -## Toast Types - -| Type | Icon | Color | -|------|------|-------| -| info | ℹ | cyan/blue | -| success | ✓ | green | -| warning | ⚠ | yellow | -| error | ✗ | red | - -## Toast Positions - -| Position | Location | -|----------|----------| -| top_left | Upper left corner | -| top_center | Upper center | -| top_right | Upper right corner | -| bottom_left | Lower left corner | -| bottom_center | Lower center | -| bottom_right | Lower right corner (default) | - -## Widget Usage - -### Single Toast - -```elixir -alias TermUI.Widgets.Toast - -# Create a toast -props = Toast.new( - message: "File saved successfully", - type: :success, - duration: 3000, - position: :bottom_right, - on_dismiss: fn -> handle_dismiss() end -) - -# Initialize state -{:ok, state} = Toast.init(props) - -# Check if should auto-dismiss -if Toast.should_dismiss?(state) do - state = Toast.dismiss_toast(state) -end -``` - -### Multiple Toasts with ToastManager - -```elixir -alias TermUI.Widgets.ToastManager - -# Create manager -manager = ToastManager.new( - position: :bottom_right, - max_toasts: 5, - default_duration: 3000 -) - -# Add toasts -manager = ToastManager.add_toast(manager, "First message", :info) -manager = ToastManager.add_toast(manager, "Second message", :success) - -# Update on tick (removes expired toasts) -manager = ToastManager.tick(manager) - -# Get visible toasts -toasts = ToastManager.get_toasts(manager) - -# Clear all -manager = ToastManager.clear_all(manager) -``` - -## Features - -- **Auto-dismiss**: Toasts automatically disappear after duration (default 3s) -- **Manual dismiss**: Click on toast or press Escape to dismiss early -- **Stacking**: Multiple toasts stack vertically at the chosen position -- **Max limit**: ToastManager limits number of simultaneous toasts (default 5) -- **Type icons**: Each type has a distinctive icon -- **Z-Order**: Toasts render above other content (z: 150) -- **Non-blocking**: Toasts don't capture focus or block interaction diff --git a/examples/toast/lib/toast/app.ex b/examples/toast/lib/toast/app.ex deleted file mode 100644 index 13ad9e98..00000000 --- a/examples/toast/lib/toast/app.ex +++ /dev/null @@ -1,225 +0,0 @@ -defmodule Toast.App do - @moduledoc """ - Toast Widget Example - - This example demonstrates how to use the TermUI.Widgets.Toast and - ToastManager widgets for displaying auto-dismissing notifications. - - Features demonstrated: - - Info, Success, Warning, Error toast types - - Different screen positions (6 positions) - - Auto-dismiss after configurable duration - - Toast stacking when multiple appear - - Click or Escape to dismiss manually - - ToastManager for handling multiple toasts - - Controls: - - 1: Show Info Toast - - 2: Show Success Toast - - 3: Show Warning Toast - - 4: Show Error Toast - - 5: Show Multiple Toasts (stacking demo) - - P: Cycle through positions - - C: Clear all toasts - - Q: Quit the application - """ - - use TermUI.Elm - - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Widgets.ToastManager - - @positions [ - :bottom_right, - :bottom_center, - :bottom_left, - :top_right, - :top_center, - :top_left - ] - - @position_names %{ - bottom_right: "Bottom Right", - bottom_center: "Bottom Center", - bottom_left: "Bottom Left", - top_right: "Top Right", - top_center: "Top Center", - top_left: "Top Left" - } - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- - - @doc """ - Initialize the component state. - """ - def init(_opts) do - %{ - toast_manager: ToastManager.new(position: :bottom_right, default_duration: 3000), - current_position: :bottom_right, - position_index: 0, - toast_count: 0, - last_action: nil - } - end - - @doc """ - Convert keyboard events to messages. - """ - def event_to_msg(%Event.Key{key: "1"}, _state), do: {:msg, {:show_toast, :info}} - def event_to_msg(%Event.Key{key: "2"}, _state), do: {:msg, {:show_toast, :success}} - def event_to_msg(%Event.Key{key: "3"}, _state), do: {:msg, {:show_toast, :warning}} - def event_to_msg(%Event.Key{key: "4"}, _state), do: {:msg, {:show_toast, :error}} - def event_to_msg(%Event.Key{key: "5"}, _state), do: {:msg, :show_multiple} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["p", "P"], do: {:msg, :cycle_position} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["c", "C"], do: {:msg, :clear_toasts} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit} - - # Tick event for auto-dismiss - def event_to_msg(%Event.Tick{}, _state), do: {:msg, :tick} - - def event_to_msg(_event, _state), do: :ignore - - @doc """ - Update state based on messages. - """ - def update({:show_toast, type}, state) do - message = get_message_for_type(type) - manager = ToastManager.add_toast(state.toast_manager, message, type) - - {%{state | - toast_manager: manager, - toast_count: state.toast_count + 1, - last_action: "Showed #{type} toast" - }, []} - end - - def update(:show_multiple, state) do - # Add multiple toasts to demonstrate stacking - manager = state.toast_manager - manager = ToastManager.add_toast(manager, "First notification", :info) - manager = ToastManager.add_toast(manager, "Second notification", :success) - manager = ToastManager.add_toast(manager, "Third notification", :warning) - - {%{state | - toast_manager: manager, - toast_count: state.toast_count + 3, - last_action: "Showed 3 stacked toasts" - }, []} - end - - def update(:cycle_position, state) do - new_index = rem(state.position_index + 1, length(@positions)) - new_position = Enum.at(@positions, new_index) - - # Update manager position - manager = %{state.toast_manager | position: new_position} - - {%{state | - toast_manager: manager, - current_position: new_position, - position_index: new_index, - last_action: "Changed position to #{@position_names[new_position]}" - }, []} - end - - def update(:clear_toasts, state) do - manager = ToastManager.clear_all(state.toast_manager) - - {%{state | - toast_manager: manager, - last_action: "Cleared all toasts" - }, []} - end - - def update(:tick, state) do - # Update toast manager to remove expired toasts - manager = ToastManager.tick(state.toast_manager) - {%{state | toast_manager: manager}, []} - end - - def update(:quit, state) do - {state, [:quit]} - end - - @doc """ - Render the current state to a render tree. - """ - def view(state) do - stack(:vertical, [ - render_main_content(state), - ToastManager.render(state.toast_manager, %{width: 80, height: 24, x: 0, y: 0}) - ]) - end - - # ---------------------------------------------------------------------------- - # Private Helpers - # ---------------------------------------------------------------------------- - - defp get_message_for_type(:info), do: "This is an informational message" - defp get_message_for_type(:success), do: "Operation completed successfully!" - defp get_message_for_type(:warning), do: "Warning: Please review this action" - defp get_message_for_type(:error), do: "Error: Something went wrong" - - defp render_main_content(state) do - stack(:vertical, [ - # Title - text("Toast Widget Example", Style.new(fg: :cyan, attrs: [:bold])), - text("", nil), - - # Instructions - text("Press a number key to show different toast types:", nil), - text("", nil), - text(" 1 - Info Toast (ℹ blue)", nil), - text(" 2 - Success Toast (✓ green)", nil), - text(" 3 - Warning Toast (⚠ yellow)", nil), - text(" 4 - Error Toast (✗ red)", nil), - text(" 5 - Multiple Toasts (stacking demo)", nil), - text("", nil), - - # Controls - render_controls(state) - ]) - end - - defp render_controls(state) do - box_width = 55 - inner_width = box_width - 2 - - top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐" - bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘" - - position_name = @position_names[state.current_position] - active_toasts = ToastManager.toast_count(state.toast_manager) - - stack(:vertical, [ - text("", nil), - text(top_border, Style.new(fg: :yellow)), - text("│" <> String.pad_trailing(" 1-5 Show toast(s)", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" P Cycle position", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" C Clear all toasts", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil), - text("│" <> String.pad_trailing("", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Position: #{position_name}", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Active toasts: #{active_toasts}", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Total shown: #{state.toast_count}", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Last action: #{state.last_action || "(none)"}", inner_width) <> "│", nil), - text(bottom_border, Style.new(fg: :yellow)), - text("", nil), - text("Toasts auto-dismiss after 3 seconds. Click or Escape to dismiss early.", Style.new(fg: :white, attrs: [:dim])) - ]) - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Run the toast example application. - """ - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/toast/lib/toast/application.ex b/examples/toast/lib/toast/application.ex deleted file mode 100644 index 5c525826..00000000 --- a/examples/toast/lib/toast/application.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule Toast.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [] - opts = [strategy: :one_for_one, name: Toast.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/examples/toast/mix.exs b/examples/toast/mix.exs deleted file mode 100644 index 024539bc..00000000 --- a/examples/toast/mix.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule Toast.MixProject do - use Mix.Project - - def project do - [ - app: :toast, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {Toast.Application, []} - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/toast/mix.lock b/examples/toast/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/toast/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/toast/run.exs b/examples/toast/run.exs deleted file mode 100644 index 4a34038b..00000000 --- a/examples/toast/run.exs +++ /dev/null @@ -1 +0,0 @@ -Toast.App.run() diff --git a/examples/tree_view/README.md b/examples/tree_view/README.md deleted file mode 100644 index 11a5f9eb..00000000 --- a/examples/tree_view/README.md +++ /dev/null @@ -1,275 +0,0 @@ -# TreeView Widget Example - -This example demonstrates how to use the `TermUI.Widgets.TreeView` widget for displaying hierarchical data with expand/collapse functionality. - -## Features Demonstrated - -- Hierarchical tree structure with indentation -- Expand/collapse nodes with keyboard -- Single and multi-selection modes -- Custom node icons -- Search/filter with path highlighting -- Lazy loading simulation -- Keyboard navigation (arrows, Home/End, Page Up/Down) - -## Installation - -```bash -cd examples/tree_view -mix deps.get -``` - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/tree_view -mix termui.run -``` - -Or manually: - -```bash -cd examples/tree_view -mix run -e "TreeView.App.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/tree_view -iex -S mix -``` - -Then in IEx: - -```elixir -TreeView.App.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -| Key | Action | -|-----|--------| -| ↑/↓ | Navigate between visible nodes | -| ← | Collapse node or move to parent | -| → | Expand node or move to first child | -| Enter/Space | Toggle expand or select | -| Home/End | Jump to first/last node | -| Page Up/Down | Jump by 10 nodes | -| / | Start search filter | -| Escape | Clear filter or selection | -| Backspace | Delete character from filter | -| M | Toggle multi-select mode | -| E | Expand all nodes | -| C | Collapse all nodes | -| L | Load lazy node children (for nodes with 📦) | -| Q | Quit | - -## Code Overview - -### Creating Tree Nodes - -```elixir -alias TermUI.Widgets.TreeView - -# Leaf node (no children) -TreeView.leaf(:id, "Label", icon: "📄") - -# Branch node with children -TreeView.branch(:parent, "Parent", [ - TreeView.leaf(:child1, "Child 1"), - TreeView.leaf(:child2, "Child 2") -], icon: "📁") - -# Lazy-loading node (children loaded on demand) -TreeView.lazy(:deps, "Dependencies", icon: "📦") -``` - -### Creating a TreeView - -```elixir -props = TreeView.new( - nodes: [ - TreeView.branch(:root, "Root", [ - TreeView.branch(:folder1, "Folder 1", [ - TreeView.leaf(:file1, "file1.txt", icon: "📄"), - TreeView.leaf(:file2, "file2.txt", icon: "📄") - ], icon: "📁"), - TreeView.lazy(:deps, "Dependencies", icon: "📦") - ], icon: "📁") - ], - selection_mode: :single, # :single, :multi, or :none - initially_expanded: [:root], # Node IDs to expand initially - on_select: fn node -> IO.puts("Selected: #{node.label}") end, - on_expand: fn node -> load_children(node) end -) - -{:ok, state} = TreeView.init(props) -``` - -### Widget Options - -```elixir -TreeView.new( - nodes: [], # List of root nodes (required) - selection_mode: :single, # :single, :multi, :none - show_root: true, # Show root nodes - indent_size: 2, # Characters per indent level - icons: %{ # Icon configuration - expanded: "▼", - collapsed: "▶", - leaf: " ", - loading: "⟳" - }, - initially_expanded: [], # Node IDs to expand initially - initially_selected: [], # Node IDs to select initially - on_select: fn node -> ... end, # Selection callback - on_expand: fn node -> ... end, # Expand callback - on_collapse: fn node -> ... end # Collapse callback -) -``` - -### Node Structure - -Each node is a map with: - -```elixir -%{ - id: :unique_id, # Unique identifier (required) - label: "Display Name", # Display text (required) - icon: "📄", # Optional icon string - children: [child_nodes], # List of children, :lazy, or nil for leaf - disabled: false, # Whether node is disabled - metadata: %{} # User-defined data -} -``` - -## TreeView API - -```elixir -# Get selected node IDs -selected = TreeView.get_selected(state) # Returns MapSet - -# Get focused node -node = TreeView.get_focused(state) - -# Get expanded node IDs -expanded = TreeView.get_expanded(state) - -# Expand/collapse nodes -state = TreeView.expand(state, node_id) -state = TreeView.collapse(state, node_id) -state = TreeView.expand_all(state) -state = TreeView.collapse_all(state) - -# Selection operations -state = TreeView.set_selected(state, [node_id1, node_id2]) -state = TreeView.clear_selection(state) - -# Filter operations -state = TreeView.set_filter(state, "search term") -state = TreeView.clear_filter(state) - -# Lazy loading -state = TreeView.set_children(state, node_id, [child_nodes]) -state = TreeView.finish_loading(state, node_id) -``` - -## Features - -### Selection Modes - -- **Single**: Select one node at a time (default) -- **Multi**: Select multiple nodes with Space, extend selection with Shift+arrows -- **None**: No selection allowed - -### Search/Filter - -Press `/` to enter filter mode. Type to search node labels: -- Matching nodes are highlighted in yellow -- Non-matching nodes are hidden -- Parent paths to matches are automatically expanded -- Filter text and match count shown at top -- Press Escape to clear filter - -### Lazy Loading - -Nodes with `children: :lazy` show a loading icon (⟳) and can load children on demand: - -```elixir -# Mark node as lazy -TreeView.lazy(:deps, "Dependencies", icon: "📦") - -# In your on_expand callback: -on_expand: fn node -> - if node.children == :lazy do - # Load children asynchronously - children = load_children_from_api(node.id) - send(self(), {:set_children, node.id, children}) - end -end - -# When children are loaded: -state = TreeView.set_children(state, node_id, children) -``` - -### Visual Indicators - -| Indicator | Meaning | -|-----------|---------| -| ► | Collapsed branch | -| ▼ | Expanded branch | -| ● | Cursor + selected | -| ► | Cursor (not selected) | -| ○ | Selected (not at cursor) | -| (space) | Normal node | -| (yellow) | Filter match | -| (dimmed) | Disabled node | - -### Keyboard Navigation - -The TreeView supports efficient keyboard navigation: - -- **Arrow keys**: Navigate through visible nodes -- **Left/Right**: Smart navigation (collapse/expand or move to parent/child) -- **Home/End**: Jump to boundaries -- **Page Up/Down**: Fast scrolling -- **Enter/Space**: Context-aware action (expand/collapse or select) - -### Multi-Selection - -In multi-select mode: -- **Space**: Toggle individual node selection -- **Shift+Up/Down**: Extend selection range -- **Ctrl+A**: Select all nodes -- **Escape**: Clear selection - -## Example Structure - -The example creates a simulated file browser with: - -- **my_project** (root folder) - - **src** (source code) - - **lib** (library code with .ex files) - - **test** (test files with .exs files) - - **docs** (documentation with .md files) - - **deps** (lazy-loaded dependencies) - - **config** (configuration files) - - Various project files (.gitignore, mix.exs, etc.) - -Each file type has a custom icon (📄, 🧪, 📝, ⚙️, 📦, etc.) to demonstrate icon support. - -## Widget API - -See `lib/term_ui/widgets/tree_view.ex` for the full API documentation. diff --git a/examples/tree_view/lib/tree_view/app.ex b/examples/tree_view/lib/tree_view/app.ex deleted file mode 100644 index 083e8125..00000000 --- a/examples/tree_view/lib/tree_view/app.ex +++ /dev/null @@ -1,274 +0,0 @@ -defmodule TreeView.App do - @moduledoc """ - TreeView Widget Example - - This example demonstrates how to use the TermUI.Widgets.TreeView widget - for displaying hierarchical data with expand/collapse functionality. - - Features demonstrated: - - Hierarchical tree structure with indentation - - Expand/collapse with keyboard - - Single and multi-selection modes - - Custom node icons - - Search/filter with path highlighting - - Lazy loading simulation - - Controls: - - Up/Down: Navigate between nodes - - Left: Collapse node or move to parent - - Right: Expand node or move to first child - - Enter/Space: Toggle expand or select - - Home/End: Jump to first/last node - - /: Start search filter - - Escape: Clear filter or selection - - M: Toggle multi-select mode - - E: Expand all - - C: Collapse all - - L: Load lazy node children - - Q: Quit the application - """ - - use TermUI.Elm - - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Widgets.TreeView, as: TV - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- - - @doc """ - Initialize the component state. - """ - def init(_opts) do - %{ - tree_state: nil, - selection_mode: :single, - status_message: "Navigate with arrows, Enter to expand/select" - } - end - - defp build_tree_state(selection_mode) do - nodes = build_file_tree() - - props = TV.new( - nodes: nodes, - selection_mode: selection_mode, - initially_expanded: [:root, :src], - icons: %{ - expanded: "▼", - collapsed: "▶", - leaf: " ", - loading: "⟳" - } - ) - - {:ok, tree_state} = TV.init(props) - tree_state - end - - defp build_file_tree do - [ - TV.branch(:root, "my_project", [ - TV.branch(:src, "src", [ - TV.branch(:lib, "lib", [ - TV.leaf(:main, "main.ex", icon: "📄"), - TV.leaf(:utils, "utils.ex", icon: "📄"), - TV.leaf(:config, "config.ex", icon: "📄") - ]), - TV.branch(:test, "test", [ - TV.leaf(:main_test, "main_test.exs", icon: "🧪"), - TV.leaf(:utils_test, "utils_test.exs", icon: "🧪") - ]) - ]), - TV.branch(:docs, "docs", [ - TV.leaf(:readme, "README.md", icon: "📝"), - TV.leaf(:changelog, "CHANGELOG.md", icon: "📝"), - TV.leaf(:license, "LICENSE", icon: "📋") - ]), - TV.lazy(:deps, "deps (lazy)", icon: "📦"), - TV.branch(:config_dir, "config", [ - TV.leaf(:config_exs, "config.exs", icon: "⚙️"), - TV.leaf(:dev_exs, "dev.exs", icon: "⚙️"), - TV.leaf(:prod_exs, "prod.exs", icon: "⚙️") - ]), - TV.leaf(:mix_exs, "mix.exs", icon: "📄"), - TV.leaf(:mix_lock, "mix.lock", icon: "🔒"), - TV.leaf(:gitignore, ".gitignore", icon: "🚫") - ], icon: "📁") - ] - end - - @doc """ - Convert keyboard events to messages. - """ - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["m", "M"], do: {:msg, :toggle_mode} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["e", "E"], do: {:msg, :expand_all} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["c", "C"], do: {:msg, :collapse_all} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["l", "L"], do: {:msg, :load_lazy} - def event_to_msg(event, _state) do - # Forward other events to tree - {:msg, {:tree_event, event}} - end - - @doc """ - Update state based on messages. - """ - def update(:quit, state) do - {state, [:quit]} - end - - def update(:toggle_mode, state) do - new_mode = if state.selection_mode == :single, do: :multi, else: :single - tree_state = build_tree_state(new_mode) - message = "Selection mode: #{new_mode}" - {%{state | selection_mode: new_mode, tree_state: tree_state, status_message: message}, []} - end - - def update(:expand_all, state) do - tree_state = ensure_tree_state(state) - tree_state = TV.expand_all(tree_state) - {%{state | tree_state: tree_state, status_message: "Expanded all nodes"}, []} - end - - def update(:collapse_all, state) do - tree_state = ensure_tree_state(state) - tree_state = TV.collapse_all(tree_state) - {%{state | tree_state: tree_state, status_message: "Collapsed all nodes"}, []} - end - - def update(:load_lazy, state) do - tree_state = ensure_tree_state(state) - focused = TV.get_focused(tree_state) - - if focused && focused.children == :lazy do - # Simulate loading children - children = [ - TV.leaf(:dep1, "jason", icon: "📦"), - TV.leaf(:dep2, "plug", icon: "📦"), - TV.leaf(:dep3, "ecto", icon: "📦"), - TV.leaf(:dep4, "phoenix", icon: "📦") - ] - tree_state = TV.set_children(tree_state, focused.id, children) - {%{state | tree_state: tree_state, status_message: "Loaded children for #{focused.label}"}, []} - else - {%{state | status_message: "Focus a lazy node (📦) and press L to load"}, []} - end - end - - def update({:tree_event, event}, state) do - tree_state = ensure_tree_state(state) - {:ok, tree_state} = TV.handle_event(event, tree_state) - - # Update status based on state - message = get_status_message(tree_state) - {%{state | tree_state: tree_state, status_message: message}, []} - end - - defp ensure_tree_state(state) do - state.tree_state || build_tree_state(state.selection_mode) - end - - defp get_status_message(tree_state) do - focused = TV.get_focused(tree_state) - selected = TV.get_selected(tree_state) - filter = tree_state.filter - - cond do - filter != nil -> - "Filter: #{filter} (#{MapSet.size(tree_state.filter_matches)} matches)" - - MapSet.size(selected) > 0 -> - "Selected: #{MapSet.size(selected)} node(s)" - - focused -> - "Focused: #{focused.label}" - - true -> - "Navigate with arrows" - end - end - - @doc """ - Render the current state to a render tree. - """ - def view(state) do - tree_state = ensure_tree_state(state) - - stack(:vertical, [ - # Title - text("TreeView Widget Example", Style.new(fg: :cyan, attrs: [:bold])), - text("", nil), - - # Tree view - render_tree_container(tree_state), - - # Status - text("", nil), - text(state.status_message, Style.new(fg: :yellow)), - - # Controls - render_controls(state) - ]) - end - - defp render_tree_container(tree_state) do - # Render the tree - tree_render = TV.render(tree_state, %{x: 0, y: 0, width: 60, height: 20}) - - box_width = 62 - inner_width = box_width - 2 - - top_border = "┌─ File Browser " <> String.duplicate("─", inner_width - 16) <> "┐" - bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘" - - stack(:vertical, [ - text(top_border, Style.new(fg: :blue)), - stack(:horizontal, [ - text("│ ", nil), - tree_render, - text(" │", nil) - ]), - text(bottom_border, Style.new(fg: :blue)) - ]) - end - - defp render_controls(state) do - box_width = 50 - inner_width = box_width - 2 - - mode_str = if state.selection_mode == :single, do: "single", else: "multi" - - top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐" - bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘" - - stack(:vertical, [ - text("", nil), - text(top_border, Style.new(fg: :yellow)), - text("│" <> String.pad_trailing(" ↑/↓ Navigate", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" ←/→ Collapse/Expand", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Enter Toggle expand/select", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Home/End First/Last node", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" / Start search filter", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Escape Clear filter/selection", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" M Toggle mode (#{mode_str})", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" E/C Expand/Collapse all", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" L Load lazy node", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil), - text(bottom_border, Style.new(fg: :yellow)) - ]) - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Run the tree view example application. - """ - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/tree_view/lib/tree_view/application.ex b/examples/tree_view/lib/tree_view/application.ex deleted file mode 100644 index e57a031f..00000000 --- a/examples/tree_view/lib/tree_view/application.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule TreeView.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [] - opts = [strategy: :one_for_one, name: TreeView.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/examples/tree_view/mix.exs b/examples/tree_view/mix.exs deleted file mode 100644 index 677b9cc4..00000000 --- a/examples/tree_view/mix.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule TreeView.MixProject do - use Mix.Project - - def project do - [ - app: :tree_view, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {TreeView.Application, []} - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/tree_view/mix.lock b/examples/tree_view/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/tree_view/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/tree_view/run.exs b/examples/tree_view/run.exs deleted file mode 100644 index 74a621f7..00000000 --- a/examples/tree_view/run.exs +++ /dev/null @@ -1 +0,0 @@ -TreeView.App.run() diff --git a/examples/viewport/README.md b/examples/viewport/README.md deleted file mode 100644 index 5a7abb93..00000000 --- a/examples/viewport/README.md +++ /dev/null @@ -1,157 +0,0 @@ -# Viewport Widget Example - -This example demonstrates how to use the `TermUI.Widgets.Viewport` widget for displaying scrollable content. - -## Features Demonstrated - -- Vertical scrolling through large content -- Scroll position tracking -- Visual scroll bar indicator -- Keyboard navigation (arrows, Page Up/Down, Home/End) - -## Installation - -```bash -cd examples/viewport -mix deps.get -``` - -## Running the Example - -### Raw Mode (Full TUI Experience) - -For the best experience with full terminal control and alternate screen: - -```bash -cd examples/viewport -mix termui.run -``` - -Or manually: - -```bash -cd examples/viewport -mix run -e "Viewport.App.run()" --no-halt -``` - -### TTY Mode (IEx Compatible) - -To run from IEx without taking over the shell: - -```bash -cd examples/viewport -iex -S mix -``` - -Then in IEx: - -```elixir -Viewport.App.run() -``` - -**Note:** TTY mode works inside IEx but has limitations: -- No alternate screen buffer (output mixes with IEx prompt) -- Character input works immediately (no Enter needed) -- For full TUI, use raw mode instead - -## Controls - -| Key | Action | -|-----|--------| -| ↑/↓ | Scroll one line | -| Page Up/Down | Scroll by 5 lines | -| Home/End | Jump to top/bottom | -| Q | Quit | - -## Code Overview - -### Creating a Viewport - -```elixir -Viewport.new( - content: large_content_tree(), - width: 40, - height: 20, - content_width: 100, # Total content width - content_height: 200, # Total content height - scroll_bars: :both # :none, :vertical, :horizontal, :both -) -``` - -### Viewport Options - -```elixir -Viewport.new( - content: render_node, # Content to display - content_width: 100, # Total content width - content_height: 200, # Total content height - width: 40, # Viewport width - height: 20, # Viewport height - scroll_x: 0, # Initial horizontal scroll - scroll_y: 0, # Initial vertical scroll - scroll_bars: :both, # Scroll bar display - scroll_step: 1, # Lines per scroll step - page_step: 20, # Lines per page scroll - on_scroll: fn x, y -> ... end # Scroll callback -) -``` - -### Scroll Bar Options - -| Value | Description | -|-------|-------------| -| `:none` | No scroll bars | -| `:vertical` | Vertical scroll bar only | -| `:horizontal` | Horizontal scroll bar only | -| `:both` | Both scroll bars | - -### Viewport API - -```elixir -# Get scroll position -{x, y} = Viewport.get_scroll(state) - -# Set scroll position -state = Viewport.set_scroll(state, 0, 50) - -# Scroll to make position visible -state = Viewport.scroll_into_view(state, 100, 150) - -# Update content -state = Viewport.set_content(state, new_content) - -# Update content dimensions -state = Viewport.set_content_size(state, 200, 500) - -# Check if scrollable -Viewport.can_scroll_vertical?(state) -Viewport.can_scroll_horizontal?(state) - -# Get visible fraction (for scroll bar thumb size) -Viewport.visible_fraction_vertical(state) # 0.0 - 1.0 -Viewport.visible_fraction_horizontal(state) # 0.0 - 1.0 -``` - -### Keyboard Navigation - -The Viewport widget handles these keys automatically: - -| Key | Action | -|-----|--------| -| ↑/↓ | Scroll by scroll_step | -| ←/→ | Horizontal scroll | -| Page Up/Down | Scroll by page_step | -| Home | Scroll to top | -| End | Scroll to bottom | -| Ctrl+Home | Scroll to top-left | -| Ctrl+End | Scroll to bottom-right | - -### Mouse Support - -- Mouse wheel: Scroll vertically -- Click scroll bar track: Page scroll -- Drag scroll bar thumb: Direct scroll - -## Widget API - -See `lib/term_ui/widgets/viewport.ex` for the full API documentation. diff --git a/examples/viewport/lib/viewport/app.ex b/examples/viewport/lib/viewport/app.ex deleted file mode 100644 index f58cb2cd..00000000 --- a/examples/viewport/lib/viewport/app.ex +++ /dev/null @@ -1,208 +0,0 @@ -defmodule Viewport.App do - @moduledoc """ - Viewport Widget Example - - This example demonstrates how to use the TermUI.Widgets.Viewport widget - for displaying scrollable content larger than the view area. - - Features demonstrated: - - Vertical scrolling through large content - - Scroll position tracking - - Visual scroll position indicator - - Keyboard navigation - - Note: The actual Viewport widget is a StatefulComponent with scroll bar - rendering. This example shows the scrolling concept with simpler rendering. - - Controls: - - Up/Down: Scroll by one line - - Page Up/Down: Scroll by page (5 lines) - - Home/End: Jump to top/bottom - - Q: Quit the application - """ - - use TermUI.Elm - - alias TermUI.Event - alias TermUI.Renderer.Style - - # Content configuration - @content_height 50 - @viewport_height 10 - - # ---------------------------------------------------------------------------- - # Component Callbacks - # ---------------------------------------------------------------------------- - - @doc """ - Initialize the component state. - """ - def init(_opts) do - %{ - scroll_y: 0, - content: generate_content() - } - end - - defp generate_content do - # Generate 50 lines of content - for i <- 1..@content_height do - line_content = - case rem(i, 10) do - 0 -> "────────── Section #{div(i, 10)} ──────────" - _ -> "Line #{String.pad_leading(to_string(i), 2, "0")}: Lorem ipsum dolor sit amet" - end - - {i, line_content} - end - end - - @doc """ - Convert keyboard events to messages. - """ - def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, {:scroll, -1}} - def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, {:scroll, 1}} - def event_to_msg(%Event.Key{key: :page_up}, _state), do: {:msg, {:scroll, -5}} - def event_to_msg(%Event.Key{key: :page_down}, _state), do: {:msg, {:scroll, 5}} - def event_to_msg(%Event.Key{key: :home}, _state), do: {:msg, :scroll_top} - def event_to_msg(%Event.Key{key: :end}, _state), do: {:msg, :scroll_bottom} - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit} - def event_to_msg(_event, _state), do: :ignore - - @doc """ - Update state based on messages. - """ - def update({:scroll, delta}, state) do - max_scroll = @content_height - @viewport_height - new_scroll = max(0, min(max_scroll, state.scroll_y + delta)) - {%{state | scroll_y: new_scroll}, []} - end - - def update(:scroll_top, state) do - {%{state | scroll_y: 0}, []} - end - - def update(:scroll_bottom, state) do - max_scroll = @content_height - @viewport_height - {%{state | scroll_y: max_scroll}, []} - end - - def update(:quit, state) do - {state, [:quit]} - end - - @doc """ - Render the current state to a render tree. - """ - def view(state) do - stack(:vertical, [ - # Title - text("Viewport Widget Example", Style.new(fg: :cyan, attrs: [:bold])), - text("", nil), - - # Content area with scroll bar - render_viewport_area(state), - text("", nil), - - # Scroll position info - text("", nil), - - # Controls - render_controls(state) - ]) - end - - defp render_controls(state) do - box_width = 56 - inner_width = box_width - 2 - - top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐" - bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘" - - stack(:vertical, [ - text("", nil), - text(top_border, Style.new(fg: :yellow)), - text("│" <> String.pad_trailing(" ↑/↓ Scroll one line", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Page Up/Down Scroll by 5", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Home/End Jump to top/bottom", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil), - text("│" <> String.pad_trailing("", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Scroll: #{state.scroll_y}/#{@content_height - @viewport_height}", inner_width) <> "│", nil), - text("│" <> String.pad_trailing(" Showing lines #{state.scroll_y + 1}-#{state.scroll_y + @viewport_height} of #{@content_height}", inner_width) <> "│", nil), - text(bottom_border, Style.new(fg: :yellow)) - ]) - end - - # ---------------------------------------------------------------------------- - # Private Helpers - # ---------------------------------------------------------------------------- - - defp render_viewport_area(state) do - # Get visible content lines - visible_lines = - state.content - |> Enum.slice(state.scroll_y, @viewport_height) - - # Render content lines - content_rows = - Enum.map(visible_lines, fn {_line_num, content} -> - # Truncate to fit viewport width - truncated = String.slice(content, 0, 50) - padded = String.pad_trailing(truncated, 50) - text("│ " <> padded <> " ", nil) - end) - - # Render scroll bar - scroll_bar = render_scroll_bar(state) - - # Combine content and scroll bar - rows_with_bar = - Enum.zip(content_rows, scroll_bar) - |> Enum.map(fn {content_row, bar_char} -> - stack(:horizontal, [content_row, text(bar_char, nil), text("│", nil)]) - end) - - # Add top and bottom borders - top_border = text("┌" <> String.duplicate("─", 52) <> "┬─┐", nil) - bottom_border = text("└" <> String.duplicate("─", 52) <> "┴─┘", nil) - - stack(:vertical, [top_border | rows_with_bar] ++ [bottom_border]) - end - - defp render_scroll_bar(state) do - max_scroll = @content_height - @viewport_height - - # Calculate thumb position and size - visible_fraction = @viewport_height / @content_height - thumb_size = max(1, round(@viewport_height * visible_fraction)) - - scroll_fraction = - if max_scroll > 0 do - state.scroll_y / max_scroll - else - 0.0 - end - - thumb_pos = round((@viewport_height - thumb_size) * scroll_fraction) - - # Build scroll bar characters - for i <- 0..(@viewport_height - 1) do - if i >= thumb_pos and i < thumb_pos + thumb_size do - "█" - else - "░" - end - end - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Run the viewport example application. - """ - def run do - TermUI.Runtime.run(root: __MODULE__) - end -end diff --git a/examples/viewport/lib/viewport/application.ex b/examples/viewport/lib/viewport/application.ex deleted file mode 100644 index 92746787..00000000 --- a/examples/viewport/lib/viewport/application.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule Viewport.Application do - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - children = [] - opts = [strategy: :one_for_one, name: Viewport.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/examples/viewport/mix.exs b/examples/viewport/mix.exs deleted file mode 100644 index 11982597..00000000 --- a/examples/viewport/mix.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule Viewport.MixProject do - use Mix.Project - - def project do - [ - app: :viewport, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {Viewport.Application, []} - ] - end - - defp deps do - [ - {:term_ui, path: "../.."} - ] - end -end diff --git a/examples/viewport/mix.lock b/examples/viewport/mix.lock deleted file mode 100644 index ee8761c0..00000000 --- a/examples/viewport/mix.lock +++ /dev/null @@ -1,12 +0,0 @@ -%{ - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, -} diff --git a/examples/viewport/run.exs b/examples/viewport/run.exs deleted file mode 100644 index c8f8cd4d..00000000 --- a/examples/viewport/run.exs +++ /dev/null @@ -1 +0,0 @@ -Viewport.App.run() diff --git a/guides/api_reference.md b/guides/api_reference.md deleted file mode 100644 index aaf7703f..00000000 --- a/guides/api_reference.md +++ /dev/null @@ -1,355 +0,0 @@ -# Phase 3 API Reference - -Quick reference for the TermUI component system API. - -## Component Behaviours - -### TermUI.Component - -Base behaviour for stateless components. - -```elixir -use TermUI.Component - -# Required -@callback render(props :: map(), area :: rect()) :: render_tree() - -# Optional -@callback describe() :: component_info() -@callback default_props() :: map() -``` - -### TermUI.StatefulComponent - -Behaviour for stateful, interactive components. - -```elixir -use TermUI.StatefulComponent - -# Required -@callback init(props :: map()) :: {:ok, state()} -@callback handle_event(event :: term(), state()) :: event_result() -@callback render(state(), area :: rect()) :: render_tree() - -# Optional -@callback mount(state()) :: {:ok, state()} | {:ok, state(), [command()]} -@callback unmount(state()) :: :ok -@callback handle_info(msg :: term(), state()) :: {:noreply, state()} -@callback handle_call(request :: term(), from :: GenServer.from(), state()) :: {:reply, reply, state()} -@callback terminate(reason :: term(), state()) :: term() -``` - -### TermUI.Container - -Behaviour for components that manage children. - -```elixir -use TermUI.Container - -# Required (in addition to StatefulComponent) -@callback children(state()) :: [child_spec()] -@callback layout(children :: [component_ref()], area :: rect(), state()) :: [{component_ref(), rect()}] - -# Optional -@callback handle_child_message(child_id :: term(), msg :: term(), state()) :: {:ok, state()} -@callback route_event(event :: term(), state()) :: {:route, component_id()} | :self -``` - -## ComponentServer - -Manages individual component lifecycle. - -```elixir -# Start and mount -{:ok, pid} = ComponentSupervisor.start_component(Module, props, id: :id) -:ok = ComponentServer.mount(pid) - -# Query state -state = ComponentServer.get_state(pid) - -# Send events -:ok = ComponentServer.send_event(pid, event) - -# Update props -:ok = ComponentServer.update_props(pid, new_props) - -# Request render -render_tree = ComponentServer.render(pid, area) -``` - -## ComponentSupervisor - -Supervises all component processes. - -```elixir -# Start component -{:ok, pid} = ComponentSupervisor.start_component(Module, props, opts) - -# Options -opts = [ - id: :component_id, # Required - restart: :transient, # :transient | :permanent | :temporary - recovery: :reset, # :reset | :last_state | :last_props - max_restarts: 3, # Restart limit - max_seconds: 5 # Time window for restarts -] - -# Stop component -:ok = ComponentSupervisor.stop_component(:id) -:ok = ComponentSupervisor.stop_component(:id, cascade: true) - -# Query -count = ComponentSupervisor.count_children() -tree = ComponentSupervisor.get_tree() -{:ok, info} = ComponentSupervisor.get_component_info(:id) -tree_text = ComponentSupervisor.format_tree() -``` - -## ComponentRegistry - -Tracks components and their relationships. - -```elixir -# Lookup -{:ok, pid} = ComponentRegistry.lookup(:id) - -# Relationships -:ok = ComponentRegistry.set_parent(:child, :parent) -{:ok, parent_id} = ComponentRegistry.get_parent(:id) -children = ComponentRegistry.get_children(:id) - -# All components -components = ComponentRegistry.list_all() -``` - -## EventRouter - -Routes events to components. - -```elixir -# Route event to appropriate target -:handled | :unhandled = EventRouter.route(event) - -# Route to specific component -:handled | :unhandled = EventRouter.route_to(:id, event) - -# Broadcast to all -{:ok, count} = EventRouter.broadcast(event) - -# Focus management -:ok = EventRouter.set_focus(:id) -{:ok, id} = EventRouter.get_focus() -:ok = EventRouter.clear_focus() - -# Fallback handler -:ok = EventRouter.set_fallback_handler(fn event -> :ok end) -:ok = EventRouter.clear_fallback_handler() -``` - -## FocusManager - -Manages focus state and traversal. - -```elixir -# Current focus -{:ok, id | nil} = FocusManager.get_focused() -:ok = FocusManager.set_focused(:id) -:ok = FocusManager.clear_focus() - -# Traversal -:ok = FocusManager.focus_next() -:ok = FocusManager.focus_prev() - -# Focus stack (for modals) -:ok = FocusManager.push_focus(:modal_component) -:ok = FocusManager.pop_focus() - -# Focus groups and trapping -:ok = FocusManager.register_group(:group, [:id1, :id2, :id3]) -:ok = FocusManager.trap_focus(:group) -:ok = FocusManager.release_focus() -:ok = FocusManager.unregister_group(:group) -``` - -## SpatialIndex - -Maps screen positions to components for mouse routing. - -```elixir -# Register bounds -:ok = SpatialIndex.update(:id, pid, %{x: 0, y: 0, width: 20, height: 5}) -:ok = SpatialIndex.update(:id, pid, bounds, z_index: 100) - -# Query -{:ok, {id, pid}} = SpatialIndex.find_at(x, y) -{:error, :not_found} = SpatialIndex.find_at(x, y) - -# Remove -:ok = SpatialIndex.remove(:id) -``` - -## Event Types - -```elixir -# Keyboard -%TermUI.Event.Key{ - key: :enter | :tab | :up | :down | :left | :right | :backspace | :delete | :escape | :home | :end | :page_up | :page_down | :f1..f12 | atom(), - char: String.t() | nil, - modifiers: [:ctrl | :alt | :shift], - timestamp: integer() -} - -# Mouse -%TermUI.Event.Mouse{ - action: :click | :release | :move | :scroll_up | :scroll_down, - button: :left | :right | :middle | nil, - x: integer(), - y: integer(), - modifiers: [:ctrl | :alt | :shift], - timestamp: integer() -} - -# Focus -%TermUI.Event.Focus{ - type: :gained | :lost -} - -# Custom -%TermUI.Event.Custom{ - name: atom(), - payload: term() -} -``` - -## StatePersistence - -Persists state for crash recovery. - -```elixir -# Manual persistence -:ok = StatePersistence.persist(:id, state) - -# Recovery -{:ok, state} = StatePersistence.recover(:id, :last_state) -:not_found = StatePersistence.recover(:id, :reset) - -# Restart tracking -count = StatePersistence.get_restart_count(:id) -:ok = StatePersistence.increment_restart_count(:id) -``` - -## Essential Widgets - -### Block - -Container with border and title. - -```elixir -%{ - border: :none | :single | :double | :rounded | :thick, - title: String.t() | nil, - title_align: :left | :center | :right, - padding: integer() | %{top: i, bottom: i, left: i, right: i} -} -``` - -### Label - -Text display. - -```elixir -%{ - text: String.t(), - style: Style.t(), - align: :left | :center | :right, - wrap: boolean(), - truncate: boolean() -} -``` - -### Button - -Clickable action trigger. - -```elixir -%{ - label: String.t(), - on_click: (-> any()), - disabled: boolean(), - style: Style.t(), - focus_style: Style.t() -} -``` - -### TextInput - -Single-line text entry. - -```elixir -%{ - value: String.t(), - placeholder: String.t(), - on_change: (String.t() -> any()), - on_submit: (String.t() -> any()), - password: boolean(), - max_length: integer() | nil -} -``` - -### List - -Selectable item list. - -```elixir -%{ - items: [String.t() | {String.t(), term()}], - selected: integer() | [integer()], - on_select: (term() -> any()), - multi_select: boolean(), - highlight_style: Style.t() -} -``` - -### Progress - -Progress indicator. - -```elixir -%{ - value: float(), # 0.0 to 1.0 - mode: :bar | :spinner, - show_percent: boolean(), - bar_char: String.t(), - empty_char: String.t() -} -``` - -## Type Reference - -```elixir -@type rect :: %{x: integer(), y: integer(), width: integer(), height: integer()} -@type render_tree :: RenderNode.t() | [render_tree()] | String.t() -@type child_spec :: {module(), props :: map()} | {module(), props :: map(), id :: term()} -@type event_result :: {:ok, state()} | {:ok, state(), [command()]} | {:stop, reason, state()} -@type command :: {:send, pid(), term()} | {:timer, ms, term()} | {:focus, term()} | term() -``` - -## Running Tests - -```bash -# All Phase 3 tests -mix test test/term_ui/component* test/term_ui/event* test/term_ui/focus* test/term_ui/widget test/term_ui/spatial* test/term_ui/integration/ - -# Integration tests only -mix test test/term_ui/integration/ - -# Specific module -mix test test/term_ui/focus_manager_test.exs -``` - -## Generating Documentation - -```bash -mix docs -open doc/index.html -``` diff --git a/guides/architecture.md b/guides/architecture.md new file mode 100644 index 00000000..982a27a5 --- /dev/null +++ b/guides/architecture.md @@ -0,0 +1,83 @@ +# Architecture + +TermUI has three boundaries. + +## Application + +One runtime process owns one application state. It serializes terminal events, +application messages, command results, render timers, resize, and shutdown. + +The application implements `TermUI.Elm`: + +- `init/1` creates state. +- `event_to_msg/2` converts terminal input to an application message. +- `update/2` creates the next state and command data. +- `view/1` creates one complete `TermUI.Frame`. +- `handle_info/2` can convert an external process message to state and commands. +- `terminate/2` can release application resources. + +The runtime does not store widget or component processes. It does not accept a +render tree, a buffer, or a list of backend cells from an application. + +## Async commands + +`Command.async/2` runs a zero-argument function outside the runtime process. +The function can return any term. The runtime, not the function, creates the +result tag that the mapper receives: a normal return becomes `{:ok, value}`; +a raise, throw, or exit becomes `{:error, reason}`. This creates exactly one +outer result tag. For example, a function return of `{:ok, value}` becomes +`{:ok, {:ok, value}}` for the mapper. + +## Widgets + +A widget is plain state plus `init/1`, `update/2`, and `view/2`. The parent +application stores that state. `view/2` returns a `TermUI.Frame`, which the +parent can place with `TermUI.Frame.overlay/4`. + +The optional `mouse/3` widget callback receives local, zero-based coordinates. +The application creates pure mouse regions from its layout and owns hover, +drag, focus, and text selection state. Clipboard output is command data. The +runtime sends it through the same backend owner that draws frames. + +Widgets that show processes, supervision trees, streams, or cluster nodes only +format snapshots. The parent application owns polling, subscriptions, RPC, and +other effects. + +## Data schemas + +All explicit production structs derive their fields and defaults from Zoi +schemas. This rule prevents differences between a schema, a type, and a +`defstruct` declaration. + +TermUI does not parse every frame or widget state through Zoi. Frame creation, +cell updates, and widget updates are hot paths. Constructors and guards keep +these paths small. `TermUI.Cell`, `TermUI.Style`, `TermUI.Frame`, +`TermUI.Event`, `TermUI.Command`, and `TermUI.Widget.Table.Column` expose a +`schema/0` function when an application needs explicit boundary validation. + +## Frame + +`TermUI.Frame` is a bounded, sparse cell map. Missing cells are blank. It clips +content to its dimensions and records the second column of wide graphemes. A +backend can compare the current frame with its last frame. + +`TermUI.Frame.overlay/4` composes child frames without adding another render +representation. `TermUI.Frame.diff/2` remains the one backend cell comparison. + +## Backend + +A backend owns all terminal state. Setup is transactional. After successful +setup, every stop path calls `shutdown/2`. The backend normalizes input to +`TermUI.Event` values and accepts only `TermUI.Frame` for rendering. + +One backend owner serializes input polling, size checks, drawing, flushing, +resize, and shutdown against one current backend state. Backend state stays +opaque to the runtime. An input failure stops the application after a final +meaningful render. + +## Shutdown + +Shutdown has three states: running, final render pending, and stopping. A +shutdown command or external shutdown request cancels a pending timer, renders +the newest dirty state, stops effect processes, calls the application terminate +callback, and closes the backend owner so that it restores the terminal. diff --git a/guides/backend.md b/guides/backend.md new file mode 100644 index 00000000..2e6fb700 --- /dev/null +++ b/guides/backend.md @@ -0,0 +1,41 @@ +# Backend contract + +A backend implements `TermUI.Backend`. + +```elixir +@callback init(keyword()) :: {:ok, state()} | {:error, term()} +@callback size(state()) :: {:ok, {rows, columns}} | {:error, term()} +@callback capabilities(state()) :: map() +@callback draw(state(), TermUI.Frame.t()) :: {:ok, state()} | {:error, term()} +@callback flush(state()) :: {:ok, state()} | {:error, term()} +@callback clipboard(state(), TermUI.Clipboard.Operation.t()) :: + {:ok, state()} | {:error, term()} +@callback poll_event(state(), non_neg_integer()) :: + {:ok, TermUI.Event.t(), state()} | {:timeout, state()} | {:error, term(), state()} +@callback resize(state(), {rows, columns}) :: {:ok, state()} | {:error, term()} +@callback shutdown(state(), term()) :: :ok +``` + +`clipboard/2` is optional. The runtime returns a structured unsupported error +when a custom backend does not implement it. The callback must return the next +backend state so clipboard output stays in sequence with draw and cleanup. + +The size at the backend boundary is `{rows, columns}`. The runtime converts it +to application dimensions `{columns, rows}`. + +`init/1` must not leave partial terminal state after an error. `shutdown/2` +must be safe during error cleanup. `draw/2` must retain the last successful +frame or equivalent backend state so that a later frame can clear old cells. + +A test backend must avoid real terminal I/O. It can receive frames and return a +fixed size. See `test/support/deterministic_backend.ex`. + +The runtime puts each backend behind one serialized owner. State returned by +input, size, draw, flush, and resize callbacks becomes the state for the next +callback and for final cleanup. + +Size polling uses a 200 ms interval when direct terminal or environment size +checks are available. It uses a 1 second interval when detection must start +`stty`. Set `backend_opts: [size_poll_interval: milliseconds]` to use an +interval of at least 50 ms. Use `:disabled` when the application supplies all +resize events through its backend input stream. diff --git a/guides/component_system.md b/guides/component_system.md deleted file mode 100644 index 80932f61..00000000 --- a/guides/component_system.md +++ /dev/null @@ -1,546 +0,0 @@ -# Component System Guide - -This guide covers how to build TUI applications using TermUI's component system. By the end, you'll understand how to create components, handle events, manage focus, and build hierarchical UIs. - -## Table of Contents - -1. [Core Concepts](#core-concepts) -2. [Creating Components](#creating-components) -3. [Component Lifecycle](#component-lifecycle) -4. [Event Handling](#event-handling) -5. [Focus Management](#focus-management) -6. [Building Hierarchies](#building-hierarchies) -7. [Fault Tolerance](#fault-tolerance) -8. [Best Practices](#best-practices) - -## Core Concepts - -TermUI's component system is built on OTP processes. Each component is a GenServer that: -- Maintains its own state -- Receives events as messages -- Produces render trees -- Is supervised for fault tolerance - -### Component Behaviours - -Three behaviours define component types: - -| Behaviour | Use Case | Key Callbacks | -|-----------|----------|---------------| -| `Component` | Stateless display | `render/2` | -| `StatefulComponent` | Interactive widgets | `init/1`, `handle_event/2`, `render/2` | -| `Container` | Layout with children | All above + `children/1`, `layout/3` | - -## Creating Components - -### Stateless Components - -Use `Component` for display-only widgets: - -```elixir -defmodule MyApp.Divider do - use TermUI.Component - - @impl true - def render(props, area) do - char = props[:char] || "-" - String.duplicate(char, area.width) - end -end -``` - -### Stateful Components - -Use `StatefulComponent` for interactive widgets: - -```elixir -defmodule MyApp.Counter do - use TermUI.StatefulComponent - - @impl true - def init(props) do - {:ok, %{ - count: props[:initial] || 0, - step: props[:step] || 1 - }} - end - - @impl true - def handle_event(%TermUI.Event.Key{key: :up}, state) do - {:ok, %{state | count: state.count + state.step}} - end - - def handle_event(%TermUI.Event.Key{key: :down}, state) do - {:ok, %{state | count: max(0, state.count - state.step)}} - end - - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, _area) do - text("Count: #{state.count}") - end -end -``` - -### Container Components - -Use `Container` to manage children: - -```elixir -defmodule MyApp.Panel do - use TermUI.Container - - @impl true - def init(props) do - {:ok, %{ - title: props[:title], - children: props[:children] || [] - }} - end - - @impl true - def children(state) do - state.children - end - - @impl true - def layout(children, area, _state) do - # Stack children vertically - Enum.with_index(children) - |> Enum.map(fn {child, idx} -> - {child, %{x: area.x, y: area.y + idx, width: area.width, height: 1}} - end) - end - - @impl true - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, area) do - box(border: :single, title: state.title) do - # Children render here - end - end -end -``` - -## Component Lifecycle - -Components go through defined lifecycle stages: - -``` -┌─────────────────────────────────────────┐ -│ init/1 │ -│ └─ Called with props │ -│ Returns {:ok, initial_state} │ -└─────────────┬───────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────┐ -│ mount/1 (optional) │ -│ └─ Component added to tree │ -│ Start timers, fetch data │ -│ Returns {:ok, state} │ -└─────────────┬───────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────┐ -│ handle_event/2 (loop) │ -│ └─ Process user input │ -│ Returns {:ok, new_state} │ -└─────────────┬───────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────┐ -│ unmount/1 (optional) │ -│ └─ Component removed from tree │ -│ Cleanup resources │ -│ Returns :ok │ -└─────────────────────────────────────────┘ -``` - -### Lifecycle Hooks - -Register hooks for lifecycle events: - -```elixir -defmodule MyApp.Widget do - use TermUI.StatefulComponent - - @impl true - def init(props) do - {:ok, %{value: props[:value]}} - end - - # Called after mount completes - @impl true - def mount(state) do - # Start a timer, register handlers, etc. - {:ok, state} - end - - # Called before unmount - @impl true - def unmount(state) do - # Cleanup resources - :ok - end -end -``` - -## Event Handling - -### Event Types - -TermUI supports these event types: - -```elixir -# Keyboard events -%TermUI.Event.Key{ - key: :enter, # Key symbol - char: nil, # Character if printable - modifiers: [:ctrl] # Active modifiers -} - -# Mouse events -%TermUI.Event.Mouse{ - action: :click, # :click, :move, :scroll - button: :left, # :left, :right, :middle - x: 10, y: 5, # Screen coordinates - modifiers: [] -} - -# Focus events -%TermUI.Event.Focus{ - type: :gained # :gained or :lost -} - -# Custom events -%TermUI.Event.Custom{ - name: :my_event, - payload: %{data: "value"} -} -``` - -### Handling Events - -Components receive events via `handle_event/2`: - -```elixir -@impl true -def handle_event(%Event.Key{key: :enter}, state) do - # Handle Enter key - {:ok, %{state | submitted: true}} -end - -def handle_event(%Event.Key{char: char}, state) when char != nil do - # Handle character input - {:ok, %{state | text: state.text <> char}} -end - -def handle_event(%Event.Mouse{action: :click}, state) do - # Handle mouse click - {:ok, %{state | clicked: true}} -end - -def handle_event(_event, state) do - # Ignore other events - {:ok, state} -end -``` - -### Event Routing - -Events are routed automatically: -- **Keyboard events** → Focused component -- **Mouse events** → Component at click position -- **Focus events** → Component gaining/losing focus - -Use `EventRouter` to route events: - -```elixir -# Route to focused component -EventRouter.route(%Event.Key{key: :tab}) - -# Route to specific component -EventRouter.route_to(:my_component, event) - -# Broadcast to all components -EventRouter.broadcast({:resize, 80, 24}) -``` - -## Focus Management - -### Setting Focus - -```elixir -# Set focus to a component -FocusManager.set_focused(:my_input) - -# Get currently focused component -{:ok, focused_id} = FocusManager.get_focused() - -# Clear focus -FocusManager.clear_focus() -``` - -### Tab Navigation - -Focus traversal follows spatial order (left-to-right, top-to-bottom): - -```elixir -# Move to next focusable component -FocusManager.focus_next() - -# Move to previous focusable component -FocusManager.focus_prev() -``` - -### Focus Stack for Modals - -When opening modals, push/pop focus to restore properly: - -```elixir -# Open modal - save current focus -def open_modal(modal_component) do - FocusManager.push_focus(modal_component) -end - -# Close modal - restore previous focus -def close_modal() do - FocusManager.pop_focus() -end -``` - -### Focus Trapping - -Keep Tab within a group (e.g., modal dialog): - -```elixir -# Register a focus group -FocusManager.register_group(:dialog, [:ok_button, :cancel_button, :input]) - -# Trap focus in the group -FocusManager.trap_focus(:dialog) - -# Release trap when modal closes -FocusManager.release_focus() -``` - -## Building Hierarchies - -### Component Registration - -Components must be registered to work with the system: - -```elixir -# Start a component under supervision -{:ok, pid} = ComponentSupervisor.start_component( - MyApp.Counter, - %{initial: 0}, - id: :my_counter -) - -# Mount the component -ComponentServer.mount(pid) - -# Register spatial bounds for mouse events -SpatialIndex.update(:my_counter, pid, %{x: 0, y: 0, width: 20, height: 1}) -``` - -### Parent-Child Relationships - -```elixir -# Set up hierarchy -ComponentRegistry.set_parent(:child_id, :parent_id) - -# Query hierarchy -{:ok, parent} = ComponentRegistry.get_parent(:child_id) -children = ComponentRegistry.get_children(:parent_id) -``` - -### Stopping Components - -```elixir -# Stop single component -ComponentSupervisor.stop_component(:my_counter) - -# Stop with cascade (stops children too) -ComponentSupervisor.stop_component(:parent, cascade: true) -``` - -## Fault Tolerance - -### Restart Strategies - -Components can specify restart behavior: - -```elixir -# Restart on crash (default) -ComponentSupervisor.start_component(Module, props, - id: :id, restart: :transient) - -# Always restart -ComponentSupervisor.start_component(Module, props, - id: :id, restart: :permanent) - -# Never restart -ComponentSupervisor.start_component(Module, props, - id: :id, restart: :temporary) -``` - -### State Recovery - -Persist state for recovery after crash: - -```elixir -ComponentSupervisor.start_component(Module, props, - id: :id, - restart: :transient, - recovery: :last_state # Recover previous state -) -``` - -Recovery options: -- `:reset` - Start fresh (default) -- `:last_state` - Recover previous state -- `:last_props` - Restart with same props - -### Supervision Introspection - -Monitor the component tree: - -```elixir -# Get tree structure -tree = ComponentSupervisor.get_tree() - -# Get component info -{:ok, info} = ComponentSupervisor.get_component_info(:my_counter) -# => %{pid: #PID<...>, restart_count: 0, uptime_ms: 12345, ...} - -# Count children -count = ComponentSupervisor.count_children() - -# Format tree for display -IO.puts(ComponentSupervisor.format_tree()) -``` - -## Best Practices - -### 1. Keep Components Focused - -Each component should do one thing well: - -```elixir -# Good - focused component -defmodule MyApp.EmailInput do - # Only handles email input -end - -# Bad - doing too much -defmodule MyApp.UserForm do - # Handles multiple inputs, validation, submission... -end -``` - -### 2. Initialize Fast - -Defer expensive operations to `mount/1`: - -```elixir -def init(props) do - # Fast - just set up state - {:ok, %{data: nil, loading: true}} -end - -def mount(state) do - # Slow operations here - data = fetch_data() - {:ok, %{state | data: data, loading: false}} -end -``` - -### 3. Handle All Events - -Always have a catch-all clause: - -```elixir -def handle_event(%Event.Key{key: :enter}, state) do - {:ok, handle_submit(state)} -end - -def handle_event(_event, state) do - # Important! Don't crash on unexpected events - {:ok, state} -end -``` - -### 4. Clean Up Resources - -Use `unmount/1` for cleanup: - -```elixir -def mount(state) do - timer_ref = :timer.send_interval(1000, self(), :tick) - {:ok, %{state | timer: timer_ref}} -end - -def unmount(state) do - if state.timer, do: :timer.cancel(state.timer) - :ok -end -``` - -### 5. Use Commands for Side Effects - -Don't perform side effects directly - return commands: - -```elixir -def handle_event(%Event.Key{key: :enter}, state) do - # Don't do this: - # send(parent, {:submitted, state.value}) - - # Do this: - {:ok, state, [{:send, parent, {:submitted, state.value}}]} -end -``` - -### 6. Leverage Supervision - -Structure your app for fault isolation: - -```elixir -# Critical components -ComponentSupervisor.start_component(Core, props, - restart: :permanent) - -# User components that can fail -ComponentSupervisor.start_component(UserWidget, props, - restart: :transient, recovery: :last_state) -``` - -## Essential Widgets Reference - -TermUI provides these built-in widgets: - -| Widget | Purpose | Key Props | -|--------|---------|-----------| -| `Block` | Container with border | `border`, `title`, `padding` | -| `Label` | Text display | `text`, `align`, `wrap` | -| `Button` | Clickable action | `label`, `on_click`, `disabled` | -| `TextInput` | Text entry | `value`, `on_change`, `on_submit` | -| `List` | Selectable items | `items`, `selected`, `on_select` | -| `Progress` | Progress indicator | `value`, `mode`, `show_percent` | - -See individual widget documentation for full details. - -## Next Steps - -- Explore the widget source code in `lib/term_ui/widget/` -- Check integration tests in `test/term_ui/integration/` for examples -- Read module documentation with `mix docs` diff --git a/guides/developer/01-architecture-overview.md b/guides/developer/01-architecture-overview.md deleted file mode 100644 index 8f199c6b..00000000 --- a/guides/developer/01-architecture-overview.md +++ /dev/null @@ -1,327 +0,0 @@ -# Architecture Overview - -This guide provides a high-level view of TermUI's internal architecture for developers contributing to the framework. - -## System Layers - -TermUI is organized into distinct layers, each with clear responsibilities: - -```mermaid -graph TB - subgraph "Application Layer" - App[User Application] - Elm[Elm Components] - end - - subgraph "Framework Layer" - Runtime[Runtime
GenServer] - MQ[MessageQueue] - Cmd[Command Executor] - end - - subgraph "Rendering Layer" - NR[NodeRenderer] - BM[BufferManager
ETS] - Diff[Diff Algorithm] - SB[SequenceBuffer] - end - - subgraph "Terminal Layer" - Term[Terminal
GenServer] - IR[InputReader] - EP[EscapeParser] - end - - subgraph "System" - TTY[/dev/tty] - STDIN[stdin] - STDOUT[stdout] - end - - App --> Elm - Elm --> Runtime - Runtime --> MQ - Runtime --> Cmd - Runtime --> NR - NR --> BM - BM --> Diff - Diff --> SB - SB --> Term - Term --> TTY - Term --> STDOUT - IR --> STDIN - IR --> EP - EP --> Runtime -``` - -## Layer Responsibilities - -### Application Layer - -**User code** that defines the UI behavior: -- Component modules using `use TermUI.Elm` -- State management via init/update/view -- Event handling via event_to_msg - -### Framework Layer - -**Core orchestration** managing the application lifecycle: - -| Module | Responsibility | -|--------|----------------| -| `Runtime` | Event dispatch loop, component lifecycle, render scheduling | -| `MessageQueue` | FIFO queue for component messages | -| `Command` | Side effect execution (timers, I/O) | - -### Rendering Layer - -**Visual output** transforming state to terminal sequences: - -| Module | Responsibility | -|--------|----------------| -| `NodeRenderer` | Traverses render tree, produces cells | -| `BufferManager` | Double-buffered ETS tables | -| `Diff` | Computes minimal update operations | -| `SequenceBuffer` | Batches ANSI escape sequences | - -### Terminal Layer - -**Low-level I/O** interfacing with the terminal: - -| Module | Responsibility | -|--------|----------------| -| `Terminal` | Raw mode, screen control, cursor | -| `InputReader` | Reads stdin in raw mode | -| `EscapeParser` | Converts bytes to Event structs | - -## Key Design Decisions - -### 1. GenServer-Based Runtime - -The Runtime is a GenServer that: -- Serializes event processing -- Manages component state -- Schedules rendering at 60 FPS -- Handles graceful shutdown - -```elixir -# Simplified runtime state -%Runtime.State{ - root_module: MyApp, - root_state: %{...}, - components: %{root: %{module: MyApp, state: %{...}}}, - message_queue: %MessageQueue{}, - dirty: true, - render_interval: 16 -} -``` - -### 2. ETS-Based Buffers - -Screen buffers use ETS for: -- Lock-free concurrent reads -- O(1) cell access -- Atomic batch updates -- Memory efficiency - -```elixir -# Cell storage: {{row, col}, cell} -:ets.insert(buffer.table, {{5, 10}, %Cell{char: "X", fg: :red}}) -``` - -### 3. Differential Rendering - -Only changed cells are sent to the terminal: - -```mermaid -graph LR - A[Current Buffer] --> D{Diff} - B[Previous Buffer] --> D - D --> O[Operations] - O --> S[SequenceBuffer] - S --> T[Terminal] -``` - -### 4. Message-Based Architecture - -All communication uses messages: -- Events → Messages via `event_to_msg/2` -- Commands execute async, return messages -- No direct state mutation - -## Module Dependency Graph - -```mermaid -graph TD - subgraph "Public API" - TUI[TermUI] - Runtime[Runtime] - end - - subgraph "Components" - Elm[Elm] - Component[Component] - Container[Container] - end - - subgraph "Events" - Event[Event] - EventKey[Event.Key] - EventMouse[Event.Mouse] - end - - subgraph "Rendering" - Style[Style] - Cell[Cell] - Buffer[Buffer] - BufferMgr[BufferManager] - Diff[Diff] - NodeRenderer[NodeRenderer] - SeqBuffer[SequenceBuffer] - end - - subgraph "Terminal" - Terminal[Terminal] - InputReader[InputReader] - EscapeParser[EscapeParser] - ANSI[ANSI] - end - - TUI --> Runtime - Runtime --> Elm - Runtime --> BufferMgr - Runtime --> Terminal - Runtime --> InputReader - - Elm --> Component - Elm --> Event - - InputReader --> EscapeParser - EscapeParser --> Event - - NodeRenderer --> Buffer - NodeRenderer --> Cell - NodeRenderer --> Style - - BufferMgr --> Buffer - Buffer --> Cell - - Diff --> Buffer - Diff --> Cell - - SeqBuffer --> Style - SeqBuffer --> ANSI - - Terminal --> ANSI -``` - -## Process Architecture - -At runtime, TermUI spawns these processes: - -```mermaid -graph TB - subgraph "Supervision Tree" - App[Application Supervisor] - Runtime[Runtime GenServer] - Terminal[Terminal GenServer] - BufferMgr[BufferManager GenServer] - InputReader[InputReader Process] - end - - App --> Runtime - App --> Terminal - App --> BufferMgr - Runtime -.->|spawns| InputReader - - InputReader -->|{:input, event}| Runtime - Runtime -->|render| BufferMgr - Runtime -->|escape sequences| Terminal -``` - -## Data Flow - -### Input Path - -```mermaid -sequenceDiagram - participant TTY as Terminal - participant IR as InputReader - participant EP as EscapeParser - participant RT as Runtime - participant Comp as Component - - TTY->>IR: Raw bytes - IR->>EP: Binary data - EP->>RT: Event struct - RT->>Comp: event_to_msg() - Comp->>RT: {:msg, message} - RT->>Comp: update() - Comp->>RT: {new_state, commands} - RT->>RT: Mark dirty -``` - -### Output Path - -```mermaid -sequenceDiagram - participant RT as Runtime - participant Comp as Component - participant NR as NodeRenderer - participant BM as BufferManager - participant Diff as Diff - participant SB as SequenceBuffer - participant Term as Terminal - - RT->>Comp: view() - Comp->>RT: Render tree - RT->>NR: Render to buffer - NR->>BM: Write cells - RT->>BM: Get buffers - BM->>RT: Current, Previous - RT->>Diff: diff() - Diff->>RT: Operations - RT->>SB: Build sequences - SB->>Term: ANSI output -``` - -## File Organization - -``` -lib/term_ui/ -├── term_ui.ex # Public API -├── runtime.ex # Core event loop -├── elm.ex # Elm Architecture macro -├── event.ex # Event types -├── command.ex # Command types -├── message_queue.ex # Message queueing -│ -├── terminal/ -│ ├── terminal.ex # Terminal GenServer -│ ├── input_reader.ex # Stdin reader -│ └── escape_parser.ex # Sequence parser -│ -├── renderer/ -│ ├── style.ex # Style struct -│ ├── cell.ex # Cell struct -│ ├── buffer.ex # Buffer operations -│ ├── buffer_manager.ex # Double buffering -│ ├── diff.ex # Diff algorithm -│ ├── sequence_buffer.ex # ANSI batching -│ └── node_renderer.ex # Tree → cells -│ -├── layout/ -│ ├── constraint.ex # Size constraints -│ └── solver.ex # Constraint solver -│ -└── widgets/ - ├── gauge.ex # Gauge widget - ├── sparkline.ex # Sparkline widget - └── table.ex # Table widget -``` - -## Next Steps - -- [Runtime Internals](02-runtime-internals.md) - Deep dive into the event loop -- [Rendering Pipeline](03-rendering-pipeline.md) - How frames are produced -- [Event System](04-event-system.md) - Input handling details diff --git a/guides/developer/02-runtime-internals.md b/guides/developer/02-runtime-internals.md deleted file mode 100644 index 817e9a70..00000000 --- a/guides/developer/02-runtime-internals.md +++ /dev/null @@ -1,408 +0,0 @@ -# Runtime Internals - -The Runtime (`TermUI.Runtime`) is the central orchestrator of a TermUI application. This guide explains its internal workings. - -## Overview - -The Runtime is a GenServer that: -1. Manages component state -2. Dispatches events to components -3. Processes messages through the update cycle -4. Executes commands -5. Schedules and performs rendering - -## State Structure - -```elixir -%TermUI.Runtime.State{ - # Component configuration - root_module: MyApp.Counter, # Root component module - root_state: %{count: 0}, # Root component state - - # Component registry - components: %{ - root: %{module: MyApp.Counter, state: %{count: 0}} - }, - - # Message processing - message_queue: %MessageQueue{}, # Pending messages - pending_commands: %{}, # Executing commands - - # Rendering - dirty: false, # Needs re-render? - render_interval: 16, # ~60 FPS - buffer_manager: #PID<...>, # BufferManager process - dimensions: {80, 24}, # {cols, rows} - - # Terminal - terminal_started: true, # Terminal available? - input_reader: #PID<...>, # InputReader process - - # Lifecycle - focused_component: :root, # Currently focused - shutting_down: false # Shutdown in progress? -} -``` - -## Lifecycle - -```mermaid -stateDiagram-v2 - [*] --> Initializing: start_link/1 - Initializing --> Running: init complete - Running --> Running: events/messages - Running --> ShuttingDown: shutdown/1 - ShuttingDown --> [*]: terminate/2 -``` - -### Initialization - -```elixir -def init(opts) do - # 1. Trap exits for cleanup - Process.flag(:trap_exit, true) - - # 2. Initialize terminal - {terminal_started, buffer_manager, dimensions} = initialize_terminal() - - # 3. Initialize root component - root_state = root_module.init(opts) - - # 4. Start input reader - {:ok, reader} = InputReader.start_link(target: self()) - - # 5. Schedule first render - schedule_render(render_interval) - - {:ok, state} -end -``` - -### Main Loop - -The Runtime handles these message types: - -```mermaid -graph TD - subgraph "GenServer Callbacks" - CI[handle_cast :event] --> DE[dispatch_event] - CM[handle_cast :message] --> EM[enqueue_message] - CR[handle_cast :shutdown] --> IS[initiate_shutdown] - IR[handle_info :render] --> PR[process_render_tick] - II[handle_info :input] --> DE - end - - DE --> ETM[event_to_msg] - ETM --> EM - EM --> MQ[MessageQueue] - MQ --> PM[process_messages] - PM --> UP[component.update] - UP --> EC[execute_commands] - UP --> MD[mark_dirty] - - PR --> PM - PR --> DR[do_render] - PR --> SR[schedule_render] -``` - -## Event Dispatch - -Events are routed based on type: - -```elixir -defp dispatch_event(%Event.Key{} = event, state) do - # Keyboard → focused component - dispatch_to_component(state.focused_component, event, state) -end - -defp dispatch_event(%Event.Mouse{} = event, state) do - # Mouse → component at position (future: spatial index) - dispatch_to_component(:root, event, state) -end - -defp dispatch_event(%Event.Resize{} = event, state) do - # Resize → broadcast to all - broadcast_event(event, state) -end -``` - -### Component Dispatch - -```elixir -defp dispatch_to_component(component_id, event, state) do - %{module: module, state: component_state} = state.components[component_id] - - case module.event_to_msg(event, component_state) do - {:msg, message} -> - enqueue_message(component_id, message, state) - - :ignore -> - state - - :propagate -> - # Would bubble to parent - state - end -end -``` - -## Message Processing - -Messages are processed in FIFO order: - -```mermaid -sequenceDiagram - participant Q as MessageQueue - participant RT as Runtime - participant C as Component - - RT->>Q: flush() - Q->>RT: [messages] - - loop For each message - RT->>C: update(msg, state) - C->>RT: {new_state, commands} - RT->>RT: Update component state - RT->>RT: Mark dirty if changed - RT->>RT: Collect commands - end - - RT->>RT: execute_commands(all_commands) -``` - -```elixir -defp process_messages(state) do - {messages, queue} = MessageQueue.flush(state.message_queue) - - {state, commands} = - Enum.reduce(messages, {state, []}, fn {component_id, msg}, {acc, cmds} -> - {new_state, new_cmds} = process_message(component_id, msg, acc) - {new_state, cmds ++ new_cmds} - end) - - execute_commands(commands, state) -end -``` - -## Command Execution - -Commands are side effects returned from `update/2`: - -```elixir -defp execute_commands(commands, state) do - # Check for quit command - if has_quit_command?(commands) do - GenServer.cast(self(), :shutdown) - %{state | shutting_down: true} - else - # Track pending commands - pending = Enum.reduce(commands, state.pending_commands, fn cmd, acc -> - command_id = make_ref() - Map.put(acc, command_id, cmd) - end) - - %{state | pending_commands: pending} - end -end -``` - -### Timer Commands - -Timer commands use `Process.send_after/3`: - -```elixir -# When timer fires, result delivered as message -def handle_info({:command_result, component_id, cmd_id, result}, state) do - state = handle_command_result(component_id, cmd_id, result, state) - {:noreply, state} -end -``` - -## Render Cycle - -Rendering is scheduled at a fixed interval (default 16ms ≈ 60 FPS): - -```elixir -defp process_render_tick(state) do - # 1. Process pending messages - state = process_messages(state) - - # 2. Render if dirty - state = if state.dirty and not state.shutting_down do - do_render(state) - else - state - end - - # 3. Schedule next tick - unless state.shutting_down do - schedule_render(state.render_interval) - end - - state -end - -defp schedule_render(interval) do - Process.send_after(self(), :render, interval) -end -``` - -### Render Flow - -```elixir -defp do_render(state) do - # 1. Get render tree from component - %{module: module, state: comp_state} = state.components[:root] - render_tree = module.view(comp_state) - - # 2. Clear current buffer - BufferManager.clear_current(state.buffer_manager) - - # 3. Render tree to buffer - NodeRenderer.render_to_buffer(render_tree, state.buffer_manager) - - # 4. Diff against previous - current = BufferManager.get_current_buffer(state.buffer_manager) - previous = BufferManager.get_previous_buffer(state.buffer_manager) - operations = Diff.diff(current, previous) - - # 5. Output to terminal - render_operations(operations) - - # 6. Swap buffers - BufferManager.swap_buffers(state.buffer_manager) - - %{state | dirty: false} -end -``` - -## Shutdown - -Graceful shutdown preserves terminal state: - -```mermaid -sequenceDiagram - participant App as Application - participant RT as Runtime - participant IR as InputReader - participant Term as Terminal - - App->>RT: shutdown() - RT->>RT: shutting_down = true - RT->>RT: Stop render scheduling - RT->>IR: stop() - RT->>RT: Clear components - RT->>RT: send(:stop_runtime) - RT->>Term: restore() - Term->>Term: Disable raw mode - Term->>Term: Leave alt screen - Term->>Term: Show cursor - RT->>App: :normal exit -``` - -```elixir -def terminate(_reason, state) do - # Stop input reader - if state.input_reader do - InputReader.stop(state.input_reader) - end - - # Restore terminal - if state.terminal_started do - Terminal.restore() - end - - :ok -end -``` - -## Error Handling - -The Runtime protects against component crashes: - -```elixir -# In event_to_msg -try do - module.event_to_msg(event, component_state) -rescue - error -> - Logger.error("Component crashed in event_to_msg: #{inspect(error)}") - state # Return unchanged -end - -# In update -try do - module.update(message, component_state) -rescue - error -> - Logger.error("Component crashed in update: #{inspect(error)}") - {state, []} # Return unchanged, no commands -end - -# In view -try do - module.view(component_state) -rescue - error -> - Logger.error("Component crashed in view: #{inspect(error)}") - {:text, "[Render Error]"} # Fallback render -end -``` - -## Performance Considerations - -### Message Batching - -Multiple events arriving between render ticks are batched: - -``` -Event 1 → Queue -Event 2 → Queue -Event 3 → Queue -Render tick → Process all 3 → Single render -``` - -### Dirty Tracking - -Components are only re-rendered when state changes: - -```elixir -dirty = state.dirty or new_component_state != component_state -``` - -### Buffer Swapping - -Double buffering avoids copying: - -```elixir -# O(1) pointer swap, not O(rows*cols) copy -def swap_buffers(state) do - %{state | current: state.previous, previous: state.current} -end -``` - -## Testing the Runtime - -```elixir -# Start without terminal for testing -{:ok, runtime} = Runtime.start_link( - root: TestComponent, - skip_terminal: true -) - -# Send events -Runtime.send_event(runtime, Event.key(:enter)) - -# Wait for processing -Runtime.sync(runtime) - -# Check state -state = Runtime.get_state(runtime) -assert state.root_state.submitted == true -``` - -## Next Steps - -- [Rendering Pipeline](03-rendering-pipeline.md) - Detailed render flow -- [Event System](04-event-system.md) - Input handling -- [Buffer Management](05-buffer-management.md) - ETS buffers diff --git a/guides/developer/03-rendering-pipeline.md b/guides/developer/03-rendering-pipeline.md deleted file mode 100644 index eb002e72..00000000 --- a/guides/developer/03-rendering-pipeline.md +++ /dev/null @@ -1,440 +0,0 @@ -# Rendering Pipeline - -This guide explains how TermUI transforms component state into terminal output. - -## Pipeline Overview - -```mermaid -graph LR - subgraph "1. View" - S[State] --> V[view/1] - V --> RT[Render Tree] - end - - subgraph "2. Rasterize" - RT --> NR[NodeRenderer] - NR --> CB[Current Buffer] - end - - subgraph "3. Diff" - CB --> D{Diff} - PB[Previous Buffer] --> D - D --> OPS[Operations] - end - - subgraph "4. Serialize" - OPS --> SB[SequenceBuffer] - SB --> ANSI[ANSI Sequences] - end - - subgraph "5. Output" - ANSI --> IO[IO.write] - IO --> T[Terminal] - end -``` - -## Stage 1: View - -The component's `view/1` function produces a render tree: - -```elixir -def view(state) do - stack(:vertical, [ - text("Counter", Style.new(fg: :cyan, attrs: [:bold])), - text("Value: #{state.count}") - ]) -end -``` - -### Render Tree Nodes - -The tree consists of tuples describing content: - -```elixir -# Text node -{:text, "Hello", %Style{}} - -# Stack (layout container) -{:stack, :vertical, [child1, child2, ...]} -{:stack, :horizontal, [child1, child2, ...]} - -# Styled wrapper -{:styled, %Style{}, child} - -# Fragment (multiple nodes) -{:fragment, [child1, child2, ...]} - -# Raw cells -{:cells, [%Cell{}, %Cell{}, ...]} - -# Viewport (scrollable clipped region) -%{ - type: :viewport, - content: child_node, # Content to render - scroll_x: 0, # Horizontal scroll offset - scroll_y: 0, # Vertical scroll offset - width: 40, # Viewport width - height: 20 # Viewport height -} -``` - -## Stage 2: Rasterize - -`NodeRenderer` traverses the tree and writes cells to the buffer: - -```mermaid -graph TD - RT[Render Tree] --> NR[NodeRenderer] - - subgraph "NodeRenderer.render_to_buffer/2" - NR --> Walk[Walk Tree] - Walk --> Pos[Track Position] - Pos --> Style[Apply Styles] - Style --> Write[Write Cells] - end - - Write --> BM[BufferManager] - BM --> ETS[(ETS Table)] -``` - -### Node Rendering - -```elixir -defp render_node({:text, content, style}, row, col, buffer) do - # Convert each grapheme to a styled cell - cells = content - |> String.graphemes() - |> Enum.with_index() - |> Enum.map(fn {char, i} -> - {row, col + i, Style.to_cell(style, char)} - end) - - BufferManager.set_cells(buffer, cells) - {row, col + String.length(content)} -end - -defp render_node({:stack, :vertical, children}, row, col, buffer) do - Enum.reduce(children, {row, col}, fn child, {r, c} -> - {new_row, _} = render_node(child, r, c, buffer) - {new_row + 1, col} # Move to next row - end) -end - -defp render_node({:stack, :horizontal, children}, row, col, buffer) do - Enum.reduce(children, {row, col}, fn child, {r, c} -> - {_, new_col} = render_node(child, r, c, buffer) - {row, new_col} # Move to next column - end) -end -``` - -### Viewport Rendering - -Viewport nodes clip content to a visible region with scroll offsets: - -```elixir -defp render_viewport(content, buffer, dest_row, dest_col, style, - scroll_x, scroll_y, vp_width, vp_height) do - # 1. Create temporary buffer for full content - {:ok, temp_buffer} = Buffer.new(content_height, content_width) - - # 2. Render content to temporary buffer - render_node(content, temp_buffer, 1, 1, style) - - # 3. Copy visible region to destination buffer - for dy <- 0..(vp_height - 1), dx <- 0..(vp_width - 1) do - src_row = scroll_y + 1 + dy - src_col = scroll_x + 1 + dx - cell = Buffer.get_cell(temp_buffer, src_row, src_col) - Buffer.set_cell(buffer, dest_row + dy, dest_col + dx, cell) - end - - # 4. Clean up temporary buffer - Buffer.destroy(temp_buffer) - - {vp_width, vp_height} -end -``` - -This approach: -- Renders full content to an off-screen buffer -- Copies only the visible portion based on scroll offsets -- Clips content automatically to viewport dimensions - -## Stage 3: Diff - -The diff algorithm compares current and previous buffers: - -```mermaid -graph TB - subgraph "Diff Algorithm" - CB[Current Buffer] --> GR[Get Rows] - PB[Previous Buffer] --> GR - GR --> CR[Compare Rows] - CR --> FS[Find Spans] - FS --> MS[Merge Spans] - MS --> GO[Generate Ops] - end - - GO --> OPS[Operations List] -``` - -### Diff Process - -```elixir -def diff(current, previous) do - {rows, cols} = Buffer.dimensions(current) - - 1..rows - |> Enum.flat_map(fn row -> - diff_row(current, previous, row, cols) - end) - |> optimize_operations() -end -``` - -### Finding Changed Spans - -```elixir -def find_changed_spans(current_cells, previous_cells, row) do - current_cells - |> Enum.zip(previous_cells) - |> Enum.reduce({[], nil}, fn {{col, curr}, {_, prev}}, acc -> - if Cell.equal?(curr, prev) do - close_span(acc) - else - extend_span(acc, col, curr, row) - end - end) - |> finalize() -end -``` - -### Span Merging - -Small gaps between spans are merged to reduce cursor movements: - -``` -Before: [CHANGED]...[CHANGED] (3 char gap) -After: [CHANGED...CHANGED] (merged) -``` - -```elixir -@merge_gap_threshold 3 - -defp merge_spans(spans) do - Enum.reduce(spans, [], fn span, acc -> - case acc do - [prev | rest] when span.start_col - prev.end_col <= @merge_gap_threshold -> - [merge(prev, span) | rest] - _ -> - [span | acc] - end - end) -end -``` - -### Operation Types - -```elixir -@type operation :: - {:move, row, col} # Move cursor - | {:style, Style.t()} # Set SGR attributes - | {:text, String.t()} # Output text - | :reset # Reset all attributes -``` - -## Stage 4: Serialize - -`SequenceBuffer` converts operations to ANSI escape sequences: - -```mermaid -graph LR - subgraph "SequenceBuffer" - OPS[Operations] --> P[Process] - P --> M[Move: ESC row;col H] - P --> S[Style: ESC params m] - P --> T[Text: raw chars] - M --> B[Buffer] - S --> B - T --> B - B --> F[Flush] - end - - F --> IO[iodata] -``` - -### Style Delta Encoding - -Only changed style attributes are emitted: - -```elixir -defp style_to_sgr_params(style, last_style) do - params = [] - - # Only emit fg if changed - params = if style.fg != last_style.fg do - [color_to_sgr(:fg, style.fg) | params] - else - params - end - - # Only emit bg if changed - params = if style.bg != last_style.bg do - [color_to_sgr(:bg, style.bg) | params] - else - params - end - - # Handle attribute changes - # ... - - params -end -``` - -### SGR Sequence Building - -```elixir -defp build_sgr_sequence(params) do - # ESC[param1;param2;...m - ["\e[", Enum.intersperse(params, ";"), "m"] -end - -# Examples: -# Red foreground: \e[31m -# Bold + blue: \e[1;34m -# Reset: \e[0m -``` - -## Stage 5: Output - -The final iodata is written to the terminal: - -```elixir -defp render_operations(operations) do - seq_buffer = SequenceBuffer.new() - - seq_buffer = - Enum.reduce(operations, seq_buffer, fn op, buf -> - apply_operation(op, buf) - end) - - # Reset at end to avoid style bleeding - seq_buffer = SequenceBuffer.append!(seq_buffer, "\e[0m") - - {output, _} = SequenceBuffer.flush(seq_buffer) - IO.write(output) -end -``` - -## Optimization Techniques - -### 1. Cursor Movement Optimization - -Choose shortest cursor movement sequence: - -```elixir -# Absolute: \e[row;colH (variable length) -# Relative: \e[nA/B/C/D (if small delta) - -defp optimal_move(from_row, from_col, to_row, to_col) do - # Calculate costs and choose cheapest -end -``` - -### 2. Batch Cell Writes - -ETS batch insert for multiple cells: - -```elixir -def set_cells(buffer, cells) do - entries = Enum.map(cells, fn {row, col, cell} -> - {{row, col}, cell} - end) - :ets.insert(buffer.table, entries) -end -``` - -### 3. Style Deduplication - -Adjacent cells with same style share one SGR sequence: - -```elixir -# Instead of: -# \e[31mH\e[31me\e[31ml\e[31ml\e[31mo -# Produces: -# \e[31mHello -``` - -### 4. Frame Rate Limiting - -Rendering capped at 60 FPS (16ms intervals): - -```elixir -# Even if 100 events arrive, max 60 renders/sec -schedule_render(16) # milliseconds -``` - -## Performance Metrics - -### Typical Frame Budget - -For 60 FPS, each frame has ~16ms: - -| Stage | Typical Time | -|-------|-------------| -| View | 0.1-1ms | -| Rasterize | 0.5-2ms | -| Diff | 0.2-1ms | -| Serialize | 0.1-0.5ms | -| Output | 0.5-2ms | -| **Total** | **1.4-6.5ms** | - -### Scaling Factors - -| Factor | Impact | -|--------|--------| -| Screen size | O(rows × cols) for full diff | -| Changed cells | O(n) where n = changed | -| Style changes | More SGR sequences | -| Unicode width | Display width calculation | - -## Debugging Rendering - -### Inspect Render Tree - -```elixir -def view(state) do - tree = build_tree(state) - IO.inspect(tree, label: "Render Tree") - tree -end -``` - -### Inspect Operations - -```elixir -# In Runtime.do_render/1 -operations = Diff.diff(current, previous) -IO.inspect(operations, label: "Diff Operations") -``` - -### Buffer Contents - -```elixir -buffer = BufferManager.get_current_buffer() -{rows, cols} = Buffer.dimensions(buffer) - -for row <- 1..rows do - cells = Buffer.get_row(buffer, row) - line = Enum.map_join(cells, & &1.char) - IO.puts(line) -end -``` - -## Next Steps - -- [Buffer Management](05-buffer-management.md) - ETS buffer details -- [Terminal Layer](06-terminal-layer.md) - ANSI sequence handling -- [Event System](04-event-system.md) - Input processing diff --git a/guides/developer/04-event-system.md b/guides/developer/04-event-system.md deleted file mode 100644 index 137927b4..00000000 --- a/guides/developer/04-event-system.md +++ /dev/null @@ -1,406 +0,0 @@ -# Event System - -This guide explains how TermUI captures, parses, and dispatches terminal input events. - -## Event Flow Overview - -```mermaid -graph LR - subgraph "Terminal" - KB[Keyboard] --> TTY[/dev/tty] - MS[Mouse] --> TTY - end - - subgraph "Input Layer" - TTY --> IR[InputReader] - IR --> EP[EscapeParser] - EP --> EV[Event Structs] - end - - subgraph "Dispatch" - EV --> RT[Runtime] - RT --> R[Route] - R --> C[Component] - end - - subgraph "Processing" - C --> ETM[event_to_msg] - ETM --> MSG[Message] - MSG --> UPD[update] - end -``` - -## Input Reader - -`TermUI.Terminal.InputReader` reads raw bytes from stdin: - -```elixir -defmodule TermUI.Terminal.InputReader do - use GenServer - - def init(target) do - # Spawn reader process that uses IO.getn - parent = self() - reader_pid = spawn_link(fn -> io_reader_loop(parent) end) - {:ok, %{target: target, reader: reader_pid}} - end - - defp io_reader_loop(parent) do - case IO.getn("", 1) do - :eof -> - send(parent, {:io_data, :eof}) - - data when is_binary(data) -> - send(parent, {:io_data, data}) - io_reader_loop(parent) - end - end - - def handle_info({:io_data, data}, state) do - # Buffer data, parse sequences, emit events - # ... - end -end -``` - -### Why IO.getn? - -- Integrates with OTP's terminal handling -- Works in raw mode -- Cross-platform (Unix/Windows) -- Non-blocking when data available - -## Escape Parser - -`TermUI.Terminal.EscapeParser` converts bytes to events: - -```mermaid -graph TD - subgraph "Parser State Machine" - B[Bytes] --> C{First Byte?} - C -->|ESC 0x1B| E[Escape Sequence] - C -->|0x00-0x1F| CTRL[Control Char] - C -->|0x20-0x7E| PRINT[Printable] - C -->|0x80+| UTF8[UTF-8] - - E --> E2{Second Byte?} - E2 -->|[| CSI[CSI Sequence] - E2 -->|O| SS3[SS3 Sequence] - E2 -->|other| ALT[Alt+Key] - - CSI --> CSIP[Parse Params] - CSIP --> CSIF{Final Byte?} - CSIF -->|A-D| ARROW[Arrow Keys] - CSIF -->|~| SPECIAL[Special Keys] - CSIF -->|M/m| MOUSE[Mouse Event] - end -``` - -### Sequence Types - -| Prefix | Name | Example | Event | -|--------|------|---------|-------| -| `ESC[A` | CSI | Arrow up | `%Event.Key{key: :up}` | -| `ESC[<0;10;5M` | SGR Mouse | Click at 10,5 | `%Event.Mouse{...}` | -| `ESCOP` | SS3 | F1 | `%Event.Key{key: :f1}` | -| `ESCa` | Alt | Alt+a | `%Event.Key{key: "a", modifiers: [:alt]}` | - -### Parsing Implementation - -```elixir -def parse(<<0x1B, rest::binary>>) do - parse_escape_sequence(rest) -end - -def parse(<>) when char in 32..126 do - # Printable ASCII - event = Event.key(<>) - {[event], rest} -end - -defp parse_escape_sequence(<<"[", rest::binary>>) do - parse_csi_sequence(rest) -end - -defp parse_escape_sequence(<<"O", rest::binary>>) do - parse_ss3_sequence(rest) -end -``` - -### CSI Sequence Parsing - -```elixir -# Arrow keys -defp parse_csi_sequence(<<"A", rest::binary>>), do: {:ok, Event.key(:up), rest} -defp parse_csi_sequence(<<"B", rest::binary>>), do: {:ok, Event.key(:down), rest} -defp parse_csi_sequence(<<"C", rest::binary>>), do: {:ok, Event.key(:right), rest} -defp parse_csi_sequence(<<"D", rest::binary>>), do: {:ok, Event.key(:left), rest} - -# Special keys with tilde -defp parse_csi_sequence(<<"1~", rest::binary>>), do: {:ok, Event.key(:home), rest} -defp parse_csi_sequence(<<"3~", rest::binary>>), do: {:ok, Event.key(:delete), rest} -defp parse_csi_sequence(<<"5~", rest::binary>>), do: {:ok, Event.key(:page_up), rest} -defp parse_csi_sequence(<<"6~", rest::binary>>), do: {:ok, Event.key(:page_down), rest} - -# Mouse (SGR format) -defp parse_csi_sequence(<<"<", rest::binary>>) do - parse_sgr_mouse(rest) -end -``` - -### Mouse Event Parsing - -SGR mouse format: `ESC[ {:scroll_up, nil} - is_scroll and button_code == 1 -> {:scroll_down, nil} - is_motion -> {:drag, decode_button(button_code)} - terminator == :release -> {:release, :left} - true -> {:press, decode_button(button_code)} - end - - # Extract modifiers from bits 2-4 - modifiers = [] - modifiers = if (cb &&& 4) != 0, do: [:shift | modifiers], else: modifiers - modifiers = if (cb &&& 8) != 0, do: [:alt | modifiers], else: modifiers - modifiers = if (cb &&& 16) != 0, do: [:ctrl | modifiers], else: modifiers - - Event.mouse(action, button, cx - 1, cy - 1, modifiers: modifiers) -end -``` - -## Escape Sequence Timeout - -Lone ESC key vs ESC sequence start: - -```mermaid -sequenceDiagram - participant U as User - participant IR as InputReader - participant EP as Parser - participant T as Timer - - U->>IR: ESC key - IR->>EP: 0x1B - EP->>T: Start 50ms timer - Note over EP: Buffer: ESC - - alt More bytes arrive - U->>IR: [ key - IR->>EP: 0x5B - EP->>T: Cancel timer - EP->>EP: Parse CSI sequence - else Timeout - T->>EP: Timeout! - EP->>EP: Emit Event.key(:escape) - end -``` - -```elixir -@escape_timeout 50 # milliseconds - -def handle_info({:io_data, data}, state) do - state = cancel_timer(state) - buffer = state.buffer <> data - {events, remaining} = EscapeParser.parse(buffer) - - # Send complete events - Enum.each(events, &send(state.target, {:input, &1})) - - # Set timeout if partial escape sequence - state = if EscapeParser.partial_sequence?(remaining) do - ref = Process.send_after(self(), :escape_timeout, @escape_timeout) - %{state | buffer: remaining, timer_ref: ref} - else - %{state | buffer: remaining} - end - - {:noreply, state} -end - -def handle_info(:escape_timeout, state) do - # Emit buffered bytes as individual events - # ... -end -``` - -## Event Structs - -### Key Event - -```elixir -defmodule TermUI.Event.Key do - defstruct [ - :key, # Atom (:enter, :up) or String ("a") - :char, # Character or nil - :modifiers, # [:ctrl, :alt, :shift] - :timestamp # System.monotonic_time(:millisecond) - ] -end -``` - -### Mouse Event - -```elixir -defmodule TermUI.Event.Mouse do - defstruct [ - :action, # :press, :release, :click, :drag, :scroll_up, :scroll_down - :button, # :left, :middle, :right, nil - :x, :y, # 0-indexed coordinates - :modifiers, - :timestamp - ] -end -``` - -### Other Events - -```elixir -# Window resize -defmodule TermUI.Event.Resize do - defstruct [:width, :height, :timestamp] -end - -# Terminal focus -defmodule TermUI.Event.Focus do - defstruct [:action, :timestamp] # :gained or :lost -end - -# Bracketed paste -defmodule TermUI.Event.Paste do - defstruct [:content, :timestamp] -end -``` - -## Event Dispatch - -The Runtime routes events to components: - -```elixir -defp dispatch_event(%Event.Key{} = event, state) do - # Keyboard → focused component - dispatch_to_component(state.focused_component, event, state) -end - -defp dispatch_event(%Event.Mouse{x: x, y: y} = event, state) do - # Mouse → component at position - # Future: use spatial index - dispatch_to_component(:root, event, state) -end - -defp dispatch_event(%Event.Resize{} = event, state) do - # Resize → broadcast to all - broadcast_event(event, state) -end - -defp dispatch_event(%Event.Focus{} = event, state) do - # Focus → broadcast to all - broadcast_event(event, state) -end -``` - -## Event to Message - -Components convert events to messages: - -```elixir -defp dispatch_to_component(component_id, event, state) do - %{module: module, state: comp_state} = state.components[component_id] - - case module.event_to_msg(event, comp_state) do - {:msg, message} -> - # Enqueue for processing - enqueue_message(component_id, message, state) - - :ignore -> - # Discard event - state - - :propagate -> - # Bubble to parent (future) - state - end -end -``` - -## Enabling Terminal Features - -### Mouse Tracking - -```elixir -# Enable SGR mouse tracking -Terminal.enable_mouse_tracking(:click) - -# Sequences sent: -# \e[?1000h - Enable X11 mouse -# \e[?1006h - Enable SGR format -``` - -### Focus Events - -```elixir -# Enable focus reporting -Terminal.enable_focus_events() - -# Sequence: \e[?1004h -# Terminal sends: \e[I (focus) or \e[O (blur) -``` - -### Bracketed Paste - -```elixir -# Enable bracketed paste -Terminal.enable_bracketed_paste() - -# Sequence: \e[?2004h -# Pasted text wrapped: \e[200~ ... \e[201~ -``` - -## Testing Events - -### Create Events Programmatically - -```elixir -# Key events -event = Event.key(:enter) -event = Event.key("a", modifiers: [:ctrl]) - -# Mouse events -event = Event.mouse(:click, :left, 10, 5) -event = Event.mouse(:scroll_up, nil, 10, 5) - -# Other -event = Event.Resize.new(120, 40) -event = Event.Focus.new(:gained) -``` - -### Test Event Handling - -```elixir -defmodule MyComponentTest do - use ExUnit.Case - - test "up arrow increments" do - state = %{count: 0} - event = Event.key(:up) - - assert {:msg, :increment} = MyComponent.event_to_msg(event, state) - - {new_state, []} = MyComponent.update(:increment, state) - assert new_state.count == 1 - end -end -``` - -## Next Steps - -- [Terminal Layer](06-terminal-layer.md) - Raw mode and escape sequences -- [Runtime Internals](02-runtime-internals.md) - Event dispatch -- [Buffer Management](05-buffer-management.md) - Screen buffers diff --git a/guides/developer/05-buffer-management.md b/guides/developer/05-buffer-management.md deleted file mode 100644 index 5c7203cc..00000000 --- a/guides/developer/05-buffer-management.md +++ /dev/null @@ -1,423 +0,0 @@ -# Buffer Management - -This guide explains TermUI's screen buffer system using ETS for efficient cell storage and double buffering for flicker-free updates. - -## Architecture - -```mermaid -graph TB - subgraph "BufferManager GenServer" - BM[BufferManager] - BM --> PT[(persistent_term)] - end - - subgraph "Buffer References" - PT --> CB[Current Buffer] - PT --> PB[Previous Buffer] - PT --> DF[Dirty Flag] - end - - subgraph "ETS Storage" - CB --> ETSC[(ETS Table
Current)] - PB --> ETSP[(ETS Table
Previous)] - end - - subgraph "Atomic" - DF --> AT[atomics ref] - end - - NR[NodeRenderer] --> CB - Diff[Diff] --> CB - Diff --> PB -``` - -## Buffer Structure - -A Buffer wraps an ETS table: - -```elixir -defmodule TermUI.Renderer.Buffer do - defstruct [ - :table, # ETS table reference - :rows, # Number of rows - :cols # Number of columns - ] - - @type t :: %__MODULE__{ - table: :ets.tid(), - rows: pos_integer(), - cols: pos_integer() - } -end -``` - -### Cell Storage - -Cells are stored as `{{row, col}, cell}` tuples: - -```elixir -# Cell at row 5, column 10 -:ets.insert(buffer.table, {{5, 10}, %Cell{char: "X", fg: :red}}) - -# Lookup -[{_, cell}] = :ets.lookup(buffer.table, {5, 10}) -``` - -### ETS Configuration - -```elixir -def new(rows, cols) do - table = :ets.new(:screen_buffer, [ - :set, # Key-value storage - :public, # Any process can read/write - read_concurrency: true, # Optimized for concurrent reads - write_concurrency: true # Optimized for concurrent writes - ]) - - # Initialize with empty cells - buffer = %__MODULE__{table: table, rows: rows, cols: cols} - clear(buffer) - - {:ok, buffer} -end -``` - -## Double Buffering - -Two buffers swap roles each frame: - -```mermaid -sequenceDiagram - participant NR as NodeRenderer - participant C as Current - participant P as Previous - participant D as Diff - - Note over C,P: Frame N - - NR->>C: Write cells - D->>C: Read current - D->>P: Read previous - D->>D: Compute diff - - Note over C,P: Swap - - C->>P: Becomes previous - P->>C: Becomes current - - Note over C,P: Frame N+1 - - NR->>C: Write cells (was P) -``` - -### BufferManager Implementation - -```elixir -defmodule TermUI.Renderer.BufferManager do - use GenServer - - def init(opts) do - rows = Keyword.fetch!(opts, :rows) - cols = Keyword.fetch!(opts, :cols) - - {:ok, current} = Buffer.new(rows, cols) - {:ok, previous} = Buffer.new(rows, cols) - - # Dirty flag using atomics for lock-free access - dirty = :atomics.new(1, signed: false) - - # Store in persistent_term for direct access - :persistent_term.put({__MODULE__, :current}, current) - :persistent_term.put({__MODULE__, :previous}, previous) - :persistent_term.put({__MODULE__, :dirty}, dirty) - - {:ok, %{current: current, previous: previous, dirty: dirty}} - end -end -``` - -### Buffer Swap - -```elixir -def handle_call(:swap_buffers, _from, state) do - # O(1) pointer swap - new_state = %{state | current: state.previous, previous: state.current} - - # Update persistent_term references - :persistent_term.put({__MODULE__, :current}, new_state.current) - :persistent_term.put({__MODULE__, :previous}, new_state.previous) - - {:reply, :ok, new_state} -end -``` - -## Direct Access - -Most buffer operations bypass the GenServer for performance: - -```elixir -# These read from persistent_term (no GenServer call) -def get_current_buffer do - :persistent_term.get({__MODULE__, :current}) -end - -def get_previous_buffer do - :persistent_term.get({__MODULE__, :previous}) -end - -def dirty? do - dirty = :persistent_term.get({__MODULE__, :dirty}) - :atomics.get(dirty, 1) == 1 -end - -def mark_dirty do - dirty = :persistent_term.get({__MODULE__, :dirty}) - :atomics.put(dirty, 1, 1) - :ok -end -``` - -## Buffer Operations - -### Writing Cells - -```elixir -def set_cell(buffer, row, col, cell) do - if in_bounds?(buffer, row, col) do - :ets.insert(buffer.table, {{row, col}, cell}) - :ok - else - {:error, :out_of_bounds} - end -end - -def set_cells(buffer, cells) do - entries = Enum.map(cells, fn {row, col, cell} -> - {{row, col}, cell} - end) - :ets.insert(buffer.table, entries) - :ok -end -``` - -### Reading Cells - -```elixir -def get_cell(buffer, row, col) do - case :ets.lookup(buffer.table, {row, col}) do - [{_, cell}] -> cell - [] -> Cell.empty() - end -end - -def get_row(buffer, row) do - # Match all cells in row - pattern = {{row, :_}, :_} - cells = :ets.match_object(buffer.table, pattern) - - # Sort by column and extract cells - cells - |> Enum.sort_by(fn {{_, col}, _} -> col end) - |> Enum.map(fn {_, cell} -> cell end) -end -``` - -### Clearing - -```elixir -def clear(buffer) do - clear_region(buffer, 1, 1, buffer.cols, buffer.rows) -end - -def clear_region(buffer, start_row, start_col, width, height) do - empty = Cell.empty() - - entries = - for row <- start_row..(start_row + height - 1), - col <- start_col..(start_col + width - 1), - in_bounds?(buffer, row, col) do - {{row, col}, empty} - end - - :ets.insert(buffer.table, entries) - :ok -end -``` - -## Cell Structure - -```elixir -defmodule TermUI.Renderer.Cell do - defstruct [ - char: " ", # Single grapheme - fg: :default, # Foreground color - bg: :default, # Background color - attrs: MapSet.new(), # Text attributes - width: 1, # Display width (1 or 2) - wide_placeholder: false - ] -end -``` - -### Cell Comparison - -Used by the diff algorithm: - -```elixir -def equal?(a, b) do - a.char == b.char and - a.fg == b.fg and - a.bg == b.bg and - MapSet.equal?(a.attrs, b.attrs) and - a.width == b.width and - a.wide_placeholder == b.wide_placeholder -end -``` - -### Wide Characters - -CJK and emoji characters take 2 cells: - -```elixir -# Primary cell -primary = %Cell{char: "中", width: 2} - -# Placeholder for second column -placeholder = %Cell{char: "", width: 0, wide_placeholder: true} - -# Both must be written -:ets.insert(buffer.table, [ - {{row, col}, primary}, - {{row, col + 1}, placeholder} -]) -``` - -## Dirty Flag - -Tracks whether re-render is needed: - -```mermaid -graph LR - subgraph "Write Path" - W[Write Cell] --> MD[mark_dirty] - MD --> A[(atomics)] - end - - subgraph "Render Path" - RT[Render Tick] --> CD{dirty?} - CD -->|true| R[Render] - CD -->|false| S[Skip] - R --> CL[clear_dirty] - end -``` - -Using atomics for lock-free access: - -```elixir -# Mark dirty (from any process) -def mark_dirty do - dirty = :persistent_term.get({__MODULE__, :dirty}) - :atomics.put(dirty, 1, 1) -end - -# Check dirty (from render loop) -def dirty? do - dirty = :persistent_term.get({__MODULE__, :dirty}) - :atomics.get(dirty, 1) == 1 -end - -# Clear dirty (after render) -def clear_dirty do - dirty = :persistent_term.get({__MODULE__, :dirty}) - :atomics.put(dirty, 1, 0) -end -``` - -## Resize Handling - -```elixir -def handle_call({:resize, rows, cols}, _from, state) do - # Create new buffers with new dimensions - {:ok, new_current} = Buffer.resize(state.current, rows, cols) - {:ok, new_previous} = Buffer.resize(state.previous, rows, cols) - - new_state = %{state | current: new_current, previous: new_previous} - - # Update persistent_term - :persistent_term.put({__MODULE__, :current}, new_current) - :persistent_term.put({__MODULE__, :previous}, new_previous) - - {:reply, :ok, new_state} -end -``` - -### Content Preservation - -```elixir -def resize(buffer, new_rows, new_cols) do - {:ok, new_buffer} = new(new_rows, new_cols) - - # Copy cells that fit in new dimensions - old_entries = :ets.tab2list(buffer.table) - - entries_to_copy = - old_entries - |> Enum.filter(fn {{row, col}, _} -> - row <= new_rows and col <= new_cols - end) - - :ets.insert(new_buffer.table, entries_to_copy) - - # Clean up old table - :ets.delete(buffer.table) - - {:ok, new_buffer} -end -``` - -## Performance Characteristics - -| Operation | Complexity | Notes | -|-----------|------------|-------| -| `get_cell` | O(1) | ETS hash lookup | -| `set_cell` | O(1) | ETS insert | -| `set_cells` | O(n) | Batch insert | -| `get_row` | O(cols) | Match + sort | -| `clear` | O(rows × cols) | Full buffer | -| `swap_buffers` | O(1) | Pointer swap | -| `dirty?` | O(1) | Atomic read | - -## Memory Usage - -Each cell: ~100-200 bytes depending on content - -For 80×24 terminal: ~200KB per buffer (400KB total) -For 200×50 terminal: ~2MB per buffer (4MB total) - -## Cleanup - -```elixir -def terminate(_reason, state) do - # Remove persistent_term entries - :persistent_term.erase({__MODULE__, :current}) - :persistent_term.erase({__MODULE__, :previous}) - :persistent_term.erase({__MODULE__, :dirty}) - - # Delete ETS tables - Buffer.destroy(state.current) - Buffer.destroy(state.previous) - - :ok -end - -# Buffer.destroy/1 -def destroy(buffer) do - :ets.delete(buffer.table) -end -``` - -## Next Steps - -- [Rendering Pipeline](03-rendering-pipeline.md) - How buffers are used -- [Terminal Layer](06-terminal-layer.md) - Output to terminal -- [Architecture Overview](01-architecture-overview.md) - System context diff --git a/guides/developer/06-terminal-layer.md b/guides/developer/06-terminal-layer.md deleted file mode 100644 index 90b33710..00000000 --- a/guides/developer/06-terminal-layer.md +++ /dev/null @@ -1,515 +0,0 @@ -# Terminal Layer - -This guide covers TermUI's low-level terminal interface, including raw mode, escape sequences, and platform handling. - -## Components - -```mermaid -graph TB - subgraph "Terminal Layer" - TG[Terminal GenServer] - IR[InputReader] - EP[EscapeParser] - ANSI[ANSI Module] - end - - subgraph "System" - TTY[/dev/tty] - STDIN[stdin] - STDOUT[stdout] - end - - TG --> TTY - TG --> STDOUT - IR --> STDIN - IR --> EP - ANSI --> TG -``` - -## Terminal GenServer - -`TermUI.Terminal` manages terminal state: - -```elixir -defmodule TermUI.Terminal do - use GenServer - - defstruct [ - :original_mode, # Saved terminal state - :raw_mode_enabled, # Currently in raw mode? - :mouse_mode, # Mouse tracking mode - :resize_callbacks # Processes to notify on resize - ] -end -``` - -### Initialization - -```elixir -def init(_opts) do - state = %__MODULE__{ - original_mode: nil, - raw_mode_enabled: false, - mouse_mode: nil, - resize_callbacks: [] - } - {:ok, state} -end -``` - -## Raw Mode - -### Enabling Raw Mode - -OTP 28+ uses the native shell API: - -```elixir -def enable_raw_mode do - if terminal?() do - # OTP 28+ native raw mode - :shell.start_interactive({:noshell, :raw}) - :ok - else - {:error, :not_a_terminal} - end -end -``` - -### Terminal Detection - -Multiple methods for SSH compatibility: - -```elixir -defp terminal? do - cond do - io_has_terminal?() -> true - File.exists?("/dev/tty") -> true - check_tty() -> true - true -> false - end -end - -defp io_has_terminal? do - case :io.getopts(:standard_io) do - {:ok, opts} -> Keyword.get(opts, :terminal, false) == true - _ -> false - end -end - -defp check_tty do - case System.cmd("test", ["-t", "0"], stderr_to_stdout: true) do - {_, 0} -> true - _ -> false - end -rescue - _ -> false -end -``` - -### Restoring Terminal - -```elixir -def restore do - # Disable raw mode - disable_raw_mode() - - # Leave alternate screen - leave_alternate_screen() - - # Show cursor - show_cursor() - - # Disable mouse tracking - disable_mouse_tracking() - - # Reset all attributes - write_to_terminal("\e[0m") - - :ok -end -``` - -## Escape Sequences - -### ANSI Module - -`TermUI.ANSI` generates escape sequences: - -```elixir -defmodule TermUI.ANSI do - # Cursor movement - def cursor_position(row, col), do: "\e[#{row};#{col}H" - def cursor_up(n \\ 1), do: "\e[#{n}A" - def cursor_down(n \\ 1), do: "\e[#{n}B" - def cursor_forward(n \\ 1), do: "\e[#{n}C" - def cursor_back(n \\ 1), do: "\e[#{n}D" - - # Cursor visibility - def hide_cursor, do: "\e[?25l" - def show_cursor, do: "\e[?25h" - - # Screen control - def clear_screen, do: "\e[2J" - def clear_line, do: "\e[2K" - def enter_alternate_screen, do: "\e[?1049h" - def leave_alternate_screen, do: "\e[?1049l" - - # Style reset - def reset, do: "\e[0m" -end -``` - -### SGR (Select Graphic Rendition) - -Text styling sequences: - -```elixir -# Colors -defp color_to_sgr(:fg, :default), do: "39" -defp color_to_sgr(:fg, :black), do: "30" -defp color_to_sgr(:fg, :red), do: "31" -defp color_to_sgr(:fg, :green), do: "32" -# ... etc - -defp color_to_sgr(:bg, :default), do: "49" -defp color_to_sgr(:bg, :black), do: "40" -# ... etc - -# 256 colors -defp color_to_sgr(:fg, n) when is_integer(n), do: "38;5;#{n}" -defp color_to_sgr(:bg, n) when is_integer(n), do: "48;5;#{n}" - -# True color -defp color_to_sgr(:fg, {r, g, b}), do: "38;2;#{r};#{g};#{b}" -defp color_to_sgr(:bg, {r, g, b}), do: "48;2;#{r};#{g};#{b}" - -# Attributes -defp attr_to_sgr(:bold), do: "1" -defp attr_to_sgr(:dim), do: "2" -defp attr_to_sgr(:italic), do: "3" -defp attr_to_sgr(:underline), do: "4" -defp attr_to_sgr(:blink), do: "5" -defp attr_to_sgr(:reverse), do: "7" -defp attr_to_sgr(:hidden), do: "8" -defp attr_to_sgr(:strikethrough), do: "9" - -# Attribute off -defp attr_off_sgr(:bold), do: "22" -defp attr_off_sgr(:underline), do: "24" -# ... etc -``` - -### Sequence Buffer - -Batches sequences for efficient output: - -```elixir -defmodule TermUI.Renderer.SequenceBuffer do - defstruct [ - buffer: [], # Accumulated iodata - size: 0, # Current size - threshold: 4096, # Auto-flush threshold - last_style: nil # For delta encoding - ] - - def append(buffer, data) do - new_size = buffer.size + IO.iodata_length(data) - new_buffer = %{buffer | buffer: [data | buffer.buffer], size: new_size} - - if new_size >= buffer.threshold do - {flushed, reset} = flush(new_buffer) - {:flush, flushed, reset} - else - {:ok, new_buffer} - end - end - - def flush(buffer) do - data = buffer.buffer |> Enum.reverse() - {data, %{buffer | buffer: [], size: 0}} - end -end -``` - -### Style Delta Encoding - -Only emit changed attributes: - -```elixir -def append_style(buffer, style) do - params = style_to_sgr_params(style, buffer.last_style) - - if params == [] do - buffer - else - sequence = build_sgr_sequence(params) - buffer = append!(buffer, sequence) - %{buffer | last_style: style} - end -end - -defp style_to_sgr_params(style, nil) do - # No previous - emit all - build_full_sgr_params(style) -end - -defp style_to_sgr_params(style, last) do - params = [] - - # Only emit if changed - params = if style.fg != last.fg do - fg = style.fg || :default - [color_to_sgr(:fg, fg) | params] - else - params - end - - params = if style.bg != last.bg do - bg = style.bg || :default - [color_to_sgr(:bg, bg) | params] - else - params - end - - # Handle attribute changes... - params -end -``` - -## Mouse Tracking - -### Modes - -```elixir -def enable_mouse_tracking(mode) do - sequences = case mode do - :click -> - # X11 mouse button events - ["\e[?1000h", "\e[?1006h"] - - :drag -> - # Button events + motion while pressed - ["\e[?1002h", "\e[?1006h"] - - :all -> - # All mouse events including motion - ["\e[?1003h", "\e[?1006h"] - end - - Enum.each(sequences, &write_to_terminal/1) - :ok -end - -def disable_mouse_tracking do - sequences = [ - "\e[?1000l", # Disable X11 - "\e[?1002l", # Disable drag - "\e[?1003l", # Disable all - "\e[?1006l" # Disable SGR - ] - Enum.each(sequences, &write_to_terminal/1) - :ok -end -``` - -### SGR Mouse Format - -More precise than X10 format: - -``` -ESC [ < Cb ; Cx ; Cy M (button press) -ESC [ < Cb ; Cx ; Cy m (button release) - -Cb = button info (bits encode button, modifiers, motion) -Cx = column (1-indexed) -Cy = row (1-indexed) -``` - -## Focus Events - -```elixir -def enable_focus_events do - write_to_terminal("\e[?1004h") -end - -def disable_focus_events do - write_to_terminal("\e[?1004l") -end - -# Terminal sends: -# \e[I - Focus gained -# \e[O - Focus lost -``` - -## Terminal Size - -### Query Size - -```elixir -def get_terminal_size do - case :io.columns() do - {:ok, cols} -> - case :io.rows() do - {:ok, rows} -> {:ok, {rows, cols}} - _ -> {:error, :unknown} - end - _ -> - {:error, :unknown} - end -end -``` - -### Resize Detection - -```elixir -# Register for SIGWINCH -def register_resize_callback(pid) do - GenServer.cast(__MODULE__, {:register_resize, pid}) -end - -# On resize signal -def handle_info(:sigwinch, state) do - case get_terminal_size() do - {:ok, {rows, cols}} -> - # Notify all registered processes - Enum.each(state.resize_callbacks, fn pid -> - send(pid, {:terminal_resize, {rows, cols}}) - end) - _ -> - :ok - end - {:noreply, state} -end -``` - -## Alternate Screen - -```mermaid -sequenceDiagram - participant App as Application - participant Term as Terminal - participant Scr as Screen - - App->>Term: enter_alternate_screen() - Term->>Scr: ESC[?1049h - Note over Scr: Switch to alt buffer - - Note over App: TUI runs... - - App->>Term: leave_alternate_screen() - Term->>Scr: ESC[?1049l - Note over Scr: Restore main buffer -``` - -```elixir -def enter_alternate_screen do - write_to_terminal("\e[?1049h") -end - -def leave_alternate_screen do - write_to_terminal("\e[?1049l") -end -``` - -## Bracketed Paste - -```elixir -def enable_bracketed_paste do - write_to_terminal("\e[?2004h") -end - -def disable_bracketed_paste do - write_to_terminal("\e[?2004l") -end - -# Pasted text arrives as: -# \e[200~ \e[201~ -``` - -## Platform Differences - -### Unix/Linux/macOS - -- `/dev/tty` for terminal access -- `stty` for fallback mode control -- SIGWINCH for resize detection - -### Windows - -- ConPTY for modern terminals -- Different escape sequence support -- Windows Terminal provides full ANSI support - -```elixir -defp platform do - case :os.type() do - {:unix, _} -> :unix - {:win32, _} -> :windows - end -end -``` - -## Error Recovery - -### Terminal Restoration - -Always restore on exit: - -```elixir -def terminate(_reason, state) do - # Best-effort restoration - try do - restore() - rescue - _ -> :ok - end - :ok -end -``` - -### Crash Recovery - -The runtime traps exits: - -```elixir -def init(opts) do - Process.flag(:trap_exit, true) - # ... -end - -def terminate(_reason, state) do - # Terminal.restore() always called - if state.terminal_started do - Terminal.restore() - end - :ok -end -``` - -## Debugging - -### Raw Escape Sequences - -```elixir -# See actual bytes -IO.inspect(data, binaries: :as_binaries) - -# Example output: -# <<27, 91, 49, 59, 51, 49, 109>> -# = ESC [ 1 ; 3 1 m -# = bold + red foreground -``` - -### Terminal State - -```elixir -# Check if in raw mode -:io.getopts(:standard_io) -# => {:ok, [terminal: true, ...]} -``` - -## Next Steps - -- [Event System](04-event-system.md) - Input parsing -- [Rendering Pipeline](03-rendering-pipeline.md) - Output flow -- [Buffer Management](05-buffer-management.md) - Screen buffers diff --git a/guides/developer/07-elm-implementation.md b/guides/developer/07-elm-implementation.md deleted file mode 100644 index c454b9d5..00000000 --- a/guides/developer/07-elm-implementation.md +++ /dev/null @@ -1,602 +0,0 @@ -# Elm Architecture Implementation - -This guide explains how TermUI implements The Elm Architecture (TEA) pattern adapted for OTP/Elixir. - -## The Pattern - -```mermaid -graph TD - subgraph "Elm Architecture" - S[State] --> V[view/1] - V --> RT[Render Tree] - RT --> T[Terminal] - - E[Event] --> ETM[event_to_msg/2] - ETM --> M[Message] - M --> U[update/2] - U --> NS[New State] - NS --> S - U --> CMD[Commands] - CMD --> EX[Execute] - EX --> M - end -``` - -## Component Behaviour - -Every TermUI component implements the `TermUI.Component` behaviour: - -```elixir -defmodule TermUI.Component do - @callback init(opts :: keyword()) :: state :: term() - @callback event_to_msg(event :: Event.t(), state :: term()) :: - {:msg, msg :: term()} | :ignore | :propagate - @callback update(msg :: term(), state :: term()) :: - {new_state :: term(), commands :: [command()]} - @callback view(state :: term()) :: render_tree :: term() -end -``` - -### Example Component - -```elixir -defmodule Counter do - @behaviour TermUI.Component - - import TermUI.View - alias TermUI.Event - - # Initialize state - @impl true - def init(_opts), do: %{count: 0} - - # Convert events to messages - @impl true - def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :increment} - def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :decrement} - def event_to_msg(%Event.Key{key: "q"}, _state), do: {:msg, :quit} - def event_to_msg(_event, _state), do: :ignore - - # Update state based on messages - @impl true - def update(:increment, state), do: {%{state | count: state.count + 1}, []} - def update(:decrement, state), do: {%{state | count: state.count - 1}, []} - def update(:quit, state), do: {state, [:quit]} - - # Render current state - @impl true - def view(state) do - stack(:vertical, [ - text("Counter: #{state.count}"), - text("↑/↓ to change, q to quit") - ]) - end -end -``` - -## Data Flow - -### 1. Init Phase - -```mermaid -sequenceDiagram - participant U as User - participant RT as Runtime - participant C as Component - participant BM as BufferManager - - U->>RT: start_link(root: Counter) - RT->>C: init(opts) - C->>RT: initial_state - RT->>BM: allocate buffers - RT->>RT: schedule_render - RT->>C: view(state) - C->>RT: render_tree - RT->>BM: render to buffer -``` - -```elixir -# Runtime.init/1 -def init(opts) do - root_module = Keyword.fetch!(opts, :root) - - # Call component's init - root_state = root_module.init(opts) - - state = %State{ - root_module: root_module, - root_state: root_state, - # ... - } - - # Schedule first render - schedule_render(state.render_interval) - - {:ok, state} -end -``` - -### 2. Event Phase - -```mermaid -sequenceDiagram - participant T as Terminal - participant IR as InputReader - participant RT as Runtime - participant C as Component - - T->>IR: raw bytes - IR->>IR: parse escape sequences - IR->>RT: Event struct - RT->>C: event_to_msg(event, state) - - alt {:msg, message} - C->>RT: {:msg, :increment} - RT->>RT: enqueue message - else :ignore - C->>RT: :ignore - Note over RT: Event discarded - else :propagate - C->>RT: :propagate - Note over RT: Bubble to parent - end -``` - -```elixir -# Runtime handles input from InputReader -def handle_info({:input, event}, state) do - state = dispatch_event(event, state) - {:noreply, state} -end - -defp dispatch_event(event, state) do - case state.root_module.event_to_msg(event, state.root_state) do - {:msg, message} -> - enqueue_message(:root, message, state) - - :ignore -> - state - - :propagate -> - # Future: bubble to parent component - state - end -end -``` - -### 3. Update Phase - -```mermaid -sequenceDiagram - participant RT as Runtime - participant MQ as MessageQueue - participant C as Component - participant CMD as CommandExecutor - - RT->>MQ: flush() - MQ->>RT: [messages] - - loop Each message - RT->>C: update(msg, state) - C->>RT: {new_state, commands} - RT->>RT: mark_dirty if changed - end - - RT->>CMD: execute(commands) - CMD->>RT: schedule results -``` - -```elixir -defp process_messages(state) do - {messages, queue} = MessageQueue.flush(state.message_queue) - - {state, all_commands} = - Enum.reduce(messages, {state, []}, fn {component_id, msg}, {acc, cmds} -> - {new_state, new_cmds} = process_single_message(component_id, msg, acc) - {new_state, cmds ++ new_cmds} - end) - - execute_commands(all_commands, %{state | message_queue: queue}) -end - -defp process_single_message(:root, msg, state) do - {new_root_state, commands} = state.root_module.update(msg, state.root_state) - - dirty = state.dirty or new_root_state != state.root_state - - {%{state | root_state: new_root_state, dirty: dirty}, commands} -end -``` - -### 4. View Phase - -```mermaid -sequenceDiagram - participant RT as Runtime - participant C as Component - participant NR as NodeRenderer - participant BM as BufferManager - participant D as Diff - participant T as Terminal - - RT->>C: view(state) - C->>RT: render_tree - RT->>BM: clear current - RT->>NR: render_to_buffer(tree) - NR->>BM: set_cells(cells) - RT->>D: diff(current, previous) - D->>RT: operations - RT->>T: write operations - RT->>BM: swap_buffers -``` - -```elixir -defp do_render(state) do - # 1. Get render tree - render_tree = state.root_module.view(state.root_state) - - # 2. Clear and render to buffer - BufferManager.clear_current() - NodeRenderer.render_to_buffer(render_tree) - - # 3. Diff against previous - current = BufferManager.get_current_buffer() - previous = BufferManager.get_previous_buffer() - operations = Diff.diff(current, previous) - - # 4. Output to terminal - render_operations(operations) - - # 5. Swap buffers for next frame - BufferManager.swap_buffers() - - %{state | dirty: false} -end -``` - -## Commands - -Commands are side effects returned from `update/2`: - -```mermaid -graph LR - subgraph "Command Types" - Q[:quit] --> Shutdown - T[{:timer, ms, msg}] --> TimerProcess - A[{:async, fun, msg}] --> TaskProcess - end - - TimerProcess --> MQ[MessageQueue] - TaskProcess --> MQ -``` - -### Built-in Commands - -```elixir -# Quit application -def update(:quit, state), do: {state, [:quit]} - -# Set timer -def update(:start_timer, state) do - {state, [{:timer, 1000, :tick}]} -end - -# Async operation -def update(:fetch_data, state) do - task = fn -> HTTP.get!("/api/data") end - {state, [{:async, task, :data_received}]} -end -``` - -### Command Execution - -```elixir -defp execute_commands(commands, state) do - Enum.reduce(commands, state, fn cmd, acc -> - execute_command(cmd, acc) - end) -end - -defp execute_command(:quit, state) do - GenServer.cast(self(), :shutdown) - %{state | shutting_down: true} -end - -defp execute_command({:timer, ms, msg}, state) do - command_id = make_ref() - Process.send_after(self(), {:command_result, :root, command_id, msg}, ms) - - pending = Map.put(state.pending_commands, command_id, {:timer, msg}) - %{state | pending_commands: pending} -end - -defp execute_command({:async, fun, msg_wrapper}, state) do - command_id = make_ref() - parent = self() - - Task.start(fn -> - result = fun.() - send(parent, {:command_result, :root, command_id, {msg_wrapper, result}}) - end) - - pending = Map.put(state.pending_commands, command_id, {:async, msg_wrapper}) - %{state | pending_commands: pending} -end -``` - -### Command Results - -```elixir -def handle_info({:command_result, component_id, cmd_id, result}, state) do - state = %{state | pending_commands: Map.delete(state.pending_commands, cmd_id)} - state = enqueue_message(component_id, result, state) - {:noreply, state} -end -``` - -## Message Queue - -FIFO ordering with component targeting: - -```elixir -defmodule TermUI.Runtime.MessageQueue do - defstruct queue: :queue.new() - - def enqueue(mq, component_id, message) do - %{mq | queue: :queue.in({component_id, message}, mq.queue)} - end - - def flush(mq) do - messages = :queue.to_list(mq.queue) - {messages, %{mq | queue: :queue.new()}} - end - - def empty?(mq) do - :queue.is_empty(mq.queue) - end -end -``` - -## Render Tree Nodes - -The `view/1` function returns a tree of render nodes: - -```elixir -# Text with optional style -{:text, "Hello", %Style{fg: :red}} - -# Vertical or horizontal stack -{:stack, :vertical, [child1, child2]} -{:stack, :horizontal, [child1, child2]} - -# Style wrapper -{:styled, %Style{bg: :blue}, child} - -# Fragment (no container) -{:fragment, [child1, child2, child3]} - -# Raw cells -{:cells, [%Cell{char: "█", fg: :green}, ...]} -``` - -### View Helpers - -```elixir -defmodule TermUI.View do - def text(content), do: {:text, content, Style.new()} - def text(content, style), do: {:text, content, style} - - def stack(direction, children) when direction in [:vertical, :horizontal] do - {:stack, direction, List.flatten(children)} - end - - def styled(style, child), do: {:styled, style, child} - - def fragment(children), do: {:fragment, List.flatten(children)} -end -``` - -## State Immutability - -All state updates create new values: - -```elixir -# Good - create new state -def update(:increment, state) do - {%{state | count: state.count + 1}, []} -end - -# Bad - mutation (doesn't work in Elixir anyway) -def update(:increment, state) do - state.count = state.count + 1 # Compile error! - {state, []} -end -``` - -### Nested State Updates - -```elixir -def update({:set_user_name, name}, state) do - # Update nested map - new_user = %{state.user | name: name} - {%{state | user: new_user}, []} -end - -# Or with put_in -def update({:set_user_name, name}, state) do - {put_in(state, [:user, :name], name), []} -end -``` - -## Error Handling - -Components are wrapped in error protection: - -```elixir -defp safe_event_to_msg(module, event, state) do - try do - module.event_to_msg(event, state) - rescue - error -> - Logger.error("event_to_msg crashed: #{inspect(error)}") - :ignore - end -end - -defp safe_update(module, msg, state) do - try do - module.update(msg, state) - rescue - error -> - Logger.error("update crashed: #{inspect(error)}") - {state, []} - end -end - -defp safe_view(module, state) do - try do - module.view(state) - rescue - error -> - Logger.error("view crashed: #{inspect(error)}") - {:text, "[Render Error]", Style.new(fg: :red)} - end -end -``` - -## Comparison with Original Elm - -| Aspect | Elm | TermUI | -|--------|-----|--------| -| Language | Elm (ML-style) | Elixir | -| Runtime | Browser/JavaScript | BEAM/OTP | -| Model | `Model` type | Component state (any term) | -| Msg | `Msg` union type | Any Elixir term | -| Cmd | `Cmd Msg` | List of command tuples | -| Sub | `Sub Msg` | Commands + Input events | -| view | Virtual DOM | Render tree | -| update | Pure function | Pure function | -| Side effects | Elm runtime | Runtime + Commands | - -### Key Differences - -1. **No subscriptions**: TermUI uses commands and the InputReader instead -2. **Commands as data**: Commands are simple tuples, not opaque types -3. **event_to_msg**: Additional callback to separate event parsing from state updates -4. **Process model**: Components could be separate processes (future) - -## Testing Components - -```elixir -defmodule CounterTest do - use ExUnit.Case - - alias TermUI.Event - - test "init returns zero count" do - assert Counter.init([]) == %{count: 0} - end - - test "up arrow increments" do - state = %{count: 5} - event = Event.key(:up) - - assert {:msg, :increment} = Counter.event_to_msg(event, state) - - {new_state, commands} = Counter.update(:increment, state) - assert new_state.count == 6 - assert commands == [] - end - - test "quit returns quit command" do - state = %{count: 0} - - {^state, commands} = Counter.update(:quit, state) - assert :quit in commands - end - - test "view renders count" do - state = %{count: 42} - tree = Counter.view(state) - - # Inspect tree structure - {:stack, :vertical, [text_node | _]} = tree - {:text, content, _style} = text_node - assert content =~ "42" - end -end -``` - -## Best Practices - -### 1. Keep State Minimal - -```elixir -# Good - only essential data -%{ - items: [...], - selected_index: 0, - filter: "" -} - -# Avoid - derived data in state -%{ - items: [...], - filtered_items: [...], # Derive in view instead - item_count: 10 # Derive from items -} -``` - -### 2. Use Pattern Matching in event_to_msg - -```elixir -# Good - specific patterns -def event_to_msg(%Event.Key{key: :enter}, _state), do: {:msg, :submit} -def event_to_msg(%Event.Key{key: :escape}, _state), do: {:msg, :cancel} -def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit} -def event_to_msg(_event, _state), do: :ignore - -# Avoid - complex logic in event_to_msg -def event_to_msg(event, state) do - cond do - event.key == :enter and state.mode == :edit -> {:msg, :save} - event.key == :enter and state.mode == :view -> {:msg, :edit} - # ... many conditions - end -end -``` - -### 3. Messages as Intent - -```elixir -# Good - messages describe intent -:increment -:decrement -{:select_item, index} -{:set_filter, text} - -# Avoid - messages that are too low-level -{:set_count, 5} # Doesn't express why -{:keypress, :up} # Already handled by event_to_msg -``` - -### 4. Commands for Side Effects - -```elixir -# Good - side effects via commands -def update(:refresh, state) do - {%{state | loading: true}, [{:async, &fetch_data/0, :data_loaded}]} -end - -# Avoid - side effects in update -def update(:refresh, state) do - data = HTTP.get!("/api") # Blocks, can crash - {%{state | data: data}, []} -end -``` - -## Next Steps - -- [Architecture Overview](01-architecture-overview.md) - System layers -- [Runtime Internals](02-runtime-internals.md) - Event loop details -- [Event System](04-event-system.md) - Input handling diff --git a/guides/developer/08-creating-widgets.md b/guides/developer/08-creating-widgets.md deleted file mode 100644 index 4097c664..00000000 --- a/guides/developer/08-creating-widgets.md +++ /dev/null @@ -1,409 +0,0 @@ -# Creating New Widgets - -This guide explains how to create new widgets for TermUI and contribute them to the project. - -## Widget Types - -TermUI supports two types of widgets: - -### 1. Stateless Widgets (Display Only) - -Simple widgets that render based on input props without maintaining internal state. - -**Examples**: Gauge, Sparkline, BarChart, LineChart - -**Use when**: The widget only displays data and doesn't need to track interactions. - -### 2. Stateful Widgets (Interactive) - -Widgets that maintain internal state and handle user events. - -**Examples**: Menu, Table, Tabs, Dialog, Viewport - -**Use when**: The widget needs to track selection, focus, scroll position, or other interactive state. - -## Creating a Stateless Widget - -### Step 1: Create the Widget Module - -Create a new file in `lib/term_ui/widgets/`: - -```elixir -defmodule TermUI.Widgets.MyWidget do - @moduledoc """ - MyWidget displays [description]. - - ## Usage - - MyWidget.render( - value: 42, - width: 20, - style: Style.new(fg: :cyan) - ) - - ## Options - - - `:value` - The value to display (required) - - `:width` - Widget width (default: 20) - - `:style` - Style for the widget - """ - - import TermUI.Component.RenderNode - - @doc """ - Renders the widget. - - ## Options - - - `:value` - Required. The value to display. - - `:width` - Optional. Width in characters (default: 20). - - `:style` - Optional. Style to apply. - """ - @spec render(keyword()) :: TermUI.Component.RenderNode.t() - def render(opts) do - value = Keyword.fetch!(opts, :value) - width = Keyword.get(opts, :width, 20) - style = Keyword.get(opts, :style) - - # Build your render tree - content = format_value(value, width) - - if style do - styled(text(content), style) - else - text(content) - end - end - - # Helper function for convenience - @doc """ - Renders with default styling. - """ - def simple(value, opts \\ []) do - render([{:value, value} | opts]) - end - - # Private helpers - defp format_value(value, width) do - value - |> to_string() - |> String.pad_trailing(width) - end -end -``` - -### Key Points for Stateless Widgets - -1. **Import RenderNode helpers**: `import TermUI.Component.RenderNode` -2. **Use `Keyword.fetch!/2`** for required options -3. **Use `Keyword.get/3`** for optional options with defaults -4. **Return a RenderNode struct** from `render/1` -5. **Provide convenience functions** like `simple/2` for common use cases - -## Creating a Stateful Widget - -### Step 1: Create the Widget Module - -```elixir -defmodule TermUI.Widgets.MyStatefulWidget do - @moduledoc """ - MyStatefulWidget provides [description]. - - ## Usage - - MyStatefulWidget.new( - items: ["one", "two", "three"], - on_select: fn item -> handle_selection(item) end - ) - - ## Keyboard Controls - - - Up/Down: Navigate items - - Enter: Select current item - - Escape: Close - """ - - use TermUI.StatefulComponent - - alias TermUI.Event - - # Constructor for props - @doc """ - Creates widget props. - - ## Options - - - `:items` - List of items (required) - - `:on_select` - Callback when item is selected - - `:style` - Style for normal items - - `:selected_style` - Style for selected item - """ - @spec new(keyword()) :: map() - def new(opts) do - %{ - items: Keyword.fetch!(opts, :items), - on_select: Keyword.get(opts, :on_select), - style: Keyword.get(opts, :style), - selected_style: Keyword.get(opts, :selected_style) - } - end - - # Initialize state from props - @impl true - def init(props) do - state = %{ - items: props.items, - cursor: 0, - on_select: props.on_select, - style: props.style, - selected_style: props.selected_style - } - - {:ok, state} - end - - # Handle keyboard events - @impl true - def handle_event(%Event.Key{key: :up}, state) do - new_cursor = max(0, state.cursor - 1) - {:ok, %{state | cursor: new_cursor}} - end - - def handle_event(%Event.Key{key: :down}, state) do - max_index = length(state.items) - 1 - new_cursor = min(max_index, state.cursor + 1) - {:ok, %{state | cursor: new_cursor}} - end - - def handle_event(%Event.Key{key: :enter}, state) do - if state.on_select do - item = Enum.at(state.items, state.cursor) - state.on_select.(item) - end - - {:ok, state} - end - - def handle_event(_event, state) do - {:ok, state} - end - - # Render the widget - @impl true - def render(state, _area) do - rows = - state.items - |> Enum.with_index() - |> Enum.map(fn {item, index} -> - render_item(item, index, state) - end) - - stack(:vertical, rows) - end - - defp render_item(item, index, state) do - is_selected = index == state.cursor - style = if is_selected, do: state.selected_style, else: state.style - - if style do - styled(text(item), style) - else - text(item) - end - end -end -``` - -### Key Points for Stateful Widgets - -1. **Use the behaviour**: `use TermUI.StatefulComponent` -2. **Provide `new/1`** to create props from options -3. **Implement `init/1`** to initialize state from props -4. **Implement `handle_event/2`** for user interactions -5. **Implement `render/2`** to produce the render tree -6. **Return `{:ok, state}` or `{:ok, state, commands}`** from event handlers - -## Writing Tests - -**Tests are required for all new widgets.** See [Testing Framework](09-testing-framework.md) for comprehensive testing documentation. - -Create a test file in `test/term_ui/widgets/`: - -```elixir -defmodule TermUI.Widgets.MyWidgetTest do - use ExUnit.Case, async: true - - alias TermUI.Widgets.MyWidget - - describe "render/1" do - test "renders with required options" do - result = MyWidget.render(value: 42) - - assert result.type == :text - assert result.content =~ "42" - end - - test "applies custom width" do - result = MyWidget.render(value: 1, width: 10) - - assert String.length(result.content) == 10 - end - - test "applies style when provided" do - style = TermUI.Renderer.Style.new(fg: :red) - result = MyWidget.render(value: 42, style: style) - - assert result.type == :box - assert result.style == style - end - - test "raises on missing required option" do - assert_raise KeyError, fn -> - MyWidget.render([]) - end - end - end - - describe "simple/2" do - test "creates widget with defaults" do - result = MyWidget.simple(100) - - assert result.type == :text - end - end -end -``` - -### Test Categories to Cover - -1. **Required options** - Verify required params raise on missing -2. **Default values** - Test behavior with minimal options -3. **All options** - Test each option individually -4. **Edge cases** - Empty data, zero values, extreme values -5. **Styling** - Verify styles are applied correctly -6. **For stateful widgets**: - - Initial state from props - - Event handling (keyboard, mouse) - - State transitions - - Callback invocation - -## File Organization - -``` -lib/term_ui/widgets/ -├── my_widget.ex # Your widget module - -test/term_ui/widgets/ -├── my_widget_test.exs # Your widget tests - -examples/my_widget/ # Optional: example application -├── mix.exs -├── run.exs -├── README.md -└── lib/my_widget/ - ├── application.ex - └── app.ex -``` - -## Checklist Before Submitting a PR - -### Code Quality - -- [ ] Widget has comprehensive `@moduledoc` with usage examples -- [ ] All public functions have `@doc` and `@spec` -- [ ] Follows existing code style (run `mix format`) -- [ ] No compiler warnings (`mix compile --warnings-as-errors`) - -### Testing - -- [ ] Test file exists in `test/term_ui/widgets/` -- [ ] Tests cover all public functions -- [ ] Tests cover edge cases -- [ ] All tests pass (`mix test`) -- [ ] Tests are async when possible (`use ExUnit.Case, async: true`) - -### Documentation - -- [ ] Module documentation explains the widget's purpose -- [ ] Usage examples in `@moduledoc` -- [ ] All options documented in `render/1` or `new/1` -- [ ] Keyboard controls documented for stateful widgets - -### Optional but Appreciated - -- [ ] Example application in `examples/` -- [ ] Example has README with installation instructions - -## Submitting Your PR - -### 1. Fork and Branch - -```bash -git checkout -b feature/my-widget -``` - -### 2. Implement and Test - -```bash -# Run tests -mix test test/term_ui/widgets/my_widget_test.exs - -# Run all tests -mix test - -# Check formatting -mix format --check-formatted - -# Check for warnings -mix compile --warnings-as-errors -``` - -### 3. Commit with Clear Message - -```bash -git add lib/term_ui/widgets/my_widget.ex test/term_ui/widgets/my_widget_test.exs -git commit -m "Add MyWidget for [purpose] - -- Implements [feature 1] -- Supports [feature 2] -- Includes comprehensive tests" -``` - -### 4. Create Pull Request - -Your PR description should include: - -- **What**: Brief description of the widget -- **Why**: Use case or motivation -- **How**: Key implementation details -- **Testing**: How to test the widget -- **Screenshots**: If applicable, show the widget in action - -### PR Requirements - -1. **Tests must pass** - CI will verify this -2. **Tests must be included** - PRs without tests will not be merged -3. **Code must be formatted** - Run `mix format` -4. **No new warnings** - Compile with `--warnings-as-errors` - -## Examples of Good PRs - -Look at existing widgets for reference: - -- **Simple stateless**: `lib/term_ui/widgets/gauge.ex` -- **Data visualization**: `lib/term_ui/widgets/sparkline.ex` -- **Interactive stateful**: `lib/term_ui/widgets/menu.ex` -- **Complex stateful**: `lib/term_ui/widgets/table.ex` - -## Getting Help - -- Open an issue to discuss your widget idea before implementing -- Ask questions in the PR if you need guidance -- Review existing widget implementations for patterns - -## Next Steps - -- [Testing Framework](09-testing-framework.md) - Comprehensive testing guide -- [Architecture Overview](01-architecture-overview.md) - Understand the system -- [Elm Implementation](07-elm-implementation.md) - Learn the component model -- [Rendering Pipeline](03-rendering-pipeline.md) - How widgets become output diff --git a/guides/developer/09-testing-framework.md b/guides/developer/09-testing-framework.md deleted file mode 100644 index 9f92b903..00000000 --- a/guides/developer/09-testing-framework.md +++ /dev/null @@ -1,472 +0,0 @@ -# Testing Framework - -This guide covers TermUI's testing framework for component and widget testing. - -## Overview - -TermUI provides a comprehensive testing framework in `TermUI.Test.*` with four key modules: - -| Module | Purpose | -|--------|---------| -| `ComponentHarness` | Mount and test components in isolation | -| `TestRenderer` | Capture rendered output for inspection | -| `EventSimulator` | Create synthetic events for testing | -| `Assertions` | TUI-specific test assertions | - -## Quick Start - -```elixir -defmodule MyWidgetTest do - use ExUnit.Case, async: true - use TermUI.Test.Assertions - - alias TermUI.Test.{ComponentHarness, EventSimulator, TestRenderer} - - test "widget renders and responds to events" do - # Mount component - {:ok, harness} = ComponentHarness.mount_test(MyWidget, initial_value: 0) - - # Render and check output - harness = ComponentHarness.render(harness) - renderer = ComponentHarness.get_renderer(harness) - assert_text_exists(renderer, "Value: 0") - - # Send event and verify state change - harness = ComponentHarness.send_event(harness, EventSimulator.simulate_key(:up)) - harness = ComponentHarness.render(harness) - assert_text_exists(renderer, "Value: 1") - - # Cleanup - ComponentHarness.unmount(harness) - end -end -``` - -## Component Harness - -The `ComponentHarness` mounts components in isolation for testing without the full runtime. - -### Mounting Components - -```elixir -# Basic mount -{:ok, harness} = ComponentHarness.mount_test(MyComponent) - -# With props -{:ok, harness} = ComponentHarness.mount_test(MyButton, label: "Click me") - -# With custom dimensions -{:ok, harness} = ComponentHarness.mount_test(MyWidget, width: 40, height: 10) -``` - -### Rendering - -```elixir -# Render component -harness = ComponentHarness.render(harness) - -# Get render result (the render tree) -render_tree = ComponentHarness.get_render(harness) - -# Get all renders (most recent first) -all_renders = ComponentHarness.get_renders(harness) -``` - -### Sending Events - -```elixir -# Single event -harness = ComponentHarness.send_event(harness, event) - -# Multiple events -harness = ComponentHarness.send_events(harness, [event1, event2, event3]) - -# Event + render cycle (common pattern) -harness = ComponentHarness.event_cycle(harness, event) -``` - -### Inspecting State - -```elixir -# Get full state -state = ComponentHarness.get_state(harness) - -# Get state at path -value = ComponentHarness.get_state_at(harness, [:counter, :value]) - -# Direct state manipulation (use sparingly) -harness = ComponentHarness.set_state(harness, %{count: 10}) -harness = ComponentHarness.update_state(harness, fn s -> %{s | count: s.count + 1} end) -``` - -### Cleanup - -```elixir -# Always unmount when done -ComponentHarness.unmount(harness) - -# Or reset to initial state -{:ok, harness} = ComponentHarness.reset(harness) -``` - -## Test Renderer - -The `TestRenderer` captures rendered output to a buffer for inspection. - -### Creating a Renderer - -```elixir -{:ok, renderer} = TestRenderer.new(24, 80) # 24 rows, 80 columns -``` - -### Writing Content - -```elixir -# Write a string -TestRenderer.write_string(renderer, 1, 1, "Hello, World!") - -# Set individual cell -TestRenderer.set_cell(renderer, 1, 1, Cell.new("X", fg: :red)) - -# Clear buffer -TestRenderer.clear(renderer) -``` - -### Reading Content - -```elixir -# Get text at position -text = TestRenderer.get_text_at(renderer, 1, 1, 5) # "Hello" - -# Get entire row -row_text = TestRenderer.get_row_text(renderer, 1) - -# Get cell -cell = TestRenderer.get_cell(renderer, 1, 1) - -# Get style at position -style = TestRenderer.get_style_at(renderer, 1, 1) -# => %{fg: :red, bg: :default, attrs: MapSet.new([:bold])} -``` - -### Searching Content - -```elixir -# Check if text exists at position -TestRenderer.text_at?(renderer, 1, 1, "Hello") # true/false - -# Check if region contains text -TestRenderer.text_contains?(renderer, 1, 1, 80, "Error") - -# Find all occurrences -positions = TestRenderer.find_text(renderer, "Error") -# => [{5, 10}, {12, 3}] -``` - -### Snapshots - -Snapshots capture buffer state for comparison: - -```elixir -# Take snapshot -snapshot = TestRenderer.snapshot(renderer) - -# Compare to snapshot -TestRenderer.matches_snapshot?(renderer, snapshot) # true/false - -# Get differences -diffs = TestRenderer.diff_snapshot(renderer, snapshot) -# => [{row, col, expected_cell, actual_cell}, ...] - -# Convert to string for debugging -TestRenderer.to_string(renderer) -TestRenderer.snapshot_to_string(snapshot) -``` - -### Cleanup - -```elixir -TestRenderer.destroy(renderer) -``` - -## Event Simulator - -The `EventSimulator` creates synthetic events without terminal input. - -### Keyboard Events - -```elixir -# Basic key press -event = EventSimulator.simulate_key(:enter) -event = EventSimulator.simulate_key(:up) -event = EventSimulator.simulate_key(:escape) - -# Key with character -event = EventSimulator.simulate_key(:a, char: "a") - -# Key with modifiers -event = EventSimulator.simulate_key(:c, modifiers: [:ctrl]) -event = EventSimulator.simulate_key(:s, modifiers: [:ctrl, :shift]) - -# Function keys -event = EventSimulator.simulate_function_key(1) # F1 -event = EventSimulator.simulate_function_key(12) # F12 - -# Navigation keys -event = EventSimulator.simulate_navigation(:up) -event = EventSimulator.simulate_navigation(:page_down) -event = EventSimulator.simulate_navigation(:home) -``` - -### Common Shortcuts - -```elixir -EventSimulator.simulate_shortcut(:copy) # Ctrl+C -EventSimulator.simulate_shortcut(:paste) # Ctrl+V -EventSimulator.simulate_shortcut(:cut) # Ctrl+X -EventSimulator.simulate_shortcut(:save) # Ctrl+S -EventSimulator.simulate_shortcut(:quit) # Ctrl+Q -EventSimulator.simulate_shortcut(:undo) # Ctrl+Z -EventSimulator.simulate_shortcut(:redo) # Ctrl+Shift+Z -EventSimulator.simulate_shortcut(:select_all) # Ctrl+A -``` - -### Typing Text - -```elixir -# Simulate typing a string (returns list of events) -events = EventSimulator.simulate_type("Hello") -# => [%Key{key: :h, char: "H"}, %Key{key: :e, char: "e"}, ...] - -# Send all events -harness = ComponentHarness.send_events(harness, events) -``` - -### Key Sequences - -```elixir -# Simulate sequence of keys -events = EventSimulator.simulate_sequence([:tab, :tab, :enter]) - -# With options -events = EventSimulator.simulate_sequence([ - {:a, char: "a"}, - :tab, - :enter -]) -``` - -### Mouse Events - -```elixir -# Click -event = EventSimulator.simulate_click(10, 20) # left click -event = EventSimulator.simulate_click(10, 20, :right) # right click -event = EventSimulator.simulate_click(10, 20, :left, modifiers: [:ctrl]) - -# Double click -event = EventSimulator.simulate_double_click(10, 20) - -# Mouse movement -event = EventSimulator.simulate_move(15, 25) - -# Drag -event = EventSimulator.simulate_drag(10, 20, :left) - -# Scroll -event = EventSimulator.simulate_scroll_up(10, 20) -event = EventSimulator.simulate_scroll_down(10, 20) -``` - -### Other Events - -```elixir -# Focus events -event = EventSimulator.simulate_focus_gained() -event = EventSimulator.simulate_focus_lost() - -# Resize -event = EventSimulator.simulate_resize(120, 40) - -# Paste -event = EventSimulator.simulate_paste("Pasted content") -``` - -## Assertions - -Import assertions with `use TermUI.Test.Assertions`. - -### Text Assertions - -```elixir -# Assert exact text at position -assert_text(renderer, 1, 1, "Hello") - -# Assert text does NOT appear -refute_text(renderer, 1, 1, "Goodbye") - -# Assert region contains text -assert_text_contains(renderer, 1, 1, 80, "Error") -refute_text_contains(renderer, 1, 1, 80, "Success") - -# Assert text exists anywhere in buffer -assert_text_exists(renderer, "Error") -refute_text_exists(renderer, "Secret") - -# Assert entire row matches -assert_row(renderer, 1, "Hello, World!") -``` - -### Style Assertions - -```elixir -# Assert foreground color -assert_style(renderer, 1, 1, fg: :red) - -# Assert background color -assert_style(renderer, 1, 1, bg: :white) - -# Assert multiple style properties -assert_style(renderer, 1, 1, fg: :red, bg: :white, attrs: [:bold]) - -# Assert single attribute -assert_attr(renderer, 1, 1, :bold) -refute_attr(renderer, 1, 1, :underline) -``` - -### State Assertions - -```elixir -# Assert state at path -assert_state(state, [:counter, :value], 42) -refute_state(state, [:counter, :value], 0) - -# Assert state exists (not nil) -assert_state_exists(state, [:user, :name]) -``` - -### Snapshot Assertions - -```elixir -# Take snapshot -snapshot = TestRenderer.snapshot(renderer) - -# ... perform operations ... - -# Assert matches snapshot -assert_snapshot(renderer, snapshot) -``` - -### Buffer Assertions - -```elixir -# Assert buffer is empty -assert_empty(renderer) -``` - -## Testing Patterns - -### Testing State Transitions - -```elixir -test "counter increments on up arrow" do - {:ok, harness} = ComponentHarness.mount_test(Counter, initial: 0) - - # Initial state - assert ComponentHarness.get_state(harness).count == 0 - - # Send event - harness = ComponentHarness.send_event(harness, EventSimulator.simulate_key(:up)) - - # Verify state changed - assert ComponentHarness.get_state(harness).count == 1 -end -``` - -### Testing Rendered Output - -```elixir -test "displays current count" do - {:ok, harness} = ComponentHarness.mount_test(Counter, initial: 42) - harness = ComponentHarness.render(harness) - - renderer = ComponentHarness.get_renderer(harness) - assert_text_exists(renderer, "Count: 42") -end -``` - -### Testing Event Sequences - -```elixir -test "navigation through menu" do - {:ok, harness} = ComponentHarness.mount_test(Menu, items: ["A", "B", "C"]) - - # Navigate down twice - harness = - harness - |> ComponentHarness.event_cycle(EventSimulator.simulate_key(:down)) - |> ComponentHarness.event_cycle(EventSimulator.simulate_key(:down)) - - # Should be on third item - assert ComponentHarness.get_state(harness).selected == 2 -end -``` - -### Testing with Snapshots - -```elixir -test "render output matches expected" do - {:ok, harness} = ComponentHarness.mount_test(MyWidget) - harness = ComponentHarness.render(harness) - - renderer = ComponentHarness.get_renderer(harness) - snapshot = TestRenderer.snapshot(renderer) - - # Store snapshot for regression testing - # In real tests, you'd load this from a file - expected = %{ - rows: 24, - cols: 80, - cells: %{...} - } - - assert_snapshot(renderer, expected) -end -``` - -### Testing Edge Cases - -```elixir -test "handles empty list" do - {:ok, harness} = ComponentHarness.mount_test(List, items: []) - harness = ComponentHarness.render(harness) - - renderer = ComponentHarness.get_renderer(harness) - assert_text_exists(renderer, "No items") -end - -test "handles boundary navigation" do - {:ok, harness} = ComponentHarness.mount_test(List, items: ["Only item"]) - - # Try to go down when already at bottom - harness = ComponentHarness.send_event(harness, EventSimulator.simulate_key(:down)) - - # Should stay at 0 - assert ComponentHarness.get_state(harness).selected == 0 -end -``` - -## Best Practices - -1. **Use `async: true`** for isolated tests -2. **Always call `unmount/1`** to clean up resources -3. **Test state and render separately** for clarity -4. **Use `event_cycle/2`** for common send-event-then-render pattern -5. **Prefer event simulation** over direct state manipulation -6. **Use assertions** for clear failure messages -7. **Test edge cases**: empty data, boundaries, invalid input - -## Next Steps - -- [Creating Widgets](08-creating-widgets.md) - Widget implementation guide -- [Architecture Overview](01-architecture-overview.md) - System architecture diff --git a/guides/developer/README.md b/guides/developer/README.md deleted file mode 100644 index 8d15ada0..00000000 --- a/guides/developer/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# Developer Guides - -Technical documentation for TermUI internals and architecture. - -## Guides - -| Guide | Description | -|-------|-------------| -| [01-architecture-overview.md](01-architecture-overview.md) | System layers, process hierarchy, data flow | -| [02-runtime-internals.md](02-runtime-internals.md) | GenServer event loop, state management, lifecycle | -| [03-rendering-pipeline.md](03-rendering-pipeline.md) | View → Buffer → Diff → Output stages | -| [04-event-system.md](04-event-system.md) | Input parsing, escape sequences, dispatch | -| [05-buffer-management.md](05-buffer-management.md) | ETS double buffering, cell storage | -| [06-terminal-layer.md](06-terminal-layer.md) | Raw mode, ANSI sequences, platform handling | -| [07-elm-implementation.md](07-elm-implementation.md) | The Elm Architecture adapted for OTP | -| [08-creating-widgets.md](08-creating-widgets.md) | How to create and contribute new widgets | -| [09-testing-framework.md](09-testing-framework.md) | Component and widget testing framework | - -## Reading Order - -For new contributors: - -1. **Architecture Overview** - Understand the layers -2. **Elm Implementation** - Learn the component model -3. **Runtime Internals** - See how components are orchestrated -4. **Event System** - Follow input from terminal to component -5. **Rendering Pipeline** - Follow output from component to terminal -6. **Buffer Management** - Understand the ETS buffer system -7. **Terminal Layer** - Low-level terminal details - -## Key Concepts - -### Three-Layer Architecture - -``` -┌─────────────────────────────────────┐ -│ Widget Layer │ ← Components (Elm Architecture) -├─────────────────────────────────────┤ -│ Renderer Layer │ ← Buffers, Diff, Output -├─────────────────────────────────────┤ -│ Port Layer │ ← Terminal I/O -└─────────────────────────────────────┘ -``` - -### Data Flow - -``` -Event → event_to_msg → Message → update → State → view → Render Tree → Buffer → Diff → Terminal -``` - -### Key Files - -| File | Purpose | -|------|---------| -| `lib/term_ui/runtime.ex` | Central GenServer orchestrating everything | -| `lib/term_ui/renderer/buffer.ex` | ETS-backed screen buffer | -| `lib/term_ui/renderer/diff.ex` | Differential rendering algorithm | -| `lib/term_ui/renderer/sequence_buffer.ex` | ANSI sequence batching | -| `lib/term_ui/terminal.ex` | Raw mode and terminal control | -| `lib/term_ui/terminal/input_reader.ex` | Stdin reading and event parsing | -| `lib/term_ui/terminal/escape_parser.ex` | Escape sequence parsing | - -## Diagrams - -All guides include Mermaid diagrams. To view them: - -- GitHub renders Mermaid automatically -- VS Code with Markdown Preview Mermaid extension -- [Mermaid Live Editor](https://mermaid.live/) diff --git a/guides/interaction.md b/guides/interaction.md new file mode 100644 index 00000000..3524c628 --- /dev/null +++ b/guides/interaction.md @@ -0,0 +1,113 @@ +# Clipboard, selection, and mouse interaction + +TermUI keeps interaction state in the Elm application. It does not use a +global mouse registry, a selection process, or direct clipboard writes. + +## Clipboard commands + +`TermUI.Clipboard.copy/2` and `TermUI.Clipboard.clear/1` create command data. +The runtime sends each operation through the backend owner. Thus, clipboard +output is in sequence with frame output and terminal cleanup. + +```elixir +def update({:copy, text}, state) do + {state, [TermUI.Clipboard.copy(text, on_result: &{:clipboard_done, &1})]} +end + +def update({:clipboard_done, :ok}, state), do: state +def update({:clipboard_done, {:error, reason}}, state), do: put_error(state, reason) +``` + +The result mapper receives `:ok` or `{:error, reason}`. A backend that does not +implement clipboard output returns an error. Clipboard data has a 100,000-byte +default limit. Use `:max_bytes` to set a smaller or larger positive limit. + +The implementation uses OSC 52. The available targets are `:clipboard`, +`:primary`, and `:secondary`. `osc52_supported?/0` is only a terminal +heuristic. A terminal can still refuse the operation. + +Bracketed paste is separate from clipboard output. A backend enables bracketed +paste and reports its content as `TermUI.Event.Paste`. + +## Text selection + +`TermUI.Selection` is pure data. Positions are zero-based Unicode grapheme +offsets. A range is half-open: `{start, finish}` includes `start` and excludes +`finish`. + +```elixir +selection = + TermUI.Selection.new() + |> TermUI.Selection.start(1) + |> TermUI.Selection.extend(3) + +TermUI.Selection.extract(selection, "a界🙂z") +#=> "界🙂" +``` + +The module supports forward and backward ranges, replacement, select all, +word selection, and line selection. `TextInput` and `TextArea` support: + +- Shift with Left, Right, Home, and End. +- Shift with Up and Down in `TextArea`. +- Ctrl+A, Ctrl+C, and Ctrl+X. +- Mouse press and drag selection. +- Paste or text replacement of the selected range. +- Selection removal with Backspace or Delete. + +Copy and cut actions return `{:copy, text}` to the parent. The parent can +convert this message to `TermUI.Clipboard.copy/2`. + +## Mouse routing + +Terminal mouse coordinates are zero-based. Build regions from the same layout +that creates the frame. Then route the event before you call a child widget. + +Raw terminals do not enable mouse reporting by default because it changes the +terminal's native text selection. Enable the smallest mode that your +application needs: + +```elixir +TermUI.run(MyApp, + backend: :raw, + backend_opts: [mouse_tracking: :drag] +) +``` + +The modes are `:none`, `:click`, `:drag`, and `:all`. Use `:click` for press +and release events. Use `:drag` for button motion. Use `:all` only when hover +motion is necessary. + +```elixir +regions = [ + TermUI.Mouse.region(:list, 2, 3, 30, 10), + TermUI.Mouse.region(:dialog, 8, 5, 40, 12, z_index: 10) +] + +case TermUI.Mouse.route(regions, event) do + {:ok, :list, local_event} -> + TermUI.Widget.mouse(TermUI.Widget.List, local_event, state.list, {30, 10}) + + {:ok, :dialog, local_event} -> + handle_dialog_mouse(local_event, state) + + :none -> + {state, []} +end +``` + +The highest `:z_index` wins. The later region wins when two regions have the +same z-index. `route_all/2` returns all matches in front-to-back order. + +`TermUI.Mouse.Tracker` gives pure hover and drag state. Its default drag +threshold is one terminal cell. Store the tracker in the application state. +Reset it when focus is lost. + +Widgets can implement the optional `mouse/3` callback. Call it through +`TermUI.Widget.mouse/4`. The helper uses the widget's `mouse/3` callback when +it exists. Otherwise, it sends the event to `update/2`. + +Interactive catalog widgets support local mouse input. This includes text +inputs, buttons, lists, menus, pick lists, command palettes, dialogs, forms, +tabs, tables, trees, scrollbars, and split panes. Scrollable content widgets +also accept mouse wheel events through `update/2`. diff --git a/guides/markdown-and-diffs.md b/guides/markdown-and-diffs.md new file mode 100644 index 00000000..3774b3c3 --- /dev/null +++ b/guides/markdown-and-diffs.md @@ -0,0 +1,42 @@ +# Markdown and diff viewers + +## Markdown + +`TermUI.Markdown` parses Markdown with MDEx. It returns styled frame rows. + +```elixir +rows = TermUI.Markdown.render(markdown, 80) +frame = TermUI.Frame.from_rows(rows, 80, 24) +``` + +Use `TermUI.Widget.MarkdownViewer` for scrolling and code-block selection. A +copy action returns `{:copy, code}` to the parent. The widget does not access +the system clipboard. + +```elixir +viewer = TermUI.Widget.MarkdownViewer.init(content: markdown) +{viewer, messages} = TermUI.Widget.MarkdownViewer.update(event, viewer) +frame = TermUI.Widget.MarkdownViewer.view(viewer, {80, 24}) +``` + +The viewer supports headings, emphasis, strong and strike-through text, inline +code, links, images, quotes, ordered and unordered lists, task lists, code +blocks, rules, and tables. Raw HTML is reduced to terminal-safe text. + +## Diffs + +Create a diff from two texts: + +```elixir +viewer = + TermUI.Widget.DiffViewer.init( + before: old_text, + after: new_text, + old_label: "a/file.ex", + new_label: "b/file.ex" + ) +``` + +Or supply an existing unified diff with `:unified_diff`. Press `s` to switch +between unified and side-by-side views. The viewer uses line-based Myers +comparison and bounds input to 5,000 lines by default. diff --git a/guides/migration-1.0.md b/guides/migration-1.0.md new file mode 100644 index 00000000..cf40184a --- /dev/null +++ b/guides/migration-1.0.md @@ -0,0 +1,48 @@ +# Migration to TermUI 1.0 + +TermUI 1.0 removes the pre-release component and render systems. It does not +provide compatibility aliases. + +## Public replacements + +| Before 1.0 | TermUI 1.0 | +| --- | --- | +| `TermUI.App` | `TermUI.run/2`, `TermUI.start_link/2`, or `TermUI.Runtime` | +| `TermUI.Component` and `TermUI.StatefulComponent` | One `TermUI.Elm` application or a pure `TermUI.Widget` | +| Component servers, registry, and supervisor | Parent-owned state in the root application | +| `TermUI.Component.RenderNode` | `TermUI.Frame` | +| Renderer buffers and tuple nodes | `TermUI.Frame` | +| `TermUI.Renderer.Cell` | `TermUI.Cell` | +| `TermUI.Renderer.Style` | `TermUI.Style` | +| `TermUI.Input.*` and terminal input readers | The `TermUI.Backend` input callback | +| Printable `Event.Key.char` input | `TermUI.Event.Text` | +| Component command tuples | `TermUI.Command` constructors | +| `TermUI.Widgets.*` | The matching parent-owned module under `TermUI.Widget.*` | + +The widget feature set is available under the singular namespace. For example, +`TermUI.Widgets.Table` becomes `TermUI.Widget.Table`, and +`TermUI.Widgets.MarkdownViewer` becomes `TermUI.Widget.MarkdownViewer`. +Widgets now return `TermUI.Frame` and never require a component PID. + +Production structs now derive their fields and defaults from Zoi schemas. +Direct struct update syntax still works. Use the public `schema/0` functions +when data enters TermUI from an external source. + +## Required application changes + +1. Select one root module and use `TermUI.Elm`. +2. Move child process state into the root state or a normal domain process. +3. Convert terminal events in `event_to_msg/2`. +4. Return command structs from `update/2`. +5. Replace render nodes and buffers with `TermUI.Frame.from_rows/4` or cell writes. +6. Handle `Event.Resize` and store the new `{columns, rows}`. +7. Start with `TermUI.run(MyApp)` or `TermUI.start_link(MyApp)`. + +## Backend changes + +Replace cursor, clear, and cell-list render callbacks with `draw/2`. The value +passed to `draw/2` is the complete frame. Keep terminal input, output, size, +cursor, capability detection, setup, and cleanup inside the backend. + +The old SSH backend is removed. Add a new SSH backend only when it can own the +complete input and terminal lifecycle for its session. diff --git a/guides/removed-and-deferred.md b/guides/removed-and-deferred.md new file mode 100644 index 00000000..212a79b9 --- /dev/null +++ b/guides/removed-and-deferred.md @@ -0,0 +1,60 @@ +# Removed and deferred features + +The 1.0 release candidate has one runtime, one input path, one frame type, and +one widget namespace. Some old features did not fit this design. + +## Intentional architecture removals + +These systems will not return in their old form: + +| Removed system | Replacement | +| --- | --- | +| Component server, registry, supervisor, containers, and state persistence | One Elm runtime and parent-owned pure widget state | +| Event router, event queue, message queue, and global focus manager | The Elm application serializes events and owns focus | +| Legacy raw, TTY, selector, and line-reader input modules | The selected backend owns one input path | +| Render nodes, renderer buffers, buffer managers, and renderer style/cell copies | `TermUI.Frame`, `TermUI.Cell`, and backend rendering | +| `TermUI.Widgets` and process-based widgets | `TermUI.Widget` pure state transitions | +| Global spatial index and mouse tracker | Pure `TermUI.Mouse.Region` lists and application-owned `TermUI.Mouse.Tracker` data | +| Global configuration and persistent-term caches | Runtime and widget options stored by their owner | + +## Deferred optional features + +These features are not in the new package: + +- SSH backend support. +- A high-level theme registry and automatic capability-based theme fallback. +- A constraint layout solver, layout cache, and alignment objects. +- A shared shortcut-sequence service and global focus traversal groups. +- Development hot reload, UI inspection, state inspection, and performance tools. +- The component test harness, event simulator, and test renderer. +- Dedicated Unix and Windows platform adapter modules. +- The old set of one application for each widget. The counter is the retained + general example. + +SSH can return only as a normal backend that owns its complete state. Layout +and focus helpers can return as pure functions. These forms keep the core +design unchanged. + +Clipboard, selection, and mouse support have returned in refined forms. +Clipboard writes are bounded command data. Selection uses Unicode grapheme +positions. Mouse regions, hit testing, hover, and drag state are pure data. + +## Widget behavior that became smaller + +The widget names are present, but some old adapters and services are not: + +| Widget area | Current behavior | Removed behavior | +| --- | --- | --- | +| Stream | Bounded parent-supplied items | GenStage consumer and backpressure process | +| Process, supervision, and cluster views | Parent-supplied snapshots | Process inspection, polling, distributed RPC, and monitoring processes | +| Toast | Pure state with explicit `tick/2` | Timer process and global stack service | +| Line input | Pure event-driven input | Blocking shell `IO.gets/1` adapter | +| Markdown code blocks | Emits copy data that the parent can send as a clipboard command | Direct clipboard writes | +| Menu and context menu | Flat actions, separators, keyboard and mouse control, and an overlay position | Submenu processes and inline service variants | +| Table | Scrolling, columns, and row selection | Built-in sorting service | +| Form | Text, checkbox, select, and required-field validation | General validation framework and field processes | +| Split pane and scrollbar | Pure state with local mouse drag and click behavior | Global mouse routing services | + +Applications can add these effects in their Elm update function. A reusable +adapter belongs in TermUI only when it can stay pure or follow the backend and +command contracts. diff --git a/guides/user/01-overview.md b/guides/user/01-overview.md deleted file mode 100644 index 48241355..00000000 --- a/guides/user/01-overview.md +++ /dev/null @@ -1,139 +0,0 @@ -# TermUI Overview - -TermUI is a direct-mode Terminal UI framework for Elixir/BEAM applications. It enables building rich, interactive terminal interfaces that leverage the BEAM's unique strengths: fault tolerance, the actor model, hot code reloading, and distribution. - -## What is TermUI? - -TermUI provides everything you need to build terminal-based user interfaces: - -- **The Elm Architecture** - A proven pattern for building interactive UIs with predictable state management -- **Rich Widget Library** - Pre-built components like gauges, tables, sparklines, and more -- **Declarative Styling** - Fluent API for colors, attributes, and themes -- **Flexible Layout** - Constraint-based layout system with automatic sizing -- **Full Input Support** - Keyboard, mouse, paste, and focus events -- **High Performance** - Differential rendering at 60 FPS with minimal terminal updates - -## Architecture Overview - -``` -┌─────────────────────────────────────────────────────────┐ -│ Your Application │ -│ ┌─────────────────────────────────────────────────┐ │ -│ │ Elm Components │ │ -│ │ init → event_to_msg → update → view │ │ -│ └─────────────────────────────────────────────────┘ │ -├─────────────────────────────────────────────────────────┤ -│ TermUI Runtime │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ Events │ │ Commands │ │ Renderer │ │ -│ └──────────┘ └──────────┘ └──────────┘ │ -├─────────────────────────────────────────────────────────┤ -│ Terminal Layer │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ Raw Mode │ │ Mouse │ │ Screen │ │ -│ └──────────┘ └──────────┘ └──────────┘ │ -└─────────────────────────────────────────────────────────┘ -``` - -## Core Concepts - -### The Elm Architecture - -TermUI uses The Elm Architecture, a pattern for building interactive programs: - -1. **Model** - Your application state (a plain Elixir map or struct) -2. **Update** - A function that takes a message and state, returns new state -3. **View** - A function that renders state to the screen - -```elixir -defmodule Counter do - use TermUI.Elm - - def init(_opts), do: %{count: 0} - - def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :increment} - def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :decrement} - def event_to_msg(_, _), do: :ignore - - def update(:increment, state), do: {%{state | count: state.count + 1}, []} - def update(:decrement, state), do: {%{state | count: state.count - 1}, []} - - def view(state) do - text("Count: #{state.count}") - end -end -``` - -### Events and Messages - -Terminal input (keys, mouse, resize) arrives as **events**. Your component converts events to **messages** via `event_to_msg/2`. Messages drive state changes through `update/2`. - -### Commands - -Side effects (timers, file I/O, etc.) are represented as **commands** returned from `update/2`. The runtime executes them asynchronously and delivers results back as messages. - -### Rendering - -The `view/1` function returns a **render tree** - a declarative description of what should appear on screen. TermUI diffs this against the previous frame and sends only the changes to the terminal. - -## Key Features - -### Widgets - -Pre-built components for common UI patterns: - -| Widget | Description | -|--------|-------------| -| `Gauge` | Progress bar with color zones | -| `Sparkline` | Compact inline trend graph | -| `Table` | Scrollable data table | -| `Menu` | Selectable menu items | -| `TextInput` | Text entry field | -| `Dialog` | Modal dialog box | - -### Styling - -Rich styling with colors and attributes: - -```elixir -Style.new(fg: :cyan, bg: :black, attrs: [:bold, :underline]) -``` - -Supports 16 colors, 256-color palette, and true color (24-bit RGB). - -### Layout - -Declarative constraints for flexible layouts: - -```elixir -stack(:horizontal, [ - {gauge, Constraint.percentage(30)}, - {table, Constraint.fill()} -]) -``` - -### Terminal Features - -TermUI supports two backend modes with automatic selection: - -- **Raw Mode** - Full TUI experience with alternate screen, character-by-character input, and mouse support -- **TTY Mode** - IEx-compatible mode for development and debugging - -See [Getting Started: Backends](02-getting-started.md#understanding-backends-raw-vs-tty) for details on when each mode is used. - -## Requirements - -- Elixir 1.15+ -- OTP 28+ -- A terminal emulator with ANSI support - -## Next Steps - -- [Getting Started](02-getting-started.md) - Build your first TermUI app -- [The Elm Architecture](03-elm-architecture.md) - Deep dive into the component model -- [Events](04-events.md) - Handle keyboard, mouse, and other input -- [Styling](05-styling.md) - Colors, attributes, and themes -- [Layout](06-layout.md) - Positioning and sizing components -- [Widgets](07-widgets.md) - Using built-in widgets -- [Terminal](08-terminal.md) - Low-level terminal control -- [Commands](09-commands.md) - Side effects and async operations diff --git a/guides/user/02-getting-started.md b/guides/user/02-getting-started.md deleted file mode 100644 index 3baa484c..00000000 --- a/guides/user/02-getting-started.md +++ /dev/null @@ -1,293 +0,0 @@ -# Getting Started - -This guide walks you through creating your first TermUI application. - -## Installation - -Add TermUI to your dependencies in `mix.exs`: - -```elixir -def deps do - [ - {:term_ui, path: "../term_ui"} # Or from Hex when published - ] -end -``` - -Then fetch dependencies: - -```bash -mix deps.get -``` - -## Understanding Backends: Raw vs TTY - -TermUI supports two terminal backends that are automatically selected based on your environment: - -### Raw Mode (Full TUI Experience) - -Raw mode provides complete terminal control: - -- **Alternate screen buffer** - Preserves your shell history -- **Character-by-character input** - No line buffering -- **Full mouse support** - Click, drag, and scroll events -- **Live UI updates** - Smooth 60 FPS rendering - -**When it's used:** -- Running from command line (`mix run`, `mix termui.run`) -- Terminal supports raw mode (OTP 28+) -- No other shell is running - -### TTY Mode (IEx Compatible) - -TTY mode works inside IEx and other constrained environments: - -- **No alternate screen** - Output appears directly in terminal -- **Immediate character input** - Uses `:io.get_chars/2` for IEx compatibility -- **Reduced feature set** - Mouse support may be limited -- **Works in IEx** - Perfect for development and debugging - -**When it's used:** -- Running inside IEx -- A shell is already running -- Raw mode activation fails - -### Automatic Backend Selection - -TermUI automatically selects the appropriate backend: - -1. Attempts raw mode first -2. Falls back to TTY mode if: - - IEx is detected - - A shell is already running - - Raw mode is unavailable - -You can also force a specific mode: - -```elixir -# Force raw mode -TermUI.Runtime.run(root: MyApp.Counter, backend: :raw) - -# Force TTY mode -TermUI.Runtime.run(root: MyApp.Counter, backend: :tty) -``` - -### Which Should You Use? - -| Scenario | Recommended Mode | -|----------|------------------| -| Production application | Raw (auto-detected) | -| Development in IEx | TTY (auto-detected) | -| Testing/Debugging | TTY for IEx convenience | -| SSH sessions | Auto (usually TTY) | - -The same code works in both modes - no changes needed! - -## Your First Application - -Let's build a simple counter that responds to keyboard input. - -### Step 1: Create the Component - -Create `lib/my_app/counter.ex`: - -```elixir -defmodule MyApp.Counter do - @moduledoc """ - A simple counter component demonstrating TermUI basics. - """ - - use TermUI.Elm - - alias TermUI.Event - alias TermUI.Renderer.Style - - # Initialize state - def init(_opts) do - %{count: 0} - end - - # Convert events to messages - def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"] do - {:msg, :quit} - end - - def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :increment} - def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :decrement} - def event_to_msg(_, _state), do: :ignore - - # Update state based on messages - def update(:quit, state) do - {state, [:quit]} - end - - def update(:increment, state) do - {%{state | count: state.count + 1}, []} - end - - def update(:decrement, state) do - {%{state | count: state.count - 1}, []} - end - - # Render the view - def view(state) do - stack(:vertical, [ - text("Simple Counter", Style.new(fg: :cyan, attrs: [:bold])), - text(""), - text("Count: #{state.count}", Style.new(fg: :white)), - text(""), - text("[↑] Increment [↓] Decrement [Q] Quit", Style.new(fg: :bright_black)) - ]) - end -end -``` - -### Step 2: Create the Entry Point - -Create `lib/my_app.ex`: - -```elixir -defmodule MyApp do - @moduledoc """ - Entry point for the counter application. - """ - - def run do - TermUI.Runtime.run(root: MyApp.Counter) - end - - def start do - TermUI.Runtime.start_link(root: MyApp.Counter) - end -end -``` - -### Step 3: Run the Application - -```bash -mix termui.run -``` - -The `mix termui.run` command will automatically discover and run your root module (`MyApp` in this case). - -You should see your counter application. Press `↑` to increment, `↓` to decrement, and `Q` to quit. - -## Understanding the Code - -### The `use TermUI.Elm` Macro - -This sets up your module as an Elm Architecture component, importing necessary functions like `text/1`, `text/2`, and `stack/2`. - -### The Four Callbacks - -1. **`init/1`** - Called once when the component starts. Returns initial state. - -2. **`event_to_msg/2`** - Converts terminal events to application messages. Return values: - - `{:msg, message}` - Send message to `update/2` - - `:ignore` - Discard the event - - `:propagate` - Pass to parent component - -3. **`update/2`** - Handles messages and returns `{new_state, commands}`. Commands are side effects like timers or quit requests. - -4. **`view/1`** - Returns a render tree describing what to display. - -### Render Tree Primitives - -- `text(string)` - Plain text -- `text(string, style)` - Styled text -- `stack(:vertical, children)` - Vertical layout -- `stack(:horizontal, children)` - Horizontal layout - -## Adding More Features - -### Color Based on Value - -```elixir -def view(state) do - count_style = cond do - state.count > 0 -> Style.new(fg: :green) - state.count < 0 -> Style.new(fg: :red) - true -> Style.new(fg: :white) - end - - stack(:vertical, [ - text("Count: #{state.count}", count_style), - # ... - ]) -end -``` - -### Reset Functionality - -Add to `event_to_msg/2`: - -```elixir -def event_to_msg(%Event.Key{key: key}, _state) when key in ["r", "R"] do - {:msg, :reset} -end -``` - -Add to `update/2`: - -```elixir -def update(:reset, state) do - {%{state | count: 0}, []} -end -``` - -### Using Widgets - -```elixir -alias TermUI.Widgets.Gauge - -def view(state) do - # Normalize count to 0-100 range for gauge - gauge_value = max(0, min(100, state.count + 50)) - - stack(:vertical, [ - text("Counter with Gauge"), - text(""), - Gauge.render(value: gauge_value, width: 30), - text(""), - text("Count: #{state.count}") - ]) -end -``` - -## Running in IEx - -For development and debugging, you can run your app in IEx using TTY mode: - -```bash -iex -S mix -``` - -Then in IEx: - -```elixir -iex> MyApp.run() -``` - -The app will run in TTY mode, which: -- Works inside IEx without taking over the shell completely -- Provides immediate character input (no Enter needed) -- Displays output directly in the terminal - -For the full TUI experience with alternate screen, run from command line instead: - -```bash -mix termui.run -``` - -or - -```bash -mix run -e "MyApp.run()" --no-halt -``` - -## Next Steps - -- [The Elm Architecture](03-elm-architecture.md) - Learn the pattern in depth -- [Events](04-events.md) - Handle all types of input -- [Styling](05-styling.md) - Make your app visually appealing -- [Widgets](07-widgets.md) - Use pre-built components diff --git a/guides/user/03-elm-architecture.md b/guides/user/03-elm-architecture.md deleted file mode 100644 index b8df543d..00000000 --- a/guides/user/03-elm-architecture.md +++ /dev/null @@ -1,352 +0,0 @@ -# The Elm Architecture - -The Elm Architecture (TEA) is the core pattern used by TermUI for building interactive applications. It provides predictable state management and a clear separation of concerns. - -## Overview - -The architecture consists of three parts: - -1. **Model** - The state of your application -2. **Update** - How state changes in response to messages -3. **View** - How state is rendered to the screen - -``` - ┌─────────────────────────────────────────┐ - │ │ - │ ┌─────────┐ message ┌──────────┐ │ - │ │ View │ ◄────────── │ Update │ │ - │ └────┬────┘ └────▲─────┘ │ - │ │ │ │ - │ │ render tree │ msg │ - │ ▼ │ │ - │ ┌─────────┐ event ┌────┴─────┐ │ - │ │ Runtime │ ──────────►│event_to_ │ │ - │ │ │ │ msg │ │ - │ └─────────┘ └──────────┘ │ - │ │ - └─────────────────────────────────────────┘ -``` - -## The Four Callbacks - -### `init/1` - Initialize State - -Called once when your component starts. Receives options and returns initial state. - -```elixir -def init(opts) do - name = Keyword.get(opts, :name, "World") - %{ - name: name, - count: 0, - items: [] - } -end -``` - -State is typically a map, but can be any Elixir term. - -### `event_to_msg/2` - Convert Events to Messages - -Transforms terminal events into application-specific messages. - -```elixir -def event_to_msg(%Event.Key{key: :enter}, state) do - {:msg, {:submit, state.input}} -end - -def event_to_msg(%Event.Key{key: :escape}, _state) do - {:msg, :cancel} -end - -def event_to_msg(%Event.Mouse{action: :click, x: x, y: y}, _state) do - {:msg, {:clicked, x, y}} -end - -def event_to_msg(_event, _state) do - :ignore -end -``` - -**Return values:** - -| Return | Effect | -|--------|--------| -| `{:msg, message}` | Send message to `update/2` | -| `:ignore` | Discard the event | -| `:propagate` | Pass to parent component | - -### `update/2` - Handle Messages - -Receives a message and current state, returns new state and commands. - -```elixir -def update(:increment, state) do - {%{state | count: state.count + 1}, []} -end - -def update({:set_name, name}, state) do - {%{state | name: name}, []} -end - -def update(:save, state) do - # Use timer with 0 delay to perform side effect on next tick - {state, [Command.timer(0, :do_save)]} -end - -def update(:do_save, state) do - # Perform the file write synchronously - File.write("data.txt", state.data) - {%{state | saved: true}, []} -end -``` - -**Return format:** `{new_state, commands}` - -- `new_state` - The updated state -- `commands` - List of side effects to execute (can be empty `[]`) - -### `view/1` - Render State - -Transforms state into a render tree describing what to display. - -```elixir -def view(state) do - stack(:vertical, [ - text("Hello, #{state.name}!", Style.new(fg: :cyan)), - text(""), - text("Count: #{state.count}"), - render_items(state.items) - ]) -end - -defp render_items([]), do: text("No items") -defp render_items(items) do - stack(:vertical, Enum.map(items, fn item -> - text("• #{item}") - end)) -end -``` - -The view function should be **pure** - given the same state, it always returns the same render tree. - -## Message Flow - -Here's the complete flow when a user presses a key: - -1. **Input** - User presses `↑` key -2. **Event** - Runtime creates `%Event.Key{key: :up}` -3. **Routing** - Event sent to focused component -4. **Transform** - `event_to_msg(%Event.Key{key: :up}, state)` returns `{:msg, :increment}` -5. **Update** - `update(:increment, state)` returns `{new_state, []}` -6. **Dirty** - Component marked for re-render -7. **Render** - On next frame, `view(new_state)` called -8. **Diff** - Render tree compared to previous -9. **Output** - Only changes sent to terminal - -## Commands - -Commands represent side effects that happen outside the pure update cycle. - -```elixir -def update(:start_timer, state) do - {state, [Command.timer(1000, :timer_tick)]} -end - -def update(:timer_tick, state) do - {%{state | ticks: state.ticks + 1}, []} -end -``` - -See [Commands](09-commands.md) for full documentation. - -## State Design - -### Keep State Minimal - -Only store what you need to render and respond to events: - -```elixir -# Good - minimal state -%{ - selected_index: 0, - items: ["a", "b", "c"] -} - -# Avoid - derived data in state -%{ - selected_index: 0, - items: ["a", "b", "c"], - selected_item: "a", # Can be derived - item_count: 3 # Can be derived -} -``` - -### Derive Values in View - -Compute derived values when rendering: - -```elixir -def view(state) do - selected_item = Enum.at(state.items, state.selected_index) - item_count = length(state.items) - - stack(:vertical, [ - text("Selected: #{selected_item}"), - text("Total: #{item_count} items") - ]) -end -``` - -### Normalize State Updates - -Use helper functions for complex state changes: - -```elixir -def update(:next_item, state) do - {select_next(state), []} -end - -def update(:prev_item, state) do - {select_prev(state), []} -end - -defp select_next(state) do - max_index = length(state.items) - 1 - new_index = min(state.selected_index + 1, max_index) - %{state | selected_index: new_index} -end - -defp select_prev(state) do - new_index = max(state.selected_index - 1, 0) - %{state | selected_index: new_index} -end -``` - -## Patterns - -### Loading States - -```elixir -def init(_opts) do - %{status: :loading, data: nil, error: nil} -end - -def update(:load, state) do - # Use timer to trigger loading on next tick - {%{state | status: :loading}, [Command.timer(0, :do_load)]} -end - -def update(:do_load, state) do - # Perform the fetch synchronously (or spawn a Task for async) - case fetch_data() do - {:ok, data} -> - {%{state | status: :ready, data: data}, []} - {:error, reason} -> - {%{state | status: :error, error: reason}, []} - end -end - -def view(state) do - case state.status do - :loading -> text("Loading...") - :error -> text("Error: #{state.error}", Style.new(fg: :red)) - :ready -> render_data(state.data) - end -end -``` - -### Form Input - -```elixir -def init(_opts) do - %{name: "", email: "", focused: :name} -end - -def event_to_msg(%Event.Key{key: :tab}, _state), do: {:msg, :next_field} -def event_to_msg(%Event.Key{char: char}, state) when is_binary(char) do - {:msg, {:input, state.focused, char}} -end - -def update(:next_field, state) do - next = case state.focused do - :name -> :email - :email -> :name - end - {%{state | focused: next}, []} -end - -def update({:input, field, char}, state) do - current = Map.get(state, field) - {Map.put(state, field, current <> char), []} -end -``` - -### Confirmation Dialogs - -```elixir -def init(_opts) do - %{items: [...], confirm_delete: nil} -end - -def update({:request_delete, item}, state) do - {%{state | confirm_delete: item}, []} -end - -def update(:confirm_delete, state) do - items = List.delete(state.items, state.confirm_delete) - {%{state | items: items, confirm_delete: nil}, []} -end - -def update(:cancel_delete, state) do - {%{state | confirm_delete: nil}, []} -end - -def view(state) do - if state.confirm_delete do - render_confirm_dialog(state.confirm_delete) - else - render_items(state.items) - end -end -``` - -## Testing - -The Elm Architecture makes testing straightforward: - -```elixir -defmodule MyApp.CounterTest do - use ExUnit.Case - - alias MyApp.Counter - - test "init returns zero count" do - state = Counter.init([]) - assert state.count == 0 - end - - test "increment increases count" do - state = %{count: 5} - {new_state, []} = Counter.update(:increment, state) - assert new_state.count == 6 - end - - test "up key sends increment message" do - event = %Event.Key{key: :up} - assert {:msg, :increment} = Counter.event_to_msg(event, %{}) - end - - test "view renders count" do - state = %{count: 42} - tree = Counter.view(state) - # Assert on render tree structure - end -end -``` - -## Next Steps - -- [Events](04-events.md) - All event types and handling -- [Commands](09-commands.md) - Side effects in detail -- [Widgets](07-widgets.md) - Pre-built components diff --git a/guides/user/04-events.md b/guides/user/04-events.md deleted file mode 100644 index 992a3a28..00000000 --- a/guides/user/04-events.md +++ /dev/null @@ -1,362 +0,0 @@ -# Events - -TermUI delivers terminal input as structured events to your components. This guide covers all event types and how to handle them. - -## Event Types - -### Key Events - -Keyboard input including regular characters, special keys, and modifier combinations. - -```elixir -%Event.Key{ - key: :enter, # Atom for special keys, string for characters - char: nil, # Character string (nil for special keys) - modifiers: [:ctrl], # List of :ctrl, :alt, :shift - timestamp: 123456789 # Monotonic time in milliseconds -} -``` - -**Special Keys:** - -| Key | Atom | -|-----|------| -| Enter | `:enter` | -| Escape | `:escape` | -| Tab | `:tab` | -| Backspace | `:backspace` | -| Delete | `:delete` | -| Insert | `:insert` | -| Home | `:home` | -| End | `:end` | -| Page Up | `:page_up` | -| Page Down | `:page_down` | -| Arrow Up | `:up` | -| Arrow Down | `:down` | -| Arrow Left | `:left` | -| Arrow Right | `:right` | -| F1-F12 | `:f1` through `:f12` | - -**Character Keys:** - -Regular characters are delivered as strings: - -```elixir -%Event.Key{key: "a", char: "a"} # Lowercase a -%Event.Key{key: "A", char: "A"} # Uppercase A (shift held) -%Event.Key{key: " ", char: " "} # Space -%Event.Key{key: "1", char: "1"} # Number 1 -``` - -**Handling Key Events:** - -```elixir -# Match special keys -def event_to_msg(%Event.Key{key: :enter}, _state), do: {:msg, :submit} -def event_to_msg(%Event.Key{key: :escape}, _state), do: {:msg, :cancel} - -# Match characters (case-insensitive) -def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"] do - {:msg, :quit} -end - -# Match with modifiers -def event_to_msg(%Event.Key{key: "s", modifiers: [:ctrl]}, _state) do - {:msg, :save} -end - -# Match any character for text input -def event_to_msg(%Event.Key{char: char}, state) when is_binary(char) do - {:msg, {:char_input, char}} -end - -# Ignore unhandled keys -def event_to_msg(%Event.Key{}, _state), do: :ignore -``` - -### Mouse Events - -Mouse clicks, movement, and scrolling. - -```elixir -%Event.Mouse{ - action: :click, # :click, :double_click, :press, :release, :drag, :move - button: :left, # :left, :middle, :right, or nil - x: 10, # Column (0-indexed) - y: 5, # Row (0-indexed) - modifiers: [], # :ctrl, :alt, :shift - timestamp: 123456789 -} -``` - -**Mouse Actions:** - -| Action | Description | -|--------|-------------| -| `:press` | Button pressed down | -| `:release` | Button released | -| `:click` | Press and release | -| `:double_click` | Two clicks in quick succession | -| `:drag` | Movement with button held | -| `:move` | Movement without button | -| `:scroll_up` | Scroll wheel up | -| `:scroll_down` | Scroll wheel down | - -**Handling Mouse Events:** - -```elixir -def event_to_msg(%Event.Mouse{action: :click, x: x, y: y}, _state) do - {:msg, {:click, x, y}} -end - -def event_to_msg(%Event.Mouse{action: :scroll_up}, _state) do - {:msg, :scroll_up} -end - -def event_to_msg(%Event.Mouse{action: :scroll_down}, _state) do - {:msg, :scroll_down} -end - -def event_to_msg(%Event.Mouse{action: :drag, x: x, y: y}, _state) do - {:msg, {:drag, x, y}} -end -``` - -**Mouse Tracking Modes:** - -Mouse events require enabling mouse tracking: - -```elixir -# In Terminal setup (done automatically by Runtime) -Terminal.enable_mouse_tracking(:click) # Click events only -Terminal.enable_mouse_tracking(:drag) # Click and drag -Terminal.enable_mouse_tracking(:all) # All movement -``` - -### Resize Events - -Terminal window size changes. - -```elixir -%Event.Resize{ - width: 120, # New column count - height: 40, # New row count - timestamp: 123456789 -} -``` - -**Handling Resize:** - -```elixir -def event_to_msg(%Event.Resize{width: w, height: h}, _state) do - {:msg, {:resize, w, h}} -end - -def update({:resize, width, height}, state) do - {%{state | width: width, height: height}, []} -end -``` - -### Focus Events - -Terminal window focus changes. - -```elixir -%Event.Focus{ - action: :gained, # :gained or :lost - timestamp: 123456789 -} -``` - -**Handling Focus:** - -```elixir -def event_to_msg(%Event.Focus{action: :gained}, _state) do - {:msg, :focus_gained} -end - -def event_to_msg(%Event.Focus{action: :lost}, _state) do - {:msg, :focus_lost} -end - -def update(:focus_lost, state) do - # Pause animations, save state, etc. - {%{state | paused: true}, []} -end -``` - -### Paste Events - -Text pasted from clipboard (with bracketed paste mode). - -```elixir -%Event.Paste{ - content: "pasted text", - timestamp: 123456789 -} -``` - -**Handling Paste:** - -```elixir -def event_to_msg(%Event.Paste{content: text}, _state) do - {:msg, {:paste, text}} -end - -def update({:paste, text}, state) do - {%{state | input: state.input <> text}, []} -end -``` - -### Tick Events - -Timer-based periodic events. - -```elixir -%Event.Tick{ - interval: 1000, # Interval in milliseconds - timestamp: 123456789 -} -``` - -These are typically generated by commands rather than received directly. - -### Custom Events - -Application-defined events. - -```elixir -%Event.Custom{ - name: :data_loaded, - payload: %{items: [...]}, - timestamp: 123456789 -} -``` - -## Event Handling Patterns - -### Catch-All Handler - -Always include a catch-all to handle unexpected events: - -```elixir -def event_to_msg(_, _state), do: :ignore -``` - -### Conditional Handling - -Handle events differently based on state: - -```elixir -def event_to_msg(%Event.Key{key: :enter}, %{mode: :edit}) do - {:msg, :confirm_edit} -end - -def event_to_msg(%Event.Key{key: :enter}, %{mode: :view}) do - {:msg, :start_edit} -end -``` - -### Key Sequences - -Track key sequences for shortcuts: - -```elixir -def init(_opts) do - %{key_buffer: []} -end - -def event_to_msg(%Event.Key{key: "g"}, %{key_buffer: ["g"]}) do - {:msg, :go_to_top} # gg command -end - -def event_to_msg(%Event.Key{key: key}, _state) when is_binary(key) do - {:msg, {:key_pressed, key}} -end - -def update({:key_pressed, key}, state) do - buffer = [key | state.key_buffer] |> Enum.take(2) - {%{state | key_buffer: buffer}, [Command.timer(500, :clear_buffer)]} -end - -def update(:clear_buffer, state) do - {%{state | key_buffer: []}, []} -end -``` - -### Modal Input - -Different handling for different modes: - -```elixir -def event_to_msg(event, %{mode: :normal} = state) do - handle_normal_mode(event, state) -end - -def event_to_msg(event, %{mode: :insert} = state) do - handle_insert_mode(event, state) -end - -defp handle_normal_mode(%Event.Key{key: "i"}, _state), do: {:msg, :enter_insert} -defp handle_normal_mode(%Event.Key{key: "j"}, _state), do: {:msg, :move_down} -defp handle_normal_mode(%Event.Key{key: "k"}, _state), do: {:msg, :move_up} -defp handle_normal_mode(_, _), do: :ignore - -defp handle_insert_mode(%Event.Key{key: :escape}, _state), do: {:msg, :exit_insert} -defp handle_insert_mode(%Event.Key{char: char}, _state) when is_binary(char) do - {:msg, {:insert_char, char}} -end -defp handle_insert_mode(_, _), do: :ignore -``` - -## Event Constructors - -Create events programmatically (useful for testing): - -```elixir -# Key events -Event.key(:enter) -Event.key("a") -Event.key("s", modifiers: [:ctrl]) - -# Mouse events -Event.mouse(:click, :left, 10, 5) -Event.mouse(:scroll_up, nil, 10, 5) - -# Other events -Event.Resize.new(120, 40) -Event.Focus.new(:gained) -Event.Paste.new("text") -``` - -## Testing Events - -```elixir -defmodule MyApp.ComponentTest do - use ExUnit.Case - alias TermUI.Event - - test "enter key submits form" do - state = %{input: "test"} - event = Event.key(:enter) - - assert {:msg, :submit} = MyApp.Component.event_to_msg(event, state) - end - - test "ctrl+s saves" do - event = Event.key("s", modifiers: [:ctrl]) - assert {:msg, :save} = MyApp.Component.event_to_msg(event, %{}) - end - - test "click selects item" do - event = Event.mouse(:click, :left, 5, 10) - assert {:msg, {:select, 5, 10}} = MyApp.Component.event_to_msg(event, %{}) - end -end -``` - -## Next Steps - -- [Styling](05-styling.md) - Visual styling and themes -- [Commands](09-commands.md) - Timers and side effects -- [Terminal](08-terminal.md) - Low-level terminal control diff --git a/guides/user/05-styling.md b/guides/user/05-styling.md deleted file mode 100644 index eaedc383..00000000 --- a/guides/user/05-styling.md +++ /dev/null @@ -1,342 +0,0 @@ -# Styling - -TermUI provides a comprehensive styling system for colors, text attributes, and themes. - -## Style Basics - -Create styles using `Style.new/1`: - -```elixir -alias TermUI.Renderer.Style - -# Basic style -style = Style.new(fg: :cyan, bg: :black) - -# With attributes -style = Style.new(fg: :red, attrs: [:bold, :underline]) - -# Apply to text -text("Hello, World!", style) -``` - -## Colors - -### Named Colors (16 colors) - -Standard terminal colors supported everywhere: - -| Color | Normal | Bright | -|-------|--------|--------| -| Black | `:black` | `:bright_black` | -| Red | `:red` | `:bright_red` | -| Green | `:green` | `:bright_green` | -| Yellow | `:yellow` | `:bright_yellow` | -| Blue | `:blue` | `:bright_blue` | -| Magenta | `:magenta` | `:bright_magenta` | -| Cyan | `:cyan` | `:bright_cyan` | -| White | `:white` | `:bright_white` | - -```elixir -Style.new(fg: :cyan) -Style.new(fg: :bright_yellow, bg: :blue) -``` - -### 256-Color Palette - -Extended palette for more color options: - -```elixir -# Color index 0-255 -Style.new(fg: 196) # Bright red -Style.new(bg: 236) # Dark gray -``` - -Color ranges: -- 0-15: Standard colors (same as named) -- 16-231: 6×6×6 color cube -- 232-255: Grayscale ramp - -### True Color (24-bit RGB) - -Full RGB support on modern terminals: - -```elixir -Style.new(fg: {255, 128, 0}) # Orange -Style.new(bg: {30, 30, 30}) # Dark gray -``` - -### Default Color - -Use terminal's default foreground/background: - -```elixir -Style.new(fg: :default) -Style.new(bg: :default) -``` - -## Text Attributes - -Modify text appearance: - -| Attribute | Effect | -|-----------|--------| -| `:bold` | Bold/bright text | -| `:dim` | Dimmed/faint text | -| `:italic` | Italic text | -| `:underline` | Underlined text | -| `:blink` | Blinking text | -| `:reverse` | Swap foreground/background | -| `:hidden` | Hidden text | -| `:strikethrough` | Strikethrough text | - -```elixir -Style.new(attrs: [:bold]) -Style.new(attrs: [:bold, :underline]) -Style.new(fg: :red, attrs: [:bold, :italic]) -``` - -**Note:** Not all terminals support all attributes. `bold`, `underline`, and `reverse` have the widest support. - -## Fluent API - -Build styles with method chaining: - -```elixir -style = Style.new() - |> Style.fg(:blue) - |> Style.bg(:white) - |> Style.bold() - |> Style.underline() -``` - -Available methods: -- `Style.fg(style, color)` - Set foreground -- `Style.bg(style, color)` - Set background -- `Style.bold(style)` - Add bold -- `Style.dim(style)` - Add dim -- `Style.italic(style)` - Add italic -- `Style.underline(style)` - Add underline -- `Style.blink(style)` - Add blink -- `Style.reverse(style)` - Add reverse -- `Style.hidden(style)` - Add hidden -- `Style.strikethrough(style)` - Add strikethrough - -## Style Merging - -Combine styles with later values overriding earlier: - -```elixir -base = Style.new(fg: :white, bg: :black) -highlight = Style.new(fg: :yellow, attrs: [:bold]) - -merged = Style.merge(base, highlight) -# Result: fg: :yellow, bg: :black, attrs: [:bold] -``` - -## Using Styles in Views - -### Styled Text - -```elixir -def view(state) do - title_style = Style.new(fg: :cyan, attrs: [:bold]) - body_style = Style.new(fg: :white) - - stack(:vertical, [ - text("My Application", title_style), - text(""), - text("Welcome!", body_style) - ]) -end -``` - -### Conditional Styling - -```elixir -def view(state) do - status_style = case state.status do - :ok -> Style.new(fg: :green) - :warning -> Style.new(fg: :yellow) - :error -> Style.new(fg: :red, attrs: [:bold]) - end - - text("Status: #{state.status}", status_style) -end -``` - -### Style Variables - -Define reusable styles: - -```elixir -defmodule MyApp.Styles do - alias TermUI.Renderer.Style - - def header, do: Style.new(fg: :cyan, attrs: [:bold]) - def label, do: Style.new(fg: :bright_black) - def value, do: Style.new(fg: :white) - def error, do: Style.new(fg: :red, attrs: [:bold]) - def success, do: Style.new(fg: :green) - def selected, do: Style.new(fg: :black, bg: :cyan) -end -``` - -Usage: - -```elixir -alias MyApp.Styles - -def view(state) do - stack(:vertical, [ - text("Dashboard", Styles.header()), - text("CPU:", Styles.label()), - text("#{state.cpu}%", Styles.value()) - ]) -end -``` - -## Themes - -Create theme maps for consistent styling: - -```elixir -defmodule MyApp.Theme do - alias TermUI.Renderer.Style - - def dark do - %{ - header: Style.new(fg: :cyan, attrs: [:bold]), - border: Style.new(fg: :cyan), - text: Style.new(fg: :white), - muted: Style.new(fg: :bright_black), - selected: Style.new(fg: :black, bg: :cyan), - error: Style.new(fg: :red), - success: Style.new(fg: :green) - } - end - - def light do - %{ - header: Style.new(fg: :blue, attrs: [:bold]), - border: Style.new(fg: :blue), - text: Style.new(fg: :black), - muted: Style.new(fg: :bright_black), - selected: Style.new(fg: :white, bg: :blue), - error: Style.new(fg: :red), - success: Style.new(fg: :green) - } - end -end -``` - -Usage with theme switching: - -```elixir -def init(_opts) do - %{theme: :dark} -end - -def event_to_msg(%Event.Key{key: "t"}, _state), do: {:msg, :toggle_theme} - -def update(:toggle_theme, state) do - new_theme = if state.theme == :dark, do: :light, else: :dark - {%{state | theme: new_theme}, []} -end - -def view(state) do - theme = case state.theme do - :dark -> MyApp.Theme.dark() - :light -> MyApp.Theme.light() - end - - stack(:vertical, [ - text("My App", theme.header), - text("Press T to toggle theme", theme.muted) - ]) -end -``` - -## Widget Styling - -Widgets accept styles in their options: - -```elixir -alias TermUI.Widgets.Gauge - -Gauge.render( - value: 75, - width: 20, - style: Style.new(fg: :green) -) -``` - -### Color Zones - -Some widgets support color zones based on value: - -```elixir -Gauge.render( - value: cpu_percent, - width: 20, - zones: [ - {0, Style.new(fg: :green)}, # 0-59%: green - {60, Style.new(fg: :yellow)}, # 60-79%: yellow - {80, Style.new(fg: :red)} # 80-100%: red - ] -) -``` - -## Best Practices - -### 1. Use Semantic Names - -```elixir -# Good - semantic meaning -error_style = Style.new(fg: :red) -success_style = Style.new(fg: :green) - -# Avoid - color-focused -red_style = Style.new(fg: :red) -``` - -### 2. Consider Accessibility - -- Ensure sufficient contrast between foreground and background -- Don't rely solely on color to convey information -- Use bold/underline for emphasis in addition to color - -### 3. Support Light and Dark - -Design themes that work on both light and dark terminal backgrounds: - -```elixir -# Works on dark background -Style.new(fg: :white) - -# Works on light background -Style.new(fg: :black) - -# Works on both (terminal default) -Style.new(fg: :cyan) # Typically visible on both -``` - -### 4. Minimize Style Changes - -The renderer optimizes style changes, but fewer changes means better performance: - -```elixir -# Good - one style for the whole line -text("Label: Value", Style.new(fg: :white)) - -# Less efficient - multiple style changes -stack(:horizontal, [ - text("Label: ", Style.new(fg: :bright_black)), - text("Value", Style.new(fg: :white)) -]) -``` - -## Next Steps - -- [Layout](06-layout.md) - Positioning and sizing -- [Widgets](07-widgets.md) - Pre-built styled components -- [Terminal](08-terminal.md) - Terminal capabilities diff --git a/guides/user/06-layout.md b/guides/user/06-layout.md deleted file mode 100644 index fdaa7801..00000000 --- a/guides/user/06-layout.md +++ /dev/null @@ -1,414 +0,0 @@ -# Layout - -TermUI provides a declarative layout system for positioning and sizing components. - -## Basic Layout - -### Vertical Stacking - -Stack elements from top to bottom: - -```elixir -stack(:vertical, [ - text("Header"), - text("Body"), - text("Footer") -]) -``` - -Output: -``` -Header -Body -Footer -``` - -### Horizontal Stacking - -Stack elements from left to right: - -```elixir -stack(:horizontal, [ - text("Left"), - text(" | "), - text("Right") -]) -``` - -Output: -``` -Left | Right -``` - -### Nested Layouts - -Combine stacks for complex layouts: - -```elixir -stack(:vertical, [ - text("=== Header ==="), - stack(:horizontal, [ - text("[Sidebar]"), - text(" "), - text("[Main Content]") - ]), - text("=== Footer ===") -]) -``` - -Output: -``` -=== Header === -[Sidebar] [Main Content] -=== Footer === -``` - -## Constraints - -Control how space is allocated using constraints. - -### Fixed Size - -Exact number of cells: - -```elixir -alias TermUI.Layout.Constraint - -stack(:horizontal, [ - {text("Fixed"), Constraint.length(10)}, - {text("Rest"), Constraint.fill()} -]) -``` - -### Percentage - -Proportion of available space: - -```elixir -stack(:horizontal, [ - {left_panel, Constraint.percentage(30)}, - {right_panel, Constraint.percentage(70)} -]) -``` - -### Fill - -Take all remaining space: - -```elixir -stack(:horizontal, [ - {sidebar, Constraint.length(20)}, # Fixed 20 columns - {content, Constraint.fill()} # Rest of the space -]) -``` - -### Ratio - -Proportional distribution: - -```elixir -stack(:horizontal, [ - {panel_a, Constraint.ratio(1)}, # 1 part - {panel_b, Constraint.ratio(2)}, # 2 parts - {panel_c, Constraint.ratio(1)} # 1 part -]) -# Results in 25%, 50%, 25% distribution -``` - -### Min and Max - -Set bounds on size: - -```elixir -# At least 10, at most 50 -Constraint.percentage(30) - |> Constraint.with_min(10) - |> Constraint.with_max(50) -``` - -## Common Layout Patterns - -### Header-Body-Footer - -```elixir -def view(state) do - stack(:vertical, [ - {render_header(state), Constraint.length(3)}, - {render_body(state), Constraint.fill()}, - {render_footer(state), Constraint.length(1)} - ]) -end - -defp render_header(state) do - text("=== My Application ===", Style.new(fg: :cyan, attrs: [:bold])) -end - -defp render_body(state) do - stack(:vertical, [ - text("Main content here"), - text("..."), - ]) -end - -defp render_footer(state) do - text("[Q]uit [H]elp", Style.new(fg: :bright_black)) -end -``` - -### Sidebar Layout - -```elixir -def view(state) do - stack(:horizontal, [ - {render_sidebar(state), Constraint.length(25)}, - {render_main(state), Constraint.fill()} - ]) -end - -defp render_sidebar(state) do - stack(:vertical, [ - text("Navigation", Style.new(attrs: [:bold])), - text(""), - text("• Dashboard"), - text("• Settings"), - text("• Help") - ]) -end - -defp render_main(state) do - text("Main content area") -end -``` - -### Two-Column Layout - -```elixir -def view(state) do - stack(:horizontal, [ - {left_column(state), Constraint.percentage(50)}, - {right_column(state), Constraint.percentage(50)} - ]) -end -``` - -### Dashboard Grid - -```elixir -def view(state) do - stack(:vertical, [ - # Top row - three equal panels - {stack(:horizontal, [ - {cpu_gauge(state), Constraint.ratio(1)}, - {memory_gauge(state), Constraint.ratio(1)}, - {disk_gauge(state), Constraint.ratio(1)} - ]), Constraint.length(5)}, - - # Bottom row - two panels - {stack(:horizontal, [ - {process_list(state), Constraint.percentage(60)}, - {network_stats(state), Constraint.percentage(40)} - ]), Constraint.fill()} - ]) -end -``` - -### Centered Content - -```elixir -def view(state) do - # Horizontal centering with fill on both sides - stack(:horizontal, [ - {text(""), Constraint.fill()}, - {render_dialog(state), Constraint.length(40)}, - {text(""), Constraint.fill()} - ]) -end -``` - -## Text Alignment - -Align text within available space: - -```elixir -alias TermUI.Layout.Alignment - -# Left aligned (default) -text("Left", alignment: :left) - -# Center aligned -text("Center", alignment: :center) - -# Right aligned -text("Right", alignment: :right) -``` - -## Box Drawing - -Create bordered containers: - -```elixir -def render_box(title, content) do - stack(:vertical, [ - text("┌─ #{title} " <> String.duplicate("─", 20) <> "┐"), - stack(:horizontal, [ - text("│ "), - content, - text(" │") - ]), - text("└" <> String.duplicate("─", 24) <> "┘") - ]) -end -``` - -## Responsive Layouts - -Adapt layout based on terminal size: - -```elixir -def view(%{width: width} = state) when width < 80 do - # Narrow layout - vertical stacking - stack(:vertical, [ - render_sidebar(state), - render_main(state) - ]) -end - -def view(state) do - # Wide layout - horizontal stacking - stack(:horizontal, [ - {render_sidebar(state), Constraint.length(25)}, - {render_main(state), Constraint.fill()} - ]) -end -``` - -Handle resize events: - -```elixir -def event_to_msg(%Event.Resize{width: w, height: h}, _state) do - {:msg, {:resize, w, h}} -end - -def update({:resize, width, height}, state) do - {%{state | width: width, height: height}, []} -end -``` - -## Empty Space - -Add spacing between elements: - -```elixir -# Empty line -text("") - -# Multiple empty lines -stack(:vertical, [ - text("First"), - text(""), - text(""), - text("Second") -]) - -# Horizontal space -stack(:horizontal, [ - text("Label:"), - text(" "), # 3 spaces - text("Value") -]) -``` - -## Conditional Rendering - -Show/hide elements based on state: - -```elixir -def view(state) do - stack(:vertical, [ - text("Header"), - if state.show_details do - render_details(state) - else - text("") - end, - text("Footer") - ]) -end -``` - -Or use list filtering: - -```elixir -def view(state) do - elements = [ - text("Header"), - state.show_details && render_details(state), - text("Footer") - ] - - stack(:vertical, Enum.filter(elements, & &1)) -end -``` - -## Performance Tips - -### 1. Avoid Deep Nesting - -Flatten layouts where possible: - -```elixir -# Less efficient -stack(:vertical, [ - stack(:vertical, [ - stack(:vertical, [ - text("Deeply nested") - ]) - ]) -]) - -# More efficient -stack(:vertical, [ - text("Flat") -]) -``` - -### 2. Use Constraints Sparingly - -Only specify constraints when needed: - -```elixir -# Simple case - no constraints needed -stack(:vertical, [ - text("Line 1"), - text("Line 2") -]) - -# Complex case - constraints needed -stack(:horizontal, [ - {sidebar, Constraint.length(20)}, - {content, Constraint.fill()} -]) -``` - -### 3. Memoize Complex Layouts - -For layouts that don't change often: - -```elixir -def view(state) do - stack(:vertical, [ - render_static_header(), # Cached internally - render_dynamic_content(state) # Recomputed each frame - ]) -end - -# Static content can be module attribute -@header text("My Application", Style.new(fg: :cyan)) -defp render_static_header, do: @header -``` - -## Next Steps - -- [Widgets](07-widgets.md) - Pre-built layout-aware components -- [Styling](05-styling.md) - Visual styling -- [Events](04-events.md) - Handle resize events diff --git a/guides/user/07-widgets.md b/guides/user/07-widgets.md deleted file mode 100644 index cbd70acf..00000000 --- a/guides/user/07-widgets.md +++ /dev/null @@ -1,583 +0,0 @@ -# Widgets - -TermUI includes pre-built widgets for common UI patterns. This guide covers the available widgets and how to use them. - -## Widget Types - -TermUI has two types of widgets: - -1. **Simple Widgets** - Stateless, render with keyword options (Gauge, Sparkline) -2. **Stateful Widgets** - Use the StatefulComponent pattern with `new/init/handle_event/render` - -## Simple Widgets - -### Gauge - -> **Example:** See [`examples/gauge/`](../../examples/gauge/) for a complete demonstration. - -Displays a value as a progress bar with optional color zones. - -```elixir -alias TermUI.Widgets.Gauge -alias TermUI.Renderer.Style - -# Basic gauge -Gauge.render(value: 75, width: 20) - -# With color zones -Gauge.render( - value: cpu_percent, - width: 20, - zones: [ - {0, Style.new(fg: :green)}, # 0-59: green - {60, Style.new(fg: :yellow)}, # 60-79: yellow - {80, Style.new(fg: :red)} # 80-100: red - ] -) - -# With value display -Gauge.render( - value: 42, - width: 30, - show_value: true, - show_range: true -) -``` - -**Options:** - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `value` | number | required | Current value (0-100) | -| `width` | integer | 20 | Width in characters | -| `zones` | list | `[]` | Color zones `[{threshold, style}]` | -| `show_value` | boolean | `false` | Display numeric value | -| `show_range` | boolean | `false` | Display min/max | -| `style` | Style | default | Base style | - -**Example Output:** -``` -[████████████░░░░░░░░] 60% -``` - -### Sparkline - -> **Example:** See [`examples/sparkline/`](../../examples/sparkline/) for a complete demonstration. - -Compact inline graph showing trends. - -```elixir -alias TermUI.Widgets.Sparkline - -# Basic sparkline -Sparkline.render(values: [10, 25, 40, 30, 50, 45, 60]) - -# With range -Sparkline.render( - values: history, - min: 0, - max: 100, - style: Style.new(fg: :cyan) -) -``` - -**Options:** - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `values` | list | required | List of numeric values | -| `min` | number | auto | Minimum value for scaling | -| `max` | number | auto | Maximum value for scaling | -| `style` | Style | default | Color style | - -**Example Output:** -``` -▁▂▄▃▆▅█ -``` - -Uses Unicode block characters (▁▂▃▄▅▆▇█) to show 8 levels of height. - -## Stateful Widgets - -Stateful widgets follow the StatefulComponent pattern: - -```elixir -# 1. Create props with Widget.new(opts) -props = Widget.new(option: value) - -# 2. Initialize state with Widget.init(props) -{:ok, widget_state} = Widget.init(props) - -# 3. Handle events with Widget.handle_event(event, state) -{:ok, widget_state} = Widget.handle_event(event, widget_state) - -# 4. Render with Widget.render(state, area) -node = Widget.render(widget_state, %{width: 80, height: 24}) -``` - -### Table - -> **Example:** See [`examples/table/`](../../examples/table/) for a complete demonstration. - -Scrollable data table with selection and sorting. - -```elixir -alias TermUI.Widgets.Table -alias TermUI.Widgets.Table.Column - -# Create props -props = Table.new( - columns: [ - Column.new(:name, "Name"), - Column.new(:age, "Age", width: 10, align: :right), - Column.new(:city, "City", width: 15) - ], - data: [ - %{name: "Alice", age: 30, city: "NYC"}, - %{name: "Bob", age: 25, city: "LA"}, - %{name: "Carol", age: 35, city: "Chicago"} - ], - selection_mode: :single, - on_select: fn row -> IO.inspect(row) end -) - -# Initialize -{:ok, table_state} = Table.init(props) - -# In your component's event handler -def update({:table_event, event}, state) do - {:ok, new_table} = Table.handle_event(event, state.table) - {%{state | table: new_table}, []} -end - -# In your view -def view(state) do - Table.render(state.table, %{width: 60, height: 15}) -end -``` - -**Options:** - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `columns` | list | required | Column definitions | -| `data` | list | required | List of row maps | -| `selection_mode` | atom | `:single` | `:none`, `:single`, or `:multi` | -| `sortable` | boolean | `true` | Enable column sorting | -| `on_select` | function | `nil` | Selection callback | -| `header_style` | Style | default | Header row style | -| `selected_style` | Style | reverse | Selected row style | - -**Keyboard Navigation:** -- Arrow keys: Move selection -- Page Up/Down: Scroll by page -- Home/End: Jump to first/last row -- Enter: Confirm selection -- Space: Toggle selection (multi mode) - -### Menu - -> **Example:** See [`examples/menu/`](../../examples/menu/) for a complete demonstration. - -Hierarchical menu with submenus and keyboard navigation. - -```elixir -alias TermUI.Widgets.Menu - -# Create props with item constructors -props = Menu.new( - items: [ - Menu.action(:new, "New File", shortcut: "Ctrl+N"), - Menu.action(:open, "Open...", shortcut: "Ctrl+O"), - Menu.separator(), - Menu.submenu(:recent, "Recent Files", [ - Menu.action(:file1, "document.txt"), - Menu.action(:file2, "notes.md") - ]), - Menu.separator(), - Menu.checkbox(:autosave, "Auto Save", checked: true), - Menu.action(:exit, "Exit", shortcut: "Ctrl+Q") - ], - on_select: fn id -> handle_menu_action(id) end -) - -# Initialize -{:ok, menu_state} = Menu.init(props) - -# Handle events and render -{:ok, menu_state} = Menu.handle_event(event, menu_state) -Menu.render(menu_state, %{width: 30, height: 20}) -``` - -**Item Types:** - -| Constructor | Description | -|------------|-------------| -| `Menu.action(id, label, opts)` | Selectable menu item | -| `Menu.submenu(id, label, children)` | Item with nested menu | -| `Menu.separator()` | Visual divider | -| `Menu.checkbox(id, label, opts)` | Toggleable item | - -**Keyboard Navigation:** -- Up/Down: Move between items -- Enter/Space: Select or expand submenu -- Left: Collapse submenu -- Right: Expand submenu -- Escape: Close menu - -### TextInput - -> **Example:** See [`examples/text_input/`](../../examples/text_input/) for a complete demonstration. - -Single-line and multi-line text input with cursor movement. - -```elixir -alias TermUI.Widgets.TextInput - -# Create props -props = TextInput.new( - placeholder: "Enter your name...", - width: 40, - multiline: false -) - -# Initialize -{:ok, input_state} = TextInput.init(props) - -# Handle events -{:ok, input_state} = TextInput.handle_event(event, input_state) - -# Get current value -value = TextInput.get_value(input_state) - -# Render -TextInput.render(input_state, %{width: 50, height: 1}) -``` - -**Options:** - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `value` | string | `""` | Initial text value | -| `placeholder` | string | `""` | Placeholder text | -| `width` | integer | 40 | Field width | -| `multiline` | boolean | `false` | Enable multi-line mode | -| `max_visible_lines` | integer | 5 | Lines before scrolling | -| `enter_submits` | boolean | `false` | Enter submits vs newline | -| `on_change` | function | `nil` | Value change callback | -| `on_submit` | function | `nil` | Submit callback | - -**Keyboard Controls:** -- Left/Right: Move cursor -- Up/Down: Move between lines (multiline) -- Home/End: Start/end of line -- Ctrl+Home/End: Start/end of text -- Backspace/Delete: Delete characters -- Ctrl+Enter: Insert newline (multiline) -- Enter: Submit or newline - -**Helper Functions:** - -```elixir -# Get current value -TextInput.get_value(state) # => "current text" - -# Get cursor position -TextInput.get_cursor(state) # => {row, col} - -# Get line count -TextInput.get_line_count(state) # => 3 - -# Set focus -state = TextInput.set_focused(state, true) - -# Clear input -state = TextInput.clear(state) -``` - -### Dialog - -> **Example:** See [`examples/dialog/`](../../examples/dialog/) for a complete demonstration. - -Modal dialog with buttons. - -```elixir -alias TermUI.Widgets.Dialog - -# Create props -props = Dialog.new( - title: "Confirm Delete", - content: text("Are you sure you want to delete this file?"), - buttons: [ - %{id: :cancel, label: "Cancel"}, - %{id: :confirm, label: "Delete", style: :danger} - ], - width: 50, - on_confirm: fn button_id -> handle_action(button_id) end -) - -# Initialize and use -{:ok, dialog_state} = Dialog.init(props) -{:ok, dialog_state} = Dialog.handle_event(event, dialog_state) -Dialog.render(dialog_state, %{width: 80, height: 24}) -``` - -**Options:** - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `title` | string | required | Dialog title | -| `content` | node | `nil` | Dialog body content | -| `buttons` | list | `[{id: :ok, label: "OK"}]` | Button definitions | -| `width` | integer | 40 | Dialog width | -| `closeable` | boolean | `true` | Escape closes dialog | -| `on_close` | function | `nil` | Close callback | -| `on_confirm` | function | `nil` | Button activation callback | - -**Keyboard Navigation:** -- Tab/Shift+Tab: Move between buttons -- Enter/Space: Activate focused button -- Escape: Close dialog - -### PickList - -> **Example:** See [`examples/pick_list/`](../../examples/pick_list/) for a complete demonstration. - -Modal selection dialog with type-ahead filtering. - -```elixir -alias TermUI.Widget.PickList - -# Create props -props = %{ - items: ["Apple", "Banana", "Cherry", "Date", "Elderberry"], - title: "Select Fruit", - width: 40, - height: 12, - on_select: fn item -> handle_selection(item) end, - on_cancel: fn -> handle_cancel() end -} - -# Initialize -{:ok, picklist_state} = PickList.init(props) - -# Handle events -{:ok, picklist_state} = PickList.handle_event(event, picklist_state) - -# Render -PickList.render(picklist_state, %{width: 80, height: 24}) -``` - -**Options:** - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `items` | list | required | List of items to display | -| `title` | string | `"Select"` | Modal title | -| `width` | integer | 40 | Modal width | -| `height` | integer | 10 | Modal height | -| `on_select` | function | `nil` | Selection callback `fn item -> ... end` | -| `on_cancel` | function | `nil` | Cancel callback `fn -> ... end` | -| `style` | map | `%{}` | Border/text style | -| `highlight_style` | map | inverted | Selected item style | - -**Keyboard Controls:** -- Up/Down: Navigate items -- Page Up/Down: Jump 10 items -- Home/End: Jump to first/last -- Enter: Confirm selection -- Escape: Cancel -- Typing: Filter items (type-ahead) -- Backspace: Remove filter character - -## Building Custom Widgets - -Create reusable widgets as functions: - -```elixir -defmodule MyApp.Widgets do - import TermUI.Component.Helpers - alias TermUI.Renderer.Style - - @doc """ - Renders a labeled value pair. - """ - def labeled_value(label, value, opts \\ []) do - label_style = Keyword.get(opts, :label_style, Style.new(fg: :bright_black)) - value_style = Keyword.get(opts, :value_style, Style.new(fg: :white)) - - stack(:horizontal, [ - text("#{label}: ", label_style), - text(to_string(value), value_style) - ]) - end - - @doc """ - Renders a bordered box with title. - """ - def box(title, content, opts \\ []) do - width = Keyword.get(opts, :width, 40) - border_style = Keyword.get(opts, :border_style, Style.new(fg: :cyan)) - - inner_width = width - 4 - top_border = "┌─ " <> title <> " " <> String.duplicate("─", inner_width - String.length(title) - 1) <> "┐" - bottom_border = "└" <> String.duplicate("─", width - 2) <> "┘" - - stack(:vertical, [ - text(top_border, border_style), - stack(:horizontal, [ - text("│ ", border_style), - content, - text(" │", border_style) - ]), - text(bottom_border, border_style) - ]) - end - - @doc """ - Renders a status indicator. - """ - def status_indicator(status) do - {symbol, style} = case status do - :ok -> {"●", Style.new(fg: :green)} - :warning -> {"●", Style.new(fg: :yellow)} - :error -> {"●", Style.new(fg: :red)} - :unknown -> {"○", Style.new(fg: :bright_black)} - end - - text(symbol, style) - end -end -``` - -Usage: - -```elixir -import MyApp.Widgets - -def view(state) do - stack(:vertical, [ - box("System Status", stack(:vertical, [ - stack(:horizontal, [ - status_indicator(:ok), - text(" "), - labeled_value("CPU", "#{state.cpu}%") - ]), - stack(:horizontal, [ - status_indicator(:warning), - text(" "), - labeled_value("Memory", "#{state.memory}%") - ]) - ])) - ]) -end -``` - -## Widget Composition - -Combine widgets for complex UIs: - -```elixir -alias TermUI.Widgets.{Gauge, Sparkline, Table} - -def view(state) do - stack(:vertical, [ - # Header with gauges - stack(:horizontal, [ - box("CPU", Gauge.render(value: state.cpu, width: 15)), - box("Memory", Gauge.render(value: state.mem, width: 15)) - ]), - - # Sparkline history - box("Network", stack(:vertical, [ - stack(:horizontal, [ - text("RX: "), - Sparkline.render(values: state.rx_history) - ]), - stack(:horizontal, [ - text("TX: "), - Sparkline.render(values: state.tx_history) - ]) - ])), - - # Process table (stateful widget) - Table.render(state.table, %{width: 60, height: 10}) - ]) -end -``` - -## Full Example: Component with TextInput - -```elixir -defmodule MyApp.SearchForm do - use TermUI.Elm - - alias TermUI.Event - alias TermUI.Widgets.TextInput - - def init(_opts) do - props = TextInput.new( - placeholder: "Search...", - width: 40 - ) - {:ok, input_state} = TextInput.init(props) - - %{ - input: TextInput.set_focused(input_state, true), - results: [] - } - end - - def event_to_msg(%Event.Key{key: :enter}, state) do - query = TextInput.get_value(state.input) - {:msg, {:search, query}} - end - - def event_to_msg(%Event.Key{key: "q"}, %{input: input}) do - # Only quit if input is empty - if TextInput.get_value(input) == "" do - {:msg, :quit} - else - {:msg, {:input_event, %Event.Key{key: "q", char: "q"}}} - end - end - - def event_to_msg(event, _state) do - {:msg, {:input_event, event}} - end - - def update(:quit, state), do: {state, [:quit]} - - def update({:input_event, event}, state) do - {:ok, new_input} = TextInput.handle_event(event, state.input) - {%{state | input: new_input}, []} - end - - def update({:search, query}, state) do - results = perform_search(query) - {%{state | results: results}, []} - end - - def view(state) do - stack(:vertical, [ - text("Search:", Style.new(fg: :cyan)), - TextInput.render(state.input, %{width: 50, height: 1}), - text(""), - render_results(state.results) - ]) - end - - defp perform_search(query), do: [] - defp render_results([]), do: text("No results") - defp render_results(results) do - stack(:vertical, Enum.map(results, &text(&1))) - end -end -``` - -## Next Steps - -- [Advanced Widgets](10-advanced-widgets.md) - Navigation, visualization, streaming, and BEAM introspection widgets -- [Styling](05-styling.md) - Customize widget appearance -- [Layout](06-layout.md) - Position widgets -- [Events](04-events.md) - Handle widget interactions diff --git a/guides/user/08-terminal.md b/guides/user/08-terminal.md deleted file mode 100644 index 0d16d81b..00000000 --- a/guides/user/08-terminal.md +++ /dev/null @@ -1,310 +0,0 @@ -# Terminal - -TermUI manages low-level terminal operations automatically, but understanding these features helps you build better applications. - -## Terminal Modes - -### Cooked Mode (Default) - -Normal terminal operation: -- Line buffering (input sent on Enter) -- Character echoing -- Signal handling (Ctrl+C sends SIGINT) - -### Raw Mode - -TermUI's operating mode: -- Character-by-character input -- No echoing -- No signal handling -- Full control over display - -The runtime enables raw mode automatically. It's restored when your app exits. - -## Alternate Screen - -Terminals have two screen buffers: - -- **Main screen** - The normal scrollback buffer -- **Alternate screen** - A separate buffer for full-screen apps - -TermUI uses the alternate screen, preserving the user's shell history. When your app exits, the terminal returns to the main screen with history intact. - -``` -┌─────────────────────┐ ┌─────────────────────┐ -│ $ ls │ │ ┌─────────────────┐ │ -│ file1.txt │ │ │ Your TermUI │ │ -│ file2.txt │ --> │ │ Application │ │ -│ $ my_app │ │ │ │ │ -│ │ │ └─────────────────┘ │ -│ Main Screen │ │ Alternate Screen │ -└─────────────────────┘ └─────────────────────┘ - │ - │ (exit) - ▼ - ┌─────────────────────┐ - │ $ ls │ - │ file1.txt │ - │ file2.txt │ - │ $ my_app │ - │ $ │ - │ Back to Main │ - └─────────────────────┘ -``` - -## Mouse Tracking - -TermUI can capture mouse events. - -### Tracking Modes - -| Mode | Events Captured | -|------|-----------------| -| `:click` | Button press/release | -| `:drag` | Click + drag movements | -| `:all` | All mouse movement | - -The runtime enables click tracking by default. - -### Mouse Coordinates - -Mouse positions are 0-indexed: -- `x` = column (0 = leftmost) -- `y` = row (0 = topmost) - -```elixir -def event_to_msg(%Event.Mouse{action: :click, x: x, y: y}, state) do - # Check if click is within a region - if x >= 10 and x < 30 and y >= 5 and y < 10 do - {:msg, :button_clicked} - else - :ignore - end -end -``` - -### Scroll Events - -Mouse wheel generates scroll events: - -```elixir -def event_to_msg(%Event.Mouse{action: :scroll_up}, _state) do - {:msg, :scroll_up} -end - -def event_to_msg(%Event.Mouse{action: :scroll_down}, _state) do - {:msg, :scroll_down} -end -``` - -## Focus Events - -Know when the terminal window gains or loses focus: - -```elixir -def event_to_msg(%Event.Focus{action: :gained}, _state) do - {:msg, :focus_gained} -end - -def event_to_msg(%Event.Focus{action: :lost}, _state) do - {:msg, :focus_lost} -end - -def update(:focus_lost, state) do - # Pause updates, dim display, etc. - {%{state | paused: true}, []} -end - -def update(:focus_gained, state) do - # Resume updates - {%{state | paused: false}, []} -end -``` - -**Note:** Focus events require terminal support. They work on most modern terminals (xterm, iTerm2, Alacritty, Kitty, Windows Terminal). - -## Terminal Size - -### Getting Size - -Query current dimensions: - -```elixir -{:ok, {rows, cols}} = TermUI.Terminal.get_terminal_size() -``` - -### Handling Resize - -Respond to window size changes: - -```elixir -def event_to_msg(%Event.Resize{width: w, height: h}, _state) do - {:msg, {:resize, w, h}} -end - -def update({:resize, width, height}, state) do - {%{state | width: width, height: height}, []} -end - -def view(state) do - if state.width < 80 do - render_compact_layout(state) - else - render_full_layout(state) - end -end -``` - -## Cursor Control - -The runtime manages cursor visibility and position. The cursor is hidden during normal operation to avoid flicker. - -For text input widgets that need a visible cursor: - -```elixir -# The cursor position is managed by the renderer -# Your TextInput widget indicates where the cursor should be -TextInput.render( - value: state.text, - cursor_position: state.cursor_pos, - focused: true # Shows cursor -) -``` - -## Color Support - -### Detection - -TermUI detects terminal color capabilities: -- 16 colors (basic) -- 256 colors (extended) -- True color (24-bit RGB) - -### Graceful Degradation - -Use named colors for maximum compatibility: - -```elixir -# Works everywhere -Style.new(fg: :red) - -# Requires 256-color support -Style.new(fg: 196) - -# Requires true color support -Style.new(fg: {255, 100, 50}) -``` - -The renderer automatically degrades colors for less capable terminals. - -## Clipboard - -### Paste Events - -Bracketed paste mode delivers pasted text as a single event: - -```elixir -def event_to_msg(%Event.Paste{content: text}, _state) do - {:msg, {:paste, text}} -end - -def update({:paste, text}, state) do - # Insert pasted text at cursor - new_text = state.text <> text - {%{state | text: new_text}, []} -end -``` - -Without bracketed paste, pasted text would arrive as individual key events, which is slower and may trigger unintended shortcuts. - -## Terminal Requirements - -### Minimum Requirements - -- ANSI escape sequence support -- UTF-8 encoding -- 80x24 minimum size - -### Recommended - -- 256-color or true color support -- Mouse tracking support -- Focus event support -- Unicode box drawing characters - -### Supported Terminals - -Tested and working: - -| Terminal | Platform | Notes | -|----------|----------|-------| -| Alacritty | Cross-platform | Full support | -| Kitty | Linux/macOS | Full support | -| iTerm2 | macOS | Full support | -| WezTerm | Cross-platform | Full support | -| GNOME Terminal | Linux | Full support | -| Windows Terminal | Windows | Full support | -| Terminal.app | macOS | Limited mouse | -| xterm | Cross-platform | Full support | - -### SSH Sessions - -TermUI works over SSH when the remote terminal supports required features. The runtime detects terminal capabilities through multiple methods to ensure SSH compatibility. - -## Error Handling - -### Terminal Not Available - -Handle cases where no terminal is present: - -```elixir -case TermUI.Runtime.start_link(root: MyApp) do - {:ok, pid} -> - # Running normally - pid - - {:error, :not_a_terminal} -> - IO.puts("Error: Must run in a terminal") - System.halt(1) -end -``` - -### Cleanup on Crash - -The runtime traps exits and restores terminal state even if your app crashes: - -```elixir -# In Runtime.init/1 -Process.flag(:trap_exit, true) - -# In Runtime.terminate/2 -Terminal.restore() # Always runs -``` - -This ensures users don't get stuck in raw mode with no echo. - -## Direct Terminal Access - -For advanced use cases, access terminal functions directly: - -```elixir -alias TermUI.Terminal - -# These are managed by Runtime, but available if needed: -Terminal.enable_raw_mode() -Terminal.disable_raw_mode() -Terminal.enter_alternate_screen() -Terminal.leave_alternate_screen() -Terminal.show_cursor() -Terminal.hide_cursor() -Terminal.clear_screen() -Terminal.set_cursor_position(row, col) -``` - -**Warning:** Direct terminal access can interfere with the runtime. Use only when necessary. - -## Next Steps - -- [Events](04-events.md) - Handle terminal input -- [Commands](09-commands.md) - Async operations -- [Styling](05-styling.md) - Colors and attributes diff --git a/guides/user/09-commands.md b/guides/user/09-commands.md deleted file mode 100644 index 93e295cc..00000000 --- a/guides/user/09-commands.md +++ /dev/null @@ -1,377 +0,0 @@ -# Commands - -Commands represent side effects in TermUI applications. They're returned from `update/2` and executed asynchronously by the runtime. - -## Why Commands? - -The Elm Architecture keeps `update/2` pure - it only transforms state based on messages. Side effects like timers, file I/O, and HTTP requests are described as commands and executed by the runtime. - -Benefits: -- **Testable** - Test state logic without mocking side effects -- **Predictable** - State changes are synchronous and traceable -- **Composable** - Combine multiple commands easily - -## Command Basics - -Return commands from `update/2`: - -```elixir -def update(:start_timer, state) do - # Return new state AND a list of commands - {state, [Command.timer(1000, :timer_done)]} -end - -def update(:timer_done, state) do - # Handle the result - {%{state | timer_fired: true}, []} -end -``` - -## Available Commands - -### Timer - -Execute a message after a delay: - -```elixir -# Fire :timeout message after 5 seconds -Command.timer(5000, :timeout) - -# With data in the message -Command.timer(1000, {:delayed_action, some_data}) -``` - -### Quit - -Request application shutdown: - -```elixir -def update(:quit, state) do - {state, [:quit]} -end - -# Or using Command module -def update(:quit, state) do - {state, [Command.quit()]} -end -``` - -The runtime will: -1. Stop accepting new events -2. Clean up resources -3. Restore terminal state -4. Exit the process - -### None - -Explicit no-op (useful for conditional commands): - -```elixir -def update(:maybe_save, state) do - cmd = if state.dirty do - Command.timer(0, :do_save) - else - Command.none() - end - {state, [cmd]} -end -``` - -## Command Patterns - -### Debouncing - -Delay action until input stops: - -```elixir -def init(_opts) do - %{search: "", debounce_ref: nil} -end - -def update({:search_input, text}, state) do - # Cancel previous timer if any - commands = if state.debounce_ref do - [] # Previous timer will be ignored - else - [] - end - - # Start new debounce timer - ref = make_ref() - commands = commands ++ [Command.timer(300, {:do_search, ref})] - - {%{state | search: text, debounce_ref: ref}, commands} -end - -def update({:do_search, ref}, %{debounce_ref: ref} = state) do - # Ref matches - this is the latest search - # Perform search... - {%{state | results: search(state.search)}, []} -end - -def update({:do_search, _old_ref}, state) do - # Ref doesn't match - ignore stale search - {state, []} -end -``` - -### Chained Operations - -Sequence multiple async operations: - -```elixir -def update(:start_workflow, state) do - {%{state | step: :loading}, [Command.timer(0, :step_1)]} -end - -def update(:step_1, state) do - # Do step 1... - {%{state | step: :step_1_done}, [Command.timer(100, :step_2)]} -end - -def update(:step_2, state) do - # Do step 2... - {%{state | step: :step_2_done}, [Command.timer(100, :step_3)]} -end - -def update(:step_3, state) do - {%{state | step: :complete}, []} -end -``` - -### Polling - -Periodic updates: - -```elixir -def init(_opts) do - # Start polling immediately - %{data: nil} -end - -def update(:init, state) do - {state, [Command.timer(0, :poll)]} -end - -def update(:poll, state) do - # Fetch new data - new_data = fetch_data() - - # Schedule next poll - {%{state | data: new_data}, [Command.timer(5000, :poll)]} -end -``` - -### Conditional Commands - -Build command list based on state: - -```elixir -def update(:save, state) do - commands = [] - - # Always show saving indicator - commands = commands ++ [Command.timer(0, :show_saving)] - - # Maybe backup first - commands = if state.backup_enabled do - commands ++ [Command.timer(0, :backup)] - else - commands - end - - # Do the save - commands = commands ++ [Command.timer(100, :do_save)] - - {state, commands} -end -``` - -### Error Handling - -Handle command failures: - -```elixir -def update(:load_data, state) do - {%{state | loading: true}, [Command.timer(0, :do_load)]} -end - -def update(:do_load, state) do - case fetch_data() do - {:ok, data} -> - {%{state | loading: false, data: data, error: nil}, []} - - {:error, reason} -> - {%{state | loading: false, error: reason}, []} - end -end - -def view(state) do - cond do - state.loading -> text("Loading...") - state.error -> text("Error: #{state.error}", Style.new(fg: :red)) - true -> render_data(state.data) - end -end -``` - -### Animation - -Frame-based animation: - -```elixir -@frame_interval 50 # ~20 FPS - -def init(_opts) do - %{frame: 0, animating: false} -end - -def update(:start_animation, state) do - {%{state | animating: true, frame: 0}, [Command.timer(@frame_interval, :animate)]} -end - -def update(:animate, %{animating: true} = state) do - next_frame = state.frame + 1 - - if next_frame >= 60 do - # Animation complete - {%{state | animating: false}, []} - else - # Continue animation - {%{state | frame: next_frame}, [Command.timer(@frame_interval, :animate)]} - end -end - -def update(:animate, state) do - # Animation was stopped - {state, []} -end - -def update(:stop_animation, state) do - {%{state | animating: false}, []} -end -``` - -### Spinner - -Indeterminate progress indicator: - -```elixir -@spinner_frames ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] -@spinner_interval 80 - -def init(_opts) do - %{loading: false, spinner_frame: 0} -end - -def update(:start_loading, state) do - {%{state | loading: true}, [Command.timer(@spinner_interval, :spin)]} -end - -def update(:spin, %{loading: true} = state) do - next_frame = rem(state.spinner_frame + 1, length(@spinner_frames)) - {%{state | spinner_frame: next_frame}, [Command.timer(@spinner_interval, :spin)]} -end - -def update(:spin, state) do - {state, []} -end - -def update(:stop_loading, state) do - {%{state | loading: false}, []} -end - -def view(state) do - if state.loading do - frame = Enum.at(@spinner_frames, state.spinner_frame) - text("#{frame} Loading...") - else - text("Ready") - end -end -``` - -## Multiple Commands - -Return multiple commands at once: - -```elixir -def update(:initialize, state) do - commands = [ - Command.timer(0, :load_config), - Command.timer(0, :load_data), - Command.timer(0, :start_heartbeat) - ] - {state, commands} -end -``` - -Commands execute concurrently. Results arrive as separate messages. - -## Testing Commands - -Test that correct commands are returned: - -```elixir -defmodule MyApp.ComponentTest do - use ExUnit.Case - alias TermUI.Command - - test "quit returns quit command" do - state = %{count: 0} - {_new_state, commands} = MyApp.Component.update(:quit, state) - - assert :quit in commands - end - - test "start timer returns timer command" do - state = %{} - {_new_state, commands} = MyApp.Component.update(:start, state) - - assert [Command.timer(1000, :tick)] == commands - end -end -``` - -## Custom Commands - -For operations not covered by built-in commands, use timer with immediate execution: - -```elixir -def update(:custom_operation, state) do - # Timer with 0 delay executes on next message loop - {state, [Command.timer(0, :do_custom)]} -end - -def update(:do_custom, state) do - # Perform the operation synchronously - result = perform_custom_operation() - {%{state | result: result}, []} -end -``` - -For truly async operations (HTTP, file I/O), spawn a task: - -```elixir -def update(:fetch_data, state) do - # Start async task - Task.start(fn -> - result = HTTPClient.get(url) - # Send result back to runtime - send(self(), {:data_loaded, result}) - end) - - {%{state | loading: true}, []} -end - -# In event_to_msg or handle_info -def event_to_msg({:data_loaded, result}, _state) do - {:msg, {:data_loaded, result}} -end -``` - -## Next Steps - -- [Elm Architecture](03-elm-architecture.md) - How commands fit in -- [Events](04-events.md) - Handle command results -- [Widgets](07-widgets.md) - Animated widgets diff --git a/guides/user/10-advanced-widgets.md b/guides/user/10-advanced-widgets.md deleted file mode 100644 index f6621546..00000000 --- a/guides/user/10-advanced-widgets.md +++ /dev/null @@ -1,992 +0,0 @@ -# Advanced Widgets - -TermUI includes advanced widgets for complex UI patterns including navigation, overlays, visualization, data streaming, and BEAM introspection. This guide covers these widgets and how to use them. - -All advanced widgets use the StatefulComponent pattern: - -```elixir -# 1. Create props with Widget.new(opts) -props = Widget.new(option: value) - -# 2. Initialize state with Widget.init(props) -{:ok, widget_state} = Widget.init(props) - -# 3. Handle events with Widget.handle_event(event, state) -{:ok, widget_state} = Widget.handle_event(event, widget_state) - -# 4. Render with Widget.render(state, area) -node = Widget.render(widget_state, %{width: 80, height: 24}) -``` - -## Navigation Widgets - -### Tabs - -> **Example:** See [`examples/tabs/`](../../examples/tabs/) for a complete demonstration. - -Tabbed interface for organizing content into switchable panels. - -```elixir -alias TermUI.Widgets.Tabs - -# Create props -props = Tabs.new( - tabs: ["Overview", "Details", "Settings"], - on_change: fn index -> handle_tab_change(index) end -) - -# Initialize and use -{:ok, tabs_state} = Tabs.init(props) -{:ok, tabs_state} = Tabs.handle_event(event, tabs_state) -Tabs.render(tabs_state, %{width: 60, height: 1}) -``` - -**Options:** - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `tabs` | list | required | Tab labels | -| `on_change` | function | `nil` | Tab change callback | -| `style` | Style | default | Tab bar style | -| `selected_style` | Style | reverse | Selected tab style | -| `closeable` | boolean | `false` | Show close buttons | - -### Context Menu - -> **Example:** See [`examples/context_menu/`](../../examples/context_menu/) for a complete demonstration. - -Right-click context menu that appears at cursor position. - -```elixir -alias TermUI.Widgets.ContextMenu - -# Create props -props = ContextMenu.new( - items: [ - %{label: "Cut", shortcut: "Ctrl+X", action: :cut}, - %{label: "Copy", shortcut: "Ctrl+C", action: :copy}, - %{label: "Paste", shortcut: "Ctrl+V", action: :paste}, - :separator, - %{label: "Delete", action: :delete} - ], - position: {10, 5}, - on_select: fn action -> handle_menu_action(action) end -) - -# Initialize and use -{:ok, menu_state} = ContextMenu.init(props) -{:ok, menu_state} = ContextMenu.handle_event(event, menu_state) -ContextMenu.render(menu_state, %{width: 30, height: 10}) -``` - -**Item Structure:** -```elixir -%{ - label: "Menu Item", # Display text - shortcut: "Ctrl+X", # Optional shortcut hint - action: :action_atom, # Action identifier - disabled: false # Optional disabled state -} -``` - -## Overlay Widgets - -### Alert Dialog - -> **Example:** See [`examples/alert_dialog/`](../../examples/alert_dialog/) for a complete demonstration. - -Modal dialog for confirmations and messages with standard button configurations. - -```elixir -alias TermUI.Widgets.AlertDialog -alias TermUI.Renderer.Style - -# Create props -props = AlertDialog.new( - type: :confirm, - title: "Delete File", - message: "Are you sure you want to delete this file?", - on_result: fn result -> handle_result(result) end -) - -# With custom styling -props = AlertDialog.new( - type: :error, - title: "Error", - message: "Something went wrong", - background_style: Style.new(bg: :bright_black), - border_style: Style.new(fg: :red, attrs: [:bold]), - message_style: Style.new(fg: :white), - on_result: fn result -> handle_result(result) end -) - -# Initialize and use -{:ok, dialog_state} = AlertDialog.init(props) -{:ok, dialog_state} = AlertDialog.handle_event(event, dialog_state) -AlertDialog.render(dialog_state, %{width: 80, height: 24}) -``` - -**Options:** - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `type` | atom | required | `:info`, `:success`, `:warning`, `:error`, `:confirm`, `:ok_cancel` | -| `title` | string | required | Dialog title | -| `message` | string | required | Dialog message | -| `on_result` | function | `nil` | Callback with result (`:ok`, `:cancel`, `:yes`, `:no`) | -| `width` | integer | `50` | Dialog width | -| `background_style` | `Style.t()` | `Style.new(bg: :black)` | Dialog background style | -| `border_style` | `Style.t()` | `Style.new(fg: :cyan)` | Border and title style | -| `icon_style` | `Style.t()` | `nil` | Style for the icon | -| `message_style` | `Style.t()` | `nil` | Style for the message | -| `button_style` | `Style.t()` | `nil` | Style for buttons | -| `focused_button_style` | `Style.t()` | `nil` | Style for focused button | - -**Type Icons:** -- `:info` - ℹ (information) -- `:warning` - ⚠ (warning) -- `:error` - ✖ (error) -- `:success` - ✔ (success) -- `:confirm` - ? (confirmation) -- `:ok_cancel` - ? (OK/Cancel) - -**Keyboard Navigation:** -- `Tab` / `Shift+Tab` - Move between buttons -- `Enter` / `Space` - Activate focused button -- `Escape` - Close (same as Cancel/No) -- `Y` / `N` - Yes/No (in confirm dialogs) - -### Toast - -> **Example:** See [`examples/toast/`](../../examples/toast/) for a complete demonstration. - -Non-blocking notification that auto-dismisses. Use `ToastManager` to manage multiple toasts with stacking. - -```elixir -alias TermUI.Widgets.ToastManager - -# Create manager in your init -def init(_opts) do - %{ - toast_manager: ToastManager.new( - position: :bottom_right, - default_duration: 3000, - max_toasts: 5 - ) - } -end - -# Add toasts -def update({:show_toast, type, message}, state) do - manager = ToastManager.add_toast(state.toast_manager, message, type) - {%{state | toast_manager: manager}, []} -end - -# Update on tick (removes expired toasts) -def update(:tick, state) do - manager = ToastManager.tick(state.toast_manager) - {%{state | toast_manager: manager}, []} -end - -# Render in view -def view(state) do - stack(:vertical, [ - render_main_content(state), - ToastManager.render(state.toast_manager, %{width: 80, height: 24, x: 0, y: 0}) - ]) -end -``` - -**ToastManager Options:** - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `position` | atom | `:bottom_right` | Toast position (see below) | -| `max_toasts` | integer | 5 | Maximum simultaneous toasts | -| `default_duration` | integer | 3000 | Default duration in ms | -| `spacing` | integer | 1 | Vertical spacing between toasts | - -**Positions:** `:top_left`, `:top_center`, `:top_right`, `:bottom_left`, `:bottom_center`, `:bottom_right` - -**Toast Types:** `:info` (ℹ blue), `:success` (✓ green), `:warning` (⚠ yellow), `:error` (✗ red) - -**ToastManager Functions:** - -```elixir -# Add a toast -manager = ToastManager.add_toast(manager, "Message", :success) -manager = ToastManager.add_toast(manager, "Message", :warning, duration: 5000) - -# Update (removes expired toasts) -manager = ToastManager.tick(manager) - -# Get visible toast count -count = ToastManager.toast_count(manager) - -# Clear all toasts -manager = ToastManager.clear_all(manager) -``` - -## Visualization Widgets - -### Bar Chart - -> **Example:** See [`examples/bar_chart/`](../../examples/bar_chart/) for a complete demonstration. - -Horizontal or vertical bar chart for categorical data. - -```elixir -alias TermUI.Widgets.BarChart - -# Render directly (simple widget) -BarChart.render( - data: [ - %{label: "Sales", value: 150}, - %{label: "Marketing", value: 80}, - %{label: "Engineering", value: 200} - ], - width: 40, - show_values: true, - show_labels: true -) -``` - -**Options:** - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `data` | list | required | List of `%{label, value}` maps | -| `direction` | atom | `:horizontal` | `:horizontal` or `:vertical` | -| `width` | integer | 40 | Chart width | -| `height` | integer | 10 | Chart height (vertical only) | -| `show_values` | boolean | `true` | Display values | -| `show_labels` | boolean | `true` | Display labels | - -**Example Output:** -``` -Sales ████████████████ 150 -Marketing ████████ 80 -Engineering █████████████████████ 200 -``` - -### Line Chart - -> **Example:** See [`examples/line_chart/`](../../examples/line_chart/) for a complete demonstration. - -Line chart using Braille characters for sub-character resolution. - -```elixir -alias TermUI.Widgets.LineChart - -# Single series -LineChart.render( - data: [10, 25, 18, 30, 22, 35, 28], - width: 40, - height: 8 -) - -# Multiple series -LineChart.render( - series: [ - %{data: cpu_history, style: Style.new(fg: :green)}, - %{data: mem_history, style: Style.new(fg: :yellow)} - ], - width: 60, - height: 10, - min: 0, - max: 100 -) -``` - -**Options:** - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `data` | list | - | Single series data | -| `series` | list | - | Multiple series with styles | -| `width` | integer | 40 | Chart width | -| `height` | integer | 8 | Chart height | -| `min` | number | auto | Y-axis minimum | -| `max` | number | auto | Y-axis maximum | - -### Canvas - -> **Example:** See [`examples/canvas/`](../../examples/canvas/) for a complete demonstration. - -Direct drawing surface for custom visualizations. - -```elixir -alias TermUI.Widgets.Canvas - -# Create canvas props -props = Canvas.new( - width: 60, - height: 20 -) - -{:ok, canvas_state} = Canvas.init(props) - -# Draw on canvas -canvas_state = canvas_state - |> Canvas.draw_rect(0, 0, 59, 19) - |> Canvas.draw_line(0, 10, 59, 10) - |> Canvas.draw_text(25, 0, "Title", Style.new(fg: :cyan)) - -Canvas.render(canvas_state, %{width: 60, height: 20}) -``` - -**Drawing Functions:** - -| Function | Description | -|----------|-------------| -| `draw_text(x, y, text, style)` | Draw text at position | -| `draw_line(x1, y1, x2, y2)` | Draw line between points | -| `draw_rect(x, y, w, h, opts)` | Draw rectangle | -| `fill_rect(x, y, w, h, char)` | Fill rectangle with character | -| `clear()` | Clear canvas | - -## Layout Widgets - -### Markdown Viewer - -> **Example:** See [`examples/markdown_viewer/`](../../examples/markdown_viewer/) for a complete demonstration. - -Scrollable markdown viewer with syntax highlighting for code blocks. - -```elixir -alias TermUI.Widgets.MarkdownViewer - -# Create props -props = MarkdownViewer.new( - content: "# Hello World\n\nThis is **bold** and `code`.\n\n```elixir\ndef hello do\n :world\nend\n```", - width: 80, - height: 24, - on_copy: fn code -> IO.puts("Copied: #{code}") end -) - -# Initialize -{:ok, viewer_state} = MarkdownViewer.init(props) - -# Handle events and render -{:ok, viewer_state} = MarkdownViewer.handle_event(event, viewer_state) -MarkdownViewer.render(viewer_state, %{width: 80, height: 24}) - -# Update content dynamically -MarkdownViewer.set_content(viewer_pid, "# New content") -``` - -**Features:** -- CommonMark compliant markdown rendering via mdex -- Syntax highlighting for code blocks (Elixir, Erlang, and many more) -- Scrollable viewport with keyboard navigation -- Focusable code blocks with copy functionality - -**Keyboard Controls:** -- `↑/↓` - Scroll by line -- `Page Up/Page Down` - Scroll by page -- `Home/End` - Jump to top/bottom -- `Tab` - Cycle focus through code blocks -- `Shift+Tab` - Reverse cycle through code blocks -- `Enter` / `c` - Copy focused code block -- Mouse wheel - Scroll - -**Supported Markdown:** -- Headings (`#`, `##`, etc.) -- Bold (`**text**`), italic (`*text*`) -- Code (`` `inline` ``) and code blocks (fenced with ` ``` `) -- Lists (ordered and unordered) -- Blockquotes (`>`) -- Links and images - -**Options:** - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `content` | string | required | Markdown content to display | -| `width` | integer | 80 | Display width | -| `height` | integer | 24 | Display height | -| `on_copy` | function | `nil` | Callback when code block copied | - -**Helper Functions:** - -```elixir -# Update content dynamically (from another process) -MarkdownViewer.set_content(viewer_pid, "# Updated content") -``` - -### Viewport - -> **Example:** See [`examples/viewport/`](../../examples/viewport/) for a complete demonstration. - -Scrollable view of content larger than the display area. The Viewport widget clips content to a visible region and supports both keyboard and mouse scrolling. - -```elixir -alias TermUI.Widgets.Viewport - -# Create props -props = Viewport.new( - content: my_large_content(), # The content to scroll (render node) - content_width: 200, # Total width of content - content_height: 100, # Total height of content - width: 60, # Viewport width - height: 20, # Viewport height - scroll_x: 0, # Initial horizontal scroll - scroll_y: 0, # Initial vertical scroll - scroll_bars: :both # :none, :vertical, :horizontal, or :both -) - -{:ok, viewport_state} = Viewport.init(props) -{:ok, viewport_state} = Viewport.handle_event(scroll_event, viewport_state) -Viewport.render(viewport_state, %{width: 60, height: 20}) -``` - -**Keyboard Navigation:** -- Arrow keys: Scroll by one line/column -- Page Up/Down: Scroll by viewport height -- Home/End: Scroll to top/bottom -- Ctrl+Home/End: Scroll to top-left/bottom-right - -**Mouse Support:** -- Mouse wheel: Scroll vertically -- Click on scroll bar track: Page scroll -- Drag scroll bar thumb: Direct scroll positioning - -**Helper Functions:** - -```elixir -# Get current scroll position -{x, y} = Viewport.get_scroll(state) - -# Set scroll position (clamped to valid range) -state = Viewport.set_scroll(state, 50, 100) - -# Scroll to make a position visible -state = Viewport.scroll_into_view(state, target_x, target_y) - -# Update content -state = Viewport.set_content(state, new_content) - -# Update content dimensions -state = Viewport.set_content_size(state, new_width, new_height) - -# Check if scrollable -Viewport.can_scroll_vertical?(state) # true/false -Viewport.can_scroll_horizontal?(state) # true/false -``` - -**Complete Example:** - -```elixir -defmodule MyApp do - use TermUI.Elm - alias TermUI.Widgets.Viewport - - def init(_opts) do - # Create large scrollable content - content = generate_large_content() - - props = Viewport.new( - content: content, - content_width: 200, - content_height: 500, - width: 60, - height: 20, - scroll_bars: :both - ) - - {:ok, viewport} = Viewport.init(props) - %{viewport: viewport} - end - - def event_to_msg(event, _state) do - {:msg, {:viewport_event, event}} - end - - def update({:viewport_event, event}, state) do - {:ok, new_viewport} = Viewport.handle_event(event, state.viewport) - {%{state | viewport: new_viewport}, []} - end - - def view(state) do - Viewport.render(state.viewport, %{width: 60, height: 20}) - end - - defp generate_large_content do - lines = for i <- 1..500 do - {:text, "Line #{i}: Lorem ipsum dolor sit amet, consectetur adipiscing elit"} - end - stack(:vertical, lines) - end -end -``` - -### Split Pane - -> **Example:** See [`examples/split_pane/`](../../examples/split_pane/) for a complete demonstration. - -Resizable split layout for IDE-style interfaces. - -```elixir -alias TermUI.Widgets.SplitPane - -# Create props -props = SplitPane.new( - direction: :horizontal, - initial_ratio: 0.3, - min_size: 10, - max_size: 50, - on_resize: fn ratio -> handle_resize(ratio) end -) - -{:ok, pane_state} = SplitPane.init(props) -{:ok, pane_state} = SplitPane.handle_event(event, pane_state) -SplitPane.render(pane_state, %{width: 100, height: 30}) -``` - -**Options:** - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `direction` | atom | `:horizontal` | `:horizontal` or `:vertical` | -| `initial_ratio` | float | 0.5 | Split ratio (0.0-1.0) | -| `min_size` | integer | 5 | Minimum pane size | -| `max_size` | integer | `nil` | Maximum pane size | -| `draggable` | boolean | `true` | Allow resize | - -### Tree View - -> **Example:** See [`examples/tree_view/`](../../examples/tree_view/) for a complete demonstration. - -Hierarchical data with expand/collapse. - -```elixir -alias TermUI.Widgets.TreeView - -# Create props -props = TreeView.new( - data: [ - %{ - id: :src, - label: "src", - icon: "📁", - children: [ - %{id: :main, label: "main.ex", icon: "📄"}, - %{id: :utils, label: "utils.ex", icon: "📄"} - ] - }, - %{id: :readme, label: "README.md", icon: "📄"} - ], - on_select: fn node_id -> handle_select(node_id) end -) - -{:ok, tree_state} = TreeView.init(props) -{:ok, tree_state} = TreeView.handle_event(event, tree_state) -TreeView.render(tree_state, %{width: 40, height: 20}) -``` - -**Node Structure:** -```elixir -%{ - id: unique_id, # Required - label: "Node Name", - icon: "📁", # Optional icon - children: [...] # Optional child nodes -} -``` - -## Input Widgets - -### Form Builder - -> **Example:** See [`examples/form_builder/`](../../examples/form_builder/) for a complete demonstration. - -Structured forms with validation and multiple field types. - -```elixir -alias TermUI.Widgets.FormBuilder - -# Create props -props = FormBuilder.new( - fields: [ - %{id: :username, type: :text, label: "Username", required: true}, - %{id: :password, type: :password, label: "Password", required: true, - validators: [&validate_password/1]}, - %{id: :role, type: :select, label: "Role", - options: [{"admin", "Admin"}, {"user", "User"}]}, - %{id: :notifications, type: :checkbox, label: "Email notifications"}, - %{id: :theme, type: :radio, label: "Theme", - options: [{"light", "Light"}, {"dark", "Dark"}]} - ], - submit_label: "Register", - label_width: 15, - field_width: 30 -) - -{:ok, form_state} = FormBuilder.init(props) - -# Handle events -{:ok, form_state} = FormBuilder.handle_event(event, form_state) - -# Get form values -values = FormBuilder.get_values(form_state) - -# Render -FormBuilder.render(form_state, %{width: 60, height: 20}) -``` - -**Field Types:** - -| Type | Description | -|------|-------------| -| `:text` | Single-line text input | -| `:password` | Masked password input | -| `:checkbox` | Boolean checkbox | -| `:radio` | Radio button group | -| `:select` | Dropdown selection | -| `:multi_select` | Multiple selection | - -**Field Options:** -```elixir -%{ - id: :field_name, - type: :text, - label: "Field Label", - required: true, - placeholder: "Enter value...", - validators: [&custom_validator/1], - visible_when: fn values -> values[:other_field] == true end -} -``` - -### Command Palette - -> **Example:** See [`examples/command_palette/`](../../examples/command_palette/) for a complete demonstration. - -VS Code-style command interface with fuzzy search. - -```elixir -alias TermUI.Widgets.CommandPalette - -# Create props -props = CommandPalette.new( - commands: [ - %{id: :save, label: "Save File", shortcut: "Ctrl+S", category: :file}, - %{id: :open, label: "Open File", shortcut: "Ctrl+O", category: :file}, - %{id: :find, label: "Find", shortcut: "Ctrl+F", category: :edit}, - %{id: :replace, label: "Find and Replace", shortcut: "Ctrl+H", category: :edit} - ], - on_select: fn command_id -> execute_command(command_id) end, - on_close: fn -> hide_palette() end, - placeholder: "Type a command..." -) - -{:ok, palette_state} = CommandPalette.init(props) -{:ok, palette_state} = CommandPalette.handle_event(event, palette_state) -CommandPalette.render(palette_state, %{width: 80, height: 24}) -``` - -**Command Structure:** -```elixir -%{ - id: :command_id, - label: "Command Label", - shortcut: "Ctrl+K", # Optional - category: :file, # Optional, for grouping - description: "Details" # Optional -} -``` - -## Data Streaming Widgets - -### Log Viewer - -> **Example:** See [`examples/log_viewer/`](../../examples/log_viewer/) for a complete demonstration. - -High-performance log viewer with virtual scrolling, search, and filtering. - -```elixir -alias TermUI.Widgets.LogViewer - -# Create props -props = LogViewer.new( - max_lines: 10000, - wrap_lines: false, - show_line_numbers: true, - show_timestamps: true -) - -{:ok, viewer_state} = LogViewer.init(props) - -# Add log lines -viewer_state = LogViewer.append_line(viewer_state, %{ - timestamp: DateTime.utc_now(), - level: :info, - message: "Application started", - source: "MyApp" -}) - -# Handle events and render -{:ok, viewer_state} = LogViewer.handle_event(event, viewer_state) -LogViewer.render(viewer_state, %{width: 100, height: 30}) -``` - -**Log Line Structure:** -```elixir -%{ - timestamp: ~U[2024-01-15 10:30:00Z], - level: :info, # :debug, :info, :warning, :error - message: "Log message", - source: "MyApp.Worker" # Optional -} -``` - -**Keyboard Controls:** -- `↑/↓` - Scroll line by line -- `PgUp/PgDn` - Scroll by page -- `Home/End` - Jump to start/end -- `/` - Start search -- `f` - Toggle filter -- `t` - Toggle tail mode -- `w` - Toggle line wrap - -### Stream Widget - -> **Example:** See [`examples/stream_widget/`](../../examples/stream_widget/) for a complete demonstration. - -GenStage-integrated widget for real-time data streams with backpressure. - -```elixir -alias TermUI.Widgets.StreamWidget - -# Create props -props = StreamWidget.new( - buffer_size: 1000, - rate_limit: 60, # updates per second - overflow: :drop_oldest -) - -{:ok, stream_state} = StreamWidget.init(props) - -# Push data to stream -stream_state = StreamWidget.push(stream_state, data_item) - -# Handle events and render -{:ok, stream_state} = StreamWidget.handle_event(event, stream_state) -StreamWidget.render(stream_state, %{width: 80, height: 20}) -``` - -**Options:** - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `buffer_size` | integer | 1000 | Maximum buffered items | -| `rate_limit` | integer | 60 | Max renders per second | -| `overflow` | atom | `:drop_oldest` | `:drop_oldest`, `:drop_newest` | - -## BEAM Introspection Widgets - -These widgets leverage Erlang's runtime introspection capabilities for live system visualization. - -### Process Monitor - -> **Example:** See [`examples/process_monitor/`](../../examples/process_monitor/) for a complete demonstration. - -Live BEAM process inspection with sorting, filtering, and process control. - -```elixir -alias TermUI.Widgets.ProcessMonitor - -props = ProcessMonitor.new( - update_interval: 1000, - show_system_processes: false, - thresholds: %{ - queue_warning: 1000, - queue_critical: 10_000, - memory_warning: 50_000_000, - memory_critical: 200_000_000 - } -) - -{:ok, monitor_state} = ProcessMonitor.init(props) - -# Handle timer messages for auto-refresh -{:ok, monitor_state} = ProcessMonitor.handle_info(:refresh, monitor_state) - -# Handle events and render -{:ok, monitor_state} = ProcessMonitor.handle_event(event, monitor_state) -ProcessMonitor.render(monitor_state, %{width: 100, height: 30}) -``` - -**Keyboard Controls:** -- `↑/↓` - Navigate processes -- `Enter` - Toggle details panel -- `s/S` - Cycle sort field / Toggle direction -- `/` - Filter by name -- `k` - Kill process (with confirmation) -- `r` - Refresh - -**Display Columns:** -- PID -- Name (registered or initial call) -- Reductions -- Memory -- Message Queue -- Status - -### Supervision Tree Viewer - -> **Example:** See [`examples/supervision_tree_viewer/`](../../examples/supervision_tree_viewer/) for a complete demonstration. - -Visualize supervision hierarchies with live status. - -```elixir -alias TermUI.Widgets.SupervisionTreeViewer - -props = SupervisionTreeViewer.new( - root: MyApp.Supervisor, - update_interval: 2000, - show_pids: true, - expand_all: false -) - -{:ok, tree_state} = SupervisionTreeViewer.init(props) - -# Handle timer messages for auto-refresh -{:ok, tree_state} = SupervisionTreeViewer.handle_info(:refresh, tree_state) - -# Handle events and render -{:ok, tree_state} = SupervisionTreeViewer.handle_event(event, tree_state) -SupervisionTreeViewer.render(tree_state, %{width: 80, height: 25}) -``` - -**Keyboard Controls:** -- `↑/↓` - Navigate tree -- `Enter` - Expand/collapse node -- `e/c` - Expand/collapse all -- `i` - Inspect process state -- `r` - Restart process (with confirmation) -- `/` - Filter tree -- `Escape` - Clear filter - -**Status Indicators:** -- `●` Running (green) -- `↻` Restarting (yellow) -- `✖` Terminated (red) -- `?` Undefined (gray) - -**Strategy Display:** -- `1:1` - one_for_one -- `1:*` - one_for_all -- `1:→` - rest_for_one - -### Cluster Dashboard - -> **Example:** See [`examples/cluster_dashboard/`](../../examples/cluster_dashboard/) for a complete demonstration. - -Distributed Erlang cluster visualization. - -```elixir -alias TermUI.Widgets.ClusterDashboard - -props = ClusterDashboard.new( - update_interval: 2000, - show_health_metrics: true, - show_pg_groups: true, - show_global_names: true -) - -{:ok, dashboard_state} = ClusterDashboard.init(props) - -# Handle timer messages for auto-refresh -{:ok, dashboard_state} = ClusterDashboard.handle_info(:refresh, dashboard_state) - -# Handle events and render -{:ok, dashboard_state} = ClusterDashboard.handle_event(event, dashboard_state) -ClusterDashboard.render(dashboard_state, %{width: 100, height: 30}) -``` - -**View Modes:** -- **Nodes** - Connected nodes with status and metrics -- **Globals** - `:global` registered names -- **PG Groups** - `:pg` process groups -- **Events** - Connection/disconnection log - -**Keyboard Controls:** -- `↑/↓` - Navigate list -- `Enter` - Toggle details -- `n` - Nodes view -- `g` - Globals view -- `p` - PG groups view -- `e` - Events view -- `r` - Refresh - -**Features:** -- Network partition detection -- Node health metrics (memory, processes, schedulers) -- Connection event history - -## Full Example: Using BEAM Introspection Widgets - -```elixir -defmodule MyApp.SystemMonitor do - use TermUI.Elm - - alias TermUI.Event - alias TermUI.Widgets.ProcessMonitor - alias TermUI.Renderer.Style - - def init(_opts) do - props = ProcessMonitor.new( - update_interval: 1000, - show_system_processes: false - ) - {:ok, monitor_state} = ProcessMonitor.init(props) - - %{ - monitor: monitor_state, - last_refresh: DateTime.utc_now() - } - end - - def event_to_msg(%Event.Key{key: "q"}, _state), do: {:msg, :quit} - def event_to_msg(%Event.Key{key: "r"}, _state), do: {:msg, :refresh} - def event_to_msg(event, _state), do: {:msg, {:monitor_event, event}} - - def update(:quit, state), do: {state, [:quit]} - - def update(:refresh, state) do - {:ok, monitor} = ProcessMonitor.handle_info(:refresh, state.monitor) - {%{state | monitor: monitor, last_refresh: DateTime.utc_now()}, []} - end - - def update({:monitor_event, event}, state) do - {:ok, monitor} = ProcessMonitor.handle_event(event, state.monitor) - {%{state | monitor: monitor}, []} - end - - # Auto-refresh timer - def handle_info(:tick, state) do - {:ok, monitor} = ProcessMonitor.handle_info(:refresh, state.monitor) - {%{state | monitor: monitor, last_refresh: DateTime.utc_now()}, - [Command.timer(1000, :tick)]} - end - - def view(state) do - stack(:vertical, [ - text("System Monitor", Style.new(fg: :cyan, attrs: [:bold])), - text("Last refresh: #{state.last_refresh}", Style.new(fg: :bright_black)), - text(""), - ProcessMonitor.render(state.monitor, %{width: 100, height: 25}), - text(""), - text("[R] Refresh [Q] Quit", Style.new(fg: :bright_black)) - ]) - end -end -``` - -## Next Steps - -- [Widgets](07-widgets.md) - Basic widgets guide -- [Styling](05-styling.md) - Customize widget appearance -- [Layout](06-layout.md) - Position widgets -- [Events](04-events.md) - Handle widget interactions diff --git a/guides/user/README.md b/guides/user/README.md deleted file mode 100644 index 74054fdd..00000000 --- a/guides/user/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# TermUI User Guides - -Welcome to the TermUI documentation. These guides cover everything you need to build terminal user interfaces with Elixir. - -## Guides - -1. **[Overview](01-overview.md)** - Introduction to TermUI and its architecture -2. **[Getting Started](02-getting-started.md)** - Build your first TermUI application -3. **[The Elm Architecture](03-elm-architecture.md)** - Understanding the component model -4. **[Events](04-events.md)** - Handling keyboard, mouse, and other input -5. **[Styling](05-styling.md)** - Colors, attributes, and themes -6. **[Layout](06-layout.md)** - Positioning and sizing components -7. **[Widgets](07-widgets.md)** - Using pre-built components -8. **[Terminal](08-terminal.md)** - Terminal modes and capabilities -9. **[Commands](09-commands.md)** - Side effects and async operations -10. **[Advanced Widgets](10-advanced-widgets.md)** - Navigation, visualization, data streaming, and BEAM introspection widgets - -## Quick Start - -```elixir -defmodule MyApp do - use TermUI.Elm - - def init(_opts), do: %{count: 0} - - def event_to_msg(%Event.Key{key: :up}, _), do: {:msg, :inc} - def event_to_msg(%Event.Key{key: :down}, _), do: {:msg, :dec} - def event_to_msg(%Event.Key{key: "q"}, _), do: {:msg, :quit} - def event_to_msg(_, _), do: :ignore - - def update(:inc, s), do: {%{s | count: s.count + 1}, []} - def update(:dec, s), do: {%{s | count: s.count - 1}, []} - def update(:quit, s), do: {s, [:quit]} - - def view(state), do: text("Count: #{state.count}") -end - -# Run with: TermUI.Runtime.run(root: MyApp) -``` - -## Requirements - -- Elixir 1.15+ -- OTP 28+ -- Terminal with ANSI support - -## Examples - -See the `examples/` directory for complete applications: - -- **dashboard** - System monitoring dashboard with gauges, sparklines, and tables diff --git a/guides/widgets.md b/guides/widgets.md new file mode 100644 index 00000000..5bc52ae7 --- /dev/null +++ b/guides/widgets.md @@ -0,0 +1,73 @@ +# Pure widgets + +Every TermUI widget is plain data. The parent application owns its state and +passes normalized events to it. + +```elixir +state = TermUI.Widget.Table.init(columns: columns, rows: rows) +{state, messages} = TermUI.Widget.Table.update(event, state) +child = TermUI.Widget.Table.view(state, {60, 12}) +frame = TermUI.Frame.overlay(frame, child, 1, 3) +``` + +`update/2` does not perform effects. It returns messages for the parent. The +parent can convert those messages to application updates or `TermUI.Command` +values. + +Use `TermUI.Widget.mouse/4` after the parent routes a mouse event to local +widget coordinates. A widget can implement the optional `mouse/3` callback. +The helper uses `update/2` as the fallback. + +Text input selection emits `{:copy, text}` for copy and cut actions. Convert +that message to `TermUI.Clipboard.copy/2` in the parent application. See the +[interaction guide](interaction.md). + +## Text and content + +- `TermUI.Widget.Label` +- `TermUI.Widget.TextInput` +- `TermUI.Widget.LineInput` +- `TermUI.Widget.TextArea` +- `TermUI.Widget.MarkdownViewer` +- `TermUI.Widget.LogViewer` +- `TermUI.Widget.Stream` +- `TermUI.Widget.DiffViewer` + +## Selection and data entry + +- `TermUI.Widget.Button` +- `TermUI.Widget.List` +- `TermUI.Widget.PickList` +- `TermUI.Widget.Menu` +- `TermUI.Widget.ContextMenu` +- `TermUI.Widget.CommandPalette` +- `TermUI.Widget.Tabs` +- `TermUI.Widget.Table` +- `TermUI.Widget.TreeView` +- `TermUI.Widget.FormBuilder` + +## Layout and feedback + +- `TermUI.Widget.Block` +- `TermUI.Widget.Dialog` +- `TermUI.Widget.AlertDialog` +- `TermUI.Widget.SplitPane` +- `TermUI.Widget.Viewport` +- `TermUI.Widget.ScrollBar` +- `TermUI.Widget.Toast` + +## Visualization and snapshots + +- `TermUI.Widget.Progress` +- `TermUI.Widget.Gauge` +- `TermUI.Widget.Sparkline` +- `TermUI.Widget.BarChart` +- `TermUI.Widget.LineChart` +- `TermUI.Widget.Canvas` +- `TermUI.Widget.ProcessMonitor` +- `TermUI.Widget.SupervisionTree` +- `TermUI.Widget.ClusterDashboard` + +Snapshot widgets do not call `Process.list/0`, monitor nodes, perform RPC, or +subscribe to streams. The parent performs those effects and supplies bounded +data with each widget's setter function. diff --git a/lib/term_ui.ex b/lib/term_ui.ex index b19f3ad5..d14be69e 100644 --- a/lib/term_ui.ex +++ b/lib/term_ui.ex @@ -1,162 +1,23 @@ defmodule TermUI do @moduledoc """ - TermUI - A direct-mode Terminal UI framework for Elixir/BEAM. + A small terminal runtime for Elm applications on the BEAM. - This module provides the main entry point for terminal operations. + The runtime owns application state and terminal lifecycle. An application + receives normalized `TermUI.Event` values, returns `TermUI.Command` data, + and renders one `TermUI.Frame`. """ - alias TermUI.Terminal + alias TermUI.Runtime - # Dialyzer: Functions with unmatched return values - @dialyzer {:nowarn_function, size: 0} - - @doc """ - Enables raw mode and sets up the terminal for TUI operation. - - This is a convenience function that: - 1. Starts the Terminal GenServer if needed - 2. Enables raw mode - 3. Enters the alternate screen - 4. Hides the cursor - - Returns `{:ok, state}` on success or `{:error, reason}` on failure. - """ - @spec init() :: {:ok, Terminal.State.t()} | {:error, term()} - def init do - with {:ok, _pid} <- ensure_terminal_started(), - {:ok, state} <- Terminal.enable_raw_mode(), - :ok <- Terminal.enter_alternate_screen(), - :ok <- Terminal.hide_cursor() do - {:ok, state} - end - end - - @doc """ - Restores the terminal to its original state. - - This is a convenience function that performs complete terminal restoration. - """ - @spec shutdown() :: :ok - def shutdown do - Terminal.restore() - end - - @doc """ - Gets the current terminal size. - - Returns `{:ok, {rows, cols}}` or `{:error, reason}`. - """ - @spec size() :: {:ok, {pos_integer(), pos_integer()}} | {:error, term()} - def size do - ensure_terminal_started() - Terminal.get_terminal_size() + @doc "Runs an Elm application until it stops." + @spec run(module(), keyword()) :: :ok | {:error, term()} + def run(root, opts \\ []) when is_atom(root) and is_list(opts) do + Runtime.run(Keyword.put(opts, :root, root)) end - @doc """ - Returns whether the application is running inside IEx. - - This function checks multiple indicators to determine if the code is - executing within an IEx session: - - 1. Whether the IEx module is loaded - 2. Whether the current process is an IEx evaluator - 3. Configuration overrides (config or environment variable) - - The result can be overridden by: - - Setting `config :term_ui, iex_compatible: true` in config - - Setting the `TERM_UI_IEX_MODE` environment variable to `"true"` or `"false"` - - ## Examples - - iex> TermUI.iex_mode?() - true - - # In a standalone script: - TermUI.iex_mode?() - false - - ## Configuration - - To force IEx-compatible mode (useful for testing): - - # config/config.exs - config :term_ui, iex_compatible: true - - To override via environment variable: - - export TERM_UI_IEX_MODE=true - - """ - @spec iex_mode?() :: boolean() - def iex_mode? do - cond do - # Environment variable override takes precedence - env_var = System.get_env("TERM_UI_IEX_MODE") -> - env_var in ["true", "1", "yes"] - - # Config override - config = Application.get_env(:term_ui, :iex_compatible) -> - config == true - - # Auto-detection - true -> - iex_running?() - end - end - - @doc """ - Returns the current execution mode. - - Returns `:iex` if running inside IEx, `:standalone` otherwise. - - ## Examples - - iex> TermUI.running_mode() - :iex - - # In a standalone script: - TermUI.running_mode() - :standalone - - """ - @spec running_mode() :: :iex | :standalone - def running_mode do - if iex_mode?(), do: :iex, else: :standalone - end - - # Check if IEx is actually running (not just loaded) - defp iex_running? do - # Check if IEx module is available and loaded - # Check if we're in an IEx evaluator process - Code.ensure_loaded?(IEx) and - iex_evaluator_process?() - end - - # Check if current process or any ancestor is an IEx evaluator - defp iex_evaluator_process? do - # Get the current process's dictionary and check for IEx-specific keys - # IEx evaluator processes have the :iex_server key in their dictionary - Process.info(self(), :dictionary) - |> case do - {:dictionary, dictionary} -> - # Check for IEx evaluator indicator - Enum.any?(dictionary, fn - {:iex_server, _} -> true - _ -> false - end) - - _ -> - false - end - end - - defp ensure_terminal_started do - case Process.whereis(Terminal) do - nil -> - Terminal.start_link() - - pid -> - {:ok, pid} - end + @doc "Starts a linked Elm application runtime." + @spec start_link(module(), keyword()) :: GenServer.on_start() + def start_link(root, opts \\ []) when is_atom(root) and is_list(opts) do + Runtime.start_link(Keyword.put(opts, :root, root)) end end diff --git a/lib/term_ui/ansi.ex b/lib/term_ui/ansi.ex index 984d9001..cb26b47a 100644 --- a/lib/term_ui/ansi.ex +++ b/lib/term_ui/ansi.ex @@ -1,11 +1,5 @@ defmodule TermUI.ANSI do - @moduledoc """ - ANSI escape sequence generation for terminal control. - - This module provides functions to generate ANSI escape sequences for cursor - control, screen manipulation, colors, styles, and special terminal modes. - All functions return iolists for efficient concatenation. - """ + @moduledoc false # Dialyzer: All functions in this module are pure data constructors that return # specific iolist structures. The iolist() spec is correct for the API, but diff --git a/lib/term_ui/app.ex b/lib/term_ui/app.ex deleted file mode 100644 index ebb5f65a..00000000 --- a/lib/term_ui/app.ex +++ /dev/null @@ -1,388 +0,0 @@ -defmodule TermUI.App do - @moduledoc """ - High-level application API for TermUI applications. - - This module provides a convenient API for starting and running TermUI - applications with automatic backend selection (raw mode or TTY mode). - - ## Application Lifecycle - - TermUI applications follow The Elm Architecture: - 1. Model (state) - Application state - 2. View - Renders UI based on state - 3. Update - Handles events, returns new state - 4. Messages - Events that trigger updates - - ## Backend Selection - - The API automatically selects the appropriate backend: - - **Raw mode**: Full terminal control (mouse, colors, Unicode) - OTP 28+ - - **TTY mode**: Line-based input with graceful degradation - - ## IEx Compatibility - - TermUI applications work directly in IEx with no code changes. This enables: - - Interactive debugging and development - - Admin tools and dashboards in production IEx sessions - - Prototyping and testing TUI interfaces - - ### Running in IEx - - Start any TermUI application from an IEx session: - - iex> TermUI.App.run(MyApp.Counter) - # Use keyboard input, press Q to quit - # Returns to IEx prompt when done - - All keyboard input works correctly in IEx: - - Arrow keys for navigation (no Enter required) - - Tab for field switching - - Function keys (F1-F12) - - Ctrl+key combinations - - Alt+key combinations - - ### IEx Detection - - Detect if your application is running in IEx: - - iex> TermUI.iex_mode?() - true - - iex> TermUI.running_mode() - :iex - - ### Configuration - - Force IEx-compatible mode via configuration: - - # config/config.exs - config :term_ui, - iex_compatible: true - - Or via environment variable: - - export TERM_UI_IEX_MODE=true - - ### Troubleshooting IEx Issues - - **Input not reaching the application:** - - Ensure the application is started from IEx (not `mix run`) - - Check that `TermUI.iex_mode?()` returns `true` - - Try forcing IEx mode with `TERM_UI_IEX_MODE=true` - - **Terminal state not restored after exit:** - - The Runtime should restore terminal state automatically - - If problems persist, call `TermUI.shutdown()` manually - - **Performance issues in IEx:** - - IEx adds some overhead due to process inspection - - Use `backend: :raw` for better performance (when OTP 28+ is available) - - ## Usage - - ### Non-blocking start (for supervisors) - - {:ok, pid} = TermUI.App.start(MyApp.Root, backend: :auto) - - ### Blocking run (for scripts and CLI apps) - - final_state = TermUI.App.run(MyApp.Root, backend: :auto) - - ### Query backend capabilities - - :raw = TermUI.App.backend_mode() - true = TermUI.App.supports?(:unicode) - true = TermUI.App.supports?(:mouse) - - ### Shutdown - - :ok = TermUI.App.shutdown() - - ## Configuration Options - - - `:backend` - Backend selection: `:auto` (default), `:raw`, `:tty` - - `:name` - GenServer name for the Runtime process - - `:render_interval` - Milliseconds between renders (default: 16, ~60 FPS) - - ## Example - - defmodule MyApp.Counter do - @moduledoc \"\"\" - A simple counter application. - \"\"\" - - @impl true - def init(_opts) do - {:ok, %{count: 0}} - end - - @impl true - def view(model) do - [ - {:text, "Count: \" <> to_string(model.count)}, - {:text, "\\nPress + to increment, - to decrement, q to quit"} - ] - end - - @impl true - def update(msg, model) do - case msg do - {:key, ?+} -> {:ok, %{model | count: model.count + 1}} - {:key, ?-} -> {:ok, %{model | count: model.count - 1}} - {:key, ?q} -> {:quit, model} - _ -> {:ok, model} - end - end - end - - # Run the application - TermUI.App.run(MyApp.Counter, backend: :auto) - - ## Component Protocol - - Your root component must implement the following callbacks: - - - `init/1` - Initialize the model, called once at startup - - `view/1` - Render the UI based on current model - - `update/2` - Handle messages, return `{:ok, new_model}` or `{:quit, model}` - - See `TermUI.Component` for full protocol documentation. - """ - - alias TermUI.PersistentTerms - alias TermUI.Runtime - - # Dialyzer: Functions return specific types - @dialyzer {:nowarn_function, run: 2, shutdown: 1} - - @type root_module :: module() - @type option :: - {:backend, :auto | :raw | :tty} - | {:name, GenServer.name()} - | {:render_interval, pos_integer()} - | {:skip_terminal, boolean()} - @type supports_query :: - :unicode - | :mouse - | :colors - | :true_color - | :color_256 - | :color_16 - | :monochrome - - @doc """ - Starts a TermUI application non-blocking. - - Returns `{:ok, pid}` where pid is the Runtime process. - Use this when you want to manage the process yourself - (e.g., in a supervisor tree). - - ## Options - - - `:backend` - Backend selection: `:auto` (default), `:raw`, `:tty` - - `:name` - GenServer name for the Runtime process - - `:render_interval` - Milliseconds between renders (default: 16) - - ## Examples - - {:ok, pid} = TermUI.App.start(MyApp.Root) - - {:ok, pid} = TermUI.App.start(MyApp.Root, backend: :tty) - - # With a named process - {:ok, _pid} = TermUI.App.start(MyApp.Root, name: :my_app) - - """ - @spec start(root_module(), [option()]) :: {:ok, pid()} | {:error, term()} - def start(root_module, opts \\ []) do - runtime_opts = [ - {:root, root_module} - | Keyword.take(opts, [:name, :backend, :render_interval, :skip_terminal, :use_input_handler]) - ] - - Runtime.start_link(runtime_opts) - end - - @doc """ - Runs a TermUI application blocking until completion. - - This is the simplest way to run a TermUI application. - It starts the runtime, waits for the application to exit, - cleans up terminal state, and returns the final result. - - Returns `{:ok, final_model}` on successful completion or - `{:error, reason}` if the application crashes. - - ## Options - - - `:backend` - Backend selection: `:auto` (default), `:raw`, `:tty` - - `:render_interval` - Milliseconds between renders (default: 16) - - ## Examples - - {:ok, final_state} = TermUI.App.run(MyApp.Root) - - {:ok, final_state} = TermUI.App.run(MyApp.Root, backend: :tty) - - ## Exit Conditions - - The application exits when: - - The root component returns `{:quit, model}` from update/2 - - The Runtime process crashes (returns error) - - User interrupts with Ctrl+C (handled by Runtime) - - """ - @spec run(root_module(), [option()]) :: {:ok, term()} | {:error, term()} - def run(root_module, opts \\ []) do - # Start the runtime - case start(root_module, opts) do - {:ok, pid} -> - # Monitor and wait for exit - ref = Process.monitor(pid) - - receive do - {:DOWN, ^ref, :process, ^pid, :normal} -> - {:ok, :exited_normally} - - {:DOWN, ^ref, :process, ^pid, reason} -> - # Ensure terminal cleanup even on crash - _ = ensure_terminal_cleanup() - {:error, reason} - end - - {:error, reason} -> - {:error, reason} - end - end - - @doc """ - Returns the current backend mode. - - Possible values: - - `:raw` - Full terminal control (OTP 28+) - - `:tty` - Line-based input (fallback) - - `:nil` - No app running or backend not initialized - - ## Examples - - case TermUI.App.backend_mode() do - :raw -> IO.puts("Running in raw mode - full features available") - :tty -> IO.puts("Running in TTY mode - limited features") - nil -> IO.puts("No app running") - end - - """ - @spec backend_mode() :: :raw | :tty | nil - def backend_mode, do: PersistentTerms.backend_mode() - - @doc """ - Checks if a capability is supported by the current terminal. - - Supported queries: - - `:unicode` - Unicode character support (box drawing, etc.) - - `:mouse` - Mouse event support - - `:colors` - Any color support (not monochrome) - - `:true_color` - 24-bit RGB color support - - `:color_256` - 256-color palette support - - `:color_16` - 16-color palette support - - `:monochrome` - No color support - - Returns `true` if the capability is supported, `false` otherwise. - Returns `false` if no app is running. - - ## Examples - - if TermUI.App.supports?(:unicode) do - # Use Unicode box drawing characters - else - # Fall back to ASCII - end - - if TermUI.App.supports?(:true_color) do - # Use RGB colors for smooth gradients - elsif TermUI.App.supports?(:color_256) do - # Use 256-color palette - else - # Use basic 16 colors - end - - """ - @spec supports?(supports_query()) :: boolean() - def supports?(query) do - capabilities = PersistentTerms.capabilities() || %{} - supports?(query, capabilities) - end - - defp supports?(:unicode, capabilities), do: Map.get(capabilities, :unicode, true) - defp supports?(:mouse, capabilities), do: Map.get(capabilities, :mouse, false) - defp supports?(:colors, capabilities), do: get_color_mode(capabilities) != :monochrome - defp supports?(:true_color, capabilities), do: get_color_mode(capabilities) == :true_color - - defp supports?(:color_256, capabilities), - do: get_color_mode(capabilities) in [:color_256, :true_color] - - defp supports?(:color_16, capabilities), - do: get_color_mode(capabilities) in [:color_16, :color_256, :true_color] - - defp supports?(:monochrome, capabilities), do: get_color_mode(capabilities) == :monochrome - defp supports?(_, _capabilities), do: false - - defp get_color_mode(capabilities), do: Map.get(capabilities, :colors, :true_color) - - @doc """ - Shuts down a running TermUI application. - - If a named Runtime process was started with `name: :my_app`, - you can shut it down by passing the name. Otherwise, this - function attempts to find and shut down the Runtime process. - - ## Examples - - # Shutdown by finding the process - :ok = TermUI.App.shutdown() - - # Shutdown by name - :ok = TermUI.App.shutdown(:my_app) - - """ - @spec shutdown(GenServer.name() | pid()) :: :ok | {:error, term()} - def shutdown(name_or_pid \\ nil) - - def shutdown(nil) do - # Try to find a running Runtime process - case Process.whereis(TermUI.Runtime) do - nil -> {:error, :not_found} - pid -> shutdown(pid) - end - end - - def shutdown(name) when is_atom(name) do - case Process.whereis(name) do - nil -> {:error, :not_found} - pid -> Runtime.shutdown(pid) - end - end - - def shutdown(pid) when is_pid(pid) do - Runtime.shutdown(pid) - end - - # Private helper to ensure terminal cleanup on crash - defp ensure_terminal_cleanup do - # Try to restore terminal via direct escape sequences - # This ensures cleanup even if Runtime GenServer is dead - # Disable mouse tracking - IO.write("\e[?1006l\e[?1003l\e[?1002l\e[?1000l") - # Show cursor - IO.write("\e[?25h") - # Reset colors - IO.write("\e[0m") - # Clear screen - IO.write("\e[2J") - # Move cursor to home - IO.write("\e[H") - :ok - rescue - _ -> :error - end -end diff --git a/lib/term_ui/backend.ex b/lib/term_ui/backend.ex index 88535409..00945ca8 100644 --- a/lib/term_ui/backend.ex +++ b/lib/term_ui/backend.ex @@ -1,274 +1,32 @@ defmodule TermUI.Backend do @moduledoc """ - Behaviour defining the contract for terminal backends. + The terminal backend contract. - The `TermUI.Backend` behaviour establishes a common interface for all terminal - rendering backends in TermUI. This abstraction enables the framework to support - multiple terminal environments: - - - **Raw mode** (`TermUI.Backend.Raw`): Direct terminal control with immediate - keystroke detection, used when `:shell.start_interactive({:noshell, :raw})` - succeeds (OTP 28+) - - - **TTY mode** (`TermUI.Backend.TTY`): Fallback rendering for constrained - environments where raw mode is unavailable (Nerves devices, SSH sessions, - remote IEx consoles) - - ## Implementing a Backend - - To implement a backend, define a module that uses this behaviour: - - defmodule MyBackend do - @behaviour TermUI.Backend - - @impl true - def init(opts) do - # Initialize backend state - {:ok, %{}} - end - - @impl true - def shutdown(state) do - # Clean up resources - :ok - end - - # ... implement remaining callbacks - end - - ## Backend Selection - - Backend selection is handled by `TermUI.Backend.Selector`, which uses the - "try raw mode first" strategy. Applications typically don't interact with - backends directly - the runtime handles backend lifecycle. - - ## Type Conventions - - - **Positions** are 1-indexed `{row, col}` tuples matching terminal standards - - **Colors** can be `:default`, named atoms, 256-color indices, or RGB tuples - - **Cells** are simplified tuples for the backend interface; the full - `TermUI.Renderer.Cell` struct is used internally - - ## Callback Categories - - The callbacks are organized into categories: - - - **Lifecycle**: `init/1`, `shutdown/1` - backend setup and teardown - - **Queries**: `size/1` - terminal state queries - - **Cursor**: `move_cursor/2`, `hide_cursor/1`, `show_cursor/1` - cursor control - - **Rendering**: `clear/1`, `draw_cells/2`, `flush/1` - screen output - - **Input**: `poll_event/2` - keyboard/mouse input - """ - - # Type Definitions - - @typedoc """ - Cursor position as a 1-indexed `{row, col}` tuple. - - Row 1 is the top of the screen, column 1 is the left edge. - This matches standard terminal addressing (ANSI escape sequences use 1-indexed positions). - - Note: Positions use `pos_integer()` (minimum 1) since terminal coordinates are 1-indexed. - Position `{0, 0}` is invalid in terminal addressing. - """ - @type position :: {row :: pos_integer(), col :: pos_integer()} - - @typedoc """ - Terminal dimensions as `{rows, cols}`. - - Represents the current terminal size in character cells. - Terminals always have at least 1 row and 1 column. + A backend owns input, output, size, capabilities, cursor state, terminal + setup, and cleanup. The runtime treats backend state as opaque data. """ - @type size :: {rows :: pos_integer(), cols :: pos_integer()} - @typedoc """ - Color specification for foreground or background. - - Supports multiple color formats: - - `:default` - Terminal default color - - Named atoms - Basic colors (`:red`, `:green`, `:blue`, etc.) - - `0..255` - 256-color palette index - - `{r, g, b}` - True color RGB values (0-255 each) - """ - @type color :: :default | atom() | 0..255 | {r :: 0..255, g :: 0..255, b :: 0..255} - - @typedoc """ - A terminal cell for backend rendering. - - Simplified tuple format for the backend interface: - - `char` - The character to display (grapheme cluster) - - `fg` - Foreground color - - `bg` - Background color - - `attrs` - Style attributes (`:bold`, `:underline`, etc.) - - This is a simplified representation for backend communication. The full - `TermUI.Renderer.Cell` struct is used internally by the renderer. - """ - @type cell :: {char :: String.t(), fg :: color(), bg :: color(), attrs :: [atom()]} - - @typedoc """ - Input event from the terminal. - - Alias for `TermUI.Event.t()` which includes key, mouse, focus, and other events. - """ + @type position :: {row :: pos_integer(), column :: pos_integer()} + @type size :: {rows :: pos_integer(), columns :: pos_integer()} + @type color :: :default | atom() | 0..255 | {0..255, 0..255, 0..255} + @type cell :: {String.t(), color(), color(), [atom()]} @type event :: TermUI.Event.t() - - @typedoc """ - Backend-specific internal state. - - Each backend implementation maintains its own state structure. - This is opaque to callers - only the backend module interprets it. - """ @type state :: term() - - # Lifecycle Callbacks - - @doc """ - Initializes the backend with the given options. - - Called once during runtime startup. The options may include: - - `:capabilities` - Map of detected terminal capabilities (TTY mode) - - Backend-specific options - - Returns `{:ok, state}` on success or `{:error, reason}` on failure. - - ## Implementation Notes - - - Raw backend receives options from successful `:shell.start_interactive/1` - - TTY backend receives capabilities map from `Backend.Selector` - - Should set up terminal state (alternate screen, cursor hiding, etc.) - """ - @callback init(opts :: keyword()) :: {:ok, state()} | {:error, reason :: term()} - - @doc """ - Shuts down the backend and restores terminal state. - - Called during runtime shutdown. Must: - - Restore terminal to its original state - - Release any held resources - - Be idempotent (safe to call multiple times) - - Handle errors gracefully (always return `:ok`) - - ## Implementation Notes - - - Should restore cursor visibility - - Should exit alternate screen if entered - - Should reset all attributes - """ - @callback shutdown(state()) :: :ok - - # Query Callbacks - - @doc """ - Returns the current terminal dimensions. - - Returns `{:ok, {rows, cols}}` with the terminal size. - Returns `{:error, :enotsup}` if size cannot be determined. - - ## Implementation Notes - - - Size may be cached and require explicit refresh after resize events - - TTY backend may use `:io.columns/0` and `:io.rows/0` - - Raw backend may query terminal directly - """ - @callback size(state()) :: {:ok, size()} | {:error, :enotsup} - - # Cursor Callbacks - - @doc """ - Moves the cursor to the specified position. - - Position is 1-indexed: `{1, 1}` is the top-left corner. - - Returns `{:ok, updated_state}` after positioning. - - ## Implementation Notes - - - Maps to ANSI CSI sequence `ESC[row;colH` - - Position should be clamped to terminal bounds - """ - @callback move_cursor(state(), position()) :: {:ok, state()} - - @doc """ - Hides the terminal cursor. - - Returns `{:ok, updated_state}` after hiding cursor. - - Typically called before rendering to prevent cursor flicker. - Maps to ANSI CSI sequence `ESC[?25l`. - """ - @callback hide_cursor(state()) :: {:ok, state()} - - @doc """ - Shows the terminal cursor. - - Returns `{:ok, updated_state}` after showing cursor. - - Called after rendering or when cursor visibility is needed. - Maps to ANSI CSI sequence `ESC[?25h`. - """ - @callback show_cursor(state()) :: {:ok, state()} - - # Rendering Callbacks - - @doc """ - Clears the entire screen. - - Returns `{:ok, updated_state}` after clearing. - - Typically resets cursor to home position as well. - Maps to ANSI CSI sequence `ESC[2J` followed by `ESC[H`. - """ - @callback clear(state()) :: {:ok, state()} - - @doc """ - Draws cells to the terminal at specified positions. - - Receives a list of `{position, cell}` tuples. Cells are sorted by position - (row-major order) for efficient sequential output. - - Returns `{:ok, updated_state}` after drawing. - - ## Implementation Notes - - - Raw backend uses differential rendering (only changed cells) - - TTY backend may use full redraw depending on configuration - - Should optimize cursor movement between cells - """ - @callback draw_cells(state(), [{position(), cell()}]) :: {:ok, state()} - - @doc """ - Flushes pending output to the terminal. - - Ensures all buffered output is sent to the terminal device. - Returns `{:ok, updated_state}` after flushing. - - ## Implementation Notes - - - May be a no-op if output is unbuffered - - Should be called after `draw_cells/2` to ensure visibility - """ - @callback flush(state()) :: {:ok, state()} - - # Input Callback - - @doc """ - Polls for input events with the specified timeout. - - - `timeout` - Milliseconds to wait for input (0 for non-blocking) - - Returns: - - `{:ok, event, updated_state}` - Event received - - `{:timeout, updated_state}` - No input within timeout - - `{:error, reason, state}` - Error occurred - - ## Implementation Notes - - - Raw backend provides immediate keystroke detection - - TTY backend uses `IO.getn/2` for character-by-character input - - Timeout may not be honored precisely in TTY mode (blocking IO) - - Events should be parsed into `TermUI.Event` structs - """ - @callback poll_event(state(), timeout :: non_neg_integer()) :: - {:ok, event(), state()} | {:timeout, state()} | {:error, reason :: term(), state()} + @type spec :: :auto | :raw | :tty | module() | {module(), keyword()} + + @callback init(keyword()) :: {:ok, state()} | {:error, term()} + @callback size(state()) :: {:ok, size()} | {:error, term()} + @callback capabilities(state()) :: map() + @callback draw(state(), TermUI.Frame.t()) :: {:ok, state()} | {:error, term()} + @callback flush(state()) :: {:ok, state()} | {:error, term()} + @callback clipboard(state(), TermUI.Clipboard.Operation.t()) :: + {:ok, state()} | {:error, term()} + @callback poll_event(state(), non_neg_integer()) :: + {:ok, event(), state()} + | {:timeout, state()} + | {:error, term(), state()} + @callback resize(state(), size()) :: {:ok, state()} | {:error, term()} + @callback shutdown(state(), term()) :: :ok + + @optional_callbacks clipboard: 2 end diff --git a/lib/term_ui/backend/config.ex b/lib/term_ui/backend/config.ex deleted file mode 100644 index 8e074168..00000000 --- a/lib/term_ui/backend/config.ex +++ /dev/null @@ -1,422 +0,0 @@ -defmodule TermUI.Backend.Config do - @moduledoc """ - Configuration handling for terminal backends. - - The Config module provides a clean interface for reading backend configuration - from the application environment. All configuration options have sensible - defaults, allowing TermUI to work out of the box without explicit configuration. - - ## Configuration Options - - Configure TermUI in your `config/config.exs`: - - config :term_ui, - backend: :auto, - character_set: :unicode, - fallback_character_set: :ascii, - tty_opts: [line_mode: :full_redraw], - raw_opts: [alternate_screen: true] - - ### Backend Selection - - The `:backend` option controls how the terminal backend is selected: - - - `:auto` (default) - Automatically detect the best backend using the selector - - `TermUI.Backend.Raw` - Force raw mode backend - - `TermUI.Backend.TTY` - Force TTY mode backend - - `TermUI.Backend.Test` - Use test backend for testing - - ### Character Set - - The `:character_set` option specifies the preferred character set for - rendering box-drawing characters and other UI elements: - - - `:unicode` (default) - Use Unicode box-drawing characters - - `:ascii` - Use ASCII-only characters - - The `:fallback_character_set` option specifies what to use when the - preferred character set is not available: - - - `:ascii` (default) - Fall back to ASCII - - `:unicode` - Fall back to Unicode (rarely useful) - - ### Backend Options - - The `:tty_opts` and `:raw_opts` options pass backend-specific configuration: - - **TTY Options:** - - `:line_mode` - Rendering mode (`:full_redraw` or `:incremental`) - - **Raw Options:** - - `:alternate_screen` - Whether to use alternate screen buffer (boolean) - - ## Usage - - # Get individual configuration values - backend = Config.get_backend() - char_set = Config.get_character_set() - - # Get backend-specific options - tty_opts = Config.get_tty_opts() - raw_opts = Config.get_raw_opts() - - ## Validation - - Use `validate!/0` to check configuration at application startup: - - # In your Application.start/2 - TermUI.Backend.Config.validate!() - - Or use `valid?/0` to check without raising: - - if Config.valid?() do - # proceed - else - # handle invalid config - end - """ - - @app :term_ui - - # Valid configuration values - @valid_backends [:auto, TermUI.Backend.Raw, TermUI.Backend.TTY, TermUI.Backend.Test] - @valid_character_sets [:unicode, :ascii] - @valid_line_modes [:full_redraw, :incremental] - - @typedoc """ - Complete runtime configuration map. - - Contains all configuration values needed to initialize and operate the backend system. - """ - @type config :: %{ - backend: :auto | module(), - character_set: :unicode | :ascii, - fallback_character_set: :unicode | :ascii, - tty_opts: keyword(), - raw_opts: keyword() - } - - @doc """ - Returns the configured backend selection mode. - - ## Returns - - - `:auto` - Use automatic backend detection (default) - - A module atom - Use the specified backend module - - ## Examples - - iex> Config.get_backend() - :auto - - # With config: [backend: TermUI.Backend.Raw] - iex> Config.get_backend() - TermUI.Backend.Raw - """ - @spec get_backend() :: :auto | module() - def get_backend do - Application.get_env(@app, :backend, :auto) - end - - @doc """ - Returns the configured character set for UI rendering. - - ## Returns - - - `:unicode` - Use Unicode characters (default) - - `:ascii` - Use ASCII-only characters - - ## Examples - - iex> Config.get_character_set() - :unicode - - # With config: [character_set: :ascii] - iex> Config.get_character_set() - :ascii - """ - @spec get_character_set() :: :unicode | :ascii - def get_character_set do - Application.get_env(@app, :character_set, :unicode) - end - - @doc """ - Returns the configured fallback character set. - - Used when the preferred character set is not available on the terminal. - - ## Returns - - - `:ascii` - Fall back to ASCII (default) - - `:unicode` - Fall back to Unicode - - ## Examples - - iex> Config.get_fallback_character_set() - :ascii - - # With config: [fallback_character_set: :unicode] - iex> Config.get_fallback_character_set() - :unicode - """ - @spec get_fallback_character_set() :: :unicode | :ascii - def get_fallback_character_set do - Application.get_env(@app, :fallback_character_set, :ascii) - end - - @doc """ - Returns the configured TTY backend options. - - ## Returns - - A keyword list of TTY-specific options. Defaults to `[line_mode: :full_redraw]`. - - ## Options - - - `:line_mode` - Rendering mode - - `:full_redraw` - Redraw entire screen each frame (default) - - `:incremental` - Only redraw changed lines - - ## Examples - - iex> Config.get_tty_opts() - [line_mode: :full_redraw] - - # With config: [tty_opts: [line_mode: :incremental]] - iex> Config.get_tty_opts() - [line_mode: :incremental] - """ - @spec get_tty_opts() :: keyword() - def get_tty_opts do - Application.get_env(@app, :tty_opts, line_mode: :full_redraw) - end - - @doc """ - Returns the configured raw backend options. - - ## Returns - - A keyword list of raw mode-specific options. Defaults to `[alternate_screen: true]`. - - ## Options - - - `:alternate_screen` - Whether to use the alternate screen buffer - - `true` - Use alternate screen, restoring original on exit (default) - - `false` - Use main screen buffer - - ## Examples - - iex> Config.get_raw_opts() - [alternate_screen: true] - - # With config: [raw_opts: [alternate_screen: false]] - iex> Config.get_raw_opts() - [alternate_screen: false] - """ - @spec get_raw_opts() :: keyword() - def get_raw_opts do - Application.get_env(@app, :raw_opts, alternate_screen: true) - end - - # ============================================================================ - # Validation Functions - # ============================================================================ - - @doc """ - Validates the current configuration, raising on errors. - - Checks that all configuration values are valid. Call this at application - startup to catch configuration errors early. - - ## Returns - - - `:ok` if configuration is valid - - ## Raises - - - `ArgumentError` with a descriptive message if any configuration is invalid - - ## Examples - - iex> Config.validate!() - :ok - - # With invalid config: [backend: :invalid] - iex> Config.validate!() - ** (ArgumentError) invalid :backend value: :invalid, expected one of [:auto, TermUI.Backend.Raw, TermUI.Backend.TTY, TermUI.Backend.Test] - """ - @spec validate!() :: :ok - def validate! do - validate_backend!() - validate_character_set!() - validate_fallback_character_set!() - validate_tty_opts!() - validate_raw_opts!() - :ok - end - - @doc """ - Checks if the current configuration is valid. - - Returns `true` if all configuration values are valid, `false` otherwise. - Does not raise exceptions. - - ## Returns - - - `true` if configuration is valid - - `false` if any configuration value is invalid - - ## Examples - - iex> Config.valid?() - true - - # With invalid config: [backend: :invalid] - iex> Config.valid?() - false - """ - @spec valid?() :: boolean() - def valid? do - validate!() - true - rescue - ArgumentError -> false - end - - @doc """ - Returns the complete runtime configuration as a map. - - This function validates the configuration before returning. If any - configuration value is invalid, an `ArgumentError` is raised. - - ## Returns - - A map containing all configuration values: - - `:backend` - Backend selection mode - - `:character_set` - Preferred character set - - `:fallback_character_set` - Fallback character set - - `:tty_opts` - TTY backend options - - `:raw_opts` - Raw backend options - - ## Raises - - - `ArgumentError` if any configuration value is invalid - - ## Examples - - iex> Config.runtime_config() - %{ - backend: :auto, - character_set: :unicode, - fallback_character_set: :ascii, - tty_opts: [line_mode: :full_redraw], - raw_opts: [alternate_screen: true] - } - - # With custom config - iex> Config.runtime_config() - %{ - backend: TermUI.Backend.Raw, - character_set: :ascii, - fallback_character_set: :ascii, - tty_opts: [line_mode: :incremental], - raw_opts: [alternate_screen: false] - } - """ - @spec runtime_config() :: config() - def runtime_config do - validate!() - - %{ - backend: get_backend(), - character_set: get_character_set(), - fallback_character_set: get_fallback_character_set(), - tty_opts: get_tty_opts(), - raw_opts: get_raw_opts() - } - end - - # Private validation helpers - - @spec validate_backend!() :: :ok - defp validate_backend! do - backend = get_backend() - - unless backend in @valid_backends do - raise ArgumentError, - "invalid :backend value: #{inspect(backend)}, " <> - "expected one of #{inspect(@valid_backends)}" - end - - :ok - end - - @spec validate_character_set!() :: :ok - defp validate_character_set! do - char_set = get_character_set() - - unless char_set in @valid_character_sets do - raise ArgumentError, - "invalid :character_set value: #{inspect(char_set)}, " <> - "expected one of #{inspect(@valid_character_sets)}" - end - - :ok - end - - @spec validate_fallback_character_set!() :: :ok - defp validate_fallback_character_set! do - fallback = get_fallback_character_set() - - unless fallback in @valid_character_sets do - raise ArgumentError, - "invalid :fallback_character_set value: #{inspect(fallback)}, " <> - "expected one of #{inspect(@valid_character_sets)}" - end - - :ok - end - - @spec validate_tty_opts!() :: :ok - defp validate_tty_opts! do - opts = get_tty_opts() - - unless Keyword.keyword?(opts) do - raise ArgumentError, - "invalid :tty_opts value: #{inspect(opts)}, expected a keyword list" - end - - if Keyword.has_key?(opts, :line_mode) do - line_mode = Keyword.get(opts, :line_mode) - - unless line_mode in @valid_line_modes do - raise ArgumentError, - "invalid :line_mode value in :tty_opts: #{inspect(line_mode)}, " <> - "expected one of #{inspect(@valid_line_modes)}" - end - end - - :ok - end - - @spec validate_raw_opts!() :: :ok - defp validate_raw_opts! do - opts = get_raw_opts() - - unless Keyword.keyword?(opts) do - raise ArgumentError, - "invalid :raw_opts value: #{inspect(opts)}, expected a keyword list" - end - - if Keyword.has_key?(opts, :alternate_screen) do - alt = Keyword.get(opts, :alternate_screen) - - unless is_boolean(alt) do - raise ArgumentError, - "invalid :alternate_screen value in :raw_opts: #{inspect(alt)}, expected boolean" - end - end - - :ok - end -end diff --git a/lib/term_ui/backend/event_stream.ex b/lib/term_ui/backend/event_stream.ex new file mode 100644 index 00000000..9ef90a90 --- /dev/null +++ b/lib/term_ui/backend/event_stream.ex @@ -0,0 +1,111 @@ +defmodule TermUI.Backend.EventStream do + @moduledoc false + + alias TermUI.Backend.{InputBuffer, InputReader} + alias TermUI.Event + alias TermUI.Terminal.EscapeParser + + @escape_timeout 50 + @max_reads_per_poll 256 + @max_event_queue 100 + + @spec poll(map(), non_neg_integer(), (-> InputReader.result()), module()) :: + {:ok, TermUI.Backend.event(), map()} + | {:timeout, map()} + | {:error, term(), map()} + def poll(state, timeout, read_fun, source) do + case parse_buffer(state) do + {:ok, event, state} -> + {:ok, event, state} + + {:need_more, state} -> + state = ensure_reader(state, read_fun) + read_and_parse(state, timeout, source, 0) + end + end + + @spec stop(map()) :: :ok + def stop(state), do: InputReader.stop(Map.get(state, :input_reader)) + + defp read_and_parse(state, timeout, source, reads) do + case InputReader.take(state.input_reader, timeout) do + {:ok, data} -> + state = + InputBuffer.append_with_limit(state, data, :input_buffer, + source: source, + paste_aware: true + ) + + case parse_buffer(state) do + {:ok, event, state} -> + {:ok, event, state} + + {:need_more, state} when reads + 1 < @max_reads_per_poll -> + read_and_parse(state, partial_timeout(state), source, reads + 1) + + {:need_more, state} -> + {:timeout, state} + end + + :timeout -> + resolve_timeout(state) + + :eof -> + resolve_end_of_input(state) + + {:error, reason} -> + {:error, reason, state} + end + end + + defp parse_buffer(%{event_queue: [event | rest]} = state) do + {:ok, event, %{state | event_queue: rest}} + end + + defp parse_buffer(%{input_buffer: ""} = state), do: {:need_more, state} + + defp parse_buffer(state) do + case EscapeParser.parse(state.input_buffer) do + {[event | rest], remaining} -> + {:ok, event, queue_events(%{state | input_buffer: remaining}, rest)} + + {[], remaining} -> + {:need_more, %{state | input_buffer: remaining}} + end + end + + defp resolve_timeout(%{input_buffer: <<0x1B>>} = state) do + {:ok, Event.key(:escape), %{state | input_buffer: ""}} + end + + defp resolve_timeout(%{input_buffer: "\e[200~" <> _paste} = state) do + {:timeout, state} + end + + defp resolve_timeout(%{input_buffer: ""} = state), do: {:timeout, state} + defp resolve_timeout(state), do: {:timeout, %{state | input_buffer: ""}} + + defp resolve_end_of_input(%{input_buffer: <<0x1B>>} = state) do + {:ok, Event.key(:escape), %{state | input_buffer: ""}} + end + + defp resolve_end_of_input(%{input_buffer: ""} = state), do: {:error, :eof, state} + defp resolve_end_of_input(state), do: {:error, :eof, %{state | input_buffer: ""}} + + defp partial_timeout(%{input_buffer: ""}), do: 0 + defp partial_timeout(_state), do: @escape_timeout + + defp ensure_reader(%{input_reader: nil} = state, read_fun) do + {:ok, reader} = InputReader.start_link(read_fun) + %{state | input_reader: reader} + end + + defp ensure_reader(state, _read_fun), do: state + + defp queue_events(state, []), do: state + + defp queue_events(state, events) do + queue = Enum.take(state.event_queue ++ events, -@max_event_queue) + %{state | event_queue: queue} + end +end diff --git a/lib/term_ui/backend/input_buffer.ex b/lib/term_ui/backend/input_buffer.ex index c022b352..a4b59f60 100644 --- a/lib/term_ui/backend/input_buffer.ex +++ b/lib/term_ui/backend/input_buffer.ex @@ -1,48 +1,5 @@ defmodule TermUI.Backend.InputBuffer do - @moduledoc """ - Shared input buffer management for terminal backends. - - This module provides secure input buffer handling with: - - Size limits to prevent memory exhaustion - - Rate-limited logging to prevent log flooding - - Consistent behavior across backends - - ## Security - - The input buffer protects against memory exhaustion attacks where - malformed input streams send unterminated escape sequences. Without - protection, the buffer would grow indefinitely. - - ### Buffer Size Limits - - - Maximum buffer size: 1024 bytes - - Keep size on truncation: 256 bytes - - The 256-byte keep size preserves potential partial escape sequences - (typical sequences are 8-20 bytes, max CSI is ~100 bytes). - - ### Rate-Limited Logging - - Buffer overflow warnings are rate-limited to prevent log flooding - attacks. Maximum one warning every 5 seconds per backend instance. - - ## Usage - - Backends should use this module instead of implementing their own - buffer management: - - # In your backend module - alias TermUI.Backend.InputBuffer - - # Appending data - new_buffer = InputBuffer.append(state.input_buffer, data) - - # Applying limit (returns {buffer, overflow_occurred?}) - {limited_buffer, overflowed} = InputBuffer.apply_limit(new_buffer) - - # Or use the combined function that handles state - new_state = InputBuffer.append_with_limit(state, data, :input_buffer) - """ + @moduledoc false require Logger @@ -51,6 +8,9 @@ defmodule TermUI.Backend.InputBuffer do # Number of bytes to keep when truncating (preserves partial sequences) @keep_size 256 + @paste_start "\e[200~" + @paste_end "\e[201~" + @max_paste_size 8 * 1024 * 1024 # Minimum time between overflow warnings (5 seconds in milliseconds) @warning_interval_ms 5_000 @@ -187,10 +147,162 @@ defmodule TermUI.Backend.InputBuffer do """ @spec append_with_limit(map(), binary(), atom(), keyword()) :: map() def append_with_limit(state, data, field, opts \\ []) when is_map(state) and is_atom(field) do - current = Map.get(state, field, "") - new_buffer = append(current, data) - {limited, _overflowed} = apply_limit(new_buffer, opts) - Map.put(state, field, limited) + if Keyword.get(opts, :paste_aware, false) do + append_terminal_input(state, data, field, opts) + else + current = Map.get(state, field, "") + new_buffer = append(current, data) + {limited, _overflowed} = apply_limit(new_buffer, opts) + Map.put(state, field, limited) + end + end + + defp append_terminal_input(state, data, field, opts) do + case Map.get(state, :paste_state) do + %{mode: :collecting} = paste -> append_paste_data(state, paste, data, field, opts) + %{mode: :discarding} = paste -> discard_paste_data(state, paste, data, field, opts) + _ -> append_regular_terminal_input(state, data, field, opts) + end + end + + defp append_regular_terminal_input(state, data, field, opts) do + buffer = append(Map.get(state, field, ""), data) + + cond do + String.starts_with?(buffer, @paste_start) -> + body = + binary_part( + buffer, + byte_size(@paste_start), + byte_size(buffer) - byte_size(@paste_start) + ) + + state + |> Map.put(field, @paste_start) + |> Map.put(:paste_state, %{mode: :collecting, chunks: [], size: 0, end_buffer: ""}) + |> append_terminal_input(body, field, opts) + + byte_size(buffer) > @max_buffer_size -> + maybe_log_terminal_overflow(opts, byte_size(buffer)) + Map.put(state, field, "") + + true -> + Map.put(state, field, buffer) + end + end + + defp append_paste_data(state, paste, data, field, opts) do + data = paste.end_buffer <> data + + case :binary.match(data, @paste_end) do + {position, marker_size} -> + body = binary_part(data, 0, position) + + trailing = + binary_part(data, position + marker_size, byte_size(data) - position - marker_size) + + complete_paste(state, paste, body, trailing, field, opts) + + :nomatch -> + {body, end_buffer} = split_end_marker_prefix(data) + body_size = paste.size + byte_size(body) + + if body_size > @max_paste_size do + discard_paste(state, body_size, end_buffer, field, opts) + else + state + |> Map.put(field, @paste_start) + |> Map.put(:paste_state, %{ + paste + | chunks: prepend_chunk(paste.chunks, body), + size: body_size, + end_buffer: end_buffer + }) + end + end + end + + defp complete_paste(state, paste, body, trailing, field, opts) do + body_size = paste.size + byte_size(body) + + if body_size > @max_paste_size do + discard_paste(state, body_size, "", field, opts, @paste_end <> trailing) + else + content = + paste.chunks + |> prepend_chunk(body) + |> Enum.reverse() + |> IO.iodata_to_binary() + + {trailing, _overflowed} = apply_limit(trailing, opts) + + state + |> Map.put(field, @paste_start <> content <> @paste_end <> trailing) + |> Map.put(:paste_state, nil) + end + end + + defp discard_paste(state, body_size, end_buffer, field, opts, remaining \\ "") do + maybe_log_terminal_overflow(opts, body_size) + + state = + state + |> Map.put(field, "") + |> Map.put(:paste_state, %{mode: :discarding, end_buffer: end_buffer}) + + if remaining == "" do + state + else + discard_paste_data(state, state.paste_state, remaining, field, opts) + end + end + + defp discard_paste_data(state, paste, data, field, opts) do + data = paste.end_buffer <> data + + case :binary.match(data, @paste_end) do + {position, marker_size} -> + trailing = + binary_part(data, position + marker_size, byte_size(data) - position - marker_size) + + state + |> Map.put(field, "") + |> Map.put(:paste_state, nil) + |> append_terminal_input(trailing, field, opts) + + :nomatch -> + {_discarded, end_buffer} = split_end_marker_prefix(data) + + state + |> Map.put(field, "") + |> Map.put(:paste_state, %{mode: :discarding, end_buffer: end_buffer}) + end + end + + defp split_end_marker_prefix(data) do + max_prefix_size = min(byte_size(data), byte_size(@paste_end) - 1) + + prefix_size = + if max_prefix_size == 0 do + 0 + else + Enum.find(Range.new(max_prefix_size, 1, -1), 0, fn size -> + suffix = binary_part(data, byte_size(data) - size, size) + String.starts_with?(@paste_end, suffix) + end) + end + + body_size = byte_size(data) - prefix_size + {binary_part(data, 0, body_size), binary_part(data, body_size, prefix_size)} + end + + defp prepend_chunk(chunks, ""), do: chunks + defp prepend_chunk(chunks, chunk), do: [chunk | chunks] + + defp maybe_log_terminal_overflow(opts, size) do + if Keyword.get(opts, :log, true) do + maybe_log_overflow(Keyword.get(opts, :source, :unknown), size, 0) + end end # =========================================================================== @@ -198,7 +310,7 @@ defmodule TermUI.Backend.InputBuffer do # =========================================================================== # Logs a warning if enough time has passed since the last warning. - @spec maybe_log_overflow(term(), pos_integer(), pos_integer()) :: :ok + @spec maybe_log_overflow(term(), pos_integer(), non_neg_integer()) :: :ok defp maybe_log_overflow(source, original_size, keep_size) do now = System.monotonic_time(:millisecond) diff --git a/lib/term_ui/backend/input_reader.ex b/lib/term_ui/backend/input_reader.ex new file mode 100644 index 00000000..8f7f4b13 --- /dev/null +++ b/lib/term_ui/backend/input_reader.ex @@ -0,0 +1,90 @@ +defmodule TermUI.Backend.InputReader do + @moduledoc false + + use GenServer + + @type result :: {:ok, binary()} | :eof | {:error, term()} + + @spec start_link((-> result())) :: GenServer.on_start() + def start_link(read_fun) when is_function(read_fun, 0) do + GenServer.start_link(__MODULE__, read_fun) + end + + @spec take(pid(), non_neg_integer()) :: result() | :timeout + def take(reader, timeout) do + GenServer.call(reader, {:take, timeout}, timeout + 1_000) + end + + @spec stop(pid() | nil) :: :ok + def stop(nil), do: :ok + + def stop(reader) when is_pid(reader) do + if Process.alive?(reader), do: GenServer.stop(reader, :normal) + :ok + catch + :exit, _reason -> :ok + end + + @impl true + def init(read_fun) do + owner = self() + worker = spawn_link(fn -> read_loop(owner, read_fun) end) + {:ok, %{worker: worker, result: nil, waiter: nil}} + end + + @impl true + def handle_call({:take, _timeout}, _from, %{result: result} = state) when not is_nil(result) do + continue_reader(state.worker, result) + {:reply, result, %{state | result: nil}} + end + + def handle_call({:take, 0}, _from, state), do: {:reply, :timeout, state} + + def handle_call({:take, timeout}, from, %{waiter: nil} = state) do + token = make_ref() + timer = Process.send_after(self(), {:take_timeout, token}, timeout) + {:noreply, %{state | waiter: {from, token, timer}}} + end + + @impl true + def handle_info({:input_result, worker, result}, %{worker: worker, waiter: nil} = state) do + {:noreply, %{state | result: result}} + end + + def handle_info( + {:input_result, worker, result}, + %{worker: worker, waiter: {from, _token, timer}} = state + ) do + _cancelled = Process.cancel_timer(timer) + GenServer.reply(from, result) + continue_reader(worker, result) + {:noreply, %{state | waiter: nil}} + end + + def handle_info({:take_timeout, token}, %{waiter: {from, token, _timer}} = state) do + GenServer.reply(from, :timeout) + {:noreply, %{state | waiter: nil}} + end + + def handle_info({:take_timeout, _old_token}, state), do: {:noreply, state} + + @impl true + def terminate(_reason, state) do + if Process.alive?(state.worker), do: Process.exit(state.worker, :kill) + :ok + end + + defp read_loop(owner, read_fun) do + result = read_fun.() + send(owner, {:input_result, self(), result}) + + if match?({:ok, _data}, result) do + receive do + :continue -> read_loop(owner, read_fun) + end + end + end + + defp continue_reader(worker, {:ok, _data}), do: send(worker, :continue) + defp continue_reader(_worker, _result), do: :ok +end diff --git a/lib/term_ui/backend/manager.ex b/lib/term_ui/backend/manager.ex new file mode 100644 index 00000000..545995d2 --- /dev/null +++ b/lib/term_ui/backend/manager.ex @@ -0,0 +1,426 @@ +defmodule TermUI.Backend.Manager do + @moduledoc false + + use GenServer + + alias TermUI.Backend + alias TermUI.Backend.{Raw, Selector, TTY} + alias TermUI.Clipboard.Operation + alias TermUI.Frame + alias TermUI.Terminal.SizeDetector + + @input_poll_timeout 10 + @fast_size_poll_interval 200 + @fallback_size_poll_interval 1_000 + @minimum_size_poll_interval 50 + + @type info :: %{ + backend: module(), + size: Backend.size(), + capabilities: map() + } + + @spec start_link(pid(), Backend.spec(), keyword()) :: GenServer.on_start() + def start_link(owner, spec, opts) when is_pid(owner) do + GenServer.start_link(__MODULE__, {owner, spec, opts}) + end + + @spec info(pid()) :: info() + def info(manager), do: GenServer.call(manager, :info) + + @spec activate(pid()) :: :ok + def activate(manager), do: GenServer.call(manager, :activate) + + @spec draw(pid(), Frame.t()) :: :ok | {:error, term()} + def draw(manager, frame), do: GenServer.call(manager, {:draw, frame}) + + @spec flush(pid()) :: :ok | {:error, term()} + def flush(manager), do: GenServer.call(manager, :flush) + + @spec clipboard(pid(), Operation.t()) :: :ok | {:error, term()} + def clipboard(manager, %Operation{} = operation), + do: GenServer.call(manager, {:clipboard, operation}) + + @spec resize(pid(), Backend.size()) :: :ok | {:error, term()} + def resize(manager, size), do: GenServer.call(manager, {:resize, size}) + + @spec close(pid(), term()) :: :ok + def close(manager, reason) do + GenServer.call(manager, {:close, reason}, 5_000) + catch + :exit, _reason -> :ok + end + + @impl true + def init({owner, spec, opts}) do + Process.flag(:trap_exit, true) + opts = Keyword.put_new(opts, :runtime, owner) + + with {:ok, requested_size_poll_interval} <- parse_size_poll_interval(opts), + {:ok, backend, backend_state} <- open_backend(spec, opts), + {:ok, size} <- query_size(backend, backend_state), + {:ok, capabilities} <- query_capabilities(backend, backend_state) do + {:ok, + %{ + owner: owner, + backend: backend, + backend_state: backend_state, + size: size, + capabilities: capabilities, + size_poll_interval: resolve_size_poll_interval(backend, requested_size_poll_interval), + active?: false, + closed?: false + }} + else + {:opened_error, backend, backend_state, reason} -> + close_backend(backend, backend_state, reason) + {:stop, reason} + + {:error, reason} -> + {:stop, reason} + end + end + + @impl true + def handle_call(:info, _from, state) do + {:reply, %{backend: state.backend, size: state.size, capabilities: state.capabilities}, state} + end + + def handle_call(:activate, _from, %{active?: false} = state) do + send(self(), :poll_input) + schedule_size_poll(state) + {:reply, :ok, %{state | active?: true}} + end + + def handle_call(:activate, _from, state), do: {:reply, :ok, state} + + def handle_call({:draw, %Frame{} = frame}, _from, state) do + case invoke_state_callback(state, :draw, [frame]) do + {:ok, backend_state} -> {:reply, :ok, %{state | backend_state: backend_state}} + {:error, reason} -> {:reply, {:error, reason}, state} + end + end + + def handle_call(:flush, _from, state) do + case invoke_state_callback(state, :flush, []) do + {:ok, backend_state} -> {:reply, :ok, %{state | backend_state: backend_state}} + {:error, reason} -> {:reply, {:error, reason}, state} + end + end + + def handle_call({:clipboard, %Operation{} = operation}, _from, state) do + if function_exported?(state.backend, :clipboard, 2) do + case invoke_state_callback(state, :clipboard, [operation]) do + {:ok, backend_state} -> {:reply, :ok, %{state | backend_state: backend_state}} + {:error, reason} -> {:reply, {:error, reason}, state} + end + else + {:reply, {:error, backend_error(state.backend, :clipboard, :unsupported)}, state} + end + end + + def handle_call({:resize, size}, _from, state) do + case invoke_state_callback(state, :resize, [size]) do + {:ok, backend_state} -> + {:reply, :ok, %{state | backend_state: backend_state, size: size}} + + {:error, reason} -> + {:reply, {:error, reason}, state} + end + end + + def handle_call({:close, reason}, _from, state) do + close_backend(state.backend, state.backend_state, reason) + {:stop, :normal, :ok, %{state | closed?: true, active?: false}} + end + + @impl true + def handle_info(:poll_input, %{active?: true} = state) do + case invoke_poll(state) do + {:ok, event, backend_state} -> + send(state.owner, {:backend_event, event}) + send(self(), :poll_input) + {:noreply, %{state | backend_state: backend_state}} + + {:timeout, backend_state} -> + send(self(), :poll_input) + {:noreply, %{state | backend_state: backend_state}} + + {:error, reason, backend_state} -> + send(state.owner, {:backend_failed, reason}) + {:noreply, %{state | backend_state: backend_state, active?: false}} + end + end + + def handle_info(:poll_input, state), do: {:noreply, state} + + def handle_info(:poll_size, %{active?: true} = state) do + state = + case refresh_size(state) do + {:ok, size, backend_state} -> + if size != state.size, do: send(state.owner, {:backend_size, size}) + %{state | size: size, backend_state: backend_state} + + {:error, _reason} -> + state + end + + schedule_size_poll(state) + {:noreply, state} + end + + def handle_info(:poll_size, state), do: {:noreply, state} + + def handle_info({:EXIT, owner, reason}, %{owner: owner} = state) do + {:stop, reason, state} + end + + def handle_info({:EXIT, reader, reason}, state) do + if reader == input_reader(state.backend_state) and state.active? do + failure = backend_error(state.backend, :input, {:reader_exit, reason}) + send(state.owner, {:backend_failed, failure}) + {:noreply, %{state | active?: false}} + else + {:noreply, state} + end + end + + @impl true + def terminate(reason, %{closed?: false} = state) do + close_backend(state.backend, state.backend_state, reason) + :ok + end + + def terminate(_reason, _state), do: :ok + + defp open_backend(:auto, opts) do + case Selector.select() do + {:raw, raw_opts} -> + start_backend(Raw, Keyword.merge(opts, Map.to_list(raw_opts)), raw?: true) + + {:tty, capabilities} -> + start_backend(TTY, Keyword.put(opts, :capabilities, capabilities)) + end + end + + defp open_backend(:raw, opts) do + case Selector.attempt_raw_mode() do + {:raw, raw_opts} -> + start_backend(Raw, Keyword.merge(opts, Map.to_list(raw_opts)), raw?: true) + + {:tty, capabilities} -> + {:error, + {:raw_mode_unavailable, Map.get(capabilities, :raw_mode_error, :already_started)}} + end + end + + defp open_backend(:tty, opts), do: start_backend(TTY, opts) + + defp open_backend({module, backend_opts}, opts) + when is_atom(module) and is_list(backend_opts) do + start_backend(module, Keyword.merge(opts, backend_opts)) + end + + defp open_backend(module, opts) when is_atom(module), do: start_backend(module, opts) + + defp start_backend(module, opts, open_opts \\ []) do + case module.init(opts) do + {:ok, state} -> + {:ok, module, state} + + {:error, reason} -> + maybe_restore_raw(open_opts) + {:error, {:backend_init_failed, module, reason}} + + other -> + maybe_restore_raw(open_opts) + {:error, {:invalid_backend_init, module, other}} + end + rescue + exception -> + maybe_restore_raw(open_opts) + {:error, {:backend_init_failed, module, exception}} + catch + kind, reason -> + maybe_restore_raw(open_opts) + {:error, {:backend_init_failed, module, {kind, reason}}} + end + + defp query_size(backend, backend_state) do + case backend.size(backend_state) do + {:ok, {rows, columns} = size} + when is_integer(rows) and rows > 0 and is_integer(columns) and columns > 0 -> + {:ok, size} + + {:error, reason} -> + {:opened_error, backend, backend_state, backend_error(backend, :size, reason)} + + other -> + {:opened_error, backend, backend_state, + backend_error(backend, :size, {:invalid_result, other})} + end + rescue + exception -> + {:opened_error, backend, backend_state, backend_error(backend, :size, exception)} + catch + kind, reason -> + {:opened_error, backend, backend_state, backend_error(backend, :size, {kind, reason})} + end + + defp query_capabilities(backend, backend_state) do + case backend.capabilities(backend_state) do + capabilities when is_map(capabilities) -> + {:ok, capabilities} + + other -> + {:opened_error, backend, backend_state, + backend_error(backend, :capabilities, {:invalid_result, other})} + end + rescue + exception -> + {:opened_error, backend, backend_state, backend_error(backend, :capabilities, exception)} + catch + kind, reason -> + {:opened_error, backend, backend_state, + backend_error(backend, :capabilities, {kind, reason})} + end + + defp invoke_state_callback(state, stage, args) do + result = apply(state.backend, stage, [state.backend_state | args]) + + case result do + {:ok, backend_state} -> {:ok, backend_state} + {:error, reason} -> {:error, backend_error(state.backend, stage, reason)} + other -> {:error, backend_error(state.backend, stage, {:invalid_result, other})} + end + rescue + exception -> {:error, backend_error(state.backend, stage, exception)} + catch + kind, reason -> {:error, backend_error(state.backend, stage, {kind, reason})} + end + + defp invoke_poll(state) do + case state.backend.poll_event(state.backend_state, @input_poll_timeout) do + {:ok, event, backend_state} -> + {:ok, event, backend_state} + + {:timeout, backend_state} -> + {:timeout, backend_state} + + {:error, reason, backend_state} -> + {:error, backend_error(state.backend, :input, reason), backend_state} + + other -> + {:error, backend_error(state.backend, :input, {:invalid_result, other}), + state.backend_state} + end + rescue + exception -> + {:error, backend_error(state.backend, :input, exception), state.backend_state} + catch + kind, reason -> + {:error, backend_error(state.backend, :input, {kind, reason}), state.backend_state} + end + + defp refresh_size(state) do + state + |> size_result() + |> normalize_size_result(state.backend) + rescue + exception -> {:error, backend_error(state.backend, :size, exception)} + catch + kind, reason -> {:error, backend_error(state.backend, :size, {kind, reason})} + end + + defp size_result(state) do + if function_exported?(state.backend, :refresh_size, 1) do + state.backend.refresh_size(state.backend_state) + else + state.backend.size(state.backend_state) + |> add_backend_state(state.backend_state) + end + end + + defp add_backend_state({:ok, size}, backend_state), do: {:ok, size, backend_state} + defp add_backend_state(other, _backend_state), do: other + + defp normalize_size_result( + {:ok, {rows, columns} = size, backend_state}, + _backend + ) + when is_integer(rows) and rows > 0 and is_integer(columns) and columns > 0, + do: {:ok, size, backend_state} + + defp normalize_size_result({:error, reason}, backend), + do: {:error, backend_error(backend, :size, reason)} + + defp normalize_size_result(other, backend), + do: {:error, backend_error(backend, :size, {:invalid_result, other})} + + defp close_backend(module, state, reason) do + module.shutdown(state, reason) + rescue + _exception -> :ok + catch + _kind, _reason -> :ok + end + + defp backend_error(backend, stage, reason), do: {:backend, backend, stage, reason} + + defp input_reader(%{input_reader: reader}), do: reader + defp input_reader(_backend_state), do: nil + + defp parse_size_poll_interval(opts) do + case Keyword.get(opts, :size_poll_interval, :auto) do + :auto -> + {:ok, :auto} + + :disabled -> + {:ok, nil} + + interval when is_integer(interval) and interval >= @minimum_size_poll_interval -> + {:ok, interval} + + invalid -> + {:error, {:invalid_size_poll_interval, invalid}} + end + end + + defp resolve_size_poll_interval(_backend, nil), do: nil + defp resolve_size_poll_interval(_backend, interval) when is_integer(interval), do: interval + + defp resolve_size_poll_interval(backend, :auto) when backend in [Raw, TTY] do + if fast_size_detection_available?(), + do: @fast_size_poll_interval, + else: @fallback_size_poll_interval + end + + defp resolve_size_poll_interval(_backend, :auto), do: @fast_size_poll_interval + + defp fast_size_detection_available? do + match?({:ok, _size}, SizeDetector.detect_from_io()) or + match?({:ok, _size}, SizeDetector.detect_from_env()) + end + + defp schedule_size_poll(%{size_poll_interval: nil}), do: :ok + + defp schedule_size_poll(%{size_poll_interval: interval}) do + Process.send_after(self(), :poll_size, interval) + :ok + end + + defp maybe_restore_raw(opts) do + if Keyword.get(opts, :raw?, false) do + try do + _result = :shell.start_interactive({:noshell, :cooked}) + :ok + rescue + _exception -> :ok + catch + _kind, _reason -> :ok + end + else + :ok + end + end +end diff --git a/lib/term_ui/backend/raw.ex b/lib/term_ui/backend/raw.ex index 0884e203..2df0bf71 100644 --- a/lib/term_ui/backend/raw.ex +++ b/lib/term_ui/backend/raw.ex @@ -1,1532 +1,223 @@ defmodule TermUI.Backend.Raw do - @moduledoc """ - Raw terminal backend providing full terminal control. - - The Raw backend is the primary high-fidelity rendering path in TermUI. It provides - direct terminal control with immediate keystroke detection, true color support, - mouse tracking, and all advanced terminal features. - - ## Requirements - - - **OTP 28+**: Raw mode is activated via `:shell.start_interactive({:noshell, :raw})` - - **Terminal access**: Requires a real terminal (not pipes or redirected I/O) - - ## How It Works - - The Raw backend assumes raw mode has already been activated by `TermUI.Backend.Selector` - before `init/1` is called. The selector uses `:shell.start_interactive({:noshell, :raw})` - to enter raw mode, and on success, routes to this backend. - - **Important**: The `init/1` callback does NOT activate raw mode itself. It only performs - terminal setup (alternate screen, cursor hiding, etc.) assuming raw mode is already active. - - ## Features - - When raw mode is active, this backend provides: - - - **Alternate screen buffer**: Preserves original terminal content, restored on exit - - **Cursor control**: Hide/show cursor, precise positioning - - **True color rendering**: Full 24-bit RGB color support (`{r, g, b}` tuples) - - **256-color palette**: Extended color support (0-255 indices) - - **Mouse tracking**: Click, drag, and movement detection - - **Immediate input**: Character-by-character keystroke detection - - **Escape sequence handling**: Function keys, arrow keys, modifiers - - ## Initialization Flow - - ``` - 1. Selector calls :shell.start_interactive({:noshell, :raw}) - └── Returns :ok (raw mode active) - - 2. Runtime creates Raw backend state - └── Calls Raw.init(opts) - - 3. Raw.init/1 performs terminal setup: - ├── Enter alternate screen buffer (optional) - ├── Hide cursor - ├── Enable mouse tracking (optional) - └── Clear screen - ``` - - ## Configuration Options - - The `init/1` callback accepts these options: - - - `:alternate_screen` - Use alternate screen buffer (default: `true`) - - `:hide_cursor` - Hide cursor during rendering (default: `true`) - - `:mouse_tracking` - Mouse tracking mode (default: `:none`) - - `:none` - No mouse tracking - - `:click` - Track button clicks only - - `:drag` - Track clicks and drag events - - `:all` - Track all mouse movement - - `:size` - Explicit terminal dimensions `{rows, cols}` (default: auto-detect) - - ## Shutdown Behavior - - The `shutdown/1` callback restores the terminal to its pre-init state: - - 1. Disable mouse tracking (if enabled) - 2. Show cursor - 3. Reset all text attributes - 4. Leave alternate screen (if entered) - 5. Return to cooked mode via `:shell.start_interactive({:noshell, :cooked})` - - Shutdown is designed to be error-safe - individual failures don't prevent - subsequent cleanup steps from running. - - ## Usage Example - - This backend is typically used via the runtime, not directly: - - # Automatic backend selection (recommended) - {:ok, runtime} = TermUI.Runtime.start_link() - - # The runtime handles: - # 1. Backend selection via Selector - # 2. Backend initialization - # 3. Rendering via draw_cells/2 - # 4. Input polling via poll_event/2 - # 5. Clean shutdown - - ## Mouse Tracking Modes - - The Raw backend uses intuitive mode names that map to underlying ANSI protocol modes: - - | Raw Backend | ANSI Protocol | Escape Sequence | Description | - |-------------|---------------|-----------------|-------------| - | `:none` | (disabled) | - | No mouse tracking | - | `:click` | Normal (1000) | `ESC[?1000h` | Button press/release only | - | `:drag` | Button (1002) | `ESC[?1002h` | Press/release + motion while pressed | - | `:all` | Any (1003) | `ESC[?1003h` | All mouse motion events | - - When mouse tracking is enabled, SGR extended mode (`ESC[?1006h`) is also activated - for accurate coordinate encoding beyond column 223. - - Note: The `TermUI.ANSI` module uses protocol names (`:normal`, `:button`, `:all`), - while this backend uses user-friendly names (`:click`, `:drag`, `:all`). The mapping - is handled internally when emitting sequences. - - ## Style Delta Optimization - - The `current_style` field in the backend state tracks the last-emitted SGR (Select - Graphic Rendition) attributes. This enables **style delta optimization** in - `draw_cells/2`: - - Instead of emitting full style sequences for every cell: - ``` - ESC[0;38;2;255;0;0;48;2;0;0;0mA <- 25 bytes per cell - ESC[0;38;2;255;0;0;48;2;0;0;0mB - ``` - - We only emit changes from the previous style: - ``` - ESC[38;2;255;0;0;48;2;0;0;0mA <- Full style for first cell - B <- No escape needed, same style! - ESC[38;2;0;255;0mC <- Only foreground changed - ``` - - This optimization can reduce escape sequence output by 80-90% for typical UIs - where adjacent cells share styles (text blocks, borders, backgrounds). - - The `current_style` map tracks: - - `:fg` - Current foreground color - - `:bg` - Current background color - - `:attrs` - Current text attributes (`:bold`, `:underline`, `:reverse`, etc.) - - ## See Also - - - `TermUI.Backend` - Behaviour definition - - `TermUI.Backend.Selector` - Backend selection logic - - `TermUI.Backend.TTY` - Fallback backend for non-raw environments - - `TermUI.ANSI` - Escape sequence generation - """ + @moduledoc false @behaviour TermUI.Backend - alias TermUI.ANSI - alias TermUI.Backend.InputBuffer - alias TermUI.Renderer.CursorOptimizer + alias TermUI.{ANSI, Clipboard, Frame} + alias TermUI.Backend.{EventStream, Renderer} alias TermUI.Terminal.SizeDetector - alias TermUI.TerminalOutput - alias TermUI.TermUtils - require Logger - - # Dialyzer: Functions with unmatched return values - @dialyzer {:nowarn_function, shutdown: 1, safe_write: 1, safe_cooked_mode: 0} + alias TermUI.{TerminalOutput, TermUtils} - # Comprehensive mouse disable sequence - disables ALL mouse modes defensively - # This ensures cleanup even if state is inconsistent @all_mouse_off "\e[?1006l\e[?1003l\e[?1002l\e[?1000l" - # Input buffer management is handled by TermUI.Backend.InputBuffer module - # which provides rate-limited logging and consistent behavior across backends. - - # Maximum event queue size to prevent memory exhaustion when events - # are parsed faster than they're consumed. - @max_event_queue_size 100 - - # =========================================================================== - # Type Definitions and State Structure - # =========================================================================== - - @typedoc """ - Mouse tracking mode for the terminal. - - These are user-friendly names that map to ANSI protocol modes internally: - - - `:none` - No mouse tracking (disabled) - - `:click` - Track button press/release only (ANSI "normal" mode, 1000) - - `:drag` - Track clicks and motion while button pressed (ANSI "button" mode, 1002) - - `:all` - Track all mouse movement (ANSI "any" mode, 1003) - - See the "Mouse Tracking Modes" section in the module documentation for details. - """ @type mouse_mode :: :none | :click | :drag | :all - - @typedoc """ - Current SGR (Select Graphic Rendition) style state. - - Tracks the current foreground color, background color, and text attributes - to enable style delta optimization - only emitting escape sequences for - changed attributes. - - ## Fields - - - `:fg` - Current foreground color (see `TermUI.Backend.color()`) - - `:bg` - Current background color (see `TermUI.Backend.color()`) - - `:attrs` - List of active text attributes: - - `:bold` - Bold/bright text - - `:dim` - Dimmed text - - `:italic` - Italic text - - `:underline` - Underlined text - - `:blink` - Blinking text - - `:reverse` - Swapped foreground/background - - `:hidden` - Hidden text - - `:strikethrough` - Struck-through text - - See the "Style Delta Optimization" section in the module documentation for - how this enables efficient rendering. - """ - @type style_state :: %{ - fg: TermUI.Backend.color(), - bg: TermUI.Backend.color(), - attrs: [atom()] - } - - @typedoc """ - Internal state for the Raw backend. - - Tracks all terminal state needed for rendering and input handling. - - ## Fields - - - `:size` - Terminal dimensions as `{rows, cols}` - - `:cursor_visible` - Whether cursor is currently visible (default: `false`) - - `:cursor_position` - Current cursor position as `{row, col}` or `nil` - - `:alternate_screen` - Whether alternate screen buffer is active - - `:mouse_mode` - Current mouse tracking mode - - `:current_style` - Current SGR state for style delta tracking - - `:optimize_cursor` - Whether to use cursor movement optimization (default: `true`) - - `:input_buffer` - Buffer for partial escape sequences during input parsing - - `:event_queue` - Queue of parsed events waiting to be returned - """ @type t :: %__MODULE__{ - size: {pos_integer(), pos_integer()}, - cursor_visible: boolean(), - cursor_position: {pos_integer(), pos_integer()} | nil, + size: TermUI.Backend.size(), alternate_screen: boolean(), mouse_mode: mouse_mode(), - current_style: style_state() | nil, - optimize_cursor: boolean(), input_buffer: binary(), event_queue: [TermUI.Backend.event()], - events_dropped: non_neg_integer() + paste_state: map() | nil, + input_reader: pid() | nil, + last_frame: Frame.t() | nil, + bracketed_paste: boolean(), + focus_events: boolean() } - defstruct size: {24, 80}, - cursor_visible: false, - cursor_position: nil, - alternate_screen: false, - mouse_mode: :none, - current_style: nil, - optimize_cursor: true, - input_buffer: <<>>, - event_queue: [], - events_dropped: 0 - - # =========================================================================== - # Behaviour Callbacks - Lifecycle, Queries, Cursor, Rendering, Input - # =========================================================================== - # Full implementations will be added in subsequent tasks + @schema Zoi.struct(__MODULE__, %{ + size: Zoi.tuple({Zoi.integer(), Zoi.integer()}) |> Zoi.default({24, 80}), + alternate_screen: Zoi.boolean() |> Zoi.default(true), + mouse_mode: Zoi.enum([:none, :click, :drag, :all]) |> Zoi.default(:none), + input_buffer: Zoi.string() |> Zoi.default(""), + event_queue: Zoi.array() |> Zoi.default([]), + paste_state: Zoi.any() |> Zoi.default(nil), + input_reader: Zoi.any() |> Zoi.default(nil), + last_frame: Zoi.any() |> Zoi.default(nil), + bracketed_paste: Zoi.boolean() |> Zoi.default(true), + focus_events: Zoi.boolean() |> Zoi.default(true) + }) + + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) @impl true - @doc """ - Initializes the Raw backend with terminal setup. - - Assumes raw mode is already active (started by Selector). Performs terminal - configuration including alternate screen, cursor hiding, and mouse tracking. - - ## Options - - - `:alternate_screen` - Use alternate screen buffer (default: `true`) - - `:hide_cursor` - Hide cursor during rendering (default: `true`) - - `:mouse_tracking` - Mouse tracking mode (default: `:none`) - - `:size` - Explicit dimensions `{rows, cols}` (default: auto-detect) - - `:optimize_cursor` - Use cursor movement optimization (default: `true`) - - ## Returns - - - `{:ok, state}` on success - - `{:error, :invalid_size}` if size option is malformed - - `{:error, :terminal_setup_failed}` if terminal configuration fails - - `{:error, :size_detection_failed}` if auto-detect fails and no size provided - - ## Examples - - # Default initialization - {:ok, state} = Raw.init([]) - - # With explicit options - {:ok, state} = Raw.init( - alternate_screen: true, - hide_cursor: true, - mouse_tracking: :click, - size: {24, 80} - ) - """ @spec init(keyword()) :: {:ok, t()} | {:error, term()} - def init(opts \\ []) do - # Parse options with defaults - alternate_screen = Keyword.get(opts, :alternate_screen, true) - hide_cursor = Keyword.get(opts, :hide_cursor, true) - mouse_tracking = Keyword.get(opts, :mouse_tracking, :none) - size_opt = Keyword.get(opts, :size, nil) - optimize_cursor = Keyword.get(opts, :optimize_cursor, true) - - # Validate and get terminal size - with {:ok, size} <- get_terminal_size(size_opt) do - # Perform terminal setup sequence - # Order: alternate screen -> hide cursor -> mouse tracking -> clear - if alternate_screen do - write_to_terminal(ANSI.enter_alternate_screen()) - end - - if hide_cursor do - write_to_terminal(ANSI.cursor_hide()) - end - - # Skip mouse tracking on WSL/ConPTY -- mouse-off sequences are silently - # ignored, so enabling mouse tracking leads to escape code leaks - if mouse_tracking != :none and not TerminalOutput.needs_hard_reset?() do - ansi_mode = mouse_mode_to_ansi(mouse_tracking) - write_to_terminal(ANSI.enable_mouse_tracking(ansi_mode)) - write_to_terminal(ANSI.enable_sgr_mouse()) - end - - # Clear screen and home cursor - write_to_terminal(ANSI.clear_screen()) - write_to_terminal(ANSI.cursor_position(1, 1)) - - # Build initial state + def init(opts) do + with {:ok, size} <- SizeDetector.detect(size: Keyword.get(opts, :size)) do state = %__MODULE__{ size: size, - cursor_visible: not hide_cursor, - cursor_position: {1, 1}, - alternate_screen: alternate_screen, - mouse_mode: mouse_tracking, - current_style: nil, - optimize_cursor: optimize_cursor + alternate_screen: Keyword.get(opts, :alternate_screen, true), + mouse_mode: Keyword.get(opts, :mouse_tracking, :none), + bracketed_paste: Keyword.get(opts, :bracketed_paste, true), + focus_events: Keyword.get(opts, :focus_events, true) } - {:ok, state} + case TerminalOutput.write(setup_sequence(state, Keyword.get(opts, :hide_cursor, true))) do + :ok -> {:ok, state} + {:error, reason} -> {:error, {:terminal_write_failed, reason}} + end end end @impl true - @doc """ - Shuts down the backend and restores terminal state. - - Performs cleanup in order: disable mouse, show cursor, reset attributes, - leave alternate screen, return to cooked mode. - - ## Error Safety - - This function is designed to be error-safe: - - Each cleanup step is wrapped in try/rescue - - Individual failures are logged but don't prevent subsequent steps - - Always returns `:ok` regardless of individual step failures - - Idempotent: safe to call multiple times - - ## Cleanup Sequence - - 1. Disable mouse tracking (if enabled) - 2. Show cursor (ANSI: `ESC[?25h`) - 3. Reset all text attributes (ANSI: `ESC[0m`) - 4. Leave alternate screen (ANSI: `ESC[?1049l`) - 5. Return to cooked mode via `:shell.start_interactive({:noshell, :cooked})` - """ - @spec shutdown(t()) :: :ok - def shutdown(state) do - # Phase 1: Direct-to-TTY write (most reliable, bypasses Erlang IO) + @spec shutdown(t(), term()) :: :ok + def shutdown(state, _reason) do + EventStream.stop(state) TerminalOutput.write_to_tty(TerminalOutput.cleanup_sequence()) - - # Phase 2: Erlang IO backup (in case /dev/tty write failed) safe_write(@all_mouse_off) + if state.bracketed_paste, do: safe_write(ANSI.disable_bracketed_paste()) + if state.focus_events, do: safe_write(ANSI.disable_focus_events()) safe_write(ANSI.cursor_show()) safe_write(ANSI.reset()) - - if state.alternate_screen do - safe_write(ANSI.leave_alternate_screen()) - end - - # Phase 3: Drain pending input (mouse events buffered during shutdown) + if state.alternate_screen, do: safe_write(ANSI.leave_alternate_screen()) drain_pending_input() - - # Phase 4: Return to cooked mode safe_cooked_mode() - :ok end @impl true - @doc """ - Returns the current terminal dimensions. - - Returns the cached size from state as `{rows, cols}`. This does not - re-query the terminal - it returns the dimensions captured at `init/1` - or last updated by `refresh_size/1`. - - ## Return Value - - - `{:ok, {rows, cols}}` - Terminal dimensions (rows first, then columns) - - ## Examples - - {:ok, {24, 80}} = Raw.size(state) # Standard 80x24 terminal - {:ok, {50, 120}} = Raw.size(state) # Larger terminal - - ## See Also - - - `refresh_size/1` - Re-query terminal dimensions (call after SIGWINCH) - - `init/1` - Initial size detection - """ - # Note: The error case `{:error, :enotsup}` is included in the typespec for future-proofing - # and consistency with the Backend behaviour, even though this implementation always returns - # the cached size. A future backend might need to report unsupported size queries. @spec size(t()) :: {:ok, TermUI.Backend.size()} - def size(state) do - {:ok, state.size} - end - - @doc """ - Re-queries terminal dimensions and updates state. - - This function queries the terminal for its current size using `:io.rows/0` - and `:io.columns/0`, then updates the cached size in state. It should be - called after receiving a SIGWINCH signal to handle terminal resize events. - - ## Return Value - - - `{:ok, {rows, cols}, updated_state}` - New dimensions and updated state - - `{:error, :size_detection_failed}` - Failed to query terminal dimensions - - ## SIGWINCH Handling - - Terminal resize events are delivered via SIGWINCH. Your application should: - - 1. Register a signal handler for SIGWINCH - 2. Call `refresh_size/1` when the signal is received - 3. Trigger a re-render with the new dimensions - - Example integration: - - def handle_info({:signal, :sigwinch}, state) do - case Raw.refresh_size(state.backend_state) do - {:ok, new_size, new_backend_state} -> - # Update state and trigger re-render - {:noreply, %{state | backend_state: new_backend_state, size: new_size}} - {:error, _reason} -> - # Keep existing size - {:noreply, state} - end - end - - ## Size Detection - - Uses the same detection logic as `init/1`: - 1. Query `:io.rows/0` and `:io.columns/0` - 2. Fall back to LINES and COLUMNS environment variables - 3. Return error if all methods fail - - ## See Also - - - `size/1` - Return cached dimensions without re-querying - - `init/1` - Initial size detection during initialization - """ - @spec refresh_size(t()) :: {:ok, TermUI.Backend.size(), t()} | {:error, :size_detection_failed} - def refresh_size(state) do - case get_terminal_size(nil) do - {:ok, new_size} -> - {:ok, new_size, %{state | size: new_size}} - - {:error, _reason} -> - {:error, :size_detection_failed} - end - end - - @impl true - @doc """ - Moves the cursor to the specified position. - - Position is 1-indexed: `{1, 1}` is the top-left corner. - - ## Cursor Optimization - - When `optimize_cursor: true` (default), this function uses `CursorOptimizer` - to select the cheapest movement sequence. This can reduce cursor movement - overhead by 40%+ compared to always using absolute positioning. - - Movement options considered: - - Absolute positioning: `ESC[{row};{col}H` (6-10 bytes) - - Relative moves: up/down/left/right (3-6 bytes) - - Carriage return + vertical (1 + 3-6 bytes) - - Home position: `ESC[H` (3 bytes) - - Literal spaces for small rightward moves (1 byte each) - - ## Position Validation - - Positions must have positive integer coordinates. This function does NOT - validate positions against terminal bounds - positions beyond the terminal - dimensions are accepted and recorded in state. Most terminals silently clamp - out-of-bounds positions, which may cause state-reality divergence. - - **Callers should validate positions before calling** using `valid_position?/2`: - - if Raw.valid_position?(state, position) do - Raw.move_cursor(state, position) - else - {:error, :out_of_bounds} - end - - This design allows the renderer layer to handle bounds checking appropriately - for its use case (e.g., scrolling, wrapping, or clamping). - - ## See Also - - - `hide_cursor/1` - Hide cursor during rendering - - `show_cursor/1` - Show cursor after rendering - - `valid_position?/2` - Check if position is within terminal bounds - - ## Examples - - {:ok, state} = Raw.move_cursor(state, {1, 1}) # Top-left - {:ok, state} = Raw.move_cursor(state, {24, 80}) # Bottom-right (80x24) - """ - @spec move_cursor(t(), TermUI.Backend.position()) :: {:ok, t()} - def move_cursor(state, {row, col} = position) - when is_integer(row) and is_integer(col) and row > 0 and col > 0 do - # Generate movement sequence (optimized or absolute based on state) - sequence = generate_cursor_sequence(state, row, col) - write_to_terminal(sequence) - - # Update state with new cursor position - updated_state = %{state | cursor_position: position} - - {:ok, updated_state} - end - - # Generates cursor movement sequence, using optimization when enabled. - # Clauses ordered from most specific to general: - # 1. Optimization disabled - always absolute (most restrictive) - # 2. No previous position - absolute (can't optimize without from position) - # 3. Optimization enabled with position - use optimizer - @spec generate_cursor_sequence(t(), pos_integer(), pos_integer()) :: iodata() - defp generate_cursor_sequence(%__MODULE__{optimize_cursor: false}, row, col) do - # Optimization disabled - always use absolute positioning - ANSI.cursor_position(row, col) - end - - defp generate_cursor_sequence(%__MODULE__{cursor_position: nil}, row, col) do - # No previous position known - use absolute positioning - # (applies regardless of optimize_cursor setting) - ANSI.cursor_position(row, col) - end - - defp generate_cursor_sequence( - %__MODULE__{optimize_cursor: true, cursor_position: {from_row, from_col}}, - to_row, - to_col - ) do - # Use optimizer to find cheapest movement, with error recovery - # Only catch expected exceptions, not system-level errors - {sequence, _cost} = CursorOptimizer.optimal_move(from_row, from_col, to_row, to_col) - sequence - rescue - e in [ArgumentError, ArithmeticError, FunctionClauseError] -> - # Fall back to absolute positioning if optimizer fails - Logger.warning( - "CursorOptimizer failed (#{Exception.message(e)}), falling back to absolute positioning: from #{inspect({from_row, from_col})} to #{inspect({to_row, to_col})}" - ) - - ANSI.cursor_position(to_row, to_col) - end - - @impl true - @doc """ - Hides the terminal cursor. - - Uses ANSI sequence `ESC[?25l` (DECTCEM off). - - ## Idempotent Behavior - - This operation is idempotent. When the cursor is already hidden: - - No escape sequence is written to the terminal - - The exact same state object is returned unchanged - - Callers cannot distinguish a no-op from an actual state change - - This design prevents redundant ANSI writes and allows callers to call - without tracking current visibility state. - - ## See Also - - - `show_cursor/1` - Show the cursor - - `move_cursor/2` - Move cursor to position - """ - @spec hide_cursor(t()) :: {:ok, t()} - def hide_cursor(%__MODULE__{cursor_visible: false} = state) do - # Already hidden - idempotent no-op - {:ok, state} - end - - def hide_cursor(state) do - # Write hide cursor sequence - write_to_terminal(ANSI.cursor_hide()) - - # Update state - updated_state = %{state | cursor_visible: false} - - {:ok, updated_state} - end - - @impl true - @doc """ - Shows the terminal cursor. - - Uses ANSI sequence `ESC[?25h` (DECTCEM on). - - ## Idempotent Behavior - - This operation is idempotent. When the cursor is already visible: - - No escape sequence is written to the terminal - - The exact same state object is returned unchanged - - Callers cannot distinguish a no-op from an actual state change - - This design prevents redundant ANSI writes and allows callers to call - without tracking current visibility state. - - ## See Also - - - `hide_cursor/1` - Hide the cursor - - `move_cursor/2` - Move cursor to position - """ - @spec show_cursor(t()) :: {:ok, t()} - def show_cursor(%__MODULE__{cursor_visible: true} = state) do - # Already visible - idempotent no-op - {:ok, state} - end - - def show_cursor(state) do - # Write show cursor sequence - write_to_terminal(ANSI.cursor_show()) - - # Update state - updated_state = %{state | cursor_visible: true} - - {:ok, updated_state} - end - - @impl true - @doc """ - Clears the entire screen and moves cursor to home position. - - Uses ANSI sequences: - - `ESC[2J` - ED (Erase Display) parameter 2: clear entire screen - - `ESC[1;1H` - CUP (Cursor Position): move to row 1, column 1 - - ## State Changes - - After clear: - - `cursor_position` is set to `{1, 1}` (home position) - - `current_style` is reset to `nil` (terminal style state is unknown after clear) - - All other state fields are preserved. - - ## Idempotency - - This operation is idempotent - calling `clear/1` multiple times in succession - is safe and will result in the same state each time. - - ## Examples - - {:ok, state} = Raw.init(size: {24, 80}) - {:ok, moved} = Raw.move_cursor(state, {10, 20}) - {:ok, cleared} = Raw.clear(moved) - - cleared.cursor_position # => {1, 1} - cleared.current_style # => nil - - ## See Also - - - `move_cursor/2` - Move cursor to specific position - - `draw_cells/2` - Draw content to screen - """ - @spec clear(t()) :: {:ok, t()} - def clear(state) do - # Write clear screen sequence followed by cursor home - write_to_terminal([ANSI.clear_screen(), ANSI.cursor_position(1, 1)]) - - # Reset style state (unknown after clear) and set cursor to home - updated_state = %{state | current_style: nil, cursor_position: {1, 1}} - - {:ok, updated_state} - end + def size(state), do: {:ok, state.size} @impl true - @doc """ - Draws cells to the terminal at specified positions. - - Cells are rendered with optimized cursor movement and style delta tracking - to minimize escape sequence output. See the "Style Delta Optimization" section - in the module documentation for details on how this works. - - ## Cell Format - - Each cell is a tuple `{position, cell_data}` where: - - `position` is `{row, col}` (1-indexed) - - `cell_data` is `{char, fg, bg, attrs}` - - ## Performance - - This function uses several optimizations: - - Style delta tracking (only emit changed attributes) - - Relative cursor movement when cheaper than absolute - - Batched I/O writes - - ## Examples - - # Draw a single red "A" at position {1, 1} - cells = [{{1, 1}, {"A", :red, :default, []}}] - {:ok, state} = Raw.draw_cells(state, cells) - - # Draw multiple cells with different styles - cells = [ - {{1, 1}, {"H", :green, :default, [:bold]}}, - {{1, 2}, {"i", :green, :default, [:bold]}}, - {{2, 1}, {"!", :yellow, :blue, []}} - ] - {:ok, state} = Raw.draw_cells(state, cells) - """ - @spec draw_cells(t(), [{TermUI.Backend.position(), TermUI.Backend.cell()}]) :: {:ok, t()} - def draw_cells(state, []) do - # Empty list - no-op - {:ok, state} - end - - def draw_cells(state, cells) when is_list(cells) do - # Sort cells in row-major order, then by column within each row - sorted_cells = - Enum.sort_by(cells, fn {{row, col}, _cell} -> {row, col} end) - - # Split into contiguous runs and render each with a single cursor position - runs = detect_runs(sorted_cells) - - {output, final_pos, final_style} = - render_runs(runs, state.cursor_position, state.current_style) - - # Write batched output to terminal - write_to_terminal(output) - - # Update state with final cursor position and style - updated_state = %{state | cursor_position: final_pos, current_style: final_style} - - {:ok, updated_state} + @spec capabilities(t()) :: map() + def capabilities(state) do + %{colors: :true_color, unicode: true, mouse: true, size: state.size} end - # Detects contiguous runs of cells: same row with consecutive columns. - # Returns a list of runs, where each run is a non-empty list of cells - # that can be rendered with a single cursor positioning. - defp detect_runs([]), do: [] - - defp detect_runs([first | rest]) do - {current_run, runs} = - Enum.reduce(rest, {[first], []}, fn {{row, col}, _cell_data} = cell, - {current_run, completed_runs} -> - # Get the last cell in the current run to check adjacency - [{{prev_row, prev_col}, _} | _] = current_run - - if row == prev_row and col == prev_col + 1 do - # Adjacent: extend current run (prepend for efficiency, reversed later) - {[cell | current_run], completed_runs} - else - # Gap or new row: finalize current run, start new one - {[cell], [Enum.reverse(current_run) | completed_runs]} - end - end) - - # Don't forget the final run - Enum.reverse([Enum.reverse(current_run) | runs]) - end - - # Renders a list of runs into an iolist. Each run gets one cursor position - # at its start, then streams characters with inline style deltas only when - # the style changes. Style state is tracked continuously across runs (no - # per-row resets). - defp render_runs(runs, initial_pos, initial_style) do - Enum.reduce(runs, {[], initial_pos, initial_style}, fn run, {output_acc, cursor_pos, style} -> - {run_output, run_end_pos, run_end_style} = - render_single_run(run, cursor_pos, style) - - {[output_acc, run_output], run_end_pos, run_end_style} - end) - end - - # Renders a single contiguous run. Emits one cursor position at the start, - # then for each cell: style delta (if changed) + character. - defp render_single_run([{{row, col}, _} | _] = run, cursor_pos, style) do - # Position cursor at run start - cursor_output = cursor_move_output(cursor_pos, {row, col}) - - # Stream characters with inline style changes - {chars_output, end_col, end_style} = - Enum.reduce(run, {[], col, style}, fn {{_row, _col}, {char, fg, bg, attrs}}, - {out_acc, cur_col, cur_style} -> - new_style = %{fg: fg, bg: bg, attrs: normalize_attrs(attrs)} - style_output = style_delta_output(cur_style, new_style) - - {[out_acc, style_output, char], cur_col + 1, new_style} - end) - - {[cursor_output, chars_output], {row, end_col}, end_style} - end - - # Normalizes attributes to a sorted list for consistent comparison. - # - # Accepts both list and MapSet input formats to support: - # - Direct cell tuples from Backend.cell() which use lists - # - Internal Cell struct which uses MapSet for attributes - # - # Sorting ensures consistent comparison regardless of input order, - # enabling reliable style delta detection. - @spec normalize_attrs([atom()] | MapSet.t()) :: [atom()] - defp normalize_attrs(attrs) when is_list(attrs), do: Enum.sort(attrs) - defp normalize_attrs(%MapSet{} = attrs), do: attrs |> MapSet.to_list() |> Enum.sort() - - # Generates cursor movement escape sequence if position has changed. - # - # Returns empty iolist if no move needed (cursor already at target position). - # Uses absolute positioning for all moves. - # - # Note on cursor advancement: After writing a character, the cursor automatically - # advances one column. This function assumes single-width characters. Multi-width - # characters (CJK, emoji) would require grapheme width tracking - a future enhancement. - # - # Note: Using CursorOptimizer could provide ~40% byte savings on cursor movement. - # Current absolute positioning is simple and correct but not optimal. - # See move_cursor/2 for example of CursorOptimizer integration. - @spec cursor_move_output({pos_integer(), pos_integer()} | nil, {pos_integer(), pos_integer()}) :: - iolist() - defp cursor_move_output(nil, {row, col}) do - # No previous position known - must use absolute - ANSI.cursor_position(row, col) - end - - defp cursor_move_output({cur_row, cur_col}, {target_row, target_col}) - when cur_row == target_row and cur_col == target_col do - # Already at target position - no move needed - [] - end - - defp cursor_move_output({_cur_row, _cur_col}, {target_row, target_col}) do - # Need to move cursor - use absolute positioning - ANSI.cursor_position(target_row, target_col) - end - - # Generates style delta escape sequences - only emits what has changed. - # - # Style delta optimization reduces escape sequence output by 80-90% for typical - # UIs where adjacent cells share styles. Instead of emitting full style for every - # cell, we only emit changes from the previous style. - # - # When attributes are removed (e.g., transitioning from [:bold, :italic] to [:bold]), - # we must reset with ESC[0m and rebuild the full style, since ANSI doesn't have - # efficient individual attribute removal for all attributes. - # - # Note: This uses ANSI module for sequence generation. For parameter-level SGR - # operations (e.g., combining into single sequence), see TermUI.SGR module. - @spec style_delta_output(style_state() | nil, style_state()) :: iolist() - defp style_delta_output(nil, new_style) do - # No previous style - emit full style - build_full_style(new_style) - end - - defp style_delta_output(current_style, new_style) when current_style == new_style do - # Styles are identical - no output needed - [] - end - - defp style_delta_output(current_style, new_style) do - # Check if we need a full reset (removing attributes is complex) - # Strategy: if new style has fewer or different attrs, reset and rebuild - current_attrs = MapSet.new(current_style.attrs) - new_attrs = MapSet.new(new_style.attrs) - - # Attributes being removed require a reset - removed_attrs = MapSet.difference(current_attrs, new_attrs) - - if MapSet.size(removed_attrs) > 0 do - # Reset and apply full new style - [ANSI.reset(), build_full_style(new_style)] - else - # Build delta - only add new attributes and changed colors - build_style_delta(current_style, new_style) + @spec refresh_size(t()) :: {:ok, TermUI.Backend.size(), t()} | {:error, term()} + def refresh_size(state) do + case SizeDetector.detect() do + {:ok, size} -> {:ok, size, %{state | size: size}} + {:error, reason} -> {:error, reason} end end - # Builds a complete style sequence from scratch. - # - # Used when: - # 1. First cell being rendered (no previous style) - # 2. After a style reset when attributes were removed - # - # Generates escape sequences for foreground color, background color, and all - # text attributes in that order. - @spec build_full_style(style_state()) :: iolist() - defp build_full_style(%{fg: fg, bg: bg, attrs: attrs}) do - [ - color_sequence(:fg, fg), - color_sequence(:bg, bg), - attr_sequences(attrs) + @impl true + @spec draw(t(), Frame.t()) :: {:ok, t()} | {:error, term()} + def draw(state, %Frame{} = frame) do + reset? = dimensions_changed?(state.last_frame, frame) + changes = if reset?, do: Frame.cells(frame), else: Frame.diff(state.last_frame, frame) + + output = [ + ANSI.cursor_hide(), + if(reset?, do: [ANSI.clear_screen(), ANSI.cursor_position(1, 1)], else: []), + Renderer.render(changes, :true_color, :unicode), + cursor_sequence(frame.cursor) ] - end - - # Builds style delta - only emits escape sequences for changes. - # - # Compares current and new styles, emitting only: - # - Foreground color sequence if fg changed - # - Background color sequence if bg changed - # - Attribute sequences for newly added attributes - # - # Note: This function is only called when no attributes were removed - # (removal requires full reset, handled by style_delta_output/2). - @spec build_style_delta(style_state(), style_state()) :: iolist() - defp build_style_delta(current, new) do - fg_output = if current.fg != new.fg, do: color_sequence(:fg, new.fg), else: [] - bg_output = if current.bg != new.bg, do: color_sequence(:bg, new.bg), else: [] - - # New attributes that weren't in current - new_attr_set = MapSet.new(new.attrs) - current_attr_set = MapSet.new(current.attrs) - added_attrs = MapSet.difference(new_attr_set, current_attr_set) |> MapSet.to_list() - attr_output = attr_sequences(added_attrs) - - [fg_output, bg_output, attr_output] - end - # Generate color sequence for foreground or background - defp color_sequence(:fg, :default), do: ["\e[39m"] - defp color_sequence(:bg, :default), do: ["\e[49m"] - - defp color_sequence(:fg, {r, g, b}) when is_integer(r) and is_integer(g) and is_integer(b) do - ANSI.foreground_rgb(r, g, b) - end - - defp color_sequence(:bg, {r, g, b}) when is_integer(r) and is_integer(g) and is_integer(b) do - ANSI.background_rgb(r, g, b) - end - - defp color_sequence(:fg, index) when is_integer(index) and index >= 0 and index <= 255 do - ANSI.foreground_256(index) - end - - defp color_sequence(:bg, index) when is_integer(index) and index >= 0 and index <= 255 do - ANSI.background_256(index) - end - - defp color_sequence(:fg, color) when is_atom(color) do - ANSI.foreground(color) - end - - defp color_sequence(:bg, color) when is_atom(color) do - ANSI.background(color) - end - - # Catch-all for invalid colors - log warning and return empty sequence - defp color_sequence(:fg, unknown) do - Logger.warning("Unknown foreground color: #{inspect(unknown)}") - [] - end - - defp color_sequence(:bg, unknown) do - Logger.warning("Unknown background color: #{inspect(unknown)}") - [] - end - - # Generate attribute sequences - defp attr_sequences([]), do: [] - - defp attr_sequences(attrs) when is_list(attrs) do - Enum.map(attrs, &attr_sequence/1) + case TerminalOutput.write(output) do + :ok -> {:ok, %{state | last_frame: frame}} + {:error, reason} -> {:error, {:terminal_write_failed, reason}} + end end - defp attr_sequence(:bold), do: ANSI.bold() - defp attr_sequence(:dim), do: ANSI.dim() - defp attr_sequence(:italic), do: ANSI.italic() - defp attr_sequence(:underline), do: ANSI.underline() - defp attr_sequence(:blink), do: ANSI.blink() - defp attr_sequence(:reverse), do: ANSI.reverse() - defp attr_sequence(:hidden), do: ANSI.hidden() - defp attr_sequence(:strikethrough), do: ANSI.strikethrough() - defp attr_sequence(_unknown), do: [] - @impl true - @doc """ - Flushes pending output to the terminal. - - For the Raw backend, this is a no-op because `IO.write/1` is synchronous - - output is written directly to the terminal without buffering. The callback - exists for API completeness and compatibility with backends that may use - buffered I/O. - - This function is idempotent and safe to call multiple times. - - ## Returns - - - `{:ok, state}` - Always succeeds, returning state unchanged - """ @spec flush(t()) :: {:ok, t()} - def flush(state) do - # IO.write/1 is synchronous in Erlang/OTP - no buffering to flush. - # For backends with buffered output, this would call :erlang.port_command/3 - # with the :nosuspend option or similar synchronization mechanism. - {:ok, state} - end - - # =========================================================================== - # Mouse Tracking - # =========================================================================== + def flush(state), do: {:ok, state} - @doc """ - Enables mouse tracking with the specified mode. - - Changes the mouse tracking mode, enabling detection of mouse events. - This function can be called after initialization to change the tracking mode. - - ## Parameters - - - `state` - Current backend state - - `mode` - Mouse tracking mode: - - `:click` - Track button press/release only (ANSI "normal" mode, 1000) - - `:drag` - Track clicks and motion while button pressed (ANSI "button" mode, 1002) - - `:all` - Track all mouse movement (ANSI "any" mode, 1003) - - ## Escape Sequences - - This function emits: - 1. The appropriate mouse tracking mode sequence: - - `:click` → `ESC[?1000h` - - `:drag` → `ESC[?1002h` - - `:all` → `ESC[?1003h` - 2. SGR extended mode (`ESC[?1006h`) for accurate coordinate encoding - - ## Idempotent Behavior - - If the requested mode matches the current mode, no escape sequences are - written and the same state is returned. - - ## Returns - - - `{:ok, updated_state}` with `mouse_mode` set to the new mode - - ## Examples - - # Enable click tracking - {:ok, state} = Raw.enable_mouse(state, :click) - - # Enable all movement tracking - {:ok, state} = Raw.enable_mouse(state, :all) - - ## See Also - - - `disable_mouse/1` - Disable mouse tracking - - `init/1` - Can set initial mouse tracking mode via `:mouse_tracking` option - """ - @spec enable_mouse(t(), :click | :drag | :all) :: {:ok, t()} - def enable_mouse(%__MODULE__{mouse_mode: mode} = state, mode) do - # Already in requested mode - idempotent no-op - {:ok, state} - end - - def enable_mouse(state, mode) when mode in [:click, :drag, :all] do - # Skip mouse tracking on WSL/ConPTY - if TerminalOutput.needs_hard_reset?() do + @impl true + @spec clipboard(t(), Clipboard.Operation.t()) :: {:ok, t()} | {:error, term()} + def clipboard(state, %Clipboard.Operation{} = operation) do + with {:ok, sequence} <- Clipboard.sequence(operation), + :ok <- TerminalOutput.write(sequence) do {:ok, state} else - # Disable current mode if active (to avoid stacking modes) - if state.mouse_mode != :none do - disable_current_mouse_mode(state.mouse_mode) - end - - # Enable new mode - ansi_mode = mouse_mode_to_ansi(mode) - write_to_terminal(ANSI.enable_mouse_tracking(ansi_mode)) - write_to_terminal(ANSI.enable_sgr_mouse()) - - {:ok, %{state | mouse_mode: mode}} - end - end - - @doc """ - Disables mouse tracking. - - Turns off mouse event reporting, returning the terminal to normal operation - where mouse actions are not reported to the application. - - ## Escape Sequences - - This function emits: - 1. Disable SGR extended mode (`ESC[?1006l`) - 2. Disable the current tracking mode: - - `:click` → `ESC[?1000l` - - `:drag` → `ESC[?1002l` - - `:all` → `ESC[?1003l` - - ## Idempotent Behavior - - If mouse tracking is already disabled (`:none`), no escape sequences are - written and the same state is returned. - - ## Returns - - - `{:ok, updated_state}` with `mouse_mode` set to `:none` - - ## Examples - - # Disable after enabling - {:ok, state} = Raw.enable_mouse(state, :click) - {:ok, state} = Raw.disable_mouse(state) - state.mouse_mode # => :none - - # Idempotent - safe to call when already disabled - {:ok, same_state} = Raw.disable_mouse(state) - - ## See Also - - - `enable_mouse/2` - Enable mouse tracking - - `shutdown/1` - Automatically disables mouse tracking during cleanup - """ - @spec disable_mouse(t()) :: {:ok, t()} - def disable_mouse(%__MODULE__{mouse_mode: :none} = state) do - # Already disabled - idempotent no-op - {:ok, state} - end - - def disable_mouse(state) do - # Disable SGR mode first - write_to_terminal(ANSI.disable_sgr_mouse()) - - # Disable the current tracking mode - ansi_mode = mouse_mode_to_ansi(state.mouse_mode) - - if ansi_mode do - write_to_terminal(ANSI.disable_mouse_tracking(ansi_mode)) + {:error, {:clipboard_too_large, _size, _maximum} = reason} -> {:error, reason} + {:error, reason} -> {:error, {:terminal_write_failed, reason}} end - - {:ok, %{state | mouse_mode: :none}} end @impl true - @doc """ - Polls for input events with the specified timeout. - - In raw mode, input arrives character-by-character enabling real-time - keyboard and mouse event handling. This function uses the `EscapeParser` - module to parse escape sequences into `TermUI.Event` structs. - - ## Parameters - - - `state` - Current backend state - - `timeout` - Milliseconds to wait (0 for non-blocking) - - ## Returns - - - `{:ok, event, state}` - Event received and parsed - - `{:timeout, state}` - No input within timeout period - - `{:error, reason, state}` - Terminal I/O error occurred - - ## Escape Sequence Handling - - Some sequences are ambiguous (ESC alone vs ESC followed by another key). - The function buffers partial sequences and uses the timeout to disambiguate. - If the buffer contains a partial escape sequence and the timeout expires, - the escape key is emitted and remaining bytes are re-parsed. - - ## Examples - - # Non-blocking poll (timeout = 0) - {:timeout, state} = Raw.poll_event(state, 0) - - # Block up to 100ms for input - case Raw.poll_event(state, 100) do - {:ok, %Event.Key{key: :enter}, state} -> handle_enter(state) - {:ok, %Event.Mouse{action: :click}, state} -> handle_click(state) - {:timeout, state} -> handle_idle(state) - end - """ @spec poll_event(t(), non_neg_integer()) :: {:ok, TermUI.Backend.event(), t()} | {:timeout, t()} | {:error, term(), t()} def poll_event(state, timeout) do - # First, try to parse any buffered input - case try_parse_buffer(state) do - {:ok, event, new_state} -> - {:ok, event, new_state} - - {:need_more, state} -> - # Try to read more input with timeout - read_input_with_timeout(state, timeout) - end - end - - # Attempts to parse an event from the current buffer or event queue. - # Returns {:ok, event, state} if a complete event is available, - # or {:need_more, state} if more input is needed. - @spec try_parse_buffer(t()) :: {:ok, TermUI.Backend.event(), t()} | {:need_more, t()} - defp try_parse_buffer(%{event_queue: [event | rest]} = state) do - # Return queued event first - {:ok, event, %{state | event_queue: rest}} - end - - defp try_parse_buffer(%{input_buffer: <<>>, event_queue: []} = state) do - {:need_more, state} - end - - defp try_parse_buffer(%{input_buffer: buffer, event_queue: []} = state) do - alias TermUI.Terminal.EscapeParser - - case EscapeParser.parse(buffer) do - {[event], remaining} -> - # Single event - simple case - {:ok, event, %{state | input_buffer: remaining}} - - {[event | rest_events], remaining} -> - # Multiple events parsed - return first, queue the rest (with size limit) - new_state = queue_events(%{state | input_buffer: remaining}, rest_events) - {:ok, event, new_state} - - {[], remaining} when remaining != <<>> -> - # Partial sequence - check if it's a potential escape sequence - if EscapeParser.partial_sequence?(remaining) do - {:need_more, %{state | input_buffer: remaining}} - else - # Unknown data - clear buffer - {:need_more, %{state | input_buffer: <<>>}} - end - - {[], <<>>} -> - {:need_more, state} - end - end - - # Reads input from the terminal with a timeout. - # Uses a Task to avoid blocking indefinitely on IO.getn/2. - @spec read_input_with_timeout(t(), non_neg_integer()) :: - {:ok, TermUI.Backend.event(), t()} | {:timeout, t()} | {:error, term(), t()} - defp read_input_with_timeout(state, timeout) do - alias TermUI.Event - alias TermUI.Terminal.EscapeParser - - # For zero timeout, just check if there's input ready - # Unfortunately, IO.getn blocks, so we use a Task with timeout - task = Task.async(fn -> read_one_byte() end) - - case Task.yield(task, timeout) || Task.shutdown(task) do - {:ok, {:ok, data}} -> - # Got input - add to buffer and try to parse (with size limit) - new_state = append_to_input_buffer(state, data) - try_parse_or_continue(new_state, timeout) - - {:ok, :eof} -> - {:error, :eof, state} - - {:ok, {:error, reason}} -> - {:error, reason, state} - - nil -> - # Timeout - if we have a partial escape sequence, handle it - handle_timeout(state) - end - end - - # After reading new input, try to parse it. If we get a partial sequence, - # continue reading with remaining timeout (simplified: just try once more). - @spec try_parse_or_continue(t(), non_neg_integer()) :: - {:ok, TermUI.Backend.event(), t()} | {:timeout, t()} | {:error, term(), t()} - defp try_parse_or_continue(state, _timeout) do - alias TermUI.Event - alias TermUI.Terminal.EscapeParser - - buffer = state.input_buffer - - case EscapeParser.parse(buffer) do - {[event | _rest], remaining} -> - {:ok, event, %{state | input_buffer: remaining}} - - {[], remaining} when remaining != <<>> -> - # Partial sequence - for escape sequences, use a short timeout - if EscapeParser.partial_sequence?(remaining) do - # Wait a bit more for the rest of the escape sequence - wait_for_escape_completion(state, remaining) - else - {:timeout, %{state | input_buffer: remaining}} - end - - {[], <<>>} -> - {:timeout, state} - end + EventStream.poll(state, timeout, &read_one_byte/0, __MODULE__) end - # Short timeout to wait for escape sequence completion. - @escape_timeout 50 - - @spec wait_for_escape_completion(t(), binary()) :: - {:ok, TermUI.Backend.event(), t()} | {:timeout, t()} | {:error, term(), t()} - defp wait_for_escape_completion(state, buffer) do - alias TermUI.Event - alias TermUI.Terminal.EscapeParser - - task = Task.async(fn -> read_one_byte() end) - - case Task.yield(task, @escape_timeout) || Task.shutdown(task) do - {:ok, {:ok, data}} -> - # Got more data - try to parse again - new_buffer = buffer <> data - handle_parse_result(EscapeParser.parse(new_buffer), state, new_buffer) - - {:ok, :eof} -> - # EOF during escape sequence - emit what we have - emit_partial_escape(state, buffer) - - {:ok, {:error, _reason}} -> - emit_partial_escape(state, buffer) - - nil -> - # Timeout - emit partial escape sequence - emit_partial_escape(state, buffer) + @impl true + @spec resize(t(), TermUI.Backend.size()) :: {:ok, t()} | {:error, term()} + def resize(state, {rows, columns} = size) + when is_integer(rows) and rows > 0 and is_integer(columns) and columns > 0 do + case TerminalOutput.write([ANSI.clear_screen(), ANSI.cursor_position(1, 1)]) do + :ok -> {:ok, %{state | size: size, last_frame: nil}} + {:error, reason} -> {:error, {:terminal_write_failed, reason}} end end - # Handles the result of parsing escape sequence data. - defp handle_parse_result({[event | _], remaining}, state, _buffer) do - {:ok, event, %{state | input_buffer: remaining}} - end - - defp handle_parse_result({[], remaining}, state, _buffer) do - alias TermUI.Terminal.EscapeParser + defp setup_sequence(state, hide_cursor?) do + mouse = + if state.mouse_mode != :none and not TerminalOutput.needs_hard_reset?() do + mode = mouse_mode_to_ansi(state.mouse_mode) + [ANSI.enable_mouse_tracking(mode), ANSI.enable_sgr_mouse()] + else + [] + end - if EscapeParser.partial_sequence?(remaining) do - wait_for_escape_completion(state, remaining) - else - {:timeout, %{state | input_buffer: remaining}} - end + [ + if(state.alternate_screen, do: ANSI.enter_alternate_screen(), else: []), + if(hide_cursor?, do: ANSI.cursor_hide(), else: []), + mouse, + if(state.bracketed_paste, do: ANSI.enable_bracketed_paste(), else: []), + if(state.focus_events, do: ANSI.enable_focus_events(), else: []), + ANSI.clear_screen(), + ANSI.cursor_position(1, 1) + ] end - # Handles timeout when we have a partial escape sequence. - @spec handle_timeout(t()) :: {:timeout, t()} | {:ok, TermUI.Backend.event(), t()} - defp handle_timeout(%{input_buffer: <<>>} = state) do - {:timeout, state} - end + defp cursor_sequence(nil), do: ANSI.cursor_hide() + defp cursor_sequence({column, row}), do: [ANSI.cursor_position(row, column), ANSI.cursor_show()] - defp handle_timeout(%{input_buffer: buffer} = state) do - alias TermUI.Terminal.EscapeParser + defp dimensions_changed?(nil, _frame), do: false - if EscapeParser.partial_sequence?(buffer) do - emit_partial_escape(state, buffer) - else - {:timeout, state} - end + defp dimensions_changed?(previous, current) do + previous.width != current.width or previous.height != current.height end - # Emits events from a partial escape sequence (timeout disambiguation). - @spec emit_partial_escape(t(), binary()) :: {:ok, TermUI.Backend.event(), t()} - defp emit_partial_escape(state, buffer) do - alias TermUI.Event - alias TermUI.Terminal.EscapeParser - - # Handle known partial sequences - case buffer do - # Lone ESC - <<0x1B>> -> - {:ok, Event.key(:escape), %{state | input_buffer: <<>>}} - - # ESC[ without terminator - emit ESC, keep [ for next parse - <<0x1B, ?[>> -> - {:ok, Event.key(:escape), %{state | input_buffer: "["}} + defp mouse_mode_to_ansi(:click), do: :normal + defp mouse_mode_to_ansi(:drag), do: :button + defp mouse_mode_to_ansi(:all), do: :all - # ESC O without terminator - <<0x1B, ?O>> -> - {:ok, Event.key(:escape), %{state | input_buffer: "O"}} - - # Other partial sequences starting with ESC - <<0x1B, rest::binary>> -> - {:ok, Event.key(:escape), %{state | input_buffer: rest}} - - # Non-escape partial - just clear buffer - _ -> - {:timeout, %{state | input_buffer: <<>>}} - end - end - - # Reads one byte from stdin. - @spec read_one_byte() :: {:ok, binary()} | :eof | {:error, term()} defp read_one_byte do case IO.getn("", 1) do :eof -> :eof {:error, reason} -> {:error, reason} data when is_binary(data) -> {:ok, data} + [byte] when is_integer(byte) -> {:ok, <>} + other -> {:error, {:unexpected_io_return, other}} end end - # =========================================================================== - # Helper Functions - # =========================================================================== - - @doc """ - Checks if a position is valid within the terminal bounds. - - Returns `true` if the position has positive coordinates and is within - the terminal dimensions stored in state. - - ## Examples - - iex> state = %Raw{size: {24, 80}} - iex> Raw.valid_position?(state, {1, 1}) - true - iex> Raw.valid_position?(state, {24, 80}) - true - iex> Raw.valid_position?(state, {25, 1}) - false - iex> Raw.valid_position?(state, {0, 1}) - false - """ - @spec valid_position?(t(), {integer(), integer()}) :: boolean() - def valid_position?(%__MODULE__{size: {max_rows, max_cols}}, {row, col}) - when is_integer(row) and is_integer(col) do - row > 0 and col > 0 and row <= max_rows and col <= max_cols - end - - def valid_position?(_state, _position), do: false - - @doc """ - Maps a Raw backend mouse mode to the corresponding ANSI protocol mode. - - This is used internally when emitting mouse tracking escape sequences. - - ## Examples - - iex> Raw.mouse_mode_to_ansi(:click) - :normal - iex> Raw.mouse_mode_to_ansi(:drag) - :button - iex> Raw.mouse_mode_to_ansi(:all) - :all - """ - @spec mouse_mode_to_ansi(mouse_mode()) :: :normal | :button | :all | nil - def mouse_mode_to_ansi(:none), do: nil - def mouse_mode_to_ansi(:click), do: :normal - def mouse_mode_to_ansi(:drag), do: :button - def mouse_mode_to_ansi(:all), do: :all - - # Provides access to the ANSI module for escape sequence generation - @doc false - def ansi_module, do: ANSI - - # =========================================================================== - # Private Functions - # =========================================================================== - - # Gets terminal size from explicit option or auto-detection. - # Delegates to SizeDetector for consistent detection across backends. - @spec get_terminal_size({pos_integer(), pos_integer()} | nil) :: - {:ok, {pos_integer(), pos_integer()}} | {:error, term()} - defp get_terminal_size(size_opt) do - SizeDetector.detect(size: size_opt) - end - - # Appends data to the input buffer with size limit protection. - # Uses the shared InputBuffer module for rate-limited logging. - @spec append_to_input_buffer(t(), binary()) :: t() - defp append_to_input_buffer(state, data) do - InputBuffer.append_with_limit(state, data, :input_buffer, source: __MODULE__) - end - - # Queues events with size limit protection. - # If the queue exceeds @max_event_queue_size, drops oldest events. - # This prevents memory exhaustion when events are parsed faster than consumed. - # Dropped events are counted in the events_dropped field for monitoring. - @spec queue_events(t(), [TermUI.Backend.event()]) :: t() - defp queue_events(state, []), do: state - - defp queue_events(state, new_events) do - combined = state.event_queue ++ new_events - queue_size = length(combined) - - if queue_size > @max_event_queue_size do - # Keep newest events, drop oldest - to_drop = queue_size - @max_event_queue_size - new_total_dropped = state.events_dropped + to_drop - - # Only log on first drop to prevent log flooding - if state.events_dropped == 0 do - Logger.warning( - "Event queue overflow (#{queue_size} events), dropping #{to_drop} oldest events" - ) - end - - %{state | event_queue: Enum.drop(combined, to_drop), events_dropped: new_total_dropped} - else - %{state | event_queue: combined} - end - end - - # Writes data to the terminal via TerminalOutput (ONLCR-aware). - defp write_to_terminal(data) do - TerminalOutput.write(data) - rescue - e -> - Logger.debug("Terminal write failed: #{Exception.message(e)}") - :ok + defp safe_write(data) do + _result = TerminalOutput.write(data) + :ok end - # Error-safe write for shutdown - logs errors but continues - defp safe_write(data) do - TerminalOutput.write(data) + defp safe_cooked_mode do + _result = :shell.start_interactive({:noshell, :cooked}) + :ok rescue - _ -> :ok + _exception -> :ok + catch + _kind, _reason -> :ok end - # Drains pending input bytes (e.g. mouse events buffered during shutdown). - # Sets stty to non-blocking, reads and discards pending bytes, then restores. defp drain_pending_input do - # Set non-blocking read: min 0 chars, timeout 0.1s - _ = TermUtils.safe_stty(["min", "0", "time", "1"]) - - drain_input_loop(0, 20) + case TermUtils.safe_stty(["min", "0", "time", "1"]) do + {:ok, _output} -> + try do + drain_input_loop(0, 20) + after + _result = TermUtils.safe_stty(["min", "1", "time", "0"]) + end - # Restore blocking read - _ = TermUtils.safe_stty(["min", "1", "time", "0"]) + {:error, _reason} -> + :ok + end rescue - _ -> :ok + _exception -> :ok end defp drain_input_loop(iteration, max) when iteration >= max, do: :ok @@ -1536,40 +227,10 @@ defmodule TermUI.Backend.Raw do data when is_binary(data) and byte_size(data) > 0 -> drain_input_loop(iteration + 1, max) - _ -> + _other -> :ok end rescue - _ -> :ok - end - - # Disables the current mouse tracking mode - defp disable_current_mouse_mode(mode) do - ansi_mode = mouse_mode_to_ansi(mode) - - if ansi_mode do - write_to_terminal(ANSI.disable_mouse_tracking(ansi_mode)) - end - end - - # Error-safe cooked mode restoration - defp safe_cooked_mode do - :shell.start_interactive({:noshell, :cooked}) - rescue - e in UndefinedFunctionError -> - # :shell.start_interactive/1 not available (pre-OTP 28) - Logger.warning( - "Cooked mode restoration not available (OTP 28+ required): #{Exception.message(e)}" - ) - - :ok - - e -> - Logger.warning("Failed to restore cooked mode: #{Exception.message(e)}") - :ok - catch - kind, reason -> - Logger.warning("Failed to restore cooked mode: #{kind} - #{inspect(reason)}") - :ok + _exception -> :ok end end diff --git a/lib/term_ui/backend/renderer.ex b/lib/term_ui/backend/renderer.ex new file mode 100644 index 00000000..6a2568ff --- /dev/null +++ b/lib/term_ui/backend/renderer.ex @@ -0,0 +1,144 @@ +defmodule TermUI.Backend.Renderer do + @moduledoc false + + alias TermUI.{ANSI, Cell} + alias TermUI.Color.Converter + + @unicode_chars TermUI.CharacterSet.get(:unicode) + @ascii_chars TermUI.CharacterSet.get(:ascii) + + @unicode_to_ascii_map ( + keys = TermUI.CharacterSet.keys() -- [:bar_levels] + + base = + Map.new(keys, fn key -> {@unicode_chars[key], @ascii_chars[key]} end) + + bars = + @unicode_chars.bar_levels + |> Enum.zip(Stream.cycle(@ascii_chars.bar_levels)) + |> Map.new() + + Map.merge(bars, base) + ) + + @spec render( + [{TermUI.Backend.position(), TermUI.Backend.cell()}], + :true_color | :color_256 | :color_16 | :monochrome, + :unicode | :ascii + ) :: iolist() + def render(changes, color_mode, character_set) do + [ + changes + |> contiguous_runs(character_set) + |> render_runs(color_mode), + ANSI.reset() + ] + end + + defp contiguous_runs(changes, character_set) do + {runs, current} = + Enum.reduce(changes, {[], nil}, fn change, {runs, current} -> + cell = render_cell(change, character_set) + + if adjacent?(current, cell) do + {runs, append_to_run(current, cell)} + else + {complete_run(runs, current), new_run(cell)} + end + end) + + runs + |> complete_run(current) + |> Enum.reverse() + end + + defp complete_run(runs, nil), do: runs + defp complete_run(runs, run), do: [run | runs] + + defp render_cell({{row, column}, {char, foreground, background, attrs}}, character_set) do + cell = Cell.new(char) + char = map_character(cell.char, character_set) + width = if char == cell.char, do: Cell.width(cell), else: char |> Cell.new() |> Cell.width() + + %{row: row, column: column, char: char, width: width, style: {foreground, background, attrs}} + end + + defp adjacent?(nil, _cell), do: false + + defp adjacent?(run, cell) do + run.row == cell.row and run.last_column + run.last_width == cell.column + end + + defp new_run(cell) do + %{ + row: cell.row, + column: cell.column, + last_column: cell.column, + last_width: cell.width, + cells: [cell] + } + end + + defp append_to_run(run, cell) do + %{run | last_column: cell.column, last_width: cell.width, cells: [cell | run.cells]} + end + + defp render_runs(runs, color_mode) do + {_style, output} = + Enum.reduce(runs, {nil, []}, fn run, {previous_style, output} -> + {style, cells} = render_run_cells(Enum.reverse(run.cells), color_mode, previous_style) + + {style, [output, ANSI.cursor_position(run.row, run.column), cells]} + end) + + output + end + + defp render_run_cells(cells, color_mode, initial_style) do + Enum.reduce(cells, {initial_style, []}, fn cell, {previous_style, output} -> + style = cell.style + sequence = if style == previous_style, do: [], else: style_sequence(style, color_mode) + {style, [output, sequence, cell.char]} + end) + end + + defp style_sequence({foreground, background, attrs}, color_mode) do + [ + ANSI.reset(), + color(:fg, foreground, color_mode), + color(:bg, background, color_mode), + Enum.map(attrs, &attribute/1) + ] + end + + defp map_character(char, :unicode), do: char + defp map_character(char, :ascii), do: Map.get(@unicode_to_ascii_map, char, char) + + defp color(_type, _color, :monochrome), do: [] + defp color(_type, :default, _mode), do: [] + defp color(:fg, color, _mode) when is_atom(color), do: ANSI.foreground(color) + defp color(:bg, color, _mode) when is_atom(color), do: ANSI.background(color) + defp color(:fg, index, _mode) when is_integer(index), do: ANSI.foreground_256(index) + defp color(:bg, index, _mode) when is_integer(index), do: ANSI.background_256(index) + defp color(:fg, {red, green, blue}, :true_color), do: ANSI.foreground_rgb(red, green, blue) + defp color(:bg, {red, green, blue}, :true_color), do: ANSI.background_rgb(red, green, blue) + + defp color(:fg, rgb, :color_256), do: rgb |> Converter.rgb_to_256() |> ANSI.foreground_256() + defp color(:bg, rgb, :color_256), do: rgb |> Converter.rgb_to_256() |> ANSI.background_256() + + defp color(:fg, rgb, :color_16), + do: ["\e[", Integer.to_string(Converter.rgb_to_16(rgb, :fg)), "m"] + + defp color(:bg, rgb, :color_16), + do: ["\e[", Integer.to_string(Converter.rgb_to_16(rgb, :bg)), "m"] + + defp attribute(:bold), do: ANSI.bold() + defp attribute(:dim), do: ANSI.dim() + defp attribute(:italic), do: ANSI.italic() + defp attribute(:underline), do: ANSI.underline() + defp attribute(:blink), do: ANSI.blink() + defp attribute(:reverse), do: ANSI.reverse() + defp attribute(:hidden), do: ANSI.hidden() + defp attribute(:strikethrough), do: ANSI.strikethrough() + defp attribute(_unknown), do: [] +end diff --git a/lib/term_ui/backend/selector.ex b/lib/term_ui/backend/selector.ex index 0afd037e..78de64b8 100644 --- a/lib/term_ui/backend/selector.ex +++ b/lib/term_ui/backend/selector.ex @@ -1,93 +1,5 @@ defmodule TermUI.Backend.Selector do - @moduledoc """ - Determines which terminal backend to use at runtime. - - The Selector module implements a "try raw mode first" strategy for backend - selection. This approach is the **only reliable method** for determining - whether raw terminal mode is available. - - ## Why Not Use Heuristics? - - Environment-based detection (checking `$TERM`, `IO.getopts/0`, etc.) cannot - reliably detect all cases where raw mode is unavailable: - - - **Nerves devices**: The erlinit process may have already started a shell, - making raw mode unavailable even though `$TERM` suggests a capable terminal - - - **SSH sessions**: Remote SSH connections often have a shell already running - in the PTY, preventing raw mode activation - - - **Remote IEx**: Connecting to a running node via `--remsh` or distributed - Erlang inherits the remote node's terminal state - - - **Docker containers**: Terminal allocation varies by configuration; a TTY - may be allocated but a shell may already be running - - - **IDE terminals**: Integrated terminals may report capabilities they don't - fully support in raw mode - - ## The Selection Strategy - - The selector attempts to start raw mode using OTP 28's - `:shell.start_interactive({:noshell, :raw})`: - - 1. **If raw mode succeeds** (returns `:ok`): - - The terminal is now in raw mode - - Return `{:raw, state}` for the Raw backend - - 2. **If raw mode fails** with `{:error, :already_started}`: - - A shell is already running, raw mode unavailable - - Detect terminal capabilities for graceful degradation - - Return `{:tty, capabilities}` for the TTY backend - - 3. **If the function is undefined** (pre-OTP 28): - - Fall back to TTY mode - - Return `{:tty, capabilities}` with detected capabilities - - ## Return Values - - The `select/0` function returns one of: - - - `{:raw, state}` - Raw mode is active. The `state` map contains: - - `:raw_mode_started` - `true` indicating raw mode was activated - - - `{:tty, capabilities}` - TTY mode should be used. The `capabilities` map contains: - - `:colors` - Color depth (`:true_color`, `:color_256`, `:color_16`, `:monochrome`) - - `:unicode` - Boolean indicating Unicode support - - `:dimensions` - `{rows, cols}` tuple or `nil` if unknown - - `:terminal` - Boolean indicating terminal presence - - ## Explicit Selection - - For testing or configuration override, use `select/1`: - - # Force TTY mode - {:tty, caps} = Selector.select(TermUI.Backend.TTY) - - # Force raw mode (will fail if unavailable) - {:raw, state} = Selector.select(TermUI.Backend.Raw) - - # Auto-detect (same as select/0) - result = Selector.select(:auto) - - ## Examples - - # Typical usage in runtime initialization - case TermUI.Backend.Selector.select() do - {:raw, state} -> - # Initialize raw backend - TermUI.Backend.Raw.init(state) - - {:tty, capabilities} -> - # Initialize TTY backend with detected capabilities - TermUI.Backend.TTY.init(capabilities: capabilities) - end - - ## OTP Version Requirements - - - **OTP 28+**: Full support with `:shell.start_interactive/1` - - **OTP 27 and earlier**: Automatic fallback to TTY mode - """ + @moduledoc false require Logger diff --git a/lib/term_ui/backend/ssh.ex b/lib/term_ui/backend/ssh.ex deleted file mode 100644 index 03c6985a..00000000 --- a/lib/term_ui/backend/ssh.ex +++ /dev/null @@ -1,500 +0,0 @@ -defmodule TermUI.Backend.SSH do - @moduledoc """ - SSH terminal backend for remote terminal sessions. - - The SSH backend renders to an Erlang SSH channel IO device, enabling TermUI - applications to run over SSH connections via OTP's `:ssh` application. - - ## How It Works - - When an SSH client connects and requests a PTY, the `:ssh` application creates - an IO device (the channel's group leader) that implements the Erlang IO protocol. - This backend writes ANSI escape sequences to that device and reads input from it. - - SSH channels are already in raw mode from the client side — no `stty` or - `:shell.start_interactive` is needed. - - ## Usage - - Start a TermUI Runtime with an explicit SSH backend: - - device = Process.group_leader() # SSH channel's IO device - {rows, cols} = get_pty_size(device) - - {:ok, runtime} = TermUI.Runtime.start_link( - root: MyApp.Root, - backend: {TermUI.Backend.SSH, device: device, size: {rows, cols}} - ) - - ## Input Handling - - SSH input is delivered externally. The host process reads bytes from the SSH - device, parses escape sequences, and sends events to the Runtime: - - send(runtime, {:ssh_input, %TermUI.Event.Key{key: :enter}}) - - The `poll_event/2` callback returns `{:timeout, state}` since input is external. - - ## Resize Events - - Terminal size changes arrive as SSH `window_change` channel requests. Forward - them to the Runtime: - - send(runtime, {:ssh_resize, new_rows, new_cols}) - - ## Multiple Sessions - - Each SSH connection gets its own Backend.SSH instance with its own device. - There is no global state — multiple concurrent sessions work independently. - - ## See Also - - - `TermUI.Backend` — Behaviour definition - - `TermUI.Backend.Raw` — Local terminal backend (raw mode) - - `TermUI.Backend.TTY` — Local terminal backend (cooked mode) - """ - - @behaviour TermUI.Backend - - alias TermUI.ANSI - - # ANSI escape sequence constants - @cursor_hide "\e[?25l" - @cursor_show "\e[?25h" - @clear_screen "\e[2J" - @cursor_home "\e[H" - @alt_screen_enter "\e[?1049h" - @alt_screen_leave "\e[?1049l" - @reset_attrs "\e[0m" - - # Mouse tracking sequences - @mouse_sgr_on "\e[?1006h" - @mouse_normal_on "\e[?1000h" - @mouse_button_on "\e[?1002h" - @mouse_any_on "\e[?1003h" - @all_mouse_off "\e[?1006l\e[?1003l\e[?1002l\e[?1000l" - - @typedoc """ - Mouse tracking mode. - - - `:none` — No mouse tracking - - `:click` — Button press/release only (mode 1000) - - `:drag` — Press/release + motion while pressed (mode 1002) - - `:all` — All mouse movement (mode 1003) - """ - @type mouse_mode :: :none | :click | :drag | :all - - @typedoc """ - Current SGR style state for delta optimization. - """ - @type style_state :: %{ - fg: TermUI.Backend.color(), - bg: TermUI.Backend.color(), - attrs: [atom()] - } - - @typedoc """ - Internal state for the SSH backend. - - ## Fields - - - `:device` — SSH channel IO device PID - - `:size` — Terminal dimensions as `{rows, cols}` - - `:cursor_visible` — Whether cursor is currently visible - - `:cursor_position` — Current cursor position as `{row, col}` or `nil` - - `:alternate_screen` — Whether alternate screen buffer is active - - `:mouse_mode` — Current mouse tracking mode - - `:current_style` — Current SGR state for style delta tracking - """ - @type t :: %__MODULE__{ - device: IO.device(), - size: {pos_integer(), pos_integer()}, - cursor_visible: boolean(), - cursor_position: {pos_integer(), pos_integer()} | nil, - alternate_screen: boolean(), - mouse_mode: mouse_mode(), - current_style: style_state() | nil - } - - defstruct device: nil, - size: {24, 80}, - cursor_visible: false, - cursor_position: nil, - alternate_screen: false, - mouse_mode: :none, - current_style: nil - - # =========================================================================== - # Lifecycle Callbacks - # =========================================================================== - - @impl true - @doc """ - Initializes the SSH backend with the given device and terminal size. - - ## Options - - - `:device` (required) — SSH channel IO device (from `Process.group_leader()` in SSH shell) - - `:size` — Terminal dimensions as `{rows, cols}` from PTY negotiation (default: `{24, 80}`) - - `:alternate_screen` — Use alternate screen buffer (default: `true`) - - `:hide_cursor` — Hide cursor during rendering (default: `true`) - - `:mouse_tracking` — Mouse tracking mode (default: `:none`) - """ - @spec init(keyword()) :: {:ok, t()} | {:error, term()} - def init(opts) do - device = Keyword.fetch!(opts, :device) - size = Keyword.get(opts, :size, {24, 80}) - alternate_screen = Keyword.get(opts, :alternate_screen, true) - hide_cursor = Keyword.get(opts, :hide_cursor, true) - mouse_tracking = Keyword.get(opts, :mouse_tracking, :none) - - state = %__MODULE__{ - device: device, - size: size, - cursor_visible: not hide_cursor - } - - # Enter alternate screen buffer - state = - if alternate_screen do - device_write(device, @alt_screen_enter) - %{state | alternate_screen: true} - else - state - end - - # Hide cursor - if hide_cursor do - device_write(device, @cursor_hide) - end - - # Enable mouse tracking - state = enable_mouse(state, mouse_tracking) - - # Clear screen - device_write(device, @clear_screen <> @cursor_home) - - {:ok, state} - end - - @impl true - @doc """ - Shuts down the SSH backend and restores terminal state. - - Writes cleanup sequences to the SSH device. Silently handles errors - since the SSH channel may already be closed on disconnect. - """ - @spec shutdown(t()) :: :ok - def shutdown(%__MODULE__{device: device} = state) do - # Disable mouse tracking - device_write(device, @all_mouse_off) - - # Reset attributes - device_write(device, @reset_attrs) - - # Show cursor - device_write(device, @cursor_show) - - # Leave alternate screen - if state.alternate_screen do - device_write(device, @alt_screen_leave) - end - - :ok - end - - # =========================================================================== - # Query Callbacks - # =========================================================================== - - @impl true - @doc """ - Returns the cached terminal dimensions. - - SSH terminal size is provided at init from PTY negotiation and updated - externally via `update_size/3` when window_change events arrive. - """ - @spec size(t()) :: {:ok, {pos_integer(), pos_integer()}} - def size(%__MODULE__{size: size}) do - {:ok, size} - end - - @doc """ - Updates the cached terminal size. - - Called when an SSH `window_change` event arrives with new dimensions. - Returns the updated state. - """ - @spec update_size(t(), pos_integer(), pos_integer()) :: {:ok, t()} - def update_size(%__MODULE__{} = state, rows, cols) - when is_integer(rows) and rows > 0 and is_integer(cols) and cols > 0 do - {:ok, %{state | size: {rows, cols}}} - end - - # =========================================================================== - # Cursor Callbacks - # =========================================================================== - - @impl true - @spec move_cursor(t(), {pos_integer(), pos_integer()}) :: {:ok, t()} - def move_cursor(%__MODULE__{device: device, size: {max_rows, max_cols}} = state, {row, col}) do - clamped_row = max(1, min(row, max_rows)) - clamped_col = max(1, min(col, max_cols)) - device_write(device, "\e[#{clamped_row};#{clamped_col}H") - {:ok, %{state | cursor_position: {clamped_row, clamped_col}}} - end - - @impl true - @spec hide_cursor(t()) :: {:ok, t()} - def hide_cursor(%__MODULE__{cursor_visible: false} = state), do: {:ok, state} - - def hide_cursor(%__MODULE__{device: device} = state) do - device_write(device, @cursor_hide) - {:ok, %{state | cursor_visible: false}} - end - - @impl true - @spec show_cursor(t()) :: {:ok, t()} - def show_cursor(%__MODULE__{cursor_visible: true} = state), do: {:ok, state} - - def show_cursor(%__MODULE__{device: device} = state) do - device_write(device, @cursor_show) - {:ok, %{state | cursor_visible: true}} - end - - # =========================================================================== - # Rendering Callbacks - # =========================================================================== - - @impl true - @spec clear(t()) :: {:ok, t()} - def clear(%__MODULE__{device: device} = state) do - device_write(device, @clear_screen <> @cursor_home) - {:ok, %{state | cursor_position: {1, 1}, current_style: nil}} - end - - @impl true - @doc """ - Draws cells to the SSH terminal at specified positions. - - Uses style delta optimization — only emits SGR escape sequences when - the style changes from the previous cell. Cells should be sorted by - position (row-major) for efficient cursor movement. - """ - @spec draw_cells(t(), [{TermUI.Backend.position(), TermUI.Backend.cell()}]) :: {:ok, t()} - def draw_cells(%__MODULE__{} = state, []), do: {:ok, state} - - def draw_cells(%__MODULE__{device: device} = state, cells) when is_list(cells) do - # Sort cells by position for sequential rendering - sorted = Enum.sort_by(cells, fn {{row, col}, _cell} -> {row, col} end) - - # Render with style delta tracking - {iodata, new_style, last_pos} = - Enum.reduce(sorted, {[], state.current_style, state.cursor_position}, fn - {{row, col}, {char, fg, bg, attrs}}, {acc, prev_style, prev_pos} -> - # Cursor movement — skip if already at the right position - move_seq = cursor_move_sequence(prev_pos, {row, col}) - - # Style delta — only emit changes - {style_seq, new_style} = style_delta_sequence(prev_style, fg, bg, attrs) - - # Sanitize character - safe_char = sanitize_char(char) - - new_acc = [acc, move_seq, style_seq, safe_char] - {new_acc, new_style, {row, col + String.length(safe_char)}} - end) - - # Flush all accumulated output in a single write - device_write(device, iodata) - - {:ok, %{state | current_style: new_style, cursor_position: last_pos}} - end - - @impl true - @spec flush(t()) :: {:ok, t()} - def flush(%__MODULE__{} = state) do - # Output is written immediately in draw_cells — nothing to flush - {:ok, state} - end - - # =========================================================================== - # Input Callback - # =========================================================================== - - @impl true - @doc """ - Returns timeout — SSH input is delivered externally. - - The host process reads from the SSH device and sends parsed events - to the Runtime via `send(runtime, {:ssh_input, event})`. - """ - @spec poll_event(t(), non_neg_integer()) :: {:timeout, t()} - def poll_event(%__MODULE__{} = state, _timeout) do - {:timeout, state} - end - - # =========================================================================== - # Private — Device IO - # =========================================================================== - - @spec device_write(IO.device(), iodata()) :: :ok - defp device_write(device, data) do - IO.write(device, data) - rescue - _ -> :ok - end - - # =========================================================================== - # Private — Mouse Tracking - # =========================================================================== - - @spec enable_mouse(t(), mouse_mode()) :: t() - defp enable_mouse(state, :none), do: %{state | mouse_mode: :none} - - defp enable_mouse(%__MODULE__{device: device} = state, mode) do - seq = - case mode do - :click -> @mouse_normal_on <> @mouse_sgr_on - :drag -> @mouse_button_on <> @mouse_sgr_on - :all -> @mouse_any_on <> @mouse_sgr_on - end - - device_write(device, seq) - %{state | mouse_mode: mode} - end - - # =========================================================================== - # Private — Cursor Movement - # =========================================================================== - - # Generate minimal cursor movement sequence - @spec cursor_move_sequence( - {pos_integer(), pos_integer()} | nil, - {pos_integer(), pos_integer()} - ) :: iodata() - defp cursor_move_sequence(nil, {row, col}) do - "\e[#{row};#{col}H" - end - - defp cursor_move_sequence({cur_row, cur_col}, {row, col}) do - cond do - cur_row == row and cur_col == col -> - [] - - cur_row == row and col == cur_col + 1 -> - # Next column — cursor advances naturally after char write - [] - - cur_row == row -> - # Same row, different column - "\e[#{row};#{col}H" - - true -> - # Different row - "\e[#{row};#{col}H" - end - end - - # =========================================================================== - # Private — Style Delta - # =========================================================================== - - # Compute minimal SGR sequence for style change - @spec style_delta_sequence( - style_state() | nil, - TermUI.Backend.color(), - TermUI.Backend.color(), - [atom()] - ) :: - {iodata(), style_state()} - defp style_delta_sequence(nil, fg, bg, attrs) do - # No previous style — emit full style - new_style = %{fg: fg, bg: bg, attrs: attrs} - seq = build_full_style(fg, bg, attrs) - {seq, new_style} - end - - defp style_delta_sequence(%{fg: fg, bg: bg, attrs: attrs} = prev, fg, bg, attrs) do - # Same style — no sequence needed - {[], prev} - end - - defp style_delta_sequence(prev, fg, bg, attrs) do - new_style = %{fg: fg, bg: bg, attrs: attrs} - - # Check if attributes changed (requires full reset) - if prev.attrs != attrs do - seq = build_full_style(fg, bg, attrs) - {seq, new_style} - else - # Only colors changed — emit delta - parts = [] - parts = if prev.fg != fg, do: [parts | fg_sequence(fg)], else: parts - parts = if prev.bg != bg, do: [parts | bg_sequence(bg)], else: parts - {parts, new_style} - end - end - - @spec build_full_style(TermUI.Backend.color(), TermUI.Backend.color(), [atom()]) :: iodata() - defp build_full_style(fg, bg, attrs) do - parts = [@reset_attrs] - parts = parts ++ attr_sequences(attrs) - parts = parts ++ [fg_sequence(fg)] - parts = parts ++ [bg_sequence(bg)] - parts - end - - @spec fg_sequence(TermUI.Backend.color()) :: iodata() - defp fg_sequence(:default), do: "\e[39m" - - defp fg_sequence({r, g, b}) when is_integer(r) and is_integer(g) and is_integer(b) do - "\e[38;2;#{r};#{g};#{b}m" - end - - defp fg_sequence(index) when is_integer(index) and index in 0..255 do - "\e[38;5;#{index}m" - end - - defp fg_sequence(name) when is_atom(name) do - ANSI.foreground(name) - end - - @spec bg_sequence(TermUI.Backend.color()) :: iodata() - defp bg_sequence(:default), do: "\e[49m" - - defp bg_sequence({r, g, b}) when is_integer(r) and is_integer(g) and is_integer(b) do - "\e[48;2;#{r};#{g};#{b}m" - end - - defp bg_sequence(index) when is_integer(index) and index in 0..255 do - "\e[48;5;#{index}m" - end - - defp bg_sequence(name) when is_atom(name) do - ANSI.background(name) - end - - @spec attr_sequences([atom()]) :: [iodata()] - defp attr_sequences(attrs) do - Enum.map(attrs, fn - :bold -> "\e[1m" - :dim -> "\e[2m" - :italic -> "\e[3m" - :underline -> "\e[4m" - :blink -> "\e[5m" - :reverse -> "\e[7m" - :hidden -> "\e[8m" - :strikethrough -> "\e[9m" - _ -> [] - end) - end - - # =========================================================================== - # Private — Character Sanitization - # =========================================================================== - - @spec sanitize_char(String.t()) :: String.t() - defp sanitize_char(""), do: " " - defp sanitize_char(char), do: char -end diff --git a/lib/term_ui/backend/state.ex b/lib/term_ui/backend/state.ex deleted file mode 100644 index 1d74b92c..00000000 --- a/lib/term_ui/backend/state.ex +++ /dev/null @@ -1,312 +0,0 @@ -defmodule TermUI.Backend.State do - @moduledoc """ - Shared state structure for terminal backends. - - The State module provides a consistent wrapper around backend-specific state, - enabling uniform state management across different backend implementations - (Raw and TTY modes). - - ## Purpose - - When the backend selector determines which mode to use, it returns initialization - data that gets wrapped in this state struct. This provides: - - - **Consistent interface**: All backends expose the same state structure - - **Mode tracking**: Easy identification of current terminal mode - - **Capability access**: Unified access to detected terminal capabilities - - **Size caching**: Cached terminal dimensions to avoid repeated queries - - **Lifecycle tracking**: Initialization status for proper cleanup - - ## Usage - - State structs are typically created by the runtime initialization code after - backend selection: - - case Selector.select() do - {:raw, raw_state} -> - %State{ - backend_module: TermUI.Backend.Raw, - backend_state: raw_state, - backend_mode: :raw, - capabilities: %{}, - initialized: false - } - - {:tty, capabilities} -> - %State{ - backend_module: TermUI.Backend.TTY, - backend_state: nil, - backend_mode: :tty, - capabilities: capabilities, - initialized: false - } - end - - ## Fields - - - `:backend_module` - The backend implementation module (required) - - `:backend_state` - Backend-specific internal state - - `:backend_mode` - Current terminal mode, `:raw` or `:tty` (required) - - `:capabilities` - Map of detected terminal capabilities - - `:size` - Cached terminal dimensions as `{rows, cols}` or `nil` - - `:initialized` - Whether the backend has been fully initialized - - ## Naming Convention - - This field is named `:backend_mode` (not `:mode`) to be consistent with - `Runtime.State.backend_mode` and to avoid confusion with other mode fields - throughout the codebase (e.g., `line_mode`, `mouse_mode`, `color_mode`). - - ## Constructors - - Instead of creating structs directly, use the constructor functions: - - # General constructor with explicit backend module - State.new(MyBackend, backend_mode: :tty, capabilities: %{colors: :true_color}) - - # Convenience constructor for raw mode - State.new_raw() - State.new_raw(%{raw_mode_started: true}) - - # Convenience constructor for TTY mode - State.new_tty(%{colors: :color_256, unicode: true}) - - ## State Updates - - State structs are immutable. Use update functions for convenience: - - state = State.new_tty(%{colors: :true_color}) - state = State.put_size(state, {24, 80}) - state = State.mark_initialized(state) - """ - - @typedoc """ - Terminal mode indicating which backend type is active. - """ - @type backend_mode :: :raw | :tty - - @typedoc """ - Cached terminal dimensions as `{rows, cols}`. - """ - @type dimensions :: {pos_integer(), pos_integer()} | nil - - @typedoc """ - The backend state struct. - - Contains all metadata needed to manage a terminal backend instance. - """ - @type t :: %__MODULE__{ - backend_module: module(), - backend_state: term(), - backend_mode: backend_mode(), - capabilities: map(), - size: dimensions(), - initialized: boolean() - } - - @enforce_keys [:backend_module, :backend_mode] - - # Dialyzer: Functions return specific struct types - @dialyzer {:nowarn_function, new_raw: 1, new_tty: 2} - - defstruct [ - :backend_module, - :backend_state, - :backend_mode, - capabilities: %{}, - size: nil, - initialized: false - ] - - @doc """ - Creates a new backend state with the given module and options. - - ## Arguments - - - `backend_module` - The backend implementation module - - `opts` - Keyword list of options: - - `:backend_mode` - Required. The terminal mode (`:raw` or `:tty`) - - `:backend_state` - Optional. Backend-specific internal state - - `:capabilities` - Optional. Map of terminal capabilities (default: `%{}`) - - `:size` - Optional. Cached dimensions as `{rows, cols}` (default: `nil`) - - `:initialized` - Optional. Initialization status (default: `false`) - - ## Examples - - iex> State.new(MyBackend, backend_mode: :tty) - %State{backend_module: MyBackend, backend_mode: :tty, ...} - - iex> State.new(MyBackend, backend_mode: :tty, capabilities: %{colors: :true_color}) - %State{backend_module: MyBackend, backend_mode: :tty, capabilities: %{colors: :true_color}, ...} - - ## Raises - - - `ArgumentError` if `:backend_mode` is not provided in options - """ - @spec new(module(), keyword()) :: t() - def new(backend_module, opts \\ []) do - unless Keyword.has_key?(opts, :backend_mode) do - raise ArgumentError, "the :backend_mode option is required" - end - - struct!(__MODULE__, [{:backend_module, backend_module} | opts]) - end - - @doc """ - Creates a new raw mode backend state. - - This is a convenience function that sets: - - `backend_module` to `TermUI.Backend.Raw` - - `backend_mode` to `:raw` - - `capabilities` to `%{}` - - ## Arguments - - - `backend_state` - Optional. Backend-specific internal state (default: `nil`) - - ## Examples - - iex> State.new_raw() - %State{backend_module: TermUI.Backend.Raw, backend_mode: :raw, ...} - - iex> State.new_raw(%{raw_mode_started: true}) - %State{backend_module: TermUI.Backend.Raw, backend_mode: :raw, backend_state: %{raw_mode_started: true}, ...} - """ - @spec new_raw(term()) :: t() - def new_raw(backend_state \\ nil) do - %__MODULE__{ - backend_module: TermUI.Backend.Raw, - backend_state: backend_state, - backend_mode: :raw, - capabilities: %{}, - size: nil, - initialized: false - } - end - - @doc """ - Creates a new TTY mode backend state with the given capabilities. - - This is a convenience function that sets: - - `backend_module` to `TermUI.Backend.TTY` - - `backend_mode` to `:tty` - - ## Arguments - - - `capabilities` - Map of detected terminal capabilities - - `backend_state` - Optional. Backend-specific internal state (default: `nil`) - - ## Examples - - iex> State.new_tty(%{colors: :color_256, unicode: true}) - %State{backend_module: TermUI.Backend.TTY, backend_mode: :tty, capabilities: %{colors: :color_256, unicode: true}, ...} - - iex> State.new_tty(%{colors: :true_color}, %{some: :state}) - %State{backend_module: TermUI.Backend.TTY, backend_mode: :tty, capabilities: %{colors: :true_color}, backend_state: %{some: :state}, ...} - """ - @spec new_tty(map(), term()) :: t() - def new_tty(capabilities, backend_state \\ nil) when is_map(capabilities) do - %__MODULE__{ - backend_module: TermUI.Backend.TTY, - backend_state: backend_state, - backend_mode: :tty, - capabilities: capabilities, - size: nil, - initialized: false - } - end - - # ============================================================================ - # Update Functions - # ============================================================================ - - @doc """ - Updates the backend-specific state. - - ## Arguments - - - `state` - The current state struct - - `backend_state` - The new backend-specific state value - - ## Examples - - iex> state = State.new_raw() - iex> state = State.put_backend_state(state, %{cursor: {1, 1}}) - iex> state.backend_state - %{cursor: {1, 1}} - """ - @spec put_backend_state(t(), term()) :: t() - def put_backend_state(%__MODULE__{} = state, backend_state) do - %{state | backend_state: backend_state} - end - - @doc """ - Updates the cached terminal dimensions. - - ## Arguments - - - `state` - The current state struct - - `size` - The new size as `{rows, cols}` tuple or `nil` - - ## Examples - - iex> state = State.new_tty(%{}) - iex> state = State.put_size(state, {24, 80}) - iex> state.size - {24, 80} - - iex> state = State.put_size(state, nil) - iex> state.size - nil - """ - @spec put_size(t(), dimensions()) :: t() - def put_size(%__MODULE__{} = state, size) do - %{state | size: size} - end - - @doc """ - Updates the capabilities map. - - Note: This replaces the entire capabilities map, it does not merge. - - ## Arguments - - - `state` - The current state struct - - `capabilities` - The new capabilities map - - ## Examples - - iex> state = State.new_tty(%{colors: :basic}) - iex> state = State.put_capabilities(state, %{colors: :true_color, unicode: true}) - iex> state.capabilities - %{colors: :true_color, unicode: true} - """ - @spec put_capabilities(t(), map()) :: t() - def put_capabilities(%__MODULE__{} = state, capabilities) when is_map(capabilities) do - %{state | capabilities: capabilities} - end - - @doc """ - Marks the state as initialized. - - This function is idempotent - calling it on an already initialized state - has no effect. - - ## Arguments - - - `state` - The current state struct - - ## Examples - - iex> state = State.new_tty(%{}) - iex> state.initialized - false - iex> state = State.mark_initialized(state) - iex> state.initialized - true - """ - @spec mark_initialized(t()) :: t() - def mark_initialized(%__MODULE__{} = state) do - %{state | initialized: true} - end -end diff --git a/lib/term_ui/backend/tty.ex b/lib/term_ui/backend/tty.ex index c96e4110..34198639 100644 --- a/lib/term_ui/backend/tty.ex +++ b/lib/term_ui/backend/tty.ex @@ -1,1241 +1,223 @@ defmodule TermUI.Backend.TTY do - @moduledoc """ - TTY terminal backend for constrained environments. - - The TTY backend provides terminal rendering when raw mode is unavailable. This - includes Nerves devices, SSH sessions, remote IEx consoles, and other scenarios - where `:shell.start_interactive({:noshell, :raw})` returns `{:error, :already_started}`. - - ## When This Backend is Selected - - The `TermUI.Backend.Selector` chooses this backend when: - 1. Raw mode activation fails with `:already_started` (a shell is already running) - 2. The environment is detected as constrained (Nerves, remote IEx) - 3. Explicit TTY mode is requested via configuration - - ## Key Difference from Raw Backend - - **This backend is still fully interactive.** Even without raw mode, we can: - - Read individual characters and escape sequences using `IO.getn/2` - - Process arrow keys, Tab, function keys, and control sequences - - Position the cursor and render styled text - - The main differences from raw mode are: - - **No terminal mode control** - Cannot switch terminal modes (shell already running) - - **Potential interference** - The existing shell's line editing may occasionally interfere - - **Capability uncertainty** - Must detect and adapt to available features - - **Limited mouse support** - Mouse events may not be available or reliable - - ## Rendering Modes - - This backend supports two rendering modes via the `:line_mode` option: - - - **`:full_redraw`** (default) - Clears the screen and redraws everything on each - frame. This is reliable but may cause visible flicker on slow connections. - - - **`:incremental`** - Only updates cells that changed since the last frame. - This is faster and reduces flicker but may have artifacts if the terminal - state becomes out of sync. - - ## Color Degradation - - The TTY backend automatically degrades colors based on detected capabilities: - - | Mode | Description | Escape Format | - |------|-------------|---------------| - | `:true_color` | Full 24-bit RGB | `ESC[38;2;r;g;bm` | - | `:color_256` | 256-color palette | `ESC[38;5;nm` | - | `:color_16` | Basic 16 colors | `ESC[31m` etc. | - | `:monochrome` | No colors | Attributes only | - - ## Character Set Handling - - When Unicode is unavailable, box-drawing characters are automatically mapped - to ASCII equivalents. The `:character_set` field tracks the current mode: - - - `:unicode` - Full Unicode box-drawing characters - - `:ascii` - ASCII fallback (`+`, `-`, `|` for corners and lines) - - ## Configuration Options - - The `init/1` callback accepts these options: - - - `:capabilities` - Map of detected terminal capabilities (from Selector) - - `:line_mode` - Rendering strategy (`:full_redraw` or `:incremental`) - - `:alternate_screen` - Whether to use alternate screen buffer (default: `false`) - - ## Example - - This backend is typically used via the runtime, not directly: - - # Automatic backend selection (recommended) - {:ok, runtime} = TermUI.Runtime.start_link() - - # The runtime handles backend selection based on environment - - ## See Also - - - `TermUI.Backend` - Behaviour definition - - `TermUI.Backend.Selector` - Backend selection logic - - `TermUI.Backend.Raw` - Full-featured backend for raw mode - - `TermUI.CharacterSet` - Unicode/ASCII character mapping - """ + @moduledoc false @behaviour TermUI.Backend - alias TermUI.Backend.InputBuffer - alias TermUI.Color.Converter - alias TermUI.Terminal.EscapeParser - - # Dialyzer: Functions return specific struct types - @dialyzer {:nowarn_function, init: 1, map_character: 2, sanitize_char: 1} - - # =========================================================================== - # ANSI Escape Sequence Constants - # =========================================================================== - - # Cursor control sequences - @cursor_hide "\e[?25l" - @cursor_show "\e[?25h" - - # Screen control sequences - @clear_screen "\e[2J" - @cursor_home "\e[H" - @alt_screen_enter "\e[?1049h" - @alt_screen_leave "\e[?1049l" - - # Attribute control sequences - @reset_attrs "\e[0m" - - # Input buffer management is handled by TermUI.Backend.InputBuffer module - # which provides rate-limited logging and consistent behavior across backends. - - # =========================================================================== - # Type Definitions and State Structure - # =========================================================================== - - @typedoc """ - Color rendering mode based on terminal capabilities. - - Determines how colors are encoded in escape sequences: - - - `:true_color` - Full 24-bit RGB colors (`ESC[38;2;r;g;bm`) - - `:color_256` - 256-color palette (`ESC[38;5;nm`) - - `:color_16` - Basic 16 ANSI colors (`ESC[31m` etc.) - - `:monochrome` - No color support, attributes only - """ - @type color_mode :: :true_color | :color_256 | :color_16 | :monochrome - - @typedoc """ - Rendering strategy for frame updates. + alias TermUI.{ANSI, Clipboard, Frame} + alias TermUI.Backend.{EventStream, Renderer} + alias TermUI.Terminal.SizeDetector + alias TermUI.TerminalOutput - - `:full_redraw` - Clear and redraw entire screen each frame (reliable) - - `:incremental` - Only update changed cells (faster but may have artifacts) - """ @type line_mode :: :full_redraw | :incremental - - @typedoc """ - Character set for box-drawing and special characters. - - - `:unicode` - Full Unicode box-drawing characters - - `:ascii` - ASCII fallback characters - """ + @type color_mode :: :true_color | :color_256 | :color_16 | :monochrome @type character_set :: :unicode | :ascii - @typedoc """ - Internal state for the TTY backend. - - Tracks terminal configuration and rendering state. - - ## Fields - - - `:size` - Terminal dimensions as `{rows, cols}` - - `:capabilities` - Map of detected terminal capabilities from Selector - - `:line_mode` - Rendering strategy (`:full_redraw` or `:incremental`) - - `:last_frame` - Previous frame for incremental rendering comparison - - `:character_set` - Unicode or ASCII character set - - `:color_mode` - Color capability level - - `:alternate_screen` - Whether alternate screen buffer is active - - `:cursor_visible` - Whether cursor is currently visible - - `:cursor_position` - Current cursor position as `{row, col}` or `nil` - - `:input_buffer` - Buffer for partial escape sequences between poll_event calls - """ @type t :: %__MODULE__{ - size: {pos_integer(), pos_integer()}, + size: TermUI.Backend.size(), capabilities: map(), line_mode: line_mode(), - last_frame: map() | nil, character_set: character_set(), color_mode: color_mode(), alternate_screen: boolean(), - cursor_visible: boolean(), - cursor_position: {pos_integer(), pos_integer()} | nil, - input_buffer: binary() + input_buffer: binary(), + event_queue: [TermUI.Backend.event()], + paste_state: map() | nil, + input_reader: pid() | nil, + rendered_frame: Frame.t() | nil, + bracketed_paste: boolean(), + focus_events: boolean() } - defstruct size: {24, 80}, - capabilities: %{}, - line_mode: :full_redraw, - last_frame: nil, - character_set: :unicode, - color_mode: :true_color, - alternate_screen: false, - cursor_visible: true, - cursor_position: nil, - input_buffer: <<>> - - # =========================================================================== - # Lifecycle Callbacks - # =========================================================================== + @schema Zoi.struct(__MODULE__, %{ + size: Zoi.tuple({Zoi.integer(), Zoi.integer()}) |> Zoi.default({24, 80}), + capabilities: Zoi.map() |> Zoi.default(%{}), + line_mode: Zoi.enum([:full_redraw, :incremental]) |> Zoi.default(:full_redraw), + character_set: Zoi.enum([:unicode, :ascii]) |> Zoi.default(:unicode), + color_mode: + Zoi.enum([:true_color, :color_256, :color_16, :monochrome]) + |> Zoi.default(:true_color), + alternate_screen: Zoi.boolean() |> Zoi.default(false), + input_buffer: Zoi.string() |> Zoi.default(""), + event_queue: Zoi.array() |> Zoi.default([]), + paste_state: Zoi.any() |> Zoi.default(nil), + input_reader: Zoi.any() |> Zoi.default(nil), + rendered_frame: Zoi.any() |> Zoi.default(nil), + bracketed_paste: Zoi.boolean() |> Zoi.default(true), + focus_events: Zoi.boolean() |> Zoi.default(true) + }) + + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) @impl true - @doc """ - Initializes the TTY backend with detected capabilities. - - Accepts options from the Selector including terminal capabilities. - - ## Options - - - `:capabilities` - Map of detected terminal capabilities - - `:line_mode` - Rendering strategy (default: `:full_redraw`) - - `:alternate_screen` - Use alternate screen buffer (default: `false`) - - `:size` - Explicit terminal dimensions (default: from capabilities or `{24, 80}`) - - ## Returns - - - `{:ok, state}` - Successfully initialized - - `{:error, reason}` - Initialization failed - """ - @spec init(keyword()) :: {:ok, t()} - def init(opts \\ []) do + @spec init(keyword()) :: {:ok, t()} | {:error, term()} + def init(opts) do capabilities = Keyword.get(opts, :capabilities, %{}) - line_mode = Keyword.get(opts, :line_mode, :full_redraw) - alternate_screen = Keyword.get(opts, :alternate_screen, false) - - # Determine color mode from capabilities - color_mode = determine_color_mode(capabilities) - - # Determine character set from capabilities - character_set = determine_character_set(capabilities) - - # Get terminal size from capabilities or option or default - size = determine_size(opts, capabilities) state = %__MODULE__{ - size: size, + size: determine_size(opts, capabilities), capabilities: capabilities, - line_mode: line_mode, - character_set: character_set, - color_mode: color_mode, - alternate_screen: alternate_screen + line_mode: Keyword.get(opts, :line_mode, :full_redraw), + character_set: if(Map.get(capabilities, :unicode, true), do: :unicode, else: :ascii), + color_mode: determine_color_mode(capabilities), + alternate_screen: Keyword.get(opts, :alternate_screen, false), + bracketed_paste: Keyword.get(opts, :bracketed_paste, true), + focus_events: Keyword.get(opts, :focus_events, true) } - # Perform terminal setup - state = setup_terminal(state) - - {:ok, state} - end - - @impl true - @doc """ - Shuts down the TTY backend and restores terminal state. - - Performs the following cleanup sequence: - 1. Reset all text attributes (colors, bold, underline, etc.) - 2. Show the cursor (in case it was hidden) - 3. Leave alternate screen buffer (if it was entered) - - ## Idempotent Behavior - - This function is safe to call multiple times. Each call will emit the same - cleanup sequences, which is harmless since terminal state converges to the - same result regardless of prior state. - - ## Error Handling - - All terminal writes use `safe_write/1` which catches and ignores errors. - This ensures cleanup completes even if the terminal is in an error state - or has been disconnected. We prioritize best-effort cleanup over failing - on individual write errors. - - ## No Cooked Mode Restoration - - Unlike the Raw backend, the TTY backend never takes the terminal out of - cooked mode (the shell is already running). Therefore, no mode restoration - is needed during shutdown. - - ## Returns - - Always returns `:ok`. - """ - @spec shutdown(t()) :: :ok - def shutdown(%__MODULE__{} = state) do - # Reset all attributes (colors, styles) - safe_write(@reset_attrs) - - # Show cursor - safe_write(@cursor_show) - - # Leave alternate screen if it was entered - if state.alternate_screen do - safe_write(@alt_screen_leave) + case TerminalOutput.write(setup_sequence(state)) do + :ok -> {:ok, state} + {:error, reason} -> {:error, {:terminal_write_failed, reason}} end - - :ok end - # =========================================================================== - # Query Callbacks - # =========================================================================== - @impl true - @doc """ - Returns the current terminal dimensions. + @spec shutdown(t(), term()) :: :ok + def shutdown(state, _reason) do + EventStream.stop(state) - ## Returns + TerminalOutput.write_to_tty(TerminalOutput.cleanup_sequence()) - - `{:ok, {rows, cols}}` - Terminal size - """ - @spec size(t()) :: {:ok, {pos_integer(), pos_integer()}} - def size(%__MODULE__{size: size}) do - {:ok, size} - end - - @doc """ - Updates the terminal size and clears the frame buffer. - - When the terminal is resized, the previous frame is no longer valid since - positions may now be out of bounds or content may need to be reflowed. - This function updates the size and clears `last_frame` to force a full - redraw on the next `draw_cells/2` call. - - ## Parameters - - - `state` - Current backend state - - `new_size` - New terminal dimensions as `{rows, cols}` - - ## Returns - - `{:ok, updated_state}` with new size and cleared last_frame. - """ - @spec set_size(t(), {pos_integer(), pos_integer()}) :: {:ok, t()} - def set_size(%__MODULE__{} = state, {rows, cols} = new_size) - when is_integer(rows) and rows > 0 and is_integer(cols) and cols > 0 do - {:ok, %{state | size: new_size, last_frame: nil}} - end - - @doc """ - Queries the terminal for its current size and updates state. - - Uses `:io.rows/0` and `:io.columns/0` to get the current terminal dimensions. - If the query fails (e.g., not connected to a terminal), the current size is preserved. - - This function also clears `last_frame` to force a full redraw, since the - terminal dimensions may have changed. - - Note: This is a TTY-specific extension function, not part of the Backend behaviour. - The return signature matches `TermUI.Backend.Raw.refresh_size/1` for consistency. - - ## Returns - - `{:ok, {rows, cols}, updated_state}` with refreshed size and cleared last_frame. - - ## Example - - {:ok, {rows, cols}, state} = TTY.refresh_size(state) - """ - @spec refresh_size(t()) :: {:ok, TermUI.Backend.size(), t()} - def refresh_size(%__MODULE__{} = state) do - new_size = query_terminal_size(state.size) - new_state = %{state | size: new_size, last_frame: nil} - {:ok, new_size, new_state} - end - - # Queries the terminal for its current dimensions. - # Falls back to the provided default if the query fails. - @spec query_terminal_size({pos_integer(), pos_integer()}) :: {pos_integer(), pos_integer()} - defp query_terminal_size(default) do - rows = - case :io.rows() do - {:ok, r} when is_integer(r) and r > 0 -> r - _ -> elem(default, 0) - end + safe_write([ + if(state.bracketed_paste, do: ANSI.disable_bracketed_paste(), else: []), + if(state.focus_events, do: ANSI.disable_focus_events(), else: []), + ANSI.reset(), + ANSI.cursor_show(), + if(state.alternate_screen, do: ANSI.leave_alternate_screen(), else: []) + ]) - cols = - case :io.columns() do - {:ok, c} when is_integer(c) and c > 0 -> c - _ -> elem(default, 1) - end - - {rows, cols} + :ok end - # =========================================================================== - # Cursor Callbacks - # =========================================================================== - @impl true - @doc """ - Moves the cursor to the specified position. - - Position is 1-indexed: `{1, 1}` is the top-left corner. - Outputs `\\e[row;colH` escape sequence. - Position is clamped to terminal bounds. - """ - @spec move_cursor(t(), {pos_integer(), pos_integer()}) :: {:ok, t()} - def move_cursor(%__MODULE__{size: {max_rows, max_cols}} = state, {row, col}) do - # Clamp position to terminal bounds - clamped_row = max(1, min(row, max_rows)) - clamped_col = max(1, min(col, max_cols)) - - # Output cursor positioning sequence - safe_write("\e[#{clamped_row};#{clamped_col}H") - - {:ok, %{state | cursor_position: {clamped_row, clamped_col}}} - end + @spec size(t()) :: {:ok, TermUI.Backend.size()} + def size(state), do: {:ok, state.size} @impl true - @doc """ - Hides the terminal cursor. - - Outputs `\\e[?25l` escape sequence. - - This operation is idempotent - if the cursor is already hidden, - no escape sequence is written. - """ - @spec hide_cursor(t()) :: {:ok, t()} - def hide_cursor(%__MODULE__{cursor_visible: false} = state) do - # Already hidden - idempotent no-op - {:ok, state} - end - - def hide_cursor(%__MODULE__{} = state) do - safe_write(@cursor_hide) - {:ok, %{state | cursor_visible: false}} + @spec capabilities(t()) :: map() + def capabilities(state), do: Map.put_new(state.capabilities, :dimensions, state.size) + + @spec refresh_size(t()) :: {:ok, TermUI.Backend.size(), t()} | {:error, term()} + def refresh_size(state) do + case SizeDetector.detect() do + {:ok, size} -> {:ok, size, %{state | size: size}} + {:error, reason} -> {:error, reason} + end end @impl true - @doc """ - Shows the terminal cursor. - - Outputs `\\e[?25h` escape sequence. - - This operation is idempotent - if the cursor is already visible, - no escape sequence is written. - """ - @spec show_cursor(t()) :: {:ok, t()} - def show_cursor(%__MODULE__{cursor_visible: true} = state) do - # Already visible - idempotent no-op - {:ok, state} - end - - def show_cursor(%__MODULE__{} = state) do - safe_write(@cursor_show) - {:ok, %{state | cursor_visible: true}} + @spec draw(t(), Frame.t()) :: {:ok, t()} | {:error, term()} + def draw(state, %Frame{} = frame) do + full? = + state.line_mode == :full_redraw or is_nil(state.rendered_frame) or + dimensions_changed?(state.rendered_frame, frame) + + changes = if full?, do: Frame.cells(frame), else: Frame.diff(state.rendered_frame, frame) + + output = [ + ANSI.cursor_hide(), + if(full?, do: [ANSI.clear_screen(), ANSI.cursor_position(1, 1)], else: []), + Renderer.render(changes, state.color_mode, state.character_set), + cursor_sequence(frame.cursor) + ] + + case TerminalOutput.write(output) do + :ok -> {:ok, %{state | rendered_frame: frame}} + {:error, reason} -> {:error, {:terminal_write_failed, reason}} + end end - # =========================================================================== - # Rendering Callbacks - # =========================================================================== - @impl true - @doc """ - Clears the entire screen and moves cursor to home position. - - Outputs the following escape sequences: - 1. `\\e[2J` - Clear entire screen - 2. `\\e[H` - Move cursor to home position (1,1) - - Also clears `last_frame` in state, which forces a full redraw on the next - `draw_cells/2` call when in incremental mode. - - ## Returns - - `{:ok, updated_state}` with cursor_position set to `{1, 1}` and last_frame cleared. - """ - @spec clear(t()) :: {:ok, t()} - def clear(state) do - # Clear entire screen and move cursor to home position - safe_write(@clear_screen <> @cursor_home) - - # Update state: clear last_frame for incremental mode, reset cursor position - {:ok, %{state | last_frame: nil, cursor_position: {1, 1}}} - end + @spec flush(t()) :: {:ok, t()} + def flush(state), do: {:ok, state} @impl true - @doc """ - Draws cells to the terminal at specified positions. - - In `:full_redraw` mode (default), clears the screen first then renders all cells. - In `:incremental` mode, only renders the provided cells without clearing. - - ## Cell Format - - Each cell is a tuple of `{position, cell_data}` where: - - `position` is `{row, col}` (1-indexed) - - `cell_data` is `{char, fg_color, bg_color, attrs}` - - ## Rendering Process - - 1. In full_redraw mode, clear screen and home cursor - 2. Group cells by row for efficient rendering - 3. For each row, position cursor and output styled characters - 4. Apply color degradation based on `color_mode` - - ## Returns - - `{:ok, updated_state}` with `last_frame` updated for incremental mode. - """ - @spec draw_cells(t(), [{TermUI.Backend.position(), TermUI.Backend.cell()}]) :: {:ok, t()} - def draw_cells(%__MODULE__{} = state, cells) do - case state.line_mode do - :full_redraw -> - # Always clear and redraw everything - do_full_redraw(cells, state) - - :incremental -> - if is_nil(state.last_frame) do - # First frame in incremental mode - do full redraw to establish baseline - do_full_redraw(cells, state) - else - # Subsequent frames - only render changes - do_incremental_render(cells, state) - end + @spec clipboard(t(), Clipboard.Operation.t()) :: {:ok, t()} | {:error, term()} + def clipboard(state, %Clipboard.Operation{} = operation) do + with {:ok, sequence} <- Clipboard.sequence(operation), + :ok <- TerminalOutput.write(sequence) do + {:ok, state} + else + {:error, {:clipboard_too_large, _size, _maximum} = reason} -> {:error, reason} + {:error, reason} -> {:error, {:terminal_write_failed, reason}} end end - # Performs a full redraw: clears screen and renders all cells. - @spec do_full_redraw( - [{TermUI.Backend.position(), TermUI.Backend.cell()}], - t() - ) :: {:ok, t()} - defp do_full_redraw(cells, state) do - # Clear screen and home cursor - safe_write(@clear_screen <> @cursor_home) - - # Group cells by row and render - cells - |> group_cells_by_row() - |> render_rows(state) - - # Build frame map for incremental mode tracking - frame = - if state.line_mode == :incremental do - build_frame_map(cells) - else - nil - end - - {:ok, %{state | last_frame: frame, cursor_position: nil}} - end - - # Performs incremental rendering: only updates changed/removed cells. - # - # Optimizations applied: - # 1. Sort cells by position (row, then col) for sequential access - # 2. Group adjacent cells on same row to minimize cursor moves - # 3. Batch render grouped cells with single cursor positioning - @spec do_incremental_render( - [{TermUI.Backend.position(), TermUI.Backend.cell()}], - t() - ) :: {:ok, t()} - defp do_incremental_render(cells, state) do - # Compare current frame with last frame - {changed, removed} = compare_frames(state.last_frame, cells) - - # Optimize: sort and group changed cells by row for efficient rendering - # This reduces cursor positioning overhead - changed - |> sort_cells_by_position() - |> group_cells_by_row() - |> render_incremental_rows(state) - - # Clear removed cells (sorted for sequential access) - removed - |> Enum.sort() - |> Enum.each(&clear_cell_at(&1, state)) - - # Update last_frame with current frame - frame = build_frame_map(cells) - - {:ok, %{state | last_frame: frame, cursor_position: nil}} - end - - # Sorts cells by position (row first, then column) for optimal cursor movement. - @spec sort_cells_by_position([{TermUI.Backend.position(), TermUI.Backend.cell()}]) :: - [{TermUI.Backend.position(), TermUI.Backend.cell()}] - defp sort_cells_by_position(cells) do - Enum.sort_by(cells, fn {{row, col}, _cell} -> {row, col} end) - end - - # Renders grouped cells for incremental mode with cursor optimization. - # - # For each row, positions cursor once at the first cell, then renders - # cells in sequence. Adjacent cells benefit from implicit cursor advance. - # Rows outside terminal bounds are skipped. - @spec render_incremental_rows([{pos_integer(), [{pos_integer(), TermUI.Backend.cell()}]}], t()) :: - :ok - defp render_incremental_rows(grouped_rows, state) do - {max_rows, _max_cols} = state.size - - Enum.each(grouped_rows, fn {row, row_cells} -> - # Skip rows outside terminal bounds - if row >= 1 and row <= max_rows do - [{start_col, _} | _] = row_cells - render_row_at_column(row, start_col, row_cells, state) - end - end) - end - - # Clears a cell at a specific position by writing a space. - # - # Used for incremental rendering to clear cells that were in the - # previous frame but not in the current frame. Positions outside - # terminal bounds are silently skipped. - @spec clear_cell_at(TermUI.Backend.position(), t()) :: :ok - defp clear_cell_at({row, col}, state) do - {max_rows, max_cols} = state.size - - # Validate position is within terminal bounds - if row >= 1 and row <= max_rows and col >= 1 and col <= max_cols do - cursor = "\e[#{row};#{col}H" - safe_write([cursor, @reset_attrs, " "]) - end - - :ok - end - - @impl true - @doc """ - Flushes pending output to the terminal. - - For TTY mode, output is synchronous so this is largely a no-op. - """ - @spec flush(t()) :: {:ok, t()} - def flush(state) do - {:ok, state} - end - - # =========================================================================== - # Input Callbacks - # =========================================================================== - @impl true - @doc """ - Polls for input events with the specified timeout. - - Uses `IO.getn/2` for character-by-character input. Note that the timeout - parameter may not be honored precisely since `IO.getn/2` is blocking. - - Input is parsed using `TermUI.Terminal.EscapeParser` to handle escape - sequences like arrow keys, function keys, and mouse events. - - Partial escape sequences are buffered in the state's `input_buffer` field - and will be completed on subsequent calls. - - ## Returns - - - `{:ok, event, state}` - An input event was received - - `{:timeout, state}` - No input available (rare with blocking IO) - - `{:error, reason, state}` - An error occurred - - ## Note - - The timeout parameter is not honored due to the blocking nature of `IO.getn/2`. - For non-blocking input, consider using the Raw backend when available. - """ @spec poll_event(t(), non_neg_integer()) :: {:ok, TermUI.Backend.event(), t()} | {:timeout, t()} | {:error, term(), t()} - def poll_event(%__MODULE__{input_buffer: buffer} = state, _timeout) do - # First check if we have buffered events from a previous partial parse - case parse_buffered_input(buffer) do - {:event, event, remaining} -> - {:ok, event, %{state | input_buffer: remaining}} - - :need_more -> - # Read a single character from input - case read_input_char() do - {:ok, char_data} -> - # Combine with buffer (with size limit protection) and parse - new_state = append_to_input_buffer(state, char_data) - parse_and_return_event(new_state, new_state.input_buffer) - - :eof -> - {:error, :eof, state} - - {:error, reason} -> - {:error, reason, state} - end - end + def poll_event(state, timeout) do + EventStream.poll(state, timeout, &read_one_character/0, __MODULE__) end - # Attempts to parse an event from buffered input. - @spec parse_buffered_input(binary()) :: {:event, TermUI.Backend.event(), binary()} | :need_more - defp parse_buffered_input(<<>>) do - :need_more - end - - defp parse_buffered_input(buffer) do - case EscapeParser.parse(buffer) do - {[event | _rest_events], remaining} -> - # Return first event, keep remaining in buffer - # Note: We discard rest_events; they'll be re-parsed on next call - {:event, event, remaining} - - {[], _remaining} -> - # No complete events parsed - might be partial sequence - :need_more - end - end - - # Reads a single character from standard input. - @spec read_input_char() :: {:ok, binary()} | :eof | {:error, term()} - defp read_input_char do - case IO.getn("", 1) do - :eof -> - :eof - - {:error, reason} -> - {:error, reason} - - char when is_binary(char) -> - {:ok, char} - - # IO.getn can return a charlist in some contexts - [char] when is_integer(char) -> - {:ok, <>} - - other -> - # Unexpected return type - return error instead of masking it - {:error, {:unexpected_io_return, other}} - end - end - - # Parses combined input and returns an event or timeout. - @spec parse_and_return_event(t(), binary()) :: - {:ok, TermUI.Backend.event(), t()} - | {:timeout, t()} - defp parse_and_return_event(state, input) do - case EscapeParser.parse(input) do - {[event | _rest], remaining} -> - {:ok, event, %{state | input_buffer: remaining}} - - {[], remaining} -> - # No complete event - buffer the input for next call - # This happens with partial escape sequences - # Apply buffer size limit to prevent memory exhaustion - new_state = apply_buffer_limit(%{state | input_buffer: remaining}) - {:timeout, new_state} + @impl true + @spec resize(t(), TermUI.Backend.size()) :: {:ok, t()} | {:error, term()} + def resize(state, {rows, columns} = size) + when is_integer(rows) and rows > 0 and is_integer(columns) and columns > 0 do + case TerminalOutput.write([ANSI.clear_screen(), ANSI.cursor_position(1, 1)]) do + :ok -> {:ok, %{state | size: size, rendered_frame: nil}} + {:error, reason} -> {:error, {:terminal_write_failed, reason}} end end - # Appends data to the input buffer with size limit protection. - # Uses the shared InputBuffer module for rate-limited logging. - @spec append_to_input_buffer(t(), binary()) :: t() - defp append_to_input_buffer(state, data) do - InputBuffer.append_with_limit(state, data, :input_buffer, source: __MODULE__) - end - - # Applies buffer size limit, truncating if necessary. - # Uses the shared InputBuffer module for rate-limited logging. - @spec apply_buffer_limit(t()) :: t() - defp apply_buffer_limit(%{input_buffer: buffer} = state) do - {limited, _overflowed} = InputBuffer.apply_limit(buffer, source: __MODULE__) - %{state | input_buffer: limited} - end - - # =========================================================================== - # Private Functions - # =========================================================================== - - # Determines color mode from capabilities map. - @spec determine_color_mode(map()) :: color_mode() - defp determine_color_mode(capabilities) do - case Map.get(capabilities, :colors) do - :true_color -> :true_color - :color_256 -> :color_256 - :color_16 -> :color_16 - :monochrome -> :monochrome - n when is_integer(n) -> color_mode_from_integer(n) - _ -> :true_color - end + defp setup_sequence(state) do + [ + if(state.alternate_screen, do: ANSI.enter_alternate_screen(), else: []), + ANSI.cursor_hide(), + ANSI.clear_screen(), + ANSI.cursor_position(1, 1), + if(state.bracketed_paste, do: ANSI.enable_bracketed_paste(), else: []), + if(state.focus_events, do: ANSI.enable_focus_events(), else: []) + ] end - defp color_mode_from_integer(n) when n >= 16_777_216, do: :true_color - defp color_mode_from_integer(n) when n >= 256, do: :color_256 - defp color_mode_from_integer(n) when n >= 16, do: :color_16 - defp color_mode_from_integer(_), do: :true_color + defp cursor_sequence(nil), do: ANSI.cursor_hide() + defp cursor_sequence({column, row}), do: [ANSI.cursor_position(row, column), ANSI.cursor_show()] - # Determines character set from capabilities map. - @spec determine_character_set(map()) :: character_set() - defp determine_character_set(capabilities) do - case Map.get(capabilities, :unicode, true) do - true -> :unicode - false -> :ascii - _ -> :unicode - end + defp dimensions_changed?(previous, current) do + previous.width != current.width or previous.height != current.height end - # Determines terminal size from options, capabilities, or defaults. - @spec determine_size(keyword(), map()) :: {pos_integer(), pos_integer()} defp determine_size(opts, capabilities) do - case Keyword.get(opts, :size) do - {rows, cols} when is_integer(rows) and is_integer(cols) and rows > 0 and cols > 0 -> - {rows, cols} - - nil -> - size_from_capabilities_or_default(capabilities) + case Keyword.get(opts, :size, Map.get(capabilities, :dimensions, {24, 80})) do + {rows, columns} + when is_integer(rows) and rows > 0 and is_integer(columns) and columns > 0 -> + {rows, columns} - _ -> + _invalid -> {24, 80} end end - defp size_from_capabilities_or_default(capabilities) do - case Map.get(capabilities, :dimensions) do - {rows, cols} when is_integer(rows) and is_integer(cols) and rows > 0 and cols > 0 -> - {rows, cols} - - _ -> - {24, 80} - end - end - - # Performs terminal setup during initialization. - # - # Outputs ANSI escape sequences to prepare the terminal for rendering: - # - Optionally enters alternate screen buffer if configured - # - Hides cursor for cleaner rendering - # - Clears screen and moves cursor to home position - # - # Note: No raw mode activation - the shell is already running in TTY mode. - @spec setup_terminal(t()) :: t() - defp setup_terminal(state) do - # Enter alternate screen if configured - if state.alternate_screen do - IO.write(@alt_screen_enter) - end - - # Hide cursor for cleaner rendering - IO.write(@cursor_hide) - - # Clear screen and move cursor to home position - IO.write(@clear_screen <> @cursor_home) - - # Update state to reflect cursor is hidden - %{state | cursor_visible: false, cursor_position: {1, 1}} - end - - # =========================================================================== - # Cell Rendering Helpers - # =========================================================================== - - # Groups cells by row number and sorts by column within each row. - @spec group_cells_by_row([{TermUI.Backend.position(), TermUI.Backend.cell()}]) :: - [{pos_integer(), [{pos_integer(), TermUI.Backend.cell()}]}] - defp group_cells_by_row(cells) do - cells - |> Enum.group_by(fn {{row, _col}, _cell} -> row end, fn {{_row, col}, cell} -> {col, cell} end) - |> Enum.sort_by(fn {row, _cells} -> row end) - |> Enum.map(fn {row, row_cells} -> - {row, Enum.sort_by(row_cells, fn {col, _cell} -> col end)} - end) - end - - # Renders all rows to the terminal. - @spec render_rows([{pos_integer(), [{pos_integer(), TermUI.Backend.cell()}]}], t()) :: :ok - defp render_rows(rows, state) do - Enum.each(rows, fn {row, row_cells} -> - render_row_at_column(row, 1, row_cells, state) - end) - end - - # Shared row rendering function for both full redraw and incremental modes. - # - # Renders a row of cells starting at a specified column with style delta tracking. - # Tracks the current style and only outputs SGR sequences when the style - # changes between cells. Uses iolist append pattern (no reverse needed). - # - # Parameters: - # - row: The row number (1-indexed) - # - start_col: The column to position cursor at (1 for full redraw, first cell col for incremental) - # - cells: List of {col, cell} tuples sorted by column - # - state: Backend state with color_mode and character_set - @spec render_row_at_column( - pos_integer(), - pos_integer(), - [{pos_integer(), TermUI.Backend.cell()}], - t() - ) :: :ok - defp render_row_at_column(row, start_col, cells, state) do - # Track current column, current style, and accumulated iolist - # Initial style is nil (no style set yet) - initial_state = {start_col, nil, []} - - {_col, _style, iolist} = - Enum.reduce(cells, initial_state, fn {col, cell}, {cur_col, cur_style, acc} -> - # Fill gap with spaces if needed - gap = - if col > cur_col do - String.duplicate(" ", col - cur_col) - else - "" - end - - # Render the cell with style delta tracking - {new_style, cell_io} = render_cell_with_delta(cell, cur_style, state) - - # Append to iolist (append pattern - no reverse needed for iolists) - new_acc = [acc, gap, cell_io] - - # Return next column position and new style - {col + 1, new_style, new_acc} - end) - - # Build final iolist: cursor position + content + reset - final_io = ["\e[#{row};#{start_col}H", iolist, @reset_attrs] - - # Single write for entire row - safe_write(final_io) - - :ok - end - - # Renders a single cell with style delta tracking. - # - # Only outputs SGR sequences when the style differs from the previous cell. - # Returns the new style and the iodata for this cell. - @spec render_cell_with_delta( - TermUI.Backend.cell(), - {TermUI.Backend.color(), TermUI.Backend.color(), [atom()]} | nil, - t() - ) :: {{TermUI.Backend.color(), TermUI.Backend.color(), [atom()]}, iodata()} - defp render_cell_with_delta({char, fg, bg, attrs}, cur_style, state) do - new_style = {fg, bg, attrs} - - # Only output SGR if style changed - sgr = - if new_style != cur_style do - build_sgr_sequence(fg, bg, attrs, state.color_mode) - else - "" - end - - # Map character (with potential character set mapping and sanitization) - mapped_char = map_character(char, state.character_set) - sanitized_char = sanitize_char(mapped_char) - - {new_style, [sgr, sanitized_char]} - end - - # Builds SGR (Select Graphic Rendition) sequence for colors and attributes. - # - # Combines reset, attributes, foreground color, and background color into - # a single efficient escape sequence string. - @spec build_sgr_sequence( - TermUI.Backend.color(), - TermUI.Backend.color(), - [atom()], - color_mode() - ) :: String.t() - defp build_sgr_sequence(fg, bg, attrs, color_mode) do - # Build each component - reset_part = @reset_attrs - attrs_part = build_attrs_sgr(attrs) - fg_part = build_fg_sgr(fg, color_mode) - bg_part = build_bg_sgr(bg, color_mode) - - # Combine non-empty parts - [reset_part, attrs_part, fg_part, bg_part] - |> Enum.reject(&(&1 == "")) - |> Enum.join("") - end - - # Builds SGR sequence for text attributes (bold, italic, etc.). - @spec build_attrs_sgr([atom()]) :: String.t() - defp build_attrs_sgr(attrs) do - attrs - |> Enum.map(&attr_to_sgr/1) - |> Enum.reject(&is_nil/1) - |> Enum.join("") - end - - # Builds SGR sequence for foreground color. - @spec build_fg_sgr(TermUI.Backend.color(), color_mode()) :: String.t() - defp build_fg_sgr(color, color_mode) do - color_to_sgr(color, :fg, color_mode) - end - - # Builds SGR sequence for background color. - @spec build_bg_sgr(TermUI.Backend.color(), color_mode()) :: String.t() - defp build_bg_sgr(color, color_mode) do - color_to_sgr(color, :bg, color_mode) - end - - # Converts an attribute to its SGR sequence. - @spec attr_to_sgr(atom()) :: String.t() | nil - defp attr_to_sgr(:bold), do: "\e[1m" - defp attr_to_sgr(:dim), do: "\e[2m" - defp attr_to_sgr(:italic), do: "\e[3m" - defp attr_to_sgr(:underline), do: "\e[4m" - defp attr_to_sgr(:blink), do: "\e[5m" - defp attr_to_sgr(:reverse), do: "\e[7m" - defp attr_to_sgr(:strikethrough), do: "\e[9m" - defp attr_to_sgr(_), do: nil - - # Converts a color to its SGR sequence based on color mode. - @spec color_to_sgr(TermUI.Backend.color(), :fg | :bg, color_mode()) :: String.t() - defp color_to_sgr(:default, :fg, _mode), do: "\e[39m" - defp color_to_sgr(:default, :bg, _mode), do: "\e[49m" - defp color_to_sgr(nil, _type, _mode), do: "" - - # True color mode - output RGB directly (with validation) - defp color_to_sgr({r, g, b}, :fg, :true_color) - when is_integer(r) and r >= 0 and r <= 255 and - is_integer(g) and g >= 0 and g <= 255 and - is_integer(b) and b >= 0 and b <= 255 do - "\e[38;2;#{r};#{g};#{b}m" - end - - defp color_to_sgr({r, g, b}, :bg, :true_color) - when is_integer(r) and r >= 0 and r <= 255 and - is_integer(g) and g >= 0 and g <= 255 and - is_integer(b) and b >= 0 and b <= 255 do - "\e[48;2;#{r};#{g};#{b}m" - end - - # 256-color mode - convert RGB to palette index (with validation) - defp color_to_sgr({r, g, b}, :fg, :color_256) - when is_integer(r) and r >= 0 and r <= 255 and - is_integer(g) and g >= 0 and g <= 255 and - is_integer(b) and b >= 0 and b <= 255 do - "\e[38;5;#{Converter.rgb_to_256({r, g, b})}m" - end - - defp color_to_sgr({r, g, b}, :bg, :color_256) - when is_integer(r) and r >= 0 and r <= 255 and - is_integer(g) and g >= 0 and g <= 255 and - is_integer(b) and b >= 0 and b <= 255 do - "\e[48;5;#{Converter.rgb_to_256({r, g, b})}m" - end - - # 16-color mode - convert RGB to basic color (with validation) - defp color_to_sgr({r, g, b}, :fg, :color_16) - when is_integer(r) and r >= 0 and r <= 255 and - is_integer(g) and g >= 0 and g <= 255 and - is_integer(b) and b >= 0 and b <= 255 do - "\e[#{Converter.rgb_to_16({r, g, b}, :fg)}m" - end - - defp color_to_sgr({r, g, b}, :bg, :color_16) - when is_integer(r) and r >= 0 and r <= 255 and - is_integer(g) and g >= 0 and g <= 255 and - is_integer(b) and b >= 0 and b <= 255 do - "\e[#{Converter.rgb_to_16({r, g, b}, :bg)}m" + defp determine_color_mode(capabilities) do + capabilities + |> Map.get(:colors, :true_color) + |> color_mode() end - # Monochrome mode - skip colors entirely - defp color_to_sgr({_r, _g, _b}, _type, :monochrome), do: "" - - # Invalid RGB values fall through to catch-all clause (returns "") - - # Monochrome mode - skip all colors (named and palette) - defp color_to_sgr(name, _type, :monochrome) when is_atom(name), do: "" - defp color_to_sgr(n, _type, :monochrome) when is_integer(n), do: "" - - # Named colors (for all other modes) - defp color_to_sgr(name, :fg, _mode) when is_atom(name), do: named_color_to_sgr(name, :fg) - defp color_to_sgr(name, :bg, _mode) when is_atom(name), do: named_color_to_sgr(name, :bg) - - # Palette index (0-255) - defp color_to_sgr(n, :fg, _mode) when is_integer(n) and n >= 0 and n <= 255, - do: "\e[38;5;#{n}m" - - defp color_to_sgr(n, :bg, _mode) when is_integer(n) and n >= 0 and n <= 255, - do: "\e[48;5;#{n}m" + defp color_mode(:true_color), do: :true_color + defp color_mode(:color_256), do: :color_256 + defp color_mode(:color_16), do: :color_16 + defp color_mode(:monochrome), do: :monochrome + defp color_mode(count) when is_integer(count) and count >= 16_777_216, do: :true_color + defp color_mode(count) when is_integer(count) and count >= 256, do: :color_256 + defp color_mode(count) when is_integer(count) and count >= 16, do: :color_16 + defp color_mode(_other), do: :monochrome - defp color_to_sgr(_, _, _), do: "" - - # Named color SGR code mappings (foreground base codes) - @named_color_codes %{ - black: 30, - red: 31, - green: 32, - yellow: 33, - blue: 34, - magenta: 35, - cyan: 36, - white: 37, - bright_black: 90, - bright_red: 91, - bright_green: 92, - bright_yellow: 93, - bright_blue: 94, - bright_magenta: 95, - bright_cyan: 96, - bright_white: 97 - } - - # Named color to SGR sequence using map lookup - @spec named_color_to_sgr(atom(), :fg | :bg) :: String.t() - defp named_color_to_sgr(name, type) do - case Map.get(@named_color_codes, name) do - nil -> - "" - - code when type == :fg -> - "\e[#{code}m" - - code when type == :bg -> - # Background codes are foreground + 10 - bg_code = code + 10 - "\e[#{bg_code}m" + defp read_one_character do + case IO.getn("", 1) do + :eof -> :eof + {:error, reason} -> {:error, reason} + data when is_binary(data) -> {:ok, data} + [byte] when is_integer(byte) -> {:ok, <>} + other -> {:error, {:unexpected_io_return, other}} end end - # =========================================================================== - # Character Set Mapping (Unicode to ASCII) - # =========================================================================== - - # Compile-time mapping from Unicode box-drawing characters to ASCII equivalents. - # Built from CharacterSet definitions to ensure consistency and automatic adaptation - # when new character keys are added. - @unicode_chars TermUI.CharacterSet.get(:unicode) - @ascii_chars TermUI.CharacterSet.get(:ascii) - - # Build the mapping in a single expression: - # 1. Map all single-character keys (excluding bar_levels) from unicode to ascii - # 2. Add bar_levels mapping (Unicode has 8 levels, ASCII has 5 - cycle ASCII to match) - # 3. Override bar_full to ensure it maps correctly (it appears in both bar_levels and standalone) - @unicode_to_ascii_map ( - # Single-character keys (all keys except bar_levels) - single_keys = TermUI.CharacterSet.keys() -- [:bar_levels] - - base = - Map.new(single_keys, fn key -> - {@unicode_chars[key], @ascii_chars[key]} - end) - - # Add bar_levels with cycling (8 Unicode levels → 5 ASCII levels cycled) - bar_map = - @unicode_chars.bar_levels - |> Enum.zip(Stream.cycle(@ascii_chars.bar_levels)) - |> Map.new() - - # Merge bar_map first, then base - this ensures bar_full gets the standalone value - # since it appears last in single_keys and overwrites the cycled bar_levels value - Map.merge(bar_map, base) - ) - - # Maps characters based on character set. - # - # When character_set is :unicode, passes through unchanged. - # When character_set is :ascii, replaces Unicode box-drawing and special - # characters with their ASCII equivalents for terminals that don't support Unicode. - @spec map_character(String.t(), character_set()) :: String.t() - defp map_character(char, :unicode), do: char - - defp map_character(char, :ascii) do - Map.get(@unicode_to_ascii_map, char, char) - end - - # Sanitizes characters to prevent escape sequence injection (defense-in-depth). - # - # This is the last line of defense against terminal escape injection. - # Cells should be pre-sanitized by TermUI.Renderer.Cell which provides - # comprehensive sanitization (CSI sequences, OSC sequences, control chars). - # This function provides minimal ESC removal as a safety net in case - # unsanitized content somehow reaches the rendering layer. - # - # For comprehensive sanitization, see TermUI.Renderer.Cell.sanitize/1. - @spec sanitize_char(String.t()) :: String.t() - defp sanitize_char(char) when is_binary(char) do - String.replace(char, "\e", "") - end - - defp sanitize_char(char), do: char - - # Builds a frame map from cells for incremental mode tracking. - @spec build_frame_map([{TermUI.Backend.position(), TermUI.Backend.cell()}]) :: map() - defp build_frame_map(cells) do - Map.new(cells, fn {pos, cell} -> {pos, cell} end) - end - - # =========================================================================== - # Frame Comparison for Incremental Rendering - # =========================================================================== - - # Compares the current frame with the previous frame to identify changes. - # - # Core diffing algorithm for incremental rendering. Identifies which cells - # need to be updated (new or changed) and which positions need to be cleared. - # - @doc """ - Compares two frames to find changed and removed cells. - - This is a testing helper function exposed for unit testing the incremental - rendering logic. It is not part of the Backend behaviour API. - - Uses MapSet for efficient position lookup when finding removed cells, - avoiding the need to build a full frame map just for membership testing. - - ## Parameters - - - `last_frame` - Map of `{row, col}` => `{char, fg, bg, attrs}` from previous frame - - `current_cells` - List of `{{row, col}, {char, fg, bg, attrs}}` tuples for current frame - - ## Returns - - Tuple of `{changed_cells, removed_positions}`: - - `changed_cells` - Cells that are new or different from last frame - - `removed_positions` - Positions that were in last frame but not in current - """ - @spec compare_frames( - map(), - [{TermUI.Backend.position(), TermUI.Backend.cell()}] - ) :: {[{TermUI.Backend.position(), TermUI.Backend.cell()}], [TermUI.Backend.position()]} - def compare_frames(last_frame, current_cells) do - # Find changed cells: new or different from last frame - changed = - Enum.filter(current_cells, fn {pos, cell} -> - case Map.get(last_frame, pos) do - nil -> true - ^cell -> false - _different -> true - end - end) - - # Build position set for efficient membership testing (cheaper than full frame map) - current_positions = MapSet.new(current_cells, fn {pos, _cell} -> pos end) - - # Find removed positions: in last frame but not in current - removed = - last_frame - |> Map.keys() - |> Enum.reject(&MapSet.member?(current_positions, &1)) - - {changed, removed} - end - - # =========================================================================== - # Terminal I/O Helpers - # =========================================================================== - - # Writes data to the terminal, ignoring any errors. - # - # This provides bulletproof writes for shutdown sequences where we want - # to attempt terminal cleanup even if the terminal is in an error state. - # Errors are silently ignored since we're cleaning up anyway. - @spec safe_write(iodata()) :: :ok defp safe_write(data) do - try do - IO.write(data) - rescue - _ -> :ok - end - + _result = TerminalOutput.write(data) :ok end end diff --git a/lib/term_ui/capabilities.ex b/lib/term_ui/capabilities.ex deleted file mode 100644 index 22b797bb..00000000 --- a/lib/term_ui/capabilities.ex +++ /dev/null @@ -1,404 +0,0 @@ -defmodule TermUI.Capabilities do - @moduledoc """ - Terminal capability detection and management. - - Detects terminal capabilities through multiple methods: - - Environment variables ($TERM, $COLORTERM, $TERM_PROGRAM, $LANG) - - Terminfo database queries - - Conservative VT100 fallbacks - - Results are cached in ETS for fast concurrent access. - """ - - @type color_mode :: :true_color | :color_256 | :color_16 | :monochrome - - @type t :: %__MODULE__{ - color_mode: color_mode(), - max_colors: non_neg_integer(), - unicode: boolean(), - mouse: boolean(), - bracketed_paste: boolean(), - focus_events: boolean(), - alternate_screen: boolean(), - terminal_type: String.t() | nil, - terminal_program: String.t() | nil - } - - defstruct color_mode: :color_16, - max_colors: 16, - unicode: false, - mouse: false, - bracketed_paste: false, - focus_events: false, - alternate_screen: true, - terminal_type: nil, - terminal_program: nil - - @ets_table :term_ui_capabilities - - # Dialyzer: Functions with unmatched return values - @dialyzer {:nowarn_function, - ensure_table_exists: 0, - clear_cache: 0, - get: 0, - detect_from_term: 1, - detect_from_colorterm: 1, - detect_from_term_program: 1, - detect_from_terminfo: 1, - supports_true_color?: 0, - supports_256_color?: 0, - cache_capabilities: 1, - get_cached: 0} - - # Known terminal emulators with their capabilities - @true_color_terminals ~w(iTerm.app vscode WezTerm kitty Alacritty Hyper) - @color_256_terminals ~w(Apple_Terminal gnome-terminal konsole xfce4-terminal) - - # Terminal type patterns for color detection - @term_patterns [ - {"truecolor", :true_color, 16_777_216}, - {"24bit", :true_color, 16_777_216}, - {"256color", :color_256, 256} - ] - - @term_prefixes [ - {"xterm", :color_256, 256}, - {"screen", :color_256, 256}, - {"tmux", :color_256, 256} - ] - - @doc """ - Detects terminal capabilities and caches them in ETS. - - Returns the detected capabilities struct. - """ - @spec detect() :: t() - def detect do - capabilities = do_detect() - cache_capabilities(capabilities) - capabilities - end - - @doc """ - Returns cached capabilities, detecting if not yet cached. - """ - @spec get() :: t() - def get do - case get_cached() do - nil -> detect() - caps -> caps - end - end - - @doc """ - Clears the cached capabilities. - """ - @spec clear_cache() :: :ok - def clear_cache do - ensure_table_exists() - - try do - :ets.delete(@ets_table, :capabilities) - rescue - ArgumentError -> :ok - end - - :ok - end - - # Capability accessors - - @doc """ - Returns true if terminal supports true-color (24-bit RGB). - """ - @spec supports_true_color?() :: boolean() - def supports_true_color? do - get().color_mode == :true_color - end - - @doc """ - Returns true if terminal supports 256 colors or better. - """ - @spec supports_256_color?() :: boolean() - def supports_256_color? do - get().color_mode in [:true_color, :color_256] - end - - @doc """ - Returns true if terminal supports mouse tracking. - """ - @spec supports_mouse?() :: boolean() - def supports_mouse? do - get().mouse - end - - @doc """ - Returns true if terminal supports bracketed paste mode. - """ - @spec supports_bracketed_paste?() :: boolean() - def supports_bracketed_paste? do - get().bracketed_paste - end - - @doc """ - Returns true if terminal supports focus event reporting. - """ - @spec supports_focus_events?() :: boolean() - def supports_focus_events? do - get().focus_events - end - - @doc """ - Returns true if terminal supports Unicode. - """ - @spec supports_unicode?() :: boolean() - def supports_unicode? do - get().unicode - end - - @doc """ - Returns true if terminal supports alternate screen buffer. - """ - @spec supports_alternate_screen?() :: boolean() - def supports_alternate_screen? do - get().alternate_screen - end - - @doc """ - Returns the maximum number of colors supported. - """ - @spec max_colors() :: non_neg_integer() - def max_colors do - get().max_colors - end - - @doc """ - Returns the color mode. - """ - @spec color_mode() :: color_mode() - def color_mode do - get().color_mode - end - - # Private implementation - - defp do_detect do - # Start with VT100 baseline - base = %__MODULE__{ - color_mode: :color_16, - max_colors: 16, - unicode: false, - mouse: false, - bracketed_paste: false, - focus_events: false, - alternate_screen: true, - terminal_type: nil, - terminal_program: nil - } - - base - |> detect_from_term() - |> detect_from_colorterm() - |> detect_from_term_program() - |> detect_unicode() - |> detect_from_terminfo() - |> finalize_capabilities() - end - - defp detect_from_term(caps) do - case System.get_env("TERM") do - nil -> - caps - - term -> - caps = %{caps | terminal_type: term} - detect_term_colors(caps, term) - end - end - - defp detect_term_colors(caps, term) do - # Check for exact matches first - case term do - "linux" -> %{caps | color_mode: :color_16, max_colors: 16} - "dumb" -> %{caps | color_mode: :monochrome, max_colors: 2} - _ -> detect_term_patterns(caps, term) - end - end - - defp detect_term_patterns(caps, term) do - # Check patterns (contains) - pattern_match = - Enum.find(@term_patterns, fn {pattern, _mode, _colors} -> - String.contains?(term, pattern) - end) - - case pattern_match do - {_, mode, colors} -> - update_color_mode(caps, mode, colors) - - nil -> - detect_term_prefixes(caps, term) - end - end - - defp detect_term_prefixes(caps, term) do - # Check prefixes (starts_with) - prefix_match = - Enum.find(@term_prefixes, fn {prefix, _mode, _colors} -> - String.starts_with?(term, prefix) - end) - - case prefix_match do - {_, mode, colors} -> update_color_mode(caps, mode, colors) - nil -> caps - end - end - - defp detect_from_colorterm(caps) do - case System.get_env("COLORTERM") do - nil -> - caps - - colorterm -> - if colorterm in ["truecolor", "24bit"] do - %{caps | color_mode: :true_color, max_colors: 16_777_216} - else - caps - end - end - end - - defp detect_from_term_program(caps) do - case System.get_env("TERM_PROGRAM") do - nil -> - caps - - program -> - caps = %{caps | terminal_program: program} - - cond do - program in @true_color_terminals -> - %{ - caps - | color_mode: :true_color, - max_colors: 16_777_216, - mouse: true, - bracketed_paste: true, - focus_events: true - } - - program in @color_256_terminals -> - caps = update_color_mode(caps, :color_256, 256) - %{caps | mouse: true, bracketed_paste: true} - - true -> - caps - end - end - end - - defp detect_unicode(caps) do - lang = System.get_env("LC_ALL") || System.get_env("LC_CTYPE") || System.get_env("LANG") || "" - - unicode = - String.contains?(String.downcase(lang), "utf-8") or - String.contains?(String.downcase(lang), "utf8") - - %{caps | unicode: unicode} - end - - defp detect_from_terminfo(caps) do - case query_terminfo_colors() do - {:ok, colors} when colors >= 16_777_216 -> - update_color_mode(caps, :true_color, colors) - - {:ok, colors} when colors >= 256 -> - update_color_mode(caps, :color_256, colors) - - {:ok, colors} when colors >= 16 -> - update_color_mode(caps, :color_16, colors) - - {:ok, colors} when colors >= 8 -> - # Only update max_colors, keep existing mode - %{caps | max_colors: max(caps.max_colors, colors)} - - _ -> - caps - end - end - - defp query_terminfo_colors do - case System.cmd("infocmp", ["-1"], stderr_to_stdout: true) do - {output, 0} -> - parse_terminfo_colors(output) - - _ -> - :error - end - rescue - _ -> :error - end - - defp parse_terminfo_colors(output) do - # Look for colors#N or colors=N pattern - case Regex.run(~r/colors[#=](\d+)/, output) do - [_, count] -> - {:ok, String.to_integer(count)} - - nil -> - :error - end - end - - defp finalize_capabilities(caps) do - # Enable features for any terminal with 256+ colors - # as these are typically modern terminals - if caps.max_colors >= 256 do - %{ - caps - | mouse: caps.mouse || true, - bracketed_paste: caps.bracketed_paste || true, - focus_events: caps.focus_events || caps.max_colors >= 16_777_216 - } - else - caps - end - end - - defp update_color_mode(caps, new_mode, new_colors) do - # Only upgrade color mode, never downgrade - current_rank = color_mode_rank(caps.color_mode) - new_rank = color_mode_rank(new_mode) - - if new_rank > current_rank do - %{caps | color_mode: new_mode, max_colors: max(caps.max_colors, new_colors)} - else - %{caps | max_colors: max(caps.max_colors, new_colors)} - end - end - - defp color_mode_rank(:monochrome), do: 0 - defp color_mode_rank(:color_16), do: 1 - defp color_mode_rank(:color_256), do: 2 - defp color_mode_rank(:true_color), do: 3 - - defp ensure_table_exists do - if :ets.whereis(@ets_table) == :undefined do - :ets.new(@ets_table, [:named_table, :public, :set, read_concurrency: true]) - end - end - - defp cache_capabilities(capabilities) do - ensure_table_exists() - :ets.insert(@ets_table, {:capabilities, capabilities}) - end - - defp get_cached do - ensure_table_exists() - - case :ets.lookup(@ets_table, :capabilities) do - [{:capabilities, caps}] -> caps - [] -> nil - end - end -end diff --git a/lib/term_ui/capabilities/fallbacks.ex b/lib/term_ui/capabilities/fallbacks.ex deleted file mode 100644 index b910bee9..00000000 --- a/lib/term_ui/capabilities/fallbacks.ex +++ /dev/null @@ -1,249 +0,0 @@ -defmodule TermUI.Capabilities.Fallbacks do - @moduledoc """ - Graceful degradation utilities for terminal capabilities. - - Provides fallback chains for: - - Colors: true-color → 256-color → 16-color → monochrome - - Characters: Unicode box-drawing → ASCII art - """ - - # Standard 16 ANSI colors as RGB - @ansi_colors %{ - # Black - 0 => {0, 0, 0}, - # Red - 1 => {128, 0, 0}, - # Green - 2 => {0, 128, 0}, - # Yellow - 3 => {128, 128, 0}, - # Blue - 4 => {0, 0, 128}, - # Magenta - 5 => {128, 0, 128}, - # Cyan - 6 => {0, 128, 128}, - # White - 7 => {192, 192, 192}, - # Bright Black - 8 => {128, 128, 128}, - # Bright Red - 9 => {255, 0, 0}, - # Bright Green - 10 => {0, 255, 0}, - # Bright Yellow - 11 => {255, 255, 0}, - # Bright Blue - 12 => {0, 0, 255}, - # Bright Magenta - 13 => {255, 0, 255}, - # Bright Cyan - 14 => {0, 255, 255}, - # Bright White - 15 => {255, 255, 255} - } - - # Box-drawing character fallbacks - @box_drawing_fallbacks %{ - # Single line box drawing - "─" => "-", - "│" => "|", - "┌" => "+", - "┐" => "+", - "└" => "+", - "┘" => "+", - "├" => "+", - "┤" => "+", - "┬" => "+", - "┴" => "+", - "┼" => "+", - # Double line box drawing - "═" => "=", - "║" => "|", - "╔" => "+", - "╗" => "+", - "╚" => "+", - "╝" => "+", - "╠" => "+", - "╣" => "+", - "╦" => "+", - "╩" => "+", - "╬" => "+", - # Rounded corners - "╭" => "+", - "╮" => "+", - "╯" => "+", - "╰" => "+", - # Block elements - "█" => "#", - "▀" => "^", - "▄" => "_", - "▌" => "|", - "▐" => "|", - "░" => ".", - "▒" => ":", - "▓" => "#", - # Arrows - "←" => "<", - "→" => ">", - "↑" => "^", - "↓" => "v", - # Other symbols - "•" => "*", - "·" => ".", - "…" => "...", - "×" => "x", - "÷" => "/", - "≠" => "!=", - "≤" => "<=", - "≥" => ">=", - "✓" => "[x]", - "✗" => "[ ]" - } - - @doc """ - Converts an RGB color to the nearest 256-color palette index. - - Returns an integer 0-255. - """ - @spec rgb_to_256(non_neg_integer(), non_neg_integer(), non_neg_integer()) :: 0..255 - def rgb_to_256(r, g, b) when r in 0..255 and g in 0..255 and b in 0..255 do - # Check grayscale first (232-255) - if grayscale?(r, g, b) do - gray_index = round((r + g + b) / 3 / 255 * 23) - 232 + min(23, gray_index) - else - # Use 6x6x6 color cube (16-231) - r_idx = color_to_cube_index(r) - g_idx = color_to_cube_index(g) - b_idx = color_to_cube_index(b) - 16 + 36 * r_idx + 6 * g_idx + b_idx - end - end - - @doc """ - Converts an RGB color to the nearest 16-color ANSI index. - - Returns an integer 0-15. - """ - @spec rgb_to_16(non_neg_integer(), non_neg_integer(), non_neg_integer()) :: 0..15 - def rgb_to_16(r, g, b) when r in 0..255 and g in 0..255 and b in 0..255 do - {best_index, _distance} = - @ansi_colors - |> Enum.map(fn {index, {ar, ag, ab}} -> - distance = color_distance(r, g, b, ar, ag, ab) - {index, distance} - end) - |> Enum.min_by(fn {_index, distance} -> distance end) - - best_index - end - - @doc """ - Converts a 256-color index to the nearest 16-color ANSI index. - - Returns an integer 0-15. - """ - @spec color_256_to_16(0..255) :: 0..15 - def color_256_to_16(index) when index in 0..15 do - # Already a 16-color index - index - end - - def color_256_to_16(index) when index in 16..231 do - # 6x6x6 color cube - cube_index = index - 16 - r = rem(div(cube_index, 36), 6) * 51 - g = rem(div(cube_index, 6), 6) * 51 - b = rem(cube_index, 6) * 51 - rgb_to_16(r, g, b) - end - - def color_256_to_16(index) when index in 232..255 do - # Grayscale ramp - gray = (index - 232) * 10 + 8 - rgb_to_16(gray, gray, gray) - end - - @doc """ - Converts a Unicode character to its ASCII fallback. - - Returns the original character if no fallback is defined. - """ - @spec unicode_to_ascii(String.t()) :: String.t() - def unicode_to_ascii(char) do - Map.get(@box_drawing_fallbacks, char, char) - end - - @doc """ - Converts a string containing Unicode to ASCII-safe version. - - Replaces all known Unicode characters with their ASCII fallbacks. - """ - @spec string_to_ascii(String.t()) :: String.t() - def string_to_ascii(string) do - string - |> String.graphemes() - |> Enum.map_join(&unicode_to_ascii/1) - end - - @doc """ - Returns the appropriate color based on terminal capabilities. - - Automatically degrades RGB to 256 to 16 based on capability. - """ - @spec degrade_color( - non_neg_integer(), - non_neg_integer(), - non_neg_integer(), - TermUI.Capabilities.color_mode() - ) :: - {:rgb, non_neg_integer(), non_neg_integer(), non_neg_integer()} - | {:index_256, 0..255} - | {:index_16, 0..15} - | :none - def degrade_color(r, g, b, color_mode) do - case color_mode do - :true_color -> - {:rgb, r, g, b} - - :color_256 -> - {:index_256, rgb_to_256(r, g, b)} - - :color_16 -> - {:index_16, rgb_to_16(r, g, b)} - - :monochrome -> - :none - end - end - - # Private helpers - - defp grayscale?(r, g, b) do - # Consider it grayscale if all components are within 8 of each other - max_val = max(r, max(g, b)) - min_val = min(r, min(g, b)) - max_val - min_val <= 8 - end - - defp color_to_cube_index(value) do - # Map 0-255 to 0-5 for the 6x6x6 color cube - cond do - value < 48 -> 0 - value < 115 -> 1 - value < 155 -> 2 - value < 195 -> 3 - value < 235 -> 4 - true -> 5 - end - end - - defp color_distance(r1, g1, b1, r2, g2, b2) do - # Euclidean distance in RGB space - dr = r1 - r2 - dg = g1 - g2 - db = b1 - b2 - dr * dr + dg * dg + db * db - end -end diff --git a/lib/term_ui/renderer/cell.ex b/lib/term_ui/cell.ex similarity index 90% rename from lib/term_ui/renderer/cell.ex rename to lib/term_ui/cell.ex index e67ed4f1..78923e8a 100644 --- a/lib/term_ui/renderer/cell.ex +++ b/lib/term_ui/cell.ex @@ -1,4 +1,4 @@ -defmodule TermUI.Renderer.Cell do +defmodule TermUI.Cell do @moduledoc """ Represents a single cell in the terminal screen buffer. @@ -42,16 +42,25 @@ defmodule TermUI.Renderer.Cell do fg: color(), bg: color(), attrs: MapSet.t(attribute()), - width: 1 | 2, + width: 0 | 1 | 2, wide_placeholder: boolean() } - defstruct char: " ", - fg: :default, - bg: :default, - attrs: MapSet.new(), - width: 1, - wide_placeholder: false + @schema Zoi.struct(__MODULE__, %{ + char: Zoi.string() |> Zoi.default(" "), + fg: Zoi.any() |> Zoi.default(:default), + bg: Zoi.any() |> Zoi.default(:default), + attrs: Zoi.map_set(Zoi.atom()) |> Zoi.default(MapSet.new()), + width: Zoi.enum([0, 1, 2]) |> Zoi.default(1), + wide_placeholder: Zoi.boolean() |> Zoi.default(false) + }) + + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @doc "Returns the Zoi schema for terminal cells." + @spec schema() :: Zoi.schema() + def schema, do: @schema @valid_attributes [:bold, :dim, :italic, :underline, :blink, :reverse, :hidden, :strikethrough] @@ -140,7 +149,7 @@ defmodule TermUI.Renderer.Cell do # Calculate display width using DisplayWidth module defp calculate_width(char) do - alias TermUI.Renderer.DisplayWidth + alias TermUI.DisplayWidth width = DisplayWidth.width(char) # Clamp to 1 or 2 for cell width cond do @@ -220,7 +229,8 @@ defmodule TermUI.Renderer.Cell do """ @spec put_char(t(), String.t()) :: t() def put_char(%__MODULE__{} = cell, char) when is_binary(char) do - %{cell | char: sanitize_char(char)} + char = sanitize_char(char) + %{cell | char: char, width: calculate_width(char), wide_placeholder: false} end @doc """ @@ -304,14 +314,16 @@ defmodule TermUI.Renderer.Cell do # Sanitize character to prevent escape sequence injection # Removes control characters (0x00-0x1F except space, 0x7F) and escape sequences defp sanitize_char(char) when is_binary(char) do - char - # Strip ANSI escape sequences first - |> strip_escape_sequences() - # Remove control characters while preserving valid Unicode - |> filter_control_chars() - |> case do - "" -> " " - sanitized -> sanitized + sanitized = + char + # Strip ANSI escape sequences first + |> strip_escape_sequences() + # Remove control characters while preserving valid Unicode + |> filter_control_chars() + + case String.graphemes(sanitized) do + [grapheme | _rest] -> grapheme + [] -> " " end end diff --git a/lib/term_ui/character_set.ex b/lib/term_ui/character_set.ex index 9e54d3b1..9f6d39b1 100644 --- a/lib/term_ui/character_set.ex +++ b/lib/term_ui/character_set.ex @@ -295,8 +295,7 @@ defmodule TermUI.CharacterSet do @doc """ Returns the currently configured character set type. - Reads from persistent_term via PersistentTerms (set by Runtime), - falling back to application config. Defaults to `:unicode` if neither is configured. + Reads application configuration and defaults to `:unicode`. ## Returns @@ -307,13 +306,18 @@ defmodule TermUI.CharacterSet do iex> TermUI.CharacterSet.current() :unicode - # After Runtime sets it based on capabilities - iex> :persistent_term.put(:term_ui_character_set, :ascii) + iex> Application.put_env(:term_ui, :character_set, :ascii) iex> TermUI.CharacterSet.current() :ascii + iex> Application.delete_env(:term_ui, :character_set) """ @spec current() :: charset() - def current, do: TermUI.PersistentTerms.character_set() + def current do + case Application.get_env(:term_ui, :character_set, :unicode) do + character_set when character_set in [:unicode, :ascii] -> character_set + _invalid -> :unicode + end + end @doc """ Returns the current character set as a map. diff --git a/lib/term_ui/clipboard.ex b/lib/term_ui/clipboard.ex index d144e8f5..5908e881 100644 --- a/lib/term_ui/clipboard.ex +++ b/lib/term_ui/clipboard.ex @@ -1,247 +1,101 @@ defmodule TermUI.Clipboard do @moduledoc """ - Clipboard integration for TermUI applications. + Bounded OSC 52 clipboard commands. - Provides clipboard writing via OSC 52 escape sequences and - paste event handling. Clipboard operations work across terminals - that support these features. + `copy/2` and `clear/1` return `TermUI.Command` data. The runtime sends the + operation to its backend owner, so clipboard output cannot race with frame + output. This module never writes directly to an IO device. The result mapper + receives `:ok` or `{:error, reason}`. - ## Usage - - # Write to clipboard - Clipboard.write("text to copy") - - # Check OSC 52 support - if Clipboard.osc52_supported?() do - Clipboard.write(content) - end - - # Enable bracketed paste mode - IO.write(Clipboard.bracketed_paste_on()) + Clipboard content has a default 100,000-byte limit. Set `:max_bytes` to a + positive integer to change the limit. Set `:target` to `:clipboard`, + `:primary`, or `:secondary`. """ - # OSC 52 clipboard sequence - # Format: ESC ] 52 ; ; ST - # Target: c = clipboard, p = primary selection + alias TermUI.Clipboard.Operation + alias TermUI.Command + @osc52_prefix "\e]52;" @osc52_suffix "\e\\" - - # Bracketed paste mode - @bracketed_paste_on "\e[?2004h" - @bracketed_paste_off "\e[?2004l" - - # Paste markers - @paste_start "\e[200~" - @paste_end "\e[201~" - - @doc """ - Returns escape sequence to enable bracketed paste mode. - """ - @spec bracketed_paste_on() :: String.t() - def bracketed_paste_on, do: @bracketed_paste_on - - @doc """ - Returns escape sequence to disable bracketed paste mode. - """ - @spec bracketed_paste_off() :: String.t() - def bracketed_paste_off, do: @bracketed_paste_off - - @doc """ - Returns the paste start marker sequence. - """ - @spec paste_start_marker() :: String.t() - def paste_start_marker, do: @paste_start - - @doc """ - Returns the paste end marker sequence. - """ - @spec paste_end_marker() :: String.t() - def paste_end_marker, do: @paste_end - - @doc """ - Generates OSC 52 escape sequence to write to clipboard. - - Returns the escape sequence string that should be written to - the terminal to set the clipboard content. - - ## Options - - - `:target` - Clipboard target: `:clipboard` (default) or `:primary` - - ## Examples - - iex> Clipboard.write_sequence("hello") - "\\e]52;c;aGVsbG8=\\e\\\\" - - iex> Clipboard.write_sequence("test", target: :primary) - "\\e]52;p;dGVzdA==\\e\\\\" - """ - @spec write_sequence(String.t(), keyword()) :: String.t() - def write_sequence(content, opts \\ []) do - target = Keyword.get(opts, :target, :clipboard) - target_char = target_to_char(target) - encoded = Base.encode64(content) - - @osc52_prefix <> target_char <> ";" <> encoded <> @osc52_suffix + @default_max_bytes 100_000 + @targets [:clipboard, :primary, :secondary] + + @doc "Creates a bounded clipboard write operation." + @spec operation(term(), keyword()) :: Operation.t() + def operation(content, opts \\ []) do + %Operation{ + kind: :write, + target: target!(opts), + content: to_string(content), + max_bytes: max_bytes!(opts) + } end - @doc """ - Writes content to the system clipboard via OSC 52. - - This writes the escape sequence directly to the terminal. - Returns `:ok` on success. - - ## Options - - - `:target` - Clipboard target: `:clipboard` (default) or `:primary` - """ - @spec write(String.t(), keyword()) :: :ok - def write(content, opts \\ []) do - sequence = write_sequence(content, opts) - IO.write(sequence) - :ok + @doc "Creates a clipboard clear operation." + @spec clear_operation(keyword()) :: Operation.t() + def clear_operation(opts \\ []) do + %Operation{ + kind: :clear, + target: target!(opts), + content: "", + max_bytes: max_bytes!(opts) + } end - @doc """ - Checks if OSC 52 clipboard is likely supported. - - This is a heuristic check based on terminal type. Some terminals - support OSC 52 but don't advertise it; others advertise but block it. - - Known supporting terminals: - - xterm (with allowWindowOps) - - Alacritty - - Kitty - - WezTerm - - iTerm2 - - foot - """ - @spec osc52_supported?() :: boolean() - def osc52_supported? do - term = System.get_env("TERM", "") - term_program = System.get_env("TERM_PROGRAM", "") - - cond do - # Known good terminals - String.contains?(term_program, "iTerm") -> true - String.contains?(term_program, "Alacritty") -> true - String.contains?(term_program, "WezTerm") -> true - System.get_env("KITTY_WINDOW_ID") != nil -> true - # xterm and derivatives often support it - String.starts_with?(term, "xterm") -> true - # foot terminal - term == "foot" or term == "foot-extra" -> true - # Conservative default - assume not supported - true -> false - end + @doc "Creates a runtime command that copies text through the active backend." + @spec copy(term(), keyword()) :: Command.clipboard_command() + def copy(content, opts \\ []) do + {mapper, operation_opts} = Keyword.pop(opts, :on_result, &{:clipboard_result, &1}) + Command.clipboard(operation(content, operation_opts), mapper) end - @doc """ - Generates OSC 52 sequence to clear the clipboard. - """ - @spec clear_sequence(keyword()) :: String.t() - def clear_sequence(opts \\ []) do - target = Keyword.get(opts, :target, :clipboard) - target_char = target_to_char(target) - - # Empty base64 clears the selection - @osc52_prefix <> target_char <> ";" <> @osc52_suffix - end - - @doc """ - Clears the system clipboard via OSC 52. - """ - @spec clear(keyword()) :: :ok + @doc "Creates a runtime command that clears a terminal clipboard target." + @spec clear(keyword()) :: Command.clipboard_command() def clear(opts \\ []) do - sequence = clear_sequence(opts) - IO.write(sequence) - :ok + {mapper, operation_opts} = Keyword.pop(opts, :on_result, &{:clipboard_result, &1}) + Command.clipboard(clear_operation(operation_opts), mapper) end - # Private functions - - defp target_to_char(:clipboard), do: "c" - defp target_to_char(:primary), do: "p" - defp target_to_char(:secondary), do: "s" - defp target_to_char(target) when is_binary(target), do: target -end + @doc "Encodes an OSC 52 operation without performing IO." + @spec sequence(Operation.t()) :: {:ok, String.t()} | {:error, term()} + def sequence(%Operation{kind: kind, content: content, max_bytes: maximum} = operation) do + size = byte_size(content) -defmodule TermUI.Clipboard.PasteAccumulator do - @moduledoc """ - Accumulates bracketed paste content. - - Handles the state machine for collecting paste content between - paste start and end markers. Supports timeout for incomplete pastes. - """ - - @type t :: %__MODULE__{ - accumulating: boolean(), - content: String.t(), - started_at: integer() | nil - } - - defstruct accumulating: false, - content: "", - started_at: nil - - @doc """ - Creates a new paste accumulator. - """ - @spec new() :: t() - def new do - %__MODULE__{} + if size > maximum do + {:error, {:clipboard_too_large, size, maximum}} + else + payload = if kind == :clear, do: "", else: Base.encode64(content) + {:ok, @osc52_prefix <> target_code(operation.target) <> ";" <> payload <> @osc52_suffix} + end end - @doc """ - Starts accumulating paste content. - """ - @spec start(t()) :: t() - def start(%__MODULE__{} = acc) do - %{acc | accumulating: true, content: "", started_at: System.monotonic_time(:millisecond)} - end + @doc "Returns true when the current terminal is likely to support OSC 52." + @spec osc52_supported?() :: boolean() + def osc52_supported? do + term = System.get_env("TERM", "") + program = System.get_env("TERM_PROGRAM", "") - @doc """ - Adds content to the accumulator. - """ - @spec add(t(), String.t()) :: t() - def add(%__MODULE__{accumulating: true} = acc, content) do - %{acc | content: acc.content <> content} + String.contains?(program, ["iTerm", "Alacritty", "WezTerm", "Apple_Terminal"]) or + System.get_env("KITTY_WINDOW_ID") != nil or String.starts_with?(term, "xterm") or + term in ["foot", "foot-extra"] end - def add(%__MODULE__{} = acc, _content), do: acc + defp target!(opts) do + target = Keyword.get(opts, :target, :clipboard) - @doc """ - Completes accumulation and returns the content. - """ - @spec complete(t()) :: {String.t(), t()} - def complete(%__MODULE__{accumulating: true, content: content} = _acc) do - {content, new()} + if target in @targets, + do: target, + else: raise(ArgumentError, "clipboard target must be :clipboard, :primary, or :secondary") end - def complete(%__MODULE__{} = acc), do: {"", acc} - - @doc """ - Checks if currently accumulating. - """ - @spec accumulating?(t()) :: boolean() - def accumulating?(%__MODULE__{accumulating: acc}), do: acc - - @doc """ - Checks if paste has timed out. - - Default timeout is 5000ms. - """ - @spec timed_out?(t(), integer()) :: boolean() - def timed_out?(%__MODULE__{accumulating: false}, _timeout), do: false - - def timed_out?(%__MODULE__{started_at: started_at}, timeout) do - now = System.monotonic_time(:millisecond) - now - started_at >= timeout + defp max_bytes!(opts) do + case Keyword.get(opts, :max_bytes, @default_max_bytes) do + maximum when is_integer(maximum) and maximum > 0 -> maximum + other -> raise ArgumentError, "clipboard max_bytes must be positive, got: #{inspect(other)}" + end end - @doc """ - Resets the accumulator, discarding any partial content. - """ - @spec reset(t()) :: t() - def reset(%__MODULE__{} = _acc), do: new() + defp target_code(:clipboard), do: "c" + defp target_code(:primary), do: "p" + defp target_code(:secondary), do: "s" end diff --git a/lib/term_ui/clipboard/operation.ex b/lib/term_ui/clipboard/operation.ex new file mode 100644 index 00000000..92b7e47c --- /dev/null +++ b/lib/term_ui/clipboard/operation.ex @@ -0,0 +1,25 @@ +defmodule TermUI.Clipboard.Operation do + @moduledoc "Clipboard operation data for one terminal backend." + + @type target :: :clipboard | :primary | :secondary + @type t :: %__MODULE__{ + kind: :write | :clear, + target: target(), + content: String.t(), + max_bytes: pos_integer() + } + + @schema Zoi.struct(__MODULE__, %{ + kind: Zoi.enum([:write, :clear]), + target: Zoi.enum([:clipboard, :primary, :secondary]) |> Zoi.default(:clipboard), + content: Zoi.string() |> Zoi.default(""), + max_bytes: Zoi.integer() |> Zoi.positive() |> Zoi.default(100_000) + }) + + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @doc "Returns the Zoi schema for clipboard operations." + @spec schema() :: Zoi.schema() + def schema, do: @schema +end diff --git a/lib/term_ui/clipboard/selection.ex b/lib/term_ui/clipboard/selection.ex deleted file mode 100644 index d808a13a..00000000 --- a/lib/term_ui/clipboard/selection.ex +++ /dev/null @@ -1,329 +0,0 @@ -defmodule TermUI.Clipboard.Selection do - @moduledoc """ - Selection state management for clipboard operations. - - Tracks text selection with start and end positions, supporting - selection expansion with Shift+arrow keys and clearing on - navigation without Shift. - - ## Usage - - # Create selection - selection = Selection.new() - - # Start selection at cursor - selection = Selection.start(selection, 5) - - # Extend selection - selection = Selection.extend(selection, 10) - - # Get selected range - {start, finish} = Selection.range(selection) - - # Extract content - selected_text = Selection.extract(selection, "Hello World") - """ - - # Dialyzer: Functions return specific struct types - @dialyzer {:nowarn_function, new: 0} - - @type t :: %__MODULE__{ - start_pos: integer() | nil, - end_pos: integer() | nil, - anchor: integer() | nil, - active: boolean() - } - - defstruct start_pos: nil, - end_pos: nil, - anchor: nil, - active: false - - @doc """ - Creates a new empty selection. - """ - @spec new() :: t() - def new do - %__MODULE__{} - end - - @doc """ - Starts a new selection at the given position. - - This sets the anchor point for the selection. - """ - @spec start(t(), integer()) :: t() - def start(%__MODULE__{} = _selection, position) do - %__MODULE__{ - start_pos: position, - end_pos: position, - anchor: position, - active: true - } - end - - @doc """ - Extends the selection to a new position. - - The selection extends from the anchor to the new position. - """ - @spec extend(t(), integer()) :: t() - def extend(%__MODULE__{active: false} = selection, position) do - start(selection, position) - end - - def extend(%__MODULE__{anchor: anchor} = selection, position) do - {start_pos, end_pos} = if position < anchor, do: {position, anchor}, else: {anchor, position} - - %{selection | start_pos: start_pos, end_pos: end_pos} - end - - @doc """ - Clears the selection. - """ - @spec clear(t()) :: t() - def clear(%__MODULE__{} = _selection) do - new() - end - - @doc """ - Checks if there is an active selection. - """ - @spec active?(t()) :: boolean() - def active?(%__MODULE__{active: active}), do: active - - @doc """ - Checks if the selection is empty (start equals end). - """ - @spec empty?(t()) :: boolean() - def empty?(%__MODULE__{active: false}), do: true - def empty?(%__MODULE__{start_pos: start, end_pos: finish}), do: start == finish - - @doc """ - Returns the selection range as {start, end}. - - Returns `nil` if no selection is active. - """ - @spec range(t()) :: {integer(), integer()} | nil - def range(%__MODULE__{active: false}), do: nil - def range(%__MODULE__{start_pos: start, end_pos: finish}), do: {start, finish} - - @doc """ - Returns the length of the selection. - """ - @spec length(t()) :: integer() - def length(%__MODULE__{active: false}), do: 0 - def length(%__MODULE__{start_pos: start, end_pos: finish}), do: finish - start - - @doc """ - Extracts selected content from a string. - - Returns empty string if no selection is active. - """ - @spec extract(t(), String.t()) :: String.t() - def extract(%__MODULE__{active: false}, _text), do: "" - - def extract(%__MODULE__{start_pos: start, end_pos: finish}, text) do - String.slice(text, start, finish - start) - end - - @doc """ - Checks if a position is within the selection. - """ - @spec contains?(t(), integer()) :: boolean() - def contains?(%__MODULE__{active: false}, _position), do: false - - def contains?(%__MODULE__{start_pos: start, end_pos: finish}, position) do - position >= start and position < finish - end - - @doc """ - Moves the selection by a delta. - - Both start and end positions are adjusted. - """ - @spec move(t(), integer()) :: t() - def move(%__MODULE__{active: false} = selection, _delta), do: selection - - def move(%__MODULE__{start_pos: start, end_pos: finish, anchor: anchor} = selection, delta) do - %{selection | start_pos: start + delta, end_pos: finish + delta, anchor: anchor + delta} - end - - @doc """ - Expands the selection in a direction. - - Direction can be `:left`, `:right`, `:word_left`, `:word_right`, - `:line_start`, `:line_end`, `:all`. - """ - @spec expand(t(), atom(), String.t(), integer()) :: t() - def expand(%__MODULE__{} = selection, direction, text, cursor_pos) do - new_pos = calculate_expansion(direction, text, cursor_pos) - - if active?(selection) do - extend(selection, new_pos) - else - selection - |> start(cursor_pos) - |> extend(new_pos) - end - end - - @doc """ - Selects all text. - """ - @spec select_all(t(), String.t()) :: t() - def select_all(%__MODULE__{} = _selection, text) do - len = String.length(text) - - %__MODULE__{ - start_pos: 0, - end_pos: len, - anchor: 0, - active: true - } - end - - @doc """ - Selects a word at the given position. - """ - @spec select_word(t(), String.t(), integer()) :: t() - def select_word(%__MODULE__{} = _selection, text, position) do - {word_start, word_end} = find_word_bounds(text, position) - - %__MODULE__{ - start_pos: word_start, - end_pos: word_end, - anchor: word_start, - active: true - } - end - - # Private functions - - defp calculate_expansion(:left, _text, cursor_pos) do - max(0, cursor_pos - 1) - end - - defp calculate_expansion(:right, text, cursor_pos) do - min(String.length(text), cursor_pos + 1) - end - - defp calculate_expansion(:word_left, text, cursor_pos) do - find_word_boundary_left(text, cursor_pos) - end - - defp calculate_expansion(:word_right, text, cursor_pos) do - find_word_boundary_right(text, cursor_pos) - end - - defp calculate_expansion(:line_start, _text, _cursor_pos) do - 0 - end - - defp calculate_expansion(:line_end, text, _cursor_pos) do - String.length(text) - end - - defp calculate_expansion(:all, text, _cursor_pos) do - String.length(text) - end - - defp find_word_boundary_left(text, position) do - text - |> String.slice(0, position) - |> String.reverse() - |> find_word_start() - |> then(&(position - &1)) - end - - defp find_word_boundary_right(text, position) do - text - |> String.slice(position, String.length(text) - position) - |> find_word_end() - |> then(&(position + &1)) - end - - defp find_word_start(reversed_text) do - # Skip whitespace, then find word characters - reversed_text - |> String.graphemes() - |> Enum.reduce_while({0, :skip_space}, fn char, {count, state} -> - cond do - state == :skip_space and whitespace?(char) -> - {:cont, {count + 1, :skip_space}} - - state == :skip_space and word_char?(char) -> - {:cont, {count + 1, :in_word}} - - state == :in_word and word_char?(char) -> - {:cont, {count + 1, :in_word}} - - true -> - {:halt, {count, :done}} - end - end) - |> elem(0) - end - - defp find_word_end(text) do - text - |> String.graphemes() - |> Enum.reduce_while({0, :skip_space}, fn char, {count, state} -> - cond do - state == :skip_space and whitespace?(char) -> - {:cont, {count + 1, :skip_space}} - - state == :skip_space and word_char?(char) -> - {:cont, {count + 1, :in_word}} - - state == :in_word and word_char?(char) -> - {:cont, {count + 1, :in_word}} - - true -> - {:halt, {count, :done}} - end - end) - |> elem(0) - end - - defp find_word_bounds(text, position) do - # Find start of word - word_start = - text - |> String.slice(0, position) - |> String.reverse() - |> then(fn prefix -> - len = - prefix - |> String.graphemes() - |> Enum.take_while(&word_char?/1) - |> Kernel.length() - - position - len - end) - - # Find end of word - word_end = - text - |> String.slice(position, String.length(text) - position) - |> then(fn suffix -> - len = - suffix - |> String.graphemes() - |> Enum.take_while(&word_char?/1) - |> Kernel.length() - - position + len - end) - - {word_start, word_end} - end - - defp word_char?(char) do - String.match?(char, ~r/\w/) - end - - defp whitespace?(char) do - String.match?(char, ~r/\s/) - end -end diff --git a/lib/term_ui/command.ex b/lib/term_ui/command.ex index 5878dcd6..a1a25cbf 100644 --- a/lib/term_ui/command.ex +++ b/lib/term_ui/command.ex @@ -1,231 +1,81 @@ defmodule TermUI.Command do @moduledoc """ - Commands represent side effects to be performed by the runtime. + Data that asks the runtime to do work outside an Elm update. - Commands are data describing effects - they don't execute immediately. - The runtime interprets commands and performs the actual effects, - sending result messages back to components. - - ## Command Types - - - `:timer` - Deliver message after delay - - `:interval` - Deliver repeated messages at interval - - `:file_read` - Read file contents - - `:send_after` - Send message to component after delay - - `:quit` - Request application shutdown - - `:none` - No-op command (useful for conditional commands) - - ## Usage - - # In component update function - def update(:start_timer, state) do - cmd = Command.timer(1000, :timer_fired) - {%{state | timer_active: true}, [cmd]} - end - - def update(:timer_fired, state) do - {%{state | timer_active: false, count: state.count + 1}, []} - end + Commands do not contain component identifiers. A runtime delivers command + results to its one application state. """ - # Dialyzer: Command constructors return specific struct types with known - # type: atoms, but the public spec uses the general t() type for API clarity. - @dialyzer {:nowarn_function, - timer: 2, interval: 2, file_read: 2, send_after: 3, quit: 1, none: 0, valid?: 1} - - @type t :: %__MODULE__{ - id: reference() | nil, - type: atom(), - payload: term(), - on_result: term(), - timeout: pos_integer() | :infinity + @type kind :: :message | :send | :timer | :async | :clipboard | :shutdown + @type message_command :: %__MODULE__{kind: :message, value: term()} + @type send_command :: %__MODULE__{kind: :send, value: {pid(), term()}} + @type timer_command :: %__MODULE__{kind: :timer, value: {non_neg_integer(), term()}} + @type async_result :: {:ok, term()} | {:error, term()} + @type async_command :: %__MODULE__{ + kind: :async, + value: {(-> term()), (async_result() -> term())} } - - @type command_type :: :timer | :interval | :file_read | :send_after | :quit | :none - - defstruct [ - :id, - :type, - :payload, - :on_result, - timeout: :infinity - ] - - @doc """ - Creates a timer command that delivers a message after delay. - - ## Examples - - Command.timer(1000, :timer_done) - Command.timer(500, {:tick, 1}) - """ - @spec timer(non_neg_integer(), term()) :: t() - def timer(delay_ms, on_result) when is_integer(delay_ms) and delay_ms >= 0 do - %__MODULE__{ - type: :timer, - payload: delay_ms, - on_result: on_result - } - end - - @doc """ - Creates an interval command that delivers repeated messages. - - The interval continues until cancelled. Each tick delivers - the on_result message. - - ## Examples - - Command.interval(100, :tick) - """ - @spec interval(pos_integer(), term()) :: t() - def interval(interval_ms, on_result) when is_integer(interval_ms) and interval_ms > 0 do - %__MODULE__{ - type: :interval, - payload: interval_ms, - on_result: on_result - } - end - - @doc """ - Creates a file read command. - - Returns `{:ok, content}` or `{:error, reason}` wrapped in the on_result message. - - ## Examples - - Command.file_read("/path/to/file", :file_loaded) - # Results in: {:file_loaded, {:ok, "contents"}} - # or: {:file_loaded, {:error, :enoent}} - """ - @spec file_read(Path.t(), term()) :: t() - def file_read(path, on_result) when is_binary(path) do - %__MODULE__{ - type: :file_read, - payload: path, - on_result: on_result - } - end - - @doc """ - Creates a send_after command that sends a message to a component after delay. - - Unlike timer which sends to the originating component, send_after - can target any component. - - ## Examples - - Command.send_after(:other_component, :wake_up, 1000) - """ - @spec send_after(atom(), term(), pos_integer()) :: t() - def send_after(component_id, message, delay_ms) - when is_atom(component_id) and is_integer(delay_ms) and delay_ms > 0 do - %__MODULE__{ - type: :send_after, - payload: {component_id, message, delay_ms}, - on_result: :send_after_complete - } - end - - @doc """ - Creates a quit command to request application shutdown. - - The runtime will initiate graceful shutdown, cleaning up all resources - and restoring the terminal to its original state. - - ## Examples - - # Simple quit - Command.quit() - - # Quit with reason - Command.quit(:normal) - Command.quit(:user_requested) - """ - @spec quit(term()) :: t() - def quit(reason \\ :normal) do - %__MODULE__{ - type: :quit, - payload: reason, - on_result: nil - } - end - - @doc """ - Creates a no-op command. - - Useful for conditional commands where you might not need an effect. - - ## Examples - - cmd = if should_fetch?, do: Command.timer(100, :fetch), else: Command.none() - """ - @spec none() :: t() - def none do - %__MODULE__{ - type: :none, - payload: nil, - on_result: nil - } - end - - @doc """ - Sets a timeout for command execution. - - If the command takes longer than the timeout, it's cancelled - and an error message is sent. - - ## Examples - - Command.file_read(path, :loaded) - |> Command.with_timeout(5000) - """ - @spec with_timeout(t(), pos_integer()) :: t() - def with_timeout(%__MODULE__{} = command, timeout_ms) - when is_integer(timeout_ms) and timeout_ms > 0 do - %{command | timeout: timeout_ms} - end - - @doc """ - Validates a command structure. - - Returns `:ok` if valid, `{:error, reason}` otherwise. - """ - @spec validate(t()) :: :ok | {:error, term()} - def validate(%__MODULE__{type: :none}), do: :ok - - def validate(%__MODULE__{type: :timer, payload: delay}) when is_integer(delay) and delay >= 0, - do: :ok - - def validate(%__MODULE__{type: :interval, payload: interval}) - when is_integer(interval) and interval > 0, - do: :ok - - def validate(%__MODULE__{type: :file_read, payload: path}) when is_binary(path), do: :ok - - def validate(%__MODULE__{type: :send_after, payload: {id, _msg, delay}}) - when is_atom(id) and is_integer(delay) and delay > 0, - do: :ok - - def validate(%__MODULE__{type: :quit}), do: :ok - - def validate(%__MODULE__{type: type, payload: payload}) do - {:error, {:invalid_command, type, payload}} - end - - def validate(_), do: {:error, :not_a_command} + @type clipboard_command :: %__MODULE__{ + kind: :clipboard, + value: {TermUI.Clipboard.Operation.t(), (term() -> term())} + } + @type shutdown_command :: %__MODULE__{kind: :shutdown, value: term()} + + @type t :: + message_command() + | send_command() + | timer_command() + | async_command() + | clipboard_command() + | shutdown_command() + + @schema Zoi.struct(__MODULE__, %{ + kind: Zoi.enum([:message, :send, :timer, :async, :clipboard, :shutdown]), + value: Zoi.any() + }) + + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @doc "Returns the Zoi schema for runtime commands." + @spec schema() :: Zoi.schema() + def schema, do: @schema + + @doc "Delivers a message to the application on the next runtime turn." + @spec message(term()) :: message_command() + def message(message), do: %__MODULE__{kind: :message, value: message} + + @doc "Sends a message to another process." + @spec send(pid(), term()) :: send_command() + def send(pid, message) when is_pid(pid), + do: %__MODULE__{kind: :send, value: {pid, message}} + + @doc "Delivers a message to the application after a delay." + @spec timer(non_neg_integer(), term()) :: timer_command() + def timer(milliseconds, message) when is_integer(milliseconds) and milliseconds >= 0, + do: %__MODULE__{kind: :timer, value: {milliseconds, message}} @doc """ - Checks if a term is a valid command. - """ - @spec valid?(term()) :: boolean() - def valid?(term), do: validate(term) == :ok + Runs a function and maps one runtime-produced result to an application message. - @doc """ - Assigns a unique ID to a command for tracking. + The function can return any term. The runtime wraps a normal return value as + `{:ok, value}` and wraps a raised, thrown, or exited function as + `{:error, reason}`. The mapper always receives this one outer result tag. A + function return such as `{:ok, value}` therefore reaches the mapper as + `{:ok, {:ok, value}}`. """ - @spec assign_id(t()) :: t() - def assign_id(%__MODULE__{} = command) do - %{command | id: make_ref()} - end + @spec async((-> term()), (async_result() -> term())) :: async_command() + def async(function, on_result \\ &{:async_result, &1}) + when is_function(function, 0) and is_function(on_result, 1), + do: %__MODULE__{kind: :async, value: {function, on_result}} + + @doc "Requests a serialized clipboard operation and maps its `:ok` or error result to a message." + @spec clipboard(TermUI.Clipboard.Operation.t(), (term() -> term())) :: clipboard_command() + def clipboard(%TermUI.Clipboard.Operation{} = operation, on_result \\ &{:clipboard_result, &1}) + when is_function(on_result, 1), + do: %__MODULE__{kind: :clipboard, value: {operation, on_result}} + + @doc "Requests a final render and runtime shutdown." + @spec shutdown(term()) :: shutdown_command() + def shutdown(reason \\ :normal), do: %__MODULE__{kind: :shutdown, value: reason} end diff --git a/lib/term_ui/command/executor.ex b/lib/term_ui/command/executor.ex deleted file mode 100644 index fb5bbe08..00000000 --- a/lib/term_ui/command/executor.ex +++ /dev/null @@ -1,362 +0,0 @@ -defmodule TermUI.Command.Executor do - @moduledoc """ - Executes commands asynchronously under a Task.Supervisor. - - The executor runs commands in isolated tasks, preventing failures - from crashing the runtime. Results are sent back as messages to - the originating component. - - ## Usage - - # Start the executor (usually in application supervision tree) - {:ok, executor} = Executor.start_link() - - # Execute a command - {:ok, command_id} = Executor.execute(executor, command, runtime_pid, component_id) - - # Cancel a running command - :ok = Executor.cancel(executor, command_id) - """ - - use GenServer - - alias TermUI.Command - - # Dialyzer: Functions with unmatched return values in side-effect calls - @dialyzer {:nowarn_function, execute_command: 4, handle_call: 3, handle_info: 2} - - @type t :: pid() - - # Default max concurrent commands - @default_max_concurrent 100 - - # --- Public API --- - - @doc """ - Starts the command executor. - - ## Options - - - `:name` - GenServer name (optional) - - `:max_concurrent` - Maximum concurrent commands (default: 100) - """ - @spec start_link(keyword()) :: GenServer.on_start() - def start_link(opts \\ []) do - {name, opts} = Keyword.pop(opts, :name) - - if name do - GenServer.start_link(__MODULE__, opts, name: name) - else - GenServer.start_link(__MODULE__, opts) - end - end - - @doc """ - Executes a command asynchronously. - - Returns the command ID that can be used for cancellation. - Results are sent to the runtime as `{:command_result, component_id, command_id, result}`. - """ - @spec execute(t(), Command.t(), pid(), atom()) :: {:ok, reference()} | {:error, term()} - def execute(executor, %Command{} = command, runtime_pid, component_id) do - GenServer.call(executor, {:execute, command, runtime_pid, component_id}) - end - - @doc """ - Cancels a running command by ID. - """ - @spec cancel(t(), reference()) :: :ok | {:error, :not_found} - def cancel(executor, command_id) do - GenServer.call(executor, {:cancel, command_id}) - end - - @doc """ - Cancels all commands for a component. - - Used when a component unmounts. - """ - @spec cancel_all_for_component(t(), atom()) :: :ok - def cancel_all_for_component(executor, component_id) do - GenServer.call(executor, {:cancel_all_for_component, component_id}) - end - - @doc """ - Returns the number of currently running commands. - """ - @spec running_count(t()) :: non_neg_integer() - def running_count(executor) do - GenServer.call(executor, :running_count) - end - - # --- GenServer Callbacks --- - - @impl true - def init(opts) do - max_concurrent = Keyword.get(opts, :max_concurrent, @default_max_concurrent) - - # Start Task.Supervisor for command execution - {:ok, task_sup} = Task.Supervisor.start_link() - - state = %{ - task_supervisor: task_sup, - running: %{}, - intervals: %{}, - max_concurrent: max_concurrent - } - - {:ok, state} - end - - @impl true - def handle_call({:execute, command, runtime_pid, component_id}, _from, state) do - # Check concurrent limit - if map_size(state.running) >= state.max_concurrent do - {:reply, {:error, :max_concurrent_reached}, state} - else - # Assign ID if not already assigned - command = if command.id, do: command, else: Command.assign_id(command) - - case execute_command(command, runtime_pid, component_id, state) do - {:ok, new_state} -> - {:reply, {:ok, command.id}, new_state} - - {:error, reason} -> - {:reply, {:error, reason}, state} - end - end - end - - @impl true - def handle_call({:cancel, command_id}, _from, state) do - case Map.get(state.running, command_id) do - nil -> - # Check intervals - case Map.get(state.intervals, command_id) do - nil -> - {:reply, {:error, :not_found}, state} - - %{timer_ref: timer_ref} -> - Process.cancel_timer(timer_ref) - intervals = Map.delete(state.intervals, command_id) - {:reply, :ok, %{state | intervals: intervals}} - end - - info -> - Task.Supervisor.terminate_child(state.task_supervisor, info.task.pid) - running = Map.delete(state.running, command_id) - {:reply, :ok, %{state | running: running}} - end - end - - @impl true - def handle_call({:cancel_all_for_component, component_id}, _from, state) do - # Cancel all running tasks for the component - {to_cancel, to_keep} = - Enum.split_with(state.running, fn {_id, info} -> - info.component_id == component_id - end) - - Enum.each(to_cancel, fn {_id, info} -> - Task.Supervisor.terminate_child(state.task_supervisor, info.task.pid) - end) - - # Cancel all intervals for the component - {intervals_to_cancel, intervals_to_keep} = - Enum.split_with(state.intervals, fn {_id, info} -> - is_map(info) and info.component_id == component_id - end) - - Enum.each(intervals_to_cancel, fn {_id, info} -> - if is_map(info), do: Process.cancel_timer(info.timer_ref) - end) - - state = %{ - state - | running: Map.new(to_keep), - intervals: Map.new(intervals_to_keep) - } - - {:reply, :ok, state} - end - - @impl true - def handle_call(:running_count, _from, state) do - {:reply, map_size(state.running), state} - end - - @impl true - def handle_info({ref, result}, state) when is_reference(ref) do - # Task completed successfully - case find_by_task_ref(state.running, ref) do - {command_id, info} -> - # Demonitor and flush - Process.demonitor(ref, [:flush]) - - # Send result to runtime - send_result(info.runtime_pid, info.component_id, command_id, result) - - running = Map.delete(state.running, command_id) - {:noreply, %{state | running: running}} - - nil -> - {:noreply, state} - end - end - - @impl true - def handle_info({:DOWN, ref, :process, _pid, reason}, state) do - # Task crashed - case find_by_task_ref(state.running, ref) do - {command_id, info} -> - # Send error to runtime - send_result(info.runtime_pid, info.component_id, command_id, {:error, reason}) - - running = Map.delete(state.running, command_id) - {:noreply, %{state | running: running}} - - nil -> - {:noreply, state} - end - end - - @impl true - def handle_info( - {:interval_tick, command_id, runtime_pid, component_id, message, interval_ms}, - state - ) do - # Deliver interval message - send_result(runtime_pid, component_id, command_id, message) - - # Schedule next tick - timer_ref = - Process.send_after( - self(), - {:interval_tick, command_id, runtime_pid, component_id, message, interval_ms}, - interval_ms - ) - - intervals = - Map.put(state.intervals, command_id, %{ - timer_ref: timer_ref, - component_id: component_id - }) - - {:noreply, %{state | intervals: intervals}} - end - - @impl true - def handle_info({:timeout, command_id}, state) do - # Command timed out - case Map.get(state.running, command_id) do - nil -> - {:noreply, state} - - info -> - Task.Supervisor.terminate_child(state.task_supervisor, info.task.pid) - send_result(info.runtime_pid, info.component_id, command_id, {:error, :timeout}) - running = Map.delete(state.running, command_id) - {:noreply, %{state | running: running}} - end - end - - # --- Private Functions --- - - defp execute_command(%Command{type: :none}, _runtime_pid, _component_id, state) do - {:ok, state} - end - - defp execute_command(%Command{type: :timer} = cmd, runtime_pid, component_id, state) do - task = - Task.Supervisor.async_nolink(state.task_supervisor, fn -> - Process.sleep(cmd.payload) - cmd.on_result - end) - - running = - Map.put(state.running, cmd.id, %{ - task: task, - runtime_pid: runtime_pid, - component_id: component_id - }) - - # Set timeout if specified - if cmd.timeout != :infinity do - Process.send_after(self(), {:timeout, cmd.id}, cmd.timeout) - end - - {:ok, %{state | running: running}} - end - - defp execute_command(%Command{type: :interval} = cmd, runtime_pid, component_id, state) do - # Schedule first tick - timer_ref = - Process.send_after( - self(), - {:interval_tick, cmd.id, runtime_pid, component_id, cmd.on_result, cmd.payload}, - cmd.payload - ) - - intervals = - Map.put(state.intervals, cmd.id, %{ - timer_ref: timer_ref, - component_id: component_id - }) - - {:ok, %{state | intervals: intervals}} - end - - defp execute_command(%Command{type: :file_read} = cmd, runtime_pid, component_id, state) do - task = - Task.Supervisor.async_nolink(state.task_supervisor, fn -> - case File.read(cmd.payload) do - {:ok, content} -> {cmd.on_result, {:ok, content}} - {:error, reason} -> {cmd.on_result, {:error, reason}} - end - end) - - running = - Map.put(state.running, cmd.id, %{ - task: task, - runtime_pid: runtime_pid, - component_id: component_id - }) - - if cmd.timeout != :infinity do - Process.send_after(self(), {:timeout, cmd.id}, cmd.timeout) - end - - {:ok, %{state | running: running}} - end - - defp execute_command(%Command{type: :send_after} = cmd, runtime_pid, component_id, state) do - {target_component, message, delay_ms} = cmd.payload - - task = - Task.Supervisor.async_nolink(state.task_supervisor, fn -> - Process.sleep(delay_ms) - # Return the target and message for the runtime to route - {:send_to, target_component, message} - end) - - running = - Map.put(state.running, cmd.id, %{ - task: task, - runtime_pid: runtime_pid, - component_id: component_id - }) - - {:ok, %{state | running: running}} - end - - defp execute_command(%Command{type: type}, _runtime_pid, _component_id, _state) do - {:error, {:unknown_command_type, type}} - end - - defp send_result(runtime_pid, component_id, command_id, result) do - send(runtime_pid, {:command_result, component_id, command_id, result}) - end - - defp find_by_task_ref(running, ref) do - Enum.find(running, fn {_id, info} -> info.task.ref == ref end) - end -end diff --git a/lib/term_ui/component.ex b/lib/term_ui/component.ex deleted file mode 100644 index 37a33fb5..00000000 --- a/lib/term_ui/component.ex +++ /dev/null @@ -1,179 +0,0 @@ -defmodule TermUI.Component do - @moduledoc """ - Base behaviour for all TermUI components. - - Components are the building blocks of TermUI applications. This behaviour - defines the minimal interface that all components must implement. - - ## Basic Usage - - The simplest component only needs to implement `render/2`: - - defmodule MyApp.Label do - use TermUI.Component - - @impl true - def render(props, _area) do - text(props[:text] || "") - end - end - - ## Optional Callbacks - - Components can also implement: - - - `describe/0` - Returns metadata about the component - - `default_props/0` - Returns default prop values - - ## Render Tree - - The `render/2` callback returns a render tree, which can be: - - - A `RenderNode` struct - - A list of render nodes - - A plain string (converted to text node) - - ## Props - - Props are passed as a map to the `render/2` callback. Use `default_props/0` - to define defaults that are merged with passed props. - - ## Area - - The area parameter defines the available space for rendering: - - %{x: integer(), y: integer(), width: integer(), height: integer()} - - Components should respect these bounds when producing render output. - """ - - alias TermUI.Component.RenderNode - - # Type definitions - - @typedoc "Render tree output - can be a node, list of nodes, or string" - @type render_tree :: RenderNode.t() | [render_tree()] | String.t() - - @typedoc "Component props passed to render" - @type props :: map() - - @typedoc "Available rendering area" - @type rect :: %{x: integer(), y: integer(), width: integer(), height: integer()} - - @typedoc "Component metadata" - @type component_info :: %{ - name: String.t(), - description: String.t() | nil, - version: String.t() | nil - } - - # Required callbacks - - @doc """ - Renders the component given props and available area. - - This is the only required callback. It receives the component's props - and the available rendering area, and must return a render tree. - - ## Parameters - - - `props` - Map of properties passed to the component - - `area` - Available rendering area with x, y, width, height - - ## Returns - - A render tree (RenderNode, list, or string). - - ## Examples - - @impl true - def render(props, area) do - text = props[:text] || "" - style = props[:style] - - if style do - styled_text(text, style) - else - text(text) - end - end - """ - @callback render(props(), rect()) :: render_tree() - - # Optional callbacks - - @doc """ - Returns metadata about the component. - - Useful for introspection, debugging, and documentation generation. - - ## Examples - - @impl true - def describe do - %{ - name: "Label", - description: "A simple text display component", - version: "1.0.0" - } - end - """ - @callback describe() :: component_info() - - @doc """ - Returns default prop values for the component. - - These defaults are merged with props passed to `render/2`, - with passed props taking precedence. - - ## Examples - - @impl true - def default_props do - %{ - text: "", - style: nil, - align: :left - } - end - """ - @callback default_props() :: props() - - @optional_callbacks describe: 0, default_props: 0 - - @doc false - defmacro __using__(_opts) do - quote do - @behaviour TermUI.Component - - alias TermUI.Component.RenderNode - alias TermUI.Renderer.Style - - import TermUI.Component.Helpers - - # Default implementations for optional callbacks - - @doc false - def describe do - %{ - name: inspect(__MODULE__), - description: nil, - version: nil - } - end - - @doc false - def default_props do - %{} - end - - defoverridable describe: 0, default_props: 0 - - # Helper to merge default props with passed props - @doc false - def merge_props(props) do - Map.merge(default_props(), props) - end - end - end -end diff --git a/lib/term_ui/component/helpers.ex b/lib/term_ui/component/helpers.ex deleted file mode 100644 index cceeb893..00000000 --- a/lib/term_ui/component/helpers.ex +++ /dev/null @@ -1,332 +0,0 @@ -defmodule TermUI.Component.Helpers do - @moduledoc """ - Common helper functions and macros for TermUI components. - - This module is automatically imported when you `use TermUI.Component`. - It provides convenience functions for building render trees and - working with props and styles. - - ## Render Tree Builders - - - `text/1`, `text/2` - Create text nodes - - `box/1`, `box/2` - Create box containers - - `stack/2`, `stack/3` - Create stacked layouts - - ## Props Helpers - - - `props!/2` - Validate and extract required props - - ## Style Helpers - - - `merge_styles/2` - Merge multiple styles - - `compute_size/2` - Calculate content dimensions - """ - - alias TermUI.Component.RenderNode - alias TermUI.Renderer.Style - - # Render tree builders - delegate to RenderNode - - @doc """ - Creates a text node. - - ## Examples - - text("Hello, World!") - text("Styled", Style.new() |> Style.fg(:red)) - """ - @spec text(String.t(), Style.t() | nil) :: RenderNode.t() - defdelegate text(content, style \\ nil), to: RenderNode - - @doc """ - Creates a box container. - - ## Examples - - box([text("Content")]) - box([text("Styled")], style: Style.new() |> Style.bg(:blue)) - """ - @spec box([RenderNode.t()], keyword()) :: RenderNode.t() - defdelegate box(children, opts \\ []), to: RenderNode - - @doc """ - Creates a stack layout. - - ## Examples - - stack(:vertical, [text("Top"), text("Bottom")]) - stack(:horizontal, [text("Left"), text("Right")]) - """ - @spec stack(RenderNode.direction(), [RenderNode.t()], keyword()) :: RenderNode.t() - defdelegate stack(direction, children, opts \\ []), to: RenderNode - - @doc """ - Creates a styled wrapper around a node. - - ## Examples - - styled(text("Hello"), Style.new() |> Style.fg(:red)) - """ - @spec styled(RenderNode.t(), Style.t()) :: RenderNode.t() - defdelegate styled(node, style), to: RenderNode - - @doc """ - Creates an empty node. - - ## Examples - - empty() - """ - @spec empty() :: RenderNode.t() - defdelegate empty(), to: RenderNode - - # Props validation - - @doc """ - Validates and extracts props with type checking and defaults. - - Raises `ArgumentError` if required props are missing or types don't match. - - ## Spec Format - - Each prop spec is a tuple: `{name, type, opts}` - - Types: `:string`, `:integer`, `:boolean`, `:atom`, `:any`, `:style` - - Options: - - `:required` - Prop must be present (default: false) - - `:default` - Default value if not provided - - ## Examples - - props!(props, [ - {:text, :string, required: true}, - {:count, :integer, default: 0}, - {:enabled, :boolean, default: true} - ]) - # Returns %{text: "...", count: 0, enabled: true} - """ - @spec props!(map(), [{atom(), atom(), keyword()}]) :: map() - def props!(props, specs) when is_map(props) and is_list(specs) do - Enum.reduce(specs, %{}, fn {name, type, opts}, acc -> - required = Keyword.get(opts, :required, false) - default = Keyword.get(opts, :default) - - value = extract_prop_value(props, name, type, required, default) - Map.put(acc, name, value) - end) - end - - defp extract_prop_value(props, name, type, required, default) do - case Map.fetch(props, name) do - {:ok, val} -> - validate_prop_type!(name, val, type) - val - - :error -> - handle_missing_prop(name, required, default) - end - end - - defp handle_missing_prop(name, true, _default) do - raise ArgumentError, "Required prop #{inspect(name)} is missing" - end - - defp handle_missing_prop(_name, false, default), do: default - - defp validate_prop_type!(_name, nil, _type), do: :ok - - defp validate_prop_type!(name, value, :string) do - unless is_binary(value) do - raise ArgumentError, - "Prop #{inspect(name)} must be a string, got: #{inspect(value)}" - end - end - - defp validate_prop_type!(name, value, :integer) do - unless is_integer(value) do - raise ArgumentError, - "Prop #{inspect(name)} must be an integer, got: #{inspect(value)}" - end - end - - defp validate_prop_type!(name, value, :boolean) do - unless is_boolean(value) do - raise ArgumentError, - "Prop #{inspect(name)} must be a boolean, got: #{inspect(value)}" - end - end - - defp validate_prop_type!(name, value, :atom) do - unless is_atom(value) do - raise ArgumentError, - "Prop #{inspect(name)} must be an atom, got: #{inspect(value)}" - end - end - - defp validate_prop_type!(name, value, :style) do - unless match?(%Style{}, value) do - raise ArgumentError, - "Prop #{inspect(name)} must be a Style, got: #{inspect(value)}" - end - end - - defp validate_prop_type!(_name, _value, :any), do: :ok - - # Style helpers - - @doc """ - Merges multiple styles in order, with later styles overriding earlier ones. - - Follows CSS cascade rules - later values take precedence, attributes combine. - - ## Examples - - base = Style.new() |> Style.fg(:white) - override = Style.new() |> Style.fg(:red) |> Style.bold() - merge_styles([base, override]) - # Result: fg: :red, attrs: [:bold] - """ - @spec merge_styles([Style.t() | nil]) :: Style.t() - def merge_styles(styles) when is_list(styles) do - styles - |> Enum.reject(&is_nil/1) - |> Enum.reduce(Style.new(), &Style.merge(&2, &1)) - end - - @doc """ - Computes the display size of text content. - - Returns `{width, height}` where width is the maximum line length - and height is the number of lines. - - ## Examples - - compute_size("Hello") - # {5, 1} - - compute_size("Line 1\\nLine 2") - # {6, 2} - """ - @spec compute_size(String.t()) :: {non_neg_integer(), non_neg_integer()} - def compute_size(text) when is_binary(text) do - lines = String.split(text, "\n") - height = length(lines) - - width = - lines - |> Enum.map(&String.length/1) - |> Enum.max(fn -> 0 end) - - {width, height} - end - - @doc """ - Computes the size of a render node. - - For text nodes, returns the text dimensions. - For containers, returns explicit size or `:auto`. - - ## Examples - - compute_node_size(text("Hello")) - # {5, 1} - - compute_node_size(box([], width: 20, height: 10)) - # {20, 10} - """ - @spec compute_node_size(RenderNode.t()) :: - {non_neg_integer() | :auto, non_neg_integer() | :auto} - def compute_node_size(%RenderNode{type: :text, content: content}) do - compute_size(content || "") - end - - def compute_node_size(%RenderNode{type: :empty}) do - {0, 0} - end - - def compute_node_size(%RenderNode{width: w, height: h}) do - {w || :auto, h || :auto} - end - - @doc """ - Checks if a value fits within a rect. - - ## Examples - - fits_in_rect?({10, 5}, %{x: 0, y: 0, width: 20, height: 10}) - # true - - fits_in_rect?({30, 5}, %{x: 0, y: 0, width: 20, height: 10}) - # false - """ - @spec fits_in_rect?({non_neg_integer(), non_neg_integer()}, TermUI.Component.rect()) :: - boolean() - def fits_in_rect?({width, height}, %{width: max_width, height: max_height}) do - width <= max_width and height <= max_height - end - - @doc """ - Truncates text to fit within a given width. - - ## Examples - - truncate_text("Hello, World!", 5) - # "Hello" - - truncate_text("Hi", 10) - # "Hi" - """ - @spec truncate_text(String.t(), non_neg_integer()) :: String.t() - def truncate_text(text, max_width) when is_binary(text) and is_integer(max_width) do - if String.length(text) <= max_width do - text - else - String.slice(text, 0, max_width) - end - end - - @doc """ - Creates a positioned cell for use with RenderNode.cells/2. - - ## Examples - - cell = positioned_cell(0, 0, "A", Style.new() |> Style.fg(:red)) - # %{x: 0, y: 0, cell: %Cell{char: "A", fg: :red}} - """ - @spec positioned_cell(non_neg_integer(), non_neg_integer(), String.t(), Style.t() | nil) :: - RenderNode.positioned_cell() - def positioned_cell(x, y, char, style \\ nil) do - alias TermUI.Renderer.Cell - - cell_opts = - if style do - # Only include non-default/non-nil values - opts = [] - opts = if style.fg in [nil, :default], do: opts, else: [{:fg, style.fg} | opts] - opts = if style.bg in [nil, :default], do: opts, else: [{:bg, style.bg} | opts] - - opts = - if MapSet.size(style.attrs) > 0, - do: [{:attrs, MapSet.to_list(style.attrs)} | opts], - else: opts - - opts - else - [] - end - - %{x: x, y: y, cell: Cell.new(char, cell_opts)} - end - - @doc """ - Delegates to RenderNode.cells/2 for creating cell-based render nodes. - - ## Examples - - cells = [positioned_cell(0, 0, "H"), positioned_cell(1, 0, "i")] - cells(cells) - """ - @spec cells([RenderNode.positioned_cell()], keyword()) :: RenderNode.t() - defdelegate cells(cells, opts \\ []), to: RenderNode -end diff --git a/lib/term_ui/component/introspection.ex b/lib/term_ui/component/introspection.ex deleted file mode 100644 index fd391e15..00000000 --- a/lib/term_ui/component/introspection.ex +++ /dev/null @@ -1,338 +0,0 @@ -defmodule TermUI.Component.Introspection do - @moduledoc """ - Supervision introspection tools for debugging and monitoring. - - Provides visibility into the component tree structure, component states, - and supervision metrics for debugging and monitoring purposes. - - ## Usage - - # Get tree structure - tree = Introspection.get_component_tree() - - # Get component info - info = Introspection.get_component_info(:my_component) - - # Print tree visualization - Introspection.print_tree() - - # Get supervision metrics - metrics = Introspection.get_metrics(:my_component) - """ - - alias TermUI.Component.StatePersistence - alias TermUI.ComponentRegistry - alias TermUI.ComponentServer - - # Dialyzer: Functions return specific map types - @dialyzer {:nowarn_function, - get_component_info: 1, - get_metrics: 1, - aggregate_stats: 0, - print_tree: 1, - format_tree: 0} - - @doc """ - Returns the component tree structure. - - ## Returns - - A map with tree structure: - ``` - %{ - id: term(), - pid: pid(), - module: module(), - children: [...] - } - ``` - """ - @spec get_component_tree() :: [map()] - def get_component_tree do - # Get all components - components = ComponentRegistry.list_all() - - # Build parent-child relationships - components - |> Enum.map(fn component -> - children = get_children_tree(component.id, components) - - %{ - id: component.id, - pid: component.pid, - module: component.module, - children: children - } - end) - |> Enum.filter(fn component -> - # Only include root components (those without parents) - case ComponentRegistry.get_parent(component.id) do - {:ok, nil} -> true - {:ok, _parent} -> false - {:error, :not_found} -> true - end - end) - end - - defp get_children_tree(parent_id, all_components) do - child_ids = ComponentRegistry.get_children(parent_id) - - Enum.map(child_ids, fn child_id -> - component = Enum.find(all_components, fn c -> c.id == child_id end) - - if component do - %{ - id: component.id, - pid: component.pid, - module: component.module, - children: get_children_tree(child_id, all_components) - } - else - nil - end - end) - |> Enum.reject(&is_nil/1) - end - - @doc """ - Returns detailed information about a component. - - ## Parameters - - - `component_id` - Component identifier - - ## Returns - - - `{:ok, info}` - Component information - - `{:error, :not_found}` - Component not found - """ - @spec get_component_info(term()) :: {:ok, map()} | {:error, :not_found} - def get_component_info(component_id) do - case ComponentRegistry.get_info(component_id) do - {:ok, info} -> - pid = info.pid - - # Get additional info from the component server - {state, props, lifecycle} = - try do - state = ComponentServer.get_state(pid) - props = ComponentServer.get_props(pid) - lifecycle = ComponentServer.get_lifecycle(pid) - {state, props, lifecycle} - catch - :exit, _ -> {nil, nil, :unknown} - end - - # Get metrics - restart_count = StatePersistence.get_restart_count(component_id) - child_count = length(ComponentRegistry.get_children(component_id)) - - # Calculate uptime (use reductions as proxy since start_time not always available) - uptime_ms = - case Process.info(pid, :reductions) do - # Can't reliably calculate uptime - {:reductions, _} -> 0 - nil -> 0 - end - - enhanced_info = - Map.merge(info, %{ - state: state, - props: props, - lifecycle: lifecycle, - restart_count: restart_count, - child_count: child_count, - uptime_ms: uptime_ms - }) - - {:ok, enhanced_info} - - {:error, :not_found} -> - {:error, :not_found} - end - end - - @doc """ - Returns supervision metrics for a component. - - ## Parameters - - - `component_id` - Component identifier - - ## Returns - - - `{:ok, metrics}` - Metrics map - - `{:error, :not_found}` - Component not found - """ - @spec get_metrics(term()) :: {:ok, map()} | {:error, :not_found} - def get_metrics(component_id) do - case ComponentRegistry.lookup(component_id) do - {:ok, pid} -> - restart_count = StatePersistence.get_restart_count(component_id) - child_count = length(ComponentRegistry.get_children(component_id)) - - # Get process info - info = - Process.info(pid, [ - :memory, - :message_queue_len, - :reductions, - :status - ]) || [] - - # uptime_ms not reliably available - uptime_ms = 0 - - metrics = %{ - restart_count: restart_count, - child_count: child_count, - uptime_ms: uptime_ms, - memory_bytes: Keyword.get(info, :memory, 0), - message_queue_len: Keyword.get(info, :message_queue_len, 0), - reductions: Keyword.get(info, :reductions, 0), - status: Keyword.get(info, :status, :unknown) - } - - {:ok, metrics} - - {:error, :not_found} -> - {:error, :not_found} - end - end - - @doc """ - Prints a text visualization of the component tree. - - ## Options - - - `:io` - IO device to print to (default: `:stdio`) - """ - @spec print_tree(keyword()) :: :ok - def print_tree(opts \\ []) do - io = Keyword.get(opts, :io, :stdio) - tree = get_component_tree() - - if Enum.empty?(tree) do - IO.puts(io, "(no components)") - else - Enum.each(tree, fn node -> - print_node(io, node, "") - end) - end - - :ok - end - - defp print_node(io, node, prefix) do - pid_str = inspect(node.pid) - module_str = inspect(node.module) |> String.replace("Elixir.", "") - - IO.puts(io, "#{prefix}#{node.id} (#{pid_str}) - #{module_str}") - - children = node.children - child_count = length(children) - - Enum.with_index(children, fn child, index -> - is_last = index == child_count - 1 - print_child_node(io, child, prefix, is_last) - end) - end - - defp print_child_node(io, child, prefix, is_last) do - child_prefix = get_child_prefix(prefix, is_last) - cont_prefix = get_continuation_prefix(prefix, is_last) - - # Print child with its prefix - pid_str = inspect(child.pid) - module_str = inspect(child.module) |> String.replace("Elixir.", "") - IO.puts(io, "#{child_prefix}#{child.id} (#{pid_str}) - #{module_str}") - - # Recursively print grandchildren - grand_children = child.children - grand_count = length(grand_children) - - Enum.with_index(grand_children, fn grandchild, gindex -> - is_last_grand = gindex == grand_count - 1 - grand_prefix = get_child_prefix(cont_prefix, is_last_grand) - print_node(io, grandchild, grand_prefix) - end) - end - - defp get_child_prefix(prefix, true), do: "#{prefix}└── " - defp get_child_prefix(prefix, false), do: "#{prefix}├── " - - defp get_continuation_prefix(prefix, true), do: "#{prefix} " - defp get_continuation_prefix(prefix, false), do: "#{prefix}│ " - - @doc """ - Returns the tree as a formatted string. - """ - @spec format_tree() :: String.t() - def format_tree do - {:ok, io} = StringIO.open("") - - print_tree(io: io) - - {_input, output} = StringIO.contents(io) - StringIO.close(io) - - output - end - - @doc """ - Returns aggregate statistics for all components. - """ - @spec aggregate_stats() :: map() - def aggregate_stats do - components = ComponentRegistry.list_all() - - total_count = length(components) - - total_restarts = - Enum.reduce(components, 0, fn c, acc -> - acc + StatePersistence.get_restart_count(c.id) - end) - - total_memory = - Enum.reduce(components, 0, fn c, acc -> - case :erlang.process_info(c.pid, :memory) do - {:memory, mem} -> acc + mem - nil -> acc - end - end) - - %{ - component_count: total_count, - total_restarts: total_restarts, - total_memory_bytes: total_memory, - persisted_state_count: StatePersistence.count() - } - end - - @doc """ - Finds components by module. - """ - @spec find_by_module(module()) :: [map()] - def find_by_module(module) do - ComponentRegistry.list_all() - |> Enum.filter(fn c -> c.module == module end) - end - - @doc """ - Finds components with high restart counts. - - ## Parameters - - - `threshold` - Minimum restart count (default: 1) - """ - @spec find_unstable(non_neg_integer()) :: [map()] - def find_unstable(threshold \\ 1) do - ComponentRegistry.list_all() - |> Enum.map(fn c -> - restart_count = StatePersistence.get_restart_count(c.id) - Map.put(c, :restart_count, restart_count) - end) - |> Enum.filter(fn c -> c.restart_count >= threshold end) - |> Enum.sort_by(fn c -> -c.restart_count end) - end -end diff --git a/lib/term_ui/component/render_node.ex b/lib/term_ui/component/render_node.ex deleted file mode 100644 index 20977ddc..00000000 --- a/lib/term_ui/component/render_node.ex +++ /dev/null @@ -1,256 +0,0 @@ -defmodule TermUI.Component.RenderNode do - @moduledoc """ - Represents a node in the render tree. - - RenderNodes are the output of component rendering. They form a tree structure - that the renderer converts to terminal buffer cells. Each node has content, - styling, and optional children. - - ## Node Types - - - **Text nodes**: Simple text content with optional styling - - **Box nodes**: Rectangular regions that can contain children - - **Stack nodes**: Vertical or horizontal arrangements of children - - ## Examples - - # Simple text node - RenderNode.text("Hello, World!") - - # Styled text - style = Style.new() |> Style.fg(:red) |> Style.bold() - RenderNode.text("Error!", style) - - # Box with children - RenderNode.box([ - RenderNode.text("Header"), - RenderNode.text("Content") - ]) - - # Horizontal stack - RenderNode.stack(:horizontal, [ - RenderNode.text("Left"), - RenderNode.text("Right") - ]) - """ - - alias TermUI.Renderer.Cell - alias TermUI.Renderer.Style - - @type node_type :: :text | :box | :stack | :empty | :cells - - @typedoc "A cell with position information for the :cells node type" - @type positioned_cell :: %{x: non_neg_integer(), y: non_neg_integer(), cell: Cell.t()} - @type direction :: :vertical | :horizontal - - @type t :: %__MODULE__{ - type: node_type(), - content: String.t() | nil, - style: Style.t() | nil, - children: [t()], - direction: direction() | nil, - width: non_neg_integer() | :auto | nil, - height: non_neg_integer() | :auto | nil, - cells: [positioned_cell()] | nil - } - - defstruct type: :empty, - content: nil, - style: nil, - children: [], - direction: nil, - width: nil, - height: nil, - cells: nil - - # Dialyzer: Functions return specific struct types - @dialyzer {:nowarn_function, empty: 0} - - @doc """ - Creates an empty render node. - - ## Examples - - iex> RenderNode.empty() - %RenderNode{type: :empty} - """ - @spec empty() :: t() - def empty do - %__MODULE__{type: :empty} - end - - @doc """ - Creates a text node with optional styling. - - ## Examples - - iex> RenderNode.text("Hello") - %RenderNode{type: :text, content: "Hello"} - - iex> style = Style.new() |> Style.fg(:red) - iex> node = RenderNode.text("Error", style) - iex> node.style.fg - :red - """ - @spec text(String.t(), Style.t() | nil) :: t() - def text(content, style \\ nil) when is_binary(content) do - %__MODULE__{ - type: :text, - content: content, - style: style - } - end - - @doc """ - Creates a box node that can contain children. - - ## Options - - - `:style` - Style to apply to the box background - - `:width` - Fixed width or `:auto` - - `:height` - Fixed height or `:auto` - - ## Examples - - iex> RenderNode.box([RenderNode.text("Content")]) - %RenderNode{type: :box, children: [%RenderNode{type: :text, content: "Content"}]} - - iex> RenderNode.box([RenderNode.text("Styled")], style: Style.new() |> Style.bg(:blue)) - %RenderNode{type: :box, style: %Style{bg: :blue}} - """ - @spec box([t()], keyword()) :: t() - def box(children, opts \\ []) when is_list(children) do - %__MODULE__{ - type: :box, - children: children, - style: Keyword.get(opts, :style), - width: Keyword.get(opts, :width), - height: Keyword.get(opts, :height) - } - end - - @doc """ - Creates a stack node that arranges children in a direction. - - ## Examples - - iex> RenderNode.stack(:vertical, [RenderNode.text("Top"), RenderNode.text("Bottom")]) - %RenderNode{type: :stack, direction: :vertical, children: [...]} - - iex> RenderNode.stack(:horizontal, [RenderNode.text("Left"), RenderNode.text("Right")]) - %RenderNode{type: :stack, direction: :horizontal, children: [...]} - """ - @spec stack(direction(), [t()], keyword()) :: t() - def stack(direction, children, opts \\ []) - when direction in [:vertical, :horizontal] and is_list(children) do - %__MODULE__{ - type: :stack, - direction: direction, - children: children, - style: Keyword.get(opts, :style), - width: Keyword.get(opts, :width), - height: Keyword.get(opts, :height) - } - end - - @doc """ - Creates a cells node with pre-rendered cells. - - This is used by widgets that need fine-grained control over cell positioning. - The cells list should contain Cell structs with absolute positions. - - ## Examples - - iex> cells = [%{x: 0, y: 0, cell: Cell.new("H")}, %{x: 1, y: 0, cell: Cell.new("i")}] - iex> RenderNode.cells(cells) - %RenderNode{type: :cells, cells: [...]} - """ - @spec cells([positioned_cell()], keyword()) :: t() - def cells(cells, opts \\ []) when is_list(cells) do - %__MODULE__{ - type: :cells, - cells: cells, - children: Keyword.get(opts, :children, []), - width: Keyword.get(opts, :width), - height: Keyword.get(opts, :height) - } - end - - @doc """ - Creates a styled wrapper around a node. - - Applies additional styling to an existing node without changing its structure. - - ## Examples - - iex> node = RenderNode.text("Hello") - iex> styled = RenderNode.styled(node, Style.new() |> Style.fg(:red)) - iex> styled.children - [%RenderNode{type: :text, content: "Hello"}] - """ - @spec styled(t(), Style.t()) :: t() - def styled(%__MODULE__{} = node, %Style{} = style) do - %__MODULE__{ - type: :box, - style: style, - children: [node] - } - end - - @doc """ - Sets the width of a node. - - ## Examples - - iex> RenderNode.box([]) |> RenderNode.width(20) - %RenderNode{type: :box, width: 20} - """ - @spec width(t(), non_neg_integer() | :auto) :: t() - def width(%__MODULE__{} = node, w) when (is_integer(w) and w >= 0) or w == :auto do - %{node | width: w} - end - - @doc """ - Sets the height of a node. - - ## Examples - - iex> RenderNode.box([]) |> RenderNode.height(10) - %RenderNode{type: :box, height: 10} - """ - @spec height(t(), non_neg_integer() | :auto) :: t() - def height(%__MODULE__{} = node, h) when (is_integer(h) and h >= 0) or h == :auto do - %{node | height: h} - end - - @doc """ - Checks if a node is empty. - - ## Examples - - iex> RenderNode.empty?(RenderNode.empty()) - true - - iex> RenderNode.empty?(RenderNode.text("Hello")) - false - """ - @spec empty?(t()) :: boolean() - def empty?(%__MODULE__{type: :empty}), do: true - def empty?(%__MODULE__{}), do: false - - @doc """ - Returns the number of direct children of a node. - - ## Examples - - iex> RenderNode.child_count(RenderNode.text("Hello")) - 0 - - iex> RenderNode.child_count(RenderNode.box([RenderNode.text("A"), RenderNode.text("B")])) - 2 - """ - @spec child_count(t()) :: non_neg_integer() - def child_count(%__MODULE__{children: children}) do - length(children) - end -end diff --git a/lib/term_ui/component/state_persistence.ex b/lib/term_ui/component/state_persistence.ex deleted file mode 100644 index 5e8ec248..00000000 --- a/lib/term_ui/component/state_persistence.ex +++ /dev/null @@ -1,305 +0,0 @@ -defmodule TermUI.Component.StatePersistence do - @moduledoc """ - ETS-based state persistence for crash recovery. - - This module allows components to persist their state before crashes - and recover it on restart. State is stored in an ETS table that survives - component process crashes. - - ## Usage - - # Persist state (typically called on state changes) - StatePersistence.persist(:my_component, state) - - # Recover state on restart - case StatePersistence.recover(:my_component) do - {:ok, state} -> {:ok, state} - :not_found -> {:ok, initial_state} - end - - # Clear persisted state - StatePersistence.clear(:my_component) - """ - - use GenServer - - @table_name :term_ui_component_states - @metadata_table :term_ui_persistence_metadata - - # Dialyzer: Functions return specific types - @dialyzer {:nowarn_function, init: 1, recover: 2, get_metadata: 1} - - # Client API - - @doc """ - Starts the state persistence server. - """ - @spec start_link(keyword()) :: GenServer.on_start() - def start_link(opts \\ []) do - name = Keyword.get(opts, :name, __MODULE__) - GenServer.start_link(__MODULE__, opts, name: name) - end - - @doc """ - Persists component state to ETS. - - ## Parameters - - - `component_id` - Component identifier - - `state` - State to persist - - `opts` - Options - - `:props` - Original props for last_props recovery mode - - ## Returns - - - `:ok` - State persisted successfully - """ - @spec persist(term(), term(), keyword()) :: :ok - def persist(component_id, state, opts \\ []) do - props = Keyword.get(opts, :props) - - entry = %{ - state: state, - props: props, - persisted_at: System.system_time(:millisecond) - } - - :ets.insert(@table_name, {component_id, entry}) - :ok - end - - @doc """ - Recovers persisted state for a component. - - ## Parameters - - - `component_id` - Component identifier - - `mode` - Recovery mode (default: `:last_state`) - - `:last_state` - Return the full persisted state - - `:last_props` - Return only the persisted props - - `:reset` - Return :not_found (forces re-initialization) - - ## Returns - - - `{:ok, state}` - State found and returned - - `:not_found` - No state persisted for this component - """ - @spec recover(term(), atom()) :: {:ok, term()} | :not_found - def recover(component_id, mode \\ :last_state) do - case mode do - :reset -> - # Clear any persisted state and return not found - clear(component_id) - :not_found - - :last_state -> - case :ets.lookup(@table_name, component_id) do - [{^component_id, %{state: state}}] -> {:ok, state} - [] -> :not_found - end - - :last_props -> - case :ets.lookup(@table_name, component_id) do - [{^component_id, %{props: props}}] when not is_nil(props) -> {:ok, props} - _ -> :not_found - end - end - end - - @doc """ - Clears persisted state for a component. - - ## Parameters - - - `component_id` - Component identifier - - ## Returns - - - `:ok` - State cleared (or was not present) - """ - @spec clear(term()) :: :ok - def clear(component_id) do - :ets.delete(@table_name, component_id) - :ok - end - - @doc """ - Clears all persisted state. - - Mainly useful for testing. - """ - @spec clear_all() :: :ok - def clear_all do - :ets.delete_all_objects(@table_name) - :ok - end - - @doc """ - Gets metadata about persisted state. - - ## Returns - - - `{:ok, metadata}` - Metadata including persisted_at timestamp - - `:not_found` - No state persisted for this component - """ - @spec get_metadata(term()) :: {:ok, map()} | :not_found - def get_metadata(component_id) do - case :ets.lookup(@table_name, component_id) do - [{^component_id, entry}] -> - {:ok, - %{ - persisted_at: entry.persisted_at, - has_props: not is_nil(entry.props) - }} - - [] -> - :not_found - end - end - - @doc """ - Lists all component IDs with persisted state. - """ - @spec list_persisted() :: [term()] - def list_persisted do - @table_name - |> :ets.tab2list() - |> Enum.map(fn {id, _entry} -> id end) - end - - @doc """ - Returns the count of persisted states. - """ - @spec count() :: non_neg_integer() - def count do - :ets.info(@table_name, :size) - end - - @doc """ - Records restart event for a component. - - Used for tracking restart counts and detecting restart storms. - """ - @spec record_restart(term()) :: :ok - def record_restart(component_id) do - now = System.system_time(:second) - - case :ets.lookup(@metadata_table, component_id) do - [{^component_id, metadata}] -> - # Remove restarts older than max_seconds window (default 5 seconds) - max_seconds = Map.get(metadata, :max_seconds, 5) - cutoff = now - max_seconds - - restarts = - metadata.restarts - |> Enum.filter(fn ts -> ts > cutoff end) - |> then(fn list -> list ++ [now] end) - - new_metadata = %{metadata | restarts: restarts} - :ets.insert(@metadata_table, {component_id, new_metadata}) - - [] -> - metadata = %{ - restarts: [now], - max_restarts: 3, - max_seconds: 5 - } - - :ets.insert(@metadata_table, {component_id, metadata}) - end - - :ok - end - - @doc """ - Gets the restart count for a component within the time window. - """ - @spec get_restart_count(term()) :: non_neg_integer() - def get_restart_count(component_id) do - case :ets.lookup(@metadata_table, component_id) do - [{^component_id, metadata}] -> length(metadata.restarts) - [] -> 0 - end - end - - @doc """ - Checks if restart intensity limit has been reached. - - ## Returns - - - `true` - Restart limit exceeded - - `false` - Within limits - """ - @spec restart_limit_reached?(term()) :: boolean() - def restart_limit_reached?(component_id) do - case :ets.lookup(@metadata_table, component_id) do - [{^component_id, metadata}] -> - length(metadata.restarts) >= metadata.max_restarts - - [] -> - false - end - end - - @doc """ - Sets restart intensity limits for a component. - - ## Parameters - - - `component_id` - Component identifier - - `max_restarts` - Maximum restarts allowed - - `max_seconds` - Time window in seconds - """ - @spec set_restart_limits(term(), non_neg_integer(), non_neg_integer()) :: :ok - def set_restart_limits(component_id, max_restarts, max_seconds) do - case :ets.lookup(@metadata_table, component_id) do - [{^component_id, metadata}] -> - new_metadata = %{metadata | max_restarts: max_restarts, max_seconds: max_seconds} - :ets.insert(@metadata_table, {component_id, new_metadata}) - - [] -> - metadata = %{ - restarts: [], - max_restarts: max_restarts, - max_seconds: max_seconds - } - - :ets.insert(@metadata_table, {component_id, metadata}) - end - - :ok - end - - @doc """ - Clears restart history for a component. - """ - @spec clear_restart_history(term()) :: :ok - def clear_restart_history(component_id) do - :ets.delete(@metadata_table, component_id) - :ok - end - - # Server Callbacks - - @impl true - def init(_opts) do - # Create ETS tables - :ets.new(@table_name, [ - :named_table, - :set, - :public, - read_concurrency: true, - write_concurrency: true - ]) - - :ets.new(@metadata_table, [ - :named_table, - :set, - :public, - read_concurrency: true, - write_concurrency: true - ]) - - {:ok, %{}} - end -end diff --git a/lib/term_ui/component_registry.ex b/lib/term_ui/component_registry.ex deleted file mode 100644 index c8f538ed..00000000 --- a/lib/term_ui/component_registry.ex +++ /dev/null @@ -1,309 +0,0 @@ -defmodule TermUI.ComponentRegistry do - @moduledoc """ - ETS-based registry for component lookup. - - The registry enables fast lookup of component processes by id, - which is essential for event routing and focus management. - Components register on mount and unregister on unmount. - - ## Usage - - # Register a component - ComponentRegistry.register(:my_button, pid, Button) - - # Lookup by id - {:ok, pid} = ComponentRegistry.lookup(:my_button) - - # Lookup by pid - {:ok, id} = ComponentRegistry.lookup_id(pid) - - # List all - components = ComponentRegistry.list_all() - """ - - use GenServer - - @table_name :term_ui_component_registry - @pid_index :term_ui_component_pid_index - @parent_table :term_ui_component_parents - - # Dialyzer: Functions return specific types - @dialyzer {:nowarn_function, init: 1, get_info: 1, handle_call: 3} - - # Client API - - @doc """ - Starts the component registry. - """ - @spec start_link(keyword()) :: GenServer.on_start() - def start_link(opts \\ []) do - name = Keyword.get(opts, :name, __MODULE__) - GenServer.start_link(__MODULE__, opts, name: name) - end - - @doc """ - Registers a component in the registry. - - ## Parameters - - - `id` - Unique identifier for the component - - `pid` - Process pid of the component - - `module` - Component module - - ## Returns - - - `:ok` - Successfully registered - - `{:error, :already_registered}` - Id already taken - """ - @spec register(term(), pid(), module()) :: :ok | {:error, :already_registered} - def register(id, pid, module) when is_pid(pid) and is_atom(module) do - GenServer.call(__MODULE__, {:register, id, pid, module}) - end - - @doc """ - Unregisters a component from the registry. - - ## Parameters - - - `id` - Component identifier to unregister - - ## Returns - - - `:ok` - Successfully unregistered (or wasn't registered) - """ - @spec unregister(term()) :: :ok - def unregister(id) do - GenServer.call(__MODULE__, {:unregister, id}) - end - - @doc """ - Looks up a component by id. - - ## Returns - - - `{:ok, pid}` - Component found - - `{:error, :not_found}` - Component not registered - """ - @spec lookup(term()) :: {:ok, pid()} | {:error, :not_found} - def lookup(id) do - case :ets.lookup(@table_name, id) do - [{^id, pid, _module}] -> {:ok, pid} - [] -> {:error, :not_found} - end - end - - @doc """ - Looks up a component id by pid. - - ## Returns - - - `{:ok, id}` - Component found - - `{:error, :not_found}` - Component not registered - """ - @spec lookup_id(pid()) :: {:ok, term()} | {:error, :not_found} - def lookup_id(pid) when is_pid(pid) do - case :ets.lookup(@pid_index, pid) do - [{^pid, id}] -> {:ok, id} - [] -> {:error, :not_found} - end - end - - @doc """ - Gets full component info by id. - - ## Returns - - - `{:ok, %{id: term(), pid: pid(), module: module()}}` - Component found - - `{:error, :not_found}` - Component not registered - """ - @spec get_info(term()) :: {:ok, map()} | {:error, :not_found} - def get_info(id) do - case :ets.lookup(@table_name, id) do - [{^id, pid, module}] -> - {:ok, %{id: id, pid: pid, module: module}} - - [] -> - {:error, :not_found} - end - end - - @doc """ - Lists all registered components. - - ## Returns - - List of `%{id: term(), pid: pid(), module: module()}` - """ - @spec list_all() :: [map()] - def list_all do - @table_name - |> :ets.tab2list() - |> Enum.map(fn {id, pid, module} -> - %{id: id, pid: pid, module: module} - end) - end - - @doc """ - Returns the count of registered components. - """ - @spec count() :: non_neg_integer() - def count do - :ets.info(@table_name, :size) - end - - @doc """ - Checks if a component is registered. - """ - @spec registered?(term()) :: boolean() - def registered?(id) do - :ets.member(@table_name, id) - end - - @doc """ - Clears all registrations. - - Mainly useful for testing. - """ - @spec clear() :: :ok - def clear do - GenServer.call(__MODULE__, :clear) - end - - @doc """ - Sets the parent of a component for propagation. - - ## Parameters - - - `id` - Component id - - `parent_id` - Parent component id (or nil for root) - """ - @spec set_parent(term(), term() | nil) :: :ok - def set_parent(id, parent_id) do - :ets.insert(@parent_table, {id, parent_id}) - :ok - end - - @doc """ - Gets the parent of a component. - - ## Returns - - - `{:ok, parent_id}` - Parent found (nil if root) - - `{:error, :not_found}` - Component not in parent table - """ - @spec get_parent(term()) :: {:ok, term() | nil} | {:error, :not_found} - def get_parent(id) do - case :ets.lookup(@parent_table, id) do - [{^id, parent_id}] -> {:ok, parent_id} - [] -> {:error, :not_found} - end - end - - @doc """ - Gets all children of a component. - - ## Returns - - List of child component ids. - """ - @spec get_children(term()) :: [term()] - def get_children(parent_id) do - @parent_table - |> :ets.tab2list() - |> Enum.filter(fn {_id, pid} -> pid == parent_id end) - |> Enum.map(fn {id, _pid} -> id end) - end - - # Server Callbacks - - @impl true - def init(_opts) do - # Create ETS tables - :ets.new(@table_name, [:set, :public, :named_table, read_concurrency: true]) - :ets.new(@pid_index, [:set, :public, :named_table, read_concurrency: true]) - :ets.new(@parent_table, [:set, :public, :named_table, read_concurrency: true]) - - {:ok, %{monitors: %{}}} - end - - @impl true - def handle_call({:register, id, pid, module}, _from, state) do - case :ets.lookup(@table_name, id) do - [] -> - # Insert into both tables - :ets.insert(@table_name, {id, pid, module}) - :ets.insert(@pid_index, {pid, id}) - - # Monitor the process for automatic cleanup - ref = Process.monitor(pid) - monitors = Map.put(state.monitors, ref, id) - - {:reply, :ok, %{state | monitors: monitors}} - - [_existing] -> - {:reply, {:error, :already_registered}, state} - end - end - - @impl true - def handle_call({:unregister, id}, _from, state) do - case :ets.lookup(@table_name, id) do - [{^id, pid, _module}] -> - # Remove from both tables - :ets.delete(@table_name, id) - :ets.delete(@pid_index, pid) - - # Find and remove monitor - {ref, monitors} = find_and_remove_monitor(state.monitors, id) - - if ref, do: Process.demonitor(ref, [:flush]) - - {:reply, :ok, %{state | monitors: monitors}} - - [] -> - {:reply, :ok, state} - end - end - - @impl true - def handle_call(:clear, _from, state) do - :ets.delete_all_objects(@table_name) - :ets.delete_all_objects(@pid_index) - :ets.delete_all_objects(@parent_table) - - # Demonitor all - Enum.each(state.monitors, fn {ref, _id} -> - Process.demonitor(ref, [:flush]) - end) - - {:reply, :ok, %{state | monitors: %{}}} - end - - @impl true - def handle_info({:DOWN, ref, :process, _pid, _reason}, state) do - case Map.pop(state.monitors, ref) do - {nil, monitors} -> - {:noreply, %{state | monitors: monitors}} - - {id, monitors} -> - # Clean up the registration - case :ets.lookup(@table_name, id) do - [{^id, pid, _module}] -> - :ets.delete(@table_name, id) - :ets.delete(@pid_index, pid) - - [] -> - :ok - end - - {:noreply, %{state | monitors: monitors}} - end - end - - defp find_and_remove_monitor(monitors, id) do - monitors - |> Enum.find_value(fn {ref, monitored_id} -> - if monitored_id == id, do: {ref, Map.delete(monitors, ref)} - end) || {nil, monitors} - end -end diff --git a/lib/term_ui/component_server.ex b/lib/term_ui/component_server.ex deleted file mode 100644 index b581938d..00000000 --- a/lib/term_ui/component_server.ex +++ /dev/null @@ -1,485 +0,0 @@ -defmodule TermUI.ComponentServer do - @moduledoc """ - GenServer that manages the lifecycle of a component. - - ComponentServer wraps any component implementing TermUI behaviours, - managing its lifecycle stages: init, mount, update, and unmount. - It handles prop validation, timeout enforcement, and command execution. - - ## Lifecycle Stages - - 1. **Init** - Create initial state from props - 2. **Mount** - Component enters active tree, ready for events - 3. **Update** - Props changed, state may update - 4. **Unmount** - Component removed, cleanup performed - - ## Usage - - Components are typically started via `ComponentSupervisor`: - - {:ok, pid} = ComponentSupervisor.start_component(MyButton, %{label: "OK"}) - - Direct usage: - - {:ok, pid} = ComponentServer.start_link(MyButton, %{label: "OK"}, []) - """ - - use GenServer - - require Logger - - alias TermUI.Component.StatePersistence - alias TermUI.ComponentRegistry - - @default_init_timeout 5_000 - @default_unmount_timeout 5_000 - - # Dialyzer: Functions with unmatched return values - @dialyzer {:nowarn_function, - execute_hooks: 2, - execute_commands: 2, - props_changed?: 2, - handle_call: 3, - handle_info: 2} - - @type state :: %{ - module: module(), - component_state: term(), - props: map(), - lifecycle: :initialized | :mounted | :unmounted, - id: term(), - hooks: %{atom() => [function()]}, - recovery: :reset | :last_props | :last_state - } - - # Client API - - @doc """ - Starts a component server. - - ## Parameters - - - `module` - Component module - - `props` - Initial properties - - `opts` - Options (`:id`, `:timeout`) - """ - @spec start_link(module(), map(), keyword()) :: GenServer.on_start() - def start_link(module, props, opts \\ []) do - id = Keyword.get(opts, :id, make_ref()) - name = Keyword.get(opts, :name) - - gen_opts = if name, do: [name: name], else: [] - - GenServer.start_link(__MODULE__, {module, props, id, opts}, gen_opts) - end - - @doc """ - Returns a child specification for starting a component in a supervisor. - """ - @spec child_spec(module(), map(), keyword()) :: Supervisor.child_spec() - def child_spec(module, props, opts \\ []) do - %{ - id: opts[:id] || module, - start: {__MODULE__, :start_link, [module, props, opts]}, - restart: :permanent, - shutdown: 5000, - type: :worker - } - end - - @doc """ - Triggers the mount lifecycle stage. - - Called when the component is added to the active component tree. - """ - @spec mount(pid()) :: :ok | {:error, term()} - def mount(pid) do - GenServer.call(pid, :mount) - end - - @doc """ - Updates the component's props. - - Triggers the update callback if props have changed. - """ - @spec update_props(pid(), map()) :: :ok | {:error, term()} - def update_props(pid, new_props) do - GenServer.call(pid, {:update_props, new_props}) - end - - @doc """ - Triggers the unmount lifecycle stage. - - Called when the component is removed from the tree. - """ - @spec unmount(pid()) :: :ok - def unmount(pid) do - GenServer.call(pid, :unmount, @default_unmount_timeout) - end - - @doc """ - Gets the current component state. - """ - @spec get_state(pid()) :: term() - def get_state(pid) do - GenServer.call(pid, :get_state) - end - - @doc """ - Gets the current props. - """ - @spec get_props(pid()) :: map() - def get_props(pid) do - GenServer.call(pid, :get_props) - end - - @doc """ - Gets the lifecycle state. - """ - @spec get_lifecycle(pid()) :: :initialized | :mounted | :unmounted - def get_lifecycle(pid) do - GenServer.call(pid, :get_lifecycle) - end - - @doc """ - Sends an event to the component. - """ - @spec send_event(pid(), term()) :: :ok | {:error, term()} - def send_event(pid, event) do - GenServer.call(pid, {:event, event}) - end - - @doc """ - Registers a lifecycle hook. - - ## Hook Types - - - `:after_mount` - Called after successful mount - - `:before_unmount` - Called before unmount cleanup - - `:on_prop_change` - Called when props change - """ - @spec register_hook(pid(), atom(), function()) :: :ok - def register_hook(pid, hook_type, fun) when is_function(fun, 1) do - GenServer.call(pid, {:register_hook, hook_type, fun}) - end - - # Server Callbacks - - @impl true - def init({module, props, id, opts}) do - if valid_component_module?(module) do - do_init(module, props, id, opts) - else - {:stop, {:error, :invalid_component_module}} - end - end - - defp valid_component_module?(module) do - function_exported?(module, :init, 1) or function_exported?(module, :render, 2) - end - - defp do_init(module, props, id, opts) do - timeout = Keyword.get(opts, :timeout, @default_init_timeout) - recovery = Keyword.get(opts, :recovery, :last_state) - recovered_state = try_recover_state(id, recovery, props) - - task = Task.async(fn -> init_component(module, props, recovered_state) end) - - case Task.yield(task, timeout) || Task.shutdown(task) do - {:ok, result} -> handle_init_result(result, module, props, id, recovery) - nil -> {:stop, {:init_timeout, timeout}} - end - end - - defp init_component(module, props, recovered_state) do - case recovered_state do - {:ok, recovered} -> - {:ok, recovered} - - :not_found -> - call_module_init(module, props) - end - rescue - e -> {:error, {:init_error, e, __STACKTRACE__}} - end - - defp call_module_init(module, props) do - if function_exported?(module, :init, 1) do - module.init(props) - else - {:ok, props} - end - end - - defp handle_init_result({:ok, component_state}, module, props, id, recovery) do - state = build_initial_state(module, component_state, props, id, recovery) - {:ok, state} - end - - defp handle_init_result({:ok, component_state, commands}, module, props, id, recovery) do - state = build_initial_state(module, component_state, props, id, recovery) - execute_commands(commands, state) - {:ok, state} - end - - defp handle_init_result({:stop, reason}, _module, _props, _id, _recovery) do - {:stop, reason} - end - - defp handle_init_result({:error, reason}, _module, _props, _id, _recovery) do - {:stop, reason} - end - - defp build_initial_state(module, component_state, props, id, recovery) do - %{ - module: module, - component_state: component_state, - props: props, - lifecycle: :initialized, - id: id, - hooks: %{ - after_mount: [], - before_unmount: [], - on_prop_change: [] - }, - recovery: recovery - } - end - - defp try_recover_state(id, recovery, _props) do - case StatePersistence.recover(id, recovery) do - {:ok, state} -> - # Record this as a restart - StatePersistence.record_restart(id) - Logger.debug("Recovered state for component #{inspect(id)}") - {:ok, state} - - :not_found -> - :not_found - end - end - - @impl true - def handle_call(:mount, _from, %{lifecycle: :initialized} = state) do - module = state.module - - result = - if function_exported?(module, :mount, 1) do - try do - module.mount(state.component_state) - rescue - e -> - Logger.error("Mount error in #{inspect(module)}: #{inspect(e)}") - {:error, {:mount_error, e}} - end - else - {:ok, state.component_state} - end - - case result do - {:ok, new_component_state} -> - new_state = %{state | component_state: new_component_state, lifecycle: :mounted} - # Register in registry - ComponentRegistry.register(state.id, self(), state.module) - # Execute after_mount hooks - execute_hooks(:after_mount, new_state) - {:reply, :ok, new_state} - - {:ok, new_component_state, commands} -> - new_state = %{state | component_state: new_component_state, lifecycle: :mounted} - ComponentRegistry.register(state.id, self(), state.module) - execute_commands(commands, new_state) - execute_hooks(:after_mount, new_state) - {:reply, :ok, new_state} - - {:stop, reason} -> - {:stop, reason, {:error, reason}, state} - - {:error, reason} -> - {:reply, {:error, reason}, state} - end - end - - def handle_call(:mount, _from, %{lifecycle: lifecycle} = state) do - {:reply, {:error, {:invalid_lifecycle, lifecycle, :expected_initialized}}, state} - end - - @impl true - def handle_call({:update_props, new_props}, _from, %{lifecycle: :mounted} = state) do - if props_changed?(state.props, new_props) do - module = state.module - - result = - if function_exported?(module, :update, 2) do - try do - module.update(new_props, state.component_state) - rescue - e -> - Logger.error("Update error in #{inspect(module)}: #{inspect(e)}") - {:error, {:update_error, e}} - end - else - # Default: just update props, keep state - {:ok, state.component_state} - end - - case result do - {:ok, new_component_state} -> - new_state = %{state | component_state: new_component_state, props: new_props} - execute_hooks(:on_prop_change, new_state) - {:reply, :ok, new_state} - - {:ok, new_component_state, commands} -> - new_state = %{state | component_state: new_component_state, props: new_props} - execute_commands(commands, new_state) - execute_hooks(:on_prop_change, new_state) - {:reply, :ok, new_state} - - {:error, reason} -> - {:reply, {:error, reason}, state} - end - else - # Props unchanged, no update needed - {:reply, :ok, state} - end - end - - def handle_call({:update_props, _new_props}, _from, %{lifecycle: lifecycle} = state) do - {:reply, {:error, {:invalid_lifecycle, lifecycle, :expected_mounted}}, state} - end - - @impl true - def handle_call(:unmount, _from, %{lifecycle: :mounted} = state) do - # Execute before_unmount hooks - execute_hooks(:before_unmount, state) - - module = state.module - - if function_exported?(module, :unmount, 1) do - try do - module.unmount(state.component_state) - rescue - e -> - Logger.error("Unmount error in #{inspect(module)}: #{inspect(e)}") - end - end - - # Unregister from registry - ComponentRegistry.unregister(state.id) - - new_state = %{state | lifecycle: :unmounted} - {:reply, :ok, new_state} - end - - def handle_call(:unmount, _from, %{lifecycle: lifecycle} = state) do - {:reply, {:error, {:invalid_lifecycle, lifecycle, :expected_mounted}}, state} - end - - @impl true - def handle_call(:get_state, _from, state) do - {:reply, state.component_state, state} - end - - @impl true - def handle_call(:get_props, _from, state) do - {:reply, state.props, state} - end - - @impl true - def handle_call(:get_lifecycle, _from, state) do - {:reply, state.lifecycle, state} - end - - @impl true - def handle_call({:event, event}, _from, %{lifecycle: :mounted} = state) do - module = state.module - - if function_exported?(module, :handle_event, 2) do - case module.handle_event(event, state.component_state) do - {:ok, new_component_state} -> - {:reply, :ok, %{state | component_state: new_component_state}} - - {:ok, new_component_state, commands} -> - execute_commands(commands, state) - {:reply, :ok, %{state | component_state: new_component_state}} - - {:stop, reason, new_component_state} -> - {:stop, reason, :ok, %{state | component_state: new_component_state}} - end - else - {:reply, {:error, :no_event_handler}, state} - end - end - - def handle_call({:event, _event}, _from, %{lifecycle: lifecycle} = state) do - {:reply, {:error, {:invalid_lifecycle, lifecycle, :expected_mounted}}, state} - end - - @impl true - def handle_call({:register_hook, hook_type, fun}, _from, state) do - hooks = Map.update!(state.hooks, hook_type, fn existing -> existing ++ [fun] end) - {:reply, :ok, %{state | hooks: hooks}} - end - - @impl true - def terminate(reason, state) do - # Persist state for potential recovery on crash - if reason != :normal and reason != :shutdown do - StatePersistence.persist(state.id, state.component_state, props: state.props) - Logger.debug("Persisted state for component #{inspect(state.id)} before crash") - end - - # Ensure cleanup happens even on crash - if state.lifecycle == :mounted do - execute_hooks(:before_unmount, state) - - module = state.module - - if function_exported?(module, :unmount, 1) do - try do - module.unmount(state.component_state) - rescue - e -> - Logger.error("Unmount error during terminate in #{inspect(module)}: #{inspect(e)}") - end - end - - ComponentRegistry.unregister(state.id) - end - - Logger.debug("Component #{inspect(state.module)} terminating: #{inspect(reason)}") - :ok - end - - # Private Functions - - defp props_changed?(old_props, new_props) do - old_props != new_props - end - - defp execute_commands(commands, _state) when is_list(commands) do - Enum.each(commands, fn - {:send, pid, message} -> - send(pid, message) - - {:timer, ms, message} -> - Process.send_after(self(), message, ms) - - other -> - Logger.warning("Unknown command: #{inspect(other)}") - end) - end - - defp execute_hooks(hook_type, state) do - hooks = Map.get(state.hooks, hook_type, []) - - Enum.each(hooks, fn fun -> - try do - fun.(state.component_state) - rescue - e -> - Logger.error("Hook error (#{hook_type}): #{inspect(e)}") - end - end) - end -end diff --git a/lib/term_ui/component_supervisor.ex b/lib/term_ui/component_supervisor.ex deleted file mode 100644 index ef27ce06..00000000 --- a/lib/term_ui/component_supervisor.ex +++ /dev/null @@ -1,420 +0,0 @@ -defmodule TermUI.ComponentSupervisor do - @moduledoc """ - Dynamic supervisor for managing component processes. - - Components are spawned as child processes under this supervisor, - providing fault isolation and automatic cleanup. Each component - runs as a GenServer managed by `TermUI.ComponentServer`. - - ## Usage - - # Start a component under the supervisor - {:ok, pid} = ComponentSupervisor.start_component(MyComponent, %{text: "Hello"}) - - # Stop a component - :ok = ComponentSupervisor.stop_component(pid) - - # Stop with cascade (stops all children) - :ok = ComponentSupervisor.stop_component(pid, cascade: true) - - ## Supervision Strategy - - Uses `:one_for_one` strategy - each component is independent. - Default restart is `:transient` - restart only on crash, not normal exit. - - ## Restart Strategies - - - `:transient` (default) - Restart only on abnormal termination - - `:permanent` - Always restart on termination - - `:temporary` - Never restart - - ## Shutdown Options - - - `:shutdown` - Timeout in ms (default 5000) or `:brutal_kill` - - `:recovery` - Recovery mode: `:reset`, `:last_props`, `:last_state` - """ - - use DynamicSupervisor - - alias TermUI.Component.StatePersistence - alias TermUI.ComponentRegistry - - # Dialyzer: Functions return specific types - @dialyzer {:nowarn_function, get_component_info: 1} - - @default_shutdown_timeout 5_000 - - @doc """ - Starts the component supervisor. - - Called by the application supervisor during startup. - """ - @spec start_link(keyword()) :: Supervisor.on_start() - def start_link(opts \\ []) do - name = Keyword.get(opts, :name, __MODULE__) - DynamicSupervisor.start_link(__MODULE__, opts, name: name) - end - - @impl true - def init(opts) do - max_restarts = Keyword.get(opts, :max_restarts, 3) - max_seconds = Keyword.get(opts, :max_seconds, 5) - - DynamicSupervisor.init( - strategy: :one_for_one, - max_restarts: max_restarts, - max_seconds: max_seconds - ) - end - - @doc """ - Starts a component under the supervisor. - - ## Parameters - - - `module` - The component module implementing a behaviour - - `props` - Initial properties for the component - - `opts` - Options including `:id` for component identification - - ## Options - - - `:id` - Component identifier for registry lookup - - `:name` - Process name registration - - `:timeout` - Init timeout in milliseconds (default 5000) - - `:restart` - Restart strategy: `:transient`, `:permanent`, `:temporary` (default `:transient`) - - `:shutdown` - Shutdown timeout in ms or `:brutal_kill` (default 5000) - - `:recovery` - Recovery mode: `:reset`, `:last_props`, `:last_state` (default `:last_state`) - - ## Returns - - - `{:ok, pid}` - Component started successfully - - `{:error, reason}` - Failed to start - - ## Examples - - {:ok, pid} = ComponentSupervisor.start_component(Label, %{text: "Hello"}) - - {:ok, pid} = ComponentSupervisor.start_component( - Button, - %{label: "Click"}, - id: :submit_button - ) - """ - @spec start_component(module(), map(), keyword()) :: DynamicSupervisor.on_start_child() - def start_component(module, props, opts \\ []) do - component_id = Keyword.get(opts, :id, make_ref()) - restart = Keyword.get(opts, :restart, :transient) - shutdown = Keyword.get(opts, :shutdown, @default_shutdown_timeout) - recovery = Keyword.get(opts, :recovery, :last_state) - - # Store recovery mode for state persistence - full_opts = Keyword.put(opts, :recovery, recovery) - - # Set restart limits for this component if specified - if Keyword.has_key?(opts, :max_restarts) do - max_restarts = Keyword.get(opts, :max_restarts, 3) - max_seconds = Keyword.get(opts, :max_seconds, 5) - StatePersistence.set_restart_limits(component_id, max_restarts, max_seconds) - end - - child_spec = %{ - id: component_id, - start: {TermUI.ComponentServer, :start_link, [module, props, full_opts]}, - restart: restart, - shutdown: shutdown, - type: :worker - } - - DynamicSupervisor.start_child(__MODULE__, child_spec) - end - - @doc """ - Stops a component gracefully. - - Triggers the unmount lifecycle before termination. - - ## Parameters - - - `pid_or_id` - The component process pid or id - - `opts` - Options - - `:cascade` - Also stop all child components (default: false) - - ## Returns - - - `:ok` - Component stopped successfully - - `{:error, :not_found}` - Component not found - """ - @spec stop_component(pid() | term(), keyword()) :: :ok | {:error, :not_found} - def stop_component(pid_or_id, opts \\ []) - - def stop_component(pid, opts) when is_pid(pid) do - cascade = Keyword.get(opts, :cascade, false) - - # If cascade, find and stop children first - if cascade do - case ComponentRegistry.lookup_id(pid) do - {:ok, id} -> - stop_children(id) - - _ -> - :ok - end - end - - case DynamicSupervisor.terminate_child(__MODULE__, pid) do - :ok -> :ok - {:error, :not_found} -> {:error, :not_found} - end - end - - def stop_component(id, opts) do - case ComponentRegistry.lookup(id) do - {:ok, pid} -> stop_component(pid, opts) - {:error, :not_found} -> {:error, :not_found} - end - end - - defp stop_children(parent_id) do - children = ComponentRegistry.get_children(parent_id) - - # Stop children in reverse order (depth-first) - Enum.each(children, fn child_id -> - # Recursively stop grandchildren first - stop_children(child_id) - - # Then stop the child - case ComponentRegistry.lookup(child_id) do - {:ok, pid} -> - DynamicSupervisor.terminate_child(__MODULE__, pid) - - _ -> - :ok - end - end) - end - - @doc """ - Returns the count of running components. - """ - @spec count_children() :: non_neg_integer() - def count_children do - %{workers: count} = DynamicSupervisor.count_children(__MODULE__) - count - end - - @doc """ - Returns all component pids. - """ - @spec which_children() :: [pid()] - def which_children do - __MODULE__ - |> DynamicSupervisor.which_children() - |> Enum.map(fn {_, pid, _, _} -> pid end) - |> Enum.filter(&is_pid/1) - end - - @doc """ - Returns the component tree structure. - - Builds a hierarchical view of all components based on their - parent-child relationships in the registry. - - ## Returns - - A list of tree nodes, where each node contains: - - `:id` - Component identifier - - `:pid` - Process identifier - - `:module` - Component module - - `:children` - List of child nodes - - ## Examples - - tree = ComponentSupervisor.get_tree() - # [ - # %{id: :root, pid: #PID<0.123.0>, module: MyApp.Root, children: [ - # %{id: :child1, pid: #PID<0.124.0>, module: MyApp.Child, children: []} - # ]} - # ] - """ - @spec get_tree() :: [map()] - def get_tree do - # Get all components - all_components = ComponentRegistry.list_all() - - # Find root components (no parent) - roots = - Enum.filter(all_components, fn {id, _pid} -> - case ComponentRegistry.get_parent(id) do - {:ok, nil} -> true - {:error, :not_found} -> true - _ -> false - end - end) - - # Build tree recursively from roots - Enum.map(roots, fn {id, pid} -> - build_tree_node(id, pid) - end) - end - - defp build_tree_node(id, pid) do - # Get component module from server state - module = - try do - state = TermUI.ComponentServer.get_state(pid) - Map.get(state, :__module__, :unknown) - catch - _, _ -> :unknown - end - - # Get children - children = ComponentRegistry.get_children(id) - - child_nodes = - Enum.flat_map(children, fn child_id -> - case ComponentRegistry.lookup(child_id) do - {:ok, child_pid} -> [build_tree_node(child_id, child_pid)] - _ -> [] - end - end) - - %{ - id: id, - pid: pid, - module: module, - children: child_nodes - } - end - - @doc """ - Returns detailed information about a component. - - ## Parameters - - - `id` - Component identifier - - ## Returns - - - `{:ok, info}` - Component information map - - `{:error, :not_found}` - Component not found - - The info map contains: - - `:id` - Component identifier - - `:pid` - Process identifier - - `:module` - Component module - - `:lifecycle` - Current lifecycle stage - - `:restart_count` - Number of times restarted - - `:uptime_ms` - Milliseconds since process started - - `:state` - Current component state - - `:props` - Current props - - ## Examples - - {:ok, info} = ComponentSupervisor.get_component_info(:my_button) - info.uptime_ms - # => 12345 - """ - @spec get_component_info(term()) :: {:ok, map()} | {:error, :not_found} - def get_component_info(id) do - case ComponentRegistry.lookup(id) do - {:ok, pid} -> - info = build_component_info(id, pid) - {:ok, info} - - {:error, :not_found} -> - {:error, :not_found} - end - end - - defp build_component_info(id, pid) do - # Get basic info from ComponentServer - {state, props, lifecycle, module} = - try do - server_state = :sys.get_state(pid) - - { - Map.get(server_state, :component_state, %{}), - Map.get(server_state, :props, %{}), - Map.get(server_state, :lifecycle, :unknown), - Map.get(server_state, :module, :unknown) - } - catch - _, _ -> {%{}, %{}, :unknown, :unknown} - end - - # Get restart count from persistence - restart_count = StatePersistence.get_restart_count(id) - - # Calculate uptime from process info - uptime_ms = - case Process.info(pid, :start_time) do - {:start_time, start_time} -> - # start_time is in native time units since VM start - current = :erlang.monotonic_time(:millisecond) - start_ms = :erlang.convert_time_unit(start_time, :native, :millisecond) - current - start_ms - - nil -> - 0 - end - - %{ - id: id, - pid: pid, - module: module, - lifecycle: lifecycle, - restart_count: restart_count, - uptime_ms: uptime_ms, - state: state, - props: props - } - end - - @doc """ - Returns a text visualization of the component tree. - - Useful for debugging and logging. - - ## Examples - - IO.puts(ComponentSupervisor.format_tree()) - # └─ :root (MyApp.Root) #PID<0.123.0> - # ├─ :sidebar (MyApp.Sidebar) #PID<0.124.0> - # └─ :content (MyApp.Content) #PID<0.125.0> - """ - @spec format_tree() :: String.t() - def format_tree do - tree = get_tree() - do_format_tree(tree) - end - - defp do_format_tree([]), do: "(no components)" - - defp do_format_tree(tree) do - formatted = Enum.map(tree, fn node -> format_tree_node(node, "", true) end) - Enum.join(formatted, "\n") - end - - defp format_tree_node(node, prefix, is_last) do - connector = if is_last, do: "└─ ", else: "├─ " - - line = - "#{prefix}#{connector}#{inspect(node.id)} (#{inspect(node.module)}) #{inspect(node.pid)}" - - if Enum.empty?(node.children) do - line - else - child_prefix = prefix <> if(is_last, do: " ", else: "│ ") - - child_lines = - node.children - |> Enum.with_index() - |> Enum.map_join("\n", fn {child, idx} -> - is_last_child = idx == length(node.children) - 1 - format_tree_node(child, child_prefix, is_last_child) - end) - - line <> "\n" <> child_lines - end - end -end diff --git a/lib/term_ui/config.ex b/lib/term_ui/config.ex deleted file mode 100644 index d1e0f966..00000000 --- a/lib/term_ui/config.ex +++ /dev/null @@ -1,242 +0,0 @@ -defmodule TermUI.Config do - @moduledoc """ - Configuration reading and defaults for TermUI applications. - - This module provides application-level configuration for TermUI. - Configuration is read from the application environment and can be - overridden by runtime options. - - ## Configuration - - Add to your `config/config.exs`: - - import Config - - config :term_ui, - backend: :auto, - color_mode: :auto, - character_set: :auto, - render_interval: 16, - iex_compatible: :auto - - ## Options - - ### `:backend` - - Controls which terminal backend to use. - - - `:auto` - (default) Automatically detect and use the best available backend - - `:raw` - Force raw mode (requires OTP 28+, error if unavailable) - - `:tty` - Force TTY mode (line-based input, no raw mode attempt) - - Example: - config :term_ui, backend: :tty - - ### `:color_mode` - - Controls color depth preference. - - - `:auto` - (default) Detect terminal color support - - `:true_color` - Force 24-bit RGB color - - `:color_256` - Force 256-color palette - - `:color_16` - Force 16-color palette - - `:monochrome` - Force monochrome (no color) - - Example: - config :term_ui, color_mode: :color_256 - - ### `:character_set` - - Controls character set preference. - - - `:auto` - (default) Detect Unicode support - - `:unicode` - Force Unicode character set - - `:ascii` - Force ASCII character set - - Example: - config :term_ui, character_set: :ascii - - ### `:render_interval` - - Milliseconds between renders. - - - Default: `16` (~60 FPS) - - Lower values = smoother animations but more CPU usage - - Higher values = less CPU but choppier animations - - Example: - config :term_ui, render_interval: 33 # ~30 FPS - - ### `:iex_compatible` - - Controls IEx compatibility mode detection. - - - `:auto` - (default) Automatically detect if running in IEx - - `true` - Force IEx-compatible mode - - `false` - Force standalone mode - - This can also be controlled via the `TERM_UI_IEX_MODE` environment variable. - - Example: - config :term_ui, iex_compatible: true - - To override via environment variable: - export TERM_UI_IEX_MODE=true - - See `TermUI.iex_mode?/0` for more details on IEx detection. - - ## Runtime Options Override - - Runtime options passed to `TermUI.App.start/2` or `TermUI.App.run/2` - always take precedence over configuration: - - # Config says :tty, but runtime option says :raw - {:ok, _pid} = TermUI.App.start(MyApp, backend: :raw) - - ## Per-Environment Configuration - - You can configure different settings per environment: - - # config/dev.exs - config :term_ui, backend: :raw - - # config/test.exs - config :term_ui, backend: :tty - - # config/prod.exs - config :term_ui, backend: :auto - - """ - - @type option_key :: - :backend - | :color_mode - | :character_set - | :render_interval - | :skip_terminal - | :use_input_handler - | :name - - @type option :: {option_key(), term()} - - @default_backend :auto - @default_color_mode :auto - @default_character_set :auto - @default_render_interval 16 - - @doc """ - Gets a configuration value by key with an optional default. - - ## Examples - - iex> TermUI.Config.get(:backend) - :auto - - iex> TermUI.Config.get(:render_interval) - 16 - - iex> Application.put_env(:term_ui, :backend, :tty) - iex> TermUI.Config.get(:backend) - :tty - - """ - @spec get(option_key(), term()) :: term() - def get(key, default \\ nil) - - def get(:backend, default) do - Application.get_env(:term_ui, :backend, default || @default_backend) - end - - def get(:color_mode, default) do - Application.get_env(:term_ui, :color_mode, default || @default_color_mode) - end - - def get(:character_set, default) do - Application.get_env(:term_ui, :character_set, default || @default_character_set) - end - - def get(:render_interval, default) do - Application.get_env(:term_ui, :render_interval, default || @default_render_interval) - end - - def get(key, default) do - Application.get_env(:term_ui, key, default) - end - - @doc """ - Gets all configuration values as a keyword list. - - Returns the current application configuration merged with defaults. - - ## Examples - - iex> Keyword.keys(TermUI.Config.all()) - [:backend, :color_mode, :character_set, :render_interval] - - """ - @spec all() :: keyword() - def all do - [ - backend: get(:backend), - color_mode: get(:color_mode), - character_set: get(:character_set), - render_interval: get(:render_interval) - ] - end - - @doc """ - Merges application configuration with runtime options. - - Runtime options take precedence over application configuration. - This allows users to override config for specific cases. - - ## Priority - - 1. Runtime options (highest) - 2. Application configuration - 3. Module defaults (lowest) - - ## Examples - - iex> TermUI.Config.merge_options([backend: :auto]) - [backend: :auto, render_interval: 16, ...] - - iex> TermUI.Config.merge_options(backend: :raw, render_interval: 33) - [backend: :raw, render_interval: 33, ...] - - # Runtime option overrides config - iex> Application.put_env(:term_ui, :backend, :tty) - iex> opts = TermUI.Config.merge_options(backend: :raw) - iex> opts[:backend] - :raw - - """ - @spec merge_options(keyword()) :: keyword() - def merge_options(runtime_opts \\ []) do - config_opts = all() - - # Runtime options take precedence - Keyword.merge(config_opts, runtime_opts) - end - - @doc """ - Returns the default options without reading from application config. - - This is useful for testing or when you want to ignore application config. - - ## Examples - - iex> TermUI.Config.defaults() - [backend: :auto, color_mode: :auto, character_set: :auto, render_interval: 16] - - """ - @spec defaults() :: keyword() - def defaults do - [ - backend: @default_backend, - color_mode: @default_color_mode, - character_set: @default_character_set, - render_interval: @default_render_interval - ] - end -end diff --git a/lib/term_ui/container.ex b/lib/term_ui/container.ex deleted file mode 100644 index bfb817e8..00000000 --- a/lib/term_ui/container.ex +++ /dev/null @@ -1,333 +0,0 @@ -defmodule TermUI.Container do - @moduledoc """ - Behaviour for container components that manage children. - - Container extends StatefulComponent with child management capabilities. - Use this for components that contain and organize other components, - like panels, forms, tabs, or split views. - - ## Basic Usage - - defmodule MyApp.Panel do - use TermUI.Container - - @impl true - def init(props) do - {:ok, %{title: props[:title] || "Panel"}} - end - - @impl true - def children(_state) do - [ - {MyApp.Label, %{text: "Header"}, :header}, - {MyApp.Content, %{}, :content} - ] - end - - @impl true - def layout(children, state, area) do - # Arrange children within available area - header_area = %{area | height: 1} - content_area = %{area | y: area.y + 1, height: area.height - 1} - - [ - {Enum.at(children, 0), header_area}, - {Enum.at(children, 1), content_area} - ] - end - - @impl true - def render(state, _area) do - # Container render is called after children - # Return empty if children handle all rendering - empty() - end - - @impl true - def handle_event(_event, state) do - {:ok, state} - end - end - - ## Child Specifications - - Children are specified as tuples: - - - `{Module, props}` - Child with auto-generated ID - - `{Module, props, id}` - Child with explicit ID - - IDs are used for event routing and child lookup. - - ## Layout - - The `layout/3` callback positions children within the container's area. - It receives the list of child specs and must return tuples of - `{child_spec, area}` assigning each child its rendering bounds. - - ## Event Routing - - Containers can route events to specific children or handle them directly. - Override `route_event/2` to customize event routing. - """ - - alias TermUI.Component.RenderNode - - # Type definitions - - @typedoc "Component state" - @type state :: term() - - @typedoc "Available rendering area" - @type rect :: %{x: integer(), y: integer(), width: integer(), height: integer()} - - @typedoc "Render tree output" - @type render_tree :: RenderNode.t() | [render_tree()] | String.t() - - @typedoc "Event from user input" - @type event :: term() - - @typedoc "Command for side effects" - @type command :: term() - - @typedoc "Child specification" - @type child_spec :: - {module(), props :: map()} - | {module(), props :: map(), id :: term()} - - @typedoc "Child with assigned area" - @type child_layout :: {child_spec(), rect()} - - @typedoc "Event routing target" - @type route_target :: - :self - | {:child, id :: term()} - | :broadcast - - # Required callbacks (inherited from StatefulComponent) - - @doc """ - Initializes container state from props. - - Same as `StatefulComponent.init/1`. - """ - @callback init(props :: map()) :: - {:ok, state()} - | {:ok, state(), [command()]} - | {:stop, term()} - - @doc """ - Returns the list of child components. - - Called to determine which children the container should manage. - Children are specified as tuples with module, props, and optional ID. - - ## Parameters - - - `state` - Current container state - - ## Returns - - List of child specifications. - - ## Examples - - @impl true - def children(state) do - [ - {Label, %{text: state.title}, :title}, - {Button, %{label: "OK"}, :ok_button}, - {Button, %{label: "Cancel"}, :cancel_button} - ] - end - """ - @callback children(state()) :: [child_spec()] - - @doc """ - Lays out children within the available area. - - Determines the position and size of each child component. - The default implementation stacks children vertically. - - ## Parameters - - - `children` - List of child specifications from `children/1` - - `state` - Current container state - - `area` - Available area for the container - - ## Returns - - List of `{child_spec, area}` tuples. - - ## Examples - - @impl true - def layout(children, _state, area) do - # Horizontal layout with equal widths - child_width = div(area.width, length(children)) - - children - |> Enum.with_index() - |> Enum.map(fn {child, i} -> - child_area = %{ - x: area.x + i * child_width, - y: area.y, - width: child_width, - height: area.height - } - {child, child_area} - end) - end - """ - @callback layout([child_spec()], state(), rect()) :: [child_layout()] - - @doc """ - Handles input events. - - Same as `StatefulComponent.handle_event/2`. - """ - @callback handle_event(event(), state()) :: - {:ok, state()} - | {:ok, state(), [command()]} - | {:stop, term(), state()} - - @doc """ - Renders the container. - - Called after children are rendered. Can render container chrome - (borders, titles) or return empty if children handle everything. - - Same signature as `StatefulComponent.render/2`. - """ - @callback render(state(), rect()) :: render_tree() - - # Optional callbacks - - @doc """ - Routes an event to the appropriate handler. - - Override to customize how events are distributed to children. - Default routes all events to self. - - ## Parameters - - - `event` - The input event - - `state` - Current container state - - ## Returns - - - `:self` - Handle event in this container - - `{:child, id}` - Route to specific child - - `:broadcast` - Send to all children - """ - @callback route_event(event(), state()) :: route_target() - - @doc """ - Called when a child emits a message. - - Use to handle messages bubbling up from child components. - - ## Parameters - - - `child_id` - ID of the child that sent the message - - `message` - The message from the child - - `state` - Current container state - """ - @callback handle_child_message(child_id :: term(), message :: term(), state()) :: - {:ok, state()} - | {:ok, state(), [command()]} - - @optional_callbacks route_event: 2, handle_child_message: 3 - - @doc false - defmacro __using__(_opts) do - quote do - @behaviour TermUI.Container - - alias TermUI.Component.RenderNode - alias TermUI.Renderer.Style - - import TermUI.Component.Helpers - - # Default implementations - - @doc false - def terminate(_reason, _state), do: :ok - - @doc false - def handle_info(_message, state), do: {:ok, state} - - @doc false - def handle_call(_request, _from, state), do: {:reply, :ok, state} - - @doc false - def route_event(_event, _state), do: :self - - @doc false - def handle_child_message(_child_id, _message, state), do: {:ok, state} - - @doc """ - Default layout: stack children vertically. - """ - def layout(children, _state, area) do - child_count = length(children) - - if child_count == 0 do - [] - else - child_height = div(area.height, child_count) - - children - |> Enum.with_index() - |> Enum.map(fn {child, i} -> - child_area = %{ - x: area.x, - y: area.y + i * child_height, - width: area.width, - height: child_height - } - - {child, child_area} - end) - end - end - - defoverridable terminate: 2, - handle_info: 2, - handle_call: 3, - route_event: 2, - handle_child_message: 3, - layout: 3 - - # Helper functions for child management - - @doc """ - Normalizes a child spec to always have an ID. - """ - def normalize_child_spec({module, props}) when is_atom(module) and is_map(props) do - {module, props, make_ref()} - end - - def normalize_child_spec({module, props, id}) when is_atom(module) and is_map(props) do - {module, props, id} - end - - @doc """ - Gets the ID from a child spec. - """ - def child_id({_module, _props, id}), do: id - def child_id({_module, _props}), do: nil - - @doc """ - Gets the module from a child spec. - """ - def child_module({module, _props, _id}), do: module - def child_module({module, _props}), do: module - - @doc """ - Gets the props from a child spec. - """ - def child_props({_module, props, _id}), do: props - def child_props({_module, props}), do: props - end - end -end diff --git a/lib/term_ui/renderer/cursor_optimizer.ex b/lib/term_ui/cursor_optimizer.ex similarity index 90% rename from lib/term_ui/renderer/cursor_optimizer.ex rename to lib/term_ui/cursor_optimizer.ex index 64632125..23c4962e 100644 --- a/lib/term_ui/renderer/cursor_optimizer.ex +++ b/lib/term_ui/cursor_optimizer.ex @@ -1,31 +1,5 @@ -defmodule TermUI.Renderer.CursorOptimizer do - @moduledoc """ - Optimizes cursor movement by selecting the cheapest movement option. - - Instead of always using absolute positioning (`ESC[{row};{col}H`), this module - calculates the byte cost of various movement options and selects the minimum. - This can reduce cursor movement overhead by 40%+ compared to naive positioning. - - ## Movement Options - - * Absolute positioning: `ESC[{r};{c}H` (6-10 bytes) - * Relative up/down/left/right: `ESC[{n}A/B/C/D` (4-6 bytes) - * Carriage return: `\\r` (1 byte) - * Newline: `\\n` (1 byte) - * Home: `ESC[H` (3 bytes) - * Literal spaces for small rightward moves (1 byte each) - - ## Usage - - # Create optimizer with initial position - optimizer = CursorOptimizer.new() - - # Get optimal movement sequence - {sequence, new_optimizer} = CursorOptimizer.move_to(optimizer, 5, 10) - - # After text output, advance cursor - new_optimizer = CursorOptimizer.advance(optimizer, 5) - """ +defmodule TermUI.CursorOptimizer do + @moduledoc false # Dialyzer: Functions return specific struct types or specific integers, # but the public spec uses general types for API clarity. @@ -38,9 +12,14 @@ defmodule TermUI.Renderer.CursorOptimizer do bytes_saved: non_neg_integer() } - defstruct row: 1, - col: 1, - bytes_saved: 0 + @schema Zoi.struct(__MODULE__, %{ + row: Zoi.integer() |> Zoi.positive() |> Zoi.default(1), + col: Zoi.integer() |> Zoi.positive() |> Zoi.default(1), + bytes_saved: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0) + }) + + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) # Cost threshold for using spaces instead of cursor right @space_threshold 3 diff --git a/lib/term_ui/dev/dev_mode.ex b/lib/term_ui/dev/dev_mode.ex deleted file mode 100644 index fa7fea02..00000000 --- a/lib/term_ui/dev/dev_mode.ex +++ /dev/null @@ -1,464 +0,0 @@ -defmodule TermUI.Dev.DevMode do - @moduledoc """ - Central coordinator for development mode features. - - DevMode manages the lifecycle and state of all development tools: - - UI Inspector - Shows component boundaries - - State Inspector - Displays component state tree - - Hot Reload - Updates code without restart - - Performance Monitor - Shows FPS, memory, frame times - - ## Usage - - # Enable development mode - DevMode.enable() - - # Toggle individual features - DevMode.toggle_ui_inspector() - DevMode.toggle_state_inspector() - DevMode.toggle_perf_monitor() - - # Check status - DevMode.enabled?() - DevMode.ui_inspector_enabled?() - - ## Keyboard Shortcuts (when enabled) - - - Ctrl+Shift+I: Toggle UI Inspector - - Ctrl+Shift+S: Toggle State Inspector - - Ctrl+Shift+P: Toggle Performance Monitor - """ - - use GenServer - - alias TermUI.Dev.HotReload - alias TermUI.Dev.PerfMonitor - alias TermUI.Dev.StateInspector - alias TermUI.Dev.UIInspector - - @type state :: %{ - enabled: boolean(), - ui_inspector: boolean(), - state_inspector: boolean(), - perf_monitor: boolean(), - hot_reload: boolean(), - selected_component: term() | nil, - components: %{term() => component_info()}, - metrics: metrics() - } - - @type component_info :: %{ - module: module(), - state: term(), - render_time: integer(), - bounds: bounds() - } - - @type bounds :: %{x: integer(), y: integer(), width: integer(), height: integer()} - - @type metrics :: %{ - fps: float(), - frame_times: [integer()], - memory: integer(), - process_count: integer() - } - - # Client API - - @doc """ - Starts the DevMode server. - """ - def start_link(opts \\ []) do - GenServer.start_link(__MODULE__, opts, name: __MODULE__) - end - - @doc """ - Enables development mode. - """ - @spec enable() :: :ok - def enable do - GenServer.call(__MODULE__, :enable) - end - - @doc """ - Disables development mode. - """ - @spec disable() :: :ok - def disable do - GenServer.call(__MODULE__, :disable) - end - - @doc """ - Returns whether development mode is enabled. - """ - @spec enabled?() :: boolean() - def enabled? do - GenServer.call(__MODULE__, :enabled?) - end - - @doc """ - Toggles UI inspector overlay. - """ - @spec toggle_ui_inspector() :: boolean() - def toggle_ui_inspector do - GenServer.call(__MODULE__, :toggle_ui_inspector) - end - - @doc """ - Returns whether UI inspector is enabled. - """ - @spec ui_inspector_enabled?() :: boolean() - def ui_inspector_enabled? do - GenServer.call(__MODULE__, :ui_inspector_enabled?) - end - - @doc """ - Toggles state inspector panel. - """ - @spec toggle_state_inspector() :: boolean() - def toggle_state_inspector do - GenServer.call(__MODULE__, :toggle_state_inspector) - end - - @doc """ - Returns whether state inspector is enabled. - """ - @spec state_inspector_enabled?() :: boolean() - def state_inspector_enabled? do - GenServer.call(__MODULE__, :state_inspector_enabled?) - end - - @doc """ - Toggles performance monitor. - """ - @spec toggle_perf_monitor() :: boolean() - def toggle_perf_monitor do - GenServer.call(__MODULE__, :toggle_perf_monitor) - end - - @doc """ - Returns whether performance monitor is enabled. - """ - @spec perf_monitor_enabled?() :: boolean() - def perf_monitor_enabled? do - GenServer.call(__MODULE__, :perf_monitor_enabled?) - end - - @doc """ - Toggles hot reload. - """ - @spec toggle_hot_reload() :: boolean() - def toggle_hot_reload do - GenServer.call(__MODULE__, :toggle_hot_reload) - end - - @doc """ - Registers a component for inspection. - """ - @spec register_component(term(), module(), term(), bounds()) :: :ok - def register_component(id, module, state, bounds) do - GenServer.cast(__MODULE__, {:register_component, id, module, state, bounds}) - end - - @doc """ - Unregisters a component. - """ - @spec unregister_component(term()) :: :ok - def unregister_component(id) do - GenServer.cast(__MODULE__, {:unregister_component, id}) - end - - @doc """ - Updates component state for inspection. - """ - @spec update_component_state(term(), term()) :: :ok - def update_component_state(id, state) do - GenServer.cast(__MODULE__, {:update_component_state, id, state}) - end - - @doc """ - Records component render time. - """ - @spec record_render_time(term(), integer()) :: :ok - def record_render_time(id, time_us) do - GenServer.cast(__MODULE__, {:record_render_time, id, time_us}) - end - - @doc """ - Selects a component for detailed inspection. - """ - @spec select_component(term()) :: :ok - def select_component(id) do - GenServer.cast(__MODULE__, {:select_component, id}) - end - - @doc """ - Gets the currently selected component. - """ - @spec get_selected_component() :: term() | nil - def get_selected_component do - GenServer.call(__MODULE__, :get_selected_component) - end - - @doc """ - Gets all registered components. - """ - @spec get_components() :: %{term() => component_info()} - def get_components do - GenServer.call(__MODULE__, :get_components) - end - - @doc """ - Gets current performance metrics. - """ - @spec get_metrics() :: metrics() - def get_metrics do - GenServer.call(__MODULE__, :get_metrics) - end - - @doc """ - Records a frame for FPS calculation. - """ - @spec record_frame(integer()) :: :ok - def record_frame(frame_time_us) do - GenServer.cast(__MODULE__, {:record_frame, frame_time_us}) - end - - @doc """ - Handles keyboard shortcut for development mode. - """ - @spec handle_shortcut(atom(), [atom()]) :: :handled | :not_handled - def handle_shortcut(key, modifiers) do - GenServer.call(__MODULE__, {:handle_shortcut, key, modifiers}) - end - - @doc """ - Gets the current state for rendering overlays. - """ - @spec get_state() :: state() - def get_state do - GenServer.call(__MODULE__, :get_state) - end - - # Server callbacks - - @impl true - def init(_opts) do - state = %{ - enabled: false, - ui_inspector: false, - state_inspector: false, - perf_monitor: false, - hot_reload: false, - selected_component: nil, - components: %{}, - metrics: %{ - fps: 0.0, - frame_times: [], - memory: 0, - process_count: 0 - } - } - - {:ok, state} - end - - @impl true - def handle_call(:enable, _from, state) do - {:reply, :ok, %{state | enabled: true}} - end - - def handle_call(:disable, _from, state) do - {:reply, :ok, %{state | enabled: false}} - end - - def handle_call(:enabled?, _from, state) do - {:reply, state.enabled, state} - end - - def handle_call(:toggle_ui_inspector, _from, state) do - new_value = not state.ui_inspector - {:reply, new_value, %{state | ui_inspector: new_value}} - end - - def handle_call(:ui_inspector_enabled?, _from, state) do - {:reply, state.ui_inspector, state} - end - - def handle_call(:toggle_state_inspector, _from, state) do - new_value = not state.state_inspector - {:reply, new_value, %{state | state_inspector: new_value}} - end - - def handle_call(:state_inspector_enabled?, _from, state) do - {:reply, state.state_inspector, state} - end - - def handle_call(:toggle_perf_monitor, _from, state) do - new_value = not state.perf_monitor - {:reply, new_value, %{state | perf_monitor: new_value}} - end - - def handle_call(:perf_monitor_enabled?, _from, state) do - {:reply, state.perf_monitor, state} - end - - def handle_call(:toggle_hot_reload, _from, state) do - new_value = not state.hot_reload - - if new_value do - HotReload.start() - else - HotReload.stop() - end - - {:reply, new_value, %{state | hot_reload: new_value}} - end - - def handle_call(:get_selected_component, _from, state) do - {:reply, state.selected_component, state} - end - - def handle_call(:get_components, _from, state) do - {:reply, state.components, state} - end - - def handle_call(:get_metrics, _from, state) do - {:reply, state.metrics, state} - end - - def handle_call(:get_state, _from, state) do - {:reply, state, state} - end - - def handle_call({:handle_shortcut, key, modifiers}, _from, state) do - if state.enabled and :ctrl in modifiers and :shift in modifiers do - case key do - :i -> - new_value = not state.ui_inspector - {:reply, :handled, %{state | ui_inspector: new_value}} - - :s -> - new_value = not state.state_inspector - {:reply, :handled, %{state | state_inspector: new_value}} - - :p -> - new_value = not state.perf_monitor - {:reply, :handled, %{state | perf_monitor: new_value}} - - _ -> - {:reply, :not_handled, state} - end - else - {:reply, :not_handled, state} - end - end - - @impl true - def handle_cast({:register_component, id, module, comp_state, bounds}, state) do - component_info = %{ - module: module, - state: comp_state, - render_time: 0, - bounds: bounds - } - - components = Map.put(state.components, id, component_info) - {:noreply, %{state | components: components}} - end - - def handle_cast({:unregister_component, id}, state) do - components = Map.delete(state.components, id) - selected = if state.selected_component == id, do: nil, else: state.selected_component - {:noreply, %{state | components: components, selected_component: selected}} - end - - def handle_cast({:update_component_state, id, comp_state}, state) do - components = - update_in(state.components, [id], fn - nil -> nil - info -> %{info | state: comp_state} - end) - - {:noreply, %{state | components: components}} - end - - def handle_cast({:record_render_time, id, time_us}, state) do - components = - update_in(state.components, [id], fn - nil -> nil - info -> %{info | render_time: time_us} - end) - - {:noreply, %{state | components: components}} - end - - def handle_cast({:select_component, id}, state) do - {:noreply, %{state | selected_component: id}} - end - - def handle_cast({:record_frame, frame_time_us}, state) do - # Keep last 60 frame times for rolling average - frame_times = [frame_time_us | state.metrics.frame_times] |> Enum.take(60) - - # Calculate FPS from average frame time - avg_time = - if length(frame_times) > 0 do - Enum.sum(frame_times) / length(frame_times) - else - # Default to ~60 FPS - 16_666 - end - - fps = if avg_time > 0, do: 1_000_000 / avg_time, else: 0.0 - - # Get memory and process count - memory = :erlang.memory(:total) - process_count = length(Process.list()) - - metrics = %{ - fps: fps, - frame_times: frame_times, - memory: memory, - process_count: process_count - } - - {:noreply, %{state | metrics: metrics}} - end - - # Rendering helpers - - @doc """ - Renders development mode overlays. - - Returns render nodes for UI inspector, state inspector, and performance monitor. - """ - @spec render_overlays(state(), bounds()) :: term() - def render_overlays(state, area) do - overlays = [] - - overlays = - if state.ui_inspector do - [UIInspector.render(state.components, state.selected_component, area) | overlays] - else - overlays - end - - overlays = - if state.state_inspector do - selected = state.components[state.selected_component] - [StateInspector.render(selected, area) | overlays] - else - overlays - end - - overlays = - if state.perf_monitor do - [PerfMonitor.render(state.metrics, area) | overlays] - else - overlays - end - - overlays - end -end diff --git a/lib/term_ui/dev/hot_reload.ex b/lib/term_ui/dev/hot_reload.ex deleted file mode 100644 index 544b9681..00000000 --- a/lib/term_ui/dev/hot_reload.ex +++ /dev/null @@ -1,367 +0,0 @@ -defmodule TermUI.Dev.HotReload do - @moduledoc """ - Hot Reload integration for development mode. - - Watches .ex files for changes and reloads modules without restarting - the application. State is preserved across reloads where possible. - - ## Usage - - # Start hot reload - HotReload.start() - - # Stop hot reload - HotReload.stop() - - # Manually reload a module - HotReload.reload_module(MyModule) - - ## How It Works - - 1. File watcher monitors lib/ directory for .ex changes - 2. On change, affected modules are identified - 3. Modules are recompiled using Mix - 4. Old code is purged and new code loaded - 5. Notification sent to UI - - Note: Uses polling-based approach for compatibility. - """ - - use GenServer - - require Logger - - # 1 second - @poll_interval 1000 - - @type state :: %{ - enabled: boolean(), - watched_dirs: [String.t()], - file_mtimes: %{String.t() => integer()}, - on_reload: (module() -> any()) | nil - } - - # Dialyzer: Pattern match coverage warnings - @dialyzer {:nowarn_function, - handle_info: 2, reload_module: 1, check_for_changes: 1, recompile_file: 1} - - # Client API - - @doc """ - Starts the hot reload watcher. - """ - def start_link(opts \\ []) do - GenServer.start_link(__MODULE__, opts, name: __MODULE__) - end - - @doc """ - Starts watching for file changes. - """ - @spec start() :: :ok - def start do - GenServer.call(__MODULE__, :start) - end - - @doc """ - Stops watching for file changes. - """ - @spec stop() :: :ok - def stop do - GenServer.call(__MODULE__, :stop) - end - - @doc """ - Returns whether hot reload is running. - """ - @spec running?() :: boolean() - def running? do - GenServer.call(__MODULE__, :running?) - end - - @doc """ - Manually reloads a specific module. - """ - @spec reload_module(module()) :: :ok | {:error, term()} - def reload_module(module) do - GenServer.call(__MODULE__, {:reload_module, module}) - end - - @doc """ - Sets callback for reload notifications. - """ - @spec on_reload((module() -> any())) :: :ok - def on_reload(callback) do - GenServer.cast(__MODULE__, {:on_reload, callback}) - end - - @doc """ - Gets recently reloaded modules. - """ - @spec get_recent_reloads() :: [{module(), DateTime.t()}] - def get_recent_reloads do - GenServer.call(__MODULE__, :get_recent_reloads) - end - - # Server callbacks - - @impl true - def init(opts) do - state = %{ - enabled: false, - watched_dirs: Keyword.get(opts, :dirs, ["lib"]), - file_mtimes: %{}, - on_reload: nil, - recent_reloads: [] - } - - {:ok, state} - end - - @impl true - def handle_call(:start, _from, state) do - if state.enabled do - {:reply, :ok, state} - else - # Initial file scan - file_mtimes = scan_files(state.watched_dirs) - - # Start polling - schedule_poll() - - Logger.info("Hot reload started, watching #{length(state.watched_dirs)} directories") - {:reply, :ok, %{state | enabled: true, file_mtimes: file_mtimes}} - end - end - - def handle_call(:stop, _from, state) do - Logger.info("Hot reload stopped") - {:reply, :ok, %{state | enabled: false}} - end - - def handle_call(:running?, _from, state) do - {:reply, state.enabled, state} - end - - def handle_call({:reload_module, module}, _from, state) do - result = do_reload_module(module) - - state = - case result do - :ok -> - notify_reload(state.on_reload, module) - add_recent_reload(state, module) - - _ -> - state - end - - {:reply, result, state} - end - - def handle_call(:get_recent_reloads, _from, state) do - {:reply, state.recent_reloads, state} - end - - @impl true - def handle_cast({:on_reload, callback}, state) do - {:noreply, %{state | on_reload: callback}} - end - - @impl true - def handle_info(:poll, state) do - if state.enabled do - state = check_for_changes(state) - schedule_poll() - {:noreply, state} - else - {:noreply, state} - end - end - - def handle_info(_msg, state) do - {:noreply, state} - end - - # Private functions - - defp schedule_poll do - Process.send_after(self(), :poll, @poll_interval) - end - - defp scan_files(dirs) do - dirs - |> Enum.flat_map(&find_ex_files/1) - |> Enum.map(fn path -> - mtime = get_file_mtime(path) - {path, mtime} - end) - |> Map.new() - end - - defp find_ex_files(dir) do - if File.dir?(dir) do - Path.wildcard(Path.join(dir, "**/*.ex")) - else - [] - end - end - - defp get_file_mtime(path) do - case File.stat(path, time: :posix) do - {:ok, %{mtime: mtime}} -> mtime - _ -> 0 - end - end - - defp check_for_changes(state) do - current_mtimes = scan_files(state.watched_dirs) - - # Find changed files - changed_files = - current_mtimes - |> Enum.filter(fn {path, mtime} -> - old_mtime = Map.get(state.file_mtimes, path, 0) - mtime > old_mtime - end) - |> Enum.map(fn {path, _} -> path end) - - if length(changed_files) > 0 do - Logger.debug("Hot reload detected changes in #{length(changed_files)} files") - - # Reload changed files - state = - Enum.reduce(changed_files, state, fn path, acc -> - reload_file(path, acc) - end) - - %{state | file_mtimes: current_mtimes} - else - state - end - end - - defp reload_file(path, state) do - Logger.info("Hot reloading: #{path}") - - case recompile_file(path) do - {:ok, modules} -> - reload_modules(modules, state) - - {:error, reason} -> - Logger.error("Failed to recompile #{path}: #{inspect(reason)}") - state - end - end - - defp reload_modules(modules, state) do - Enum.reduce(modules, state, fn module, acc -> - reload_single_module(module, acc, state.on_reload) - end) - end - - defp reload_single_module(module, state, on_reload) do - case do_reload_module(module) do - :ok -> - notify_reload(on_reload, module) - add_recent_reload(state, module) - - {:error, reason} -> - Logger.error("Failed to reload #{module}: #{inspect(reason)}") - state - end - end - - defp recompile_file(path) do - # Get modules defined in the file before recompilation - _old_modules = get_modules_in_file(path) - - # Recompile using Code module - case Code.compile_file(path) do - modules when is_list(modules) -> - module_names = Enum.map(modules, fn {name, _binary} -> name end) - {:ok, module_names} - - _ -> - {:error, :compilation_failed} - end - rescue - e -> - {:error, e} - end - - defp get_modules_in_file(path) do - # Parse file to find module definitions - case File.read(path) do - {:ok, content} -> - Regex.scan(~r/defmodule\s+([\w.]+)/, content) - |> Enum.map(fn [_, name] -> - String.to_atom("Elixir.#{name}") - end) - - _ -> - [] - end - end - - defp do_reload_module(module) do - # Purge old code - :code.purge(module) - - # Delete old code if still loaded - :code.delete(module) - - # The module should already be loaded from compilation - # Just ensure it's available - case Code.ensure_loaded(module) do - {:module, ^module} -> :ok - {:error, reason} -> {:error, reason} - end - rescue - e -> {:error, e} - end - - defp notify_reload(nil, _module), do: :ok - - defp notify_reload(callback, module) when is_function(callback, 1) do - callback.(module) - rescue - e -> - Logger.error("Hot reload callback failed: #{inspect(e)}") - end - - defp add_recent_reload(state, module) do - reload = {module, DateTime.utc_now()} - recent = [reload | state.recent_reloads] |> Enum.take(20) - %{state | recent_reloads: recent} - end - - # Public helpers - - @doc """ - Gets the source file path for a module. - """ - @spec get_module_source(module()) :: String.t() | nil - def get_module_source(module) do - case module.__info__(:compile)[:source] do - source when is_list(source) -> List.to_string(source) - _ -> nil - end - rescue - _ -> nil - end - - @doc """ - Checks if a module can be hot reloaded. - - Some modules (like those with NIFs or ports) may not reload properly. - """ - @spec can_reload?(module()) :: boolean() - def can_reload?(module) do - # Check if module exists and is loaded - # Check if it has source info (not a native module) - Code.ensure_loaded?(module) and - is_list(module.__info__(:compile)[:source]) - rescue - _ -> false - end -end diff --git a/lib/term_ui/dev/perf_monitor.ex b/lib/term_ui/dev/perf_monitor.ex deleted file mode 100644 index ca27dd72..00000000 --- a/lib/term_ui/dev/perf_monitor.ex +++ /dev/null @@ -1,235 +0,0 @@ -defmodule TermUI.Dev.PerfMonitor do - @moduledoc """ - Performance Monitor for development mode. - - Displays real-time performance metrics: FPS, frame time, memory usage, - and process count. Toggle with Ctrl+Shift+P when dev mode is enabled. - - ## Metrics - - - **FPS**: Frames per second (rolling average) - - **Frame Time**: Time to render each frame (graph) - - **Memory**: Total BEAM memory usage - - **Processes**: Number of BEAM processes - """ - - import TermUI.Component.Helpers - - # Dialyzer: Functions return specific map types - @dialyzer {:nowarn_function, get_memory_breakdown: 0} - - @panel_width 35 - @graph_height 5 - - @doc """ - Renders the performance monitor panel. - - Returns render nodes for the metrics display. - """ - @spec render(map(), map()) :: term() - def render(metrics, _area) do - # Build panel content - header = render_header() - fps_line = render_fps(metrics.fps) - frame_graph = render_frame_graph(metrics.frame_times) - memory_line = render_memory(metrics.memory) - process_line = render_processes(metrics.process_count) - footer = render_footer() - - content = [header, fps_line] ++ frame_graph ++ [memory_line, process_line, footer] - - panel = stack(:vertical, content) - - # Position at bottom-left - %{ - type: :positioned, - content: panel, - x: 0, - # Will be adjusted by renderer - y: 0, - # Below inspectors but above content - z: 195 - } - end - - defp render_header do - title = " Performance Monitor " - remaining = @panel_width - String.length(title) - left = div(remaining, 2) - right = remaining - left - - text( - "┌" <> String.duplicate("─", left - 1) <> title <> String.duplicate("─", right - 1) <> "┐" - ) - end - - defp render_footer do - text("└" <> String.duplicate("─", @panel_width - 2) <> "┘") - end - - defp render_fps(fps) do - fps_str = Float.round(fps, 1) |> to_string() - label = "FPS: #{fps_str}" - padded = String.pad_trailing(label, @panel_width - 4) - text("│ " <> padded <> " │") - end - - defp render_memory(bytes) do - memory_str = format_bytes(bytes) - label = "Memory: #{memory_str}" - padded = String.pad_trailing(label, @panel_width - 4) - text("│ " <> padded <> " │") - end - - defp render_processes(count) do - label = "Processes: #{count}" - padded = String.pad_trailing(label, @panel_width - 4) - text("│ " <> padded <> " │") - end - - defp render_frame_graph(frame_times) when frame_times == [] do - # Empty graph - for _i <- 1..@graph_height do - text("│" <> String.duplicate(" ", @panel_width - 2) <> "│") - end - end - - defp render_frame_graph(frame_times) do - # Normalize frame times to graph height - max_time = Enum.max(frame_times) - min_time = Enum.min(frame_times) - range = max(1, max_time - min_time) - - # Take last N frame times that fit in width - graph_width = @panel_width - 4 - times = frame_times |> Enum.take(graph_width) |> Enum.reverse() - - # Create graph rows (top to bottom) - for row <- (@graph_height - 1)..0//-1 do - threshold = min_time + row / @graph_height * range - - chars = - Enum.map_join(times, "", fn time -> - if time >= threshold, do: "▄", else: " " - end) - - padded = String.pad_trailing(chars, graph_width) - text("│ " <> padded <> " │") - end - end - - @doc """ - Formats bytes into human-readable string. - """ - @spec format_bytes(integer()) :: String.t() - def format_bytes(bytes) when bytes < 1024 do - "#{bytes} B" - end - - def format_bytes(bytes) when bytes < 1024 * 1024 do - kb = Float.round(bytes / 1024, 1) - "#{kb} KB" - end - - def format_bytes(bytes) when bytes < 1024 * 1024 * 1024 do - mb = Float.round(bytes / (1024 * 1024), 1) - "#{mb} MB" - end - - def format_bytes(bytes) do - gb = Float.round(bytes / (1024 * 1024 * 1024), 2) - "#{gb} GB" - end - - @doc """ - Formats microseconds into human-readable string. - """ - @spec format_time(integer()) :: String.t() - def format_time(us) when us < 1000 do - "#{us}μs" - end - - def format_time(us) when us < 1_000_000 do - ms = Float.round(us / 1000, 1) - "#{ms}ms" - end - - def format_time(us) do - s = Float.round(us / 1_000_000, 2) - "#{s}s" - end - - @doc """ - Gets detailed BEAM memory breakdown. - """ - @spec get_memory_breakdown() :: map() - def get_memory_breakdown do - %{ - total: :erlang.memory(:total), - processes: :erlang.memory(:processes), - atom: :erlang.memory(:atom), - binary: :erlang.memory(:binary), - code: :erlang.memory(:code), - ets: :erlang.memory(:ets) - } - end - - @doc """ - Gets scheduler utilization. - """ - @spec get_scheduler_utilization() :: [float()] - def get_scheduler_utilization do - # The :scheduler.utilization/1 function is only available in OTP 28+ - # Using apply/3 to avoid compiler warning about undefined function - if function_exported?(:scheduler, :utilization, 1) do - try do - # credo:disable-for-next-line Credo.Check.Refactor.Apply - case apply(:scheduler, :utilization, [1]) do - [{:total, _, total} | _] -> [total] - _ -> [] - end - rescue - _ -> [] - end - else - [] - end - end - - @doc """ - Gets message queue length for a process. - """ - @spec get_message_queue_length(pid()) :: integer() - def get_message_queue_length(pid) do - case Process.info(pid, :message_queue_len) do - {:message_queue_len, len} -> len - _ -> 0 - end - end - - @doc """ - Gets reduction count for a process (rough CPU usage indicator). - """ - @spec get_reductions(pid()) :: integer() - def get_reductions(pid) do - case Process.info(pid, :reductions) do - {:reductions, count} -> count - _ -> 0 - end - end - - @doc """ - Calculates sparkline characters for a list of values. - """ - @spec values_to_sparkline([number()], number(), number()) :: String.t() - def values_to_sparkline(values, min_val, max_val) do - bars = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] - range = max(1, max_val - min_val) - - Enum.map_join(values, "", fn value -> - normalized = (value - min_val) / range - index = min(7, trunc(normalized * 8)) - Enum.at(bars, index) - end) - end -end diff --git a/lib/term_ui/dev/state_inspector.ex b/lib/term_ui/dev/state_inspector.ex deleted file mode 100644 index a879a84c..00000000 --- a/lib/term_ui/dev/state_inspector.ex +++ /dev/null @@ -1,292 +0,0 @@ -defmodule TermUI.Dev.StateInspector do - @moduledoc """ - State Inspector panel for development mode. - - Shows detailed component state in a side panel with expandable tree view. - Toggle with Ctrl+Shift+S when dev mode is enabled. - - ## Features - - - Tree view of component state - - Expand/collapse nested values - - State change highlighting - - Type information display - """ - - import TermUI.Component.Helpers - - @default_width 40 - - @doc """ - Renders the state inspector panel. - - Returns render nodes for the side panel with state tree. - """ - @spec render(map() | nil, map()) :: term() - def render(nil, _area) do - render_empty_panel() - end - - def render(component_info, area) do - panel_width = min(@default_width, div(area.width, 3)) - panel_x = area.width - panel_width - - # Render state tree - state_tree = render_state_tree(component_info.state, 0) - - # Create panel - header = render_panel_header(component_info.module, panel_width) - content = render_panel_content(state_tree, panel_width) - - panel = stack(:vertical, [header | content]) - - %{ - type: :positioned, - content: panel, - x: panel_x, - y: 0, - # Below UI inspector but above content - z: 190 - } - end - - defp render_empty_panel do - %{ - type: :empty - } - end - - defp render_panel_header(module, width) do - module_name = get_module_name(module) - title = " State: #{module_name} " - - # Center title - remaining = width - String.length(title) - left = div(remaining, 2) - right = remaining - left - - header_text = String.duplicate("─", left) <> title <> String.duplicate("─", right) - text(header_text) - end - - defp render_panel_content(tree_lines, width) do - tree_lines - |> Enum.map(fn line -> - # Pad or truncate to panel width - padded = String.pad_trailing(line, width - 2) - truncated = String.slice(padded, 0, width - 2) - text("│" <> truncated <> "│") - end) - end - - @doc """ - Renders state as a tree of lines. - """ - @spec render_state_tree(term(), integer()) :: [String.t()] - def render_state_tree(value, depth) do - render_value_by_type(value, depth, String.duplicate(" ", depth)) - end - - defp render_value_by_type(%{__struct__: _} = value, depth, _indent) do - render_struct_tree(value, depth) - end - - defp render_value_by_type(value, depth, indent) when is_map(value) do - render_map_value(value, depth, indent) - end - - defp render_value_by_type(value, depth, indent) when is_list(value) do - render_list_value(value, depth, indent) - end - - defp render_value_by_type(value, depth, indent) when is_tuple(value) do - render_tuple_tree(value, depth) - |> ensure_indent_applied(indent) - end - - defp render_value_by_type(value, _depth, indent), do: [indent <> format_value(value)] - - defp render_map_value(value, _depth, indent) when map_size(value) == 0 do - [indent <> "%{}"] - end - - defp render_map_value(value, depth, _indent) do - render_map_tree(value, depth) - end - - defp render_list_value([], _depth, indent), do: [indent <> "[]"] - defp render_list_value(value, depth, _indent), do: render_list_tree(value, depth) - - defp ensure_indent_applied(lines, _indent), do: lines - - defp render_map_tree(map, depth) do - indent = String.duplicate(" ", depth) - - header = [indent <> "%{"] - - entries = - map - |> Enum.flat_map(fn {key, value} -> - key_str = format_key(key) - - if simple_value?(value) do - [indent <> " #{key_str}: #{format_value(value)}"] - else - [indent <> " #{key_str}:" | render_state_tree(value, depth + 2)] - end - end) - - footer = [indent <> "}"] - - header ++ entries ++ footer - end - - defp render_list_tree(list, depth) do - indent = String.duplicate(" ", depth) - - if length(list) > 10 do - # Truncate long lists - first_items = - list - |> Enum.take(5) - |> Enum.with_index() - |> Enum.flat_map(fn {item, idx} -> - render_indexed_item(item, idx, indent, depth, "[") - end) - - [indent <> "["] ++ - first_items ++ [indent <> " ... (#{length(list) - 5} more)", indent <> "]"] - else - entries = - list - |> Enum.with_index() - |> Enum.flat_map(fn {item, idx} -> - render_indexed_item(item, idx, indent, depth, "[") - end) - - [indent <> "["] ++ entries ++ [indent <> "]"] - end - end - - defp render_tuple_tree(tuple, depth) do - indent = String.duplicate(" ", depth) - elements = Tuple.to_list(tuple) - - if tuple_size(tuple) <= 3 and Enum.all?(elements, &simple_value?/1) do - # Inline small tuples - values = Enum.map_join(elements, ", ", &format_value/1) - [indent <> "{#{values}}"] - else - entries = - elements - |> Enum.with_index() - |> Enum.flat_map(fn {item, idx} -> - render_indexed_item(item, idx, indent, depth, ".") - end) - - [indent <> "{"] ++ entries ++ [indent <> "}"] - end - end - - defp render_struct_tree(struct, depth) do - indent = String.duplicate(" ", depth) - struct_name = struct.__struct__ |> get_module_name() - - map = Map.from_struct(struct) - - if map_size(map) == 0 do - [indent <> "%#{struct_name}{}"] - else - entries = - map - |> Enum.flat_map(fn {key, value} -> - render_keyed_item(value, to_string(key), indent, depth) - end) - - [indent <> "%#{struct_name}{"] ++ entries ++ [indent <> "}"] - end - end - - defp render_indexed_item(item, idx, indent, depth, prefix) do - if simple_value?(item) do - [indent <> " #{prefix}#{idx}]: #{format_value(item)}"] - else - [indent <> " #{prefix}#{idx}]:" | render_state_tree(item, depth + 2)] - end - end - - defp render_keyed_item(value, key_str, indent, depth) do - if simple_value?(value) do - [indent <> " #{key_str}: #{format_value(value)}"] - else - [indent <> " #{key_str}:" | render_state_tree(value, depth + 2)] - end - end - - defp simple_value?(value) do - is_atom(value) or is_number(value) or is_binary(value) or is_pid(value) or is_reference(value) - end - - defp format_key(key) when is_atom(key), do: to_string(key) - defp format_key(key), do: inspect(key) - - defp format_value(nil), do: "nil" - defp format_value(true), do: "true" - defp format_value(false), do: "false" - defp format_value(value) when is_atom(value), do: ":#{value}" - defp format_value(value) when is_integer(value), do: to_string(value) - defp format_value(value) when is_float(value), do: Float.to_string(value) - - defp format_value(value) when is_binary(value) do - if String.printable?(value) do - if String.length(value) > 30 do - "\"#{String.slice(value, 0, 27)}...\"" - else - "\"#{value}\"" - end - else - "<>" - end - end - - defp format_value(value) when is_pid(value), do: inspect(value) - defp format_value(value) when is_reference(value), do: "#Ref<...>" - - defp format_value(value) when is_function(value) do - info = Function.info(value) - "#Function<#{info[:arity]}>" - end - - defp format_value(value), do: inspect(value, limit: 10) - - defp get_module_name(module) when is_atom(module) do - module - |> Atom.to_string() - |> String.split(".") - |> List.last() - end - - defp get_module_name(_), do: "Unknown" - - @doc """ - Compares two states and returns paths that changed. - """ - @spec diff_states(term(), term()) :: [list()] - def diff_states(old_state, new_state) do - diff_values(old_state, new_state, []) - end - - defp diff_values(old, new, _path) when old == new, do: [] - - defp diff_values(old, new, path) when is_map(old) and is_map(new) do - all_keys = MapSet.union(MapSet.new(Map.keys(old)), MapSet.new(Map.keys(new))) - - Enum.flat_map(all_keys, fn key -> - old_val = Map.get(old, key) - new_val = Map.get(new, key) - diff_values(old_val, new_val, path ++ [key]) - end) - end - - defp diff_values(_old, _new, path), do: [path] -end diff --git a/lib/term_ui/dev/ui_inspector.ex b/lib/term_ui/dev/ui_inspector.ex deleted file mode 100644 index 0cfaf3ea..00000000 --- a/lib/term_ui/dev/ui_inspector.ex +++ /dev/null @@ -1,180 +0,0 @@ -defmodule TermUI.Dev.UIInspector do - @moduledoc """ - UI Inspector overlay for development mode. - - Shows component boundaries, names, types, and render times as an overlay - on top of the application. Toggle with Ctrl+Shift+I when dev mode is enabled. - - ## Features - - - Component boundary outlines - - Component name and type labels - - Render time display - - Click to select component for state inspection - """ - - import TermUI.Component.Helpers - - @doc """ - Renders the UI inspector overlay. - - Returns render nodes for component boundaries and labels. - """ - @spec render(map(), term() | nil, map()) :: term() - def render(components, selected_id, _area) do - # Render boundaries for all components - boundaries = - components - |> Enum.map(fn {id, info} -> - render_component_boundary(id, info, id == selected_id) - end) - - # Create overlay container - %{ - type: :overlay, - content: stack(:vertical, boundaries), - x: 0, - y: 0, - # Above normal content - z: 200 - } - end - - @doc """ - Renders a single component's boundary and label. - """ - @spec render_component_boundary(term(), map(), boolean()) :: term() - def render_component_boundary(id, info, selected?) do - bounds = info.bounds - module_name = get_module_name(info.module) - render_time = format_render_time(info.render_time) - - # Create boundary outline - border_char = if selected?, do: "█", else: "░" - border_style = if selected?, do: :selected, else: :normal - - # Top border with label - label = "#{module_name} (#{render_time})" - top_line = create_labeled_border(label, bounds.width, border_char) - - # Side borders - side_lines = - for _y <- 1..(bounds.height - 2) do - border_char <> String.duplicate(" ", bounds.width - 2) <> border_char - end - - # Bottom border - bottom_line = String.duplicate(border_char, bounds.width) - - # Combine into positioned element - content = [top_line | side_lines] ++ [bottom_line] - lines = Enum.map(content, &text/1) - - %{ - type: :positioned, - content: stack(:vertical, lines), - x: bounds.x, - y: bounds.y, - id: {:inspector_boundary, id}, - style: border_style - } - end - - @doc """ - Creates a top border line with embedded label. - """ - @spec create_labeled_border(String.t(), integer(), String.t()) :: String.t() - def create_labeled_border(label, width, char) do - label_with_brackets = "[ #{label} ]" - label_len = String.length(label_with_brackets) - - if label_len >= width - 2 do - # Label too long, truncate - truncated = String.slice(label_with_brackets, 0, width - 2) - char <> truncated <> char - else - # Center the label - remaining = width - label_len - left = div(remaining, 2) - right = remaining - left - String.duplicate(char, left) <> label_with_brackets <> String.duplicate(char, right) - end - end - - @doc """ - Extracts short module name from full module atom. - """ - @spec get_module_name(module()) :: String.t() - def get_module_name(module) when is_atom(module) do - module - |> Atom.to_string() - |> String.split(".") - |> List.last() - end - - def get_module_name(_), do: "Unknown" - - @doc """ - Formats render time for display. - """ - @spec format_render_time(integer()) :: String.t() - def format_render_time(time_us) when time_us < 1000 do - "#{time_us}μs" - end - - def format_render_time(time_us) when time_us < 1_000_000 do - ms = Float.round(time_us / 1000, 1) - "#{ms}ms" - end - - def format_render_time(time_us) do - s = Float.round(time_us / 1_000_000, 2) - "#{s}s" - end - - @doc """ - Finds component at screen position for selection. - """ - @spec find_component_at(map(), integer(), integer()) :: term() | nil - def find_component_at(components, x, y) do - components - |> Enum.filter(fn {_id, info} -> - bounds = info.bounds - - x >= bounds.x and x < bounds.x + bounds.width and - y >= bounds.y and y < bounds.y + bounds.height - end) - |> Enum.sort_by(fn {_id, info} -> - # Prefer smaller (more specific) components - info.bounds.width * info.bounds.height - end) - |> case do - [{id, _} | _] -> id - [] -> nil - end - end - - @doc """ - Gets summary of component state for quick display. - """ - @spec get_state_summary(term()) :: String.t() - def get_state_summary(state) when is_map(state) do - keys = Map.keys(state) - count = length(keys) - - if count <= 3 do - Enum.map_join(keys, ", ", &to_string/1) - else - first_three = keys |> Enum.take(3) |> Enum.map_join(", ", &to_string/1) - "#{first_three}... (+#{count - 3})" - end - end - - def get_state_summary(state) when is_list(state) do - "List[#{length(state)}]" - end - - def get_state_summary(state) do - inspect(state, limit: 50) - end -end diff --git a/lib/term_ui/renderer/display_width.ex b/lib/term_ui/display_width.ex similarity index 99% rename from lib/term_ui/renderer/display_width.ex rename to lib/term_ui/display_width.ex index b5e712cb..0325499b 100644 --- a/lib/term_ui/renderer/display_width.ex +++ b/lib/term_ui/display_width.ex @@ -1,4 +1,4 @@ -defmodule TermUI.Renderer.DisplayWidth do +defmodule TermUI.DisplayWidth do @moduledoc """ Calculates display width of Unicode characters and strings. diff --git a/lib/term_ui/elm.ex b/lib/term_ui/elm.ex index f0d8810d..edeb9b1d 100644 --- a/lib/term_ui/elm.ex +++ b/lib/term_ui/elm.ex @@ -1,303 +1,53 @@ defmodule TermUI.Elm do @moduledoc """ - The Elm Architecture implementation for TermUI components. + The application contract for TermUI. - This module provides the core callbacks for implementing components - using The Elm Architecture pattern: `update/2` for state changes and - `view/1` for rendering. - - ## The Pattern - - 1. **Events** arrive from terminal input - 2. **event_to_msg/2** converts events to component-specific messages - 3. **update/2** transforms state based on messages, returns new state + commands - 4. **view/1** renders current state to a render tree - 5. **Commands** execute asynchronously, sending result messages back - - ## Usage - - defmodule Counter do - use TermUI.Elm - - def init(_opts), do: %{count: 0} - - def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :increment} - def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :decrement} - def event_to_msg(_, _), do: :ignore - - def update(:increment, state), do: {%{state | count: state.count + 1}, []} - def update(:decrement, state), do: {%{state | count: state.count - 1}, []} - - def view(state) do - text("Count: \#{state.count}") - end - end + One runtime owns one application state. `event_to_msg/2`, `update/2`, and + `view/1` are pure. Effects are returned as `TermUI.Command` data. """ - alias TermUI.Event - alias TermUI.Message + alias TermUI.{Command, Event, Frame} @type state :: term() - @type msg :: Message.t() - @type command :: term() - @type render_tree :: term() - @type init_result :: - state() - | {state(), [command()]} - | {:ok, state()} - | {:ok, state(), [command()]} - - @type update_result :: - {state(), [command()]} - | {state()} - | :noreply - - @type event_to_msg_result :: - {:msg, msg()} - | :ignore - | :propagate - - @doc """ - Converts an event to a component-specific message. - - This callback transforms raw terminal events into domain-specific messages - that have semantic meaning for the component. - - ## Parameters - - - `event` - The terminal event (Key, Mouse, Resize, etc.) - - `state` - Current component state - - ## Returns - - - `{:msg, message}` - Event converted to a message for update - - `:ignore` - Event not handled by this component - - `:propagate` - Pass event to parent component - """ - @callback event_to_msg(Event.t(), state()) :: event_to_msg_result() - - @doc """ - Updates component state based on a message. - - This is the core logic of the component. It receives the current state - and a message, and returns the new state plus any commands to execute. - - Update functions must be pure—no side effects, no external calls. - Side effects are performed through commands returned in the result. - - ## Parameters - - - `msg` - The message to handle - - `state` - Current component state - - ## Returns - - - `{new_state, commands}` - New state and commands to execute - - `{new_state}` - Shorthand for `{new_state, []}` - - `:noreply` - Keep state unchanged, no commands - - ## Examples + @type message :: term() + @type update_result :: state() | {state(), [Command.t()]} | :noreply - def update(:increment, state) do - {%{state | count: state.count + 1}, []} - end + @callback init(keyword()) :: state() | {state(), [Command.t()]} + @callback event_to_msg(Event.t(), state()) :: {:msg, message()} | :ignore + @callback update(message(), state()) :: update_result() + @callback view(state()) :: Frame.t() + @callback handle_info(term(), state()) :: update_result() + @callback terminate(term(), state()) :: term() - def update({:fetch_data, url}, state) do - cmd = Command.http_get(url, {:data_loaded, :response}) - {%{state | loading: true}, [cmd]} - end - - def update(:noop, _state), do: :noreply - """ - @callback update(msg(), state()) :: update_result() - - @doc """ - Handles application messages sent to the runtime process. - - This callback uses the same result contract as `update/2`. It is useful for - subscriptions, task results, and other OTP messages that do not come from - terminal input. - """ - @callback handle_info(message :: term(), state()) :: update_result() - - @doc "Handles final application cleanup before the runtime stops." - @callback terminate(reason :: term(), state()) :: term() - - @doc """ - Renders the current state to a render tree. - - View functions must be pure—given the same state, they always produce - the same output. View functions should be fast since they run every frame. - - ## Parameters - - - `state` - Current component state - - ## Returns - - A render tree structure that will be processed into terminal output. - - ## Examples - - def view(state) do - box(border: true) do - text("Count: \#{state.count}") - end - end - """ - @callback view(state()) :: render_tree() - - @doc """ - Initializes component state from options. - - Called once when the component is created. - - ## Parameters - - - `opts` - Options passed to the component - - ## Returns - - Initial state for the component. - """ - @callback init(opts :: keyword()) :: init_result() - - @optional_callbacks [init: 1, handle_info: 2, terminate: 2] + @optional_callbacks init: 1, handle_info: 2, terminate: 2 defmacro __using__(_opts) do quote do @behaviour TermUI.Elm - # Import Component.Helpers for RenderNode-based view building - # (text/1, text/2, box/1, box/2, stack/2, stack/3, styled/2, empty/0) - import TermUI.Component.Helpers - - # Import Elm.Helpers for macros that don't conflict - # Exclude text, styled, box which are provided by Component.Helpers - import TermUI.Elm.Helpers, except: [text: 1, styled: 2, box: 1, box: 2] - - # Default implementations - - @doc false + @impl TermUI.Elm def init(_opts), do: %{} - @doc false - def event_to_msg(_event, _state), do: :ignore - - @doc false + @impl TermUI.Elm def handle_info(_message, _state), do: :noreply - @doc false + @impl TermUI.Elm def terminate(_reason, _state), do: :ok - defoverridable init: 1, event_to_msg: 2, handle_info: 2, terminate: 2 + defoverridable init: 1, handle_info: 2, terminate: 2 end end - @doc """ - Normalizes init result to standard form. - - Supports plain state as well as state with startup commands. - """ - @spec normalize_init_result(init_result()) :: {state(), [command()]} - def normalize_init_result({:ok, state}), do: {state, []} - - def normalize_init_result({:ok, state, commands}) when is_list(commands) do - {state, commands} - end - - def normalize_init_result({state, commands}) when is_list(commands) do - {state, commands} - end - + @doc false + @spec normalize_init_result(term()) :: {state(), [Command.t()]} + def normalize_init_result({state, commands}) when is_list(commands), do: {state, commands} def normalize_init_result(state), do: {state, []} - @doc """ - Normalizes update result to standard form. - - Converts shorthand forms to the full `{state, commands}` tuple. - """ - @spec normalize_update_result(update_result(), state()) :: {state(), [command()]} - def normalize_update_result({state, commands}, _old_state) when is_list(commands) do - {state, commands} - end + @doc false + @spec normalize_update_result(term(), state()) :: {state(), [Command.t()]} + def normalize_update_result({state, commands}, _old_state) when is_list(commands), + do: {state, commands} - def normalize_update_result({state}, _old_state) do - {state, []} - end - - def normalize_update_result(:noreply, old_state) do - {old_state, []} - end - - @doc """ - Validates that an update function is pure (best effort). - - Returns warnings if the update function appears to have side effects. - This is a heuristic check, not a guarantee. - """ - @spec validate_update_purity(module()) :: :ok | {:warnings, [String.t()]} - def validate_update_purity(_module) do - # This would require compile-time analysis or runtime tracing - # For now, we document the requirement and trust the developer - :ok - end -end - -defmodule TermUI.Elm.Helpers do - @moduledoc """ - Helper functions for Elm Architecture components. - """ - - @doc """ - Creates a text render node. - """ - def text(content) when is_binary(content) do - {:text, content} - end - - def text(content) do - {:text, to_string(content)} - end - - @doc """ - Creates a styled text render node. - """ - def styled(content, style) do - {:styled, content, style} - end - - @doc """ - Creates a box container. - """ - defmacro box(opts \\ [], do: block) do - quote do - {:box, unquote(opts), unquote(block)} - end - end - - @doc """ - Creates a row container (horizontal layout). - """ - defmacro row(opts \\ [], do: block) do - quote do - {:row, unquote(opts), unquote(block)} - end - end - - @doc """ - Creates a column container (vertical layout). - """ - defmacro column(opts \\ [], do: block) do - quote do - {:column, unquote(opts), unquote(block)} - end - end - - @doc """ - Groups multiple render nodes. - """ - def fragment(children) when is_list(children) do - {:fragment, children} - end + def normalize_update_result(:noreply, old_state), do: {old_state, []} + def normalize_update_result(state, _old_state), do: {state, []} end diff --git a/lib/term_ui/error.ex b/lib/term_ui/error.ex deleted file mode 100644 index ff7695e5..00000000 --- a/lib/term_ui/error.ex +++ /dev/null @@ -1,184 +0,0 @@ -defmodule TermUI.Error do - @moduledoc """ - Standardized error types for TermUI. - - This module provides a consistent set of error types that are used throughout - the TermUI codebase. Using standardized error types makes error handling - more predictable and allows for better error messages to users. - - ## Error Types - - The following error types are defined: - - - `:invalid_argument` - A required argument was missing or invalid - - `:not_found` - A requested resource was not found - - `:not_supported` - An operation is not supported in the current context - - `:timeout` - An operation timed out - - `:terminal_setup_failed` - Failed to initialize the terminal - - `:size_detection_failed` - Failed to detect terminal dimensions - - `:invalid_size` - Terminal dimensions were invalid - - `:out_of_bounds` - An operation exceeded valid bounds - - `:backend_unavailable` - The requested backend is not available - - `:command_failed` - An external command failed - - `:command_not_found` - An external command was not found - - `:command_not_allowed` - An external command is not in the whitelist - - `:invalid_configuration` - Application configuration is invalid - - `:component_crashed` - A component process crashed - - `:component_unavailable` - A component is not available - - ## Usage - - When returning errors from functions, use these standardized reasons: - - def init(opts) do - case Keyword.get(opts, :size) do - nil -> {:error, {:invalid_size, "size is required"}} - size when is_integer(size) and size > 0 -> {:ok, size} - _ -> {:error, {:invalid_size, "size must be a positive integer"}} - end - end - - ## Error Reasons - - Error reasons are either: - - An atom from the list above (simple error) - - A tuple `{error_type, details}` (error with additional context) - - ## Examples - - {:error, :not_found} - {:error, {:invalid_size, "dimensions must be positive"}} - {:error, {:command_failed, {:exit_code, 1}}} - """ - - @type error_reason :: - :invalid_argument - | :not_found - | :not_supported - | :timeout - | :terminal_setup_failed - | :size_detection_failed - | :invalid_size - | :out_of_bounds - | :backend_unavailable - | :command_failed - | :command_not_found - | :command_not_allowed - | :invalid_configuration - | :component_crashed - | :component_unavailable - | {atom(), term()} - - @type result :: {:ok, term()} | {:error, error_reason()} - - @doc """ - Formats an error reason into a human-readable string. - - ## Examples - - iex> TermUI.Error.format(:not_found) - "not found" - - iex> TermUI.Error.format({:invalid_size, "must be positive"}) - "invalid size: must be positive" - - iex> TermUI.Error.format({:command_failed, {:exit_code, 1}}) - "command failed: {:exit_code, 1}" - """ - @spec format(error_reason()) :: String.t() - def format(:invalid_argument), do: "invalid argument" - def format(:not_found), do: "not found" - def format(:not_supported), do: "not supported" - def format(:timeout), do: "operation timed out" - def format(:terminal_setup_failed), do: "terminal setup failed" - def format(:size_detection_failed), do: "failed to detect terminal size" - def format(:invalid_size), do: "invalid size" - def format(:out_of_bounds), do: "out of bounds" - def format(:backend_unavailable), do: "backend unavailable" - def format(:command_failed), do: "command failed" - def format(:command_not_found), do: "command not found" - def format(:command_not_allowed), do: "command not allowed" - def format(:invalid_configuration), do: "invalid configuration" - def format(:component_crashed), do: "component crashed" - def format(:component_unavailable), do: "component unavailable" - - def format({type, details}) when is_binary(details) do - "#{format(type)}: #{details}" - end - - def format({type, details}) do - "#{format(type)}: #{inspect(details)}" - end - - @doc """ - Creates an error reason with details. - - ## Examples - - iex> TermUI.Error.error(:invalid_size, "dimensions must be positive") - {:invalid_size, "dimensions must be positive"} - - """ - @spec error(atom(), term()) :: {atom(), term()} - def error(type, details), do: {type, details} - - @doc """ - Returns true if the given term is an error reason. - - ## Examples - - iex> TermUI.Error.error_reason?(:not_found) - true - - iex> TermUI.Error.error_reason?({:invalid_size, "too small"}) - true - - iex> TermUI.Error.error_reason?(:ok) - false - - iex> TermUI.Error.error_reason?({:ok, "result"}) - false - - """ - @spec error_reason?(term()) :: boolean() - def error_reason?(:invalid_argument), do: true - def error_reason?(:not_found), do: true - def error_reason?(:not_supported), do: true - def error_reason?(:timeout), do: true - def error_reason?(:terminal_setup_failed), do: true - def error_reason?(:size_detection_failed), do: true - def error_reason?(:invalid_size), do: true - def error_reason?(:out_of_bounds), do: true - def error_reason?(:backend_unavailable), do: true - def error_reason?(:command_failed), do: true - def error_reason?(:command_not_found), do: true - def error_reason?(:command_not_allowed), do: true - def error_reason?(:invalid_configuration), do: true - def error_reason?(:component_crashed), do: true - def error_reason?(:component_unavailable), do: true - - def error_reason?({type, _}) when is_atom(type) do - error_reason?(type) - end - - def error_reason?(_), do: false - - @doc """ - Returns the error type from an error reason. - - For simple error reasons (atoms), returns the atom itself. - For tuple error reasons, returns the first element (the type). - - ## Examples - - iex> TermUI.Error.error_type(:not_found) - :not_found - - iex> TermUI.Error.error_type({:invalid_size, "too small"}) - :invalid_size - - """ - @spec error_type(error_reason()) :: atom() - def error_type({type, _}), do: type - def error_type(type), do: type -end diff --git a/lib/term_ui/event.ex b/lib/term_ui/event.ex index 5a280cee..ba0e5d0a 100644 --- a/lib/term_ui/event.ex +++ b/lib/term_ui/event.ex @@ -1,206 +1,146 @@ defmodule TermUI.Event do @moduledoc """ - Event type definitions for TermUI. + Normalized input from a terminal backend. - Events represent user input from the terminal: keyboard presses, - mouse actions, and focus changes. Events are routed to components - by the EventRouter based on focus state and position. - - ## Event Types - - - `Key` - Keyboard input (key press, char input) - - `Mouse` - Mouse actions (click, move, scroll) - - `Focus` - Focus changes (gained, lost) - - `Custom` - Application-defined events - - ## Examples - - # Key event - event = Event.key(:enter) - event = Event.key(:a, char: "a") - event = Event.key(:c, modifiers: [:ctrl]) - - # Mouse event - event = Event.mouse(:click, :left, 10, 20) - event = Event.mouse(:move, nil, 15, 25) - - # Focus event - event = Event.focus(:gained) - event = Event.focus(:lost) + Printable input is `Text`. Named or modified keys are `Key`. Paste, mouse, + resize, and focus input have separate types. Applications do not parse + terminal byte sequences. """ - @typedoc "Union type for all event types" @type t :: __MODULE__.Key.t() + | __MODULE__.Text.t() + | __MODULE__.Paste.t() | __MODULE__.Mouse.t() - | __MODULE__.Focus.t() - | __MODULE__.Custom.t() | __MODULE__.Resize.t() - | __MODULE__.Paste.t() - | __MODULE__.Tick.t() - - # Key Event + | __MODULE__.Focus.t() defmodule Key do - @moduledoc """ - Keyboard input event. - - Represents a key press with optional character and modifiers. - """ - - @type t :: %__MODULE__{ - key: atom(), - char: String.t() | nil, - modifiers: [atom()], - timestamp: integer() - } - - defstruct key: nil, - char: nil, - modifiers: [], - timestamp: 0 - - @doc """ - Creates a new key event. - """ - def new(key, opts \\ []) do + @moduledoc "A named or modified key press." + @type t :: %__MODULE__{key: atom() | String.t(), modifiers: [atom()], timestamp: integer()} + @schema Zoi.struct(__MODULE__, %{ + key: Zoi.union([Zoi.atom(), Zoi.string()]), + modifiers: Zoi.array(Zoi.atom()) |> Zoi.default([]), + timestamp: Zoi.integer() |> Zoi.default(0) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @doc false + def schema, do: @schema + + @doc false + def new(key, opts) when is_atom(key) or is_binary(key) do %__MODULE__{ key: key, - char: Keyword.get(opts, :char), - modifiers: Keyword.get(opts, :modifiers, []), + modifiers: opts |> Keyword.get(:modifiers, []) |> Enum.uniq(), timestamp: Keyword.get(opts, :timestamp, System.monotonic_time(:millisecond)) } end end - # Mouse Event - - defmodule Mouse do - @moduledoc """ - Mouse input event. - - Represents mouse actions with position and button info. - """ - - @type action :: - :click | :double_click | :move | :drag | :scroll_up | :scroll_down | :press | :release - @type button :: :left | :middle | :right | nil - - @type t :: %__MODULE__{ - action: action(), - button: button(), - x: integer(), - y: integer(), - modifiers: [atom()], - timestamp: integer() - } - - defstruct action: :click, - button: :left, - x: 0, - y: 0, - modifiers: [], - timestamp: 0 - - @doc """ - Creates a new mouse event. - """ - def new(action, button, x, y, opts \\ []) do + defmodule Text do + @moduledoc "Printable Unicode text input." + @type t :: %__MODULE__{text: String.t(), timestamp: integer()} + @schema Zoi.struct(__MODULE__, %{ + text: Zoi.string() |> Zoi.min(1), + timestamp: Zoi.integer() |> Zoi.default(0) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @doc false + def schema, do: @schema + + @doc false + def new(text, opts) when is_binary(text) and text != "" do %__MODULE__{ - action: action, - button: button, - x: x, - y: y, - modifiers: Keyword.get(opts, :modifiers, []), + text: text, timestamp: Keyword.get(opts, :timestamp, System.monotonic_time(:millisecond)) } end end - # Focus Event - - defmodule Focus do - @moduledoc """ - Focus change event. - - Sent to components when they gain or lose focus. - """ - - @type action :: :gained | :lost - - @type t :: %__MODULE__{ - action: action(), - timestamp: integer() - } - - defstruct action: :gained, - timestamp: 0 - - @doc """ - Creates a new focus event. - """ - def new(action, opts \\ []) when action in [:gained, :lost] do + defmodule Paste do + @moduledoc "Text received in one bracketed-paste operation." + @type t :: %__MODULE__{content: String.t(), timestamp: integer()} + @schema Zoi.struct(__MODULE__, %{ + content: Zoi.string(), + timestamp: Zoi.integer() |> Zoi.default(0) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @doc false + def schema, do: @schema + + @doc false + def new(content, opts) when is_binary(content) do %__MODULE__{ - action: action, + content: content, timestamp: Keyword.get(opts, :timestamp, System.monotonic_time(:millisecond)) } end end - # Custom Event - - defmodule Custom do - @moduledoc """ - Application-defined custom event. - - For app-specific events not covered by standard types. - """ - + defmodule Mouse do + @moduledoc "A normalized mouse action." + @type action :: :press | :release | :move | :drag | :scroll_up | :scroll_down + @type button :: :left | :middle | :right | nil @type t :: %__MODULE__{ - name: atom(), - payload: term(), + action: action(), + button: button(), + x: non_neg_integer(), + y: non_neg_integer(), + modifiers: [atom()], timestamp: integer() } - - defstruct name: nil, - payload: nil, - timestamp: 0 - - @doc """ - Creates a new custom event. - """ - def new(name, payload \\ nil, opts \\ []) do + @schema Zoi.struct(__MODULE__, %{ + action: Zoi.enum([:press, :release, :move, :drag, :scroll_up, :scroll_down]), + button: Zoi.enum([:left, :middle, :right, nil]), + x: Zoi.integer() |> Zoi.non_negative(), + y: Zoi.integer() |> Zoi.non_negative(), + modifiers: Zoi.array(Zoi.atom()) |> Zoi.default([]), + timestamp: Zoi.integer() |> Zoi.default(0) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @doc false + def schema, do: @schema + + @doc false + def new(action, button, x, y, opts) + when action in [:press, :release, :move, :drag, :scroll_up, :scroll_down] and + button in [:left, :middle, :right, nil] and is_integer(x) and x >= 0 and + is_integer(y) and y >= 0 do %__MODULE__{ - name: name, - payload: payload, + action: action, + button: button, + x: x, + y: y, + modifiers: opts |> Keyword.get(:modifiers, []) |> Enum.uniq(), timestamp: Keyword.get(opts, :timestamp, System.monotonic_time(:millisecond)) } end end - # Resize Event - defmodule Resize do - @moduledoc """ - Terminal resize event. - - Sent when the terminal window dimensions change. - """ - - @type t :: %__MODULE__{ - width: pos_integer(), - height: pos_integer(), - timestamp: integer() - } - - defstruct width: 80, - height: 24, - timestamp: 0 - - @doc """ - Creates a new resize event. - """ - def new(width, height, opts \\ []) when is_integer(width) and is_integer(height) do + @moduledoc "A terminal size change in columns and rows." + @type t :: %__MODULE__{width: pos_integer(), height: pos_integer(), timestamp: integer()} + @schema Zoi.struct(__MODULE__, %{ + width: Zoi.integer() |> Zoi.positive(), + height: Zoi.integer() |> Zoi.positive(), + timestamp: Zoi.integer() |> Zoi.default(0) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @doc false + def schema, do: @schema + + @doc false + def new(width, height, opts) when width > 0 and height > 0 do %__MODULE__{ width: width, height: height, @@ -209,229 +149,76 @@ defmodule TermUI.Event do end end - # Paste Event - - defmodule Paste do - @moduledoc """ - Clipboard paste event. - - Sent when content is pasted from the clipboard via bracketed paste mode. - """ - - @type t :: %__MODULE__{ - content: String.t(), - timestamp: integer() - } - - defstruct content: "", - timestamp: 0 - - @doc """ - Creates a new paste event. - """ - def new(content, opts \\ []) when is_binary(content) do - %__MODULE__{ - content: content, - timestamp: Keyword.get(opts, :timestamp, System.monotonic_time(:millisecond)) - } - end - end - - # Tick Event - - defmodule Tick do - @moduledoc """ - Timer tick event. - - Represents a periodic timer event for animations and time-based updates. - """ - - @type t :: %__MODULE__{ - interval: pos_integer(), - timestamp: integer() - } - - defstruct interval: 16, - timestamp: 0 - - @doc """ - Creates a new tick event. - """ - def new(interval, opts \\ []) when is_integer(interval) and interval > 0 do + defmodule Focus do + @moduledoc "A terminal focus change." + @type t :: %__MODULE__{action: :gained | :lost, timestamp: integer()} + @schema Zoi.struct(__MODULE__, %{ + action: Zoi.enum([:gained, :lost]), + timestamp: Zoi.integer() |> Zoi.default(0) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @doc false + def schema, do: @schema + + @doc false + def new(action, opts) when action in [:gained, :lost] do %__MODULE__{ - interval: interval, + action: action, timestamp: Keyword.get(opts, :timestamp, System.monotonic_time(:millisecond)) } end - - @doc """ - Returns the tick rate in Hz (ticks per second). - """ - def rate(%__MODULE__{interval: interval}) do - 1000 / interval - end end - # Convenience constructors - - @doc """ - Creates a key event. - - ## Examples - - Event.key(:enter) - Event.key(:a, char: "a") - Event.key(:c, modifiers: [:ctrl]) - """ - @spec key(atom(), keyword()) :: __MODULE__.Key.t() - def key(key, opts \\ []) do - Key.new(key, opts) + @doc "Creates a named or modified key event." + @spec key(atom() | String.t(), keyword()) :: Key.t() + def key(key, opts \\ []), do: Key.new(key, opts) + + @doc "Creates a printable text event." + @spec text(String.t(), keyword()) :: Text.t() + def text(text, opts \\ []), do: Text.new(text, opts) + + @doc "Creates a bracketed-paste event." + @spec paste(String.t(), keyword()) :: Paste.t() + def paste(content, opts \\ []), do: Paste.new(content, opts) + + @doc "Creates a mouse event." + @spec mouse(Mouse.action(), Mouse.button(), non_neg_integer(), non_neg_integer(), keyword()) :: + Mouse.t() + def mouse(action, button, x, y, opts \\ []), do: Mouse.new(action, button, x, y, opts) + + @doc "Creates a resize event." + @spec resize(pos_integer(), pos_integer(), keyword()) :: Resize.t() + def resize(width, height, opts \\ []), do: Resize.new(width, height, opts) + + @doc "Creates a focus event." + @spec focus(:gained | :lost, keyword()) :: Focus.t() + def focus(action, opts \\ []), do: Focus.new(action, opts) + + @doc "Returns the Zoi schema for all normalized terminal events." + @spec schema() :: Zoi.schema() + def schema do + Zoi.union([ + Key.schema(), + Text.schema(), + Paste.schema(), + Mouse.schema(), + Resize.schema(), + Focus.schema() + ]) end - @doc """ - Creates a mouse event. - - ## Examples - - Event.mouse(:click, :left, 10, 20) - Event.mouse(:move, nil, x, y) - """ - @spec mouse( - __MODULE__.Mouse.action(), - __MODULE__.Mouse.button(), - integer(), - integer(), - keyword() - ) :: __MODULE__.Mouse.t() - def mouse(action, button, x, y, opts \\ []) do - Mouse.new(action, button, x, y, opts) - end - - @doc """ - Creates a focus event. - - ## Examples - - Event.focus(:gained) - Event.focus(:lost) - """ - @spec focus(__MODULE__.Focus.action(), keyword()) :: __MODULE__.Focus.t() - def focus(action, opts \\ []) do - Focus.new(action, opts) - end - - @doc """ - Creates a custom event. - - ## Examples - - Event.custom(:submit, %{value: "hello"}) - """ - @spec custom(atom(), term(), keyword()) :: __MODULE__.Custom.t() - def custom(name, payload \\ nil, opts \\ []) do - Custom.new(name, payload, opts) - end - - @doc """ - Creates a resize event. - - ## Examples - - Event.resize(120, 40) - """ - @spec resize(pos_integer(), pos_integer(), keyword()) :: __MODULE__.Resize.t() - def resize(width, height, opts \\ []) do - Resize.new(width, height, opts) - end - - @doc """ - Creates a paste event. - - ## Examples - - Event.paste("Hello, World!") - """ - @spec paste(String.t(), keyword()) :: __MODULE__.Paste.t() - def paste(content, opts \\ []) do - Paste.new(content, opts) - end - - @doc """ - Creates a tick event. - - ## Examples - - Event.tick(16) # ~60 FPS - Event.tick(1000) # 1 second - """ - @spec tick(pos_integer(), keyword()) :: __MODULE__.Tick.t() - def tick(interval, opts \\ []) do - Tick.new(interval, opts) - end - - # Type checks - - @doc "Returns true if event is a key event" - @spec key?(term()) :: boolean() - def key?(%Key{}), do: true - def key?(_), do: false - - @doc "Returns true if event is a mouse event" - @spec mouse?(term()) :: boolean() - def mouse?(%Mouse{}), do: true - def mouse?(_), do: false - - @doc "Returns true if event is a focus event" - @spec focus?(term()) :: boolean() - def focus?(%Focus{}), do: true - def focus?(_), do: false - - @doc "Returns true if event is a custom event" - @spec custom?(term()) :: boolean() - def custom?(%Custom{}), do: true - def custom?(_), do: false - - @doc "Returns true if event is a resize event" - @spec resize?(term()) :: boolean() - def resize?(%Resize{}), do: true - def resize?(_), do: false - - @doc "Returns true if event is a paste event" - @spec paste?(term()) :: boolean() - def paste?(%Paste{}), do: true - def paste?(_), do: false - - @doc "Returns true if event is a tick event" - @spec tick?(term()) :: boolean() - def tick?(%Tick{}), do: true - def tick?(_), do: false - - @doc """ - Returns the event type as an atom. - """ - @spec type( - __MODULE__.Key.t() - | __MODULE__.Mouse.t() - | __MODULE__.Focus.t() - | __MODULE__.Custom.t() - | __MODULE__.Resize.t() - | __MODULE__.Paste.t() - | __MODULE__.Tick.t() - ) :: - :key | :mouse | :focus | :custom | :resize | :paste | :tick + @doc "Returns the event type." + @spec type(t()) :: :key | :text | :paste | :mouse | :resize | :focus def type(%Key{}), do: :key + def type(%Text{}), do: :text + def type(%Paste{}), do: :paste def type(%Mouse{}), do: :mouse - def type(%Focus{}), do: :focus - def type(%Custom{}), do: :custom def type(%Resize{}), do: :resize - def type(%Paste{}), do: :paste - def type(%Tick{}), do: :tick + def type(%Focus{}), do: :focus - @doc """ - Checks if a modifier is present in the event. - """ - @spec has_modifier?(__MODULE__.Key.t() | __MODULE__.Mouse.t(), atom()) :: boolean() - def has_modifier?(%{modifiers: modifiers}, modifier) do - modifier in modifiers - end + @doc "Returns true when the event contains a modifier." + @spec has_modifier?(Key.t() | Mouse.t(), atom()) :: boolean() + def has_modifier?(%{modifiers: modifiers}, modifier), do: modifier in modifiers end diff --git a/lib/term_ui/event/propagation.ex b/lib/term_ui/event/propagation.ex deleted file mode 100644 index ce549d29..00000000 --- a/lib/term_ui/event/propagation.ex +++ /dev/null @@ -1,208 +0,0 @@ -defmodule TermUI.Event.Propagation do - @moduledoc """ - Event propagation utilities for the component tree. - - Handles bubbling and capturing phases of event propagation. - Events bubble up from target to root until handled. - - ## Propagation Phases - - 1. **Capture** - Event travels from root to target (optional) - 2. **Target** - Event delivered to target component - 3. **Bubble** - Event travels from target to root (default) - - ## Usage - - # Propagate event up through parent chain - Propagation.bubble(event, component_id) - - # Build parent chain for propagation - parents = Propagation.get_parent_chain(component_id) - """ - - alias TermUI.ComponentRegistry - - @type phase :: :capture | :target | :bubble - @type propagation_result :: :handled | :unhandled | :stopped - - @doc """ - Bubbles an event up through the parent chain. - - Starts from the given component and propagates up to parents - until a component handles the event or the root is reached. - - ## Parameters - - - `event` - The event to propagate - - `start_id` - Component to start bubbling from - - `opts` - Options: - - `:skip_start` - Skip the starting component (default: false) - - ## Returns - - - `:handled` - A component handled the event - - `:unhandled` - No component handled the event - """ - @spec bubble(term(), term(), keyword()) :: propagation_result() - def bubble(event, start_id, opts \\ []) do - skip_start = Keyword.get(opts, :skip_start, false) - - parent_chain = get_parent_chain(start_id) - - chain = - if skip_start do - parent_chain - else - [start_id | parent_chain] - end - - propagate_through(event, chain) - end - - @doc """ - Captures an event down through the parent chain to target. - - Starts from the root and propagates down to the target component. - Each component can intercept before reaching target. - - ## Parameters - - - `event` - The event to propagate - - `target_id` - Target component - - ## Returns - - - `:handled` - A component handled the event - - `:unhandled` - No component handled the event - """ - @spec capture(term(), term()) :: propagation_result() - def capture(event, target_id) do - parent_chain = get_parent_chain(target_id) - chain = Enum.reverse(parent_chain) ++ [target_id] - propagate_through(event, chain) - end - - @doc """ - Gets the parent chain for a component. - - Returns list of parent component ids from immediate parent to root. - - ## Example - - # If component tree is: root -> container -> button - get_parent_chain(:button) - # => [:container, :root] - """ - @spec get_parent_chain(term()) :: [term()] - def get_parent_chain(component_id) do - case ComponentRegistry.get_parent(component_id) do - {:ok, nil} -> - [] - - {:ok, parent_id} -> - [parent_id | get_parent_chain(parent_id)] - - {:error, :not_found} -> - [] - end - end - - @doc """ - Sets the parent for a component. - - Used to build the component tree for propagation. - - ## Parameters - - - `component_id` - Child component - - `parent_id` - Parent component (or nil for root) - """ - @spec set_parent(term(), term() | nil) :: :ok - def set_parent(component_id, parent_id) do - ComponentRegistry.set_parent(component_id, parent_id) - end - - @doc """ - Gets children of a component. - - ## Returns - - List of child component ids. - """ - @spec get_children(term()) :: [term()] - def get_children(component_id) do - ComponentRegistry.get_children(component_id) - end - - @doc """ - Adds metadata about propagation phase to event. - - ## Parameters - - - `event` - The event - - `phase` - Current propagation phase - - ## Returns - - Event with `:propagation_phase` metadata. - """ - @spec with_phase(term(), phase()) :: map() - def with_phase(event, phase) when is_map(event) do - Map.put(event, :propagation_phase, phase) - end - - @doc """ - Checks if an event should stop propagating. - - Events can be marked to stop propagation by returning - `:stop` from handle_event. - """ - @spec stopped?(term()) :: boolean() - def stopped?(result) do - result == :stopped || result == :stop - end - - # Private Functions - - defp propagate_through(_event, []) do - :unhandled - end - - defp propagate_through(event, [component_id | rest]) do - case send_to_component(component_id, event) do - :handled -> - :handled - - :stopped -> - :stopped - - :unhandled -> - propagate_through(event, rest) - - {:error, _} -> - propagate_through(event, rest) - end - end - - defp send_to_component(component_id, event) do - case ComponentRegistry.lookup(component_id) do - {:ok, pid} -> call_component(pid, event) - {:error, :not_found} -> {:error, :not_found} - end - end - - defp call_component(pid, event) do - pid - |> GenServer.call({:event, event}, 5000) - |> normalize_event_result() - catch - :exit, _ -> {:error, :component_unavailable} - end - - defp normalize_event_result(:handled), do: :handled - defp normalize_event_result(:stop), do: :stopped - defp normalize_event_result(:stopped), do: :stopped - defp normalize_event_result(:unhandled), do: :unhandled - defp normalize_event_result({:ok, _}), do: :handled - defp normalize_event_result(_), do: :unhandled -end diff --git a/lib/term_ui/event/transformation.ex b/lib/term_ui/event/transformation.ex deleted file mode 100644 index d91337b8..00000000 --- a/lib/term_ui/event/transformation.ex +++ /dev/null @@ -1,231 +0,0 @@ -defmodule TermUI.Event.Transformation do - @moduledoc """ - Event transformation utilities. - - Transforms events as they route to components, including: - - Coordinate transformation (screen to component-local) - - Event metadata enrichment - - Event filtering - - ## Usage - - # Transform mouse coordinates to component-local - local_event = Transformation.to_local(event, component_bounds) - - # Add metadata to event - enriched = Transformation.with_metadata(event, %{target: :button}) - """ - - alias TermUI.Event.Mouse - - # Dialyzer: Functions return specific map types - @dialyzer {:nowarn_function, with_metadata: 2, envelope: 2} - - @doc """ - Transforms screen coordinates to component-local coordinates. - - For mouse events, subtracts the component's position from the - event coordinates so the component receives coordinates relative - to its own origin (0, 0). - - ## Parameters - - - `event` - Mouse event with screen coordinates - - `bounds` - Component bounds with x, y position - - ## Returns - - Event with transformed coordinates, or unchanged event if not a mouse event. - - ## Example - - event = %Mouse{x: 15, y: 10, ...} - bounds = %{x: 10, y: 5, width: 20, height: 10} - local = to_local(event, bounds) - # local.x = 5, local.y = 5 - """ - @spec to_local(Mouse.t() | term(), map()) :: Mouse.t() | term() - def to_local(%Mouse{x: x, y: y} = event, %{x: bx, y: by}) do - %{event | x: x - bx, y: y - by} - end - - def to_local(event, _bounds), do: event - - @doc """ - Transforms component-local coordinates back to screen coordinates. - - Inverse of `to_local/2`. - - ## Parameters - - - `event` - Mouse event with local coordinates - - `bounds` - Component bounds with x, y position - - ## Returns - - Event with screen coordinates. - """ - @spec to_screen(Mouse.t() | term(), map()) :: Mouse.t() | term() - def to_screen(%Mouse{x: x, y: y} = event, %{x: bx, y: by}) do - %{event | x: x + bx, y: y + by} - end - - def to_screen(event, _bounds), do: event - - @doc """ - Adds metadata to an event. - - Creates or updates a `:metadata` field on the event struct. - - ## Parameters - - - `event` - The event to enrich - - `metadata` - Map of metadata to add - - ## Returns - - Event with metadata merged. - - ## Example - - event = with_metadata(key_event, %{target: :input, phase: :bubble}) - """ - @spec with_metadata(map(), map()) :: map() - def with_metadata(event, metadata) when is_map(event) and is_map(metadata) do - existing = Map.get(event, :metadata, %{}) - Map.put(event, :metadata, Map.merge(existing, metadata)) - end - - @doc """ - Gets metadata from an event. - - ## Parameters - - - `event` - The event - - `key` - Metadata key to get - - `default` - Default value if key not found - - ## Returns - - The metadata value or default. - """ - @spec get_metadata(map(), atom(), term()) :: term() - def get_metadata(event, key, default \\ nil) when is_map(event) do - event - |> Map.get(:metadata, %{}) - |> Map.get(key, default) - end - - @doc """ - Checks if an event matches a filter. - - ## Filter Options - - - `:type` - Event type (:key, :mouse, :focus, :custom) - - `:key` - Specific key (for key events) - - `:action` - Specific action (for mouse/focus events) - - `:button` - Specific button (for mouse events) - - `:modifiers` - Required modifiers (any or all) - - `:modifiers_all` - All modifiers must be present - - `:modifiers_any` - Any modifier must be present - - ## Example - - # Match Ctrl+C - matches?(event, type: :key, key: :c, modifiers_all: [:ctrl]) - - # Match any click - matches?(event, type: :mouse, action: :click) - """ - @spec matches?(term(), keyword()) :: boolean() - def matches?(event, filters) when is_list(filters) do - Enum.all?(filters, fn {key, value} -> - matches_filter?(event, key, value) - end) - end - - @doc """ - Filters a list of events based on criteria. - - ## Parameters - - - `events` - List of events - - `filters` - Filter criteria (see `matches?/2`) - - ## Returns - - List of events matching all filters. - """ - @spec filter(list(), keyword()) :: list() - def filter(events, filters) when is_list(events) do - Enum.filter(events, &matches?(&1, filters)) - end - - @doc """ - Creates a standard event envelope with routing metadata. - - ## Parameters - - - `event` - The raw event - - `opts` - Options: - - `:source` - Source of the event - - `:target` - Target component id - - `:timestamp` - Override timestamp - - ## Returns - - Event with envelope metadata. - """ - @spec envelope(term(), keyword()) :: map() - def envelope(event, opts \\ []) when is_map(event) do - metadata = %{ - source: Keyword.get(opts, :source), - target: Keyword.get(opts, :target), - routed_at: Keyword.get(opts, :timestamp, System.monotonic_time(:millisecond)) - } - - with_metadata(event, metadata) - end - - # Private Functions - - defp matches_filter?(%{__struct__: struct}, :type, type) do - case type do - :key -> struct == TermUI.Event.Key - :mouse -> struct == TermUI.Event.Mouse - :focus -> struct == TermUI.Event.Focus - :custom -> struct == TermUI.Event.Custom - _ -> false - end - end - - defp matches_filter?(%{key: event_key}, :key, key) do - event_key == key - end - - defp matches_filter?(%{action: event_action}, :action, action) do - event_action == action - end - - defp matches_filter?(%{button: event_button}, :button, button) do - event_button == button - end - - defp matches_filter?(%{modifiers: event_mods}, :modifiers_all, required) do - Enum.all?(required, &(&1 in event_mods)) - end - - defp matches_filter?(%{modifiers: event_mods}, :modifiers_any, required) do - Enum.any?(required, &(&1 in event_mods)) - end - - defp matches_filter?(%{modifiers: event_mods}, :modifiers, required) do - # Default to all modifiers required - Enum.all?(required, &(&1 in event_mods)) - end - - defp matches_filter?(_event, _key, _value) do - # Unknown filter or field not present - false - end -end diff --git a/lib/term_ui/event_queue.ex b/lib/term_ui/event_queue.ex deleted file mode 100644 index 9aa15ad2..00000000 --- a/lib/term_ui/event_queue.ex +++ /dev/null @@ -1,282 +0,0 @@ -defmodule TermUI.EventQueue do - @moduledoc """ - Bounded event queue for preventing DoS via event flooding. - - This module implements a fixed-size queue with a drop-oldest strategy - to prevent unbounded memory growth from rapid event input. - - ## Design - - The queue uses Erlang's `:queue` module for efficient operations: - - O(1) amortized for enqueue/dequeue - - O(1) for length checks - - When the queue is full and a new event arrives, the oldest event is - dropped and a warning is logged (rate-limited). - - ## Example - - # Create a new queue with max size - queue = EventQueue.new(max_size: 1000) - - # Add an event - {:ok, queue} = EventQueue.push(queue, :some_event) - - # Drop oldest when full - {{:dropped, oldest_event}, queue} = EventQueue.push(queue, :new_event) - - # Take next event - {{:value, event}, queue} = EventQueue.pop(queue) - {:empty, queue} = EventQueue.pop(queue) - """ - - require Logger - - @typedoc "Event queue structure" - @type t :: %__MODULE__{ - queue: :queue.queue(), - size: non_neg_integer(), - max_size: pos_integer(), - dropped_count: non_neg_integer(), - last_warning: integer() | nil - } - - @typedoc "Push result - either success or dropped event" - @type push_result :: {:ok, t()} | {{:dropped, term()}, t()} - - @typedoc "Pop result - value, empty, or timeout" - @type pop_result :: {{:value, term()}, t()} | {:empty, t()} - - defstruct [:queue, :size, :max_size, :dropped_count, :last_warning] - - # Dialyzer: Functions with unmatched return values - @dialyzer {:nowarn_function, maybe_log_overflow: 1, push: 2, drop_oldest_and_push: 2} - - @doc """ - Default maximum queue size. - - This value balances memory usage with responsiveness: - - At 60 FPS, 1000 events = ~16 seconds of input buffer - - Typical key presses are <100 events/sec - """ - def max_size, do: 1000 - - @doc """ - Warning rate limit in milliseconds (log once per 5 seconds max). - """ - def warning_interval, do: 5000 - - @doc """ - Creates a new event queue with the given options. - - ## Options - - - `:max_size` - Maximum number of events in queue (default: 1000) - - ## Example - - queue = EventQueue.new() - queue = EventQueue.new(max_size: 500) - """ - @spec new(keyword()) :: t() - def new(opts \\ []) do - max_size = Keyword.get(opts, :max_size, max_size()) - - %__MODULE__{ - queue: :queue.new(), - size: 0, - max_size: max_size, - dropped_count: 0, - last_warning: nil - } - end - - @doc """ - Returns the current size of the queue. - """ - @spec size(t()) :: non_neg_integer() - def size(%__MODULE__{size: size}), do: size - - @doc """ - Returns the maximum size of the queue. - """ - @spec max_size(t()) :: pos_integer() - def max_size(%__MODULE__{max_size: max_size}), do: max_size - - @doc """ - Returns whether the queue is empty. - """ - @spec empty?(t()) :: boolean() - def empty?(%__MODULE__{size: 0}), do: true - def empty?(%__MODULE__{}), do: false - - @doc """ - Returns whether the queue is full. - """ - @spec full?(t()) :: boolean() - def full?(%__MODULE__{size: size, max_size: max_size}), do: size >= max_size - - @doc """ - Pushes an event onto the queue. - - If the queue is full, the oldest event is dropped and returned. - - ## Returns - - - `{:ok, queue}` - Event was added - - `{{:dropped, oldest_event}, queue}` - Queue was full, oldest event dropped - - ## Example - - {:ok, queue} = EventQueue.push(queue, :event) - {{:dropped, oldest}, queue} = EventQueue.push(queue, :new_event) - """ - @spec push(t(), term()) :: push_result() - def push(%__MODULE__{} = q, event) do - if full?(q) do - drop_oldest_and_push(q, event) - else - new_queue = :queue.in(event, q.queue) - {:ok, %{q | queue: new_queue, size: q.size + 1}} - end - end - - @doc """ - Pushes an event onto the queue, dropping oldest if full. - - Similar to `push/2` but always returns the updated queue without - indicating whether a drop occurred. Use `dropped_count/1` to check - for drops. - """ - @spec push!(t(), term()) :: t() - def push!(%__MODULE__{} = q, event) do - case push(q, event) do - {:ok, new_q} -> new_q - {{:dropped, _}, new_q} -> new_q - end - end - - @doc """ - Pops the next event from the queue. - - ## Returns - - - `{{:value, event}, queue}` - Next event - - `{:empty, queue}` - Queue is empty - - ## Example - - {{:value, event}, queue} = EventQueue.pop(queue) - {:empty, queue} = EventQueue.pop(queue) - """ - @spec pop(t()) :: pop_result() - def pop(%__MODULE__{size: 0} = q) do - {:empty, q} - end - - def pop(%__MODULE__{} = q) do - case :queue.out(q.queue) do - {{:value, event}, new_queue} -> - {{:value, event}, %{q | queue: new_queue, size: q.size - 1}} - - {:empty, _} -> - {:empty, q} - end - end - - @doc """ - Peeks at the next event without removing it. - - ## Returns - - - `{{:value, event}, queue}` - Next event - - `{:empty, queue}` - Queue is empty - """ - @spec peek(t()) :: pop_result() - def peek(%__MODULE__{size: 0} = q) do - {:empty, q} - end - - def peek(%__MODULE__{} = q) do - case :queue.peek(q.queue) do - {:value, event} -> {{:value, event}, q} - :empty -> {:empty, q} - end - end - - @doc """ - Returns the number of events that have been dropped due to overflow. - - This counter is cumulative for the lifetime of the queue. - """ - @spec dropped_count(t()) :: non_neg_integer() - def dropped_count(%__MODULE__{dropped_count: count}), do: count - - @doc """ - Resets the dropped event counter to zero. - """ - @spec reset_dropped_count(t()) :: t() - def reset_dropped_count(%__MODULE__{} = q), do: %{q | dropped_count: 0} - - @doc """ - Clears all events from the queue. - """ - @spec clear(t()) :: t() - def clear(%__MODULE__{} = q) do - %{q | queue: :queue.new(), size: 0} - end - - @doc """ - Converts the queue to a list for inspection/testing. - - Events are ordered from oldest to newest (front to back). - """ - @spec to_list(t()) :: [term()] - def to_list(%__MODULE__{} = q) do - :queue.to_list(q.queue) - end - - # Private functions - - # Drops the oldest event and pushes a new one. - # Logs a warning if rate limit allows. - defp drop_oldest_and_push(%__MODULE__{} = q, new_event) do - # Drop oldest from front - {{:value, oldest}, queue_after_drop} = :queue.out(q.queue) - - # Add new event at back - new_queue = :queue.in(new_event, queue_after_drop) - - new_q = %{q | queue: new_queue, dropped_count: q.dropped_count + 1} - - # Log warning with rate limiting - maybe_log_overflow(new_q) - - # Return dropped event and new queue - {{:dropped, oldest}, new_q} - end - - # Logs overflow warning if rate limit allows. - defp maybe_log_overflow(%__MODULE__{dropped_count: count} = q) do - now = System.monotonic_time(:millisecond) - should_log = should_log_warning?(q, now) - - if should_log do - Logger.warning( - "TermUI.EventQueue: Overflow! Dropped events (total: #{count}). " <> - "Input arriving faster than processing. Events are being dropped." - ) - - %{q | last_warning: now} - else - q - end - end - - # Determines if we should log a warning based on rate limit. - defp should_log_warning?(%__MODULE__{last_warning: nil}, _now), do: true - - defp should_log_warning?(%__MODULE__{last_warning: last}, now) do - now - last >= warning_interval() - end -end diff --git a/lib/term_ui/event_router.ex b/lib/term_ui/event_router.ex deleted file mode 100644 index 005a65ed..00000000 --- a/lib/term_ui/event_router.ex +++ /dev/null @@ -1,308 +0,0 @@ -defmodule TermUI.EventRouter do - @moduledoc """ - Central event routing for TermUI components. - - The EventRouter manages event distribution to components based on: - - Focus state for keyboard events - - Spatial index for mouse events - - Broadcast for system events (resize) - - ## Usage - - # Route a keyboard event to focused component - EventRouter.route(%Event.Key{key: :enter}) - - # Route a mouse event to component at position - EventRouter.route(%Event.Mouse{action: :click, x: 10, y: 5}) - - # Set focused component - EventRouter.set_focus(:my_input) - - # Broadcast to all components - EventRouter.broadcast({:resize, 80, 24}) - - ## Event Flow - - 1. Event received by router - 2. Router determines target based on event type - 3. Event delivered to target component - 4. If unhandled, event bubbles to parent (if propagation enabled) - """ - - use GenServer - - alias TermUI.ComponentRegistry - alias TermUI.Event - alias TermUI.SpatialIndex - - # Dialyzer: Functions with unmatched return values in side-effect calls - @dialyzer {:nowarn_function, handle_call: 3, send_focus_event: 2} - - @type route_result :: :handled | :unhandled | {:error, term()} - - # Client API - - @doc """ - Starts the event router. - """ - @spec start_link(keyword()) :: GenServer.on_start() - def start_link(opts \\ []) do - name = Keyword.get(opts, :name, __MODULE__) - GenServer.start_link(__MODULE__, opts, name: name) - end - - @doc """ - Routes an event to the appropriate component. - - Keyboard and focus events go to the focused component. - Mouse events go to the component at the mouse position. - - ## Returns - - - `:handled` - Event was processed by a component - - `:unhandled` - No component handled the event - - `{:error, reason}` - Routing failed - """ - @spec route(Event.Key.t() | Event.Mouse.t() | Event.Focus.t() | Event.Custom.t()) :: - route_result() - def route(event) do - GenServer.call(__MODULE__, {:route, event}) - end - - @doc """ - Sets the currently focused component. - - Sends focus lost event to previous focus and focus gained to new focus. - - ## Parameters - - - `component_id` - The component to focus, or nil to clear focus - """ - @spec set_focus(term() | nil) :: :ok - def set_focus(component_id) do - GenServer.call(__MODULE__, {:set_focus, component_id}) - end - - @doc """ - Gets the currently focused component. - - ## Returns - - - `{:ok, component_id}` - The focused component - - `{:ok, nil}` - No component focused - """ - @spec get_focus() :: {:ok, term() | nil} - def get_focus do - GenServer.call(__MODULE__, :get_focus) - end - - @doc """ - Clears the current focus. - """ - @spec clear_focus() :: :ok - def clear_focus do - set_focus(nil) - end - - @doc """ - Broadcasts an event to all registered components. - - Useful for system-wide events like resize. - - ## Returns - - - `{:ok, count}` - Number of components that received the event - """ - @spec broadcast(term()) :: {:ok, non_neg_integer()} - def broadcast(event) do - GenServer.call(__MODULE__, {:broadcast, event}) - end - - @doc """ - Routes an event directly to a specific component by id. - - ## Returns - - - `:handled` - Component handled the event - - `:unhandled` - Component did not handle the event - - `{:error, :not_found}` - Component not found - """ - @spec route_to(term(), term()) :: route_result() - def route_to(component_id, event) do - GenServer.call(__MODULE__, {:route_to, component_id, event}) - end - - @doc """ - Registers a global event handler for events that no component handles. - - The handler receives unhandled events and can process them as needed. - - ## Parameters - - - `handler` - Function that receives events: `fn event -> :ok end` - """ - @spec set_fallback_handler((term() -> :ok)) :: :ok - def set_fallback_handler(handler) when is_function(handler, 1) do - GenServer.call(__MODULE__, {:set_fallback_handler, handler}) - end - - @doc """ - Clears the fallback handler. - """ - @spec clear_fallback_handler() :: :ok - def clear_fallback_handler do - GenServer.call(__MODULE__, :clear_fallback_handler) - end - - # Server Callbacks - - @impl true - def init(_opts) do - state = %{ - focus: nil, - fallback_handler: nil - } - - {:ok, state} - end - - @impl true - def handle_call({:route, event}, _from, state) do - result = do_route(event, state) - {:reply, result, state} - end - - @impl true - def handle_call({:set_focus, component_id}, _from, state) do - old_focus = state.focus - - # Send focus lost to old component - if old_focus && old_focus != component_id do - send_focus_event(old_focus, :lost) - end - - # Send focus gained to new component - if component_id && component_id != old_focus do - send_focus_event(component_id, :gained) - end - - {:reply, :ok, %{state | focus: component_id}} - end - - @impl true - def handle_call(:get_focus, _from, state) do - {:reply, {:ok, state.focus}, state} - end - - @impl true - def handle_call({:broadcast, event}, _from, state) do - components = ComponentRegistry.list_all() - count = length(components) - - Enum.each(components, fn %{pid: pid} -> - send_event(pid, event) - end) - - {:reply, {:ok, count}, state} - end - - @impl true - def handle_call({:route_to, component_id, event}, _from, state) do - result = - case ComponentRegistry.lookup(component_id) do - {:ok, pid} -> - send_event(pid, event) - - {:error, :not_found} -> - {:error, :not_found} - end - - {:reply, result, state} - end - - @impl true - def handle_call({:set_fallback_handler, handler}, _from, state) do - {:reply, :ok, %{state | fallback_handler: handler}} - end - - @impl true - def handle_call(:clear_fallback_handler, _from, state) do - {:reply, :ok, %{state | fallback_handler: nil}} - end - - # Private Functions - - defp do_route(%Event.Key{} = event, state) do - route_to_focus(event, state) - end - - defp do_route(%Event.Focus{} = event, state) do - route_to_focus(event, state) - end - - defp do_route(%Event.Mouse{} = event, state) do - route_to_position(event, state) - end - - defp do_route(%Event.Custom{} = event, state) do - # Custom events go to focused component by default - route_to_focus(event, state) - end - - defp route_to_focus(event, state) do - case state.focus do - nil -> - handle_unrouted(event, state) - - component_id -> - case ComponentRegistry.lookup(component_id) do - {:ok, pid} -> - send_event(pid, event) - - {:error, :not_found} -> - handle_unrouted(event, state) - end - end - end - - defp route_to_position(%Event.Mouse{x: x, y: y} = event, state) do - case SpatialIndex.find_at(x, y) do - {:ok, {_id, pid}} -> - send_event(pid, event) - - {:error, :not_found} -> - handle_unrouted(event, state) - end - end - - defp handle_unrouted(event, %{fallback_handler: handler}) when is_function(handler) do - handler.(event) - :unhandled - end - - defp handle_unrouted(_event, _state) do - :unhandled - end - - defp send_event(pid, event) do - case GenServer.call(pid, {:event, event}, 5000) do - :handled -> :handled - :unhandled -> :unhandled - {:ok, _} -> :handled - _ -> :unhandled - end - catch - :exit, _ -> {:error, :component_unavailable} - end - - defp send_focus_event(component_id, action) do - case ComponentRegistry.lookup(component_id) do - {:ok, pid} -> - event = Event.focus(action) - send_event(pid, event) - - {:error, :not_found} -> - :ok - end - end -end diff --git a/lib/term_ui/focus.ex b/lib/term_ui/focus.ex deleted file mode 100644 index 7d3867c6..00000000 --- a/lib/term_ui/focus.ex +++ /dev/null @@ -1,371 +0,0 @@ -defmodule TermUI.Focus do - @moduledoc """ - Focus event utilities for terminal window focus tracking. - - Provides escape sequences and utilities for detecting when the - terminal window gains or loses system focus. This enables optimization - opportunities like pausing animations when backgrounded. - - ## Usage - - # Enable focus reporting - IO.write(Focus.enable()) - - # Check if focus reporting is supported - if Focus.supported?() do - IO.write(Focus.enable()) - end - - # Disable focus reporting - IO.write(Focus.disable()) - """ - - # Focus reporting mode - # ESC [ ? 1004 h - Enable focus reporting - # ESC [ ? 1004 l - Disable focus reporting - @focus_enable "\e[?1004h" - @focus_disable "\e[?1004l" - - # Focus event sequences - # ESC [ I - Focus gained - # ESC [ O - Focus lost - @focus_gained "\e[I" - @focus_lost "\e[O" - - @doc """ - Returns escape sequence to enable focus reporting. - """ - @spec enable() :: String.t() - def enable, do: @focus_enable - - @doc """ - Returns escape sequence to disable focus reporting. - """ - @spec disable() :: String.t() - def disable, do: @focus_disable - - @doc """ - Returns the focus gained sequence. - """ - @spec gained_sequence() :: String.t() - def gained_sequence, do: @focus_gained - - @doc """ - Returns the focus lost sequence. - """ - @spec lost_sequence() :: String.t() - def lost_sequence, do: @focus_lost - - @doc """ - Checks if focus reporting is likely supported. - - This is a heuristic check based on terminal type. Many modern - terminals support focus reporting but don't advertise it. - - Known supporting terminals: - - xterm (with allowWindowOps) - - iTerm2 - - Alacritty - - Kitty - - WezTerm - - foot - - GNOME Terminal - - Windows Terminal - """ - @spec supported?() :: boolean() - def supported? do - term = System.get_env("TERM", "") - term_program = System.get_env("TERM_PROGRAM", "") - - known_terminal_program?(term_program) or - known_terminal_env?() or - known_terminal_type?(term) - end - - defp known_terminal_program?(term_program) do - String.contains?(term_program, "iTerm") or - String.contains?(term_program, "Alacritty") or - String.contains?(term_program, "WezTerm") - end - - defp known_terminal_env? do - System.get_env("KITTY_WINDOW_ID") != nil or - System.get_env("WT_SESSION") != nil or - System.get_env("VTE_VERSION") != nil - end - - defp known_terminal_type?(term) do - String.starts_with?(term, "xterm") or - term == "foot" or - term == "foot-extra" - end - - @doc """ - Parses input to detect focus events. - - Returns `{:focus, :gained}`, `{:focus, :lost}`, or `nil` if not a focus event. - """ - @spec parse(String.t()) :: {:focus, :gained | :lost} | nil - def parse(@focus_gained), do: {:focus, :gained} - def parse(@focus_lost), do: {:focus, :lost} - def parse(_), do: nil -end - -defmodule TermUI.Focus.Tracker do - @moduledoc """ - Focus state tracker with action registration. - - Maintains focus state and executes registered actions when - focus changes. Supports optimization hooks for reducing work - when the application is backgrounded. - - ## Usage - - {:ok, tracker} = Focus.Tracker.start_link() - - # Register focus actions - Focus.Tracker.on_focus_lost(tracker, fn -> - save_state() - end) - - Focus.Tracker.on_focus_gained(tracker, fn -> - refresh_content() - end) - - # Update focus state - Focus.Tracker.set_focus(tracker, true) - - # Query focus state - Focus.Tracker.has_focus?(tracker) - """ - - use GenServer - - @type t :: %__MODULE__{ - has_focus: boolean(), - on_gained: [(-> any())], - on_lost: [(-> any())], - paused: boolean(), - reduced_framerate: boolean(), - auto_pause: boolean(), - auto_reduce_framerate: boolean() - } - - defstruct has_focus: true, - on_gained: [], - on_lost: [], - paused: false, - reduced_framerate: false, - auto_pause: false, - auto_reduce_framerate: false - - # --- Public API --- - - @doc """ - Starts the focus tracker. - """ - @spec start_link(keyword()) :: GenServer.on_start() - def start_link(opts \\ []) do - {name, opts} = Keyword.pop(opts, :name) - - if name do - GenServer.start_link(__MODULE__, opts, name: name) - else - GenServer.start_link(__MODULE__, opts) - end - end - - @doc """ - Sets the focus state. - """ - @spec set_focus(GenServer.server(), boolean()) :: :ok - def set_focus(tracker, focused) when is_boolean(focused) do - GenServer.call(tracker, {:set_focus, focused}) - end - - @doc """ - Returns true if the application has focus. - """ - @spec has_focus?(GenServer.server()) :: boolean() - def has_focus?(tracker) do - GenServer.call(tracker, :has_focus?) - end - - @doc """ - Registers an action to execute when focus is gained. - """ - @spec on_focus_gained(GenServer.server(), (-> any())) :: :ok - def on_focus_gained(tracker, action) when is_function(action, 0) do - GenServer.call(tracker, {:on_focus_gained, action}) - end - - @doc """ - Registers an action to execute when focus is lost. - """ - @spec on_focus_lost(GenServer.server(), (-> any())) :: :ok - def on_focus_lost(tracker, action) when is_function(action, 0) do - GenServer.call(tracker, {:on_focus_lost, action}) - end - - @doc """ - Clears all registered actions. - """ - @spec clear_actions(GenServer.server()) :: :ok - def clear_actions(tracker) do - GenServer.call(tracker, :clear_actions) - end - - @doc """ - Returns true if animations should be paused. - - This is set when focus is lost and auto_pause is enabled. - """ - @spec paused?(GenServer.server()) :: boolean() - def paused?(tracker) do - GenServer.call(tracker, :paused?) - end - - @doc """ - Sets the paused state manually. - """ - @spec set_paused(GenServer.server(), boolean()) :: :ok - def set_paused(tracker, paused) when is_boolean(paused) do - GenServer.call(tracker, {:set_paused, paused}) - end - - @doc """ - Returns true if framerate should be reduced. - - This is set when focus is lost and auto_reduce_framerate is enabled. - """ - @spec reduced_framerate?(GenServer.server()) :: boolean() - def reduced_framerate?(tracker) do - GenServer.call(tracker, :reduced_framerate?) - end - - @doc """ - Sets the reduced framerate state manually. - """ - @spec set_reduced_framerate(GenServer.server(), boolean()) :: :ok - def set_reduced_framerate(tracker, reduced) when is_boolean(reduced) do - GenServer.call(tracker, {:set_reduced_framerate, reduced}) - end - - @doc """ - Enables automatic pause when focus is lost. - """ - @spec enable_auto_pause(GenServer.server()) :: :ok - def enable_auto_pause(tracker) do - GenServer.call(tracker, :enable_auto_pause) - end - - @doc """ - Enables automatic framerate reduction when focus is lost. - """ - @spec enable_auto_reduce_framerate(GenServer.server()) :: :ok - def enable_auto_reduce_framerate(tracker) do - GenServer.call(tracker, :enable_auto_reduce_framerate) - end - - # --- GenServer Callbacks --- - - @impl true - def init(opts) do - state = %__MODULE__{ - has_focus: Keyword.get(opts, :initial_focus, true) - } - - {:ok, state} - end - - @impl true - def handle_call({:set_focus, focused}, _from, state) do - if focused == state.has_focus do - {:reply, :ok, state} - else - # Update focus state - state = %{state | has_focus: focused} - - # Update auto-pause and auto-reduce states - state = - if state.auto_pause do - %{state | paused: not focused} - else - state - end - - state = - if state.auto_reduce_framerate do - %{state | reduced_framerate: not focused} - else - state - end - - # Execute actions - actions = if focused, do: state.on_gained, else: state.on_lost - - Enum.each(actions, fn action -> - try do - action.() - rescue - _ -> :ok - end - end) - - {:reply, :ok, state} - end - end - - @impl true - def handle_call(:has_focus?, _from, state) do - {:reply, state.has_focus, state} - end - - @impl true - def handle_call({:on_focus_gained, action}, _from, state) do - state = %{state | on_gained: state.on_gained ++ [action]} - {:reply, :ok, state} - end - - @impl true - def handle_call({:on_focus_lost, action}, _from, state) do - state = %{state | on_lost: state.on_lost ++ [action]} - {:reply, :ok, state} - end - - @impl true - def handle_call(:clear_actions, _from, state) do - state = %{state | on_gained: [], on_lost: []} - {:reply, :ok, state} - end - - @impl true - def handle_call(:paused?, _from, state) do - {:reply, state.paused, state} - end - - @impl true - def handle_call({:set_paused, paused}, _from, state) do - {:reply, :ok, %{state | paused: paused}} - end - - @impl true - def handle_call(:reduced_framerate?, _from, state) do - {:reply, state.reduced_framerate, state} - end - - @impl true - def handle_call({:set_reduced_framerate, reduced}, _from, state) do - {:reply, :ok, %{state | reduced_framerate: reduced}} - end - - @impl true - def handle_call(:enable_auto_pause, _from, state) do - {:reply, :ok, %{state | auto_pause: true}} - end - - @impl true - def handle_call(:enable_auto_reduce_framerate, _from, state) do - {:reply, :ok, %{state | auto_reduce_framerate: true}} - end -end diff --git a/lib/term_ui/focus/indicator.ex b/lib/term_ui/focus/indicator.ex deleted file mode 100644 index 5571f3dc..00000000 --- a/lib/term_ui/focus/indicator.ex +++ /dev/null @@ -1,199 +0,0 @@ -defmodule TermUI.Focus.Indicator do - @moduledoc """ - Focus indicator styles for visual focus feedback. - - Provides default and customizable styles for indicating - which component has focus. - - ## Usage - - # Get default focus style - style = Indicator.default_style() - - # Get focus style for component - style = Indicator.get_style(:my_button, opts) - - # Apply focus styling to a cell - cell = Indicator.apply_focus_style(cell) - """ - - alias TermUI.Renderer.Style - - # Dialyzer: Functions return specific atom types - @dialyzer {:nowarn_function, focus_border_color: 0} - - @type border_style :: :none | :single | :double | :rounded | :thick - - @type indicator_style :: %{ - border: border_style() | nil, - fg: Style.color() | nil, - bg: Style.color() | nil, - bold: boolean() - } - - @doc """ - Returns the default focus indicator style. - - Default style uses a highlighted border color. - """ - @spec default_style() :: indicator_style() - def default_style do - %{ - border: :single, - fg: :cyan, - bg: nil, - bold: true - } - end - - @doc """ - Gets the focus indicator style for a component. - - Merges default style with component-specific overrides. - - ## Parameters - - - `component_id` - Component to get style for - - `opts` - Options: - - `:styles` - Map of component_id => indicator_style - - ## Returns - - Focus indicator style map. - """ - @spec get_style(term(), keyword()) :: indicator_style() - def get_style(component_id, opts \\ []) do - styles = Keyword.get(opts, :styles, %{}) - custom = Map.get(styles, component_id, %{}) - - Map.merge(default_style(), custom) - end - - @doc """ - Creates a Style struct from focus indicator style. - - ## Parameters - - - `indicator` - Focus indicator style map - - ## Returns - - A Style struct suitable for rendering. - """ - @spec to_render_style(indicator_style()) :: Style.t() - def to_render_style(indicator) do - opts = [] - - opts = - if indicator[:fg] do - [{:fg, indicator[:fg]} | opts] - else - opts - end - - opts = - if indicator[:bg] do - [{:bg, indicator[:bg]} | opts] - else - opts - end - - opts = - if indicator[:bold] do - [{:attrs, [:bold]} | opts] - else - opts - end - - Style.new(opts) - end - - @doc """ - Gets focus border color. - - Returns the color to use for focused component borders. - - ## Returns - - Color atom (e.g., :cyan, :blue). - """ - @spec focus_border_color() :: atom() - def focus_border_color do - :cyan - end - - @doc """ - Checks if focus indicators should animate. - - Some terminals support blinking or pulsing focus indicators. - - ## Returns - - Boolean indicating animation support. - """ - @spec animate?() :: false - def animate? do - # Animation disabled by default for simplicity - false - end - - @doc """ - Returns predefined focus indicator themes. - - ## Available Themes - - - `:default` - Cyan border with bold - - `:subtle` - Dim border color change - - `:bold` - Bright yellow with background - - `:minimal` - No border, just cursor - - ## Returns - - Map of theme name to indicator style. - """ - @spec themes() :: %{atom() => indicator_style()} - def themes do - %{ - default: %{ - border: :single, - fg: :cyan, - bg: nil, - bold: true - }, - subtle: %{ - border: :single, - fg: :white, - bg: nil, - bold: false - }, - bold: %{ - border: :double, - fg: :yellow, - bg: :blue, - bold: true - }, - minimal: %{ - border: nil, - fg: nil, - bg: nil, - bold: false - } - } - end - - @doc """ - Gets a predefined theme by name. - - ## Parameters - - - `theme_name` - Name of the theme - - ## Returns - - Indicator style for the theme, or default if not found. - """ - @spec get_theme(atom()) :: indicator_style() - def get_theme(theme_name) do - Map.get(themes(), theme_name, default_style()) - end -end diff --git a/lib/term_ui/focus/traversal.ex b/lib/term_ui/focus/traversal.ex deleted file mode 100644 index 15fc9fe0..00000000 --- a/lib/term_ui/focus/traversal.ex +++ /dev/null @@ -1,184 +0,0 @@ -defmodule TermUI.Focus.Traversal do - @moduledoc """ - Focus traversal utilities for calculating tab order. - - Provides utilities for determining the order in which components - receive focus during Tab/Shift+Tab navigation. - - ## Tab Order - - Components are ordered by: - 1. Explicit `tab_index` (lower numbers first) - 2. Screen position (top-to-bottom, left-to-right) - - ## Usage - - # Get tab order for components - order = Traversal.calculate_order(component_ids) - - # Check if component should be skipped - Traversal.should_skip?(component_id) - """ - - alias TermUI.SpatialIndex - - @doc """ - Calculates the tab order for a list of components. - - Returns components sorted by tab index, then by position. - - ## Parameters - - - `component_ids` - List of component ids - - `opts` - Options: - - `:tab_indices` - Map of component_id => tab_index - - ## Returns - - Sorted list of component ids. - """ - @spec calculate_order([term()], keyword()) :: [term()] - def calculate_order(component_ids, opts \\ []) do - tab_indices = Keyword.get(opts, :tab_indices, %{}) - - component_ids - |> Enum.map(fn id -> - tab_index = Map.get(tab_indices, id) - position = get_position(id) - {id, tab_index, position} - end) - |> Enum.sort_by(fn {_id, tab_index, {x, y}} -> - # nil tab_index sorts last - index = tab_index || 999_999 - {index, y, x} - end) - |> Enum.map(fn {id, _, _} -> id end) - end - - @doc """ - Gets the next component in tab order. - - ## Parameters - - - `ordered_list` - Components in tab order - - `current` - Currently focused component (or nil) - - ## Returns - - Next component id, wrapping to first if at end. - """ - @spec next([term()], term() | nil) :: term() | nil - def next([], _current), do: nil - - def next(ordered_list, nil) do - List.first(ordered_list) - end - - def next(ordered_list, current) do - case Enum.find_index(ordered_list, &(&1 == current)) do - nil -> - List.first(ordered_list) - - idx -> - next_idx = rem(idx + 1, length(ordered_list)) - Enum.at(ordered_list, next_idx) - end - end - - @doc """ - Gets the previous component in tab order. - - ## Parameters - - - `ordered_list` - Components in tab order - - `current` - Currently focused component (or nil) - - ## Returns - - Previous component id, wrapping to last if at beginning. - """ - @spec prev([term()], term() | nil) :: term() | nil - def prev([], _current), do: nil - - def prev(ordered_list, nil) do - List.last(ordered_list) - end - - def prev(ordered_list, current) do - case Enum.find_index(ordered_list, &(&1 == current)) do - nil -> - List.last(ordered_list) - - 0 -> - List.last(ordered_list) - - idx -> - Enum.at(ordered_list, idx - 1) - end - end - - @doc """ - Checks if a component should be skipped during traversal. - - A component is skipped if: - - It has `focusable: false` - - It has `disabled: true` - - It has a negative `tab_index` - - ## Parameters - - - `component_id` - Component to check - - `opts` - Options: - - `:focusable` - Map of component_id => boolean - - `:disabled` - Map of component_id => boolean - - `:tab_indices` - Map of component_id => integer - - ## Returns - - Boolean indicating if component should be skipped. - """ - @spec should_skip?(term(), keyword()) :: boolean() - def should_skip?(component_id, opts \\ []) do - focusable_map = Keyword.get(opts, :focusable, %{}) - disabled_map = Keyword.get(opts, :disabled, %{}) - tab_indices = Keyword.get(opts, :tab_indices, %{}) - - # Check focusable (default true) - focusable = Map.get(focusable_map, component_id, true) - - # Check disabled (default false) - disabled = Map.get(disabled_map, component_id, false) - - # Check negative tab_index - tab_index = Map.get(tab_indices, component_id) - negative_tab = is_integer(tab_index) && tab_index < 0 - - !focusable || disabled || negative_tab - end - - @doc """ - Filters a list to only focusable components. - - ## Parameters - - - `component_ids` - List of component ids - - `opts` - Options passed to `should_skip?/2` - - ## Returns - - Filtered list of focusable component ids. - """ - @spec filter_focusable([term()], keyword()) :: [term()] - def filter_focusable(component_ids, opts \\ []) do - Enum.reject(component_ids, &should_skip?(&1, opts)) - end - - # Private Functions - - defp get_position(component_id) do - case SpatialIndex.get_bounds(component_id) do - {:ok, %{x: x, y: y}} -> {x, y} - _ -> {0, 0} - end - end -end diff --git a/lib/term_ui/focus_manager.ex b/lib/term_ui/focus_manager.ex deleted file mode 100644 index 330189a9..00000000 --- a/lib/term_ui/focus_manager.ex +++ /dev/null @@ -1,561 +0,0 @@ -defmodule TermUI.FocusManager do - @moduledoc """ - Central focus management for TermUI components. - - The FocusManager tracks which component receives keyboard input, - provides focus traversal (Tab/Shift+Tab), and manages focus - trapping for modal contexts. - - ## Usage - - # Get current focus - {:ok, component_id} = FocusManager.get_focused() - - # Set focus to component - :ok = FocusManager.set_focused(:my_input) - - # Tab navigation - :ok = FocusManager.focus_next() - :ok = FocusManager.focus_prev() - - # Focus trapping for modals - :ok = FocusManager.trap_focus(:modal_group) - :ok = FocusManager.release_focus() - - ## Focus Stack - - The FocusManager maintains a focus stack for modal contexts. - When a modal opens, it pushes the current focus and sets new focus. - When closed, focus pops back to the previous component. - """ - - use GenServer - - alias TermUI.ComponentRegistry - alias TermUI.Event - alias TermUI.EventRouter - alias TermUI.SpatialIndex - - # Dialyzer: Pattern match and unmatched return warnings - @dialyzer {:nowarn_function, - get_focused: 0, - set_focused: 1, - find_next: 2, - find_prev: 2, - handle_call: 3, - clear_focus: 0} - - # Client API - - @doc """ - Starts the focus manager. - """ - @spec start_link(keyword()) :: GenServer.on_start() - def start_link(opts \\ []) do - name = Keyword.get(opts, :name, __MODULE__) - GenServer.start_link(__MODULE__, opts, name: name) - end - - @doc """ - Gets the currently focused component. - - ## Returns - - - `{:ok, component_id}` - The focused component - - `{:ok, nil}` - No component focused - """ - @spec get_focused() :: {:ok, term() | nil} - def get_focused do - GenServer.call(__MODULE__, :get_focused) - end - - @doc """ - Sets focus to a specific component. - - Sends blur event to the previously focused component and - focus event to the new component. - - ## Parameters - - - `component_id` - Component to focus, or nil to clear focus - - ## Returns - - - `:ok` - Focus changed successfully - - `{:error, :not_focusable}` - Component cannot receive focus - - `{:error, :not_found}` - Component not registered - """ - @spec set_focused(term() | nil) :: :ok | {:error, atom()} - def set_focused(component_id) do - GenServer.call(__MODULE__, {:set_focused, component_id}) - end - - @doc """ - Clears the current focus. - """ - @spec clear_focus() :: :ok - def clear_focus do - set_focused(nil) - :ok - end - - @doc """ - Moves focus to the next focusable component in tab order. - - ## Returns - - - `:ok` - Focus moved to next component - - `{:error, :no_focusable}` - No focusable components available - """ - @spec focus_next() :: :ok | {:error, atom()} - def focus_next do - GenServer.call(__MODULE__, :focus_next) - end - - @doc """ - Moves focus to the previous focusable component in tab order. - - ## Returns - - - `:ok` - Focus moved to previous component - - `{:error, :no_focusable}` - No focusable components available - """ - @spec focus_prev() :: :ok | {:error, atom()} - def focus_prev do - GenServer.call(__MODULE__, :focus_prev) - end - - @doc """ - Pushes current focus to stack and sets new focus. - - Useful for modal dialogs that need to restore focus when closed. - - ## Parameters - - - `component_id` - Component to focus - """ - @spec push_focus(term()) :: :ok | {:error, atom()} - def push_focus(component_id) do - GenServer.call(__MODULE__, {:push_focus, component_id}) - end - - @doc """ - Pops focus from stack, restoring previous focus. - - ## Returns - - - `:ok` - Focus restored - - `{:error, :empty_stack}` - No focus to restore - """ - @spec pop_focus() :: :ok | {:error, atom()} - def pop_focus do - GenServer.call(__MODULE__, :pop_focus) - end - - @doc """ - Registers a focus group for focus trapping. - - ## Parameters - - - `group_id` - Unique identifier for the group - - `component_ids` - List of component ids in the group - """ - @spec register_group(term(), [term()]) :: :ok - def register_group(group_id, component_ids) do - GenServer.call(__MODULE__, {:register_group, group_id, component_ids}) - end - - @doc """ - Unregisters a focus group. - """ - @spec unregister_group(term()) :: :ok - def unregister_group(group_id) do - GenServer.call(__MODULE__, {:unregister_group, group_id}) - end - - @doc """ - Traps focus within a group. - - Tab navigation will cycle within the group instead of - escaping to other components. - - ## Parameters - - - `group_id` - Group to trap focus within - """ - @spec trap_focus(term()) :: :ok | {:error, atom()} - def trap_focus(group_id) do - GenServer.call(__MODULE__, {:trap_focus, group_id}) - end - - @doc """ - Releases the current focus trap. - """ - @spec release_focus() :: :ok - def release_focus do - GenServer.call(__MODULE__, :release_focus) - end - - @doc """ - Checks if a component is currently focused. - """ - @spec focused?(term()) :: boolean() - def focused?(component_id) do - case get_focused() do - {:ok, ^component_id} -> true - _ -> false - end - end - - @doc """ - Requests auto-focus for a component on mount. - - Should be called from component mount if auto_focus prop is true. - """ - @spec request_auto_focus(term()) :: :ok - def request_auto_focus(component_id) do - GenServer.cast(__MODULE__, {:request_auto_focus, component_id}) - end - - @doc """ - Gets all registered focus groups. - """ - @spec get_groups() :: %{term() => [term()]} - def get_groups do - GenServer.call(__MODULE__, :get_groups) - end - - @doc """ - Gets the current focus stack. - """ - @spec get_stack() :: [term()] - def get_stack do - GenServer.call(__MODULE__, :get_stack) - end - - # Server Callbacks - - @impl true - def init(_opts) do - state = %{ - current: nil, - stack: [], - groups: %{}, - trapped_group: nil - } - - {:ok, state} - end - - @impl true - def handle_call(:get_focused, _from, state) do - {:reply, {:ok, state.current}, state} - end - - @impl true - def handle_call({:set_focused, component_id}, _from, state) do - case do_set_focused(component_id, state) do - {:ok, new_state} -> - {:reply, :ok, new_state} - - {:error, reason} -> - {:reply, {:error, reason}, state} - end - end - - @impl true - def handle_call(:focus_next, _from, state) do - case do_focus_next(state) do - {:ok, new_state} -> - {:reply, :ok, new_state} - - {:error, reason} -> - {:reply, {:error, reason}, state} - end - end - - @impl true - def handle_call(:focus_prev, _from, state) do - case do_focus_prev(state) do - {:ok, new_state} -> - {:reply, :ok, new_state} - - {:error, reason} -> - {:reply, {:error, reason}, state} - end - end - - @impl true - def handle_call({:push_focus, component_id}, _from, state) do - # Push current to stack - new_stack = - if state.current do - [state.current | state.stack] - else - state.stack - end - - state = %{state | stack: new_stack} - - case do_set_focused(component_id, state) do - {:ok, new_state} -> - {:reply, :ok, new_state} - - {:error, reason} -> - {:reply, {:error, reason}, state} - end - end - - @impl true - def handle_call(:pop_focus, _from, state) do - case state.stack do - [] -> - {:reply, {:error, :empty_stack}, state} - - [prev | rest] -> - state = %{state | stack: rest} - - case do_set_focused(prev, state) do - {:ok, new_state} -> - {:reply, :ok, new_state} - - {:error, _reason} -> - # If we can't restore focus, just clear it - {:reply, :ok, %{state | current: nil}} - end - end - end - - @impl true - def handle_call({:register_group, group_id, component_ids}, _from, state) do - groups = Map.put(state.groups, group_id, component_ids) - {:reply, :ok, %{state | groups: groups}} - end - - @impl true - def handle_call({:unregister_group, group_id}, _from, state) do - groups = Map.delete(state.groups, group_id) - - # Release trap if we're removing the trapped group - trapped = - if state.trapped_group == group_id do - nil - else - state.trapped_group - end - - {:reply, :ok, %{state | groups: groups, trapped_group: trapped}} - end - - @impl true - def handle_call({:trap_focus, group_id}, _from, state) do - if Map.has_key?(state.groups, group_id) do - {:reply, :ok, %{state | trapped_group: group_id}} - else - {:reply, {:error, :group_not_found}, state} - end - end - - @impl true - def handle_call(:release_focus, _from, state) do - {:reply, :ok, %{state | trapped_group: nil}} - end - - @impl true - def handle_call(:get_groups, _from, state) do - {:reply, state.groups, state} - end - - @impl true - def handle_call(:get_stack, _from, state) do - {:reply, state.stack, state} - end - - @impl true - def handle_cast({:request_auto_focus, component_id}, state) do - # Only auto-focus if nothing is currently focused - if state.current == nil do - case do_set_focused(component_id, state) do - {:ok, new_state} -> {:noreply, new_state} - {:error, _} -> {:noreply, state} - end - else - {:noreply, state} - end - end - - # Private Functions - - defp do_set_focused(nil, state) do - old_focus = state.current - - # Send blur to old - if old_focus do - send_focus_event(old_focus, :lost) - end - - # Update EventRouter - EventRouter.set_focus(nil) - - {:ok, %{state | current: nil}} - end - - defp do_set_focused(component_id, state) do - # Check if component exists and is focusable - with {:ok, _pid} <- ComponentRegistry.lookup(component_id), - true <- focusable?(component_id) do - update_focus(component_id, state) - else - {:error, :not_found} -> {:error, :not_found} - false -> {:error, :not_focusable} - end - end - - defp update_focus(component_id, %{current: old_focus} = state) when component_id != old_focus do - # Update EventRouter - this sends focus events - EventRouter.set_focus(component_id) - {:ok, %{state | current: component_id}} - end - - defp update_focus(component_id, state) do - {:ok, %{state | current: component_id}} - end - - defp do_focus_next(state) do - focusable = get_focusable_list(state) - - case focusable do - [] -> - {:error, :no_focusable} - - list -> - next = find_next(list, state.current) - do_set_focused(next, state) - end - end - - defp do_focus_prev(state) do - focusable = get_focusable_list(state) - - case focusable do - [] -> - {:error, :no_focusable} - - list -> - prev = find_prev(list, state.current) - do_set_focused(prev, state) - end - end - - defp get_focusable_list(state) do - # If trapped, only include group components - components = - if state.trapped_group do - Map.get(state.groups, state.trapped_group, []) - |> Enum.filter(&component_exists?/1) - else - ComponentRegistry.list_all() - |> Enum.map(& &1.id) - end - - # Filter to focusable and sort by tab order - components - |> Enum.filter(&focusable?/1) - |> sort_by_tab_order() - end - - defp sort_by_tab_order(component_ids) do - component_ids - |> Enum.map(fn id -> - {tab_index, position} = get_tab_info(id) - {id, tab_index, position} - end) - |> Enum.sort_by(fn {_id, tab_index, {x, y}} -> - # Sort by tab_index first (nil = max), then by position (y, x) - {tab_index || 999_999, y, x} - end) - |> Enum.map(fn {id, _, _} -> id end) - end - - defp get_tab_info(component_id) do - # Get tab_index from component props if available - # Get position from spatial index - tab_index = get_component_tab_index(component_id) - - position = - case SpatialIndex.get_bounds(component_id) do - {:ok, %{x: x, y: y}} -> {x, y} - _ -> {0, 0} - end - - {tab_index, position} - end - - defp get_component_tab_index(_component_id) do - # Return nil to use position-based ordering - nil - end - - defp find_next(list, nil) do - # No current focus, return first - List.first(list) - end - - defp find_next(list, current) do - case Enum.find_index(list, &(&1 == current)) do - nil -> - List.first(list) - - idx -> - next_idx = rem(idx + 1, length(list)) - Enum.at(list, next_idx) - end - end - - defp find_prev(list, nil) do - # No current focus, return last - List.last(list) - end - - defp find_prev(list, current) do - case Enum.find_index(list, &(&1 == current)) do - nil -> - List.last(list) - - 0 -> - List.last(list) - - idx -> - Enum.at(list, idx - 1) - end - end - - defp focusable?(component_id) do - # Check if component is focusable - # Components are focusable by default unless explicitly disabled - component_exists?(component_id) - end - - defp component_exists?(component_id) do - case ComponentRegistry.lookup(component_id) do - {:ok, _} -> true - _ -> false - end - end - - defp send_focus_event(component_id, action) do - case ComponentRegistry.lookup(component_id) do - {:ok, pid} -> - event = Event.focus(action) - - try do - GenServer.call(pid, {:event, event}, 5000) - catch - :exit, _ -> :ok - end - - {:error, _} -> - :ok - end - end -end diff --git a/lib/term_ui/frame.ex b/lib/term_ui/frame.ex new file mode 100644 index 00000000..312666b1 --- /dev/null +++ b/lib/term_ui/frame.ex @@ -0,0 +1,342 @@ +defmodule TermUI.Frame do + @moduledoc """ + A complete terminal frame. + + A frame is the only render value accepted by `TermUI.Runtime`. It stores + terminal cells in a sparse map. Missing positions are default blank cells. + Frame dimensions and cursor coordinates are one-based. The cursor tuple is + `{column, row}`. + """ + + alias TermUI.{Cell, DisplayWidth, Style} + + @max_rows 500 + @max_columns 1000 + + @type cursor :: {pos_integer(), pos_integer()} | nil + @type position :: {row :: pos_integer(), column :: pos_integer()} + @type span :: String.t() | {iodata(), Style.t()} + @type row :: iodata() | [span()] + + @type t :: %__MODULE__{ + width: pos_integer(), + height: pos_integer(), + cells: %{optional(position()) => Cell.t()}, + cursor: cursor() + } + + @schema Zoi.struct(__MODULE__, %{ + width: Zoi.integer() |> Zoi.positive(), + height: Zoi.integer() |> Zoi.positive(), + cells: Zoi.map() |> Zoi.default(%{}), + cursor: Zoi.any() |> Zoi.default(nil) + }) + + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @doc "Returns the Zoi schema for complete terminal frames." + @spec schema() :: Zoi.schema() + def schema, do: @schema + + @doc "Creates an empty frame." + @spec new(pos_integer(), pos_integer(), keyword()) :: t() + def new(width, height, opts \\ []) do + validate_dimensions!(width, height) + + %__MODULE__{ + width: width, + height: height, + cells: normalize_cells(Keyword.get(opts, :cells, %{}), width, height), + cursor: normalize_cursor(Keyword.get(opts, :cursor), width, height) + } + end + + @doc "Builds a frame from plain rows or styled spans." + @spec from_rows([row()], pos_integer(), pos_integer(), keyword()) :: t() + def from_rows(rows, width, height, opts \\ []) when is_list(rows) do + frame = new(width, height, opts) + + rows + |> Enum.take(height) + |> Enum.with_index(1) + |> Enum.reduce(frame, fn {row, row_index}, acc -> put_row(acc, row_index, row) end) + end + + @doc "Writes one row. Content outside the frame is clipped." + @spec put_row(t(), pos_integer(), row()) :: t() + def put_row(%__MODULE__{} = frame, row, content) when row >= 1 and row <= frame.height do + spans = normalize_spans(content) + + {cells, _column} = + Enum.reduce(spans, {frame.cells, 1}, fn {text, style}, {cells, column} -> + write_text(cells, frame.width, row, column, IO.iodata_to_binary(text), style) + end) + + %{frame | cells: cells} + end + + def put_row(%__MODULE__{} = frame, _row, _content), do: frame + + @doc "Puts one cell. Empty default cells remain implicit." + @spec put_cell(t(), pos_integer(), pos_integer(), Cell.t()) :: t() + def put_cell(%__MODULE__{} = frame, row, column, %Cell{} = cell) + when row >= 1 and row <= frame.height and column >= 1 and column <= frame.width do + cells = put_sparse(frame.cells, {row, column}, normalize_cell(cell)) + %{frame | cells: cells} + end + + def put_cell(%__MODULE__{} = frame, _row, _column, %Cell{}), do: frame + + @doc "Writes consecutive rows starting at a one-based row." + @spec put_rows(t(), pos_integer(), [row()]) :: t() + def put_rows(%__MODULE__{} = frame, start_row, rows) + when is_integer(start_row) and start_row > 0 and is_list(rows) do + rows + |> Enum.with_index(start_row) + |> Enum.reduce(frame, fn {content, row}, acc -> put_row(acc, row, content) end) + end + + @doc "Overlays one frame at a one-based column and row." + @spec overlay(t(), t(), pos_integer(), pos_integer()) :: t() + def overlay(%__MODULE__{} = base, %__MODULE__{} = child, column, row) + when is_integer(column) and column > 0 and is_integer(row) and row > 0 do + if column > base.width or row > base.height do + base + else + do_overlay(base, child, column, row) + end + end + + defp do_overlay(base, child, column, row) do + cleared = clear_region(base, column, row, child.width, child.height) + + frame = + Enum.reduce(child.cells, cleared, fn {{child_row, child_column}, cell}, acc -> + put_cell(acc, row + child_row - 1, column + child_column - 1, cell) + end) + + case child.cursor do + nil -> + frame + + {child_column, child_row} -> + %{ + frame + | cursor: + normalize_cursor( + {column + child_column - 1, row + child_row - 1}, + base.width, + base.height + ) + } + end + end + + @doc "Gets one cell." + @spec cell(t(), pos_integer(), pos_integer()) :: Cell.t() + def cell(%__MODULE__{} = frame, row, column) do + Map.get(frame.cells, {row, column}, Cell.empty()) + end + + @doc "Returns one row as terminal text, including trailing blanks." + @spec row_text(t(), pos_integer()) :: String.t() + def row_text(%__MODULE__{} = frame, row) when row >= 1 and row <= frame.height do + 1..frame.width + |> Enum.map_join(fn column -> + case cell(frame, row, column) do + %Cell{wide_placeholder: true} -> "" + %Cell{char: char} -> char + end + end) + |> DisplayWidth.pad(frame.width) + end + + def row_text(%__MODULE__{}, _row), do: "" + + @doc "Returns all visible cells in backend row and column format." + @spec cells(t()) :: [{position(), TermUI.Backend.cell()}] + def cells(%__MODULE__{} = frame) do + frame.cells + |> Enum.flat_map(fn + {_position, %Cell{wide_placeholder: true}} -> [] + {position, cell} -> [{position, backend_cell(cell)}] + end) + |> Enum.sort_by(&elem(&1, 0)) + end + + @doc "Returns the changed backend cells between two frames." + @spec diff(t() | nil, t()) :: [{position(), TermUI.Backend.cell()}] + def diff(nil, %__MODULE__{} = current), do: cells(current) + + def diff(%__MODULE__{} = previous, %__MODULE__{} = current) do + previous.cells + |> Map.keys() + |> Kernel.++(Map.keys(current.cells)) + |> Enum.uniq() + |> Enum.filter(fn {row, column} -> row <= current.height and column <= current.width end) + |> Enum.reduce([], fn position, changes -> + old_cell = Map.get(previous.cells, position, Cell.empty()) + new_cell = Map.get(current.cells, position, Cell.empty()) + + cond do + Cell.equal?(old_cell, new_cell) -> + changes + + new_cell.wide_placeholder -> + changes + + Cell.empty?(new_cell) -> + [{position, {" ", :default, :default, []}} | changes] + + true -> + [{position, backend_cell(new_cell)} | changes] + end + end) + |> Enum.sort_by(&elem(&1, 0)) + end + + @doc "Fits text to one display row." + @spec fit(iodata(), non_neg_integer()) :: String.t() + def fit(_text, 0), do: "" + + def fit(text, width) when width > 0 do + text = text |> IO.iodata_to_binary() |> String.replace(["\r", "\n"], " ") + {content, _width} = DisplayWidth.truncate(text, width) + DisplayWidth.pad(content, width) + end + + @doc "Wraps text at display-width boundaries." + @spec wrap(String.t(), pos_integer()) :: [String.t()] + def wrap(text, width) when is_binary(text) and width > 0 do + text + |> String.split("\n", trim: false) + |> Enum.flat_map(&wrap_line(&1, width)) + end + + defp normalize_spans(content) when is_binary(content), do: [{content, Style.new()}] + + defp normalize_spans(content) when is_list(content) do + if :io_lib.printable_unicode_list(content) do + [{IO.iodata_to_binary(content), Style.new()}] + else + Enum.map(content, fn + {text, %Style{} = style} -> {text, style} + text -> {text, Style.new()} + end) + end + end + + defp normalize_spans(content), do: [{to_string(content), Style.new()}] + + defp write_text(cells, width, row, start_column, text, style) do + text + |> String.replace(["\r", "\n"], " ") + |> String.graphemes() + |> Enum.reduce_while({cells, start_column}, fn grapheme, {cells, column} -> + cell = Style.to_cell(style, grapheme) + cell_width = Cell.width(cell) + + cond do + column > width -> + {:halt, {cells, column}} + + cell_width == 2 and column == width -> + {:halt, {cells, column}} + + cell_width == 2 -> + cells = put_sparse(cells, {row, column}, cell) + cells = put_sparse(cells, {row, column + 1}, Cell.wide_placeholder(cell)) + {:cont, {cells, column + 2}} + + true -> + {:cont, {put_sparse(cells, {row, column}, cell), column + 1}} + end + end) + end + + defp normalize_cells(cells, width, height) when is_map(cells) do + Enum.reduce(cells, %{}, fn + {{row, column}, %Cell{} = cell}, acc + when row >= 1 and row <= height and column >= 1 and column <= width -> + put_sparse(acc, {row, column}, normalize_cell(cell)) + + _entry, acc -> + acc + end) + end + + defp normalize_cells(cells, width, height) when is_list(cells) do + cells + |> Map.new(fn + {row, column, %Cell{} = cell} -> {{row, column}, cell} + {{row, column}, %Cell{} = cell} -> {{row, column}, cell} + end) + |> normalize_cells(width, height) + end + + defp put_sparse(cells, position, %Cell{} = cell) do + if Cell.empty?(cell), do: Map.delete(cells, position), else: Map.put(cells, position, cell) + end + + defp clear_region(frame, column, row, width, height) do + positions = + for target_row <- row..min(row + height - 1, frame.height), + target_column <- column..min(column + width - 1, frame.width), + do: {target_row, target_column} + + cells = Enum.reduce(positions, frame.cells, &Map.delete(&2, &1)) + %{frame | cells: cells} + end + + defp normalize_cell(%Cell{wide_placeholder: true} = cell) do + " " + |> Cell.new(fg: cell.fg, bg: cell.bg, attrs: cell.attrs) + |> Cell.wide_placeholder() + end + + defp normalize_cell(%Cell{} = cell) do + Cell.new(cell.char, fg: cell.fg, bg: cell.bg, attrs: cell.attrs) + end + + defp normalize_cursor(nil, _width, _height), do: nil + + defp normalize_cursor({column, row}, width, height) + when is_integer(column) and is_integer(row) do + {column |> max(1) |> min(width), row |> max(1) |> min(height)} + end + + defp normalize_cursor(_cursor, _width, _height), do: nil + + defp backend_cell(%Cell{char: char, fg: foreground, bg: background, attrs: attrs}) do + {char, foreground || :default, background || :default, + attrs |> MapSet.to_list() |> Enum.sort()} + end + + defp wrap_line("", _width), do: [""] + + defp wrap_line(line, width) do + line + |> String.graphemes() + |> Enum.reduce({[], "", 0}, fn grapheme, {lines, current, current_width} -> + grapheme_width = max(DisplayWidth.width(grapheme), 0) + + if current != "" and current_width + grapheme_width > width do + {[current | lines], grapheme, grapheme_width} + else + {lines, current <> grapheme, current_width + grapheme_width} + end + end) + |> then(fn {lines, current, _current_width} -> Enum.reverse([current | lines]) end) + end + + defp validate_dimensions!(width, height) + when is_integer(width) and width > 0 and width <= @max_columns and + is_integer(height) and height > 0 and height <= @max_rows, + do: :ok + + defp validate_dimensions!(width, height) do + raise ArgumentError, + "frame dimensions must be within 1..#{@max_columns} by 1..#{@max_rows}, got #{inspect({width, height})}" + end +end diff --git a/lib/term_ui/helpers/border_helper.ex b/lib/term_ui/helpers/border_helper.ex deleted file mode 100644 index 92985d69..00000000 --- a/lib/term_ui/helpers/border_helper.ex +++ /dev/null @@ -1,317 +0,0 @@ -defmodule TermUI.Helpers.BorderHelper do - @moduledoc """ - Helper functions for rendering borders using CharacterSet. - - This module provides convenience functions for common border rendering - patterns, eliminating code duplication across widgets that draw borders. - - All functions use the current CharacterSet to ensure correct character - selection based on terminal capabilities (Unicode or ASCII). - - ## Usage - - import TermUI.Helpers.BorderHelper - - # Draw a horizontal line - line = horizontal_line(20) - # => "────────────────────" (Unicode) or "--------------------" (ASCII) - - # Draw a box top - top = box_top(20) - # => "┌──────────────────┐" (Unicode) or "+------------------+" (ASCII) - - ## Integration with Widgets - - Widgets can use these helpers to render borders consistently: - - def render_border(state, area) do - import TermUI.Helpers.BorderHelper - - stack(:vertical, [ - text(box_top(area.width)), - # ... content ... - text(box_bottom(area.width)) - ]) - end - """ - - alias TermUI.CharacterSet - - # Dialyzer: Functions return specific string types - @dialyzer {:nowarn_function, bordered_row: 3} - - @doc """ - Renders a horizontal line of the specified width. - - Uses the current CharacterSet's horizontal line character. - - ## Parameters - - - `width` - Width of the line in characters - - ## Examples - - iex> horizontal_line(5) - "─────" # Unicode mode - - iex> Application.put_env(:term_ui, :character_set, :ascii) - iex> horizontal_line(5) - "-----" - """ - @spec horizontal_line(non_neg_integer()) :: String.t() - def horizontal_line(width) when is_integer(width) and width >= 0 do - chars = CharacterSet.current_charset() - String.duplicate(chars.h_line, width) - end - - @doc """ - Renders a heavy horizontal line of the specified width. - - Uses the current CharacterSet's heavy horizontal line character. - - ## Parameters - - - `width` - Width of the line in characters - - ## Examples - - iex> horizontal_line_heavy(5) - "━━━━━" # Unicode mode - """ - @spec horizontal_line_heavy(non_neg_integer()) :: String.t() - def horizontal_line_heavy(width) when is_integer(width) and width >= 0 do - chars = CharacterSet.current_charset() - String.duplicate(chars.h_line_heavy, width) - end - - @doc """ - Renders a vertical line of the specified height. - - Returns a list of strings, one per line. - - ## Parameters - - - `height` - Height of the line in characters - - ## Examples - - iex> vertical_line(3) - ["│", "│", "│"] # Unicode mode - """ - @spec vertical_line(non_neg_integer()) :: [String.t()] - def vertical_line(height) when is_integer(height) and height >= 0 do - chars = CharacterSet.current_charset() - List.duplicate(chars.v_line, height) - end - - @doc """ - Renders the top border of a box. - - Format: `┌` + horizontal line + `┐` - - ## Parameters - - - `width` - Total width including corners (minimum 2) - - ## Examples - - iex> box_top(10) - "┌────────┐" # Unicode mode - - iex> Application.put_env(:term_ui, :character_set, :ascii) - iex> box_top(10) - "+--------+" - """ - @spec box_top(non_neg_integer()) :: String.t() - def box_top(width) when is_integer(width) and width >= 2 do - chars = CharacterSet.current_charset() - inner_width = max(0, width - 2) - chars.tl <> String.duplicate(chars.h_line, inner_width) <> chars.tr - end - - def box_top(width) when is_integer(width) and width >= 0 do - chars = CharacterSet.current_charset() - String.duplicate(chars.h_line, width) - end - - @doc """ - Renders the bottom border of a box. - - Format: `└` + horizontal line + `┘` - - ## Parameters - - - `width` - Total width including corners (minimum 2) - - ## Examples - - iex> box_bottom(10) - "└────────┘" # Unicode mode - - iex> Application.put_env(:term_ui, :character_set, :ascii) - iex> box_bottom(10) - "+--------+" - """ - @spec box_bottom(non_neg_integer()) :: String.t() - def box_bottom(width) when is_integer(width) and width >= 2 do - chars = CharacterSet.current_charset() - inner_width = max(0, width - 2) - chars.bl <> String.duplicate(chars.h_line, inner_width) <> chars.br - end - - def box_bottom(width) when is_integer(width) and width >= 0 do - chars = CharacterSet.current_charset() - String.duplicate(chars.h_line, width) - end - - @doc """ - Renders the top border of a box with rounded corners. - - Format: `╭` + horizontal line + `╮` - - ## Parameters - - - `width` - Total width including corners (minimum 2) - - ## Examples - - iex> box_top_round(10) - "╭────────╮" # Unicode mode - """ - @spec box_top_round(non_neg_integer()) :: String.t() - def box_top_round(width) when is_integer(width) and width >= 2 do - chars = CharacterSet.current_charset() - inner_width = max(0, width - 2) - chars.tl_round <> String.duplicate(chars.h_line, inner_width) <> chars.tr_round - end - - def box_top_round(width) when is_integer(width) and width >= 0 do - chars = CharacterSet.current_charset() - String.duplicate(chars.h_line, width) - end - - @doc """ - Renders the bottom border of a box with rounded corners. - - Format: `╰` + horizontal line + `╯` - - ## Parameters - - - `width` - Total width including corners (minimum 2) - - ## Examples - - iex> box_bottom_round(10) - "╰────────╯" # Unicode mode - """ - @spec box_bottom_round(non_neg_integer()) :: String.t() - def box_bottom_round(width) when is_integer(width) and width >= 2 do - chars = CharacterSet.current_charset() - inner_width = max(0, width - 2) - chars.bl_round <> String.duplicate(chars.h_line, inner_width) <> chars.br_round - end - - def box_bottom_round(width) when is_integer(width) and width >= 0 do - chars = CharacterSet.current_charset() - String.duplicate(chars.h_line, width) - end - - @doc """ - Renders a left border character with optional content. - - Format: `│` + content - - ## Parameters - - - `content` - Optional content to append after the border (default: "") - - ## Examples - - iex> left_border() - "│" - - iex> left_border(" Hello") - "│ Hello" - """ - @spec left_border(String.t()) :: String.t() - def left_border(content \\ "") do - chars = CharacterSet.current_charset() - chars.v_line <> content - end - - @doc """ - Renders a right border character with optional content. - - Format: content + `│` - - ## Parameters - - - `content` - Optional content to prepend before the border (default: "") - - ## Examples - - iex> right_border() - "│" - - iex> right_border("Hello ") - "Hello │" - """ - @spec right_border(String.t()) :: String.t() - def right_border(content \\ "") do - chars = CharacterSet.current_charset() - content <> chars.v_line - end - - @doc """ - Renders a complete row with left and right borders. - - Format: `│` + padded content + `│` - - The content is padded to fill the inner width. - - ## Parameters - - - `content` - Content to display between borders - - `width` - Total width including borders (minimum 2) - - `opts` - Options: - - `:pad` - Padding character (default: " ") - - `:align` - `:left`, `:right`, or `:center` (default: `:left`) - - ## Examples - - iex> bordered_row("Hello", 12) - "│Hello │" - - iex> bordered_row("Hi", 10, align: :center) - "│ Hi │" - """ - @spec bordered_row(String.t(), non_neg_integer(), keyword()) :: String.t() - def bordered_row(content, width, opts \\ []) when is_integer(width) and width >= 2 do - chars = CharacterSet.current_charset() - pad_char = Keyword.get(opts, :pad, " ") - align = Keyword.get(opts, :align, :left) - - inner_width = max(0, width - 2) - content_len = String.length(content) - padding_needed = max(0, inner_width - content_len) - - padded_content = - case align do - :left -> - content <> String.duplicate(pad_char, padding_needed) - - :right -> - String.duplicate(pad_char, padding_needed) <> content - - :center -> - left_pad = div(padding_needed, 2) - right_pad = padding_needed - left_pad - String.duplicate(pad_char, left_pad) <> content <> String.duplicate(pad_char, right_pad) - end - - # Truncate if content is too long - padded_content = String.slice(padded_content, 0, inner_width) - - chars.v_line <> padded_content <> chars.v_line - end -end diff --git a/lib/term_ui/helpers/cursor_helper.ex b/lib/term_ui/helpers/cursor_helper.ex deleted file mode 100644 index cfc7261b..00000000 --- a/lib/term_ui/helpers/cursor_helper.ex +++ /dev/null @@ -1,269 +0,0 @@ -defmodule TermUI.Helpers.CursorHelper do - @moduledoc """ - Helper functions for cursor navigation within lists. - - This module provides convenience functions for managing cursor positions - in widgets with selectable items (menus, lists, tables, tree views, etc.). - - ## Usage - - import TermUI.Helpers.CursorHelper - - # Move cursor down with wrapping - new_cursor = move_down(cursor, 1, item_count, wrap: true) - - # Move cursor up with clamping - new_cursor = move_up(cursor, 1, item_count) - - # Clamp cursor to valid range - new_cursor = clamp_cursor(cursor, 0, item_count - 1) - - ## Common Patterns - - All functions work with 0-based cursor indices. The `max` parameter - is typically `length(items) - 1` for the last valid index. - """ - - @doc """ - Moves the cursor down (towards higher indices). - - ## Parameters - - - `cursor` - Current cursor position (0-based) - - `step` - Number of positions to move (default: 1) - - `max` - Maximum valid cursor position (inclusive) - - `opts` - Options: - - `:wrap` - If true, wraps from max to 0 (default: false) - - ## Examples - - iex> move_down(0, 1, 4) - 1 - - iex> move_down(4, 1, 4) # At max, clamped - 4 - - iex> move_down(4, 1, 4, wrap: true) # At max, wraps to 0 - 0 - - iex> move_down(2, 3, 4) # Move 3 positions, clamped to max - 4 - """ - @spec move_down(non_neg_integer(), non_neg_integer(), non_neg_integer(), keyword()) :: - non_neg_integer() - def move_down(cursor, step \\ 1, max, opts \\ []) - when is_integer(cursor) and is_integer(step) and is_integer(max) do - wrap = Keyword.get(opts, :wrap, false) - new_pos = cursor + step - - cond do - new_pos > max and wrap -> rem(new_pos, max + 1) - new_pos > max -> max - true -> new_pos - end - end - - @doc """ - Moves the cursor up (towards lower indices). - - ## Parameters - - - `cursor` - Current cursor position (0-based) - - `step` - Number of positions to move (default: 1) - - `max` - Maximum valid cursor position (used for wrapping) - - `opts` - Options: - - `:wrap` - If true, wraps from 0 to max (default: false) - - ## Examples - - iex> move_up(2, 1, 4) - 1 - - iex> move_up(0, 1, 4) # At 0, clamped - 0 - - iex> move_up(0, 1, 4, wrap: true) # At 0, wraps to max - 4 - - iex> move_up(1, 3, 4) # Move 3 positions, clamped to 0 - 0 - """ - @spec move_up(non_neg_integer(), non_neg_integer(), non_neg_integer(), keyword()) :: - non_neg_integer() - def move_up(cursor, step \\ 1, max, opts \\ []) - when is_integer(cursor) and is_integer(step) and is_integer(max) do - wrap = Keyword.get(opts, :wrap, false) - new_pos = cursor - step - - cond do - new_pos < 0 and wrap -> max + 1 + new_pos - new_pos < 0 -> 0 - true -> new_pos - end - end - - @doc """ - Clamps the cursor to valid bounds. - - Ensures cursor is within [min, max] range. - - ## Parameters - - - `cursor` - Current cursor position - - `min` - Minimum valid position (default: 0) - - `max` - Maximum valid position - - ## Examples - - iex> clamp_cursor(5, 0, 3) - 3 - - iex> clamp_cursor(-2, 0, 3) - 0 - - iex> clamp_cursor(2, 0, 3) - 2 - """ - @spec clamp_cursor(integer(), integer(), integer()) :: integer() - def clamp_cursor(cursor, min \\ 0, max) when is_integer(cursor) do - cursor - |> max(min) - |> min(max) - end - - @doc """ - Wraps cursor position within valid range. - - Unlike clamp, wrap treats the range as circular. - - ## Parameters - - - `cursor` - Current cursor position (can be negative or > max) - - `min` - Minimum valid position (default: 0) - - `max` - Maximum valid position - - ## Examples - - iex> wrap_cursor(5, 0, 3) # 5 wraps to 1 (5 mod 4 = 1) - 1 - - iex> wrap_cursor(-1, 0, 3) # -1 wraps to 3 - 3 - - iex> wrap_cursor(4, 0, 3) # 4 wraps to 0 - 0 - """ - @spec wrap_cursor(integer(), integer(), integer()) :: integer() - def wrap_cursor(cursor, min \\ 0, max) when is_integer(cursor) do - range = max - min + 1 - - if range <= 0 do - min - else - result = rem(cursor - min, range) - - if result < 0 do - min + range + result - else - min + result - end - end - end - - @doc """ - Finds the next valid cursor position, skipping invalid positions. - - Useful for skipping separators or disabled items in menus. - - ## Parameters - - - `cursor` - Current cursor position - - `direction` - `:up` or `:down` - - `max` - Maximum valid position - - `valid?` - Function that returns true if position is valid - - `opts` - Options: - - `:wrap` - If true, wraps at boundaries (default: false) - - `:max_attempts` - Maximum positions to try (default: max + 1) - - ## Examples - - # Skip disabled items (positions 1 and 2) - valid? = fn pos -> pos not in [1, 2] end - move_to_next_valid(0, :down, 4, valid?) - # => 3 (skips 1 and 2) - """ - @spec move_to_next_valid( - non_neg_integer(), - :up | :down, - non_neg_integer(), - (non_neg_integer() -> boolean()), - keyword() - ) :: non_neg_integer() | nil - def move_to_next_valid(cursor, direction, max, valid?, opts \\ []) do - wrap = Keyword.get(opts, :wrap, false) - max_attempts = Keyword.get(opts, :max_attempts, max + 1) - - move_fn = - case direction do - :down -> &move_down(&1, 1, max, wrap: wrap) - :up -> &move_up(&1, 1, max, wrap: wrap) - end - - find_next_valid(cursor, move_fn, valid?, max_attempts, cursor) - end - - defp find_next_valid(_cursor, _move_fn, _valid?, 0, _start), do: nil - - defp find_next_valid(cursor, move_fn, valid?, attempts, start) do - next = move_fn.(cursor) - - cond do - next == start and attempts < start + 1 -> nil - valid?.(next) -> next - true -> find_next_valid(next, move_fn, valid?, attempts - 1, start) - end - end - - @doc """ - Moves cursor to first valid position from the beginning. - - ## Parameters - - - `max` - Maximum valid position - - `valid?` - Function that returns true if position is valid - - ## Examples - - # Find first non-disabled item - valid? = fn pos -> pos not in [0, 1] end - first_valid(4, valid?) - # => 2 - """ - @spec first_valid(non_neg_integer(), (non_neg_integer() -> boolean())) :: - non_neg_integer() | nil - def first_valid(max, valid?) do - Enum.find(0..max, valid?) - end - - @doc """ - Moves cursor to last valid position from the end. - - ## Parameters - - - `max` - Maximum valid position - - `valid?` - Function that returns true if position is valid - - ## Examples - - # Find last non-disabled item - valid? = fn pos -> pos not in [3, 4] end - last_valid(4, valid?) - # => 2 - """ - @spec last_valid(non_neg_integer(), (non_neg_integer() -> boolean())) :: - non_neg_integer() | nil - def last_valid(max, valid?) do - max..0//-1 - |> Enum.find(valid?) - end -end diff --git a/lib/term_ui/input.ex b/lib/term_ui/input.ex deleted file mode 100644 index 8220ab2e..00000000 --- a/lib/term_ui/input.ex +++ /dev/null @@ -1,227 +0,0 @@ -defmodule TermUI.Input do - @moduledoc """ - Behaviour defining the input abstraction for TermUI. - - This module establishes a unified interface for reading terminal input, - regardless of whether the application is running with the Raw backend - or the TTY backend. - - ## Input Modes - - TermUI supports two input approaches: - - ### Character Mode (Default) - - Both Raw and TTY backends use character-by-character input via `IO.getn/2`. - This means keyboard navigation (arrow keys, Tab, Enter, function keys) works - **identically** in both modes. The shell only provides line editing for - `IO.gets/1` calls—single character reads are immediate in both modes. - - This is the primary input mode used by most widgets: - - `Menu`, `PickList`, `Table` - navigation with arrows, selection with Enter - - `Dialog`, `AlertDialog` - button navigation with Tab - - `Tabs`, `TreeView` - keyboard navigation - - ### Line Mode (TextInput.Line Only) - - The `TermUI.Input.LineReader` module provides line-based input using - `IO.gets/1`. This is **only** used by the `TextInput.Line` widget, which - benefits from shell line editing features: - - Backspace, delete, cursor movement - - Command history (if shell supports it) - - Input submitted on Enter - - Most applications should use character mode. Line mode is a specialized - feature for free-form text entry where shell editing is desirable. - - ## Implementing the Behaviour - - Input handlers must implement three callbacks: - - - `poll/2` - Read input with optional timeout - - `mode/1` - Return the input mode (`:raw` or `:tty`) - - `stop/1` - Cleanup and release resources - - ## Example Implementation - - defmodule MyApp.CustomInput do - @behaviour TermUI.Input - - @impl true - def poll(state, timeout) do - # Read input, return {:ok, event}, :timeout, or :eof - {:ok, event, state} - end - - @impl true - def mode(_state), do: :custom - end - - ## Built-in Handlers - - - `TermUI.Input.Raw` - Wraps `TermUI.Terminal.InputReader` for raw mode - - `TermUI.Input.TTY` - Uses `IO.getn/2` for TTY mode character input - - Use `TermUI.Input.Selector` to automatically choose the appropriate handler - based on the active backend. - """ - - alias TermUI.Event - - # Type Definitions - - @typedoc """ - Key event returned from input polling. - - This is the standard key event type from `TermUI.Event.Key`. - """ - @type key_event :: Event.Key.t() - - @typedoc """ - Result of an input polling operation. - - - `{:ok, key_event()}` - A key event was received - - `{:ok, Event.Mouse.t()}` - A mouse event was received - - `{:ok, Event.Paste.t()}` - A paste event was received (bracketed paste) - - `:timeout` - No input within the timeout period - - `:eof` - End of input stream - """ - @type input_result :: - {:ok, key_event() | Event.Mouse.t() | Event.Paste.t()} - | :timeout - | :eof - - @typedoc """ - Result of poll/2 including updated state. - """ - @type poll_result :: {input_result(), state()} - - @typedoc """ - Opaque state maintained by the input handler. - - Each handler implementation defines its own state structure. - """ - @type state :: term() - - @typedoc """ - Input mode indicator. - - - `:raw` - Raw mode with full terminal control - - `:tty` - TTY mode with shell present - """ - @type mode :: :raw | :tty - - # Callbacks - - @doc """ - Poll for input with an optional timeout. - - Reads input from the terminal and returns a parsed event. The timeout - specifies the maximum time to wait for input in milliseconds. - - ## Parameters - - - `state` - Handler-specific state (escape sequence buffer, etc.) - - `timeout` - Maximum wait time in milliseconds (0 for non-blocking) - - ## Returns - - - `{{:ok, event}, new_state}` - An event was received - - `{:timeout, new_state}` - No input within timeout - - `{:eof, new_state}` - End of input stream - - ## Timeout Semantics - - The timeout is best-effort: - - - **Raw mode**: Supports non-blocking reads; timeout is honored accurately - - **TTY mode**: Uses blocking `IO.getn/2`; timeout may not be honored - - Components should not rely on precise timeout behavior. Use `:timeout` - results for periodic updates, but design for the blocking case. - - ## Escape Sequences - - Handlers are responsible for buffering and parsing escape sequences. - Multi-byte sequences (arrow keys, function keys) should be assembled - before returning an event. Incomplete sequences should be buffered - in the state and completed on subsequent calls. - - ## Examples - - # Non-blocking poll - {result, new_state} = MyInput.poll(state, 0) - - # Wait up to 100ms - {result, new_state} = MyInput.poll(state, 100) - - # Process result - case result do - {:ok, %Event.Key{key: :enter}} -> handle_enter() - {:ok, %Event.Key{key: :up}} -> handle_up() - :timeout -> continue_animation() - :eof -> shutdown() - end - """ - @callback poll(state(), timeout :: non_neg_integer()) :: poll_result() - - @doc """ - Return the input mode for this handler. - - Returns `:raw` or `:tty` to indicate which mode the handler operates in. - This allows components to adapt their behavior if needed, though most - widgets work identically in both modes. - - ## Use Cases - - Most widgets do not need to check the mode—input events are normalized - across both handlers. However, some specialized components might use this: - - - Displaying mode indicator in status bar - - Adjusting behavior for mode-specific features - - Debugging and logging - - ## Examples - - mode = MyInput.mode(state) - # => :raw or :tty - """ - @callback mode(state()) :: mode() - - @doc """ - Stop the input handler and release any resources. - - This callback is called during runtime shutdown to allow the handler - to perform cleanup operations such as: - - - Restoring terminal IO options - - Stopping any background processes - - Closing file descriptors or ports - - The function should be idempotent—calling it multiple times should - have the same effect as calling it once. - - ## Parameters - - - `state` - Handler-specific state - - ## Returns - - - `:ok` - Cleanup completed successfully - - ## Examples - - :ok = MyInput.stop(state) - - ## Implementation Notes - - - **Raw handler**: Typically a no-op since InputReader is managed separately - - **TTY handler**: Should restore IO options (echo, binary mode) - - Custom handlers: Implement any necessary cleanup - - This callback is always called during runtime shutdown, even if - the handler was never successfully started or has already been - stopped due to EOF. - """ - @callback stop(state()) :: :ok -end diff --git a/lib/term_ui/input/line_reader.ex b/lib/term_ui/input/line_reader.ex deleted file mode 100644 index 5ef0be66..00000000 --- a/lib/term_ui/input/line_reader.ex +++ /dev/null @@ -1,265 +0,0 @@ -defmodule TermUI.Input.LineReader do - @moduledoc """ - Line-based input module for the `TextInput.Line` widget. - - This module provides line-oriented input using `IO.gets/1`, which enables - shell line editing features. It is specifically designed for the `TextInput.Line` - widget, where users enter free-form text and submit with Enter. - - > #### Not a Behaviour Implementation {: .info} - > - > Unlike `TermUI.Input.Raw` and `TermUI.Input.TTY`, this module does **not** - > implement the `TermUI.Input` behaviour. It is a standalone utility module - > for line-based input, not character-by-character polling. Use this module - > directly when you need line input with shell editing; use the behaviour - > implementations for immediate character input. - - ## When to Use LineReader - - Use `LineReader` when you need: - - **Free-form text entry**: User types arbitrary text - - **Shell line editing**: Backspace, cursor movement, etc. - - **Submit on Enter**: Input is complete when user presses Enter - - Most TermUI widgets use character-by-character input (`Input.Raw` or `Input.TTY`) - for immediate key response. Use `LineReader` only for text fields that benefit - from shell editing. - - ## Security Considerations - - This module provides raw line input and does not perform sanitization: - - - **Input length**: No length limits are enforced by this module. The shell - and terminal typically impose their own limits (commonly 4KB-128KB depending - on configuration). If your application has specific length requirements, - validate after reading. For concurrent usage, consider that each pending - read could hold up to the shell's maximum line length in memory. - - - **Input sanitization**: Input is returned as-is from `IO.gets/1`. The - application is responsible for any sanitization (escaping, filtering - special characters, etc.) appropriate for its use case. - - - **No injection protection**: This module does not filter or escape input. - If the input will be used in shell commands, SQL queries, or other - security-sensitive contexts, proper escaping must be applied by the caller. - - - **Blocking I/O**: `read_line/1` blocks indefinitely until input is received. - This could be exploited in a DoS scenario if many concurrent reads are - started. For server applications, consider using timeouts at a higher level. - - ## Shell Line Editing Features - - When using `LineReader`, the shell provides (depending on terminal): - - **Backspace**: Delete character before cursor - - **Delete**: Delete character at cursor - - **Left/Right arrows**: Move cursor within line - - **Home/End**: Jump to start/end of line - - **Ctrl+A/E**: Jump to start/end (Emacs-style) - - **Ctrl+K**: Kill to end of line - - **History**: Up/Down for command history (if shell supports) - - These features are provided by the shell, not by TermUI. The exact features - available depend on the user's shell configuration. - - ## Usage - - # Simple line input - case LineReader.read_line("Enter name: ") do - {:ok, name} -> process_name(name) - :eof -> handle_eof() - end - - # With validation - validator = fn input -> - if String.length(input) >= 3 do - :ok - else - {:error, "Name must be at least 3 characters"} - end - end - - case LineReader.read_line("Enter name: ", validator) do - {:ok, name} -> process_name(name) - {:error, reason} -> show_error(reason) - :eof -> handle_eof() - end - - ## Comparison with Character Input - - | Feature | LineReader | Input.Raw/TTY | - |---------|------------|---------------| - | Input style | Line-based | Character-by-character | - | Submit | Enter key | Immediate | - | Editing | Shell-provided | Application-handled | - | Use case | TextInput.Line | Menu, PickList, etc. | - - ## Important Notes - - - **Blocking**: `read_line/1` blocks until the user presses Enter or EOF - - **No timeout**: Cannot interrupt or timeout the read - - **Raw mode**: If running in raw mode, line editing may not work as expected - - **TTY only**: Best used with the TTY backend for full shell editing support - - **Error handling**: IO errors from `IO.gets/1` are converted to `:eof` for - simplified error handling. Most callers don't need to distinguish between - "stream ended" and "read error" scenarios. - - ## TextInput.Line Widget - - This module is the input backend for `TextInput.Line`. The widget: - 1. Displays a prompt and current value - 2. Calls `LineReader.read_line/1` to get user input - 3. Validates and processes the result - - For character-by-character text input with custom editing, use `TextInput` - (without `.Line`) which uses `Input.Raw` or `Input.TTY`. - """ - - @typedoc """ - Result of a line read operation. - - - `{:ok, line}` - Successfully read a line (trimmed of trailing newline) - - `:eof` - End of input stream - """ - @type read_result :: {:ok, String.t()} | :eof - - @typedoc """ - Result of a validated line read operation. - - - `{:ok, value}` - Line was read and validation passed - - `{:error, reason}` - Line was read but validation failed - - `:eof` - End of input stream - """ - @type validated_result :: {:ok, term()} | {:error, term()} | :eof - - @typedoc """ - Validator function for input validation. - - Should accept the trimmed input string and return: - - `:ok` - Input is valid (original string is returned) - - `{:ok, transformed}` - Input is valid, return transformed value - - `{:error, reason}` - Input is invalid with given reason - """ - @type validator :: (String.t() -> :ok | {:ok, term()} | {:error, term()}) - - @doc """ - Reads a line of input with an optional prompt. - - Displays the prompt (if provided) and reads a complete line of input from - stdin. The trailing newline is automatically trimmed from the result. - - ## Parameters - - - `prompt` - Optional prompt string to display (default: `""`) - - ## Returns - - - `{:ok, line}` - The line that was entered (without trailing newline) - - `:eof` - End of input stream - - ## Examples - - # With prompt - {:ok, name} = LineReader.read_line("Enter your name: ") - - # Without prompt - {:ok, input} = LineReader.read_line() - - # Handling EOF - case LineReader.read_line("Input: ") do - {:ok, line} -> process(line) - :eof -> shutdown() - end - - ## Notes - - - This function blocks until the user presses Enter or EOF is received - - Empty input (just Enter) returns `{:ok, ""}` - - The prompt is written to stdout before reading - """ - @spec read_line(String.t()) :: read_result() - def read_line(prompt \\ "") do - case IO.gets(prompt) do - :eof -> - :eof - - {:error, _reason} -> - :eof - - line when is_binary(line) -> - {:ok, String.trim_trailing(line, "\n")} - end - end - - @doc """ - Reads a line of input with validation. - - Displays the prompt, reads a line, and validates it using the provided - validator function. The validator receives the trimmed input and should - return validation status. - - ## Parameters - - - `prompt` - Prompt string to display - - `validator` - Function to validate the input - - ## Validator Function - - The validator should accept a string and return one of: - - `:ok` - Input is valid, return original string - - `{:ok, transformed}` - Input is valid, return transformed value - - `{:error, reason}` - Input is invalid - - ## Returns - - - `{:ok, value}` - Input was valid (original or transformed value) - - `{:error, reason}` - Input was invalid - - `:eof` - End of input stream - - ## Examples - - # Simple validation - validator = fn input -> - if String.length(input) > 0, do: :ok, else: {:error, "Cannot be empty"} - end - {:ok, name} = LineReader.read_line("Name: ", validator) - - # Transforming validation (parse to integer) - int_validator = fn input -> - case Integer.parse(input) do - {num, ""} -> {:ok, num} - _ -> {:error, "Must be a valid integer"} - end - end - {:ok, age} = LineReader.read_line("Age: ", int_validator) - - # Regex validation - email_validator = fn input -> - if String.match?(input, ~r/^[^@]+@[^@]+\\.[^@]+$/) do - :ok - else - {:error, "Invalid email format"} - end - end - {:ok, email} = LineReader.read_line("Email: ", email_validator) - - ## Notes - - - Validation is only performed if a line was successfully read - - EOF bypasses validation and returns `:eof` directly - - The validator receives the trimmed input (no trailing newline) - """ - @spec read_line(String.t(), validator()) :: validated_result() - def read_line(prompt, validator) when is_function(validator, 1) do - case read_line(prompt) do - {:ok, line} -> - case validator.(line) do - :ok -> {:ok, line} - {:ok, transformed} -> {:ok, transformed} - {:error, reason} -> {:error, reason} - end - - :eof -> - :eof - end - end -end diff --git a/lib/term_ui/input/raw.ex b/lib/term_ui/input/raw.ex deleted file mode 100644 index aa2de349..00000000 --- a/lib/term_ui/input/raw.ex +++ /dev/null @@ -1,377 +0,0 @@ -defmodule TermUI.Input.Raw do - @moduledoc """ - Raw mode input handler implementing the `TermUI.Input` behaviour. - - This module provides synchronous input polling with timeout support for - applications running with the Raw backend. It reads single characters - from stdin and parses escape sequences into `TermUI.Event` structs. - - ## Features - - - **Non-blocking input**: Supports timeout-based polling (including 0ms for - non-blocking checks) - - **Escape sequence parsing**: Handles arrow keys, function keys, mouse events, - and other terminal escape sequences - - **Buffer management**: Maintains partial escape sequences between poll calls - - **Security**: Buffer and queue size limits prevent memory exhaustion - - ## Usage - - # Create initial state - state = TermUI.Input.Raw.new() - - # Poll for input with 100ms timeout - case TermUI.Input.Raw.poll(state, 100) do - {{:ok, event}, new_state} -> handle_event(event, new_state) - {:timeout, new_state} -> handle_idle(new_state) - {:eof, new_state} -> handle_shutdown(new_state) - end - - ## How It Works - - The module spawns a Task to read from stdin using `:io.get_chars/2` (Erlang's - IO module directly). This is critical for compatibility with raw mode activated - via `:shell.start_interactive({:noshell, :raw})`, which redirects standard - input. Elixir's `IO.getn/2` wrapper cannot access the redirected input, but - `:io.get_chars/2` works correctly. - - Since `:io.get_chars/2` blocks until input is available, using a Task allows - us to implement timeout semantics via `Task.yield/2`. - - When an escape sequence spans multiple reads (e.g., arrow keys send multiple - bytes), the partial sequence is buffered and completed on subsequent polls. - - ## Escape Sequence Timeout - - When a partial escape sequence is detected (e.g., lone ESC), the handler waits - up to 50ms for completion. This matches standard terminal emulator behavior - and distinguishes ESC key presses from escape sequences. The 50ms timeout is - the same value used by `TermUI.Terminal.InputReader`. - - ## Comparison with InputReader - - Unlike `TermUI.Terminal.InputReader` which is a GenServer that asynchronously - sends events to a target process, this module provides synchronous polling - suitable for use with the `TermUI.Input` behaviour interface. This module - uses direct `:io.get_chars/2` calls wrapped in Tasks for timeout support, rather - than delegating to InputReader, because InputReader's async message-based - design is incompatible with the synchronous polling contract. - - Both modules use the same underlying approach (`:io.get_chars/2`) for reading - from stdin, ensuring compatibility with raw mode's redirected input. - """ - - @behaviour TermUI.Input - - require Logger - - alias TermUI.Backend.InputBuffer - alias TermUI.Event - alias TermUI.Terminal.EscapeParser - - # Dialyzer: Functions return specific struct types - # Dialyzer: emit_partial_escape/2 calls Event.key with string args for partial escape chars - # Key.new/2 spec says atom() but the function works with strings too - @dialyzer {:nowarn_function, - new: 0, - emit_partial_escape: 2, - read_char: 0, - poll: 2, - handle_escape_timeout: 2, - do_read_with_timeout: 2} - - # Escape sequence bytes - @esc 0x1B - @left_bracket ?[ - @letter_o ?O - - # Timeout for escape sequence completion (ms). - # This matches terminal emulator behavior for distinguishing ESC key - # presses from escape sequences. The same value is used by InputReader. - @escape_timeout 50 - - # Note: InputBuffer.apply_limit/2 uses its own limit (1KB) and truncates - # to 256 bytes when exceeded. This provides security against memory - # exhaustion from malformed escape sequences. We don't need a separate - # buffer size constant here since InputBuffer handles the limiting. - - # Maximum event queue size to prevent memory exhaustion. - @max_queue_size 1000 - - defstruct buffer: <<>>, - event_queue: [] - - @typedoc """ - State for the Raw input handler. - - - `:buffer` - Binary buffer for partial escape sequences - - `:event_queue` - Queue of parsed events waiting to be returned - """ - @type t :: %__MODULE__{ - buffer: binary(), - event_queue: [Event.t()] - } - - @doc """ - Creates a new Raw input handler state. - - ## Examples - - state = TermUI.Input.Raw.new() - """ - @spec new() :: t() - def new do - %__MODULE__{ - buffer: <<>>, - event_queue: [] - } - end - - @doc """ - Polls for input with the specified timeout. - - Reads input from stdin and parses it into events. The timeout specifies - the maximum time to wait for input in milliseconds. Use 0 for non-blocking - polls. - - ## Parameters - - - `state` - Current handler state - - `timeout` - Maximum wait time in milliseconds - - ## Returns - - - `{{:ok, event}, new_state}` - An event was received - - `{:timeout, new_state}` - No input within timeout - - `{:eof, new_state}` - End of input stream - - ## Examples - - # Non-blocking check - {result, state} = Raw.poll(state, 0) - - # Wait up to 100ms - {result, state} = Raw.poll(state, 100) - """ - @impl TermUI.Input - @spec poll(t(), non_neg_integer()) :: TermUI.Input.poll_result() - def poll(%__MODULE__{} = state, timeout) when is_integer(timeout) and timeout >= 0 do - # First, check if we have queued events from a previous parse - case state.event_queue do - [event | rest] -> - {{:ok, event}, %{state | event_queue: rest}} - - [] -> - # Try to get an event from the buffer - case try_parse_buffer(state) do - {:ok, event, new_state} -> - {{:ok, event}, new_state} - - :need_more -> - # Need to read more input - read_with_timeout(state, timeout) - end - end - end - - @doc """ - Returns the input mode for this handler. - - Always returns `:raw` for the Raw input handler. - - ## Examples - - mode = Raw.mode(state) - # => :raw - """ - @impl TermUI.Input - @spec mode(t()) :: :raw - def mode(%__MODULE__{}), do: :raw - - @doc """ - Stops the Raw input handler. - - For the Raw handler, this is a no-op since the InputReader GenServer - is managed separately by the Runtime. This function exists for - compatibility with the `TermUI.Input` behaviour. - - ## Examples - - :ok = Raw.stop(state) - """ - @impl TermUI.Input - @spec stop(t()) :: :ok - def stop(%__MODULE__{}), do: :ok - - # Private Functions - - # Try to parse a complete event from the buffer - @spec try_parse_buffer(t()) :: {:ok, Event.t(), t()} | :need_more - defp try_parse_buffer(%__MODULE__{buffer: <<>>}), do: :need_more - - defp try_parse_buffer(%__MODULE__{buffer: buffer} = state) do - case EscapeParser.parse(buffer) do - {[event | rest_events], remaining} -> - # Got at least one event - # Queue any additional events for subsequent polls (with size limit) - queued_events = limit_queue(rest_events) - new_state = %{state | buffer: remaining, event_queue: queued_events} - {:ok, event, new_state} - - {[], _remaining} -> - # No complete events yet, need more input - :need_more - end - end - - # Limit queue size to prevent memory exhaustion - @spec limit_queue([Event.t()]) :: [Event.t()] - defp limit_queue(events) when length(events) <= @max_queue_size, do: events - - defp limit_queue(events) do - Logger.warning( - "Input.Raw: Event queue overflow, dropping #{length(events) - @max_queue_size} events" - ) - - Enum.take(events, @max_queue_size) - end - - # Read input with timeout using a Task - @spec read_with_timeout(t(), non_neg_integer()) :: TermUI.Input.poll_result() - defp read_with_timeout(%__MODULE__{} = state, timeout) do - # Check if we have a partial escape sequence that needs timeout handling - if EscapeParser.partial_sequence?(state.buffer) and timeout > @escape_timeout do - # Wait a short time for escape sequence completion - handle_escape_timeout(state, timeout) - else - # Normal read with full timeout - do_read_with_timeout(state, timeout) - end - end - - # Handle the case where we have a partial escape sequence - @spec handle_escape_timeout(t(), non_neg_integer()) :: TermUI.Input.poll_result() - defp handle_escape_timeout(%__MODULE__{} = state, timeout) do - # First try to complete the escape sequence with a short timeout - case do_read_with_timeout(state, @escape_timeout) do - {:timeout, state_after_short} -> - # Escape sequence didn't complete, emit what we have - emit_partial_escape(state_after_short, timeout - @escape_timeout) - - # Success or EOF - return as-is - result -> - result - end - end - - # Emit partial escape sequence as individual key events - @spec emit_partial_escape(t(), non_neg_integer()) :: TermUI.Input.poll_result() - defp emit_partial_escape(%__MODULE__{buffer: buffer} = state, remaining_timeout) do - events = - cond do - # Lone ESC - buffer == <<@esc>> -> - [Event.key(:escape)] - - # ESC[ without terminator - buffer == <<@esc, @left_bracket>> -> - [Event.key(:escape), Event.key("[", char: "[")] - - # ESC O without terminator - buffer == <<@esc, @letter_o>> -> - [Event.key(:escape), Event.key("O", char: "O")] - - # Other partial sequences starting with ESC - String.starts_with?(buffer, <<@esc>>) -> - <<@esc, rest::binary>> = buffer - {rest_events, _} = EscapeParser.parse(rest) - [Event.key(:escape) | rest_events] - - true -> - [] - end - - case events do - [event | rest] -> - # Return first event, queue remaining events, clear buffer - queued_events = limit_queue(rest) - {{:ok, event}, %{state | buffer: <<>>, event_queue: queued_events}} - - [] -> - # No events to emit, continue waiting with remaining timeout - if remaining_timeout > 0 do - do_read_with_timeout(%{state | buffer: <<>>}, remaining_timeout) - else - {:timeout, %{state | buffer: <<>>}} - end - end - end - - # Perform the actual read with timeout - @spec do_read_with_timeout(t(), non_neg_integer()) :: TermUI.Input.poll_result() - defp do_read_with_timeout(%__MODULE__{} = state, timeout) do - # Spawn a task to read input - task = Task.async(fn -> read_char() end) - - # Use explicit Task.yield and Task.shutdown for clarity - case Task.yield(task, timeout) do - {:ok, {:ok, data}} -> - # Got input, add to buffer with size limit and try to parse - new_buffer = state.buffer <> data - # InputBuffer.apply_limit uses rate-limited logging via the :source option - {limited_buffer, _truncated} = InputBuffer.apply_limit(new_buffer, source: :input_raw) - - new_state = %{state | buffer: limited_buffer} - - case try_parse_buffer(new_state) do - {:ok, event, final_state} -> - {{:ok, event}, final_state} - - :need_more -> - # Still need more, but we've used our timeout - {:timeout, new_state} - end - - {:ok, :eof} -> - {:eof, state} - - {:ok, {:error, reason}} -> - # Log IO errors at debug level for troubleshooting - Logger.debug("Input.Raw: IO read error: #{inspect(reason)}") - {:eof, state} - - nil -> - # Timeout - no input received, shut down the task - Task.shutdown(task) - {:timeout, state} - end - end - - # Read a single character from stdin - # Uses :io.get_chars/2 (Erlang's IO module) for compatibility with - # :shell.start_interactive({:noshell, :raw}) which redirects standard input. - # Elixir's IO.getn/2 cannot access the redirected input. - @spec read_char() :: {:ok, binary()} | :eof | {:error, term()} - defp read_char do - case :io.get_chars(~c"", 1) do - :eof -> - :eof - - chars when is_list(chars) -> - # Convert charlist to binary - case :unicode.characters_to_binary(chars) do - binary when is_binary(binary) -> - {:ok, binary} - - :error -> - {:error, :invalid_unicode} - end - - {:error, reason} -> - {:error, reason} - - other -> - {:error, {:unexpected_io_return, other}} - end - end -end diff --git a/lib/term_ui/input/selector.ex b/lib/term_ui/input/selector.ex deleted file mode 100644 index 89b30af1..00000000 --- a/lib/term_ui/input/selector.ex +++ /dev/null @@ -1,181 +0,0 @@ -defmodule TermUI.Input.Selector do - @moduledoc """ - Selects the appropriate input handler based on the active backend mode. - - This module bridges the gap between backend selection and input handling, - providing a way to choose the correct input handler for the current - terminal mode. - - ## Relationship with Backend.Selector - - The `TermUI.Backend.Selector` determines which terminal backend to use - (Raw or TTY). This module, `TermUI.Input.Selector`, then selects the - corresponding input handler: - - | Backend Mode | Backend Module | Input Handler | - |--------------|----------------|---------------| - | `:raw` | `TermUI.Backend.Raw` | `TermUI.Input.Raw` | - | `:tty` | `TermUI.Backend.TTY` | `TermUI.Input.TTY` | - - ## Usage - - There are two ways to select an input handler: - - ### Explicit Selection - - When you know which mode you want, use `select/1`: - - # Get the Raw input handler - handler = TermUI.Input.Selector.select(:raw) - # => TermUI.Input.Raw - - # Get the TTY input handler - handler = TermUI.Input.Selector.select(:tty) - # => TermUI.Input.TTY - - ### Auto-Detection - - When you want to match the current backend, use `select/0`: - - # Automatically select based on current backend - handler = TermUI.Input.Selector.select() - # => TermUI.Input.Raw or TermUI.Input.TTY - - ## State-Based Selection - - For runtime code that already has a `Backend.State` struct, you can - extract the mode and pass it directly: - - backend_state = %TermUI.Backend.State{backend_mode: :tty, ...} - handler = TermUI.Input.Selector.select(backend_state.backend_mode) - - ## Input Handler Contract - - Both `TermUI.Input.Raw` and `TermUI.Input.TTY` implement the `TermUI.Input` - behaviour, providing a consistent interface: - - - `new/0` - Create initial handler state - - `poll/2` - Poll for input with timeout - - `mode/1` - Return the handler's mode (`:raw` or `:tty`) - - ## Example Integration - - # Typical usage in runtime initialization - case TermUI.Backend.Selector.select() do - {:raw, backend_state} -> - input_handler = TermUI.Input.Selector.select(:raw) - input_state = input_handler.new() - # ... - - {:tty, capabilities} -> - input_handler = TermUI.Input.Selector.select(:tty) - input_state = input_handler.new() - # ... - end - - ## Note on LineReader - - `TermUI.Input.LineReader` is **not** included in the selector. LineReader - is a specialized module for line-based input (used by `TextInput.Line`) - and does not implement the `TermUI.Input` behaviour. Use LineReader - directly when you need line-based input with shell editing. - """ - - @typedoc """ - Valid input mode atoms. - - - `:raw` - Select `TermUI.Input.Raw` for raw mode input - - `:tty` - Select `TermUI.Input.TTY` for TTY mode input - """ - @type mode :: :raw | :tty - - @typedoc """ - Input handler module that implements the `TermUI.Input` behaviour. - """ - @type handler :: module() - - alias TermUI.Backend.Selector - alias TermUI.Input.Raw - alias TermUI.Input.TTY - - # Dialyzer: Functions return specific module types - @dialyzer {:nowarn_function, select: 0, select: 1} - - @doc """ - Selects the appropriate input handler based on the current backend mode. - - This function auto-detects the current backend mode by attempting to - determine whether raw mode is active. If detection cannot determine - the mode, it defaults to TTY mode as the safer fallback. - - ## Returns - - - `TermUI.Input.Raw` if raw mode is active - - `TermUI.Input.TTY` if TTY mode is active or mode cannot be determined - - ## Examples - - handler = TermUI.Input.Selector.select() - state = handler.new() - {result, state} = handler.poll(state, 100) - - ## Implementation Note - - This function uses `TermUI.Backend.Selector.select/0` to determine the - current mode. This means it will attempt raw mode detection each time - it's called. For performance, prefer using `select/1` with an explicit - mode when the mode is already known from backend initialization. - """ - @spec select() :: handler() - def select do - case Selector.select() do - {:raw, _state} -> Raw - {:tty, _capabilities} -> TTY - end - end - - @doc """ - Selects the input handler for the specified mode. - - This function provides explicit selection when the mode is already known, - avoiding the overhead of backend detection. - - ## Arguments - - - `mode` - The input mode: `:raw` or `:tty` - - ## Returns - - - `TermUI.Input.Raw` for `:raw` mode - - `TermUI.Input.TTY` for `:tty` mode - - ## Raises - - - `ArgumentError` if an invalid mode is provided - - ## Examples - - # Select Raw input handler - handler = TermUI.Input.Selector.select(:raw) - # => TermUI.Input.Raw - - # Select TTY input handler - handler = TermUI.Input.Selector.select(:tty) - # => TermUI.Input.TTY - - # Using with Backend.State - backend_state = %TermUI.Backend.State{backend_mode: :tty, ...} - handler = TermUI.Input.Selector.select(backend_state.backend_mode) - - # Invalid mode raises - TermUI.Input.Selector.select(:invalid) - # ** (ArgumentError) invalid input mode: :invalid, expected :raw or :tty - """ - @spec select(mode()) :: handler() - def select(:raw), do: Raw - def select(:tty), do: TTY - - def select(mode) do - raise ArgumentError, "invalid input mode: #{inspect(mode)}, expected :raw or :tty" - end -end diff --git a/lib/term_ui/input/tty.ex b/lib/term_ui/input/tty.ex deleted file mode 100644 index d016230c..00000000 --- a/lib/term_ui/input/tty.ex +++ /dev/null @@ -1,455 +0,0 @@ -defmodule TermUI.Input.TTY do - @moduledoc """ - TTY mode input handler implementing the `TermUI.Input` behaviour. - - This module provides character-by-character input using `:io.get_chars/2` - for IEx compatibility. The key to IEx compatibility is using Erlang's `:io` - module directly instead of Elixir's `IO` module wrapper. - - ## Features - - - **IEx Compatible**: Uses `:io.get_chars/2` to bypass IEx's input interception - - **Character-by-character input**: Single character reads work immediately - - **Full keyboard support**: Arrow keys, Tab, Enter, function keys work normally - - **Escape sequence parsing**: Handles arrow keys, function keys, mouse events, - and other terminal escape sequences - - **Buffer management**: Maintains partial escape sequences between poll calls - - **Security**: Buffer and queue size limits prevent memory exhaustion - - ## IEx Compatibility - - The key to IEx compatibility is using `:io.get_chars/2` (Erlang) instead of - `IO.getn/2` (Elixir). While both ultimately use the same IO server, the direct - Erlang call behaves differently when running inside IEx, allowing TUI applications - to receive keyboard input instead of having it stolen by IEx. - - This approach was verified in the `snake_test` project where TUI applications - run correctly inside IEx using this method. - - ## How Arrow Keys and Special Keys Work - - A common misconception is that TTY mode requires Enter to submit input. This is - only true for `IO.gets/1` (line-based input). Single character reads via - `:io.get_chars/2` return immediately, so: - - - **Arrow keys**: Work normally (↑↓←→) - - **Tab**: Works for field/button navigation - - **Enter**: Detected immediately for selection - - **Function keys**: F1-F12 work normally - - **Ctrl combinations**: Ctrl+C, Ctrl+Z, etc. work - - This means most TUI widgets work **identically** in both Raw and TTY modes. - - ## Usage - - # Create initial state - state = TermUI.Input.TTY.new() - - # Poll for input (timeout is noted but not honored - blocking I/O) - case TermUI.Input.TTY.poll(state, 100) do - {{:ok, event}, new_state} -> handle_event(event, new_state) - {:eof, new_state} -> handle_shutdown(new_state) - end - - ## Timeout Semantics - - **Important**: The timeout parameter is accepted for API compatibility but - is **not honored** in TTY mode. `:io.get_chars/2` is blocking and will wait - indefinitely for input. Design your application to handle this: - - - Don't rely on `:timeout` results for animations - - Consider using a separate process for time-based updates - - For timeout support, use the Raw backend instead - - ## Comparison with Raw Input Handler - - | Feature | TTY (`Input.TTY`) | Raw (`Input.Raw`) | - |---------|-------------------|-------------------| - | IEx Compatible | Yes | No | - | Timeout support | No (blocking) | Yes (Task-based) | - | Non-blocking poll | No | Yes | - | Escape sequences | Yes | Yes | - | Arrow/Tab/Enter | Yes | Yes | - | Mouse events | Yes | Yes | - - ## When to Use TTY Mode - - TTY mode is appropriate when: - - You want to run TUI applications inside IEx - - You don't need timeout-based polling - - You want simpler deployment (no raw mode setup) - - Your application can block waiting for input - - You're building simple interactive scripts - - For applications requiring animations, periodic updates, or non-blocking - input checks, use the Raw backend with `Input.Raw` instead. - - ## Escape Sequence Handling - - When an escape sequence spans multiple reads (e.g., arrow keys send multiple - bytes), the partial sequence is buffered and completed on subsequent polls. - - When a partial escape sequence is detected (e.g., lone ESC), the handler waits - up to 100ms for completion using a blocking read. This matches standard terminal - emulator behavior and distinguishes ESC key presses from escape sequences. - - ## Security - - This module implements several security measures to prevent resource exhaustion: - - - **Buffer size limit**: Input buffer is limited by `InputBuffer.apply_limit/2` - (1KB max, truncates to 256 bytes when exceeded). This prevents memory - exhaustion from malformed or malicious escape sequences. - - - **Event queue limit**: Maximum 1000 events can be queued. Excess events are - dropped with a warning. This prevents memory exhaustion from rapid input. - - - **Rate-limited logging**: Buffer overflow warnings use rate-limited logging - (via `InputBuffer`) to prevent log flooding attacks. - - - **Escape sequence timeout**: Partial sequences timeout after 100ms, preventing - indefinite buffering of incomplete sequences. - - For concurrent usage, each handler instance maintains independent state, so - memory usage scales linearly with the number of concurrent handlers. - """ - - @behaviour TermUI.Input - - require Logger - - alias TermUI.Backend.InputBuffer - alias TermUI.Event - alias TermUI.Terminal.EscapeParser - - # Dialyzer: Functions return specific struct types - # Dialyzer: emit_partial_escape/1 calls Event.key with string args for partial escape chars - # Key.new/2 spec says atom() but the function works with strings too - @dialyzer {:nowarn_function, - new: 0, - stop: 1, - emit_partial_escape: 1, - restore_io_opts: 1, - process_input: 2, - poll: 2, - setup_io_opts: 0, - read_char: 0, - handle_escape_timeout: 1} - - # Timeout for escape sequence completion (ms). - # Matches snake_test's 100ms timeout for distinguishing ESC key presses. - @escape_timeout 100 - - # Maximum event queue size to prevent memory exhaustion. - @max_queue_size 1000 - - defstruct buffer: <<>>, - event_queue: [], - io_opts_restored: false, - io_opts_set: false, - original_opts: [] - - @typedoc """ - State for the TTY input handler. - - - `:buffer` - Binary buffer for partial escape sequences - - `:event_queue` - Queue of parsed events waiting to be returned - - `:io_opts_restored` - Whether IO options have been restored - - `:io_opts_set` - Whether IO options have been set - """ - @type t :: %__MODULE__{ - buffer: binary(), - event_queue: [Event.t()], - io_opts_restored: boolean(), - io_opts_set: boolean() - } - - @doc """ - Creates a new TTY input handler state. - - Configures the IO server for TTY input (echo: false, binary: false). - - ## Examples - - state = TermUI.Input.TTY.new() - """ - @spec new() :: t() - def new do - # Set IO options for IEx-compatible TTY input - # We save the original options so we can restore them later - original_opts = setup_io_opts() - - %__MODULE__{ - buffer: <<>>, - event_queue: [], - io_opts_set: true, - io_opts_restored: false, - original_opts: original_opts - } - end - - @doc """ - Polls for input. - - **Note**: The timeout parameter is accepted for API compatibility but is - **not honored** in TTY mode. `:io.get_chars/2` is blocking and will wait - indefinitely for input. This function will not return `:timeout` in normal operation. - - ## Parameters - - - `state` - Current handler state - - `timeout` - Maximum wait time in milliseconds (ignored in TTY mode) - - ## Returns - - - `{{:ok, event}, new_state}` - An event was received - - `{:eof, new_state}` - End of input stream - - ## Examples - - # Note: timeout is ignored, this will block until input - {result, state} = TTY.poll(state, 100) - """ - @impl TermUI.Input - @spec poll(t(), non_neg_integer()) :: TermUI.Input.poll_result() - def poll(%__MODULE__{} = state, timeout) when is_integer(timeout) and timeout >= 0 do - # First, check if we have queued events from a previous parse - case state.event_queue do - [event | rest] -> - {{:ok, event}, %{state | event_queue: rest}} - - [] -> - # Try to get an event from the buffer - case try_parse_buffer(state) do - {:ok, event, new_state} -> - {{:ok, event}, new_state} - - :need_more -> - # Need to read more input (blocking) - read_blocking(state) - end - end - end - - @doc """ - Returns the input mode for this handler. - - Always returns `:tty` for the TTY input handler. - - ## Examples - - mode = TTY.mode(state) - # => :tty - """ - @impl TermUI.Input - @spec mode(t()) :: :tty - def mode(%__MODULE__{}), do: :tty - - @doc """ - Stops the TTY input handler and restores IO options. - - ## Examples - - :ok = TTY.stop(state) - """ - @impl TermUI.Input - @spec stop(t()) :: :ok - def stop(%__MODULE__{original_opts: original_opts}) do - restore_io_opts(original_opts) - # Ensure echo is enabled after stopping (critical for IEx compatibility) - # We do this unconditionally because IEx always needs echo on - :io.setopts(echo: true) - :ok - end - - # Private Functions - - defp setup_io_opts do - # Save original options - original = :io.getopts() |> Keyword.take([:echo, :binary]) - - # Set options for TTY input (like snake_test does) - # binary: false means :io.get_chars returns charlists - :io.setopts(echo: false, binary: false) - - original - end - - defp restore_io_opts(original_opts) do - # Restore echo and binary mode from original options - # We use Keyword.get to safely extract values with defaults - echo = Keyword.get(original_opts, :echo, true) - binary = Keyword.get(original_opts, :binary, true) - :io.setopts(echo: echo, binary: binary) - end - - # Try to parse a complete event from the buffer - @spec try_parse_buffer(t()) :: {:ok, Event.t(), t()} | :need_more - defp try_parse_buffer(%__MODULE__{buffer: <<>>}), do: :need_more - - defp try_parse_buffer(%__MODULE__{buffer: buffer} = state) do - case EscapeParser.parse(buffer) do - {[event | rest_events], remaining} -> - # Got at least one event - # Queue any additional events for subsequent polls (with size limit) - queued_events = limit_queue(rest_events) - new_state = %{state | buffer: remaining, event_queue: queued_events} - {:ok, event, new_state} - - {[], _remaining} -> - # No complete events yet, need more input - :need_more - end - end - - # Limit queue size to prevent memory exhaustion - @spec limit_queue([Event.t()]) :: [Event.t()] - defp limit_queue(events) when length(events) <= @max_queue_size, do: events - - defp limit_queue(events) do - Logger.warning( - "Input.TTY: Event queue overflow, dropping #{length(events) - @max_queue_size} events" - ) - - Enum.take(events, @max_queue_size) - end - - # Read input with blocking I/O - @spec read_blocking(t()) :: TermUI.Input.poll_result() - defp read_blocking(%__MODULE__{} = state) do - # Check if we have a partial escape sequence that needs timeout handling - if EscapeParser.partial_sequence?(state.buffer) do - # Wait a short time for escape sequence completion - handle_escape_timeout(state) - else - # Normal blocking read - do_read_blocking(state) - end - end - - # Handle the case where we have a partial escape sequence - @spec handle_escape_timeout(t()) :: TermUI.Input.poll_result() - defp handle_escape_timeout(%__MODULE__{} = state) do - # For TTY mode, we use a Task with short timeout to check for sequence completion - task = Task.async(fn -> read_char() end) - - case Task.yield(task, @escape_timeout) do - {:ok, {:ok, data}} -> - # Got more input, add to buffer and try to parse - process_input(state, data) - - {:ok, :eof} -> - {:eof, state} - - {:ok, {:error, reason}} -> - Logger.debug("Input.TTY: IO read error: #{inspect(reason)}") - {:eof, state} - - nil -> - # Timeout - escape sequence didn't complete, emit partial - Task.shutdown(task) - emit_partial_escape(state) - end - end - - # Emit partial escape sequence as individual key events - @spec emit_partial_escape(t()) :: TermUI.Input.poll_result() - defp emit_partial_escape(%__MODULE__{buffer: buffer} = state) do - events = - cond do - # Lone ESC - buffer == <<27>> -> - [Event.key(:escape)] - - # ESC[ without terminator - buffer == <<27, ?[>> -> - [Event.key(:escape), Event.key("[", char: "[")] - - # ESC O without terminator - buffer == <<27, ?O>> -> - [Event.key(:escape), Event.key("O", char: "O")] - - # Other partial sequences starting with ESC - String.starts_with?(buffer, <<27>>) -> - <<27, rest::binary>> = buffer - {rest_events, _} = EscapeParser.parse(rest) - [Event.key(:escape) | rest_events] - - true -> - [] - end - - case events do - [event | rest] -> - # Return first event, queue rest, clear buffer - {{:ok, event}, %{state | buffer: <<>>, event_queue: rest}} - - [] -> - # No events to emit, continue with blocking read - do_read_blocking(%{state | buffer: <<>>}) - end - end - - # Perform the actual blocking read - @spec do_read_blocking(t()) :: TermUI.Input.poll_result() - defp do_read_blocking(%__MODULE__{} = state) do - case read_char() do - {:ok, data} -> - process_input(state, data) - - :eof -> - {:eof, state} - - {:error, reason} -> - Logger.debug("Input.TTY: IO read error: #{inspect(reason)}") - {:eof, state} - end - end - - # Process input data and try to parse - @spec process_input(t(), binary()) :: TermUI.Input.poll_result() - defp process_input(%__MODULE__{} = state, data) do - new_buffer = state.buffer <> data - # InputBuffer.apply_limit uses rate-limited logging via the :source option - {limited_buffer, _truncated} = InputBuffer.apply_limit(new_buffer, source: :input_tty) - - new_state = %{state | buffer: limited_buffer} - - case try_parse_buffer(new_state) do - {:ok, event, final_state} -> - {{:ok, event}, final_state} - - :need_more -> - # Still need more, continue reading - read_blocking(new_state) - end - end - - # Read a single character from stdin using :io.get_chars/2 - # This is the key to IEx compatibility - using Erlang's :io module directly - @spec read_char() :: {:ok, binary()} | :eof | {:error, term()} - defp read_char do - result = :io.get_chars(~c"", 1) - - case result do - :eof -> - :eof - - chars when is_list(chars) -> - # Convert charlist to binary - case :unicode.characters_to_binary(chars) do - binary when is_binary(binary) -> - {:ok, binary} - - :error -> - {:error, :invalid_unicode} - end - - {:error, reason} -> - {:error, reason} - - other -> - {:error, {:unexpected_io_return, other}} - end - end -end diff --git a/lib/term_ui/input/tty_server.ex b/lib/term_ui/input/tty_server.ex deleted file mode 100644 index b32f6526..00000000 --- a/lib/term_ui/input/tty_server.ex +++ /dev/null @@ -1,339 +0,0 @@ -defmodule TermUI.Input.TTY.Server do - @moduledoc """ - GenServer that manages IEx-compatible TTY input using a separate process. - - This server spawns a separate process that continuously polls for input using - `:io.get_chars/2`. This approach allows TUI applications to work correctly - inside IEx, bypassing IEx's input interception. - - ## Architecture - - The server manages a spawned process that: - 1. Continuously polls with `receive after 0` for non-blocking behavior - 2. Calls `:io.get_chars("", 1)` to read single characters - 3. Parses escape sequences and converts charlists to binaries - 4. Sends parsed key events as messages to the server - - The server maintains: - - A queue of parsed events waiting to be delivered - - The original IO options (for restoration on shutdown) - - The spawned input process PID - - ## Usage - - {:ok, server} = TermUI.Input.TTY.Server.start_link(receiver: self()) - {:ok, event} = TermUI.Input.TTY.Server.poll(server, 100) - :ok = TermUI.Input.TTY.Server.stop(server) - - ## IO Server Configuration - - The server configures the IO server on startup: - - Saves original options via `:io.getopts/0` - - Sets `echo: false` to disable character echo - - Sets `binary: false` so `:io.get_chars/2` returns charlists - - On termination, it restores the original options. - """ - - use GenServer - require Logger - - alias TermUI.Terminal.EscapeParser - - # Dialyzer: Complex guard patterns in handle_escape_timeout/3 and parse_buffer/1 - # Dialyzer: input_loop/4 is called in spawn, Dialyzer cannot prove safety - @dialyzer {:nowarn_function, - handle_escape_timeout: 3, - parse_buffer: 1, - input_loop: 4, - handle_info: 2, - terminate: 2, - setup_io_opts: 0, - restore_io_opts: 1} - - @escape_timeout 100 - @max_queue_size 1000 - - defstruct event_queue: [], - buffer: <<>>, - original_opts: nil, - input_pid: nil, - receiver: nil - - # Client API - - @doc """ - Starts the TTY input server. - - ## Options - - - `:receiver` - PID to send key events to (defaults to `self()`) - - `:name` - Name for GenServer registration (optional) - - ## Examples - - {:ok, server} = TermUI.Input.TTY.Server.start_link() - {:ok, server} = TermUI.Input.TTY.Server.start_link(receiver: some_pid) - """ - def start_link(opts \\ []) do - {gen_opts, opts} = Keyword.split(opts, [:name]) - GenServer.start_link(__MODULE__, opts, gen_opts) - end - - @doc """ - Stops the TTY input server. - """ - def stop(server, reason \\ :normal, timeout \\ 5000) do - GenServer.stop(server, reason, timeout) - end - - @doc """ - Polls for a key event. - - Returns the next queued event, or waits for one if none is available. - The timeout is in milliseconds. - - ## Returns - - - `{:ok, event}` - A key event was received - - `{:error, :eof}` - End of input stream - - `{:error, :timeout}` - No event within timeout (rare in TTY mode) - - ## Examples - - case TermUI.Input.TTY.Server.poll(server, 100) do - {:ok, %Event.Key{} = event} -> handle_key(event) - {:error, :eof} -> handle_shutdown() - end - """ - def poll(server, timeout \\ 100) do - GenServer.call(server, {:poll, timeout}, timeout + 100) - end - - @doc """ - Returns the current event queue size. - """ - def queue_size(server) do - GenServer.call(server, :queue_size) - end - - # Server Callbacks - - @impl true - def init(opts) do - receiver = Keyword.get(opts, :receiver) - - # Save original IO options and configure for TTY input - original_opts = setup_io_opts() - - # Spawn the input process - input_pid = spawn_input_process(self(), receiver) - - state = %__MODULE__{ - original_opts: original_opts, - input_pid: input_pid, - receiver: receiver, - buffer: <<>>, - event_queue: [] - } - - {:ok, state} - end - - @impl true - def handle_call({:poll, _timeout}, _from, %__MODULE__{} = state) do - case state.event_queue do - [event | rest] -> - {:reply, {:ok, event}, %{state | event_queue: rest}} - - [] -> - # Check if input process is still alive - if Process.alive?(state.input_pid) do - # No events queued, wait a bit and check again - # In TTY mode, we typically block, so we'll tell caller to try again - # or wait for next message - {:reply, {:error, :no_event}, state} - else - # Input process died, likely EOF - {:reply, {:error, :eof}, state} - end - end - end - - @impl true - def handle_call(:queue_size, _from, state) do - {:reply, length(state.event_queue), state} - end - - @impl true - def handle_cast({:input, data}, state) do - # Process input data from the input process - new_state = process_input_data(state, data) - {:noreply, new_state} - end - - @impl true - def handle_cast(:eof, state) do - # Input process reached EOF - {:noreply, state} - end - - @impl true - def handle_info({:input_event, event}, state) do - # Direct event from input process (for immediate delivery) - new_queue = limit_queue(state.event_queue ++ [event]) - {:noreply, %{state | event_queue: new_queue}} - end - - @impl true - def handle_info({:DOWN, _ref, :process, pid, reason}, %{input_pid: pid} = state) do - # Input process died - Logger.debug("TTY.Server: Input process died: #{inspect(reason)}") - {:noreply, state} - end - - @impl true - def terminate(_reason, state) do - # Stop input process - if state.input_pid && Process.alive?(state.input_pid) do - Process.exit(state.input_pid, :stop) - end - - # Restore original IO options - if state.original_opts do - restore_io_opts(state.original_opts) - end - - :ok - end - - # Private Functions - - defp setup_io_opts do - # Save original options - original = :io.getopts() |> Keyword.take([:echo, :binary]) - - # Set options for TTY input - :io.setopts(echo: false, binary: false) - - original - end - - defp restore_io_opts(original) do - :io.setopts(original) - end - - defp spawn_input_process(server, receiver) do - spawn(fn -> - input_loop(server, receiver, <<>>, System.monotonic_time(:millisecond)) - end) - end - - # Input loop - runs in separate process - # Inspired by snake_test's TUI.loop/3 - defp input_loop(server, receiver, buffer, last_read_time) do - receive do - :stop -> - :ok - after - 0 -> - # Try to read input - case :io.get_chars(~c"", 1) do - :eof -> - # End of input - GenServer.cast(server, :eof) - :ok - - chars when is_list(chars) -> - now = System.monotonic_time(:millisecond) - dt = now - last_read_time - - # Handle escape sequence timeout - {new_buffer, events} = handle_escape_timeout(buffer, chars, dt) - - # Parse complete sequences - {remaining_buffer, parsed_events} = parse_buffer(new_buffer) - - # Combine events from timeout handling and parsing - all_events = events ++ parsed_events - - # Send events to receiver - Enum.each(all_events, fn event -> - send(receiver, {:input_event, event}) - end) - - # Also queue them in the server for poll/2 - if all_events != [] do - GenServer.cast(server, {:input, all_events}) - end - - input_loop(server, receiver, remaining_buffer, now) - - other -> - Logger.debug("TTY.Server: Unexpected input: #{inspect(other)}") - input_loop(server, receiver, buffer, System.monotonic_time(:millisecond)) - end - end - end - - # Handle escape sequence timeout (similar to snake_test timeout/3) - # If we get another ESC quickly (<100ms), it's an ESC key press - # If we get other chars, accumulate them for parsing - defp handle_escape_timeout(buffer, chars, dt) when dt < @escape_timeout do - # Within timeout window, just accumulate - {buffer ++ chars, []} - end - - defp handle_escape_timeout(~c"\e", ~c"\e", _dt) do - # Two ESC presses - emit ESC key - {~c"\e", [:escape]} - end - - defp handle_escape_timeout(~c"\e", chars, _dt) do - # ESC followed by other chars - start of escape sequence - {~c"\e" ++ chars, []} - end - - defp handle_escape_timeout(buffer, chars, _dt) do - # Normal case - just accumulate - {buffer ++ chars, []} - end - - # Parse buffer for complete sequences - defp parse_buffer(charlist) do - # Convert charlist to binary for parsing - binary = :unicode.characters_to_binary(charlist) - - case EscapeParser.parse(binary) do - {[event | rest_events], remaining} -> - # Got at least one event - # Convert remaining back to charlist - remaining_charlist = :unicode.characters_to_list(remaining) - {remaining_charlist, [event | rest_events]} - - {[], _remaining} -> - # No complete events yet - {charlist, []} - end - end - - defp limit_queue(events) when length(events) <= @max_queue_size, do: events - - defp limit_queue(events) do - Logger.warning( - "TTY.Server: Event queue overflow, dropping #{length(events) - @max_queue_size} events" - ) - - Enum.take(events, @max_queue_size) - end - - defp process_input_data(state, events) when is_list(events) do - new_queue = limit_queue(state.event_queue ++ events) - %{state | event_queue: new_queue} - end - - defp process_input_data(state, event) do - new_queue = limit_queue(state.event_queue ++ [event]) - %{state | event_queue: new_queue} - end -end diff --git a/lib/term_ui/layout/alignment.ex b/lib/term_ui/layout/alignment.ex deleted file mode 100644 index 9dbcf9fd..00000000 --- a/lib/term_ui/layout/alignment.ex +++ /dev/null @@ -1,339 +0,0 @@ -defmodule TermUI.Layout.Alignment do - @moduledoc """ - Flexbox-inspired alignment for positioning components within allocated space. - - ## Alignment Model - - - **Main axis**: Direction of layout (X for horizontal, Y for vertical) - - **Cross axis**: Perpendicular to main axis - - ## Justify Content (Main Axis) - - - `:start` - Pack at beginning - - `:center` - Center in space - - `:end` - Pack at end - - `:space_between` - Equal space between components - - `:space_around` - Equal space around each component - - ## Align Items (Cross Axis) - - - `:start` - Position at cross-axis start - - `:center` - Center on cross-axis - - `:end` - Position at cross-axis end - - `:stretch` - Expand to fill cross-axis - - ## Examples - - # Apply alignment to solved rects - rects = Solver.solve_to_rects(constraints, area) - aligned = Alignment.apply(rects, area, - direction: :horizontal, - justify: :space_between, - align: :center - ) - - # With margins - aligned = Alignment.apply_with_spacing(rects, area, - direction: :horizontal, - margin: %{top: 5, right: 5, bottom: 5, left: 5} - ) - """ - - @type rect :: %{x: integer(), y: integer(), width: integer(), height: integer()} - @type direction :: :horizontal | :vertical - @type justify :: :start | :center | :end | :space_between | :space_around - @type align :: :start | :center | :end | :stretch - @type spacing :: %{top: integer(), right: integer(), bottom: integer(), left: integer()} - - @type opts :: [ - direction: direction(), - justify: justify(), - align: align(), - align_self: [align() | nil] - ] - - # Public API - - @doc """ - Applies alignment to a list of rectangles within a container area. - - ## Parameters - - - `rects` - list of rectangles from solver - - `area` - container bounding rectangle - - `opts` - alignment options - - `:direction` - `:horizontal` (default) or `:vertical` - - `:justify` - main axis alignment (default `:start`) - - `:align` - cross axis alignment (default `:start`) - - `:align_self` - per-component cross axis overrides - - ## Returns - - List of aligned rectangles. - """ - @spec apply([rect()], rect(), opts()) :: [rect()] - def apply(rects, area, opts \\ []) do - direction = Keyword.get(opts, :direction, :horizontal) - justify = Keyword.get(opts, :justify, :start) - align = Keyword.get(opts, :align, :start) - align_self = Keyword.get(opts, :align_self, []) - - rects - |> apply_justify(area, direction, justify) - |> apply_align(area, direction, align, align_self) - end - - @doc """ - Applies margin to rectangles, shrinking them. - - ## Parameters - - - `rects` - list of rectangles - - `margins` - list of margin maps (one per rect) or single margin for all - - ## Returns - - List of rectangles with margins applied. - """ - @spec apply_margins([rect()], [spacing()] | spacing()) :: [rect()] - def apply_margins(rects, margins) when is_map(margins) do - Enum.map(rects, &apply_margin(&1, margins)) - end - - def apply_margins(rects, margins) when is_list(margins) do - rects - |> Enum.zip(margins ++ List.duplicate(%{top: 0, right: 0, bottom: 0, left: 0}, length(rects))) - |> Enum.map(fn {rect, margin} -> apply_margin(rect, margin) end) - end - - @doc """ - Applies padding to a rectangle, shrinking the content area. - - ## Parameters - - - `rect` - rectangle to pad - - `padding` - padding map - - ## Returns - - Rectangle with padding applied (position adjusted, size reduced). - """ - @spec apply_padding(rect(), spacing()) :: rect() - def apply_padding(rect, padding) do - %{ - x: rect.x + padding.left, - y: rect.y + padding.top, - width: max(0, rect.width - padding.left - padding.right), - height: max(0, rect.height - padding.top - padding.bottom) - } - end - - @doc """ - Parses spacing shorthand into a spacing map. - - ## Examples - - iex> Alignment.parse_spacing(10) - %{top: 10, right: 10, bottom: 10, left: 10} - - iex> Alignment.parse_spacing({5, 10}) - %{top: 5, right: 10, bottom: 5, left: 10} - - iex> Alignment.parse_spacing({1, 2, 3, 4}) - %{top: 1, right: 2, bottom: 3, left: 4} - """ - @spec parse_spacing( - integer() - | {integer(), integer()} - | {integer(), integer(), integer(), integer()} - ) :: spacing() - def parse_spacing(value) when is_integer(value) do - %{top: value, right: value, bottom: value, left: value} - end - - def parse_spacing({vertical, horizontal}) do - %{top: vertical, right: horizontal, bottom: vertical, left: horizontal} - end - - def parse_spacing({top, right, bottom, left}) do - %{top: top, right: right, bottom: bottom, left: left} - end - - def parse_spacing(%{} = map) do - %{ - top: Map.get(map, :top, 0), - right: Map.get(map, :right, 0), - bottom: Map.get(map, :bottom, 0), - left: Map.get(map, :left, 0) - } - end - - # Justify (main axis) implementation - - defp apply_justify(rects, area, direction, :start) do - {main_start, _main_size} = get_main_axis(area, direction) - shift_main_axis(rects, main_start, direction) - end - - defp apply_justify(rects, area, direction, :center) do - {main_start, main_size} = get_main_axis(area, direction) - total_content = total_main_size(rects, direction) - offset = div(main_size - total_content, 2) - - shift_main_axis(rects, main_start + offset, direction) - end - - defp apply_justify(rects, area, direction, :end) do - {main_start, main_size} = get_main_axis(area, direction) - total_content = total_main_size(rects, direction) - offset = main_size - total_content - - shift_main_axis(rects, main_start + offset, direction) - end - - defp apply_justify(rects, area, direction, :space_between) do - count = length(rects) - - if count <= 1 do - rects - else - {main_start, main_size} = get_main_axis(area, direction) - total_content = total_main_size(rects, direction) - total_space = main_size - total_content - space_between = div(total_space, count - 1) - - distribute_with_spacing(rects, main_start, space_between, direction) - end - end - - defp apply_justify(rects, area, direction, :space_around) do - count = length(rects) - - if count == 0 do - rects - else - {main_start, main_size} = get_main_axis(area, direction) - total_content = total_main_size(rects, direction) - total_space = main_size - total_content - space_unit = div(total_space, count * 2) - - # Start with half space, then full space between each - distribute_with_around(rects, main_start + space_unit, space_unit * 2, direction) - end - end - - # Align (cross axis) implementation - - defp apply_align(rects, area, direction, align, align_self) do - {cross_start, cross_size} = get_cross_axis(area, direction) - - rects - |> Enum.with_index() - |> Enum.map(fn {rect, idx} -> - effective_align = Enum.at(align_self, idx) || align - apply_single_align(rect, cross_start, cross_size, direction, effective_align) - end) - end - - defp apply_single_align(rect, cross_start, _cross_size, direction, :start) do - set_rect_cross_pos(rect, cross_start, direction) - end - - defp apply_single_align(rect, cross_start, cross_size, direction, :center) do - rect_cross_size = get_rect_cross_size(rect, direction) - offset = div(cross_size - rect_cross_size, 2) - set_rect_cross_pos(rect, cross_start + offset, direction) - end - - defp apply_single_align(rect, cross_start, cross_size, direction, :end) do - rect_cross_size = get_rect_cross_size(rect, direction) - offset = cross_size - rect_cross_size - set_rect_cross_pos(rect, cross_start + offset, direction) - end - - defp apply_single_align(rect, cross_start, cross_size, direction, :stretch) do - rect - |> set_rect_cross_pos(cross_start, direction) - |> set_rect_cross_size(cross_size, direction) - end - - # Helper functions - - defp get_main_axis(area, :horizontal), do: {area.x, area.width} - defp get_main_axis(area, :vertical), do: {area.y, area.height} - - defp get_cross_axis(area, :horizontal), do: {area.y, area.height} - defp get_cross_axis(area, :vertical), do: {area.x, area.width} - - defp get_rect_main_size(rect, :horizontal), do: rect.width - defp get_rect_main_size(rect, :vertical), do: rect.height - - defp get_rect_cross_size(rect, :horizontal), do: rect.height - defp get_rect_cross_size(rect, :vertical), do: rect.width - - defp set_rect_cross_pos(rect, pos, :horizontal), do: %{rect | y: pos} - defp set_rect_cross_pos(rect, pos, :vertical), do: %{rect | x: pos} - - defp set_rect_cross_size(rect, size, :horizontal), do: %{rect | height: size} - defp set_rect_cross_size(rect, size, :vertical), do: %{rect | width: size} - - defp total_main_size(rects, direction) do - Enum.reduce(rects, 0, fn rect, acc -> - acc + get_rect_main_size(rect, direction) - end) - end - - defp shift_main_axis(rects, start_pos, direction) do - {shifted, _pos} = - Enum.map_reduce(rects, start_pos, fn rect, pos -> - new_rect = - case direction do - :horizontal -> %{rect | x: pos} - :vertical -> %{rect | y: pos} - end - - {new_rect, pos + get_rect_main_size(rect, direction)} - end) - - shifted - end - - defp distribute_with_spacing(rects, start_pos, spacing, direction) do - {distributed, _pos} = - Enum.map_reduce(rects, start_pos, fn rect, pos -> - new_rect = - case direction do - :horizontal -> %{rect | x: pos} - :vertical -> %{rect | y: pos} - end - - {new_rect, pos + get_rect_main_size(rect, direction) + spacing} - end) - - distributed - end - - defp distribute_with_around(rects, start_pos, spacing, direction) do - {distributed, _pos} = - Enum.map_reduce(rects, start_pos, fn rect, pos -> - new_rect = - case direction do - :horizontal -> %{rect | x: pos} - :vertical -> %{rect | y: pos} - end - - {new_rect, pos + get_rect_main_size(rect, direction) + spacing} - end) - - distributed - end - - defp apply_margin(rect, margin) do - %{ - x: rect.x + margin.left, - y: rect.y + margin.top, - width: max(0, rect.width - margin.left - margin.right), - height: max(0, rect.height - margin.top - margin.bottom) - } - end -end diff --git a/lib/term_ui/layout/cache.ex b/lib/term_ui/layout/cache.ex deleted file mode 100644 index f13ad9b2..00000000 --- a/lib/term_ui/layout/cache.ex +++ /dev/null @@ -1,341 +0,0 @@ -defmodule TermUI.Layout.Cache do - @moduledoc """ - Layout cache with LRU eviction for caching constraint solver results. - - The cache stores solved layouts keyed by constraint hash and dimensions, - providing O(1) lookup for unchanged layouts. LRU eviction keeps memory - bounded while maintaining frequently-used layouts. - - ## Usage - - # Start cache (typically in supervision tree) - Cache.start_link(max_size: 1000) - - # Cached solve - rects = Cache.solve(constraints, area) - - # Statistics - stats = Cache.stats() - # => %{size: 150, hits: 1234, misses: 56, hit_rate: 0.956} - - # Clear on resize - Cache.clear() - - ## Configuration - - - `:max_size` - Maximum entries before eviction (default 500) - - `:eviction_count` - Entries to remove per eviction (default 50) - """ - - use GenServer - - alias TermUI.Layout.Solver - - @table :term_ui_layout_cache - @stats_table :term_ui_layout_cache_stats - @default_max_size 500 - @default_eviction_count 50 - - # Dialyzer: Functions with unmatched return values - @dialyzer {:nowarn_function, init: 1, increment_hits: 0, increment_misses: 0, solve: 3} - - # Client API - - @doc """ - Starts the layout cache. - - ## Options - - - `:max_size` - Maximum cache entries (default 500) - - `:eviction_count` - Entries to remove per eviction (default 50) - - `:name` - GenServer name (default __MODULE__) - """ - def start_link(opts \\ []) do - name = Keyword.get(opts, :name, __MODULE__) - GenServer.start_link(__MODULE__, opts, name: name) - end - - @doc """ - Solves constraints with automatic caching. - - Checks cache first, falls back to solver on miss. - - ## Parameters - - - `constraints` - list of constraints - - `area` - bounding rectangle - - `opts` - solver options (direction, gap, etc.) - - ## Returns - - List of positioned rectangles. - """ - def solve(constraints, area, opts \\ []) do - key = cache_key(constraints, area) - - case lookup(key) do - {:ok, result} -> - increment_hits() - result - - :miss -> - increment_misses() - result = Solver.solve_to_rects(constraints, area, opts) - insert(key, result) - result - end - end - - @doc """ - Solves constraints without caching. - - Use for testing or when caching is not desired. - """ - def solve_uncached(constraints, area, opts \\ []) do - Solver.solve_to_rects(constraints, area, opts) - end - - @doc """ - Looks up a cached result by key. - - Returns `{:ok, result}` if found, `:miss` otherwise. - """ - def lookup(key) do - case :ets.lookup(@table, key) do - [{^key, result, _access_time}] -> - # Update access time - :ets.update_element(@table, key, {3, current_time()}) - {:ok, result} - - [] -> - :miss - end - end - - @doc """ - Inserts a result into the cache. - - Triggers eviction if cache exceeds max size. - """ - def insert(key, result) do - now = current_time() - :ets.insert(@table, {key, result, now}) - maybe_evict() - :ok - end - - @doc """ - Invalidates a specific cache entry. - """ - def invalidate(key) do - :ets.delete(@table, key) - :ok - end - - @doc """ - Invalidates cache entries matching constraints. - - Useful when a component's constraints change. - """ - def invalidate_constraints(constraints) do - hash = constraint_hash(constraints) - - # Find and delete all entries with this constraint hash - :ets.select_delete(@table, [ - {{{hash, :_, :_}, :_, :_}, [], [true]} - ]) - - :ok - end - - @doc """ - Clears all cache entries. - - Call this on terminal resize. - """ - def clear do - :ets.delete_all_objects(@table) - :ok - end - - @doc """ - Returns cache statistics. - - ## Returns - - Map with: - - `:size` - current entry count - - `:hits` - total cache hits - - `:misses` - total cache misses - - `:hit_rate` - hits / (hits + misses) - """ - def stats do - size = :ets.info(@table, :size) - - [{_, hits}] = :ets.lookup(@stats_table, :hits) - [{_, misses}] = :ets.lookup(@stats_table, :misses) - - total = hits + misses - - hit_rate = - if total > 0 do - Float.round(hits / total, 3) - else - 0.0 - end - - %{ - size: size, - hits: hits, - misses: misses, - hit_rate: hit_rate - } - end - - @doc """ - Resets cache statistics. - """ - def reset_stats do - :ets.insert(@stats_table, {:hits, 0}) - :ets.insert(@stats_table, {:misses, 0}) - :ok - end - - @doc """ - Warms the cache with common layouts. - - ## Parameters - - - `layouts` - list of `{constraints, area, opts}` tuples - """ - def warm(layouts) when is_list(layouts) do - Enum.each(layouts, fn {constraints, area, opts} -> - solve(constraints, area, opts) - end) - - # Reset stats after warming so they reflect actual usage - reset_stats() - :ok - end - - @doc """ - Returns the current cache size. - """ - def size do - :ets.info(@table, :size) - end - - @doc """ - Forces eviction synchronously. Useful for testing. - """ - def evict_now(name \\ __MODULE__) do - GenServer.call(name, :evict_sync) - end - - # GenServer callbacks - - @impl true - def init(opts) do - max_size = Keyword.get(opts, :max_size, @default_max_size) - eviction_count = Keyword.get(opts, :eviction_count, @default_eviction_count) - - # Create ETS tables - :ets.new(@table, [:set, :public, :named_table, read_concurrency: true]) - :ets.new(@stats_table, [:set, :public, :named_table]) - - # Initialize stats - :ets.insert(@stats_table, {:hits, 0}) - :ets.insert(@stats_table, {:misses, 0}) - - state = %{ - max_size: max_size, - eviction_count: eviction_count - } - - {:ok, state} - end - - @impl true - def handle_call(:get_config, _from, state) do - {:reply, state, state} - end - - @impl true - def handle_call(:evict_sync, _from, state) do - do_eviction(state.max_size, state.eviction_count) - {:reply, :ok, state} - end - - @impl true - def handle_cast(:evict, state) do - do_eviction(state.max_size, state.eviction_count) - {:noreply, state} - end - - @impl true - def terminate(_reason, _state) do - # Clean up ETS tables - if :ets.whereis(@table) != :undefined, do: :ets.delete(@table) - if :ets.whereis(@stats_table) != :undefined, do: :ets.delete(@stats_table) - :ok - end - - # Private functions - - defp cache_key(constraints, area) do - hash = constraint_hash(constraints) - {hash, area.width, area.height} - end - - defp constraint_hash(constraints) do - :erlang.phash2(constraints) - end - - defp current_time do - :erlang.monotonic_time(:millisecond) - end - - defp increment_hits do - :ets.update_counter(@stats_table, :hits, 1) - end - - defp increment_misses do - :ets.update_counter(@stats_table, :misses, 1) - end - - defp maybe_evict do - current_size = :ets.info(@table, :size) - config = get_config() - - if current_size > config.max_size do - GenServer.cast(__MODULE__, :evict) - end - end - - defp get_config do - GenServer.call(__MODULE__, :get_config, 100) - catch - :exit, _ -> - %{max_size: @default_max_size, eviction_count: @default_eviction_count} - end - - defp do_eviction(max_size, eviction_count) do - current_size = :ets.info(@table, :size) - - if current_size > max_size do - # Get all entries sorted by access time - entries = - :ets.tab2list(@table) - |> Enum.sort_by(fn {_key, _result, access_time} -> access_time end) - - # Remove oldest entries - to_remove = min(eviction_count, current_size - max_size + eviction_count) - - entries - |> Enum.take(to_remove) - |> Enum.each(fn {key, _result, _access_time} -> - :ets.delete(@table, key) - end) - end - end -end diff --git a/lib/term_ui/layout/constraint.ex b/lib/term_ui/layout/constraint.ex deleted file mode 100644 index 4e3d6043..00000000 --- a/lib/term_ui/layout/constraint.ex +++ /dev/null @@ -1,518 +0,0 @@ -defmodule TermUI.Layout.Constraint do - @moduledoc """ - Constraint types for the layout system. - - Constraints express how components request space from their parent container. - They are declarative—describing desired outcome, not how to achieve it. - - ## Constraint Types - - - `length/1` - Exact size in terminal cells - - `percentage/1` - Fraction of parent size (0-100) - - `ratio/1` - Proportional share of remaining space - - `min/1`, `max/1` - Bounds on size - - `fill/0` - Take all remaining space - - ## Examples - - # Fixed 20 cells - Constraint.length(20) - - # 50% of parent - Constraint.percentage(50) - - # 50% but at least 10 cells - Constraint.percentage(50) |> Constraint.with_min(10) - - # Fill remaining space - Constraint.fill() - - # 2:1 ratio distribution - [Constraint.ratio(2), Constraint.ratio(1)] - - ## Composition - - Constraints can be composed with bounds using `with_min/2` and `with_max/2`: - - Constraint.percentage(50) |> Constraint.with_min(10) |> Constraint.with_max(100) - - This creates a constraint that requests 50% of parent, but at least 10 and at most 100 cells. - """ - - require Logger - - # Constraint type structs - - defmodule Length do - @moduledoc "Fixed size constraint in terminal cells." - defstruct [:value] - - @type t :: %__MODULE__{value: non_neg_integer()} - end - - defmodule Percentage do - @moduledoc "Percentage of parent size constraint." - defstruct [:value] - - @type t :: %__MODULE__{value: number()} - end - - defmodule Ratio do - @moduledoc "Proportional share of remaining space constraint." - defstruct [:value] - - @type t :: %__MODULE__{value: number()} - end - - defmodule Min do - @moduledoc "Minimum size bound on another constraint." - defstruct [:value, :constraint] - - @type t :: %__MODULE__{value: non_neg_integer(), constraint: TermUI.Layout.Constraint.t()} - end - - defmodule Max do - @moduledoc "Maximum size bound on another constraint." - defstruct [:value, :constraint] - - @type t :: %__MODULE__{value: non_neg_integer(), constraint: TermUI.Layout.Constraint.t()} - end - - defmodule Fill do - @moduledoc "Fill remaining space constraint." - defstruct [] - - @type t :: %__MODULE__{} - end - - @type t :: Length.t() | Percentage.t() | Ratio.t() | Min.t() | Max.t() | Fill.t() - - # Public API - - @doc """ - Creates a length constraint for exactly `n` cells. - - ## Parameters - - - `n` - Number of cells (non-negative integer) - - ## Returns - - A length constraint struct. - - ## Examples - - iex> Constraint.length(20) - %TermUI.Layout.Constraint.Length{value: 20} - - iex> Constraint.length(0) - %TermUI.Layout.Constraint.Length{value: 0} - - ## Errors - - Raises `ArgumentError` if `n` is negative or not an integer. - """ - @spec length(non_neg_integer()) :: Length.t() - def length(n) when is_integer(n) and n >= 0 do - %Length{value: n} - end - - def length(n) when is_integer(n) do - raise ArgumentError, "length must be non-negative, got: #{n}" - end - - def length(n) do - raise ArgumentError, "length must be a non-negative integer, got: #{inspect(n)}" - end - - @doc """ - Creates a percentage constraint for `p`% of parent size. - - ## Parameters - - - `p` - Percentage value (0 to 100, can be float) - - ## Returns - - A percentage constraint struct. - - ## Examples - - iex> Constraint.percentage(50) - %TermUI.Layout.Constraint.Percentage{value: 50} - - iex> Constraint.percentage(33.33) - %TermUI.Layout.Constraint.Percentage{value: 33.33} - - ## Errors - - Raises `ArgumentError` if `p` is outside 0-100 range. - """ - @spec percentage(number()) :: Percentage.t() - def percentage(p) when is_number(p) and p >= 0 and p <= 100 do - %Percentage{value: p} - end - - def percentage(p) when is_number(p) do - raise ArgumentError, "percentage must be between 0 and 100, got: #{p}" - end - - def percentage(p) do - raise ArgumentError, "percentage must be a number between 0 and 100, got: #{inspect(p)}" - end - - @doc """ - Creates a ratio constraint for proportional space distribution. - - Ratio constraints share remaining space (after fixed and percentage allocations) - proportionally among siblings with ratio constraints. - - ## Parameters - - - `r` - Ratio value (positive number) - - ## Returns - - A ratio constraint struct. - - ## Examples - - # Two siblings with 2:1 ratio (first gets 2/3, second gets 1/3) - [Constraint.ratio(2), Constraint.ratio(1)] - - # Three equal siblings - [Constraint.ratio(1), Constraint.ratio(1), Constraint.ratio(1)] - - ## Errors - - Raises `ArgumentError` if `r` is not positive. - """ - @spec ratio(number()) :: Ratio.t() - def ratio(r) when is_number(r) and r > 0 do - %Ratio{value: r} - end - - def ratio(r) when is_number(r) do - raise ArgumentError, "ratio must be positive, got: #{r}" - end - - def ratio(r) do - raise ArgumentError, "ratio must be a positive number, got: #{inspect(r)}" - end - - @doc """ - Creates a minimum size constraint. - - When used alone, acts as a minimum size requirement. - When composed with another constraint, acts as a lower bound. - - ## Parameters - - - `n` - Minimum size in cells (non-negative integer) - - ## Returns - - A min constraint struct with a fill constraint as default inner constraint. - - ## Examples - - # At least 10 cells - Constraint.min(10) - - ## Errors - - Raises `ArgumentError` if `n` is negative or not an integer. - """ - @spec min(non_neg_integer()) :: Min.t() - def min(n) when is_integer(n) and n >= 0 do - %Min{value: n, constraint: %Fill{}} - end - - def min(n) when is_integer(n) do - raise ArgumentError, "min must be non-negative, got: #{n}" - end - - def min(n) do - raise ArgumentError, "min must be a non-negative integer, got: #{inspect(n)}" - end - - @doc """ - Creates a maximum size constraint. - - When used alone, acts as a maximum size requirement with fill behavior. - When composed with another constraint, acts as an upper bound. - - ## Parameters - - - `n` - Maximum size in cells (non-negative integer) - - ## Returns - - A max constraint struct with a fill constraint as default inner constraint. - - ## Examples - - # At most 100 cells - Constraint.max(100) - - ## Errors - - Raises `ArgumentError` if `n` is negative or not an integer. - """ - @spec max(non_neg_integer()) :: Max.t() - def max(n) when is_integer(n) and n >= 0 do - %Max{value: n, constraint: %Fill{}} - end - - def max(n) when is_integer(n) do - raise ArgumentError, "max must be non-negative, got: #{n}" - end - - def max(n) do - raise ArgumentError, "max must be a non-negative integer, got: #{inspect(n)}" - end - - @doc """ - Creates combined min/max bounds. - - ## Parameters - - - `min_val` - Minimum size in cells - - `max_val` - Maximum size in cells - - ## Returns - - A min constraint wrapping a max constraint with fill behavior. - - ## Examples - - # Between 10 and 100 cells - Constraint.min_max(10, 100) - - ## Errors - - Raises `ArgumentError` if min > max or values are invalid. - """ - @spec min_max(non_neg_integer(), non_neg_integer()) :: Min.t() - def min_max(min_val, max_val) - when is_integer(min_val) and is_integer(max_val) and min_val >= 0 and max_val >= 0 do - if min_val > max_val do - raise ArgumentError, "min (#{min_val}) cannot be greater than max (#{max_val})" - end - - %Min{value: min_val, constraint: %Max{value: max_val, constraint: %Fill{}}} - end - - def min_max(min_val, max_val) do - raise ArgumentError, - "min_max requires non-negative integers, got: min=#{inspect(min_val)}, max=#{inspect(max_val)}" - end - - @doc """ - Creates a fill constraint that takes all remaining space. - - Fill is equivalent to `ratio(1)` in calculation but semantically distinct— - it means "take whatever is left" rather than "share proportionally". - - ## Returns - - A fill constraint struct. - - ## Examples - - # Main content area fills remaining space - Constraint.fill() - - Multiple fills distribute space equally among them. - """ - @spec fill() :: Fill.t() - def fill do - %Fill{} - end - - @doc """ - Adds a minimum bound to a constraint. - - ## Parameters - - - `constraint` - The constraint to bound - - `min_val` - Minimum size in cells - - ## Returns - - The constraint wrapped in a min bound. - - ## Examples - - # 50% but at least 10 cells - Constraint.percentage(50) |> Constraint.with_min(10) - """ - @spec with_min(t(), non_neg_integer()) :: Min.t() - def with_min(constraint, min_val) when is_integer(min_val) and min_val >= 0 do - %Min{value: min_val, constraint: constraint} - end - - def with_min(_constraint, min_val) do - raise ArgumentError, "with_min requires non-negative integer, got: #{inspect(min_val)}" - end - - @doc """ - Adds a maximum bound to a constraint. - - ## Parameters - - - `constraint` - The constraint to bound - - `max_val` - Maximum size in cells - - ## Returns - - The constraint wrapped in a max bound. - - ## Examples - - # 50% but at most 100 cells - Constraint.percentage(50) |> Constraint.with_max(100) - """ - @spec with_max(t(), non_neg_integer()) :: Max.t() - def with_max(constraint, max_val) when is_integer(max_val) and max_val >= 0 do - %Max{value: max_val, constraint: constraint} - end - - def with_max(_constraint, max_val) do - raise ArgumentError, "with_max requires non-negative integer, got: #{inspect(max_val)}" - end - - @doc """ - Resolves a constraint to a concrete size given available space. - - This is used by the constraint solver to calculate final sizes. - - ## Parameters - - - `constraint` - The constraint to resolve - - `available` - Available space in cells - - `opts` - Options including `:remaining` for ratio calculations - - ## Returns - - The resolved size in cells (non-negative integer). - - ## Examples - - iex> Constraint.resolve(Constraint.length(20), 100) - 20 - - iex> Constraint.resolve(Constraint.percentage(50), 100) - 50 - - iex> Constraint.resolve(Constraint.fill(), 100, remaining: 30) - 30 - """ - @spec resolve(t(), non_neg_integer(), keyword()) :: non_neg_integer() - def resolve(constraint, available, opts \\ []) - - def resolve(%Length{value: n}, available, _opts) do - if n > available do - Logger.warning("Length constraint #{n} exceeds available space #{available}, truncating") - available - else - n - end - end - - def resolve(%Percentage{value: p}, available, _opts) do - result = available * p / 100 - round(result) - end - - def resolve(%Ratio{value: r}, _available, opts) do - remaining = Keyword.get(opts, :remaining, 0) - total_ratio = Keyword.get(opts, :total_ratio, r) - - if total_ratio == 0 do - 0 - else - result = remaining * r / total_ratio - round(result) - end - end - - def resolve(%Fill{}, _available, opts) do - Keyword.get(opts, :remaining, 0) - end - - def resolve(%Min{value: min_val, constraint: inner}, available, opts) do - inner_size = resolve(inner, available, opts) - max(min_val, inner_size) - end - - def resolve(%Max{value: max_val, constraint: inner}, available, opts) do - inner_size = resolve(inner, available, opts) - min(max_val, inner_size) - end - - @doc """ - Returns the constraint type as an atom. - - Useful for categorizing constraints during solving. - - ## Examples - - iex> Constraint.type(Constraint.length(20)) - :length - - iex> Constraint.type(Constraint.percentage(50)) - :percentage - """ - @spec type(t()) :: atom() - def type(%Length{}), do: :length - def type(%Percentage{}), do: :percentage - def type(%Ratio{}), do: :ratio - def type(%Fill{}), do: :fill - def type(%Min{constraint: inner}), do: {:min, type(inner)} - def type(%Max{constraint: inner}), do: {:max, type(inner)} - - @doc """ - Checks if a constraint is fixed (length or bounded length). - - Fixed constraints are allocated first during solving. - """ - @spec fixed?(t()) :: boolean() - def fixed?(%Length{}), do: true - def fixed?(%Min{constraint: %Length{}}), do: true - def fixed?(%Max{constraint: %Length{}}), do: true - def fixed?(_), do: false - - @doc """ - Checks if a constraint uses remaining space (ratio or fill). - """ - @spec flexible?(t()) :: boolean() - def flexible?(%Ratio{}), do: true - def flexible?(%Fill{}), do: true - def flexible?(%Min{constraint: inner}), do: flexible?(inner) - def flexible?(%Max{constraint: inner}), do: flexible?(inner) - def flexible?(_), do: false - - @doc """ - Gets the minimum value from a constraint, if bounded. - """ - @spec get_min(t()) :: non_neg_integer() | nil - def get_min(%Min{value: v}), do: v - def get_min(_), do: nil - - @doc """ - Gets the maximum value from a constraint, if bounded. - """ - @spec get_max(t()) :: non_neg_integer() | nil - def get_max(%Max{value: v}), do: v - def get_max(%Min{constraint: inner}), do: get_max(inner) - def get_max(_), do: nil - - @doc """ - Gets the inner constraint, unwrapping bounds. - """ - @spec unwrap(t()) :: t() - def unwrap(%Min{constraint: inner}), do: unwrap(inner) - def unwrap(%Max{constraint: inner}), do: unwrap(inner) - def unwrap(constraint), do: constraint -end diff --git a/lib/term_ui/layout/solver.ex b/lib/term_ui/layout/solver.ex deleted file mode 100644 index 7df4f90d..00000000 --- a/lib/term_ui/layout/solver.ex +++ /dev/null @@ -1,521 +0,0 @@ -defmodule TermUI.Layout.Solver do - @moduledoc """ - Constraint solver for the layout system. - - Translates constraints into concrete cell positions and sizes using a - Cassowary-inspired greedy multi-pass algorithm. - - ## Algorithm - - The solver processes constraints in priority order: - 1. **Fixed pass** - allocate length constraints exactly - 2. **Percentage pass** - calculate from total available space - 3. **Ratio/Fill pass** - distribute remaining space proportionally - - ## Examples - - # Three-pane layout - constraints = [ - Constraint.length(20), - Constraint.ratio(1), - Constraint.ratio(2) - ] - - sizes = Solver.solve(constraints, 100) - # => [20, 27, 53] - - # Get positioned rectangles - rects = Solver.solve_to_rects(constraints, %{x: 0, y: 0, width: 100, height: 10}) - # => [ - # %{x: 0, y: 0, width: 20, height: 10}, - # %{x: 20, y: 0, width: 27, height: 10}, - # %{x: 47, y: 0, width: 53, height: 10} - # ] - """ - - require Logger - - alias TermUI.Layout.Constraint - alias TermUI.Layout.Constraint.Fill - alias TermUI.Layout.Constraint.Length - alias TermUI.Layout.Constraint.Percentage - alias TermUI.Layout.Constraint.Ratio - - @type rect :: %{x: integer(), y: integer(), width: integer(), height: integer()} - @type direction :: :horizontal | :vertical - @type solve_opts :: [ - direction: direction(), - gap: non_neg_integer(), - cross_axis: non_neg_integer() | nil - ] - - # Public API - - @doc """ - Solves constraints and returns a list of sizes. - - ## Parameters - - - `constraints` - list of constraints to solve - - `available` - total available space in cells - - ## Returns - - List of solved sizes (non-negative integers) in same order as constraints. - - ## Examples - - iex> Solver.solve([Constraint.length(20), Constraint.fill()], 100) - [20, 80] - - iex> Solver.solve([Constraint.percentage(50), Constraint.percentage(50)], 100) - [50, 50] - - iex> Solver.solve([Constraint.ratio(1), Constraint.ratio(2)], 90) - [30, 60] - """ - @spec solve([Constraint.t()], non_neg_integer()) :: [non_neg_integer()] - def solve(constraints, available) when is_list(constraints) and available >= 0 do - # Try fast paths first - case try_fast_path(constraints, available) do - {:ok, sizes} -> - sizes - - :general -> - solve_general(constraints, available) - end - end - - @doc """ - Solves constraints and returns positioned rectangles. - - ## Parameters - - - `constraints` - list of constraints to solve - - `area` - bounding rectangle with x, y, width, height - - `opts` - solving options - - `:direction` - `:horizontal` (default) or `:vertical` - - `:gap` - spacing between elements (default 0) - - `:cross_axis` - size on cross axis (default uses area dimension) - - ## Returns - - List of rectangles with x, y, width, height. - - ## Examples - - iex> Solver.solve_to_rects( - ...> [Constraint.length(20), Constraint.fill()], - ...> %{x: 0, y: 0, width: 100, height: 10} - ...> ) - [ - %{x: 0, y: 0, width: 20, height: 10}, - %{x: 20, y: 0, width: 80, height: 10} - ] - """ - @spec solve_to_rects([Constraint.t()], rect(), solve_opts()) :: [rect()] - def solve_to_rects(constraints, area, opts \\ []) do - direction = Keyword.get(opts, :direction, :horizontal) - gap = Keyword.get(opts, :gap, 0) - - {main_size, cross_size} = - case direction do - :horizontal -> {area.width, area.height} - :vertical -> {area.height, area.width} - end - - cross_size = Keyword.get(opts, :cross_axis, cross_size) - - # Account for gaps in available space - total_gaps = max(0, length(constraints) - 1) * gap - available = max(0, main_size - total_gaps) - - sizes = solve(constraints, available) - - # Convert sizes to rectangles - sizes_to_rects(sizes, area, direction, gap, cross_size) - end - - @doc """ - Solves horizontal layout (widths) with explicit cross-axis height. - - ## Parameters - - - `constraints` - width constraints - - `area` - bounding rectangle - - `opts` - options including `:gap` - - ## Returns - - List of rectangles positioned horizontally. - """ - @spec solve_horizontal([Constraint.t()], rect(), keyword()) :: [rect()] - def solve_horizontal(constraints, area, opts \\ []) do - solve_to_rects(constraints, area, Keyword.put(opts, :direction, :horizontal)) - end - - @doc """ - Solves vertical layout (heights) with explicit cross-axis width. - - ## Parameters - - - `constraints` - height constraints - - `area` - bounding rectangle - - `opts` - options including `:gap` - - ## Returns - - List of rectangles positioned vertically. - """ - @spec solve_vertical([Constraint.t()], rect(), keyword()) :: [rect()] - def solve_vertical(constraints, area, opts \\ []) do - solve_to_rects(constraints, area, Keyword.put(opts, :direction, :vertical)) - end - - # Fast paths for common cases - - defp try_fast_path([], _available), do: {:ok, []} - - defp try_fast_path(constraints, available) do - cond do - all_fixed?(constraints) -> - {:ok, solve_all_fixed(constraints, available)} - - single_fill?(constraints) -> - {:ok, solve_single_fill(constraints, available)} - - true -> - :general - end - end - - defp all_fixed?(constraints) do - Enum.all?(constraints, &Constraint.fixed?/1) - end - - defp single_fill?(constraints) do - fills = - Enum.count(constraints, fn c -> - case Constraint.unwrap(c) do - %Fill{} -> true - _ -> false - end - end) - - fills == 1 and - Enum.all?(constraints, fn c -> - inner = Constraint.unwrap(c) - match?(%Length{}, inner) or match?(%Fill{}, inner) - end) - end - - defp solve_all_fixed(constraints, available) do - sizes = Enum.map(constraints, fn c -> resolve_length(c) end) - total = Enum.sum(sizes) - - if total > available do - Logger.warning("Fixed constraints total #{total} exceeds available #{available}") - scale_proportionally(sizes, available) - else - sizes - end - end - - defp solve_single_fill(constraints, available) do - {sizes, fill_idx} = - constraints - |> Enum.with_index() - |> Enum.map_reduce(nil, fn {c, idx}, fill_idx -> - case Constraint.unwrap(c) do - %Fill{} -> - {{0, idx}, idx} - - %Length{value: v} -> - {{v, idx}, fill_idx} - end - end) - - fixed_total = sizes |> Enum.map(&elem(&1, 0)) |> Enum.sum() - fill_size = max(0, available - fixed_total) - - # Apply min/max bounds to fill - fill_constraint = Enum.at(constraints, fill_idx) - bounded_fill = apply_bounds(fill_constraint, fill_size) - - sizes - |> Enum.map(fn {size, idx} -> - if idx == fill_idx, do: bounded_fill, else: size - end) - end - - # General solving algorithm - - defp solve_general(constraints, available) do - indexed = Enum.with_index(constraints) - - # Pass 1: Allocate fixed sizes - {fixed_sizes, remaining1} = allocate_fixed(indexed, available) - - # Pass 2: Allocate percentages (from original available) - {percent_sizes, remaining2} = allocate_percentages(indexed, available, remaining1) - - # Pass 3: Allocate ratios and fills - {flex_sizes, _remaining3} = allocate_flexible(indexed, remaining2) - - # Merge results in original order - merge_sizes(indexed, fixed_sizes, percent_sizes, flex_sizes) - |> apply_all_bounds(constraints, available) - |> handle_overflow(available) - end - - defp allocate_fixed(indexed, available) do - fixed = - indexed - |> Enum.filter(fn {c, _idx} -> length?(c) end) - |> Enum.map(fn {c, idx} -> {idx, resolve_length(c)} end) - |> Map.new() - - used = fixed |> Map.values() |> Enum.sum() - {fixed, max(0, available - used)} - end - - defp allocate_percentages(indexed, total_available, remaining) do - percentages = - indexed - |> Enum.filter(fn {c, _idx} -> percentage?(c) end) - |> Enum.map(fn {c, idx} -> - inner = Constraint.unwrap(c) - size = round(total_available * inner.value / 100) - {idx, size} - end) - |> Map.new() - - used = percentages |> Map.values() |> Enum.sum() - {percentages, max(0, remaining - used)} - end - - defp allocate_flexible(indexed, remaining) do - flex_constraints = - indexed - |> Enum.filter(fn {c, _idx} -> flexible?(c) end) - - if flex_constraints == [] do - {%{}, remaining} - else - total_ratio = - flex_constraints - |> Enum.map(fn {c, _idx} -> get_ratio_value(c) end) - |> Enum.sum() - - flex_sizes = - flex_constraints - |> Enum.map(fn {c, idx} -> - ratio = get_ratio_value(c) - size = calculate_flex_size(ratio, remaining, total_ratio) - {idx, size} - end) - |> Map.new() - - used = flex_sizes |> Map.values() |> Enum.sum() - {flex_sizes, max(0, remaining - used)} - end - end - - defp merge_sizes(indexed, fixed, percentages, flexible) do - indexed - |> Enum.map(fn {_c, idx} -> - Map.get(fixed, idx) || Map.get(percentages, idx) || Map.get(flexible, idx) || 0 - end) - end - - defp apply_all_bounds(sizes, constraints, available) do - # First pass: apply bounds - bounded = - Enum.zip(sizes, constraints) - |> Enum.map(fn {size, constraint} -> - apply_bounds(constraint, size) - end) - - # Check if bounds caused overflow - total = Enum.sum(bounded) - - if total > available do - # Reduce non-min-bounded items proportionally - reduce_to_fit(bounded, constraints, available) - else - bounded - end - end - - defp reduce_to_fit(sizes, constraints, available) do - total = Enum.sum(sizes) - excess = total - available - - # Find reducible items (not at their min) - reducible = - Enum.zip(sizes, constraints) - |> Enum.with_index() - |> Enum.filter(fn {{size, constraint}, _idx} -> - min_val = Constraint.get_min(constraint) || 0 - size > min_val - end) - - do_reduce_to_fit(sizes, constraints, excess, total, available, reducible) - end - - defp do_reduce_to_fit(sizes, _constraints, _excess, total, available, []) do - # Nothing can be reduced, return as is with warning - Logger.warning( - "Cannot satisfy min constraints: total #{total} exceeds available #{available}" - ) - - sizes - end - - defp do_reduce_to_fit(sizes, constraints, excess, _total, _available, reducible) do - # Calculate how much each can be reduced - reducible_total = calculate_reducible_total(reducible) - - apply_reductions(sizes, constraints, excess, reducible_total) - end - - defp calculate_reducible_total(reducible) do - reducible - |> Enum.map(fn {{size, constraint}, _idx} -> - min_val = Constraint.get_min(constraint) || 0 - size - min_val - end) - |> Enum.sum() - end - - defp apply_reductions(sizes, _constraints, _excess, reducible_total) - when reducible_total <= 0 do - Logger.warning("Cannot reduce: all at minimum") - sizes - end - - defp apply_reductions(sizes, constraints, excess, reducible_total) do - sizes - |> Enum.with_index() - |> Enum.map(fn {size, idx} -> - constraint = Enum.at(constraints, idx) - reduce_size(size, constraint, excess, reducible_total) - end) - end - - defp handle_overflow(sizes, available) do - total = Enum.sum(sizes) - - if total > available do - Logger.warning("Constraint overflow: total #{total} exceeds available #{available}") - scale_proportionally(sizes, available) - else - sizes - end - end - - defp scale_proportionally(sizes, available) do - total = Enum.sum(sizes) - - if total == 0 do - sizes - else - Enum.map(sizes, fn size -> - round(size * available / total) - end) - end - end - - defp calculate_flex_size(_ratio, _remaining, 0), do: 0 - - defp calculate_flex_size(ratio, remaining, total_ratio) do - round(remaining * ratio / total_ratio) - end - - defp reduce_size(size, constraint, excess, reducible_total) do - min_val = Constraint.get_min(constraint) || 0 - reducible_amount = size - min_val - - if reducible_amount > 0 do - reduction = round(excess * reducible_amount / reducible_total) - max(min_val, size - reduction) - else - size - end - end - - # Helper functions - - defp length?(constraint) do - case Constraint.unwrap(constraint) do - %Length{} -> true - _ -> false - end - end - - defp percentage?(constraint) do - case Constraint.unwrap(constraint) do - %Percentage{} -> true - _ -> false - end - end - - defp flexible?(constraint) do - case Constraint.unwrap(constraint) do - %Ratio{} -> true - %Fill{} -> true - _ -> false - end - end - - defp resolve_length(constraint) do - case Constraint.unwrap(constraint) do - %Length{value: v} -> v - _ -> 0 - end - end - - defp get_ratio_value(constraint) do - case Constraint.unwrap(constraint) do - %Ratio{value: v} -> v - %Fill{} -> 1 - _ -> 0 - end - end - - defp apply_bounds(constraint, size) do - min_val = Constraint.get_min(constraint) - max_val = Constraint.get_max(constraint) - - size - |> then(fn s -> if min_val, do: max(min_val, s), else: s end) - |> then(fn s -> if max_val, do: min(max_val, s), else: s end) - end - - # Position calculation - - defp sizes_to_rects(sizes, area, direction, gap, cross_size) do - {start_main, start_cross} = - case direction do - :horizontal -> {area.x, area.y} - :vertical -> {area.y, area.x} - end - - {rects, _pos} = - sizes - |> Enum.map_reduce(start_main, fn size, pos -> - rect = - case direction do - :horizontal -> - %{x: pos, y: start_cross, width: size, height: cross_size} - - :vertical -> - %{x: start_cross, y: pos, width: cross_size, height: size} - end - - {rect, pos + size + gap} - end) - - rects - end -end diff --git a/lib/term_ui/markdown.ex b/lib/term_ui/markdown.ex index a14483f7..49d0063a 100644 --- a/lib/term_ui/markdown.ex +++ b/lib/term_ui/markdown.ex @@ -1,780 +1,313 @@ -if Code.ensure_loaded?(MDEx) and Code.ensure_loaded?(Makeup) and - Code.ensure_loaded?(Makeup.Lexers.ElixirLexer) do - defmodule TermUI.Markdown do - @moduledoc """ - Markdown processor for rendering styled text in TermUI. - - Converts markdown content to styled segments that can be rendered - by TermUI components. - - ## Usage - - iex> lines = TermUI.Markdown.render("**bold** and *italic*", 80) - - iex> result = TermUI.Markdown.render_with_elements("```elixir\\ndef hello, do: :world\\n```", 80) - """ - - alias TermUI.Component.RenderNode - alias TermUI.Renderer.Style - - @type styled_segment :: {String.t(), Style.t() | nil} - @type styled_line :: [styled_segment] - - @type interactive_element :: %{ - id: String.t(), - type: :code_block, - content: String.t(), - language: String.t() | nil, - start_line: non_neg_integer(), - end_line: non_neg_integer() - } - - @type render_result :: %{ - lines: [styled_line()], - elements: [interactive_element()], - content_height: non_neg_integer() - } - - @doc "Returns true when full Markdown rendering is available." - @spec available?() :: boolean() - def available?, do: true - - # Style definitions - @header1_style Style.new(fg: :cyan, attrs: [:bold]) - @header2_style Style.new(fg: :cyan, attrs: [:bold]) - @header3_style Style.new(fg: :white, attrs: [:bold]) - @bold_style Style.new(attrs: [:bold]) - @italic_style Style.new(attrs: [:italic]) - @code_style Style.new(fg: :yellow) - @code_block_style Style.new(fg: :yellow) - @code_border_style Style.new(fg: :bright_black) - @code_border_focused_style Style.new(fg: :cyan, attrs: [:bold]) - @blockquote_style Style.new(fg: :bright_black) - @link_style Style.new(fg: :blue, attrs: [:underline]) - @list_bullet_style Style.new(fg: :cyan) - @hr_style Style.new(fg: :bright_black) - - # Dialyzer: Pattern match coverage warnings - @dialyzer {:nowarn_function, - render: 2, - render_with_elements: 3, - render_line_to_node: 1, - process_document: 1, - process_document_with_elements: 2} - - # Syntax highlighting token styles - @token_styles %{ - keyword: Style.new(fg: :magenta, attrs: [:bold]), - keyword_namespace: Style.new(fg: :magenta, attrs: [:bold]), - keyword_pseudo: Style.new(fg: :magenta, attrs: [:bold]), - keyword_reserved: Style.new(fg: :magenta, attrs: [:bold]), - keyword_constant: Style.new(fg: :magenta, attrs: [:bold]), - keyword_declaration: Style.new(fg: :magenta, attrs: [:bold]), - keyword_type: Style.new(fg: :magenta, attrs: [:bold]), - string: Style.new(fg: :green), - string_char: Style.new(fg: :green), - string_doc: Style.new(fg: :green), - string_double: Style.new(fg: :green), - string_single: Style.new(fg: :green), - string_sigil: Style.new(fg: :green), - string_regex: Style.new(fg: :green), - string_interpol: Style.new(fg: :red), - string_escape: Style.new(fg: :cyan), - string_symbol: Style.new(fg: :cyan), - comment: Style.new(fg: :bright_black), - comment_single: Style.new(fg: :bright_black), - comment_multiline: Style.new(fg: :bright_black), - comment_doc: Style.new(fg: :bright_black), - atom: Style.new(fg: :cyan), - number: Style.new(fg: :yellow), - number_integer: Style.new(fg: :yellow), - number_float: Style.new(fg: :yellow), - number_bin: Style.new(fg: :yellow), - number_oct: Style.new(fg: :yellow), - number_hex: Style.new(fg: :yellow), - operator: Style.new(fg: :yellow), - operator_word: Style.new(fg: :magenta, attrs: [:bold]), - name: Style.new(fg: :white), - name_function: Style.new(fg: :blue), - name_class: Style.new(fg: :yellow, attrs: [:bold]), - name_builtin: Style.new(fg: :cyan), - name_builtin_pseudo: Style.new(fg: :cyan), - name_attribute: Style.new(fg: :cyan), - name_label: Style.new(fg: :cyan), - name_constant: Style.new(fg: :yellow, attrs: [:bold]), - name_exception: Style.new(fg: :red), - name_tag: Style.new(fg: :blue), - name_decorator: Style.new(fg: :cyan), - name_namespace: Style.new(fg: :yellow, attrs: [:bold]), - punctuation: Style.new(fg: :white), - whitespace: nil, - text: nil - } - - @supported_lexers %{ - "elixir" => Makeup.Lexers.ElixirLexer, - "ex" => Makeup.Lexers.ElixirLexer, - "exs" => Makeup.Lexers.ElixirLexer, - "iex" => Makeup.Lexers.ElixirLexer, - "erlang" => Makeup.Lexers.ErlangLexer, - "erl" => Makeup.Lexers.ErlangLexer, - "hrl" => Makeup.Lexers.ErlangLexer - } +defmodule TermUI.Markdown do + @moduledoc """ + Converts MDEx Markdown documents to styled terminal rows. + + The renderer supports headings, emphasis, strong text, strike-through text, + inline code, links, images, quotes, lists, task lists, fenced code blocks, + rules, and tables. Raw HTML is shown as plain text and never becomes terminal + control data. + """ + + alias TermUI.{DisplayWidth, Frame, Style} + alias TermUI.Widget.Helpers + + @dialyzer {:nowarn_function, inline_node: 2} + + @extensions [table: true, strikethrough: true, tasklist: true, autolink: true] + @plain Style.new() + @heading1 Style.new(fg: :cyan, attrs: [:bold, :underline]) + @heading2 Style.new(fg: :cyan, attrs: [:bold]) + @heading Style.new(attrs: [:bold]) + @strong Style.new(attrs: [:bold]) + @emphasis Style.new(attrs: [:italic]) + @strike Style.new(attrs: [:strikethrough]) + @code Style.new(fg: :yellow) + @code_border Style.new(fg: :bright_black) + @quote Style.new(fg: :bright_black) + @link Style.new(fg: :blue, attrs: [:underline]) + @bullet Style.new(fg: :cyan) + @rule Style.new(fg: :bright_black) + @table_header Style.new(fg: :cyan, attrs: [:bold]) + + @type styled_line :: [Frame.span()] + @type element :: %{ + id: String.t(), + type: :code_block, + content: String.t(), + language: String.t() | nil, + start_line: non_neg_integer(), + end_line: non_neg_integer() + } + @type result :: %{ + lines: [styled_line()], + elements: [element()], + content_height: non_neg_integer() + } + + @doc "Returns true because MDEx is a required dependency." + @spec available?() :: true + def available?, do: true + + @doc "Parses Markdown with the supported CommonMark extensions." + @spec parse(String.t()) :: {:ok, MDEx.Document.t()} | {:error, term()} + def parse(markdown) when is_binary(markdown), + do: MDEx.parse_document(markdown, extension: @extensions) + + @doc "Renders Markdown to styled terminal rows." + @spec render(String.t(), pos_integer(), keyword()) :: [styled_line()] + def render(markdown, width, opts \\ []) do + render_with_elements(markdown, width, opts).lines + end - @doc """ - Renders markdown content as a list of styled lines. - """ - @spec render(String.t(), pos_integer()) :: [styled_line()] - def render("", _max_width), do: [[{"", nil}]] - def render(nil, _max_width), do: [[{"", nil}]] - - def render(content, max_width) when is_binary(content) and max_width > 0 do - case MDEx.parse_document(content) do - {:ok, document} -> - document - |> process_document() - |> wrap_styled_lines(max_width) - - {:error, _reason} -> - content - |> String.split("\n") - |> Enum.map(fn line -> [{line, nil}] end) - |> wrap_styled_lines(max_width) - end - end + @doc "Renders Markdown and returns code-block metadata." + @spec render_with_elements(String.t(), pos_integer(), keyword()) :: result() + def render_with_elements(markdown, width, opts \\ []) when is_binary(markdown) and width > 0 do + focused_id = Keyword.get(opts, :focused_element_id) - def render(content, _max_width) when is_binary(content), do: render(content, 80) + case parse(markdown) do + {:ok, %MDEx.Document{nodes: nodes}} -> + {lines, elements} = render_nodes(nodes, width, focused_id) + lines = if lines == [], do: [[""]], else: trim_blank_tail(lines) + %{lines: lines, elements: elements, content_height: length(lines)} - @doc """ - Renders markdown content with interactive element tracking. - """ - @spec render_with_elements(String.t(), pos_integer(), keyword()) :: render_result() - def render_with_elements("", _max_width, _opts) do - %{lines: [[{"", nil}]], elements: [], content_height: 1} - end + {:error, _reason} -> + lines = + markdown |> String.split("\n", trim: false) |> Enum.flat_map(&wrap_spans([&1], width)) - def render_with_elements(nil, _max_width, _opts) do - %{lines: [[{"", nil}]], elements: [], content_height: 1} + %{lines: lines, elements: [], content_height: length(lines)} end + end - def render_with_elements(content, max_width, opts) - when is_binary(content) and max_width > 0 do - focused_id = Keyword.get(opts, :focused_element_id) + @doc "Returns code blocks in source order without rendering the document." + @spec code_blocks(String.t()) :: [element()] + def code_blocks(markdown) do + render_with_elements(markdown, 80).elements + end - case MDEx.parse_document(content) do - {:ok, document} -> - {raw_lines, elements} = process_document_with_elements(document, focused_id) - wrapped_lines = wrap_styled_lines(raw_lines, max_width) - %{lines: wrapped_lines, elements: elements, content_height: length(wrapped_lines)} + defp render_nodes(nodes, width, focused_id) do + {lines, elements, _index} = + Enum.reduce(nodes, {[], [], 0}, fn node, {lines, elements, line_index} -> + {node_lines, node_elements} = render_block(node, width, focused_id, line_index) + separator = if lines == [] or node_lines == [], do: [], else: [[""]] + start_shift = length(separator) + node_elements = Enum.map(node_elements, &shift_element(&1, start_shift)) + next_lines = lines ++ separator ++ node_lines + {next_lines, elements ++ node_elements, length(next_lines)} + end) - {:error, _reason} -> - lines = - content - |> String.split("\n") - |> Enum.map(fn line -> [{line, nil}] end) - |> wrap_styled_lines(max_width) + {lines, elements} + end - %{lines: lines, elements: [], content_height: length(lines)} + defp render_block(%MDEx.Heading{nodes: nodes, level: level}, width, _focused, _index) do + style = + case level do + 1 -> @heading1 + 2 -> @heading2 + _ -> @heading end - end - - def render_with_elements(content, _max_width, opts) when is_binary(content) do - render_with_elements(content, 80, opts) - end - - @doc """ - Converts a styled line to a TermUI render node. - """ - @spec render_line_to_node(styled_line()) :: RenderNode.t() - def render_line_to_node([]), do: RenderNode.text("", nil) - def render_line_to_node([{text, style}]) do - RenderNode.text(text, style) - end - - def render_line_to_node(segments) when is_list(segments) do - nodes = - Enum.map(segments, fn {text, style} -> - RenderNode.text(text, style) - end) - - RenderNode.stack(:horizontal, nodes) - end - - # Document Processing - defp process_document(%MDEx.Document{nodes: nodes}) do - Enum.flat_map(nodes, &process_node/1) - end - - defp process_document(_), do: [[{"", nil}]] - - defp process_document_with_elements(%MDEx.Document{nodes: nodes}, focused_id) do - {lines, elements, _line_idx} = - Enum.reduce(nodes, {[], [], 0}, fn node, {acc_lines, acc_elements, line_idx} -> - {node_lines, node_elements} = process_node_with_elements(node, line_idx, focused_id) - new_line_idx = line_idx + length(node_lines) - {acc_lines ++ node_lines, acc_elements ++ node_elements, new_line_idx} - end) - - {lines, elements} - end - - defp process_document_with_elements(_, _focused_id), do: {[[{"", nil}]], []} - - defp process_node_with_elements( - %MDEx.CodeBlock{literal: code, info: info}, - line_idx, - focused_id - ) do - lang = if info && info != "", do: String.downcase(String.trim(info)), else: nil - element_id = generate_element_id(code, line_idx) - is_focused = element_id == focused_id - border_style = if is_focused, do: @code_border_focused_style, else: @code_border_style - - header = - if lang do - focus_hint = if is_focused, do: " [c]", else: "" - - [ - [ - {"┌─ " <> lang <> focus_hint <> " ", @code_block_style}, - {String.duplicate("─", 40 - String.length(focus_hint)), border_style} - ] - ] - else - focus_hint = if is_focused, do: " [c]", else: "" - - [ - [ - {"┌" <> focus_hint, @code_block_style}, - {String.duplicate("─", 44 - String.length(focus_hint)), border_style} - ] - ] - end - - code_lines = render_code_block(code, lang) - - footer = [ - [{"└", @code_block_style}, {String.duplicate("─", 44), border_style}], - [{"", nil}] - ] - - lines = header ++ code_lines ++ footer - - element = %{ - id: element_id, - type: :code_block, - content: String.trim_trailing(code), - language: lang, - start_line: line_idx, - end_line: line_idx + length(lines) - 1 - } - - {lines, [element]} - end - - defp process_node_with_elements(node, _line_idx, _focused_id) do - lines = process_node(node) - {lines, []} - end - - defp generate_element_id(content, line_idx) do - :crypto.hash(:md5, "#{line_idx}:#{content}") - |> Base.encode16(case: :lower) - |> String.slice(0, 16) - end - - # Node Processing - defp process_node(%MDEx.Heading{level: 1, nodes: children}) do - content = extract_text(children) - [[{content, @header1_style}], [{"", nil}]] - end - - defp process_node(%MDEx.Heading{level: 2, nodes: children}) do - content = extract_text(children) - [[{content, @header2_style}], [{"", nil}]] - end + {wrap_spans(inline(nodes, style), width), []} + end - defp process_node(%MDEx.Heading{level: level, nodes: children}) when level >= 3 do - content = extract_text(children) - [[{content, @header3_style}], [{"", nil}]] - end + defp render_block(%MDEx.Paragraph{nodes: nodes}, width, _focused, _index), + do: {wrap_spans(inline(nodes, @plain), width), []} - defp process_node(%MDEx.Paragraph{nodes: children}) do - segments = process_inline_nodes(children) - [segments, [{"", nil}]] - end + defp render_block(%MDEx.BlockQuote{nodes: nodes}, width, focused, line_index) do + {lines, elements} = + render_nodes_without_spacing(nodes, max(width - 2, 1), focused, line_index) - defp process_node(%MDEx.CodeBlock{literal: code, info: info}) do - lang = if info && info != "", do: String.downcase(String.trim(info)), else: nil - - header = - if lang do - [ - [ - {"┌─ " <> lang <> " ", @code_block_style}, - {String.duplicate("─", 40), @code_border_style} - ] - ] - else - [[{"┌", @code_block_style}, {String.duplicate("─", 44), @code_border_style}]] - end - - code_lines = render_code_block(code, lang) - - footer = [ - [{"└", @code_block_style}, {String.duplicate("─", 44), @code_border_style}], - [{"", nil}] - ] - - header ++ code_lines ++ footer - end + quoted = Enum.map(lines, fn line -> [{"│ ", @quote} | line] end) + {quoted, elements} + end - defp process_node(%MDEx.Code{literal: code}) do - [[{"`" <> code <> "`", @code_style}]] - end + defp render_block(%MDEx.List{} = list, width, focused, line_index) do + {lines, elements, _number} = + Enum.reduce(list.nodes, {[], [], list.start || 1}, fn item, {lines, elements, number} -> + marker = list_marker(list, item, number) - defp process_node(%MDEx.BlockQuote{nodes: children}) do - children - |> Enum.flat_map(&process_node/1) - |> Enum.map(fn segments -> - case segments do - [{text, _style} | rest] -> - [{"│ " <> text, @blockquote_style} | rest] - - [] -> - [{"│ ", @blockquote_style}] - end - end) - end + {item_lines, item_elements} = + render_list_item( + item, + max(width - DisplayWidth.width(marker), 1), + focused, + line_index + length(lines) + ) - defp process_node(%MDEx.List{list_type: :bullet, nodes: items}) do - items - |> Enum.flat_map(fn item -> - process_list_item(item, "• ") - end) - |> Kernel.++([[{"", nil}]]) - end + item_lines = Enum.with_index(item_lines, &prefix_list_line(&1, &2, marker)) - defp process_node(%MDEx.List{list_type: :ordered, nodes: items, start: start}) do - items - |> Enum.with_index(start || 1) - |> Enum.flat_map(fn {item, idx} -> - process_list_item(item, "#{idx}. ") + {lines ++ item_lines, elements ++ item_elements, number + 1} end) - |> Kernel.++([[{"", nil}]]) - end - - defp process_node(%MDEx.ListItem{nodes: children}) do - Enum.flat_map(children, &process_node/1) - end - - defp process_node(%MDEx.ThematicBreak{}) do - [[{"───────────────────────────────────────", @hr_style}], [{"", nil}]] - end - - defp process_node(%MDEx.SoftBreak{}), do: [] - defp process_node(%MDEx.LineBreak{}), do: [[{"", nil}]] - - defp process_node(node) when is_map(node) do - case Map.get(node, :nodes) do - nil -> - case Map.get(node, :literal) do - nil -> [] - text -> [[{text, nil}]] - end - children -> - Enum.flat_map(children, &process_node/1) - end - end + {lines, elements} + end - defp process_node(_), do: [] + defp render_block(%MDEx.CodeBlock{literal: code, info: info}, width, focused_id, line_index) do + language = info |> to_string() |> String.trim() |> empty_to_nil() + id = "code-" <> Integer.to_string(:erlang.phash2({code, line_index})) + focused? = id == focused_id + border_style = if focused?, do: Style.new(fg: :cyan, attrs: [:bold]), else: @code_border - # Code Block Rendering - defp render_code_block(code, lang) do - case Map.get(@supported_lexers, lang) do - nil -> - plain_code_lines(code) + label = + if language, + do: "─ " <> language <> if(focused?, do: " [selected] ", else: " "), + else: if(focused?, do: "─ [selected] ", else: "─") - lexer -> - try do - highlighted_code_lines(code, lexer) - rescue - _ -> plain_code_lines(code) - end - end - end + top = [[{"┌" <> Frame.fit(label, max(width - 1, 0)), border_style}]] - defp plain_code_lines(code) do + body = code - |> String.trim_trailing() - |> String.split("\n") - |> Enum.map(fn line -> [{"│ " <> line, @code_block_style}] end) - end - - defp highlighted_code_lines(code, lexer) do - tokens = lexer.lex(code |> String.trim_trailing()) - - {lines, current_line} = - Enum.reduce(tokens, {[], []}, fn {type, _meta, text}, {lines, current} -> - style = Map.get(@token_styles, type) || @code_block_style - text_str = normalize_token_text(text) - add_token_to_lines(text_str, style, lines, current) - end) - - all_lines = finalize_code_lines(lines, current_line) - - Enum.map(all_lines, fn segments -> - [{"│ ", @code_block_style} | segments] - end) - end - - defp add_token_to_lines(text, style, lines, current) do - parts = String.split(text, "\n") + |> String.trim_trailing("\n") + |> String.split("\n", trim: false) + |> Enum.flat_map(fn line -> wrap_spans([{"│ ", border_style}, {line, @code}], width) end) + + bottom = [[{"└" <> String.duplicate("─", max(width - 1, 0)), border_style}]] + lines = top ++ body ++ bottom + + element = %{ + id: id, + type: :code_block, + content: code, + language: language, + start_line: line_index, + end_line: line_index + length(lines) - 1 + } - case parts do - [single] -> - {lines, current ++ [{single, style}]} + {lines, [element]} + end - [first | rest] -> - finished_line = current ++ [{first, style}] - {middle_parts, [last]} = Enum.split(rest, -1) - middle_lines = Enum.map(middle_parts, fn part -> [{part, style}] end) - {lines ++ [finished_line] ++ middle_lines, [{last, style}]} - end - end + defp render_block(%MDEx.ThematicBreak{}, width, _focused, _index), + do: {[[{String.duplicate("─", width), @rule}]], []} - defp finalize_code_lines(lines, []), do: lines - defp finalize_code_lines(lines, current), do: lines ++ [current] + defp render_block(%MDEx.Table{nodes: rows, alignments: alignments}, width, _focused, _index) do + column_count = rows |> List.first(%{nodes: []}) |> Map.get(:nodes, []) |> length() |> max(1) + column_width = max(div(max(width - column_count - 1, column_count), column_count), 1) - defp normalize_token_text(text) when is_binary(text), do: text + rendered = + Enum.map(rows, fn %MDEx.TableRow{nodes: cells, header: header?} -> + style = if header?, do: @table_header, else: @plain - defp normalize_token_text(text) when is_list(text) do - text - |> List.flatten() - |> Enum.map_join(fn - char when is_integer(char) -> <> - str when is_binary(str) -> str + cells + |> Enum.with_index() + |> Enum.flat_map(fn {%MDEx.TableCell{nodes: nodes}, index} -> + alignment = Enum.at(alignments, index, :left) |> normalize_alignment() + text = nodes |> inline(@plain) |> plain_text() + [{"│", @rule}, {Helpers.align(text, column_width, alignment), style}] + end) + |> Kernel.++([{"│", @rule}]) end) - end - - defp normalize_token_text(text), do: to_string(text) - - # Inline Node Processing - defp process_inline_nodes(nodes) when is_list(nodes) do - nodes - |> Enum.flat_map(&process_inline_node/1) - |> merge_adjacent_segments() - end - - defp process_inline_node(%MDEx.Text{literal: text}), do: [{text, nil}] - defp process_inline_node(%MDEx.Strong{nodes: children}) do - text = extract_text(children) - [{text, @bold_style}] - end - - defp process_inline_node(%MDEx.Emph{nodes: children}) do - text = extract_text(children) - [{text, @italic_style}] - end - - defp process_inline_node(%MDEx.Code{literal: code}) do - [{"`" <> code <> "`", @code_style}] - end + {rendered, []} + end - defp process_inline_node(%MDEx.Link{url: url, nodes: children}) do - text = extract_text(children) + defp render_block(%{literal: literal}, width, _focused, _index) when is_binary(literal) do + text = Regex.replace(~r/<[^>]*>/u, literal, "") + {text |> String.split("\n", trim: false) |> Enum.flat_map(&wrap_spans([&1], width)), []} + end - if text == url do - [{text, @link_style}] - else - [{text, @link_style}, {" (#{url})", Style.new(fg: :bright_black)}] - end - end + defp render_block(%{nodes: nodes}, width, focused, line_index) when is_list(nodes), + do: render_nodes_without_spacing(nodes, width, focused, line_index) - defp process_inline_node(%MDEx.SoftBreak{}), do: [{" ", nil}] - defp process_inline_node(%MDEx.LineBreak{}), do: [{"\n", nil}] + defp render_block(_node, _width, _focused, _index), do: {[], []} - defp process_inline_node(node) when is_map(node) do - case Map.get(node, :literal) do - nil -> - case Map.get(node, :nodes) do - nil -> [] - children -> process_inline_nodes(children) - end + defp prefix_list_line(line, 0, marker), do: [{marker, @bullet} | line] - text -> - [{text, nil}] - end - end + defp prefix_list_line(line, _index, marker), + do: [{String.duplicate(" ", String.length(marker)), @bullet} | line] - defp process_inline_node(_), do: [] + defp render_list_item(%{nodes: nodes}, width, focused, line_index), + do: render_nodes_without_spacing(nodes, width, focused, line_index) - # List Processing - defp process_list_item(%MDEx.ListItem{nodes: children}, prefix) do - children - |> Enum.flat_map(&process_node/1) - |> Enum.with_index() - |> Enum.map(fn {segments, idx} -> - process_list_line(segments, idx, prefix) - end) - |> Enum.reject(fn segments -> - segments == [{"", nil}] + defp render_nodes_without_spacing(nodes, width, focused, line_index) do + {lines, elements, _index} = + Enum.reduce(nodes, {[], [], line_index}, fn node, {lines, elements, index} -> + {node_lines, node_elements} = render_block(node, width, focused, index) + {lines ++ node_lines, elements ++ node_elements, index + length(node_lines)} end) - end - - defp process_list_line(segments, 0, prefix) do - case segments do - [{text, style} | rest] -> - [{prefix, @list_bullet_style}, {text, style} | rest] - - [] -> - [{prefix, @list_bullet_style}] - end - end - - defp process_list_line(segments, _idx, prefix) do - indent = String.duplicate(" ", String.length(prefix)) - case segments do - [{text, style} | rest] -> - [{indent <> text, style} | rest] - - [] -> - segments - end - end - - # Text Extraction - defp extract_text(nodes) when is_list(nodes) do - Enum.map_join(nodes, &extract_text/1) - end - - defp extract_text(%{literal: text}) when is_binary(text), do: text - defp extract_text(%{nodes: children}), do: extract_text(children) - defp extract_text(_), do: "" + {lines, elements} + end - # Segment Merging - defp merge_adjacent_segments([]), do: [] + defp inline(nodes, style), do: Enum.flat_map(nodes, &inline_node(&1, style)) + defp inline_node(%MDEx.Text{literal: literal}, style), do: [{literal, style}] - defp merge_adjacent_segments(segments) do - segments - |> Enum.reduce([], fn {text, style}, acc -> - case acc do - [{prev_text, ^style} | rest] -> - [{prev_text <> text, style} | rest] + defp inline_node(%MDEx.Code{literal: literal}, style), + do: [{literal, Style.merge(style, @code)}] - _ -> - [{text, style} | acc] - end - end) - |> Enum.reverse() - end + defp inline_node(%MDEx.Strong{nodes: nodes}, style), + do: inline(nodes, Style.merge(style, @strong)) - # Line Wrapping - @spec wrap_styled_lines([styled_line()], pos_integer()) :: [styled_line()] - def wrap_styled_lines(lines, max_width) do - lines - |> Enum.flat_map(fn line -> - wrap_styled_line(line, max_width) - end) - end + defp inline_node(%MDEx.Emph{nodes: nodes}, style), + do: inline(nodes, Style.merge(style, @emphasis)) - defp wrap_styled_line([], _max_width), do: [[]] + defp inline_node(%MDEx.Strikethrough{nodes: nodes}, style), + do: inline(nodes, Style.merge(style, @strike)) - defp wrap_styled_line(segments, max_width) do - expanded_segments = expand_newlines_in_segments(segments) + defp inline_node(%MDEx.Link{nodes: nodes}, style), do: inline(nodes, Style.merge(style, @link)) - {current, wrapped} = - Enum.reduce(expanded_segments, {[], []}, fn - :newline, {current, acc} -> - {[], acc ++ [Enum.reverse(current)]} + defp inline_node(%MDEx.Image{nodes: nodes}, style), + do: [{"[image: " <> plain_text(inline(nodes, style)) <> "]", Style.merge(style, @link)}] - segment, {current, acc} -> - {[segment | current], acc} - end) + defp inline_node(%{nodes: nodes}, style) when is_list(nodes), do: inline(nodes, style) + defp inline_node(%{literal: literal}, style) when is_binary(literal), do: [{literal, style}] + defp inline_node(_node, _style), do: [] - lines_from_newlines = wrapped ++ [Enum.reverse(current)] + defp wrap_spans(spans, width) do + {lines, current, _used} = + Enum.reduce(spans, {[], [], 0}, fn span, acc -> add_span(span, acc, width) end) - lines_from_newlines - |> Enum.flat_map(fn line_segments -> - wrap_segments_for_width(line_segments, max_width) - end) - end - - defp expand_newlines_in_segments(segments) do - Enum.flat_map(segments, fn {text, style} -> - expand_segment_newlines(text, style) - end) - end - - defp expand_segment_newlines(text, style) do - if String.contains?(text, "\n") do - text - |> String.split("\n") - |> Enum.intersperse(:newline) - |> Enum.map(fn - :newline -> :newline - t -> {t, style} - end) - else - [{text, style}] - end - end - - defp wrap_segments_for_width([], _max_width), do: [[]] + Enum.reverse([Enum.reverse(current) | lines]) + end - defp wrap_segments_for_width(segments, max_width) do - {lines, current_line, _current_width} = - Enum.reduce(segments, {[], [], 0}, fn {text, style}, {lines, current, width} -> - wrap_segment({text, style}, lines, current, width, max_width) - end) + defp add_span({text, %Style{} = style}, acc, width), + do: add_graphemes(IO.iodata_to_binary(text), style, acc, width) - all_lines = lines ++ [current_line] + defp add_span(text, acc, width), + do: add_graphemes(IO.iodata_to_binary(text), @plain, acc, width) - all_lines - |> Enum.map(fn line -> - case line do - [] -> [{"", nil}] - segments -> segments - end - end) - end + defp add_graphemes(text, style, acc, width) do + text + |> String.graphemes() + |> Enum.reduce(acc, fn + "\n", {lines, current, _used} -> + {[Enum.reverse(current) | lines], [], 0} - defp wrap_segment({text, style}, lines, current, width, max_width) do - text_len = String.length(text) + grapheme, {lines, current, used} -> + grapheme_width = max(DisplayWidth.width(grapheme), 0) - cond do - text == "" -> - {lines, current ++ [{text, style}], width} + if current != [] and used + grapheme_width > width, + do: {[Enum.reverse(current) | lines], [{grapheme, style}], grapheme_width}, + else: {lines, merge_grapheme(current, grapheme, style), used + grapheme_width} + end) + end - width + text_len <= max_width -> - {lines, current ++ [{text, style}], width + text_len} + defp merge_grapheme([{text, style} | rest], grapheme, style), + do: [{text <> grapheme, style} | rest] - true -> - wrap_text_at_words(text, style, lines, current, width, max_width) - end - end + defp merge_grapheme(current, grapheme, style), do: [{grapheme, style} | current] - defp wrap_text_at_words(text, style, lines, current, width, max_width) do - words = String.split(text, ~r/(\s+)/, include_captures: true) + defp list_marker(%MDEx.List{list_type: :ordered}, _item, number), do: "#{number}. " + defp list_marker(_list, %MDEx.TaskItem{checked: true}, _number), do: "[x] " + defp list_marker(_list, %MDEx.TaskItem{}, _number), do: "[ ] " + defp list_marker(_list, _item, _number), do: "• " - Enum.reduce(words, {lines, current, width}, fn word, acc -> - handle_wrap_word(word, style, acc, max_width) + defp plain_text(spans), + do: + Enum.map_join(spans, fn + {text, _style} -> IO.iodata_to_binary(text) + text -> IO.iodata_to_binary(text) end) - end - - defp handle_wrap_word("", _style, acc, _max_width), do: acc - - defp handle_wrap_word(word, style, {ls, cur, w}, max_width) do - word_len = String.length(word) - - cond do - w + word_len <= max_width -> - {ls, cur ++ [{word, style}], w + word_len} - - word_len > max_width -> - handle_long_word(word, style, ls, cur, w, max_width) - - String.trim(word) == "" -> - {ls, cur, w} - - true -> - {ls ++ [cur], [{word, style}], word_len} - end - end - - defp handle_long_word(word, style, ls, cur, w, max_width) do - {new_lines, remainder} = break_long_word(word, style, max_width - w, max_width) - - if cur == [] do - {ls ++ new_lines, [{remainder, style}], String.length(remainder)} - else - {ls ++ [cur] ++ new_lines, [{remainder, style}], String.length(remainder)} - end - end - - defp break_long_word(word, style, first_chunk_size, max_width) do - first_chunk_size = max(first_chunk_size, 1) - - chunks = - word - |> String.graphemes() - |> Enum.chunk_every(max_width) - |> Enum.map(&Enum.join/1) - - case chunks do - [] -> - {[], ""} - [only] -> - {[], only} + defp normalize_alignment(:center), do: :center + defp normalize_alignment(:right), do: :right + defp normalize_alignment(_alignment), do: :left + defp empty_to_nil(""), do: nil + defp empty_to_nil(text), do: text - [first | rest] -> - first_part = String.slice(first, 0, first_chunk_size) - remainder_of_first = String.slice(first, first_chunk_size..-1//1) + defp trim_blank_tail(lines), + do: Enum.reverse(Enum.drop_while(Enum.reverse(lines), &(&1 in [[], [""]]))) - all_parts = [remainder_of_first | rest] + defp shift_element(element, 0), do: element - lines = - all_parts - |> Enum.slice(0..-2//1) - |> Enum.map(fn part -> [{part, style}] end) - - last = List.last(all_parts) || "" - - if first_part == "" do - {lines, last} - else - {[[{first_part, style}]] ++ lines, last} - end - end - end - end -else - defmodule TermUI.Markdown do - @moduledoc """ - Markdown support for TermUI. - - Add `:mdex`, `:makeup`, and `:makeup_elixir` to the host application to - enable this optional feature. - """ - - alias TermUI.Component.RenderNode - - @doc "Returns false when TermUI uses its plain-text fallback." - @spec available?() :: boolean() - def available?, do: false - - @doc "Renders content as plain text when optional Markdown dependencies are absent." - @spec render(String.t() | nil, pos_integer()) :: [[{String.t(), nil}]] - def render(content, _max_width) do - content - |> to_string() - |> String.split("\n") - |> Enum.map(&[{&1, nil}]) - end - - @doc "Renders content as plain text without interactive Markdown elements." - @spec render_with_elements(String.t() | nil, pos_integer(), keyword()) :: map() - def render_with_elements(content, max_width, _opts) do - lines = render(content, max_width) - %{lines: lines, elements: [], content_height: length(lines)} - end - - @doc "Converts a plain-text fallback line to a render node." - @spec render_line_to_node(list()) :: RenderNode.t() - def render_line_to_node([]), do: RenderNode.text("") - - def render_line_to_node([{text, style}]) do - RenderNode.text(text, style) - end - - def render_line_to_node(segments) do - nodes = Enum.map(segments, fn {text, style} -> RenderNode.text(text, style) end) - RenderNode.stack(:horizontal, nodes) - end - end + defp shift_element(element, shift), + do: %{element | start_line: element.start_line + shift, end_line: element.end_line + shift} end diff --git a/lib/term_ui/message.ex b/lib/term_ui/message.ex deleted file mode 100644 index b7460d1c..00000000 --- a/lib/term_ui/message.ex +++ /dev/null @@ -1,141 +0,0 @@ -defmodule TermUI.Message do - @moduledoc """ - Message type conventions and helpers for component messages. - - Messages are component-specific types representing meaningful actions. - They carry semantic meaning—`{:select_item, 3}` is clearer than the raw - key event that triggered it. - - ## Message Conventions - - Components define their own message types using one of these patterns: - - ### Simple Atom Messages - - :increment - :decrement - :submit - :cancel - - ### Tuple Messages with Data - - {:select_item, 3} - {:update_text, "hello"} - {:set_value, 42} - - ### Struct Messages (for complex data) - - defmodule MyComponent.Msg do - defmodule SelectItem do - defstruct [:index, :source] - end - end - - %MyComponent.Msg.SelectItem{index: 3, source: :keyboard} - - ## Event to Message Conversion - - Components implement `event_to_msg/2` to convert events to messages: - - def event_to_msg(%Event.Key{key: :enter}, _state) do - {:msg, :submit} - end - - def event_to_msg(%Event.Key{key: :up}, _state) do - {:msg, {:move, :up}} - end - - def event_to_msg(_event, _state) do - :ignore - end - - ## Message Routing - - Messages route to the component that should handle them. The runtime - delivers messages and components update their state in response. - """ - - @type t :: atom() | tuple() | struct() - - @doc """ - Checks if a value is a valid message. - - Messages can be atoms, tuples, or structs. - """ - @spec valid?(term()) :: boolean() - def valid?(msg) when is_atom(msg) and not is_nil(msg), do: true - def valid?(msg) when is_tuple(msg) and tuple_size(msg) >= 1, do: true - def valid?(%{__struct__: _}), do: true - def valid?(_), do: false - - @doc """ - Returns the message type/name. - - For atoms, returns the atom itself. - For tuples, returns the first element. - For structs, returns the struct module name. - """ - @spec name(t()) :: atom() - def name(msg) when is_atom(msg), do: msg - def name(msg) when is_tuple(msg), do: elem(msg, 0) - def name(%{__struct__: module}), do: module - - @doc """ - Returns the message payload. - - For atoms, returns nil. - For tuples with 2 elements, returns the second element. - For tuples with more elements, returns a list of remaining elements. - For structs, returns the struct itself. - """ - @spec payload(t()) :: term() - def payload(msg) when is_atom(msg), do: nil - def payload(msg) when is_tuple(msg) and tuple_size(msg) == 1, do: nil - def payload(msg) when is_tuple(msg) and tuple_size(msg) == 2, do: elem(msg, 1) - def payload(msg) when is_tuple(msg), do: Tuple.to_list(msg) |> tl() - def payload(%{__struct__: _} = msg), do: msg - - @doc """ - Creates a wrapped message result from event_to_msg. - - Returns `{:msg, message}` to indicate the event was converted. - """ - @spec wrap(t()) :: {:msg, t()} - def wrap(msg), do: {:msg, msg} - - @doc """ - Checks if a value is an atom message. - """ - @spec atom?(term()) :: boolean() - def atom?(msg) when is_atom(msg) and not is_nil(msg), do: true - def atom?(_), do: false - - @doc """ - Checks if a value is a tuple message. - """ - @spec tuple?(term()) :: boolean() - def tuple?(msg) when is_tuple(msg) and tuple_size(msg) >= 1, do: true - def tuple?(_), do: false - - @doc """ - Checks if a value is a struct message. - """ - @spec struct?(term()) :: boolean() - def struct?(%{__struct__: _}), do: true - def struct?(_), do: false - - @doc """ - Matches a message against a pattern. - - ## Examples - - Message.match?(:submit, :submit) # true - Message.match?({:select, 3}, :select) # true - Message.match?(%Msg.SelectItem{index: 3}, Msg.SelectItem) # true - """ - @spec match?(t(), atom()) :: boolean() - def match?(msg, pattern) when is_atom(msg), do: msg == pattern - def match?(msg, pattern) when is_tuple(msg), do: elem(msg, 0) == pattern - def match?(%{__struct__: module}, pattern), do: module == pattern - def match?(_, _), do: false -end diff --git a/lib/term_ui/message_queue.ex b/lib/term_ui/message_queue.ex deleted file mode 100644 index a60daa98..00000000 --- a/lib/term_ui/message_queue.ex +++ /dev/null @@ -1,189 +0,0 @@ -defmodule TermUI.MessageQueue do - @moduledoc """ - Message queue for batching multiple messages before rendering. - - Multiple messages may arrive between renders. We batch messages, applying - all updates before rendering once. This prevents redundant renders when - multiple events arrive quickly. The batch preserves message order for - deterministic updates. - - ## Usage - - # Create a queue - queue = MessageQueue.new() - - # Enqueue messages - queue = MessageQueue.enqueue(queue, :increment) - queue = MessageQueue.enqueue(queue, {:set_value, 42}) - - # Process all messages - {messages, queue} = MessageQueue.flush(queue) - - # Apply messages to state - state = Enum.reduce(messages, state, fn msg, state -> - {new_state, _commands} = Component.update(msg, state) - new_state - end) - """ - - @default_max_size 1000 - - # Dialyzer: Functions return specific tuple types - @dialyzer {:nowarn_function, process: 3} - - @type message :: term() - @type t :: %__MODULE__{ - messages: :queue.queue(message()), - size: non_neg_integer(), - max_size: pos_integer(), - overflow_count: non_neg_integer() - } - - defstruct messages: nil, - size: 0, - max_size: @default_max_size, - overflow_count: 0 - - @doc """ - Creates a new message queue. - - ## Options - - - `:max_size` - Maximum number of messages before dropping (default: #{@default_max_size}) - """ - @spec new(keyword()) :: t() - def new(opts \\ []) do - %__MODULE__{ - messages: :queue.new(), - size: 0, - max_size: Keyword.get(opts, :max_size, @default_max_size), - overflow_count: 0 - } - end - - @doc """ - Enqueues a message for processing. - - Messages are added to the back of the queue, preserving order. - If the queue is at max capacity, the message is dropped and - overflow count is incremented. - """ - @spec enqueue(t(), message()) :: t() - def enqueue(%__MODULE__{size: size, max_size: max_size} = queue, _message) - when size >= max_size do - %{queue | overflow_count: queue.overflow_count + 1} - end - - def enqueue(%__MODULE__{} = queue, message) do - %{ - queue - | messages: :queue.in(message, queue.messages), - size: queue.size + 1 - } - end - - @doc """ - Enqueues multiple messages at once. - """ - @spec enqueue_all(t(), [message()]) :: t() - def enqueue_all(queue, messages) do - Enum.reduce(messages, queue, &enqueue(&2, &1)) - end - - @doc """ - Removes and returns all messages from the queue. - - Returns `{messages, empty_queue}` where messages is a list - in the order they were enqueued. - """ - @spec flush(t()) :: {[message()], t()} - def flush(%__MODULE__{} = queue) do - messages = :queue.to_list(queue.messages) - - new_queue = %{ - queue - | messages: :queue.new(), - size: 0 - } - - {messages, new_queue} - end - - @doc """ - Returns true if the queue is empty. - """ - @spec empty?(t()) :: boolean() - def empty?(%__MODULE__{size: 0}), do: true - def empty?(_), do: false - - @doc """ - Returns the number of messages in the queue. - """ - @spec size(t()) :: non_neg_integer() - def size(%__MODULE__{size: size}), do: size - - @doc """ - Returns the number of dropped messages due to overflow. - """ - @spec overflow_count(t()) :: non_neg_integer() - def overflow_count(%__MODULE__{overflow_count: count}), do: count - - @doc """ - Peeks at the front message without removing it. - """ - @spec peek(t()) :: {:value, message()} | :empty - def peek(%__MODULE__{messages: messages}) do - :queue.peek(messages) - end - - @doc """ - Removes and returns the front message. - """ - @spec dequeue(t()) :: {{:value, message()}, t()} | {:empty, t()} - def dequeue(%__MODULE__{size: 0} = queue), do: {:empty, queue} - - def dequeue(%__MODULE__{} = queue) do - {{:value, message}, new_messages} = :queue.out(queue.messages) - - new_queue = %{ - queue - | messages: new_messages, - size: queue.size - 1 - } - - {{:value, message}, new_queue} - end - - @doc """ - Clears the queue and resets overflow count. - """ - @spec clear(t()) :: t() - def clear(%__MODULE__{} = queue) do - %{ - queue - | messages: :queue.new(), - size: 0, - overflow_count: 0 - } - end - - @doc """ - Processes all queued messages with a function. - - Applies `fun` to each message and the accumulator, returning - the final accumulator and empty queue. - - ## Example - - {final_state, commands, queue} = MessageQueue.process(queue, {state, []}, fn msg, {state, cmds} -> - {new_state, new_cmds} = Component.update(msg, state) - {new_state, cmds ++ new_cmds} - end) - """ - @spec process(t(), acc, (message(), acc -> acc)) :: {acc, t()} when acc: term() - def process(%__MODULE__{} = queue, initial_acc, fun) do - {messages, new_queue} = flush(queue) - final_acc = Enum.reduce(messages, initial_acc, fun) - {final_acc, new_queue} - end -end diff --git a/lib/term_ui/mouse.ex b/lib/term_ui/mouse.ex index 044a738c..d70bae81 100644 --- a/lib/term_ui/mouse.ex +++ b/lib/term_ui/mouse.ex @@ -1,137 +1,269 @@ -defmodule TermUI.Mouse do - @moduledoc """ - Mouse support utilities for terminal applications. +defmodule TermUI.Mouse.Region do + @moduledoc "A zero-based screen region for pure mouse routing." - Provides functions to enable/disable mouse tracking modes and - utilities for working with mouse events. + @type t :: %__MODULE__{ + id: term(), + x: non_neg_integer(), + y: non_neg_integer(), + width: pos_integer(), + height: pos_integer(), + z_index: integer(), + metadata: map() + } - ## Mouse Tracking Modes + @schema Zoi.struct(__MODULE__, %{ + id: Zoi.any(), + x: Zoi.integer() |> Zoi.non_negative(), + y: Zoi.integer() |> Zoi.non_negative(), + width: Zoi.integer() |> Zoi.positive(), + height: Zoi.integer() |> Zoi.positive(), + z_index: Zoi.integer() |> Zoi.default(0), + metadata: Zoi.map() |> Zoi.default(%{}) + }) - - **Normal (1000)** - Report button press/release - - **Button (1002)** - Report motion while button pressed - - **Any (1003)** - Report all motion events - - **SGR Extended (1006)** - Decimal coordinates, press/release distinction + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) - ## Usage + @doc "Returns the Zoi schema for mouse regions." + @spec schema() :: Zoi.schema() + def schema, do: @schema +end - # Enable mouse tracking - sequences = Mouse.enable_mouse() - IO.write(sequences) +defmodule TermUI.Mouse.Tracker do + @moduledoc "Pure drag and hover state for one Elm application." - # Enable motion tracking with SGR Extended - sequences = Mouse.enable_mouse_motion() - IO.write(sequences) + alias TermUI.Event - # Disable mouse tracking - sequences = Mouse.disable_mouse() - IO.write(sequences) - """ + @type t :: %__MODULE__{ + button_down: Event.Mouse.button(), + press_position: {integer(), integer()} | nil, + last_position: {integer(), integer()} | nil, + dragging: boolean(), + hovered: term(), + drag_threshold: non_neg_integer() + } - # Mouse tracking mode escape sequences - @mouse_normal_on "\e[?1000h" - @mouse_normal_off "\e[?1000l" - @mouse_button_on "\e[?1002h" - @mouse_button_off "\e[?1002l" - @mouse_any_on "\e[?1003h" - @mouse_any_off "\e[?1003l" - @mouse_sgr_on "\e[?1006h" - @mouse_sgr_off "\e[?1006l" - - @doc """ - Returns escape sequences to enable normal mouse tracking. - - Normal mode reports button press and release events. - Also enables SGR Extended mode for accurate coordinates. - """ - @spec enable_mouse() :: String.t() - def enable_mouse do - @mouse_normal_on <> @mouse_sgr_on + @schema Zoi.struct(__MODULE__, %{ + button_down: Zoi.enum([:left, :middle, :right, nil]) |> Zoi.default(nil), + press_position: Zoi.any() |> Zoi.default(nil), + last_position: Zoi.any() |> Zoi.default(nil), + dragging: Zoi.boolean() |> Zoi.default(false), + hovered: Zoi.any() |> Zoi.default(nil), + drag_threshold: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(1) + }) + + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @doc "Creates tracker state. The drag threshold is measured in terminal cells." + @spec new(keyword()) :: t() + def new(opts \\ []) do + %__MODULE__{drag_threshold: max(Keyword.get(opts, :drag_threshold, 1), 0)} end - @doc """ - Returns escape sequences to enable button motion tracking. + @doc "Updates drag state and returns generated drag messages." + @spec update(t(), Event.Mouse.t()) :: {t(), [term()]} + def update(tracker, %Event.Mouse{action: :press, button: button, x: x, y: y}) do + {%{ + tracker + | button_down: button, + press_position: {x, y}, + last_position: {x, y}, + dragging: false + }, []} + end - Button mode reports motion events while a button is pressed. - Also enables SGR Extended mode for accurate coordinates. - """ - @spec enable_mouse_button() :: String.t() - def enable_mouse_button do - @mouse_button_on <> @mouse_sgr_on + def update(tracker, %Event.Mouse{action: :release, button: button, x: x, y: y}) do + events = if tracker.dragging, do: [{:drag_end, button || tracker.button_down, x, y}], else: [] + + {%{ + tracker + | button_down: nil, + press_position: nil, + last_position: {x, y}, + dragging: false + }, events} end - @doc """ - Returns escape sequences to enable all motion tracking. + def update(tracker, %Event.Mouse{action: action, button: button, x: x, y: y}) + when action in [:move, :drag] do + update_motion(tracker, button || tracker.button_down, x, y) + end - Any mode reports all mouse motion events. - Also enables SGR Extended mode for accurate coordinates. - """ - @spec enable_mouse_motion() :: String.t() - def enable_mouse_motion do - @mouse_any_on <> @mouse_sgr_on + def update(tracker, %Event.Mouse{}), do: {tracker, []} + + @doc "Updates the hovered region identifier." + @spec hover(t(), term()) :: {t(), [term()]} + def hover(%__MODULE__{hovered: target} = tracker, target), do: {tracker, []} + + def hover(%__MODULE__{hovered: nil} = tracker, target), + do: {%{tracker | hovered: target}, [{:hover_enter, target}]} + + def hover(%__MODULE__{hovered: previous} = tracker, nil), + do: {%{tracker | hovered: nil}, [{:hover_leave, previous}]} + + def hover(%__MODULE__{hovered: previous} = tracker, target), + do: {%{tracker | hovered: target}, [{:hover_leave, previous}, {:hover_enter, target}]} + + @doc "Returns true during a drag." + @spec dragging?(t()) :: boolean() + def dragging?(%__MODULE__{dragging: dragging}), do: dragging + + @doc "Returns the hovered region identifier." + @spec hovered(t()) :: term() + def hovered(%__MODULE__{hovered: hovered}), do: hovered + + @doc "Clears drag state, such as after terminal focus is lost." + @spec reset_drag(t()) :: t() + def reset_drag(tracker), + do: %{tracker | button_down: nil, press_position: nil, last_position: nil, dragging: false} + + defp update_motion(%{button_down: nil} = tracker, nil, x, y), + do: {%{tracker | last_position: {x, y}}, []} + + defp update_motion(%{press_position: nil} = tracker, _button, x, y), + do: {%{tracker | last_position: {x, y}}, []} + + defp update_motion(%{dragging: true} = tracker, button, x, y) do + {dx, dy} = delta(tracker.last_position, {x, y}) + {%{tracker | last_position: {x, y}}, [{:drag, button, x, y, dx, dy}]} end - @doc """ - Returns escape sequences to disable all mouse tracking. - """ - @spec disable_mouse() :: String.t() - def disable_mouse do - @mouse_sgr_off <> @mouse_any_off <> @mouse_button_off <> @mouse_normal_off + defp update_motion(tracker, button, x, y) do + {press_x, press_y} = tracker.press_position + + if abs(x - press_x) >= tracker.drag_threshold or + abs(y - press_y) >= tracker.drag_threshold do + {%{tracker | dragging: true, last_position: {x, y}}, + [ + {:drag_start, button, press_x, press_y}, + {:drag, button, x, y, x - press_x, y - press_y} + ]} + else + {%{tracker | last_position: {x, y}}, []} + end end - @doc """ - Returns the escape sequence for SGR Extended mode. + defp delta(nil, _position), do: {0, 0} + defp delta({old_x, old_y}, {x, y}), do: {x - old_x, y - old_y} +end - SGR Extended mode provides: - - Decimal coordinate encoding (no 223 limit) - - Press/release distinction via 'm' vs 'M' suffix - """ - @spec sgr_extended_on() :: String.t() - def sgr_extended_on, do: @mouse_sgr_on +defmodule TermUI.Mouse do + @moduledoc """ + Pure mouse hit testing and local-coordinate routing. - @doc """ - Returns the escape sequence to disable SGR Extended mode. + The Elm application creates regions from its current layout and stores any + `TermUI.Mouse.Tracker` state. No registry, process, or global spatial index is + used. Coordinates are zero-based because terminal mouse events are zero-based. """ - @spec sgr_extended_off() :: String.t() - def sgr_extended_off, do: @mouse_sgr_off - # Scroll wheel directions - @doc """ - Scroll up direction constant. - """ - def scroll_up, do: :scroll_up + alias TermUI.Event + alias TermUI.Mouse.Region - @doc """ - Scroll down direction constant. - """ - def scroll_down, do: :scroll_down + @doc "Creates a routing region. Later equal-z regions are treated as topmost." + @spec region( + term(), + non_neg_integer(), + non_neg_integer(), + pos_integer(), + pos_integer(), + keyword() + ) :: + Region.t() + def region(id, x, y, width, height, opts \\ []) + when is_integer(x) and x >= 0 and is_integer(y) and y >= 0 and is_integer(width) and + width > 0 and is_integer(height) and height > 0 do + %Region{ + id: id, + x: x, + y: y, + width: width, + height: height, + z_index: Keyword.get(opts, :z_index, 0), + metadata: Keyword.get(opts, :metadata, %{}) + } + end - @doc """ - Default number of lines to scroll per wheel tick. - """ - def default_scroll_lines, do: 3 + @doc "Returns the topmost region and local coordinates at a screen position." + @spec hit_test([Region.t()], integer(), integer()) :: + {:ok, Region.t(), {integer(), integer()}} | :none + def hit_test(regions, x, y) when is_list(regions) and is_integer(x) and is_integer(y) do + regions + |> topmost_region(x, y) + |> case do + nil -> :none + region -> {:ok, region, to_local(region, x, y)} + end + end - @doc """ - Checks if a mouse action is a scroll action. - """ - @spec scroll_action?(atom()) :: boolean() - def scroll_action?(:scroll_up), do: true - def scroll_action?(:scroll_down), do: true - def scroll_action?(_), do: false + @doc "Routes a mouse event to the topmost region with local coordinates." + @spec route([Region.t()], Event.Mouse.t()) :: {:ok, term(), Event.Mouse.t()} | :none + def route(regions, %Event.Mouse{x: x, y: y} = event) do + case hit_test(regions, x, y) do + {:ok, region, {local_x, local_y}} -> + {:ok, region.id, %{event | x: local_x, y: local_y}} - @doc """ - Checks if a mouse action is a click action. - """ - @spec click_action?(atom()) :: boolean() - def click_action?(:press), do: true - def click_action?(:release), do: true - def click_action?(:click), do: true - def click_action?(_), do: false - - @doc """ - Checks if a mouse action is a motion action. - """ - @spec motion_action?(atom()) :: boolean() - def motion_action?(:move), do: true - def motion_action?(:drag), do: true - def motion_action?(_), do: false + :none -> + :none + end + end + + @doc "Routes a mouse event to all matching regions in front-to-back order." + @spec route_all([Region.t()], Event.Mouse.t()) :: [{term(), Event.Mouse.t()}] + def route_all(regions, %Event.Mouse{x: x, y: y} = event) do + Enum.map(matching_regions(regions, x, y), fn region -> + {local_x, local_y} = to_local(region, x, y) + {region.id, %{event | x: local_x, y: local_y}} + end) + end + + @doc "Transforms global coordinates to region-local coordinates." + @spec to_local(Region.t(), integer(), integer()) :: {integer(), integer()} + def to_local(%Region{} = region, x, y), do: {x - region.x, y - region.y} + + @doc "Transforms region-local coordinates to global coordinates." + @spec to_global(Region.t(), integer(), integer()) :: {integer(), integer()} + def to_global(%Region{} = region, x, y), do: {x + region.x, y + region.y} + + @doc "Returns true when a point is inside a region." + @spec contains?(Region.t(), integer(), integer()) :: boolean() + def contains?(%Region{} = region, x, y) do + x >= region.x and x < region.x + region.width and y >= region.y and + y < region.y + region.height + end + + @doc "Returns true when two regions overlap." + @spec overlap?(Region.t(), Region.t()) :: boolean() + def overlap?(%Region{} = first, %Region{} = second) do + not (first.x + first.width <= second.x or second.x + second.width <= first.x or + first.y + first.height <= second.y or second.y + second.height <= first.y) + end + + @doc "Clips global coordinates to a region." + @spec clip(Region.t(), integer(), integer()) :: {integer(), integer()} + def clip(%Region{} = region, x, y) do + { + x |> max(region.x) |> min(region.x + region.width - 1), + y |> max(region.y) |> min(region.y + region.height - 1) + } + end + + defp matching_regions(regions, x, y) do + regions + |> Enum.with_index() + |> Enum.filter(fn {region, _index} -> contains?(region, x, y) end) + |> Enum.sort_by(fn {region, index} -> {region.z_index, index} end, :desc) + |> Enum.map(&elem(&1, 0)) + end + + defp topmost_region(regions, x, y) do + Enum.reduce(regions, nil, fn region, current -> + cond do + not contains?(region, x, y) -> current + is_nil(current) -> region + region.z_index >= current.z_index -> region + true -> current + end + end) + end end diff --git a/lib/term_ui/mouse/router.ex b/lib/term_ui/mouse/router.ex deleted file mode 100644 index 34c4f0a9..00000000 --- a/lib/term_ui/mouse/router.ex +++ /dev/null @@ -1,131 +0,0 @@ -defmodule TermUI.Mouse.Router do - @moduledoc """ - Routes mouse events to components based on position. - - The router uses component bounds to determine which component - should receive a mouse event, handles z-order for overlapping - components, and transforms coordinates to component-local space. - - ## Usage - - # Find component at position - {component_id, local_x, local_y} = Router.hit_test(components, x, y) - - # Route event to component - {target_id, transformed_event} = Router.route(components, mouse_event) - """ - - alias TermUI.Event - - @type bounds :: %{x: integer(), y: integer(), width: integer(), height: integer()} - @type component_entry :: %{bounds: bounds(), z_index: integer()} - @type components :: %{atom() => component_entry()} - - @doc """ - Finds the component at the given position. - - Returns `{component_id, local_x, local_y}` or `nil` if no component at position. - - When multiple components overlap, returns the one with highest z_index. - """ - @spec hit_test(components(), integer(), integer()) :: {atom(), integer(), integer()} | nil - def hit_test(components, x, y) do - components - |> Enum.filter(fn {_id, entry} -> point_in_bounds?(x, y, entry.bounds) end) - |> Enum.max_by(fn {_id, entry} -> Map.get(entry, :z_index, 0) end, fn -> nil end) - |> case do - nil -> - nil - - {id, entry} -> - local_x = x - entry.bounds.x - local_y = y - entry.bounds.y - {id, local_x, local_y} - end - end - - @doc """ - Routes a mouse event to the appropriate component. - - Returns `{component_id, transformed_event}` where the event has - coordinates transformed to component-local space. - - Returns `nil` if no component at the event position. - """ - @spec route(components(), Event.Mouse.t()) :: {atom(), Event.Mouse.t()} | nil - def route(components, %Event.Mouse{x: x, y: y} = event) do - case hit_test(components, x, y) do - nil -> - nil - - {id, local_x, local_y} -> - transformed = %{event | x: local_x, y: local_y} - {id, transformed} - end - end - - @doc """ - Finds all components at the given position, ordered by z-index (highest first). - - Useful for event bubbling through overlapping components. - """ - @spec hit_test_all(components(), integer(), integer()) :: [{atom(), integer(), integer()}] - def hit_test_all(components, x, y) do - components - |> Enum.filter(fn {_id, entry} -> point_in_bounds?(x, y, entry.bounds) end) - |> Enum.sort_by(fn {_id, entry} -> Map.get(entry, :z_index, 0) end, :desc) - |> Enum.map(fn {id, entry} -> - local_x = x - entry.bounds.x - local_y = y - entry.bounds.y - {id, local_x, local_y} - end) - end - - @doc """ - Transforms global coordinates to component-local coordinates. - """ - @spec to_local(bounds(), integer(), integer()) :: {integer(), integer()} - def to_local(bounds, x, y) do - {x - bounds.x, y - bounds.y} - end - - @doc """ - Transforms component-local coordinates to global coordinates. - """ - @spec to_global(bounds(), integer(), integer()) :: {integer(), integer()} - def to_global(bounds, local_x, local_y) do - {local_x + bounds.x, local_y + bounds.y} - end - - @doc """ - Checks if a point is within bounds. - """ - @spec point_in_bounds?(integer(), integer(), bounds()) :: boolean() - def point_in_bounds?(x, y, bounds) do - x >= bounds.x and - x < bounds.x + bounds.width and - y >= bounds.y and - y < bounds.y + bounds.height - end - - @doc """ - Checks if two bounds overlap. - """ - @spec bounds_overlap?(bounds(), bounds()) :: boolean() - def bounds_overlap?(a, b) do - not (a.x + a.width <= b.x or - b.x + b.width <= a.x or - a.y + a.height <= b.y or - b.y + b.height <= a.y) - end - - @doc """ - Clips coordinates to be within bounds. - """ - @spec clip_to_bounds(integer(), integer(), bounds()) :: {integer(), integer()} - def clip_to_bounds(x, y, bounds) do - clipped_x = x |> max(bounds.x) |> min(bounds.x + bounds.width - 1) - clipped_y = y |> max(bounds.y) |> min(bounds.y + bounds.height - 1) - {clipped_x, clipped_y} - end -end diff --git a/lib/term_ui/mouse/tracker.ex b/lib/term_ui/mouse/tracker.ex deleted file mode 100644 index 19dd2f15..00000000 --- a/lib/term_ui/mouse/tracker.ex +++ /dev/null @@ -1,209 +0,0 @@ -defmodule TermUI.Mouse.Tracker do - @moduledoc """ - Tracks mouse state for drag and hover detection. - - The tracker maintains state for: - - Drag operations (press → move → release) - - Hover detection (enter/leave events) - - Last known mouse position - - ## Usage - - # Create new tracker - tracker = Tracker.new() - - # Process mouse events - {tracker, events} = Tracker.process(tracker, mouse_event) - - # Events may include: - # - {:drag_start, button, x, y} - # - {:drag_move, button, x, y, dx, dy} - # - {:drag_end, button, x, y} - # - {:hover_enter, component_id} - # - {:hover_leave, component_id} - """ - - alias TermUI.Event - - @type t :: %__MODULE__{ - button_down: atom() | nil, - press_position: {integer(), integer()} | nil, - last_position: {integer(), integer()} | nil, - dragging: boolean(), - hovered_component: atom() | nil, - drag_threshold: integer() - } - - defstruct [ - :button_down, - :press_position, - :last_position, - :hovered_component, - dragging: false, - drag_threshold: 3 - ] - - @doc """ - Creates a new mouse tracker. - - ## Options - - - `:drag_threshold` - Pixels of movement before drag starts (default: 3) - """ - @spec new(keyword()) :: t() - def new(opts \\ []) do - %__MODULE__{ - drag_threshold: Keyword.get(opts, :drag_threshold, 3) - } - end - - @doc """ - Processes a mouse event and returns updated tracker and generated events. - - Generated events: - - `{:drag_start, button, x, y}` - Drag operation started - - `{:drag_move, button, x, y, dx, dy}` - Mouse moved during drag - - `{:drag_end, button, x, y}` - Drag operation ended - """ - @spec process(t(), Event.Mouse.t()) :: {t(), list()} - def process(tracker, %Event.Mouse{action: :press, button: button, x: x, y: y}) do - tracker = %{ - tracker - | button_down: button, - press_position: {x, y}, - last_position: {x, y}, - dragging: false - } - - {tracker, []} - end - - def process(tracker, %Event.Mouse{action: :release, button: button, x: x, y: y}) do - events = - if tracker.dragging and tracker.button_down == button do - [{:drag_end, button, x, y}] - else - [] - end - - tracker = %{ - tracker - | button_down: nil, - press_position: nil, - dragging: false - } - - {tracker, events} - end - - def process(tracker, %Event.Mouse{action: :move, x: x, y: y}) do - {tracker, events} = process_motion(tracker, x, y) - tracker = %{tracker | last_position: {x, y}} - {tracker, events} - end - - def process(tracker, %Event.Mouse{action: :drag, button: button, x: x, y: y}) do - # Drag events come with button info - {tracker, events} = process_motion(tracker, x, y, button) - tracker = %{tracker | last_position: {x, y}} - {tracker, events} - end - - def process(tracker, %Event.Mouse{}) do - # Scroll or other events don't affect drag/hover state - {tracker, []} - end - - @doc """ - Updates hover state and returns enter/leave events. - """ - @spec update_hover(t(), atom() | nil) :: {t(), list()} - def update_hover(tracker, component_id) do - cond do - tracker.hovered_component == component_id -> - {tracker, []} - - tracker.hovered_component == nil -> - tracker = %{tracker | hovered_component: component_id} - {tracker, [{:hover_enter, component_id}]} - - component_id == nil -> - old = tracker.hovered_component - tracker = %{tracker | hovered_component: nil} - {tracker, [{:hover_leave, old}]} - - true -> - old = tracker.hovered_component - tracker = %{tracker | hovered_component: component_id} - {tracker, [{:hover_leave, old}, {:hover_enter, component_id}]} - end - end - - @doc """ - Returns whether a drag operation is in progress. - """ - @spec dragging?(t()) :: boolean() - def dragging?(tracker), do: tracker.dragging - - @doc """ - Returns the currently hovered component. - """ - @spec hovered_component(t()) :: atom() | nil - def hovered_component(tracker), do: tracker.hovered_component - - @doc """ - Returns the button currently pressed. - """ - @spec button_down(t()) :: atom() | nil - def button_down(tracker), do: tracker.button_down - - @doc """ - Resets drag state (useful on focus loss). - """ - @spec reset_drag(t()) :: t() - def reset_drag(tracker) do - %{tracker | button_down: nil, press_position: nil, dragging: false} - end - - # --- Private Functions --- - - defp process_motion(tracker, x, y, button \\ nil) do - button = button || tracker.button_down - - cond do - # No button down, no drag events - button == nil -> - {tracker, []} - - # Already dragging, emit drag move - tracker.dragging -> - {dx, dy} = delta(tracker.last_position, {x, y}) - {tracker, [{:drag_move, button, x, y, dx, dy}]} - - # Check if we should start dragging - should_start_drag?(tracker, x, y) -> - tracker = %{tracker | dragging: true} - {px, py} = tracker.press_position - {tracker, [{:drag_start, button, px, py}, {:drag_move, button, x, y, x - px, y - py}]} - - # Not yet dragging - true -> - {tracker, []} - end - end - - defp should_start_drag?(tracker, x, y) do - case tracker.press_position do - nil -> - false - - {px, py} -> - dx = abs(x - px) - dy = abs(y - py) - dx >= tracker.drag_threshold or dy >= tracker.drag_threshold - end - end - - defp delta(nil, _), do: {0, 0} - defp delta({x1, y1}, {x2, y2}), do: {x2 - x1, y2 - y1} -end diff --git a/lib/term_ui/persistent_terms.ex b/lib/term_ui/persistent_terms.ex deleted file mode 100644 index 26a7425a..00000000 --- a/lib/term_ui/persistent_terms.ex +++ /dev/null @@ -1,178 +0,0 @@ -defmodule TermUI.PersistentTerms do - @moduledoc """ - Centralized management of persistent_term storage for TermUI. - - TermUI uses `:persistent_term` for fast global access to runtime configuration - like backend mode, capabilities, and character set. This module provides a - single interface for managing the lifecycle of these terms. - - ## Persistent Term Keys - - The following keys are used by TermUI: - - - `:term_ui_backend_mode` - Current backend mode (:raw, :tty, or nil) - - `:term_ui_capabilities` - Detected terminal capabilities map - - `term_ui_character_set` - Character set (:unicode or :ascii) - - BufferManager also uses persistent terms with its own name prefix: - - `{TermUI.Renderer.BufferManager, name, :current}` - Current buffer reference - - `{TermUI.Renderer.BufferManager, name, :previous}` - Previous buffer reference - - `{TermUI.Renderer.BufferManager, name, :dirty}` - Dirty flag atomic - - ## Cleanup - - Always call `cleanup/0` when shutting down a TermUI application to prevent - memory leaks from orphaned persistent terms. - - ## Usage - - # Store backend context - PersistentTerms.store_backend_context(:raw, capabilities) - - # Query backend mode - :raw = PersistentTerms.backend_mode() - - # Clean up on shutdown - PersistentTerms.cleanup() - """ - - alias TermUI.Backend.Selector - - # Dialyzer: Pattern match coverage warnings - @dialyzer {:nowarn_function, - cleanup: 0, - store_backend_context: 2, - determine_character_set: 1, - detect_capabilities: 0} - - @doc """ - Stores backend context in persistent_term. - - This is called by Runtime during initialization to make backend information - globally available to components that need to query capabilities. - - ## Parameters - - - `backend_mode` - The backend mode (:raw, :tty, etc.) - - `capabilities` - The detected capabilities map - """ - @spec store_backend_context(:raw | :tty | nil, map() | nil) :: :ok - def store_backend_context(backend_mode, capabilities) do - :persistent_term.put(:term_ui_backend_mode, backend_mode) - - caps_to_store = - if backend_mode == :raw do - # Detect capabilities even in raw mode for consistency - detect_capabilities() - else - capabilities - end - - :persistent_term.put(:term_ui_capabilities, caps_to_store) - - # Determine and store character set (:unicode or :ascii) - charset = determine_character_set(caps_to_store) - :persistent_term.put(:term_ui_character_set, charset) - - # Log capabilities at debug level - log_capabilities(caps_to_store, charset) - - :ok - end - - @doc """ - Gets the current backend mode from persistent_term. - - Returns `:raw`, `:tty`, or `nil` if not set. - """ - @spec backend_mode() :: :raw | :tty | nil - def backend_mode do - :persistent_term.get(:term_ui_backend_mode, nil) - end - - @doc """ - Gets the detected terminal capabilities from persistent_term. - - Returns a map with keys like `:colors`, `:unicode`, `:dimensions`, `:terminal` - or `nil` if not set. - """ - @spec capabilities() :: map() | nil - def capabilities do - :persistent_term.get(:term_ui_capabilities, nil) - end - - @doc """ - Gets the current character set from persistent_term. - - Returns `:unicode` or `:ascii`. - """ - @spec character_set() :: :unicode | :ascii - def character_set do - case :persistent_term.get(:term_ui_character_set, :fallback) do - :fallback -> - # Fall back to application config - Application.get_env(:term_ui, :character_set, :unicode) - - charset -> - charset - end - end - - @doc """ - Cleans up all TermUI persistent terms. - - This should be called during graceful shutdown to prevent memory leaks. - BufferManager persistent terms are handled by BufferManager itself. - - ## Examples - - TermUI.PersistentTerms.cleanup() - """ - @spec cleanup() :: :ok - def cleanup do - # Erase all TermUI global persistent terms - :persistent_term.erase(:term_ui_backend_mode) - :persistent_term.erase(:term_ui_capabilities) - :persistent_term.erase(:term_ui_character_set) - - :ok - end - - @doc """ - Checks if any TermUI persistent terms are currently set. - - Useful for testing and debugging to ensure cleanup is working. - - ## Examples - - iex> TermUI.PersistentTerms.any_terms?() - false - """ - @spec any_terms?() :: boolean() - def any_terms? do - :persistent_term.get(:term_ui_backend_mode, :not_set) != :not_set or - :persistent_term.get(:term_ui_capabilities, :not_set) != :not_set or - :persistent_term.get(:term_ui_character_set, :not_set) != :not_set - end - - # Private Functions - - defp detect_capabilities do - Selector.detect_capabilities() - rescue - _ -> %{} - end - - defp determine_character_set(capabilities) when is_map(capabilities) do - case Map.get(capabilities, :unicode, true) do - true -> :unicode - false -> :ascii - _ -> :unicode - end - end - - defp determine_character_set(_capabilities), do: :unicode - - # No-op - capabilities logging removed for cleaner console output - defp log_capabilities(_capabilities, _charset), do: :ok -end diff --git a/lib/term_ui/platform.ex b/lib/term_ui/platform.ex deleted file mode 100644 index ba649a3b..00000000 --- a/lib/term_ui/platform.ex +++ /dev/null @@ -1,204 +0,0 @@ -defmodule TermUI.Platform do - @moduledoc """ - Platform detection and abstraction for cross-platform terminal support. - - Provides unified API for platform-specific operations, automatically - selecting the appropriate implementation for the current OS. - """ - - # Dialyzer: Functions return specific map types - @dialyzer {:nowarn_function, - info: 0, platform: 0, check_wsl: 0, parse_version_string: 1, os_version: 0} - - @type platform :: :linux | :macos | :windows | :freebsd | :unknown - @type version :: {non_neg_integer(), non_neg_integer(), non_neg_integer()} | nil - - @doc """ - Returns the current platform identifier. - - ## Examples - - iex> TermUI.Platform.platform() - :linux - - iex> TermUI.Platform.platform() - :macos - """ - @spec platform() :: platform() - def platform do - case :os.type() do - {:unix, :linux} -> :linux - {:unix, :darwin} -> :macos - {:unix, :freebsd} -> :freebsd - {:win32, _} -> :windows - _ -> :unknown - end - end - - @doc """ - Returns the OS version as a tuple. - - ## Examples - - iex> TermUI.Platform.os_version() - {5, 15, 0} - - iex> TermUI.Platform.os_version() - {14, 0, 0} - """ - @spec os_version() :: version() - def os_version do - case :os.version() do - {major, minor, patch} -> - {major, minor, patch} - - version_string when is_list(version_string) -> - parse_version_string(to_string(version_string)) - - _ -> - nil - end - end - - @doc """ - Returns true if running on Unix (Linux, macOS, FreeBSD). - """ - @spec unix?() :: boolean() - def unix? do - platform() in [:linux, :macos, :freebsd] - end - - @doc """ - Returns true if running on Windows. - """ - @spec windows?() :: boolean() - def windows? do - platform() == :windows - end - - @doc """ - Returns true if running in Windows Subsystem for Linux (WSL). - """ - @spec wsl?() :: boolean() - def wsl? do - if platform() == :linux do - check_wsl() - else - false - end - end - - @doc """ - Returns true if running on macOS. - """ - @spec macos?() :: boolean() - def macos? do - platform() == :macos - end - - @doc """ - Returns true if running on Linux (native, not WSL). - """ - @spec linux?() :: boolean() - def linux? do - platform() == :linux and not wsl?() - end - - @doc """ - Returns the terminal size as {rows, cols}. - - Falls back to default {24, 80} if unable to detect. - """ - @spec terminal_size() :: {pos_integer(), pos_integer()} - def terminal_size do - rows = get_terminal_rows() - cols = get_terminal_cols() - {rows, cols} - end - - # Platform feature support matrix - @unix_features MapSet.new([:signals, :pty, :terminfo, :vt_sequences]) - @windows_features MapSet.new([:vt_sequences]) - - @doc """ - Returns true if the platform supports the given feature. - - ## Features - - `:signals` - POSIX signal handling - - `:pty` - Pseudo-terminal support - - `:terminfo` - Terminfo database - - `:vt_sequences` - VT escape sequences - """ - @spec supports_feature?(atom()) :: boolean() - def supports_feature?(feature) do - current_platform = platform() - - cond do - current_platform in [:linux, :macos, :freebsd] -> - MapSet.member?(@unix_features, feature) - - current_platform == :windows -> - MapSet.member?(@windows_features, feature) - - true -> - false - end - end - - @doc """ - Returns platform-specific information as a map. - """ - @spec info() :: map() - def info do - %{ - platform: platform(), - os_version: os_version(), - unix: unix?(), - windows: windows?(), - wsl: wsl?(), - terminal_size: terminal_size() - } - end - - # Private functions - - defp check_wsl do - # Check /proc/version for WSL indicators - case File.read("/proc/version") do - {:ok, content} -> - content = String.downcase(content) - String.contains?(content, "microsoft") or String.contains?(content, "wsl") - - {:error, _} -> - false - end - end - - defp parse_version_string(version_string) do - # Parse version strings like "5.15.0-generic" or "14.0.0" - case Regex.run(~r/^(\d+)\.(\d+)(?:\.(\d+))?/, version_string) do - [_, major, minor, patch] -> - {String.to_integer(major), String.to_integer(minor), String.to_integer(patch)} - - [_, major, minor] -> - {String.to_integer(major), String.to_integer(minor), 0} - - _ -> - nil - end - end - - defp get_terminal_rows do - case :io.rows() do - {:ok, rows} -> rows - _ -> 24 - end - end - - defp get_terminal_cols do - case :io.columns() do - {:ok, cols} -> cols - _ -> 80 - end - end -end diff --git a/lib/term_ui/platform/unix.ex b/lib/term_ui/platform/unix.ex deleted file mode 100644 index f993a0ea..00000000 --- a/lib/term_ui/platform/unix.ex +++ /dev/null @@ -1,128 +0,0 @@ -defmodule TermUI.Platform.Unix do - @moduledoc """ - Unix-specific terminal handling for Linux and macOS. - - Provides platform-specific implementations for: - - Terminal size detection - - Signal handling hints - - Capability detection hints - """ - - # Dialyzer: Functions return specific map or atom types - @dialyzer {:nowarn_function, info: 0, capability_hints: 0, supported_signals: 0} - - @doc """ - Returns Unix-specific terminal information. - """ - @spec info() :: map() - def info do - %{ - platform: detect_unix_variant(), - kernel_version: kernel_version(), - terminfo_paths: terminfo_paths(), - supports_signals: true, - supports_pty: true - } - end - - @doc """ - Returns the Unix variant (linux, macos, freebsd). - """ - @spec detect_unix_variant() :: :linux | :macos | :freebsd | :unknown - def detect_unix_variant do - case :os.type() do - {:unix, :linux} -> :linux - {:unix, :darwin} -> :macos - {:unix, :freebsd} -> :freebsd - _ -> :unknown - end - end - - @doc """ - Returns the kernel version string. - """ - @spec kernel_version() :: String.t() | nil - def kernel_version do - case :os.version() do - {major, minor, patch} -> - "#{major}.#{minor}.#{patch}" - - _ -> - nil - end - end - - @doc """ - Returns paths where terminfo database may be found. - """ - @spec terminfo_paths() :: [String.t()] - def terminfo_paths do - base_paths = [ - "/usr/share/terminfo", - "/usr/lib/terminfo", - "/lib/terminfo", - "/etc/terminfo" - ] - - # Add user terminfo if it exists - home = System.get_env("HOME") - - user_paths = - if home do - [Path.join(home, ".terminfo")] - else - [] - end - - user_paths ++ base_paths - end - - @doc """ - Returns hints for Unix-specific capability detection. - """ - @spec capability_hints() :: map() - def capability_hints do - variant = detect_unix_variant() - - base_hints = %{ - supports_mouse: true, - supports_bracketed_paste: true, - supports_focus_events: true, - supports_alternate_screen: true - } - - # Add variant-specific hints - case variant do - :macos -> - Map.merge(base_hints, %{ - default_terminal: "Apple_Terminal", - notes: "iTerm2 recommended for full feature support" - }) - - :linux -> - Map.merge(base_hints, %{ - default_terminal: "xterm", - notes: "Most modern terminals fully supported" - }) - - _ -> - base_hints - end - end - - @doc """ - Returns signal names supported on Unix. - """ - @spec supported_signals() :: [atom()] - def supported_signals do - [:sigwinch, :sigterm, :sigint, :sighup, :sigusr1, :sigusr2] - end - - @doc """ - Checks if a specific signal is available. - """ - @spec signal_available?(atom()) :: boolean() - def signal_available?(signal) do - signal in supported_signals() - end -end diff --git a/lib/term_ui/platform/windows.ex b/lib/term_ui/platform/windows.ex deleted file mode 100644 index bb8be0f8..00000000 --- a/lib/term_ui/platform/windows.ex +++ /dev/null @@ -1,159 +0,0 @@ -defmodule TermUI.Platform.Windows do - @moduledoc """ - Windows-specific terminal handling stubs. - - Full Windows support requires NIFs or ports to call Win32 APIs. - This module provides stubs with clear error messages for future implementation. - - ## Requirements for Full Support - - Windows 10 build 1511+ for VT sequence support - - SetConsoleMode with ENABLE_VIRTUAL_TERMINAL_PROCESSING - - SetConsoleMode with ENABLE_VIRTUAL_TERMINAL_INPUT - - GetConsoleScreenBufferInfo for terminal size - - Console event handling for resize/focus - - ## Future Implementation - Would require NIF wrapping: - - kernel32.dll SetConsoleMode - - kernel32.dll GetConsoleMode - - kernel32.dll GetConsoleScreenBufferInfo - - Console event loop for input - """ - - # Dialyzer: Functions return specific map types - @dialyzer {:nowarn_function, - info: 0, vt_support_available?: 0, capability_hints: 0, minimum_version: 0} - - @doc """ - Returns Windows-specific terminal information. - - Note: Currently returns stub data as full implementation requires NIFs. - """ - @spec info() :: map() - def info do - %{ - platform: :windows, - vt_support: :unknown, - console_mode: :unknown, - supports_signals: false, - supports_pty: false, - implementation_status: :stub, - notes: "Full Windows support requires NIF implementation" - } - end - - @doc """ - Checks if Windows VT sequence support is available. - - Note: Currently a stub. Would need to call GetConsoleMode to check. - """ - @spec vt_support_available?() :: boolean() - def vt_support_available? do - # Stub - would need NIF to check actual console mode - # For now, assume Windows 10+ has VT support - case windows_version() do - {major, _, _} when major >= 10 -> true - _ -> false - end - end - - @doc """ - Returns the Windows version. - """ - @spec windows_version() :: {non_neg_integer(), non_neg_integer(), non_neg_integer()} | nil - def windows_version do - case :os.version() do - {major, minor, build} -> {major, minor, build} - _ -> nil - end - end - - @doc """ - Enables VT sequence processing for the console. - - Note: Stub implementation. Would need NIF to call SetConsoleMode. - """ - @spec enable_vt_processing() :: {:ok, :stub} | {:error, String.t()} - def enable_vt_processing do - if vt_support_available?() do - {:ok, :stub} - else - {:error, "Windows VT support requires Windows 10 build 1511 or later"} - end - end - - @doc """ - Disables VT sequence processing. - - Note: Stub implementation. - """ - @spec disable_vt_processing() :: :ok - def disable_vt_processing do - :ok - end - - @doc """ - Returns hints for Windows-specific capability detection. - """ - @spec capability_hints() :: map() - def capability_hints do - %{ - supports_mouse: vt_support_available?(), - supports_bracketed_paste: vt_support_available?(), - supports_focus_events: vt_support_available?(), - supports_alternate_screen: vt_support_available?(), - requires_vt_mode: true, - notes: "Windows Terminal recommended for best experience" - } - end - - @doc """ - Returns terminal size on Windows. - - Note: Stub using Erlang's :io module. For accurate results, - would need GetConsoleScreenBufferInfo via NIF. - """ - @spec terminal_size() :: {pos_integer(), pos_integer()} - def terminal_size do - rows = - case :io.rows() do - {:ok, r} -> r - _ -> 24 - end - - cols = - case :io.columns() do - {:ok, c} -> c - _ -> 80 - end - - {rows, cols} - end - - @doc """ - Returns the minimum Windows version required for full support. - """ - @spec minimum_version() :: {non_neg_integer(), non_neg_integer(), non_neg_integer()} - def minimum_version do - # Windows 10 version 1511, build 10586 - {10, 0, 10_586} - end - - @doc """ - Checks if the current Windows version meets minimum requirements. - """ - @spec meets_minimum_version?() :: boolean() - def meets_minimum_version? do - case windows_version() do - {major, minor, build} -> - compare_versions({major, minor, build}, minimum_version()) - - nil -> - false - end - end - - defp compare_versions({major, minor, build}, {min_major, min_minor, min_build}) do - {major, minor, build} >= {min_major, min_minor, min_build} - end -end diff --git a/lib/term_ui/renderer/buffer.ex b/lib/term_ui/renderer/buffer.ex deleted file mode 100644 index 483f26f8..00000000 --- a/lib/term_ui/renderer/buffer.ex +++ /dev/null @@ -1,431 +0,0 @@ -defmodule TermUI.Renderer.Buffer do - @moduledoc """ - ETS-based screen buffer for storing cells. - - The buffer uses an ETS `:ordered_set` table keyed by `{row, col}` tuples - for O(log n) access and efficient row-major iteration. This enables fast - cell lookup and sequential rendering. - - ## Usage - - {:ok, buffer} = Buffer.new(24, 80) - Buffer.set_cell(buffer, 1, 1, Cell.new("A", fg: :red)) - cell = Buffer.get_cell(buffer, 1, 1) - Buffer.destroy(buffer) - - ## Coordinates - - Rows and columns are 1-indexed to match terminal conventions. - """ - - alias TermUI.Renderer.Cell - alias TermUI.Renderer.Style - - # Maximum buffer dimensions to prevent resource exhaustion - # 500 rows x 1000 cols = 500,000 cells max (reasonable for any terminal) - @max_rows 500 - @max_cols 1000 - - # Dialyzer: max_rows/0 and max_cols/0 return specific constants, not general pos_integer() - @dialyzer {:nowarn_function, max_rows: 0, max_cols: 0, write_grapheme: 5} - - @type t :: %__MODULE__{ - table: :ets.tid(), - rows: pos_integer(), - cols: pos_integer() - } - - defstruct table: nil, - rows: 0, - cols: 0 - - @doc """ - Creates a new buffer with the given dimensions. - - Initializes all cells to empty (space with default colors). - - Maximum dimensions are #{@max_rows} rows x #{@max_cols} cols to prevent - resource exhaustion. - - ## Examples - - iex> {:ok, buffer} = Buffer.new(24, 80) - iex> buffer.rows - 24 - iex> buffer.cols - 80 - """ - @spec new(pos_integer(), pos_integer()) :: {:ok, t()} | {:error, term()} - def new(rows, cols) when is_integer(rows) and rows > 0 and is_integer(cols) and cols > 0 do - cond do - rows > @max_rows -> - {:error, {:dimensions_too_large, "rows #{rows} exceeds maximum #{@max_rows}"}} - - cols > @max_cols -> - {:error, {:dimensions_too_large, "cols #{cols} exceeds maximum #{@max_cols}"}} - - true -> - table = :ets.new(:buffer, [:ordered_set, :public]) - - buffer = %__MODULE__{ - table: table, - rows: rows, - cols: cols - } - - # Initialize all cells to empty - initialize_cells(buffer) - - {:ok, buffer} - end - end - - @doc """ - Returns the maximum allowed rows. - """ - @spec max_rows() :: pos_integer() - def max_rows, do: @max_rows - - @doc """ - Returns the maximum allowed columns. - """ - @spec max_cols() :: pos_integer() - def max_cols, do: @max_cols - - @doc """ - Destroys the buffer and frees ETS table. - - ## Examples - - iex> {:ok, buffer} = Buffer.new(10, 10) - iex> Buffer.destroy(buffer) - :ok - """ - @spec destroy(t()) :: :ok - def destroy(%__MODULE__{table: table}) do - :ets.delete(table) - :ok - end - - @doc """ - Gets the cell at the given position. - - Returns empty cell if position is out of bounds. - - ## Examples - - iex> {:ok, buffer} = Buffer.new(10, 10) - iex> cell = Buffer.get_cell(buffer, 1, 1) - iex> cell.char - " " - """ - @spec get_cell(t(), pos_integer(), pos_integer()) :: Cell.t() - def get_cell(%__MODULE__{} = buffer, row, col) do - if in_bounds?(buffer, row, col) do - case :ets.lookup(buffer.table, {row, col}) do - [{{^row, ^col}, cell}] -> cell - [] -> Cell.empty() - end - else - Cell.empty() - end - end - - @doc """ - Sets the cell at the given position. - - Returns `:ok` if successful, `{:error, :out_of_bounds}` if position is invalid. - - ## Examples - - iex> {:ok, buffer} = Buffer.new(10, 10) - iex> Buffer.set_cell(buffer, 1, 1, Cell.new("X")) - :ok - iex> Buffer.get_cell(buffer, 1, 1).char - "X" - """ - @spec set_cell(t(), pos_integer(), pos_integer(), Cell.t()) :: :ok | {:error, :out_of_bounds} - def set_cell(%__MODULE__{} = buffer, row, col, %Cell{} = cell) do - if in_bounds?(buffer, row, col) do - :ets.insert(buffer.table, {{row, col}, cell}) - :ok - else - {:error, :out_of_bounds} - end - end - - @doc """ - Sets multiple cells at once for efficiency. - - Cells is a list of `{row, col, cell}` tuples. - - ## Examples - - iex> {:ok, buffer} = Buffer.new(10, 10) - iex> cells = [{1, 1, Cell.new("A")}, {1, 2, Cell.new("B")}] - iex> Buffer.set_cells(buffer, cells) - :ok - """ - @spec set_cells(t(), [{pos_integer(), pos_integer(), Cell.t()}]) :: :ok - def set_cells(%__MODULE__{} = buffer, cells) when is_list(cells) do - entries = - cells - |> Enum.filter(fn {row, col, _cell} -> in_bounds?(buffer, row, col) end) - |> Enum.map(fn {row, col, cell} -> {{row, col}, cell} end) - - :ets.insert(buffer.table, entries) - :ok - end - - @doc """ - Clears a rectangular region, filling it with empty cells. - - ## Examples - - iex> {:ok, buffer} = Buffer.new(10, 10) - iex> Buffer.clear_region(buffer, 1, 1, 5, 5) - :ok - """ - @spec clear_region(t(), pos_integer(), pos_integer(), pos_integer(), pos_integer()) :: :ok - def clear_region(%__MODULE__{} = buffer, start_row, start_col, width, height) - when is_integer(width) and width > 0 and is_integer(height) and height > 0 do - empty = Cell.empty() - - entries = - for row <- start_row..(start_row + height - 1), - col <- start_col..(start_col + width - 1), - in_bounds?(buffer, row, col) do - {{row, col}, empty} - end - - :ets.insert(buffer.table, entries) - :ok - end - - def clear_region(%__MODULE__{}, _start_row, _start_col, _width, _height) do - # Invalid dimensions (width or height <= 0), do nothing - :ok - end - - @doc """ - Clears the entire buffer. - - ## Examples - - iex> {:ok, buffer} = Buffer.new(10, 10) - iex> Buffer.clear(buffer) - :ok - """ - @spec clear(t()) :: :ok - def clear(%__MODULE__{} = buffer) do - clear_region(buffer, 1, 1, buffer.cols, buffer.rows) - end - - @doc """ - Clears a single row. - - ## Examples - - iex> {:ok, buffer} = Buffer.new(10, 10) - iex> Buffer.clear_row(buffer, 1) - :ok - """ - @spec clear_row(t(), pos_integer()) :: :ok - def clear_row(%__MODULE__{} = buffer, row) do - clear_region(buffer, row, 1, buffer.cols, 1) - end - - @doc """ - Clears a single column. - - ## Examples - - iex> {:ok, buffer} = Buffer.new(10, 10) - iex> Buffer.clear_col(buffer, 1) - :ok - """ - @spec clear_col(t(), pos_integer()) :: :ok - def clear_col(%__MODULE__{} = buffer, col) do - clear_region(buffer, 1, col, 1, buffer.rows) - end - - @doc """ - Resizes the buffer, preserving content where possible. - - Content that fits in the new dimensions is preserved. - New areas are filled with empty cells. - - ## Examples - - iex> {:ok, buffer} = Buffer.new(10, 10) - iex> {:ok, new_buffer} = Buffer.resize(buffer, 20, 20) - iex> new_buffer.rows - 20 - """ - @spec resize(t(), pos_integer(), pos_integer()) :: {:ok, t()} | {:error, term()} - def resize(%__MODULE__{} = buffer, new_rows, new_cols) - when is_integer(new_rows) and new_rows > 0 and is_integer(new_cols) and new_cols > 0 do - cond do - new_rows > @max_rows -> - {:error, {:dimensions_too_large, "rows #{new_rows} exceeds maximum #{@max_rows}"}} - - new_cols > @max_cols -> - {:error, {:dimensions_too_large, "cols #{new_cols} exceeds maximum #{@max_cols}"}} - - true -> - # Create new buffer - new_table = :ets.new(:buffer, [:ordered_set, :public]) - - new_buffer = %__MODULE__{ - table: new_table, - rows: new_rows, - cols: new_cols - } - - # Initialize new buffer with empty cells - initialize_cells(new_buffer) - - # Copy existing content that fits - copy_rows = min(buffer.rows, new_rows) - copy_cols = min(buffer.cols, new_cols) - - for row <- 1..copy_rows, col <- 1..copy_cols do - cell = get_cell(buffer, row, col) - :ets.insert(new_table, {{row, col}, cell}) - end - - # Destroy old buffer - destroy(buffer) - - {:ok, new_buffer} - end - end - - @doc """ - Returns buffer dimensions as `{rows, cols}`. - """ - @spec dimensions(t()) :: {pos_integer(), pos_integer()} - def dimensions(%__MODULE__{rows: rows, cols: cols}) do - {rows, cols} - end - - @doc """ - Checks if a position is within buffer bounds. - """ - @spec in_bounds?(t(), pos_integer(), pos_integer()) :: boolean() - def in_bounds?(%__MODULE__{rows: rows, cols: cols}, row, col) do - row >= 1 and row <= rows and col >= 1 and col <= cols - end - - @doc """ - Iterates over all cells in row-major order. - - Calls the function with `{row, col, cell}` for each cell. - - ## Examples - - iex> {:ok, buffer} = Buffer.new(2, 2) - iex> Buffer.each(buffer, fn {row, col, cell} -> IO.inspect({row, col}) end) - :ok - """ - @spec each(t(), ({pos_integer(), pos_integer(), Cell.t()} -> any())) :: :ok - def each(%__MODULE__{} = buffer, fun) when is_function(fun, 1) do - :ets.foldl( - fn {{row, col}, cell}, _acc -> - fun.({row, col, cell}) - :ok - end, - :ok, - buffer.table - ) - end - - @doc """ - Gets all cells as a list of `{row, col, cell}` tuples in row-major order. - """ - @spec to_list(t()) :: [{pos_integer(), pos_integer(), Cell.t()}] - def to_list(%__MODULE__{} = buffer) do - buffer.table - |> :ets.tab2list() - |> Enum.map(fn {{row, col}, cell} -> {row, col, cell} end) - |> Enum.sort() - end - - @doc """ - Gets a row as a list of cells. - - Uses a single ETS match operation for efficiency instead of - individual cell lookups. - """ - @spec get_row(t(), pos_integer()) :: [Cell.t()] - def get_row(%__MODULE__{} = buffer, row) do - if row >= 1 and row <= buffer.rows do - # Single ETS operation to get all cells in row - buffer.table - |> :ets.match_object({{row, :_}, :_}) - |> Enum.sort_by(fn {{_row, col}, _cell} -> col end) - |> Enum.map(fn {{_row, _col}, cell} -> cell end) - else - # Return empty cells for out-of-bounds row - List.duplicate(Cell.empty(), buffer.cols) - end - end - - @doc """ - Writes a string starting at the given position. - - Returns the number of columns written. - - ## Examples - - iex> {:ok, buffer} = Buffer.new(10, 80) - iex> Buffer.write_string(buffer, 1, 1, "Hello") - 5 - """ - @spec write_string(t(), pos_integer(), pos_integer(), String.t(), keyword()) :: - non_neg_integer() - def write_string(%__MODULE__{} = buffer, row, col, string, opts \\ []) do - style = Keyword.get(opts, :style) - - string - |> String.graphemes() - |> Enum.reduce(col, fn grapheme, current_col -> - write_grapheme(buffer, row, current_col, grapheme, style) - end) - |> then(&(&1 - col)) - end - - defp write_grapheme(buffer, row, current_col, grapheme, style) do - if in_bounds?(buffer, row, current_col) do - cell = build_cell(grapheme, style) - set_cell(buffer, row, current_col, cell) - write_wide_placeholder(buffer, row, current_col, cell) - current_col + Cell.width(cell) - else - current_col - end - end - - defp write_wide_placeholder(buffer, row, current_col, cell) do - if Cell.wide?(cell) and in_bounds?(buffer, row, current_col + 1) do - placeholder = Cell.wide_placeholder(cell) - :ets.insert(buffer.table, {{row, current_col + 1}, placeholder}) - end - end - - defp build_cell(grapheme, nil), do: Cell.new(grapheme) - defp build_cell(grapheme, style), do: Style.to_cell(style, grapheme) - - # Private helpers - - defp initialize_cells(%__MODULE__{} = buffer) do - empty = Cell.empty() - - entries = - for row <- 1..buffer.rows, col <- 1..buffer.cols do - {{row, col}, empty} - end - - :ets.insert(buffer.table, entries) - end -end diff --git a/lib/term_ui/renderer/buffer_manager.ex b/lib/term_ui/renderer/buffer_manager.ex deleted file mode 100644 index 42bfe63b..00000000 --- a/lib/term_ui/renderer/buffer_manager.ex +++ /dev/null @@ -1,390 +0,0 @@ -defmodule TermUI.Renderer.BufferManager do - @moduledoc """ - GenServer managing double-buffered screen rendering. - - The BufferManager owns two ETS-based buffers: - - **Current buffer**: Components write to this buffer - - **Previous buffer**: Contains the last rendered frame for diffing - - After rendering, `swap_buffers/0` exchanges the buffer references atomically. - This enables efficient differential updates without copying buffer contents. - - ## Usage - - # Start the manager - {:ok, pid} = BufferManager.start_link(rows: 24, cols: 80) - - # Get buffer for writing - buffer = BufferManager.get_current_buffer() - Buffer.set_cell(buffer, 1, 1, Cell.new("X")) - - # Mark dirty after modifications - BufferManager.mark_dirty() - - # Check if render needed - if BufferManager.dirty?() do - current = BufferManager.get_current_buffer() - previous = BufferManager.get_previous_buffer() - # ... perform diff and render ... - BufferManager.swap_buffers() - BufferManager.clear_dirty() - end - - ## Concurrency - - Multiple processes can write to the current buffer concurrently via ETS. - Cell writes are atomic but unordered—last writer wins for overlapping cells. - Components should write to non-overlapping regions for deterministic results. - - **Important:** This module is designed for a single-writer pattern where one - process (typically the render loop) coordinates buffer access. If you hold a - buffer reference while another process calls `swap_buffers/1`, your writes - will go to the wrong buffer. To avoid this race condition: - - 1. Complete all writes before calling `swap_buffers/1` - 2. Use a single coordinator process for the write → swap cycle - 3. Don't cache buffer references across swap operations - - ## Typical Render Loop - - # Single process coordinates all buffer access - buffer = BufferManager.get_current_buffer() - - # All writes happen here - Buffer.write_string(buffer, 1, 1, "Hello") - BufferManager.mark_dirty() - - # Only swap after writes are complete - if BufferManager.dirty?() do - current = BufferManager.get_current_buffer() - previous = BufferManager.get_previous_buffer() - operations = Diff.diff(current, previous) - # ... render operations ... - BufferManager.swap_buffers() - BufferManager.clear_dirty() - end - - ## Direct Access - - Most operations bypass the GenServer for maximum throughput. Buffer references - and the dirty flag are stored in `:persistent_term` for lock-free access from - any process. Only `swap_buffers/1` and `resize/3` require GenServer coordination. - - ## Dirty Flag - - The dirty flag uses `:atomics` for lock-free concurrent access. Any process - can mark the buffer dirty after modifications, and the renderer checks and - clears the flag during the render cycle. - """ - - use GenServer - - alias TermUI.Renderer.Buffer - - @type t :: %__MODULE__{ - name: atom() | pid(), - current: Buffer.t(), - previous: Buffer.t(), - dirty: :atomics.atomics_ref() - } - - defstruct name: nil, - current: nil, - previous: nil, - dirty: nil - - # Client API - - @doc """ - Starts the BufferManager with the given dimensions. - - ## Options - - * `:rows` - Number of rows (required) - * `:cols` - Number of columns (required) - * `:name` - GenServer name (default: `__MODULE__`); use `nil` for an unnamed manager - - ## Examples - - {:ok, pid} = BufferManager.start_link(rows: 24, cols: 80) - """ - @spec start_link(keyword()) :: GenServer.on_start() - def start_link(opts) do - case Keyword.get(opts, :name, __MODULE__) do - nil -> GenServer.start_link(__MODULE__, opts) - name -> GenServer.start_link(__MODULE__, opts, name: name) - end - end - - @doc """ - Returns a child specification for starting in a supervisor. - """ - @spec child_spec(keyword()) :: Supervisor.child_spec() - def child_spec(opts) do - %{ - id: __MODULE__, - start: {__MODULE__, :start_link, [opts]}, - restart: :permanent, - shutdown: 5000, - type: :worker - } - end - - @doc """ - Returns the current buffer for writing. - - Components use this buffer for all cell modifications. - This is a direct access operation (no GenServer call). - """ - @spec get_current_buffer(GenServer.server()) :: Buffer.t() - def get_current_buffer(server \\ __MODULE__) do - name = server_name(server) - :persistent_term.get({__MODULE__, name, :current}) - end - - @doc """ - Returns the previous buffer for diffing. - - The renderer compares current against previous to identify changes. - This is a direct access operation (no GenServer call). - """ - @spec get_previous_buffer(GenServer.server()) :: Buffer.t() - def get_previous_buffer(server \\ __MODULE__) do - name = server_name(server) - :persistent_term.get({__MODULE__, name, :previous}) - end - - # Convert server reference to name for persistent_term keys - defp server_name(name) when is_atom(name), do: name - defp server_name(pid) when is_pid(pid), do: GenServer.call(pid, :get_name) - - @doc """ - Atomically swaps the current and previous buffers. - - After rendering, call this to make the current frame the new previous - frame for the next render cycle. This is O(1)—only references swap. - """ - @spec swap_buffers(GenServer.server()) :: :ok - def swap_buffers(server \\ __MODULE__) do - GenServer.call(server, :swap_buffers) - end - - @doc """ - Returns the buffer dimensions as `{rows, cols}`. - - This is a direct access operation (no GenServer call). - """ - @spec dimensions(GenServer.server()) :: {pos_integer(), pos_integer()} - def dimensions(server \\ __MODULE__) do - buffer = get_current_buffer(server) - Buffer.dimensions(buffer) - end - - @doc """ - Resizes both buffers to new dimensions. - - Content is preserved where it fits within the new dimensions. - """ - @spec resize(GenServer.server(), pos_integer(), pos_integer()) :: :ok - def resize(server \\ __MODULE__, rows, cols) do - GenServer.call(server, {:resize, rows, cols}) - end - - @doc """ - Clears the entire current buffer. - - This is a direct access operation (no GenServer call). - """ - @spec clear_current(GenServer.server()) :: :ok - def clear_current(server \\ __MODULE__) do - buffer = get_current_buffer(server) - Buffer.clear(buffer) - end - - @doc """ - Clears a single row in the current buffer. - - This is a direct access operation (no GenServer call). - """ - @spec clear_row(GenServer.server(), pos_integer()) :: :ok - def clear_row(server \\ __MODULE__, row) do - buffer = get_current_buffer(server) - Buffer.clear_row(buffer, row) - end - - @doc """ - Clears a rectangular region in the current buffer. - - This is a direct access operation (no GenServer call). - """ - @spec clear_region( - GenServer.server(), - pos_integer(), - pos_integer(), - pos_integer(), - pos_integer() - ) :: - :ok - def clear_region(server \\ __MODULE__, start_row, start_col, width, height) do - buffer = get_current_buffer(server) - Buffer.clear_region(buffer, start_row, start_col, width, height) - end - - @doc """ - Marks the buffer as dirty, indicating it needs rendering. - - This uses an atomic operation and can be called from any process. - This is a direct access operation (no GenServer call). - """ - @spec mark_dirty(GenServer.server()) :: :ok - def mark_dirty(server \\ __MODULE__) do - name = server_name(server) - dirty = :persistent_term.get({__MODULE__, name, :dirty}) - :atomics.put(dirty, 1, 1) - :ok - end - - @doc """ - Clears the dirty flag after rendering. - - This is a direct access operation (no GenServer call). - """ - @spec clear_dirty(GenServer.server()) :: :ok - def clear_dirty(server \\ __MODULE__) do - name = server_name(server) - dirty = :persistent_term.get({__MODULE__, name, :dirty}) - :atomics.put(dirty, 1, 0) - :ok - end - - @doc """ - Returns whether the buffer is dirty and needs rendering. - - This is a direct access operation (no GenServer call). - """ - @spec dirty?(GenServer.server()) :: boolean() - def dirty?(server \\ __MODULE__) do - name = server_name(server) - dirty = :persistent_term.get({__MODULE__, name, :dirty}) - :atomics.get(dirty, 1) == 1 - end - - @doc """ - Sets a cell in the current buffer. - - Convenience function that delegates to Buffer.set_cell/4. - """ - @spec set_cell(GenServer.server(), pos_integer(), pos_integer(), TermUI.Renderer.Cell.t()) :: - :ok | {:error, :out_of_bounds} - def set_cell(server \\ __MODULE__, row, col, cell) do - buffer = get_current_buffer(server) - Buffer.set_cell(buffer, row, col, cell) - end - - @doc """ - Sets multiple cells in the current buffer. - - Cells is a list of `{row, col, cell}` tuples. - """ - @spec set_cells(GenServer.server(), [{pos_integer(), pos_integer(), TermUI.Renderer.Cell.t()}]) :: - :ok - def set_cells(server \\ __MODULE__, cells) do - buffer = get_current_buffer(server) - Buffer.set_cells(buffer, cells) - end - - @doc """ - Gets a cell from the current buffer. - - Convenience function that delegates to Buffer.get_cell/3. - """ - @spec get_cell(GenServer.server(), pos_integer(), pos_integer()) :: TermUI.Renderer.Cell.t() - def get_cell(server \\ __MODULE__, row, col) do - buffer = get_current_buffer(server) - Buffer.get_cell(buffer, row, col) - end - - @doc """ - Writes a string to the current buffer. - - Convenience function that delegates to Buffer.write_string/4. - """ - @spec write_string(GenServer.server(), pos_integer(), pos_integer(), String.t(), keyword()) :: - non_neg_integer() - def write_string(server \\ __MODULE__, row, col, string, opts \\ []) do - buffer = get_current_buffer(server) - Buffer.write_string(buffer, row, col, string, opts) - end - - # Server Callbacks - - @impl true - def init(opts) do - rows = Keyword.fetch!(opts, :rows) - cols = Keyword.fetch!(opts, :cols) - name = Keyword.get(opts, :name, __MODULE__) || self() - - {:ok, current} = Buffer.new(rows, cols) - {:ok, previous} = Buffer.new(rows, cols) - - # Create atomic for dirty flag (1 element, unsigned 64-bit) - dirty = :atomics.new(1, signed: false) - - # Store references in persistent_term for direct access - :persistent_term.put({__MODULE__, name, :current}, current) - :persistent_term.put({__MODULE__, name, :previous}, previous) - :persistent_term.put({__MODULE__, name, :dirty}, dirty) - - state = %__MODULE__{ - name: name, - current: current, - previous: previous, - dirty: dirty - } - - {:ok, state} - end - - @impl true - def handle_call(:get_name, _from, state) do - {:reply, state.name, state} - end - - @impl true - def handle_call(:swap_buffers, _from, state) do - new_state = %{state | current: state.previous, previous: state.current} - - # Update persistent_term references - :persistent_term.put({__MODULE__, state.name, :current}, new_state.current) - :persistent_term.put({__MODULE__, state.name, :previous}, new_state.previous) - - {:reply, :ok, new_state} - end - - @impl true - def handle_call({:resize, rows, cols}, _from, state) do - {:ok, new_current} = Buffer.resize(state.current, rows, cols) - {:ok, new_previous} = Buffer.resize(state.previous, rows, cols) - - new_state = %{state | current: new_current, previous: new_previous} - - # Update persistent_term references - :persistent_term.put({__MODULE__, state.name, :current}, new_current) - :persistent_term.put({__MODULE__, state.name, :previous}, new_previous) - - {:reply, :ok, new_state} - end - - @impl true - def terminate(_reason, state) do - # Clean up persistent_term entries - :persistent_term.erase({__MODULE__, state.name, :current}) - :persistent_term.erase({__MODULE__, state.name, :previous}) - :persistent_term.erase({__MODULE__, state.name, :dirty}) - - # Clean up ETS tables - Buffer.destroy(state.current) - Buffer.destroy(state.previous) - :ok - end -end diff --git a/lib/term_ui/renderer/diff.ex b/lib/term_ui/renderer/diff.ex deleted file mode 100644 index 71482b0d..00000000 --- a/lib/term_ui/renderer/diff.ex +++ /dev/null @@ -1,319 +0,0 @@ -defmodule TermUI.Renderer.Diff do - @moduledoc """ - Differential rendering algorithm for terminal UI. - - Compares current and previous buffers to produce minimal render operations. - The algorithm identifies changed cells, groups them into spans, and generates - operations for cursor movement, style changes, and text output. - - ## Usage - - operations = Diff.diff(current_buffer, previous_buffer) - # => [{:move, 1, 5}, {:style, style}, {:text, "Hello"}, ...] - - ## Operation Types - - * `{:move, row, col}` - Move cursor to position - * `{:style, style}` - Set text style (colors, attributes) - * `{:text, string}` - Output text at current cursor position - * `:reset` - Reset all style attributes - - ## Algorithm - - 1. Iterate rows in order (row-major for efficient terminal output) - 2. For each row, find spans of changed cells - 3. Optimize spans by merging small gaps - 4. Generate render operations for each span - 5. Track style to emit deltas only - """ - - alias TermUI.Renderer.Buffer - alias TermUI.Renderer.Cell - alias TermUI.Renderer.DisplayWidth - alias TermUI.Renderer.Style - - @type operation :: - {:move, pos_integer(), pos_integer()} - | {:style, Style.t()} - | {:text, String.t()} - | :reset - - @type span :: %{ - row: pos_integer(), - start_col: pos_integer(), - end_col: pos_integer(), - cells: [Cell.t()] - } - - # Minimum gap size (in columns) to merge spans - # If gap is smaller than cursor move cost, include unchanged cells - @merge_gap_threshold 3 - - @doc """ - Compares two buffers and returns a list of render operations. - - The current buffer contains the new frame to render, and the previous - buffer contains the last rendered frame. Only differences are output. - - ## Examples - - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - Buffer.write_string(current, 1, 1, "Hello") - - operations = Diff.diff(current, previous) - # => [{:move, 1, 1}, {:style, %Style{}}, {:text, "Hello"}] - """ - @spec diff(Buffer.t(), Buffer.t()) :: [operation()] - def diff(current, previous) do - {rows, cols} = Buffer.dimensions(current) - - 1..rows - |> Enum.flat_map(fn row -> - diff_row(current, previous, row, cols) - end) - |> optimize_operations() - end - - @doc """ - Compares a single row and returns render operations for changed spans. - """ - @spec diff_row(Buffer.t(), Buffer.t(), pos_integer(), pos_integer()) :: [operation()] - def diff_row(current, previous, row, _cols) do - # Get all cells for the row using optimized batch lookup - current_row = Buffer.get_row(current, row) - previous_row = Buffer.get_row(previous, row) - - # Convert to indexed format for find_changed_spans - current_cells = - current_row |> Enum.with_index(1) |> Enum.map(fn {cell, col} -> {col, cell} end) - - # Build a map for quick lookup of current cells by column - current_cells_map = Map.new(current_cells) - - previous_cells = - previous_row |> Enum.with_index(1) |> Enum.map(fn {cell, col} -> {col, cell} end) - - # Find changed spans - spans = find_changed_spans(current_cells, previous_cells, row) - - # Merge small gaps between spans, using actual cells from current buffer for gaps - merged_spans = merge_spans(spans, current_cells_map) - - # Generate operations for each span - Enum.flat_map(merged_spans, &span_to_operations/1) - end - - @doc """ - Finds spans of changed cells within a row. - - Returns a list of spans, where each span contains contiguous changed cells. - """ - @spec find_changed_spans( - [{pos_integer(), Cell.t()}], - [{pos_integer(), Cell.t()}], - pos_integer() - ) :: [span()] - def find_changed_spans(current_cells, previous_cells, row) do - current_cells - |> Enum.zip(previous_cells) - |> Enum.reduce({[], nil}, fn {{col, curr}, {_col, prev}}, acc -> - process_cell_pair(acc, col, curr, prev, row) - end) - |> finalize_last_span() - |> Enum.reverse() - end - - defp process_cell_pair({spans, current_span}, col, curr, prev, row) do - if Cell.equal?(curr, prev) do - close_span_if_any(spans, current_span) - else - extend_or_start_span(spans, current_span, col, curr, row) - end - end - - defp close_span_if_any(spans, nil), do: {spans, nil} - defp close_span_if_any(spans, span), do: {[finalize_span(span) | spans], nil} - - defp extend_or_start_span(spans, nil, col, curr, row) do - new_span = %{row: row, start_col: col, end_col: col, cells: [curr]} - {spans, new_span} - end - - defp extend_or_start_span(spans, span, col, curr, _row) do - # Prepend for O(1) instead of append O(n) - reversed in finalize_span - extended = %{span | end_col: col, cells: [curr | span.cells]} - {spans, extended} - end - - @doc """ - Merges adjacent spans when the gap is smaller than cursor move cost. - - This reduces cursor movements by including unchanged cells in the output - when it's cheaper than moving the cursor around them. - - The current_cells_map is used to fill gaps with actual cell content from - the current buffer, rather than empty cells. - """ - @spec merge_spans([span()], map()) :: [span()] - def merge_spans([], _current_cells_map), do: [] - def merge_spans([span], _current_cells_map), do: [span] - - def merge_spans(spans, current_cells_map) do - spans - |> Enum.reduce([], fn span, acc -> merge_span_into_acc(span, acc, current_cells_map) end) - |> Enum.reverse() - end - - defp merge_span_into_acc(span, [], _current_cells_map), do: [span] - - defp merge_span_into_acc(span, [prev | rest], current_cells_map) do - gap = span.start_col - prev.end_col - 1 - - if gap <= @merge_gap_threshold and gap >= 0 do - merged = create_merged_span(prev, span, gap, current_cells_map) - [merged | rest] - else - [span, prev | rest] - end - end - - defp create_merged_span(prev, span, _gap, current_cells_map) do - # Get actual cells from current buffer for the gap positions - gap_cells = - for col <- (prev.end_col + 1)..(span.start_col - 1) do - Map.get(current_cells_map, col, Cell.empty()) - end - - %{ - row: prev.row, - start_col: prev.start_col, - end_col: span.end_col, - cells: prev.cells ++ gap_cells ++ span.cells - } - end - - @doc """ - Converts a span to render operations. - - Generates move, style, and text operations for the span. - Splits on style changes to minimize SGR sequence overhead. - """ - @spec span_to_operations(span()) :: [operation()] - def span_to_operations(%{row: row, start_col: start_col, cells: cells}) do - # Split cells by style for efficient SGR output - style_groups = group_by_style(cells) - - # Generate operations - # Reset style before move to prevent style bleeding from previous position - [:reset, {:move, row, start_col} | style_groups_to_operations(style_groups, start_col)] - end - - @doc """ - Checks if a cell contains a wide character (display width > 1). - """ - @spec wide_char?(Cell.t()) :: boolean() - def wide_char?(%Cell{char: char}) do - DisplayWidth.width(char) > 1 - end - - # Private functions - - defp finalize_span(span) do - # Reverse cells (they were prepended for O(1) performance) - # then handle wide characters - ensure pairs stay together - cells = span.cells |> Enum.reverse() |> handle_wide_chars() - %{span | cells: cells} - end - - defp finalize_last_span({spans, nil}), do: spans - defp finalize_last_span({spans, span}), do: [finalize_span(span) | spans] - - defp handle_wide_chars(cells) do - # For now, just return cells as-is - # Wide character handling will ensure both cells are included - cells - end - - defp group_by_style(cells) do - cells - |> Enum.reduce([], fn cell, acc -> add_cell_to_style_groups(cell, acc) end) - |> Enum.reverse() - end - - defp add_cell_to_style_groups(cell, []) do - style = cell_to_style(cell) - [{style, [cell]}] - end - - defp add_cell_to_style_groups(cell, [{prev_style, prev_cells} | rest]) do - style = cell_to_style(cell) - - if Style.equal?(style, prev_style) do - # Prepend for O(1) instead of append O(n) - reversed in style_groups_to_operations - [{prev_style, [cell | prev_cells]} | rest] - else - [{style, [cell]}, {prev_style, prev_cells} | rest] - end - end - - defp cell_to_style(%Cell{fg: fg, bg: bg, attrs: attrs}) do - %Style{fg: fg, bg: bg, attrs: attrs} - end - - defp style_groups_to_operations(groups, _start_col) do - Enum.flat_map(groups, fn {style, cells} -> - # Reverse cells (they were prepended for O(1) performance) - text = cells |> Enum.reverse() |> Enum.map_join("", & &1.char) - [{:style, style}, {:text, text}] - end) - end - - defp optimize_operations(operations) do - operations - |> merge_adjacent_text() - |> remove_redundant_styles() - end - - defp merge_adjacent_text(operations) do - operations - |> Enum.reduce([], fn op, acc -> - case {op, acc} do - {{:text, text1}, [{:text, text2} | rest]} -> - [{:text, text2 <> text1} | rest] - - _ -> - [op | acc] - end - end) - |> Enum.reverse() - end - - defp remove_redundant_styles(operations) do - {result, _last_style} = - Enum.reduce(operations, {[], nil}, fn op, acc -> filter_redundant_style(op, acc) end) - - Enum.reverse(result) - end - - defp filter_redundant_style({:style, style}, {acc, last_style}) do - if last_style && Style.equal?(style, last_style) do - {acc, last_style} - else - {[{:style, style} | acc], style} - end - end - - # A :reset op writes "\e[0m" to the terminal, clearing all SGR state. - # We must forget the tracked last_style so that the next :style op is - # re-emitted even if it matches what was active before the reset -- - # otherwise multi-row spans that share a style lose their styling on - # every row but the first. - defp filter_redundant_style(:reset, {acc, _last_style}) do - {[:reset | acc], nil} - end - - defp filter_redundant_style(op, {acc, last_style}) do - {[op | acc], last_style} - end -end diff --git a/lib/term_ui/renderer/framerate_limiter.ex b/lib/term_ui/renderer/framerate_limiter.ex deleted file mode 100644 index 0796b751..00000000 --- a/lib/term_ui/renderer/framerate_limiter.ex +++ /dev/null @@ -1,497 +0,0 @@ -defmodule TermUI.Renderer.FramerateLimiter do - @moduledoc """ - Caps rendering to a maximum FPS with dirty flag coalescing. - - The FramerateLimiter schedules render cycles at regular intervals (default 60 FPS) - and only renders when the buffer is dirty. Multiple buffer writes between frames - coalesce into a single render, creating smooth animation while being efficient. - - ## Features - - * **Frame timing** - Configurable FPS (30, 60, 120) - * **Drift compensation** - Adjusts intervals based on actual elapsed time - * **Dirty coalescing** - Multiple writes become single render - * **Immediate mode** - Bypass frame timing for urgent updates - * **Performance metrics** - Tracks FPS, render time, skip ratio - - ## Usage - - # Start with default 60 FPS - {:ok, pid} = FramerateLimiter.start_link(render_callback: fn -> :ok end) - - # Start with custom FPS - {:ok, pid} = FramerateLimiter.start_link(fps: 120, render_callback: fn -> :ok end) - - # Mark buffer as dirty (triggers render on next tick) - FramerateLimiter.mark_dirty() - - # Force immediate render - FramerateLimiter.render_immediate() - - # Get performance metrics - FramerateLimiter.stats() - - ## Render Callback - - The render callback is invoked on each frame tick when the buffer is dirty. - It should perform the actual rendering work (diff, cursor optimization, etc.). - """ - - use GenServer - - @type fps :: 30 | 60 | 120 - - @type stats :: %{ - rendered_frames: non_neg_integer(), - skipped_frames: non_neg_integer(), - total_frames: non_neg_integer(), - actual_fps: float(), - avg_render_time_us: float(), - slow_frames: non_neg_integer() - } - - @type t :: %__MODULE__{ - fps: fps(), - interval_ms: float(), - render_callback: (-> any()), - dirty_check: (-> boolean()), - dirty_clear: (-> :ok), - paused: boolean(), - timer_ref: reference() | nil, - last_tick: integer(), - rendered_frames: non_neg_integer(), - skipped_frames: non_neg_integer(), - render_times: [non_neg_integer()], - slow_frames: non_neg_integer(), - frame_timestamps: [integer()], - internal_dirty: :atomics.atomics_ref() | nil - } - - defstruct fps: 60, - interval_ms: 16.67, - render_callback: nil, - dirty_check: nil, - dirty_clear: nil, - paused: false, - timer_ref: nil, - last_tick: 0, - rendered_frames: 0, - skipped_frames: 0, - render_times: [], - slow_frames: 0, - frame_timestamps: [], - internal_dirty: nil - - # Dialyzer: Functions with unmatched return values - @dialyzer {:nowarn_function, schedule_tick: 1, calculate_stats: 1, handle_call: 3} - - # Client API - - @doc """ - Starts the FramerateLimiter. - - ## Options - - * `:fps` - Target FPS: 30, 60, or 120 (default: 60) - * `:render_callback` - Function to call for rendering (required) - * `:dirty_check` - Function returning true if render needed (optional) - * `:dirty_clear` - Function to clear dirty flag after render (optional) - * `:name` - GenServer name (default: `__MODULE__`) - - If `:dirty_check` and `:dirty_clear` are not provided, an internal dirty flag - is created. For integration with BufferManager, pass its dirty functions: - - ## Examples - - # Standalone with internal dirty flag - {:ok, pid} = FramerateLimiter.start_link( - fps: 60, - render_callback: fn -> render_frame() end - ) - - # With BufferManager integration - {:ok, pid} = FramerateLimiter.start_link( - fps: 60, - render_callback: fn -> render_frame() end, - dirty_check: fn -> BufferManager.dirty?(manager) end, - dirty_clear: fn -> BufferManager.clear_dirty(manager) end - ) - """ - @spec start_link(keyword()) :: GenServer.on_start() - def start_link(opts) do - name = Keyword.get(opts, :name, __MODULE__) - GenServer.start_link(__MODULE__, opts, name: name) - end - - @doc """ - Marks the internal dirty flag (for standalone use without BufferManager). - - When using BufferManager integration, call `BufferManager.mark_dirty/1` instead. - """ - @spec mark_dirty(GenServer.server()) :: :ok - def mark_dirty(server \\ __MODULE__) do - GenServer.call(server, :mark_dirty) - end - - @doc """ - Clears the internal dirty flag (for standalone use without BufferManager). - - When using BufferManager integration, this is called automatically via the - `dirty_clear` callback after each render. - """ - @spec clear_dirty(GenServer.server()) :: :ok - def clear_dirty(server \\ __MODULE__) do - GenServer.call(server, :clear_dirty) - end - - @doc """ - Returns whether the internal dirty flag is set (for standalone use). - - When using BufferManager integration, call `BufferManager.dirty?/1` instead. - """ - @spec dirty?(GenServer.server()) :: boolean() - def dirty?(server \\ __MODULE__) do - GenServer.call(server, :dirty?) - end - - @doc """ - Forces an immediate render, bypassing frame timing. - - Use for urgent updates that can't wait for the next tick. - """ - @spec render_immediate(GenServer.server()) :: :ok - def render_immediate(server \\ __MODULE__) do - GenServer.call(server, :render_immediate) - end - - @doc """ - Pauses frame timing (stops render ticks). - """ - @spec pause(GenServer.server()) :: :ok - def pause(server \\ __MODULE__) do - GenServer.call(server, :pause) - end - - @doc """ - Resumes frame timing after pause. - """ - @spec resume(GenServer.server()) :: :ok - def resume(server \\ __MODULE__) do - GenServer.call(server, :resume) - end - - @doc """ - Returns whether frame timing is paused. - """ - @spec paused?(GenServer.server()) :: boolean() - def paused?(server \\ __MODULE__) do - GenServer.call(server, :paused?) - end - - @doc """ - Changes the target FPS. - """ - @spec set_fps(GenServer.server(), fps()) :: :ok - def set_fps(server \\ __MODULE__, fps) do - GenServer.call(server, {:set_fps, fps}) - end - - @doc """ - Returns the current target FPS. - """ - @spec get_fps(GenServer.server()) :: fps() - def get_fps(server \\ __MODULE__) do - GenServer.call(server, :get_fps) - end - - @doc """ - Returns performance statistics. - - Returns a map with: - * `:rendered_frames` - Number of frames rendered - * `:skipped_frames` - Number of clean frames skipped - * `:total_frames` - Total frame ticks - * `:actual_fps` - Calculated FPS from recent frames - * `:avg_render_time_us` - Average render time in microseconds - * `:slow_frames` - Frames that exceeded target interval - """ - @spec stats(GenServer.server()) :: stats() - def stats(server \\ __MODULE__) do - GenServer.call(server, :stats) - end - - @doc """ - Resets performance statistics. - """ - @spec reset_stats(GenServer.server()) :: :ok - def reset_stats(server \\ __MODULE__) do - GenServer.call(server, :reset_stats) - end - - # Server Callbacks - - @impl true - def init(opts) do - fps = Keyword.get(opts, :fps, 60) - render_callback = Keyword.fetch!(opts, :render_callback) - - interval_ms = fps_to_interval(fps) - - # Set up dirty callbacks - use provided ones or create internal atomics - {dirty_check, dirty_clear, internal_dirty} = - case {Keyword.get(opts, :dirty_check), Keyword.get(opts, :dirty_clear)} do - {check, clear} when is_function(check, 0) and is_function(clear, 0) -> - {check, clear, nil} - - _ -> - # Create internal atomic for standalone use (stored in state, not process dict) - dirty = :atomics.new(1, signed: false) - check = fn -> :atomics.get(dirty, 1) == 1 end - - clear = fn -> - :atomics.put(dirty, 1, 0) - :ok - end - - {check, clear, dirty} - end - - state = %__MODULE__{ - fps: fps, - interval_ms: interval_ms, - render_callback: render_callback, - dirty_check: dirty_check, - dirty_clear: dirty_clear, - last_tick: System.monotonic_time(:microsecond), - internal_dirty: internal_dirty - } - - # Schedule first tick - timer_ref = schedule_tick(state) - state = %{state | timer_ref: timer_ref} - - {:ok, state} - end - - @impl true - def handle_call(:mark_dirty, _from, state) do - # Use internal dirty flag if available (standalone mode) - if state.internal_dirty do - :atomics.put(state.internal_dirty, 1, 1) - end - - {:reply, :ok, state} - end - - @impl true - def handle_call(:clear_dirty, _from, state) do - # Use internal dirty flag if available (standalone mode) - if state.internal_dirty do - :atomics.put(state.internal_dirty, 1, 0) - end - - {:reply, :ok, state} - end - - @impl true - def handle_call(:dirty?, _from, state) do - # Use internal dirty flag if available (standalone mode) - result = - if state.internal_dirty do - :atomics.get(state.internal_dirty, 1) == 1 - else - false - end - - {:reply, result, state} - end - - @impl true - def handle_call(:render_immediate, _from, state) do - state = do_render(state) - {:reply, :ok, state} - end - - @impl true - def handle_call(:pause, _from, state) do - # Cancel pending timer - state = - if state.timer_ref do - Process.cancel_timer(state.timer_ref) - %{state | timer_ref: nil, paused: true} - else - %{state | paused: true} - end - - {:reply, :ok, state} - end - - @impl true - def handle_call(:resume, _from, state) do - state = - if state.paused do - timer_ref = schedule_tick(state) - - %{ - state - | timer_ref: timer_ref, - paused: false, - last_tick: System.monotonic_time(:microsecond) - } - else - state - end - - {:reply, :ok, state} - end - - @impl true - def handle_call(:paused?, _from, state) do - {:reply, state.paused, state} - end - - @impl true - def handle_call({:set_fps, fps}, _from, state) do - interval_ms = fps_to_interval(fps) - {:reply, :ok, %{state | fps: fps, interval_ms: interval_ms}} - end - - @impl true - def handle_call(:get_fps, _from, state) do - {:reply, state.fps, state} - end - - @impl true - def handle_call(:stats, _from, state) do - stats = calculate_stats(state) - {:reply, stats, state} - end - - @impl true - def handle_call(:reset_stats, _from, state) do - state = %{ - state - | rendered_frames: 0, - skipped_frames: 0, - render_times: [], - slow_frames: 0, - frame_timestamps: [] - } - - {:reply, :ok, state} - end - - @impl true - def handle_info(:tick, state) do - now = System.monotonic_time(:microsecond) - - # Check if dirty using callback - is_dirty = state.dirty_check.() - - state = - if is_dirty do - do_render(state) - else - %{state | skipped_frames: state.skipped_frames + 1} - end - - # Record timestamp for FPS calculation - state = record_frame_timestamp(state, now) - - # Schedule next tick with drift compensation - elapsed_us = now - state.last_tick - target_us = trunc(state.interval_ms * 1000) - drift = elapsed_us - target_us - next_interval = max(0, target_us - drift) - - timer_ref = Process.send_after(self(), :tick, div(next_interval, 1000)) - - state = %{state | timer_ref: timer_ref, last_tick: now} - - {:noreply, state} - end - - # Private functions - - defp fps_to_interval(30), do: 33.33 - defp fps_to_interval(60), do: 16.67 - defp fps_to_interval(120), do: 8.33 - - defp schedule_tick(state) do - Process.send_after(self(), :tick, trunc(state.interval_ms)) - end - - defp do_render(state) do - start_time = System.monotonic_time(:microsecond) - - # Call render callback - state.render_callback.() - - # Clear dirty flag using callback - state.dirty_clear.() - - end_time = System.monotonic_time(:microsecond) - render_time = end_time - start_time - - # Check for slow frame - target_us = trunc(state.interval_ms * 1000) - slow_frames = if render_time > target_us, do: state.slow_frames + 1, else: state.slow_frames - - # Keep last 60 render times for average - render_times = Enum.take([render_time | state.render_times], 60) - - %{ - state - | rendered_frames: state.rendered_frames + 1, - render_times: render_times, - slow_frames: slow_frames - } - end - - defp record_frame_timestamp(state, timestamp) do - # Keep last 60 timestamps for FPS calculation - timestamps = Enum.take([timestamp | state.frame_timestamps], 60) - %{state | frame_timestamps: timestamps} - end - - defp calculate_stats(state) do - total_frames = state.rendered_frames + state.skipped_frames - - # Calculate actual FPS from timestamps - actual_fps = - case state.frame_timestamps do - [latest | rest] when length(rest) >= 1 -> - oldest = List.last(rest) - duration_s = (latest - oldest) / 1_000_000 - count = length(rest) - - if duration_s > 0 do - count / duration_s - else - 0.0 - end - - _ -> - 0.0 - end - - # Calculate average render time - avg_render_time_us = - case state.render_times do - [] -> - 0.0 - - times -> - Enum.sum(times) / length(times) - end - - %{ - rendered_frames: state.rendered_frames, - skipped_frames: state.skipped_frames, - total_frames: total_frames, - actual_fps: Float.round(actual_fps, 2), - avg_render_time_us: Float.round(avg_render_time_us, 2), - slow_frames: state.slow_frames - } - end -end diff --git a/lib/term_ui/renderer/sequence_buffer.ex b/lib/term_ui/renderer/sequence_buffer.ex deleted file mode 100644 index dfe7461d..00000000 --- a/lib/term_ui/renderer/sequence_buffer.ex +++ /dev/null @@ -1,284 +0,0 @@ -defmodule TermUI.Renderer.SequenceBuffer do - @moduledoc """ - Batches escape sequences for efficient terminal output. - - Accumulates escape sequences and text in an iolist, then flushes to output - when threshold is reached or frame completes. This reduces system call - overhead and ensures atomic frame updates. - - ## Features - - * **Iolist accumulator** - Efficient append without copying - * **Size threshold** - Auto-flush when buffer exceeds limit - * **SGR combining** - Merges adjacent style sequences - * **Statistics** - Tracks bytes written and flush count - - ## Usage - - buffer = SequenceBuffer.new() - buffer = SequenceBuffer.append(buffer, "\\e[1;31m") - buffer = SequenceBuffer.append(buffer, "Hello") - {data, buffer} = SequenceBuffer.flush(buffer) - IO.binwrite(data) - - ## SGR Combining - - Adjacent SGR sequences are combined into a single sequence: - - # Instead of: ESC[1m ESC[31m ESC[4m - # Produces: ESC[1;31;4m - - This reduces output bytes and terminal parsing overhead. - """ - - alias TermUI.Renderer.Style - alias TermUI.SGR - - # Dialyzer: new/0 and new/1 return specific struct types with defaults - @dialyzer {:nowarn_function, new: 0, new: 1} - - @type t :: %__MODULE__{ - buffer: iolist(), - size: non_neg_integer(), - threshold: pos_integer(), - pending_sgr: [String.t()], - last_style: Style.t() | nil, - total_bytes: non_neg_integer(), - flush_count: non_neg_integer() - } - - defstruct buffer: [], - size: 0, - threshold: 4096, - pending_sgr: [], - last_style: nil, - total_bytes: 0, - flush_count: 0 - - @doc """ - Creates a new sequence buffer with default threshold (4KB). - """ - @spec new() :: t() - def new do - %__MODULE__{} - end - - @doc """ - Creates a new sequence buffer with specified threshold. - - ## Options - - * `:threshold` - Flush threshold in bytes (default: 4096) - """ - @spec new(keyword()) :: t() - def new(opts) do - threshold = Keyword.get(opts, :threshold, 4096) - %__MODULE__{threshold: threshold} - end - - @doc """ - Appends data to the buffer. - - Returns `{:ok, buffer}` normally, or `{:flush, data, buffer}` if the - threshold was exceeded and an auto-flush occurred. - """ - @spec append(t(), iodata()) :: {:ok, t()} | {:flush, iodata(), t()} - def append(%__MODULE__{} = buffer, data) do - data_size = IO.iodata_length(data) - new_size = buffer.size + data_size - - # Prepend to buffer (will reverse on flush) - new_buffer = %{buffer | buffer: [data | buffer.buffer], size: new_size} - - if new_size >= buffer.threshold do - {flushed, reset_buffer} = flush(new_buffer) - {:flush, flushed, reset_buffer} - else - {:ok, new_buffer} - end - end - - @doc """ - Appends data to the buffer, automatically writing flushed data to IO. - - When the buffer threshold is exceeded, the accumulated data is written - to IO immediately and the buffer is reset. This ensures no data is lost - during large render operations. - """ - @spec append!(t(), iodata()) :: t() - def append!(%__MODULE__{} = buffer, data) do - case append(buffer, data) do - {:ok, new_buffer} -> - new_buffer - - {:flush, flushed_data, new_buffer} -> - # Write the flushed data immediately instead of discarding it - IO.write(flushed_data) - new_buffer - end - end - - @doc """ - Appends a style, emitting SGR sequence with delta from last style. - - Only emits parameters that changed from the previous style. - """ - @spec append_style(t(), Style.t()) :: t() - def append_style(%__MODULE__{} = buffer, %Style{} = style) do - sgr_params = style_to_sgr_params(style, buffer.last_style) - - if sgr_params == [] do - # No change from last style - buffer - else - sgr_sequence = SGR.build_sequence(sgr_params) - new_buffer = append!(buffer, sgr_sequence) - %{new_buffer | last_style: style} - end - end - - @doc """ - Appends multiple SGR parameters to be combined into a single sequence. - - Call `emit_pending_sgr/1` to output the combined sequence. - """ - @spec add_sgr_param(t(), String.t()) :: t() - def add_sgr_param(%__MODULE__{} = buffer, param) do - %{buffer | pending_sgr: [param | buffer.pending_sgr]} - end - - @doc """ - Emits any pending SGR parameters as a combined sequence. - """ - @spec emit_pending_sgr(t()) :: t() - def emit_pending_sgr(%__MODULE__{pending_sgr: []} = buffer), do: buffer - - def emit_pending_sgr(%__MODULE__{pending_sgr: params} = buffer) do - # Reverse to maintain order - sgr_sequence = SGR.build_sequence(Enum.reverse(params)) - new_buffer = append!(buffer, sgr_sequence) - %{new_buffer | pending_sgr: []} - end - - @doc """ - Flushes the buffer, returning accumulated data and resetting. - - Returns `{iodata, new_buffer}`. - """ - @spec flush(t()) :: {iodata(), t()} - def flush(%__MODULE__{} = buffer) do - # Emit any pending SGR first - buffer = emit_pending_sgr(buffer) - - # Reverse buffer to get correct order - data = Enum.reverse(buffer.buffer) - bytes = buffer.size - - new_buffer = %{ - buffer - | buffer: [], - size: 0, - total_bytes: buffer.total_bytes + bytes, - flush_count: buffer.flush_count + 1 - } - - {data, new_buffer} - end - - @doc """ - Returns the current buffer size in bytes. - """ - @spec size(t()) :: non_neg_integer() - def size(%__MODULE__{size: size}), do: size - - @doc """ - Returns whether the buffer is empty. - """ - @spec empty?(t()) :: boolean() - def empty?(%__MODULE__{size: 0}), do: true - def empty?(%__MODULE__{}), do: false - - @doc """ - Returns buffer statistics. - - Returns `{total_bytes, flush_count}`. - """ - @spec stats(t()) :: {non_neg_integer(), non_neg_integer()} - def stats(%__MODULE__{total_bytes: bytes, flush_count: count}) do - {bytes, count} - end - - @doc """ - Returns the current buffer contents as iodata without flushing. - """ - @spec to_iodata(t()) :: iolist() - def to_iodata(%__MODULE__{buffer: buffer}) do - Enum.reverse(buffer) - end - - @doc """ - Resets the style tracking, useful when style is explicitly reset. - """ - @spec reset_style(t()) :: t() - def reset_style(%__MODULE__{} = buffer) do - %{buffer | last_style: nil} - end - - @doc """ - Clears the buffer without flushing. - """ - @spec clear(t()) :: t() - def clear(%__MODULE__{} = buffer) do - %{buffer | buffer: [], size: 0, pending_sgr: []} - end - - # Private functions - - defp style_to_sgr_params(%Style{} = style, nil) do - # No previous style - emit all - build_full_sgr_params(style) - end - - defp style_to_sgr_params(%Style{} = style, %Style{} = last) do - # Emit only changed parameters - params = [] - - params = - if style.fg != last.fg do - # Use :default when fg is nil to reset to default foreground - fg = style.fg || :default - [SGR.color_param(:fg, fg) | params] - else - params - end - - params = - if style.bg != last.bg do - # Use :default when bg is nil to reset to default background - bg = style.bg || :default - [SGR.color_param(:bg, bg) | params] - else - params - end - - # Check for new attributes - new_attrs = MapSet.difference(style.attrs, last.attrs) - params = Enum.reduce(new_attrs, params, fn attr, acc -> [SGR.attr_param(attr) | acc] end) - - # Check for removed attributes (need reset) - removed_attrs = MapSet.difference(last.attrs, style.attrs) - - params = - Enum.reduce(removed_attrs, params, fn attr, acc -> [SGR.attr_off_param(attr) | acc] end) - - Enum.reverse(params) |> Enum.reject(&is_nil/1) - end - - defp build_full_sgr_params(%Style{fg: fg, bg: bg, attrs: attrs}) do - params = [] - params = if fg && fg != :default, do: [SGR.color_param(:fg, fg) | params], else: params - params = if bg && bg != :default, do: [SGR.color_param(:bg, bg) | params], else: params - params = Enum.reduce(attrs, params, fn attr, acc -> [SGR.attr_param(attr) | acc] end) - Enum.reverse(params) |> Enum.reject(&is_nil/1) - end -end diff --git a/lib/term_ui/renderer/style.ex b/lib/term_ui/renderer/style.ex deleted file mode 100644 index 07b8f822..00000000 --- a/lib/term_ui/renderer/style.ex +++ /dev/null @@ -1,346 +0,0 @@ -defmodule TermUI.Renderer.Style do - @moduledoc """ - Represents visual styling for text and cells. - - Styles encapsulate colors and text attributes, providing a fluent builder - API and support for style merging (cascading). Styles can be converted to - cells for rendering. - - ## Fluent Builder API - - Style.new() - |> Style.fg(:red) - |> Style.bg(:black) - |> Style.bold() - |> Style.underline() - - ## Style Merging - - Styles can be merged with later styles overriding earlier values: - - base = Style.new() |> Style.fg(:white) - override = Style.new() |> Style.fg(:red) |> Style.bold() - merged = Style.merge(base, override) - # fg: :red, attrs: [:bold] - """ - - alias TermUI.Renderer.Cell - - @type color :: Cell.color() - @type attribute :: Cell.attribute() - - @type t :: %__MODULE__{ - fg: color() | nil, - bg: color() | nil, - attrs: MapSet.t(attribute()) - } - - defstruct fg: nil, - bg: nil, - attrs: MapSet.new() - - @valid_attributes [:bold, :dim, :italic, :underline, :blink, :reverse, :hidden, :strikethrough] - - @named_colors [ - :black, - :red, - :green, - :yellow, - :blue, - :magenta, - :cyan, - :white, - :bright_black, - :bright_red, - :bright_green, - :bright_yellow, - :bright_blue, - :bright_magenta, - :bright_cyan, - :bright_white - ] - - @doc """ - Creates a new empty style. - - ## Examples - - iex> Style.new() - %Style{fg: nil, bg: nil, attrs: MapSet.new()} - """ - @dialyzer {:nowarn_function, new: 0, reset: 1} - @spec new() :: t() - def new do - %__MODULE__{} - end - - @doc """ - Creates a style with initial values. - - ## Examples - - iex> Style.new(fg: :red, attrs: [:bold]) - %Style{fg: :red, bg: nil, attrs: MapSet.new([:bold])} - """ - @spec new(keyword()) :: t() - def new(opts) when is_list(opts) do - fg = Keyword.get(opts, :fg) - bg = Keyword.get(opts, :bg) - attrs = Keyword.get(opts, :attrs, []) - - %__MODULE__{ - fg: validate_color!(fg), - bg: validate_color!(bg), - attrs: attrs |> Enum.map(&validate_attribute!/1) |> MapSet.new() - } - end - - @doc """ - Sets the foreground color. - - ## Examples - - iex> Style.new() |> Style.fg(:red) - %Style{fg: :red, bg: nil, attrs: MapSet.new()} - """ - @spec fg(t(), color()) :: t() - def fg(%__MODULE__{} = style, color) do - %{style | fg: validate_color!(color)} - end - - @doc """ - Sets the background color. - - ## Examples - - iex> Style.new() |> Style.bg(:blue) - %Style{fg: nil, bg: :blue, attrs: MapSet.new()} - """ - @spec bg(t(), color()) :: t() - def bg(%__MODULE__{} = style, color) do - %{style | bg: validate_color!(color)} - end - - @doc """ - Adds the bold attribute. - """ - @spec bold(t()) :: t() - def bold(%__MODULE__{} = style) do - add_attr(style, :bold) - end - - @doc """ - Adds the dim attribute. - """ - @spec dim(t()) :: t() - def dim(%__MODULE__{} = style) do - add_attr(style, :dim) - end - - @doc """ - Adds the italic attribute. - """ - @spec italic(t()) :: t() - def italic(%__MODULE__{} = style) do - add_attr(style, :italic) - end - - @doc """ - Adds the underline attribute. - """ - @spec underline(t()) :: t() - def underline(%__MODULE__{} = style) do - add_attr(style, :underline) - end - - @doc """ - Adds the blink attribute. - """ - @spec blink(t()) :: t() - def blink(%__MODULE__{} = style) do - add_attr(style, :blink) - end - - @doc """ - Adds the reverse attribute. - """ - @spec reverse(t()) :: t() - def reverse(%__MODULE__{} = style) do - add_attr(style, :reverse) - end - - @doc """ - Adds the hidden attribute. - """ - @spec hidden(t()) :: t() - def hidden(%__MODULE__{} = style) do - add_attr(style, :hidden) - end - - @doc """ - Adds the strikethrough attribute. - """ - @spec strikethrough(t()) :: t() - def strikethrough(%__MODULE__{} = style) do - add_attr(style, :strikethrough) - end - - @doc """ - Adds an attribute to the style. - """ - @spec add_attr(t(), attribute()) :: t() - def add_attr(%__MODULE__{} = style, attr) do - %{style | attrs: MapSet.put(style.attrs, validate_attribute!(attr))} - end - - @doc """ - Removes an attribute from the style. - """ - @spec remove_attr(t(), attribute()) :: t() - def remove_attr(%__MODULE__{} = style, attr) do - %{style | attrs: MapSet.delete(style.attrs, attr)} - end - - @doc """ - Merges two styles, with the second style overriding the first. - - Only non-nil values from the override style replace values in the base. - Attributes are combined (union of both sets). - - ## Examples - - iex> base = Style.new(fg: :white, bg: :black) - iex> override = Style.new(fg: :red, attrs: [:bold]) - iex> merged = Style.merge(base, override) - iex> merged.fg - :red - iex> merged.bg - :black - iex> :bold in merged.attrs - true - """ - @spec merge(t(), t()) :: t() - def merge(%__MODULE__{} = base, %__MODULE__{} = override) do - %__MODULE__{ - fg: override.fg || base.fg, - bg: override.bg || base.bg, - attrs: MapSet.union(base.attrs, override.attrs) - } - end - - @doc """ - Converts a style to a cell with the given character. - - Applies the style's colors and attributes to create a new cell. - Uses `:default` for any unset colors. - - ## Examples - - iex> style = Style.new() |> Style.fg(:red) |> Style.bold() - iex> cell = Style.to_cell(style, "X") - iex> cell.char - "X" - iex> cell.fg - :red - iex> cell.bg - :default - """ - @spec to_cell(t(), String.t()) :: Cell.t() - def to_cell(%__MODULE__{} = style, char) when is_binary(char) do - Cell.new(char, - fg: style.fg || :default, - bg: style.bg || :default, - attrs: MapSet.to_list(style.attrs) - ) - end - - @doc """ - Applies a style to an existing cell, returning a new cell. - - The style's values override the cell's values where set. - - ## Examples - - iex> cell = Cell.new("A", fg: :white) - iex> style = Style.new() |> Style.fg(:red) - iex> new_cell = Style.apply_to_cell(style, cell) - iex> new_cell.fg - :red - """ - @spec apply_to_cell(t(), Cell.t()) :: Cell.t() - def apply_to_cell(%__MODULE__{} = style, %Cell{} = cell) do - %Cell{ - char: cell.char, - fg: style.fg || cell.fg, - bg: style.bg || cell.bg, - attrs: MapSet.union(cell.attrs, style.attrs) - } - end - - @doc """ - Resets style to default (empty). - """ - @spec reset(t()) :: t() - def reset(%__MODULE__{}) do - new() - end - - @doc """ - Checks if the style has any properties set. - """ - @spec empty?(t()) :: boolean() - def empty?(%__MODULE__{} = style) do - is_nil(style.fg) and is_nil(style.bg) and MapSet.size(style.attrs) == 0 - end - - @doc """ - Checks if two styles are visually equal. - - Compares foreground color, background color, and all attributes. - - ## Examples - - iex> s1 = Style.new(fg: :red, attrs: [:bold]) - iex> s2 = Style.new(fg: :red, attrs: [:bold]) - iex> Style.equal?(s1, s2) - true - """ - @spec equal?(t(), t()) :: boolean() - def equal?(%__MODULE__{} = a, %__MODULE__{} = b) do - a.fg == b.fg and a.bg == b.bg and MapSet.equal?(a.attrs, b.attrs) - end - - @doc """ - Checks if style has an attribute. - """ - @spec has_attr?(t(), attribute()) :: boolean() - def has_attr?(%__MODULE__{} = style, attr) do - MapSet.member?(style.attrs, attr) - end - - # Private validation helpers - - defp validate_color!(nil), do: nil - - defp validate_color!(color) when color in @named_colors, do: color - - defp validate_color!(color) when is_integer(color) and color >= 0 and color <= 255, do: color - - defp validate_color!({r, g, b} = color) - when is_integer(r) and r >= 0 and r <= 255 and - is_integer(g) and g >= 0 and g <= 255 and - is_integer(b) and b >= 0 and b <= 255 do - color - end - - defp validate_color!(invalid) do - raise ArgumentError, "Invalid color: #{inspect(invalid)}" - end - - defp validate_attribute!(attr) when attr in @valid_attributes, do: attr - - defp validate_attribute!(invalid) do - raise ArgumentError, - "Invalid attribute: #{inspect(invalid)}. Valid attributes: #{inspect(@valid_attributes)}" - end -end diff --git a/lib/term_ui/runtime.ex b/lib/term_ui/runtime.ex index 44f1199c..3733aa25 100644 --- a/lib/term_ui/runtime.ex +++ b/lib/term_ui/runtime.ex @@ -1,300 +1,81 @@ defmodule TermUI.Runtime do @moduledoc """ - The central runtime orchestrator for TermUI applications. + Runs one Elm application against one terminal backend. - The runtime implements The Elm Architecture dispatch loop: - 1. Receive event from terminal - 2. Route to appropriate component - 3. Call component's event_to_msg - 4. Call component's update with message - 5. Collect commands from update - 6. Mark component dirty - 7. On render timer, call view and render - - ## Usage - - # Start with a root component - {:ok, runtime} = Runtime.start_link(root: MyApp.Root) - - # Send events (usually from terminal input) - Runtime.send_event(runtime, Event.key(:enter)) - - # Shutdown gracefully - Runtime.shutdown(runtime) + The runtime is the only owner of application state. It serializes terminal + events, application messages, command results, frame scheduling, and + shutdown. Backend state is opaque to the runtime. """ use GenServer - require Logger - - alias TermUI.Backend.Selector - alias TermUI.Command - alias TermUI.Command.Executor - alias TermUI.Config - alias TermUI.Elm - alias TermUI.Event - alias TermUI.EventQueue - alias TermUI.Input.Selector, as: InputSelector - alias TermUI.MessageQueue - alias TermUI.PersistentTerms - alias TermUI.Renderer.Buffer - alias TermUI.Renderer.BufferManager - alias TermUI.Renderer.Cell - alias TermUI.Runtime.NodeRenderer - alias TermUI.Runtime.State - alias TermUI.Terminal - alias TermUI.Terminal.InputReader - alias TermUI.TerminalOutput - - # Dialyzer: Functions with unmatched return values in side-effect calls - @dialyzer {:nowarn_function, - init: 1, - handle_call: 3, - handle_info: 2, - terminate: 2, - process_render_tick: 1, - cleanup_input_reader: 1, - cleanup_input_handler: 1, - cleanup_resize_callback: 1, - cleanup_backend: 1, - cleanup_shutdown: 1, - cleanup_terminal_restore: 1, - cleanup_persistent_terms: 0, - ensure_echo_enabled: 1, - render_with_buffer_manager: 2, - render_to_tty_backend: 2, - extract_all_cells: 1} + alias TermUI.Backend + alias TermUI.Backend.Manager, as: BackendManager + alias TermUI.{Command, Elm, Event, Frame} + + @default_render_interval 16 @type option :: {:root, module()} | {:name, GenServer.name()} + | {:backend, Backend.spec()} + | {:backend_opts, keyword()} | {:render_interval, pos_integer()} - | {:backend, :auto | :raw | :tty} - | {:skip_terminal, boolean()} - | {:use_input_handler, boolean()} - - # Default render interval in milliseconds (~60 FPS) - @default_render_interval 16 - - # Timeout for input handler poll calls in the async reader process. - # For Raw handler: controls how long each poll waits before returning :timeout. - # For TTY handler: ignored (TTY.poll blocks regardless of timeout). - @input_poll_interval 16 - - # --- Public API --- - - @doc """ - Starts the runtime with the given options. - - ## Options - - - `:root` - The root component module (required) - - `:name` - GenServer name (optional) - - `:render_interval` - Milliseconds between renders (default: 16) - - `:backend` - Backend selection: `:auto` (default), `:raw`, `:tty` - - `:skip_terminal` - Skip terminal initialization (default: false, for testing) - - ## Backend Selection - The `:backend` option controls which terminal backend is used: - - - `:auto` (default) - Attempts raw mode first, falls back to TTY if unavailable - - `:raw` - Forces raw mode (requires OTP 28+, errors if unavailable) - - `:tty` - Forces TTY mode (line-based input, no raw mode attempt) - - ## Examples - - # Auto-detect backend (default behavior) - {:ok, runtime} = Runtime.start_link(root: MyApp.Root) - - # Force TTY mode - {:ok, runtime} = Runtime.start_link(root: MyApp.Root, backend: :tty) - - # Query backend mode at runtime - :raw = Runtime.backend_mode() - - # Query capabilities (useful for TTY mode) - %{colors: :true_color, unicode: true} = Runtime.capabilities() - """ + @type state :: %{ + app: module(), + app_state: term(), + backend: module(), + backend_manager: pid(), + capabilities: map(), + dimensions: {pos_integer(), pos_integer()}, + render_interval: pos_integer(), + render_timer: {reference(), reference()} | nil, + dirty: boolean(), + status: :running | :final_render_pending | :stopping, + stop_reason: term(), + async_tasks: map(), + async_monitors: map(), + frames_rendered: non_neg_integer() + } + + @doc "Starts a linked runtime process." @spec start_link([option()]) :: GenServer.on_start() def start_link(opts) do {name, opts} = Keyword.pop(opts, :name) - - if name do - GenServer.start_link(__MODULE__, opts, name: name) - else - GenServer.start_link(__MODULE__, opts) - end - end - - defp start(opts) do - {name, opts} = Keyword.pop(opts, :name) - - if name do - GenServer.start(__MODULE__, opts, name: name) - else - GenServer.start(__MODULE__, opts) - end + start_options = if name, do: [name: name], else: [] + GenServer.start_link(__MODULE__, opts, start_options) end - @doc """ - Returns a child specification for starting the runtime in a supervisor. - - ## Options - - Same as `start_link/1`: - - `:root` - The root component module (required) - - `:name` - GenServer name (optional) - - `:render_interval` - Milliseconds between renders (default: 16) - - `:backend` - Backend selection: `:auto`, `:raw`, `:tty` - - `:skip_terminal` - Skip terminal initialization (default: false) - - ## Examples - - children = [ - {TermUI.Runtime, root: MyApp.Root, name: :my_runtime} - ] - - Supervisor.start_link(children, strategy: :one_for_one) - """ + @doc false @spec child_spec([option()]) :: Supervisor.child_spec() def child_spec(opts) do %{ - id: __MODULE__, + id: Keyword.get(opts, :name, __MODULE__), start: {__MODULE__, :start_link, [opts]}, - restart: :permanent, - shutdown: 5000, + restart: :transient, + shutdown: 5_000, type: :worker } end - @doc """ - Sends an event to the runtime for processing. - """ - @spec send_event(GenServer.server(), Event.t()) :: :ok - def send_event(runtime, event) do - GenServer.cast(runtime, {:event, event}) - end - - @doc """ - Sends a message directly to a component. - """ - @spec send_message(GenServer.server(), term(), term()) :: :ok - def send_message(runtime, component_id, message) do - GenServer.cast(runtime, {:message, component_id, message}) - end - - @doc """ - Delivers a command result back to the runtime. - """ - @spec command_result(GenServer.server(), term(), term(), term()) :: :ok - def command_result(runtime, component_id, command_id, result) do - GenServer.cast(runtime, {:command_result, component_id, command_id, result}) - end - - @doc """ - Initiates graceful shutdown of the runtime. - """ - @spec shutdown(GenServer.server()) :: :ok - def shutdown(runtime) do - GenServer.cast(runtime, :shutdown) - end - - @doc """ - Gets the current runtime state (for testing/debugging). - """ - @spec get_state(GenServer.server()) :: State.t() - def get_state(runtime) do - GenServer.call(runtime, :get_state) - end - - @doc """ - Synchronously waits for all pending events and messages to be processed. - - This is primarily useful for testing to avoid race conditions from - Process.sleep. It processes all queued messages and returns when complete. - - ## Example - - Runtime.send_event(runtime, Event.key(:up)) - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) # Wait for both events to be processed - state = Runtime.get_state(runtime) - assert state.root_state.count == 2 - """ - @spec sync(GenServer.server(), timeout()) :: :ok - def sync(runtime, timeout \\ 5000) do - GenServer.call(runtime, :sync, timeout) - end - - @doc """ - Gets the current backend mode. - - Returns `:raw` if raw mode is active, `:tty` if TTY mode is active, - or `nil` if no runtime has been started. - - ## Examples - - :raw = Runtime.backend_mode() - :tty = Runtime.backend_mode() - """ - @spec backend_mode() :: State.backend_mode() - def backend_mode, do: PersistentTerms.backend_mode() - - @doc """ - Gets the detected terminal capabilities. - - Returns a map with keys: - - `:colors` - Color depth (`:true_color`, `:color_256`, `:color_16`, `:monochrome`) - - `:unicode` - Boolean indicating Unicode support - - `:dimensions` - `{rows, cols}` tuple or `nil` - - `:terminal` - Boolean indicating terminal presence - - Returns `nil` if no runtime has been started. - - ## Examples - - %{colors: :true_color, unicode: true} = Runtime.capabilities() - """ - @spec capabilities() :: State.capabilities() | nil - def capabilities, do: PersistentTerms.capabilities() - - @doc """ - Forces an immediate render (bypassing framerate limiter). - """ - @spec force_render(GenServer.server()) :: :ok - def force_render(runtime) do - GenServer.cast(runtime, :force_render) - end - - @doc """ - Starts the runtime and blocks until it shuts down. - - This is the main entry point for running a TUI application. It starts the - runtime, takes over the terminal, and blocks the calling process until - the application exits (e.g., user presses quit key). - - ## Options - - Same as `start_link/1`. - - ## Example - - # In your application entry point: - TermUI.Runtime.run(root: MyApp.Root) - # This blocks until the app exits - """ + @doc "Runs an application until it exits." @spec run([option()]) :: :ok | {:error, term()} def run(opts) do - case start(opts) do + {name, opts} = Keyword.pop(opts, :name) + start_options = if name, do: [name: name], else: [] + + case GenServer.start(__MODULE__, opts, start_options) do {:ok, runtime} -> - # Monitor the runtime process and block until it exits - ref = Process.monitor(runtime) + reference = Process.monitor(runtime) receive do - {:DOWN, ^ref, :process, ^runtime, reason} when reason in [:normal, :shutdown] -> + {:DOWN, ^reference, :process, ^runtime, reason} when reason in [:normal, :shutdown] -> + :ok + + {:DOWN, ^reference, :process, ^runtime, {:shutdown, :normal}} -> :ok - {:DOWN, ^ref, :process, ^runtime, reason} -> + {:DOWN, ^reference, :process, ^runtime, reason} -> {:error, reason} end @@ -303,1296 +84,500 @@ defmodule TermUI.Runtime do end end - # --- GenServer Callbacks --- + @doc "Queues an application message." + @spec send_message(GenServer.server(), term()) :: :ok + def send_message(runtime, message), do: GenServer.cast(runtime, {:message, message}) - @impl true - def init(opts) do - # Trap exits to ensure terminate/2 is called even on crashes - Process.flag(:trap_exit, true) - - # Merge runtime options with application configuration - # Runtime options take precedence over config - opts = Config.merge_options(opts) - - root_module = Keyword.fetch!(opts, :root) - render_interval = Keyword.get(opts, :render_interval, @default_render_interval) - - # Suppress default Logger handler to prevent bare \n writes to stdout - # during raw mode (Logger output corrupts TUI rendering) - logger_handler_config = - if Keyword.get(opts, :skip_terminal, false) do - nil - else - suppress_logger() - end - - {backend_mode, backend, backend_state, capabilities, terminal_started, buffer_manager, - dimensions} = - init_backend(opts) - - # Store backend info in persistent_term for global access - PersistentTerms.store_backend_context(backend_mode, capabilities) - - # Initialize async command execution before the root module boots. - {:ok, command_executor} = Executor.start_link() - - # Initialize root component state and any startup commands. - root_opts = Keyword.put_new(opts, :dimensions, dimensions || {80, 24}) - - {root_state, init_commands} = - root_opts - |> root_module.init() - |> Elm.normalize_init_result() - - # Initialize input handling - {use_input_handler, input_handler, input_state, input_reader} = - init_input_handling(opts, backend_mode, terminal_started) - - # Register for resize callbacks if using new input handler - register_resize_callback(use_input_handler, backend_mode) - - state = - build_initial_state(%{ - root_module: root_module, - root_state: root_state, - render_interval: render_interval, - terminal_started: terminal_started, - buffer_manager: buffer_manager, - dimensions: dimensions, - input_reader: input_reader, - backend_mode: backend_mode, - backend: backend, - backend_state: backend_state, - capabilities: capabilities, - command_executor: command_executor, - input_handler: input_handler, - input_state: input_state, - logger_handler_config: logger_handler_config - }) - - # Schedule first render - schedule_render(render_interval) - - # Spawn async reader process for input handler (runs poll loop in separate process) - state = - if state.input_handler do - reader_pid = - spawn_input_handler_reader(state.input_handler, state.input_state, self()) - - %{state | input_handler_reader: reader_pid} - else - state - end - - state = - init_commands - |> Enum.map(&{:root, &1}) - |> then(&execute_commands(&1, state)) - - {:ok, state} - end - - defp init_backend(opts) do - skip_terminal = Keyword.get(opts, :skip_terminal, false) - backend_opt = Keyword.get(opts, :backend, :auto) - - if skip_terminal do - {:skip, nil, nil, nil, false, nil, nil} - else - select_backend(backend_opt) - end - end - - defp init_input_handling(opts, backend_mode, terminal_started) do - use_input_handler_opt = Keyword.get(opts, :use_input_handler, false) - - # TTY mode requires the new input handler (IEx compatible) - # Raw mode can use either InputReader (legacy) or Input.Raw (new) - use_input_handler = use_input_handler_opt or backend_mode == :tty - - {input_handler, input_state} = - if use_input_handler and backend_mode in [:raw, :tty] do - handler = InputSelector.select(backend_mode) - {handler, handler.new()} - else - {nil, nil} - end - - # Start input reader and register for resize callbacks if using legacy InputReader - input_reader = - if not use_input_handler and terminal_started do - {:ok, reader_pid} = InputReader.start_link(target: self()) - Terminal.register_resize_callback(self()) - reader_pid - else - nil - end - - {use_input_handler, input_handler, input_state, input_reader} - end - - defp register_resize_callback(use_input_handler, backend_mode) do - if use_input_handler and backend_mode in [:raw, :tty] do - # Only register if Terminal GenServer is running - if Process.whereis(Terminal) do - Terminal.register_resize_callback(self()) - end - end - end - - defp build_initial_state(params) do - %{ - root_module: params.root_module, - root_state: params.root_state, - message_queue: MessageQueue.new(), - event_queue: EventQueue.new(), - render_interval: params.render_interval, - # Initial render needed - dirty: true, - focused_component: :root, - components: %{root: %{module: params.root_module, state: params.root_state}}, - pending_commands: %{}, - shutting_down: false, - terminal_started: params.terminal_started, - buffer_manager: params.buffer_manager, - dimensions: params.dimensions, - input_reader: params.input_reader, - input_handler_reader: nil, - backend_mode: params.backend_mode, - backend: params.backend, - backend_state: params.backend_state, - capabilities: params.capabilities, - command_executor: params.command_executor, - input_handler: params.input_handler, - input_state: params.input_state, - logger_handler_config: params[:logger_handler_config] - } - end - - defp select_backend(backend_opt) do - case Selector.select(backend_opt) do - {:raw, _raw_state} -> attempt_raw_backend(fallback_to_tty: true) - {:tty, capabilities} -> init_tty_backend(capabilities) - {:explicit, :raw, _opts} -> attempt_raw_backend(fallback_to_tty: false) - {:explicit, :tty, _opts} -> init_tty_backend(Selector.detect_capabilities()) - {:explicit, module, opts} -> init_explicit_backend(module, opts) - end - end - - defp attempt_raw_backend(opts) do - case setup_terminal_and_buffers() do - {true, buffer_manager, dimensions} -> - init_raw_backend(buffer_manager, dimensions) - - {false, nil, nil} -> - if Keyword.get(opts, :fallback_to_tty, false) do - init_tty_backend(Selector.detect_capabilities()) - else - raise "Raw backend requested but unavailable" - end - end - end - - defp init_raw_backend(buffer_manager, {cols, rows}) do - backend = TermUI.Backend.Raw - - # Enable ONLCR translation (bare \n → \r\n) since raw mode disables OPOST - TerminalOutput.enable_onlcr() - - # Terminal GenServer already entered alternate screen and hid cursor in - # setup_terminal_and_buffers, so skip those here to avoid double entry. - # Mouse tracking is also already configured. - {:ok, backend_state} = - backend.init( - alternate_screen: false, - hide_cursor: false, - mouse_tracking: :none, - size: {cols, rows} - ) - - {:raw, backend, backend_state, nil, true, buffer_manager, {cols, rows}} - end - - defp init_tty_backend(capabilities) do - backend = TermUI.Backend.TTY - {:ok, backend_state} = backend.init(capabilities: capabilities, alternate_screen: true) - {rows, cols} = backend_state.size - {:tty, backend, backend_state, capabilities, false, nil, {cols, rows}} - end - - defp init_explicit_backend(TermUI.Backend.Raw, _opts) do - attempt_raw_backend(fallback_to_tty: false) - end - - defp init_explicit_backend(TermUI.Backend.TTY, _opts) do - init_tty_backend(Selector.detect_capabilities()) - end - - defp init_explicit_backend(module, opts) when is_atom(module) do - {:ok, backend_state} = module.init(opts) - {:ok, {rows, cols}} = module.size(backend_state) + @doc "Requests a final render and clean shutdown." + @spec shutdown(GenServer.server()) :: :ok + def shutdown(runtime), do: GenServer.cast(runtime, {:shutdown, :normal}) - # Start BufferManager for the custom backend - {:ok, buffer_pid} = BufferManager.start_link(rows: rows, cols: cols, name: nil) + @doc "Forces the newest state to render now." + @spec force_render(GenServer.server()) :: :ok + def force_render(runtime), do: GenServer.cast(runtime, :force_render) - {:custom, module, backend_state, nil, false, buffer_pid, {cols, rows}} - end + @doc "Waits until older messages from this caller are processed." + @spec sync(GenServer.server(), timeout()) :: :ok + def sync(runtime, timeout \\ 5_000), do: GenServer.call(runtime, :sync, timeout) - defp setup_terminal_and_buffers do - # Start Terminal GenServer (or reuse if already running) - case Terminal.start_link() do - {:ok, _pid} -> :ok - {:error, {:already_started, _pid}} -> :ok - {:error, reason} -> throw({:terminal_failed, reason}) - end + @doc "Returns internal state for deterministic tests and diagnostics." + @spec get_state(GenServer.server()) :: state() + def get_state(runtime), do: GenServer.call(runtime, :get_state) - # Configure terminal for TUI mode - :ok = Terminal.enter_alternate_screen() - :ok = Terminal.hide_cursor() - :ok = Terminal.enable_mouse_tracking(:all) + @doc "Returns capabilities for one runtime." + @spec capabilities(GenServer.server()) :: map() + def capabilities(runtime), do: GenServer.call(runtime, :capabilities) - {rows, cols} = get_terminal_dimensions_safe() + @impl true + def init(opts) do + Process.flag(:trap_exit, true) - # Start BufferManager (or reuse if already running) - buffer_pid = - case BufferManager.start_link(rows: rows, cols: cols) do - {:ok, pid} -> pid - {:error, {:already_started, pid}} -> pid + with {:ok, app} <- fetch_app(opts), + {:ok, backend_manager} <- + BackendManager.start_link( + self(), + Keyword.get(opts, :backend, :auto), + Keyword.get(opts, :backend_opts, []) + ) do + backend_info = BackendManager.info(backend_manager) + + case build_state(app, backend_manager, backend_info, opts) do + {:ok, state, commands} -> + {:ok, state, {:continue, {:start, commands}}} + + {:error, reason} -> + BackendManager.close(backend_manager, reason) + {:stop, reason} end - - {true, buffer_pid, {cols, rows}} - rescue - _ -> {false, nil, nil} - catch - {:terminal_failed, _} -> {false, nil, nil} - end - - defp get_terminal_dimensions_safe do - case Terminal.get_terminal_size() do - {:ok, {rows, cols}} -> {rows, cols} - {:error, _reason} -> {24, 80} - end - end - - @impl true - def handle_cast({:event, event}, state) do - if state.shutting_down do - {:noreply, state} else - # Add to bounded event queue (may drop oldest if full) - {result, new_queue} = EventQueue.push(state.event_queue, event) - state = %{state | event_queue: new_queue} - # Log if event was dropped - case result do - # EventQueue already logged - {:dropped, _} -> :ok - :ok -> :ok - end - - # Process queued events - state = process_event_queue(state) - {:noreply, state} + {:error, reason} -> + {:stop, reason} end end @impl true - def handle_cast({:message, component_id, message}, state) do - if state.shutting_down do - {:noreply, state} + def handle_continue({:start, commands}, state) do + with {:ok, state} <- render_now(state), + {:ok, state} <- execute_commands(commands, state) do + if state.status == :final_render_pending do + finish(state) + else + :ok = BackendManager.activate(state.backend_manager) + {:noreply, state} + end else - state = enqueue_message(component_id, message, state) - {:noreply, state} + {:error, reason, state} -> {:stop, reason, %{state | stop_reason: reason}} + {:error, reason} -> {:stop, reason, %{state | stop_reason: reason}} end end @impl true - def handle_cast({:command_result, component_id, command_id, result}, state) do - state = handle_command_result(component_id, command_id, result, state) - {:noreply, state} - end + def handle_cast({:message, message}, state), do: process_update(message, state) - @impl true - def handle_cast(:shutdown, state) do - state = initiate_shutdown(state) - {:noreply, state} + def handle_cast({:shutdown, reason}, state) do + finish(%{state | status: :final_render_pending, stop_reason: reason}) end - @impl true def handle_cast(:force_render, state) do - state = do_render(state) - {:noreply, state} - end - - @impl true - def handle_info(:render, state) do - state = process_render_tick(state) - {:noreply, state} - end - - @impl true - def handle_info(:input_eof, state) do - # EOF from async input handler reader - initiate shutdown - if state.shutting_down do - {:noreply, state} - else - state = initiate_shutdown(%{state | input_handler_reader: nil}) - {:noreply, state} - end - end - - @impl true - def handle_info({:input, event}, state) do - # Keyboard/mouse input from InputReader or async input handler reader - if state.shutting_down do - {:noreply, state} - else - # Add to bounded event queue (may drop oldest if full) - {result, new_queue} = EventQueue.push(state.event_queue, event) - state = %{state | event_queue: new_queue} - # Process queued events - state = process_event_queue(state) - # Log if event was dropped (EventQueue handles rate limiting) - case result do - {:dropped, _} -> :ok - :ok -> :ok - end + state = cancel_render(state) - {:noreply, state} + case render_now(%{state | dirty: true}) do + {:ok, state} -> {:noreply, state} + {:error, reason, state} -> {:stop, reason, %{state | stop_reason: reason}} end end @impl true - def handle_info({:terminal_resize, {rows, cols}}, state) do - # Terminal window was resized - if state.shutting_down do - {:noreply, state} - else - state = handle_resize(rows, cols, state) - {:noreply, state} - end - end + def handle_call(:sync, _from, state), do: {:reply, :ok, state} + def handle_call(:get_state, _from, state), do: {:reply, state, state} + def handle_call(:capabilities, _from, state), do: {:reply, state.capabilities, state} @impl true - def handle_info({:ssh_input, event}, state) do - # SSH input delivered externally from the host process - if state.shutting_down do - {:noreply, state} - else - {result, new_queue} = EventQueue.push(state.event_queue, event) - state = %{state | event_queue: new_queue} - state = process_event_queue(state) - - case result do - {:dropped, _} -> :ok - :ok -> :ok - end + def handle_info({:render, token}, %{render_timer: {_reference, token}} = state) do + state = %{state | render_timer: nil} - {:noreply, state} + case render_now(state) do + {:ok, state} -> {:noreply, state} + {:error, reason, state} -> {:stop, reason, %{state | stop_reason: reason}} end end - @impl true - def handle_info({:ssh_resize, rows, cols}, state) - when is_integer(rows) and rows > 0 and is_integer(cols) and cols > 0 do - # SSH window_change event from the host process - if state.shutting_down do - {:noreply, state} - else - state = handle_resize(rows, cols, state) - {:noreply, state} - end - end + def handle_info({:render, _old_token}, state), do: {:noreply, state} - @impl true - def handle_info({:DOWN, _ref, :process, _pid, _reason}, state) do - # Command task completed (handled via command_result) - {:noreply, state} - end + def handle_info({:backend_event, event}, state), do: process_event(event, state) - @impl true - def handle_info({:command_result, component_id, command_id, result}, state) do - state = handle_command_result(component_id, command_id, result, state) - {:noreply, state} + def handle_info({:backend_size, {rows, columns}}, state) do + event = Event.resize(columns, rows) + process_event(event, state) end - @impl true - def handle_info(:stop_runtime, state) do - # Stop the GenServer after shutdown cleanup - {:stop, :normal, state} - end - - # Handle linked process exits (input handler reader, etc.) - @impl true - def handle_info({:EXIT, pid, reason}, state) do - if pid == state[:input_handler_reader] do - Logger.warning("Input handler reader exited: #{inspect(reason)}") - {:noreply, %{state | input_handler_reader: nil}} - else - # Forward to root module or ignore - if function_exported?(state.root_module, :handle_info, 2) do - handle_root_info({:EXIT, pid, reason}, state) - else - {:noreply, state} - end - end + def handle_info({:backend_failed, reason}, state) do + finish(%{state | status: :final_render_pending, stop_reason: reason}) end - # Catch-all for unknown messages - forward to root module's handle_info if it exists - @impl true - def handle_info(msg, state) do - if function_exported?(state.root_module, :handle_info, 2) do - handle_root_info(msg, state) - else - # Ignore unknown messages if root module doesn't handle them - {:noreply, state} - end + def handle_info({:EXIT, manager, reason}, %{backend_manager: manager} = state) do + stop_reason = {:backend, state.backend, :manager, reason} + {:stop, stop_reason, %{state | stop_reason: stop_reason}} end - defp handle_root_info(_msg, %{shutting_down: true} = state), do: {:noreply, state} + def handle_info({:app_message, message}, state), do: process_update(message, state) - defp handle_root_info(msg, state) do - case state.root_module.handle_info(msg, state.root_state) do - :noreply -> + def handle_info({:async_result, token, result}, state) do + case Map.pop(state.async_tasks, token) do + {nil, _tasks} -> {:noreply, state} - result -> - {new_root_state, commands} = Elm.normalize_update_result(result, state.root_state) - state = update_root_state(state, new_root_state) - tagged_commands = Enum.map(commands, fn command -> {:root, command} end) - {:noreply, execute_commands(tagged_commands, state)} - end - end - - defp update_root_state(state, new_root_state) do - components = - Map.update!(state.components, :root, fn comp -> - %{comp | state: new_root_state} - end) - - %{state | root_state: new_root_state, components: components, dirty: true} - end - - @impl true - def handle_call(:get_state, _from, state) do - {:reply, state, state} - end - - @impl true - def handle_call(:sync, _from, state) do - # Process all pending messages synchronously - state = process_messages(state) - {:reply, :ok, state} - end - - @impl true - def terminate(reason, state) do - # Let the root release application workers before terminal teardown. - terminate_root(reason, state) - cleanup_command_executor(state) - cleanup_buffer_manager(state) + {%{mapper: mapper, monitor: monitor}, tasks} -> + Process.demonitor(monitor, [:flush]) - # Step 1: Restore logger FIRST (before any other cleanup that might log) - terminate_logger_restore(state) + state = %{ + state + | async_tasks: tasks, + async_monitors: Map.delete(state.async_monitors, monitor) + } - # Step 2: Stop input reader BEFORE backend (prevents stdin contention during drain) - cleanup_input_reader(state) - cleanup_input_handler(state) - - # Step 3: Backend shutdown (drain pending input, cooked mode) - cleanup_backend(state) - - # Step 4: Terminal restore and resize callback cleanup - cleanup_resize_callback(state) - cleanup_shutdown(state) - cleanup_terminal_restore(state) - - # Step 5: Defensive cleanup (catches anything missed above) - terminate_defensive_cleanup(state) - - # Step 6: Persistent terms and echo - cleanup_persistent_terms() - ensure_echo_enabled(state) - - :ok - end - - defp terminate_root(reason, state) do - if function_exported?(state.root_module, :terminate, 2) do - state.root_module.terminate(reason, state.root_state) + case safe_apply_mapper(mapper, result) do + {:ok, message} -> process_update(message, state) + {:error, reason} -> {:stop, reason, %{state | stop_reason: reason}} + end end - - :ok - rescue - _ -> :ok - catch - _, _ -> :ok end - defp cleanup_input_reader(state) do - if state.input_reader do - InputReader.stop(state.input_reader) - end - rescue - _ -> :ok - end + def handle_info({:DOWN, reference, :process, pid, reason}, state) do + case Map.get(state.async_monitors, reference) do + nil -> + handle_application_info({:DOWN, reference, :process, pid, reason}, state) - defp cleanup_input_handler(state) do - # Kill the async reader process first - if is_pid(state[:input_handler_reader]) and Process.alive?(state[:input_handler_reader]) do - Process.unlink(state[:input_handler_reader]) - Process.exit(state[:input_handler_reader], :shutdown) - end + token -> + state = drop_async_task(state, token, reference) - # Then stop the handler (restores IO opts for TTY, etc.) - if not is_nil(state.input_handler) and not is_nil(state.input_state) do - state.input_handler.stop(state.input_state) + if reason == :normal do + {:noreply, state} + else + process_update({:async_error, reason}, state) + end end - rescue - _ -> :ok end - defp cleanup_resize_callback(state) do - if state.terminal_started do - Terminal.unregister_resize_callback(self()) - end - rescue - _ -> :ok - end + def handle_info(message, state), do: handle_application_info(message, state) - defp cleanup_backend(state) do - if not is_nil(state.backend) and not is_nil(state.backend_state) do - state.backend.shutdown(state.backend_state) - end - rescue - _ -> :ok - end + defp handle_application_info(message, state) do + case app_handle_info(state.app, message, state.app_state) do + {:ok, app_state, commands} -> + after_application_update(app_state, commands, state) - defp cleanup_shutdown(state) do - if not state.shutting_down do - do_shutdown(state) - end - rescue - _ -> :ok - end - - defp cleanup_terminal_restore(state) do - # Only restore Terminal singleton for local backends (Raw/TTY). - # Custom backends (SSH) handle their own cleanup in cleanup_backend/1. - if state.terminal_started and state.backend_mode in [:raw, :tty] do - Terminal.restore() + {:error, reason} -> + {:stop, reason, %{state | stop_reason: reason}} end - rescue - _ -> :ok end - defp cleanup_persistent_terms do - PersistentTerms.cleanup() - rescue - _ -> :ok + @impl true + def terminate(reason, state) do + _state = cancel_render(state) + stop_async_tasks(state) + app_terminate(state.app, effective_reason(reason, state), state.app_state) + BackendManager.close(state.backend_manager, effective_reason(reason, state)) + :ok end - defp cleanup_command_executor(state) do - if is_pid(state.command_executor) and Process.alive?(state.command_executor) do - GenServer.stop(state.command_executor, :normal) + defp fetch_app(opts) do + case Keyword.fetch(opts, :root) do + {:ok, app} when is_atom(app) -> validate_app(app) + :error -> {:error, {:invalid_option, :root, :missing}} + {:ok, value} -> {:error, {:invalid_option, :root, value}} end - - :ok - rescue - _ -> :ok - catch - :exit, _ -> :ok end - defp cleanup_buffer_manager(%{backend_mode: :custom, buffer_manager: buffer_manager}) - when is_pid(buffer_manager) do - if Process.alive?(buffer_manager) do - GenServer.stop(buffer_manager, :normal) - end + defp validate_app(app) do + required_callbacks = [event_to_msg: 2, update: 2, view: 1] - :ok + with {:module, ^app} <- Code.ensure_loaded(app), + [] <- + Enum.reject(required_callbacks, fn {name, arity} -> + function_exported?(app, name, arity) + end) do + {:ok, app} + else + {:error, reason} -> {:error, {:application, :load, {app, reason}}} + missing when is_list(missing) -> {:error, {:application, :callbacks, {app, missing}}} + end + end + + defp build_state(app, backend_manager, backend_info, opts) do + app_opts = Keyword.put(opts, :dimensions, size_to_dimensions(backend_info.size)) + + with {:ok, app_state, commands} <- app_init(app, app_opts) do + {:ok, + %{ + app: app, + app_state: app_state, + backend: backend_info.backend, + backend_manager: backend_manager, + capabilities: backend_info.capabilities, + dimensions: size_to_dimensions(backend_info.size), + render_interval: render_interval(opts), + render_timer: nil, + dirty: true, + status: :running, + stop_reason: :normal, + async_tasks: %{}, + async_monitors: %{}, + frames_rendered: 0 + }, commands} + end + end + + defp app_init(app, opts) do + result = if function_exported?(app, :init, 1), do: app.init(opts), else: %{} + {app_state, commands} = Elm.normalize_init_result(result) + validate_commands(commands, app_state) rescue - _ -> :ok + exception -> {:error, application_error(:init, :error, exception, __STACKTRACE__)} catch - :exit, _ -> :ok + kind, reason -> {:error, application_error(:init, kind, reason, __STACKTRACE__)} end - defp cleanup_buffer_manager(_state), do: :ok + defp app_handle_info(app, message, app_state) do + result = + if function_exported?(app, :handle_info, 2), + do: app.handle_info(message, app_state), + else: :noreply - defp ensure_echo_enabled(%{backend_mode: backend_mode}) - when backend_mode in [:raw, :tty] do - # Only restore echo on local terminals, not custom backends (SSH) - :io.setopts(echo: true) + {new_state, commands} = Elm.normalize_update_result(result, app_state) + validate_commands(commands, new_state) rescue - _ -> :ok + exception -> {:error, application_error(:handle_info, :error, exception, __STACKTRACE__)} + catch + kind, reason -> {:error, application_error(:handle_info, kind, reason, __STACKTRACE__)} end - defp ensure_echo_enabled(_state), do: :ok + defp app_update(app, message, app_state) do + {new_state, commands} = + app.update(message, app_state) + |> Elm.normalize_update_result(app_state) - defp terminate_logger_restore(state) do - restore_logger(state.logger_handler_config) + validate_commands(commands, new_state) rescue - _ -> :ok + exception -> {:error, application_error(:update, :error, exception, __STACKTRACE__)} + catch + kind, reason -> {:error, application_error(:update, kind, reason, __STACKTRACE__)} end - defp terminate_defensive_cleanup(%{backend_mode: backend_mode}) - when backend_mode in [:raw, :tty] do - # Crash-safe logger restore from persistent_term - restore_logger_from_persistent_term() - - # Direct-to-TTY cleanup (bypasses Erlang IO) - TerminalOutput.write_to_tty(TerminalOutput.cleanup_sequence()) - - # Cleanup ONLCR persistent_term - TerminalOutput.disable_onlcr() - - # Safety net stty restore (skip on WSL where stty always fails) - unless TerminalOutput.needs_hard_reset?() do - TermUI.TermUtils.safe_stty(["sane"]) + defp validate_commands(commands, app_state) do + if Enum.all?(commands, &match?(%Command{}, &1)) do + {:ok, app_state, commands} + else + {:error, {:application, :commands, {:invalid_commands, commands}}} end - rescue - _ -> :ok end - defp terminate_defensive_cleanup(_state), do: :ok - - defp suppress_logger do - case :logger.get_handler_config(:default) do - {:ok, config} -> - _ = :logger.remove_handler(:default) - :persistent_term.put(:term_ui_logger_handler_config, config) - config + defp process_event(%Event.Resize{width: width, height: height} = event, state) do + case BackendManager.resize(state.backend_manager, {height, width}) do + :ok -> + state = %{state | dimensions: {width, height}, dirty: true} + dispatch_event(event, state) - _ -> - nil + {:error, stop_reason} -> + {:stop, stop_reason, %{state | stop_reason: stop_reason}} end - rescue - _ -> nil end - defp restore_logger(nil), do: :ok - - defp restore_logger(%{module: module} = config) do - # Only add if not already present (idempotent) - case :logger.get_handler_config(:default) do - {:ok, _} -> - :ok + defp process_event(event, state), do: dispatch_event(event, state) - _ -> - _ = :logger.add_handler(:default, module, config) - :ok + defp dispatch_event(event, state) do + case app_event_to_message(state.app, event, state.app_state) do + {:ok, :ignore} -> {:noreply, state} + {:ok, {:msg, message}} -> process_update(message, state) + {:error, reason} -> {:stop, reason, %{state | stop_reason: reason}} end - - :persistent_term.erase(:term_ui_logger_handler_config) - :ok - rescue - _ -> :ok end - defp restore_logger(_), do: :ok - - defp restore_logger_from_persistent_term do - case :persistent_term.get(:term_ui_logger_handler_config, nil) do - nil -> :ok - config -> restore_logger(config) + defp app_event_to_message(app, event, app_state) do + case app.event_to_msg(event, app_state) do + :ignore = result -> {:ok, result} + {:msg, _message} = result -> {:ok, result} + other -> {:error, {:application, :event_to_msg, {:invalid_result, other}}} end rescue - _ -> :ok - end - - # --- Event Dispatch --- - - # Processes events from the bounded event queue. - # - # Processes one event per call to prevent event loop starvation. - # Multiple events will be processed across multiple GenServer handle_info/call cycles. - defp process_event_queue(state) do - case EventQueue.pop(state.event_queue) do - {{:value, event}, new_queue} -> - state = %{state | event_queue: new_queue} - dispatch_event(event, state) - - {:empty, _} -> - state - end - end - - defp dispatch_event(%Event.Key{} = event, state) do - # Keyboard events go to focused component - dispatch_to_component(state.focused_component, event, state) - end - - defp dispatch_event(%Event.Mouse{} = event, state) do - # Mouse events go to component at position - # For now, just send to root (spatial index will be added later) - dispatch_to_component(:root, event, state) - end - - defp dispatch_event(%Event.Resize{} = event, state) do - # Resize broadcasts to all components - broadcast_event(event, state) - end - - defp dispatch_event(%Event.Focus{} = event, state) do - # Focus broadcasts to all components - broadcast_event(event, state) - end - - defp dispatch_event(%Event.Paste{} = event, state) do - # Paste goes to focused component - dispatch_to_component(state.focused_component, event, state) - end - - defp dispatch_event(%Event.Tick{} = event, state) do - # Tick broadcasts to all components - broadcast_event(event, state) - end - - defp dispatch_event(_event, state) do - # Unknown event type, ignore - state + exception -> {:error, application_error(:event_to_msg, :error, exception, __STACKTRACE__)} + catch + kind, reason -> {:error, application_error(:event_to_msg, kind, reason, __STACKTRACE__)} end - defp dispatch_to_component(component_id, event, state) do - case Map.get(state.components, component_id) do - nil -> - state - - %{module: module, state: component_state} -> - # Transform event to message, with error handling - try do - case module.event_to_msg(event, component_state) do - {:msg, message} -> - enqueue_message(component_id, message, state) - - :ignore -> - state + defp process_update(message, state) do + case app_update(state.app, message, state.app_state) do + {:ok, app_state, commands} -> + after_application_update(app_state, commands, state) - :propagate -> - # Would propagate to parent, for now just ignore - state - end - rescue - error -> - require Logger - Logger.error("Component #{component_id} crashed in event_to_msg: #{inspect(error)}") - state - end + {:error, reason} -> + {:stop, reason, %{state | stop_reason: reason}} end end - defp broadcast_event(event, state) do - Enum.reduce(state.components, state, fn {component_id, _}, acc -> - dispatch_to_component(component_id, event, acc) - end) - end - - # --- Message Processing --- - - defp enqueue_message(component_id, message, state) do - queue = MessageQueue.enqueue(state.message_queue, {component_id, message}) - %{state | message_queue: queue} - end - - defp process_messages(state) do - {messages, queue} = MessageQueue.flush(state.message_queue) - - {state, commands} = - Enum.reduce(messages, {%{state | message_queue: queue}, []}, fn {component_id, message}, - {acc_state, acc_cmds} -> - {new_state, cmds} = process_message(component_id, message, acc_state) - {new_state, acc_cmds ++ cmds} - end) - - # Execute collected commands - state = execute_commands(commands, state) - - state - end + defp after_application_update(app_state, commands, state) do + state = %{state | app_state: app_state, dirty: true} - defp process_message(component_id, message, state) do - case Map.get(state.components, component_id) do - nil -> - {state, []} - - %{module: module, state: component_state} -> - # Call update function with error handling - try do - result = module.update(message, component_state) - {new_component_state, commands} = Elm.normalize_update_result(result, component_state) - - # Update component state - components = - Map.update!(state.components, component_id, fn comp -> - %{comp | state: new_component_state} - end) - - # Mark dirty if state changed - dirty = state.dirty or new_component_state != component_state - - # Update root_state if this is root - state = - if component_id == :root do - %{state | root_state: new_component_state, components: components, dirty: dirty} - else - %{state | components: components, dirty: dirty} - end - - # Tag commands with component_id - tagged_commands = Enum.map(commands, fn cmd -> {component_id, cmd} end) - - {state, tagged_commands} - rescue - error -> - require Logger - Logger.error("Component #{component_id} crashed in update: #{inspect(error)}") - # Return unchanged state and no commands - {state, []} - end + case execute_commands(commands, state) do + {:ok, %{status: :final_render_pending} = state} -> finish(state) + {:ok, state} -> {:noreply, schedule_render(state)} + {:error, reason, state} -> {:stop, reason, %{state | stop_reason: reason}} end end - # --- Command Execution --- - - defp execute_commands([], state), do: state - defp execute_commands(commands, state) do - # Check for quit command first - # Handle both Command struct and legacy atom :quit - quit_cmd = - Enum.find(commands, fn {_component_id, cmd} -> - case cmd do - %{type: :quit} -> true - :quit -> true - _ -> false - end - end) - - if quit_cmd do - # Quit command takes precedence - initiate shutdown - # Stop the GenServer after cleanup - GenServer.cast(self(), :shutdown) - %{state | shutting_down: true} - else - Enum.reduce(commands, state, fn {component_id, cmd}, acc -> - execute_command(component_id, cmd, acc) - end) - end - end - - defp execute_command(_component_id, %Command{type: :none}, state), do: state - - defp execute_command(component_id, %Command{} = command, state) do - case Executor.execute(state.command_executor, command, self(), component_id) do - {:ok, command_id} -> - pending = - Map.put(state.pending_commands, command_id, %{ - component_id: component_id, - command: command - }) + Enum.reduce_while(commands, {:ok, state}, fn + %Command{kind: :message, value: message}, {:ok, state} -> + send(self(), {:app_message, message}) + {:cont, {:ok, state}} - %{state | pending_commands: pending} + %Command{kind: :send, value: {pid, message}}, {:ok, state} -> + send(pid, message) + {:cont, {:ok, state}} - {:error, reason} -> - enqueue_message(component_id, {:error, reason}, state) - end - end - - defp execute_command(component_id, {:timer, ms, message}, state) - when is_integer(ms) and ms >= 0 do - execute_command(component_id, Command.timer(ms, message), state) - end - - defp execute_command(_component_id, {:send, pid, message}, state) when is_pid(pid) do - send(pid, message) - state - end + %Command{kind: :timer, value: {milliseconds, message}}, {:ok, state} -> + Process.send_after(self(), {:app_message, message}, milliseconds) + {:cont, {:ok, state}} - defp execute_command(component_id, other, state) do - Logger.warning("Unknown command for #{inspect(component_id)}: #{inspect(other)}") - state - end - - defp handle_command_result(component_id, command_id, result, state) do - # Remove from pending - pending = Map.delete(state.pending_commands, command_id) - state = %{state | pending_commands: pending} + %Command{kind: :async, value: {function, mapper}}, {:ok, state} -> + {:cont, {:ok, start_async_task(state, function, mapper)}} - if state.shutting_down do - state - else - case result do - {:send_to, target_component, message} -> - enqueue_message(target_component, message, state) - - _ -> - enqueue_message(component_id, result, state) - end - end - end + %Command{kind: :clipboard, value: {operation, mapper}}, {:ok, state} -> + result = BackendManager.clipboard(state.backend_manager, operation) - # --- Rendering --- + case safe_apply_mapper(mapper, result) do + {:ok, message} -> + send(self(), {:app_message, message}) + {:cont, {:ok, state}} - defp schedule_render(interval) do - Process.send_after(self(), :render, interval) - end + {:error, reason} -> + {:halt, {:error, reason, state}} + end - # Spawns a linked process that runs the input handler's poll loop. - # Events are sent as {:input, event} messages (same format as InputReader). - # This prevents blocking handlers (like Input.TTY) from freezing the GenServer. - defp spawn_input_handler_reader(handler, input_state, target) do - spawn_link(fn -> - input_handler_loop(handler, input_state, target) + %Command{kind: :shutdown, value: reason}, {:ok, state} -> + {:halt, {:ok, %{state | status: :final_render_pending, stop_reason: reason}}} end) end - defp input_handler_loop(handler, input_state, target) do - case handler.poll(input_state, @input_poll_interval) do - {{:ok, event}, new_input_state} -> - send(target, {:input, event}) - input_handler_loop(handler, new_input_state, target) - - {:timeout, new_input_state} -> - input_handler_loop(handler, new_input_state, target) + defp schedule_render(%{dirty: false} = state), do: state + defp schedule_render(%{render_timer: {_reference, _token}} = state), do: state - {:eof, _new_input_state} -> - send(target, :input_eof) - end + defp schedule_render(state) do + token = make_ref() + reference = Process.send_after(self(), {:render, token}, state.render_interval) + %{state | render_timer: {reference, token}} end - defp process_render_tick(state) do - # Process any pending messages - state = process_messages(state) - - # Render if dirty - state = - if state.dirty and not state.shutting_down do - do_render(state) - else - state - end - - # Schedule next render unless shutting down - unless state.shutting_down do - schedule_render(state.render_interval) - end + defp cancel_render(%{render_timer: nil} = state), do: state - state + defp cancel_render(%{render_timer: {reference, _token}} = state) do + _cancelled = Process.cancel_timer(reference) + %{state | render_timer: nil} end - defp do_render(%{backend: nil} = state), do: %{state | dirty: false} + defp render_now(%{dirty: false} = state), do: {:ok, state} - defp do_render(state) do - render_tree = root_view(state) - {cells, backend_state} = render_cells(render_tree, state) + defp render_now(state) do + with {:ok, %Frame{} = frame} <- app_view(state.app, state.app_state), + :ok <- BackendManager.draw(state.backend_manager, frame) do + case BackendManager.flush(state.backend_manager) do + :ok -> + {:ok, + %{ + state + | dirty: false, + frames_rendered: state.frames_rendered + 1 + }} - state.backend - |> draw_and_flush(backend_state, cells) - |> then(&%{state | dirty: false, backend_state: &1}) - end - - defp root_view(state) do - %{module: module, state: component_state} = Map.fetch!(state.components, :root) - - module.view(component_state) - rescue - error -> - require Logger - Logger.error("Component :root crashed in view: #{inspect(error)}") - {:text, "[Render Error]"} - end - - defp render_cells(render_tree, %{buffer_manager: buffer_manager} = state) - when not is_nil(buffer_manager), - do: render_with_buffer_manager(render_tree, state) - - defp render_cells(render_tree, state), do: render_to_tty_backend(render_tree, state) - - defp draw_and_flush(backend, backend_state, cells) do - case backend.draw_cells(backend_state, cells) do - {:ok, drawn_backend_state} -> flush_backend(backend, drawn_backend_state) - {:error, reason} -> exit({:shutdown, {:backend_draw_failed, reason}}) - end - end - - defp flush_backend(backend, backend_state) do - case backend.flush(backend_state) do - {:ok, flushed_backend_state} -> flushed_backend_state - {:error, reason} -> exit({:shutdown, {:backend_flush_failed, reason}}) - end - end - - # Renders using BufferManager with double buffering and diffing (Raw backend) - defp render_with_buffer_manager(render_tree, state) do - # Clear current buffer - BufferManager.clear_current(state.buffer_manager) - - # Render tree to buffer - NodeRenderer.render_to_buffer(render_tree, state.buffer_manager) - - # Get buffers for diffing - current = BufferManager.get_current_buffer(state.buffer_manager) - previous = BufferManager.get_previous_buffer(state.buffer_manager) - - # Get changed cells and convert to backend format - cells = get_changed_cells(current, previous) - - # Swap buffers - BufferManager.swap_buffers(state.buffer_manager) - - {cells, state.backend_state} - end - - # Renders to TTY backend without double buffering - defp render_to_tty_backend(render_tree, state) do - # Get terminal size from backend state or capabilities - {rows, cols} = - case state.backend_state do - %{size: {r, c}} -> {r, c} - _ -> {24, 80} + {:error, reason} -> + {:error, reason, state} end - - # Create temporary buffer for this frame - case Buffer.new(rows, cols) do - {:ok, temp_buffer} -> - # Render tree directly to temporary buffer (bypassing BufferManager) - NodeRenderer.render_to_buffer_direct(render_tree, temp_buffer) - - # Extract all non-empty cells for TTY backend - cells = extract_all_cells(temp_buffer) - - # Clean up temporary buffer - Buffer.destroy(temp_buffer) - - {cells, state.backend_state} - - {:error, _reason} -> - # If buffer creation fails, render nothing - {[], state.backend_state} + else + {:error, reason} -> {:error, reason, state} end end - # Extracts all non-empty cells from buffer for TTY backend - defp extract_all_cells(buffer) do - {rows, _cols} = Buffer.dimensions(buffer) - - for row <- 1..rows, reduce: [] do - acc -> - buffer_row = Buffer.get_row(buffer, row) - - cells_in_row = - buffer_row - |> Enum.with_index(1) - |> Enum.filter(fn {%TermUI.Renderer.Cell{} = cell, _col} -> displayable_cell?(cell) end) - |> Enum.flat_map(fn {cell, col} -> cell_to_backend_tuple(cell, row, col) end) - - cells_in_row ++ acc + defp app_view(app, app_state) do + case app.view(app_state) do + %Frame{} = frame -> {:ok, frame} + other -> {:error, {:application, :view, {:expected_frame, other}}} end + rescue + exception -> {:error, application_error(:view, :error, exception, __STACKTRACE__)} + catch + kind, reason -> {:error, application_error(:view, kind, reason, __STACKTRACE__)} end - # Gets changed cells by comparing current and previous buffers. - # Returns cells in the format expected by Backend.draw_cells/2: [{position, cell_data}] - # where position is {row, col} and cell_data is {char, fg, bg, attrs} - defp get_changed_cells(current, previous) do - {rows, _cols} = Buffer.dimensions(current) + defp finish(state) do + state = state |> cancel_render() |> Map.put(:status, :final_render_pending) - for row <- 1..rows, reduce: [] do - acc -> - current_row = Buffer.get_row(current, row) - previous_row = Buffer.get_row(previous, row) + case render_now(state) do + {:ok, state} -> + reason = normalize_stop_reason(state.stop_reason) + {:stop, reason, %{state | status: :stopping}} - # Single O(n) zip pass instead of two O(n^2) passes with Enum.at - diff_row_cells(current_row, previous_row, row, 1, acc) + {:error, reason, state} -> + {:stop, reason, %{state | status: :stopping, stop_reason: reason}} end end - # Zips current and previous rows in a single pass, emitting: - # - Changed displayable cells (new content to draw) - # - Clear cells (previous content that needs erasing) - defp diff_row_cells([], [], _row, _col, acc), do: acc - - defp diff_row_cells([cur | cur_rest], [prev | prev_rest], row, col, acc) do - acc = diff_cell(cur, prev, row, col, acc) + defp normalize_stop_reason(:normal), do: :normal + defp normalize_stop_reason(:shutdown), do: :shutdown + defp normalize_stop_reason({:backend, _, _, _} = reason), do: reason + defp normalize_stop_reason({:application, _, _} = reason), do: reason + defp normalize_stop_reason(reason), do: {:shutdown, reason} - diff_row_cells(cur_rest, prev_rest, row, col + 1, acc) - end + defp start_async_task(state, function, mapper) do + parent = self() + token = make_ref() - # Handle rows of different lengths (shouldn't happen normally, but be safe) - defp diff_row_cells([cur | cur_rest], [], row, col, acc) do - acc = - if displayable_cell?(cur) do - cell_to_backend_tuple(cur, row, col) ++ acc - else - acc - end + {pid, monitor} = + spawn_monitor(fn -> + result = + try do + {:ok, function.()} + rescue + exception -> {:error, {:error, exception, __STACKTRACE__}} + catch + kind, reason -> {:error, {kind, reason, __STACKTRACE__}} + end - diff_row_cells(cur_rest, [], row, col + 1, acc) - end + send(parent, {:async_result, token, result}) + end) - defp diff_row_cells([], [prev | prev_rest], row, col, acc) do - acc = - if displayable_cell?(prev) do - [{{row, col}, {" ", :default, :default, []}} | acc] - else - acc - end + task = %{pid: pid, monitor: monitor, mapper: mapper} - diff_row_cells([], prev_rest, row, col + 1, acc) + %{ + state + | async_tasks: Map.put(state.async_tasks, token, task), + async_monitors: Map.put(state.async_monitors, monitor, token) + } end - defp diff_cell(cur, prev, row, col, acc) do - if Cell.equal?(cur, prev) do - acc - else - diff_changed_cell(cur, prev, row, col, acc) - end + defp safe_apply_mapper(mapper, result) do + {:ok, mapper.(result)} + rescue + exception -> {:error, application_error(:command_result, :error, exception, __STACKTRACE__)} + catch + kind, reason -> {:error, application_error(:command_result, kind, reason, __STACKTRACE__)} end - defp diff_changed_cell(cur, prev, row, col, acc) do - cur_displayable? = displayable_cell?(cur) - prev_displayable? = displayable_cell?(prev) - acc = maybe_draw_cell(cur, row, col, acc, cur_displayable?) - - if clear_previous_cell?(cur, prev, cur_displayable?, prev_displayable?) do - [{{row, col}, {" ", :default, :default, []}} | acc] - else - acc - end + defp drop_async_task(state, token, monitor) do + %{ + state + | async_tasks: Map.delete(state.async_tasks, token), + async_monitors: Map.delete(state.async_monitors, monitor) + } end - defp maybe_draw_cell(cell, row, col, acc, true), - do: cell_to_backend_tuple(cell, row, col) ++ acc - - defp maybe_draw_cell(_cell, _row, _col, acc, false), do: acc - - defp clear_previous_cell?(_cur, _prev, false, true), do: true - - defp clear_previous_cell?(cur, prev, true, true), - do: prev.bg not in [nil, :default] and cur.char == " " - - defp clear_previous_cell?(_cur, _prev, _cur_displayable?, _prev_displayable?), do: false - - defp displayable_cell?(%Cell{} = cell) do - cell.char != " " or (cell.bg != nil and cell.bg != :default) or MapSet.size(cell.attrs) > 0 + defp stop_async_tasks(state) do + Enum.each(state.async_tasks, fn {_token, %{pid: pid, monitor: monitor}} -> + Process.demonitor(monitor, [:flush]) + Process.exit(pid, :kill) + end) end - # Converts a Cell struct to the backend format: {{row, col}, {char, fg, bg, attrs}} - # Skips wide placeholder cells (they're part of wide characters) - # Returns [] for skipped cells to filter them out - defp cell_to_backend_tuple(%Cell{wide_placeholder: true}, _row, _col), do: [] - - defp cell_to_backend_tuple(%Cell{char: char, fg: fg, bg: bg, attrs: attrs}, row, col) do - # Convert MapSet attrs to list for backend format - attrs_list = MapSet.to_list(attrs) - [{{row, col}, {char, normalize_color(fg), normalize_color(bg), attrs_list}}] + defp app_terminate(app, reason, app_state) do + if function_exported?(app, :terminate, 2), do: app.terminate(reason, app_state), else: :ok + rescue + _exception -> :ok + catch + _kind, _reason -> :ok end - # Normalizes colors to ensure :default instead of nil - defp normalize_color(nil), do: :default - defp normalize_color(color), do: color - - # --- Resize Handling --- - - defp handle_resize(rows, cols, state) do - cond do - state.terminal_started -> - # Local terminal (Raw/TTY) with Terminal singleton - new_dimensions = {cols, rows} - - if state.buffer_manager do - BufferManager.resize(state.buffer_manager, rows, cols) - end - - resize_event = Event.Resize.new(cols, rows) - state = broadcast_event(resize_event, %{state | dimensions: new_dimensions}) - - # broadcast_event only enqueues; drain the queue so the upcoming - # render uses the new dimensions instead of the previous frame's. - # Without this the first frame after a resize draws stale content - # into the resized buffer -- visible as blanks (on grow) or - # clipping (on shrink) during drag-resize. - # - # We intentionally do not call backend.clear here: the diff - # already emits both content cells and erase cells for positions - # that went from content to empty, so the terminal repaints - # cleanly without a flicker-inducing full screen wipe. - state = process_messages(state) - finish_resize(state) - - state.backend != nil -> - # Custom backend (SSH, etc.) — no Terminal singleton - new_dimensions = {cols, rows} - - if state.buffer_manager do - BufferManager.resize(state.buffer_manager, rows, cols) - end - - # Update backend size (no clear -- see terminal_started branch). - backend_state = - if function_exported?(state.backend, :update_size, 3) do - {:ok, bs} = state.backend.update_size(state.backend_state, rows, cols) - bs - else - state.backend_state - end - - # The diff renderer only emits erase cells when a buffer_manager is - # in play. Backends without one (TTY path) need a real clear so - # stale pre-resize content isn't left on screen. - backend_state = - if state.buffer_manager == nil do - {:ok, cleared} = state.backend.clear(backend_state) - cleared - else - backend_state - end + defp effective_reason(:normal, state), do: state.stop_reason + defp effective_reason(reason, %{stop_reason: :normal}), do: reason + defp effective_reason(_reason, state), do: state.stop_reason - state = %{state | backend_state: backend_state} - - resize_event = Event.Resize.new(cols, rows) - state = broadcast_event(resize_event, %{state | dimensions: new_dimensions}) - - # See note on the terminal_started branch above -- drain the queue - # so the render uses the new dimensions, not the previous frame's. - state = process_messages(state) - finish_resize(state) - - true -> - state - end + defp application_error(stage, kind, reason, stacktrace) do + {:application, stage, {kind, reason, stacktrace}} end - # process_messages can run a component's :quit command and flip - # shutting_down. Match process_render_tick's guard so we don't paint - # into a backend that's about to be torn down. - defp finish_resize(%{shutting_down: true} = state), do: state - defp finish_resize(state), do: do_render(%{state | dirty: true}) - - # --- Shutdown --- - - defp initiate_shutdown(state) do - state = %{state | shutting_down: true} - state = do_shutdown(state) - - # Schedule the GenServer to stop after returning from this callback - # This allows terminate/2 to run and clean up properly - Process.send_after(self(), :stop_runtime, 0) + defp size_to_dimensions({rows, columns}), do: {columns, rows} - state - end - - defp do_shutdown(state) do - if state.command_executor do - Enum.each(Map.keys(state.pending_commands), fn command_id -> - _ = Executor.cancel(state.command_executor, command_id) - end) + defp render_interval(opts) do + case Keyword.get(opts, :render_interval, @default_render_interval) do + interval when is_integer(interval) and interval > 0 -> interval + _other -> @default_render_interval end - - state = %{state | pending_commands: %{}} - - # Terminate components (leaf to root) - # For now, just clear components - state = %{state | components: %{}} - - state end end diff --git a/lib/term_ui/runtime/node_renderer.ex b/lib/term_ui/runtime/node_renderer.ex deleted file mode 100644 index 04920819..00000000 --- a/lib/term_ui/runtime/node_renderer.ex +++ /dev/null @@ -1,474 +0,0 @@ -defmodule TermUI.Runtime.NodeRenderer do - @moduledoc """ - Converts render trees to buffer cells for terminal output. - - This module bridges the gap between the component's render tree output - and the low-level buffer cell representation needed for terminal rendering. - - Supports both tuple-based render nodes (from TermUI.Elm.Helpers) and - struct-based RenderNodes (from TermUI.Component.RenderNode). - """ - - alias TermUI.Component.RenderNode - alias TermUI.Renderer.Buffer - alias TermUI.Renderer.BufferManager - alias TermUI.Renderer.Cell - alias TermUI.Renderer.Style - - # Dialyzer: Functions with unmatched return values - @dialyzer {:nowarn_function, - render_node: 5, - render_text: 5, - render_line: 5, - render_children_vertical: 5, - render_children_horizontal: 5, - render_positioned_cells: 5, - render_viewport: 1, - render_and_copy_viewport: 4, - fill_overlay_background: 4, - fill_overlay_area: 6, - fill_background: 6, - apply_parent_style_to_cell: 2, - copy_viewport_region: 2} - - @doc """ - Renders a node tree to the buffer starting at the given position. - - Returns the bounds of the rendered content as {width, height}. - """ - @spec render_to_buffer(term(), BufferManager.t() | pid(), pos_integer(), pos_integer()) :: - {non_neg_integer(), non_neg_integer()} - def render_to_buffer(node, buffer_manager, start_row \\ 1, start_col \\ 1) do - buffer = BufferManager.get_current_buffer(buffer_manager) - render_node(node, buffer, start_row, start_col, nil) - end - - @doc """ - Renders a node tree directly to a Buffer struct (not via BufferManager). - - This is used for TTY mode where we create temporary buffers per frame. - - Returns the bounds of the rendered content as {width, height}. - """ - @spec render_to_buffer_direct(term(), Buffer.t(), pos_integer(), pos_integer()) :: - {non_neg_integer(), non_neg_integer()} - def render_to_buffer_direct(node, buffer, start_row \\ 1, start_col \\ 1) do - render_node(node, buffer, start_row, start_col, nil) - end - - # Handle RenderNode structs - defp render_node(%RenderNode{type: :empty}, _buffer, _row, _col, _style), do: {0, 0} - - defp render_node( - %RenderNode{type: :text, content: content, style: style}, - buffer, - row, - col, - parent_style - ) do - effective_style = merge_styles(parent_style, style) - render_text(content, buffer, row, col, effective_style) - end - - defp render_node( - %RenderNode{type: :box, children: children, style: style, width: width, height: height}, - buffer, - row, - col, - parent_style - ) do - effective_style = merge_styles(parent_style, style) - - {rendered_width, rendered_height} = - render_children_vertical(children, buffer, row, col, effective_style) - - # Return specified dimensions if provided, otherwise use rendered dimensions - final_width = width || rendered_width - final_height = height || rendered_height - {final_width, final_height} - end - - defp render_node( - %RenderNode{type: :stack, direction: :vertical, children: children, style: style}, - buffer, - row, - col, - parent_style - ) do - effective_style = merge_styles(parent_style, style) - render_children_vertical(children, buffer, row, col, effective_style) - end - - defp render_node( - %RenderNode{type: :stack, direction: :horizontal, children: children, style: style}, - buffer, - row, - col, - parent_style - ) do - effective_style = merge_styles(parent_style, style) - render_children_horizontal(children, buffer, row, col, effective_style) - end - - defp render_node(%RenderNode{type: :cells, cells: cells}, buffer, row, col, parent_style) do - render_positioned_cells(cells, buffer, row, col, parent_style) - end - - # Handle viewport nodes (from Viewport widget) - # Viewport clips content to a region and applies scroll offsets - defp render_node( - %{ - type: :viewport, - content: content, - scroll_x: scroll_x, - scroll_y: scroll_y, - width: width, - height: height - }, - buffer, - row, - col, - style - ) do - render_viewport(%{ - content: content, - buffer: buffer, - dest_row: row, - dest_col: col, - style: style, - scroll_x: scroll_x, - scroll_y: scroll_y, - vp_width: width, - vp_height: height - }) - end - - # Handle overlay nodes (from AlertDialog, Dialog, ContextMenu, Toast widgets) - # Overlay renders content at an absolute position on screen - # Optional: width, height, bg for opaque background fill - defp render_node( - %{ - type: :overlay, - content: content, - x: x, - y: y - } = overlay, - buffer, - _row, - _col, - style - ) do - # Overlay uses absolute positioning - x and y are 0-indexed screen coordinates - # Convert to 1-indexed buffer coordinates - buf_row = y + 1 - buf_col = x + 1 - - # If width, height, and bg are provided, fill background first - # This creates an opaque background for the overlay content - _fill_result = - case overlay do - %{width: width, height: height, bg: %Style{} = bg_style} - when is_integer(width) and width > 0 and is_integer(height) and height > 0 -> - fill_background(buffer, buf_row, buf_col, width, height, bg_style) - - _ -> - :ok - end - - # Merge bg style with parent style so content inherits the background - # This ensures borders and text without explicit bg get the overlay background - effective_style = - case overlay do - %{bg: %Style{} = bg_style} -> merge_styles(style, bg_style) - _ -> style - end - - render_node(content, buffer, buf_row, buf_col, effective_style) - end - - # Handle tuple-based render nodes from Elm.Helpers - defp render_node({:text, content}, buffer, row, col, style) do - render_text(content, buffer, row, col, style) - end - - # Handle overlay tuple from Elm components (background content with overlay on top) - defp render_node({:overlay, background, %{} = overlay_map}, buffer, row, col, style) do - # First render the background content at current position - render_node(background, buffer, row, col, style) - - # Then render the overlay using its absolute positioning - x = Map.get(overlay_map, :x, 0) - y = Map.get(overlay_map, :y, 0) - - # Fill overlay background if specified - fill_overlay_background(buffer, overlay_map, x, y) - - # Merge overlay's bg style with parent style for proper background inheritance - overlay_style = merge_overlay_style(style, overlay_map) - - render_node(overlay_map, buffer, y + 1, x + 1, overlay_style) - end - - defp render_node({:styled, content, style}, buffer, row, col, parent_style) do - effective_style = merge_styles(parent_style, style) - render_node(content, buffer, row, col, effective_style) - end - - defp render_node({:box, _opts, children}, buffer, row, col, style) do - render_node(children, buffer, row, col, style) - end - - defp render_node({:row, _opts, children}, buffer, row, col, style) do - render_children_horizontal(children, buffer, row, col, style) - end - - defp render_node({:column, _opts, children}, buffer, row, col, style) do - render_children_vertical(children, buffer, row, col, style) - end - - defp render_node({:fragment, children}, buffer, row, col, style) do - render_children_vertical(children, buffer, row, col, style) - end - - # Handle lists of children (from stack(:vertical, [...])) - defp render_node(children, buffer, row, col, style) when is_list(children) do - render_children_vertical(children, buffer, row, col, style) - end - - # Fallback for unknown node types - defp render_node(_node, _buffer, _row, _col, _style), do: {0, 0} - - # Overlay helper functions - defp fill_overlay_background(buffer, overlay_map, x, y) do - case overlay_map do - %{width: width, height: height, bg: %Style{} = bg_style} - when is_integer(width) and width > 0 and is_integer(height) and height > 0 -> - fill_overlay_area(buffer, x, y, width, height, bg_style) - - _ -> - :ok - end - end - - defp fill_overlay_area(buffer, x, y, width, height, bg_style) do - for dy <- 0..(height - 1) do - buf_row = y + 1 + dy - - for dx <- 0..(width - 1) do - cell = create_cell(" ", bg_style) - Buffer.set_cell(buffer, buf_row, x + 1 + dx, cell) - end - end - end - - defp merge_overlay_style(parent_style, overlay_map) do - case Map.get(overlay_map, :bg) do - %Style{} = bg_style -> merge_styles(parent_style, bg_style) - _ -> parent_style - end - end - - # Text rendering - defp render_text(nil, _buffer, _row, _col, _style), do: {0, 0} - # Empty string should still take up one line (for blank lines) - defp render_text("", _buffer, _row, _col, _style), do: {0, 1} - - defp render_text(text, buffer, row, col, style) when is_binary(text) do - lines = String.split(text, "\n") - max_width = 0 - height = length(lines) - - {max_width, _final_row} = - Enum.reduce(lines, {max_width, row}, fn line, {max_w, current_row} -> - width = render_line(line, buffer, current_row, col, style) - {max(max_w, width), current_row + 1} - end) - - {max_width, height} - end - - defp render_text(content, buffer, row, col, style) do - render_text(to_string(content), buffer, row, col, style) - end - - defp render_line(line, buffer, row, col, style) do - graphemes = String.graphemes(line) - width = length(graphemes) - - graphemes - |> Enum.with_index() - |> Enum.each(fn {char, idx} -> - cell = create_cell(char, style) - Buffer.set_cell(buffer, row, col + idx, cell) - end) - - width - end - - # Children rendering - defp render_children_vertical(children, buffer, row, col, style) when is_list(children) do - {max_width, final_row} = - Enum.reduce(children, {0, row}, fn child, {max_w, current_row} -> - {width, height} = render_node(child, buffer, current_row, col, style) - {max(max_w, width), current_row + height} - end) - - {max_width, final_row - row} - end - - defp render_children_horizontal(children, buffer, row, col, style) when is_list(children) do - {max_height, final_col} = - Enum.reduce(children, {0, col}, fn child, {max_h, current_col} -> - {width, height} = render_node(child, buffer, row, current_col, style) - {max(max_h, height), current_col + width} - end) - - {final_col - col, max_height} - end - - # Positioned cells rendering (for widgets like Gauge that pre-render cells) - defp render_positioned_cells(cells, buffer, offset_row, offset_col, parent_style) do - max_x = 0 - max_y = 0 - - {max_x, max_y} = - Enum.reduce(cells, {max_x, max_y}, fn %{x: x, y: y, cell: cell}, {mx, my} -> - # Apply parent style if cell doesn't have its own - cell = apply_parent_style_to_cell(cell, parent_style) - Buffer.set_cell(buffer, offset_row + y, offset_col + x, cell) - {max(mx, x + 1), max(my, y + 1)} - end) - - {max_x, max_y} - end - - # Viewport rendering - clips content to a region with scroll offsets - # Creates a temporary buffer to render content, then copies visible portion - defp render_viewport(params) do - %{ - content: content, - buffer: buffer, - dest_row: dest_row, - dest_col: dest_col, - style: style, - scroll_x: scroll_x, - scroll_y: scroll_y, - vp_width: vp_width, - vp_height: vp_height - } = params - - {content_width, content_height} = - calculate_viewport_content_size(scroll_x, scroll_y, vp_width, vp_height) - - case Buffer.new(content_height, content_width) do - {:ok, temp_buffer} -> - opts = - build_viewport_opts(buffer, dest_row, dest_col, scroll_x, scroll_y, vp_width, vp_height) - - render_and_copy_viewport(temp_buffer, content, style, opts) - - _error -> - {vp_width, vp_height} - end - end - - defp build_viewport_opts(buffer, dest_row, dest_col, scroll_x, scroll_y, vp_width, vp_height) do - %{ - buffer: buffer, - dest_row: dest_row, - dest_col: dest_col, - scroll_x: scroll_x, - scroll_y: scroll_y, - vp_width: vp_width, - vp_height: vp_height - } - end - - defp calculate_viewport_content_size(scroll_x, scroll_y, vp_width, vp_height) do - content_width = scroll_x + vp_width + 100 - content_height = scroll_y + vp_height + 100 - - content_width = min(content_width, Buffer.max_cols()) - content_height = min(content_height, Buffer.max_rows()) - - {content_width, content_height} - end - - defp render_and_copy_viewport(temp_buffer, content, style, opts) do - # Render content to temporary buffer - render_node(content, temp_buffer, 1, 1, style) - - # Copy visible region to destination buffer - copy_viewport_region(temp_buffer, opts) - - # Clean up temporary buffer - Buffer.destroy(temp_buffer) - - {opts.vp_width, opts.vp_height} - end - - defp copy_viewport_region(temp_buffer, opts) do - for dy <- 0..(opts.vp_height - 1), dx <- 0..(opts.vp_width - 1) do - src_row = opts.scroll_y + 1 + dy - src_col = opts.scroll_x + 1 + dx - - cell = Buffer.get_cell(temp_buffer, src_row, src_col) - Buffer.set_cell(opts.buffer, opts.dest_row + dy, opts.dest_col + dx, cell) - end - end - - # Fill a rectangular region with a background color - defp fill_background(buffer, row, col, width, height, bg_style) do - cell = create_cell(" ", bg_style) - - for dy <- 0..(height - 1), dx <- 0..(width - 1) do - Buffer.set_cell(buffer, row + dy, col + dx, cell) - end - - :ok - end - - # Cell creation - defp create_cell(char, nil) do - Cell.new(char) - end - - defp create_cell(char, %Style{fg: fg, bg: bg, attrs: attrs}) do - opts = [] - opts = if fg && fg != :default, do: [{:fg, fg} | opts], else: opts - opts = if bg && bg != :default, do: [{:bg, bg} | opts], else: opts - opts = if MapSet.size(attrs) > 0, do: [{:attrs, MapSet.to_list(attrs)} | opts], else: opts - Cell.new(char, opts) - end - - defp apply_parent_style_to_cell(%Cell{fg: nil, bg: nil, attrs: []} = cell, %Style{} = style) do - opts = [] - opts = if style.fg && style.fg != :default, do: [{:fg, style.fg} | opts], else: opts - opts = if style.bg && style.bg != :default, do: [{:bg, style.bg} | opts], else: opts - - opts = - if MapSet.size(style.attrs) > 0, - do: [{:attrs, MapSet.to_list(style.attrs)} | opts], - else: opts - - if opts == [] do - cell - else - %{cell | fg: style.fg, bg: style.bg, attrs: MapSet.to_list(style.attrs)} - end - end - - defp apply_parent_style_to_cell(cell, _style), do: cell - - # Style merging - defp merge_styles(nil, nil), do: nil - defp merge_styles(nil, style), do: style - defp merge_styles(style, nil), do: style - - defp merge_styles(%Style{} = parent, %Style{} = child) do - Style.merge(parent, child) - end - - # Handle non-Style types (in case of raw maps or tuples) - defp merge_styles(_parent, child), do: child -end diff --git a/lib/term_ui/runtime/state.ex b/lib/term_ui/runtime/state.ex deleted file mode 100644 index d36f6b5b..00000000 --- a/lib/term_ui/runtime/state.ex +++ /dev/null @@ -1,93 +0,0 @@ -defmodule TermUI.Runtime.State do - @moduledoc """ - State struct for the Runtime GenServer. - - Contains all runtime state including: - - Root component module and state - - Component registry - - Message queue - - Event queue (bounded, prevents DoS) - - Render configuration - - Focus tracking - - Shutdown status - - Backend selection and capabilities - - Input handler (Raw or TTY mode) - - Command executor for async side effects - """ - - alias TermUI.Command.Executor - alias TermUI.EventQueue - alias TermUI.MessageQueue - - @type backend_mode :: :raw | :tty | nil - - @type capabilities :: %{ - optional(:colors) => :true_color | :color_256 | :color_16 | :monochrome, - optional(:unicode) => boolean(), - optional(:dimensions) => {pos_integer(), pos_integer()} | nil, - optional(:terminal) => boolean(), - optional(:raw_mode_error) => term() - } - - @type t :: %__MODULE__{ - root_module: module(), - root_state: term(), - message_queue: MessageQueue.t(), - event_queue: EventQueue.t(), - render_interval: pos_integer(), - dirty: boolean(), - focused_component: atom(), - components: %{atom() => component_entry()}, - pending_commands: %{reference() => command_entry()}, - shutting_down: boolean(), - terminal_started: boolean(), - buffer_manager: pid() | nil, - dimensions: {pos_integer(), pos_integer()} | nil, - input_reader: pid() | nil, - input_handler_reader: pid() | nil, - backend_mode: backend_mode(), - backend: module() | nil, - backend_state: term() | nil, - capabilities: capabilities() | nil, - command_executor: Executor.t() | nil, - input_handler: module() | nil, - input_state: term() | nil, - logger_handler_config: map() | nil - } - - @type component_entry :: %{ - module: module(), - state: term() - } - - @type command_entry :: %{ - component_id: atom(), - command: term() - } - - defstruct [ - :root_module, - :root_state, - :message_queue, - :event_queue, - :render_interval, - :dirty, - :focused_component, - :components, - :pending_commands, - :shutting_down, - :terminal_started, - :buffer_manager, - :dimensions, - :input_reader, - input_handler_reader: nil, - backend_mode: nil, - backend: nil, - backend_state: nil, - capabilities: nil, - command_executor: nil, - input_handler: nil, - input_state: nil, - logger_handler_config: nil - ] -end diff --git a/lib/term_ui/sanitize.ex b/lib/term_ui/sanitize.ex deleted file mode 100644 index bd71fb04..00000000 --- a/lib/term_ui/sanitize.ex +++ /dev/null @@ -1,229 +0,0 @@ -defmodule TermUI.Sanitize do - @moduledoc """ - Input sanitization for terminal escape sequence injection prevention. - - This module provides utilities to sanitize user input before rendering - to prevent terminal escape sequence injection attacks. - - ## Security Model - - Terminal escape sequences can be maliciously injected into user input - to: - - Clear the screen - - Modify terminal colors - - Move cursor position - - Execute arbitrary commands (in some terminals) - - Hide/alter displayed content - - This module strips or neutralizes such sequences. - - ## Example - - iex> Sanitize.sanitize("\e[31mMalicious\e[0m") - "[ESC][31mMalicious[ESC][0m" - - iex> Sanitize.sanitize("\e[31mMalicious\e[0m", escape: :remove) - "Malicious" - - iex> Sanitize.sanitize("Normal text") - "Normal text" - """ - - # NOTE: Defined as a function rather than a module attribute because compiled - # Regex structs contain references that cannot be injected into function bodies. - defp ansi_escape_pattern do - ~r/(\x1b\[ - [0-9;:=?]*[ - \x40-\x7e]| - \x1b\] - [^\x07\x1b]*\x07| - \x1b[^\x1b\x07]| - \x07[\x05\x06]| - \x00-\x08|\x0b-\x0c|\x0e-\x1f - )/x - end - - # Dialyzer: Functions return specific atom types - @dialyzer {:nowarn_function, validate: 1} - - @doc """ - Sanitizes a string by processing terminal escape sequences. - - ## Options - - - `:escape` - How to handle ANSI escapes: - - `:bracket` (default) - Replace with safe bracket notation - - `:remove` - Remove entirely - - `:keep` - Keep as-is (use with caution) - - - `:max_length` - Maximum string length (default: 10_000) - - ## Returns - - - Sanitized string - - String truncated if exceeds max_length - - ## Examples - - iex> Sanitize.sanitize("\\e[31mRed\\e[0m") - "[ESC][31mRed[ESC][0m" - - iex> Sanitize.sanitize("\\e[31mRed\\e[0m", escape: :remove) - "Red" - - iex> Sanitize.sanitize(String.duplicate("a", 20000)) - String.duplicate("a", 10000) - """ - @spec sanitize(binary(), keyword()) :: binary() - def sanitize(input, opts \\ []) when is_binary(input) do - escape_mode = Keyword.get(opts, :escape, :bracket) - max_length = Keyword.get(opts, :max_length, 10_000) - - input - |> truncate_length(max_length) - |> sanitize_escapes(escape_mode) - end - - @doc """ - Returns true if the string contains ANSI escape sequences. - - ## Examples - - iex> Sanitize.has_ansi?("\\e[31mRed") - true - - iex> Sanitize.has_ansi?("Plain text") - false - """ - @spec has_ansi?(binary()) :: boolean() - def has_ansi?(input) when is_binary(input) do - Regex.match?(ansi_escape_pattern(), input) - end - - @doc """ - Strips all ANSI escape sequences from the string. - - ## Examples - - iex> Sanitize.strip_ansi("\\e[31mRed\\e[0m") - "Red" - - iex> Sanitize.strip_ansi("\\e[2J\\e[HHello") - "Hello" - """ - @spec strip_ansi(binary()) :: binary() - def strip_ansi(input) when is_binary(input) do - Regex.replace(ansi_escape_pattern(), input, "") - end - - @doc """ - Validates that a string contains only safe printable characters. - - Returns `:ok` if safe, `{:error, reason}` if unsafe. - - ## Safety Rules - - - Only printable ASCII (32-126) and valid UTF-8 - - No control characters (except tab, newline, carriage return) - - No ANSI escape sequences - - No null bytes - - ## Examples - - iex> Sanitize.validate("Safe text") - :ok - - iex> Sanitize.validate("\\e[31mUnsafe") - {:error, :contains_ansi} - - iex> Sanitize.validate("Null\\x00byte") - {:error, :contains_null_byte} - """ - @spec validate(binary()) :: :ok | {:error, atom()} - def validate(input) when is_binary(input) do - cond do - String.contains?(input, <<0>>) -> - {:error, :contains_null_byte} - - has_ansi?(input) -> - {:error, :contains_ansi} - - contains_unsafe_controls?(input) -> - {:error, :contains_control_chars} - - true -> - :ok - end - end - - @doc """ - Escapes a string for safe rendering by replacing dangerous sequences - with safe bracket notation. - - This is useful when you want to visually indicate that escape - sequences were present without allowing them to execute. - - ## Examples - - iex> Sanitize.escape_bracket("\\e[31m") - "[ESC][31m" - - iex> Sanitize.escape_bracket("Normal") - "Normal" - """ - @spec escape_bracket(binary()) :: binary() - def escape_bracket(input) when is_binary(input) do - input - |> String.replace("\e", "[ESC]") - |> replace_control_chars() - end - - # Private functions - - # Truncates string to max length - defp truncate_length(input, max) when byte_size(input) > max do - binary_part(input, 0, max) - end - - defp truncate_length(input, _max), do: input - - # Sanitizes escapes based on mode - defp sanitize_escapes(input, :bracket) do - input - |> String.replace("\e", "[ESC]") - |> replace_control_chars() - end - - defp sanitize_escapes(input, :remove) do - Regex.replace(ansi_escape_pattern(), input, "") - end - - defp sanitize_escapes(input, :keep), do: input - - # Replaces control characters with safe notation - defp replace_control_chars(input) do - input - |> String.replace("\a", "[BEL]") - |> String.replace("\b", "[BS]") - |> String.replace("\v", "[VT]") - |> String.replace("\f", "[FF]") - |> String.replace("\e", "[ESC]") - end - - # Checks for unsafe control characters - # Allows: \t (9), \n (10), \r (13) - # Rejects: \0-\8, \11-\12, \14-\31 - defp contains_unsafe_controls?(input) do - # Collect bytes first, then check - bytes = for <>, do: byte - - Enum.any?(bytes, fn byte -> - cond do - byte in [0, 1, 2, 3, 4, 5, 6, 7, 8] -> true - byte in [11, 12] -> true - byte >= 14 and byte <= 31 -> true - true -> false - end - end) - end -end diff --git a/lib/term_ui/selection.ex b/lib/term_ui/selection.ex new file mode 100644 index 00000000..92c87311 --- /dev/null +++ b/lib/term_ui/selection.ex @@ -0,0 +1,206 @@ +defmodule TermUI.Selection do + @moduledoc """ + Pure Unicode text selection state. + + Positions are zero-based grapheme offsets. A selection keeps its anchor and + moving head, so backward selections remain directional. The public range is + always a half-open `{start, finish}` tuple in ascending order. + """ + + import Kernel, except: [length: 1] + + @type position :: non_neg_integer() + @type t :: %__MODULE__{anchor: position() | nil, head: position() | nil} + + @position Zoi.union([Zoi.integer() |> Zoi.non_negative(), Zoi.literal(nil)]) + @schema Zoi.struct(__MODULE__, %{ + anchor: @position |> Zoi.default(nil), + head: @position |> Zoi.default(nil) + }) + + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @doc "Returns the Zoi schema for selection state." + @spec schema() :: Zoi.schema() + def schema, do: @schema + + @doc "Creates an empty selection." + @spec new() :: %__MODULE__{anchor: nil, head: nil} + def new, do: %__MODULE__{} + + @doc "Starts a selection and sets its anchor." + @spec start(t(), position()) :: t() + def start(%__MODULE__{}, position) when is_integer(position) and position >= 0, + do: %__MODULE__{anchor: position, head: position} + + @doc "Moves the selection head while retaining its anchor." + @spec extend(t(), position()) :: t() + def extend(%__MODULE__{anchor: nil} = selection, position), do: start(selection, position) + + def extend(%__MODULE__{} = selection, position) + when is_integer(position) and position >= 0, + do: %{selection | head: position} + + @doc "Clears the selection." + @spec clear(t()) :: t() + def clear(%__MODULE__{}), do: new() + + @doc "Returns true when the selection has an anchor and head." + @spec active?(t()) :: boolean() + def active?(%__MODULE__{anchor: anchor, head: head}), do: anchor != nil and head != nil + + @doc "Returns true when the selection has no selected graphemes." + @spec empty?(t()) :: boolean() + def empty?(%__MODULE__{} = selection) do + not active?(selection) or selection.anchor == selection.head + end + + @doc "Returns the ascending half-open range, or `nil` when inactive." + @spec range(t()) :: {position(), position()} | nil + def range(%__MODULE__{} = selection) do + if active?(selection), + do: {min(selection.anchor, selection.head), max(selection.anchor, selection.head)}, + else: nil + end + + @doc "Returns the anchor position." + @spec anchor(t()) :: position() | nil + def anchor(%__MODULE__{anchor: anchor}), do: anchor + + @doc "Returns the moving head position." + @spec head(t()) :: position() | nil + def head(%__MODULE__{head: head}), do: head + + @doc "Returns the selected grapheme count." + @spec length(t()) :: non_neg_integer() + def length(%__MODULE__{} = selection) do + case range(selection) do + nil -> 0 + {start, finish} -> finish - start + end + end + + @doc "Returns true when a grapheme position is inside the selection." + @spec contains?(t(), position()) :: boolean() + def contains?(%__MODULE__{} = selection, position) + when is_integer(position) and position >= 0 do + case range(selection) do + nil -> false + {start, finish} -> position >= start and position < finish + end + end + + @doc "Extracts selected graphemes from text." + @spec extract(t(), String.t()) :: String.t() + def extract(%__MODULE__{} = selection, text) when is_binary(text) do + graphemes = String.graphemes(text) + + case clamped_range(selection, Kernel.length(graphemes)) do + nil -> "" + {start, finish} -> graphemes |> Enum.slice(start, finish - start) |> Enum.join() + end + end + + @doc "Replaces selected graphemes and returns `{text, cursor, cleared_selection}`." + @spec replace(t(), String.t(), String.t()) :: {String.t(), position(), t()} + def replace(%__MODULE__{} = selection, text, replacement) + when is_binary(text) and is_binary(replacement) do + graphemes = String.graphemes(text) + inserted = String.graphemes(replacement) + + {start, finish} = clamped_range(selection, Kernel.length(graphemes)) || {0, 0} + value = Enum.take(graphemes, start) ++ inserted ++ Enum.drop(graphemes, finish) + {Enum.join(value), start + Kernel.length(inserted), new()} + end + + @doc "Selects all text." + @spec select_all(t(), String.t()) :: t() + def select_all(%__MODULE__{}, text) when is_binary(text), + do: %__MODULE__{anchor: 0, head: grapheme_count(text)} + + @doc "Selects the word, whitespace run, or punctuation run at a position." + @spec select_word(t(), String.t(), position()) :: t() + def select_word(%__MODULE__{}, text, position) + when is_binary(text) and is_integer(position) and position >= 0 do + graphemes = String.graphemes(text) + + case graphemes do + [] -> + start(new(), 0) + + _items -> + index = min(position, Kernel.length(graphemes) - 1) + category = graphemes |> Enum.at(index) |> category() + start = run_start(graphemes, index, category) + finish = run_finish(graphemes, index, category) + %__MODULE__{anchor: start, head: finish} + end + end + + @doc "Selects the line containing a grapheme position, without its newline." + @spec select_line(t(), String.t(), position()) :: t() + def select_line(%__MODULE__{}, text, position) + when is_binary(text) and is_integer(position) and position >= 0 do + graphemes = String.graphemes(text) + position = min(position, Kernel.length(graphemes)) + start = line_start(graphemes, position) + finish = line_finish(graphemes, position) + %__MODULE__{anchor: start, head: finish} + end + + defp clamped_range(selection, maximum) do + case range(selection) do + nil -> nil + {start, finish} -> {min(start, maximum), min(finish, maximum)} + end + end + + defp run_start(graphemes, index, category) do + graphemes + |> Enum.take(index) + |> Enum.reverse() + |> Enum.take_while(&(category(&1) == category)) + |> Kernel.length() + |> then(&(index - &1)) + end + + defp run_finish(graphemes, index, category) do + graphemes + |> Enum.drop(index) + |> Enum.take_while(&(category(&1) == category)) + |> Kernel.length() + |> Kernel.+(index) + end + + defp line_start(graphemes, position) do + graphemes + |> Enum.take(position) + |> Enum.reverse() + |> Enum.find_index(&(&1 == "\n")) + |> case do + nil -> 0 + distance -> position - distance + end + end + + defp line_finish(graphemes, position) do + graphemes + |> Enum.drop(position) + |> Enum.find_index(&(&1 == "\n")) + |> case do + nil -> Kernel.length(graphemes) + distance -> position + distance + end + end + + defp category(grapheme) do + cond do + String.match?(grapheme, ~r/^[\p{L}\p{N}_]$/u) -> :word + String.match?(grapheme, ~r/^\s$/u) -> :space + true -> :punctuation + end + end + + defp grapheme_count(text), do: text |> String.graphemes() |> Kernel.length() +end diff --git a/lib/term_ui/sgr.ex b/lib/term_ui/sgr.ex deleted file mode 100644 index 61b3b3a9..00000000 --- a/lib/term_ui/sgr.ex +++ /dev/null @@ -1,360 +0,0 @@ -defmodule TermUI.SGR do - @moduledoc """ - SGR (Select Graphic Rendition) sequence generation for terminal styling. - - This module provides centralized generation of SGR parameters and sequences - for terminal text styling, including colors and text attributes. - - ## Overview - - SGR sequences control text appearance (colors, bold, italic, etc.) in terminals. - They follow the format `ESC[m` where params are semicolon-separated numbers. - - ## Two Modes of Operation - - 1. **Parameter mode** - Returns parameter strings for combining into sequences - - Use when building combined sequences like `ESC[1;31;4m` - - Functions: `color_param/2`, `attr_param/1` - - 2. **Sequence mode** - Returns complete escape sequences - - Use for direct terminal output - - Functions: `color_sequence/2`, `attr_sequence/1` - - ## Color Types - - - Named colors: `:red`, `:green`, `:blue`, `:cyan`, `:magenta`, `:yellow`, `:black`, `:white` - - Bright variants: `:bright_red`, `:bright_green`, etc. - - 256-color palette: Integer 0-255 - - True color RGB: `{r, g, b}` tuple - - Default: `:default` to reset to terminal default - - ## Attributes - - Supported: `:bold`, `:dim`, `:italic`, `:underline`, `:blink`, `:reverse`, - `:hidden`, `:strikethrough` - - ## Examples - - # Parameter mode for combining - iex> SGR.color_param(:fg, :red) - "31" - - iex> SGR.color_param(:fg, {255, 128, 0}) - "38;2;255;128;0" - - iex> SGR.attr_param(:bold) - "1" - - # Building combined sequence - iex> params = [SGR.attr_param(:bold), SGR.color_param(:fg, :red)] - iex> SGR.build_sequence(params) - ["\\e[", ["1", ";", "31"], "m"] - - # Sequence mode for direct output - iex> SGR.color_sequence(:fg, :red) |> IO.iodata_to_binary() - "\\e[31m" - - iex> SGR.attr_sequence(:bold) |> IO.iodata_to_binary() - "\\e[1m" - """ - - # Dialyzer: Functions in this module are pure data constructors that return - # specific iolist or String.t() structures. The public specs are correct - # for the API, but Dialyzer's success typing infers more specific types. - @dialyzer {:nowarn_function, - color_param: 2, - attr_param: 1, - attr_off_param: 1, - build_sequence: 1, - color_sequence: 2, - attr_sequence: 1, - reset: 0, - named_colors: 0, - supported_attrs: 0} - - @csi "\e[" - - # =========================================================================== - # Parameter Mode - Returns strings for combining - # =========================================================================== - - @doc """ - Returns SGR parameter string for a color. - - Used when building combined sequences like `ESC[1;31;4m`. - - ## Examples - - iex> SGR.color_param(:fg, :red) - "31" - - iex> SGR.color_param(:bg, :blue) - "44" - - iex> SGR.color_param(:fg, 196) - "38;5;196" - - iex> SGR.color_param(:bg, {0, 255, 128}) - "48;2;0;255;128" - """ - @spec color_param(:fg | :bg, color :: term()) :: String.t() | nil - # Default colors - def color_param(:fg, :default), do: "39" - def color_param(:bg, :default), do: "49" - - # Named foreground colors - def color_param(:fg, :black), do: "30" - def color_param(:fg, :red), do: "31" - def color_param(:fg, :green), do: "32" - def color_param(:fg, :yellow), do: "33" - def color_param(:fg, :blue), do: "34" - def color_param(:fg, :magenta), do: "35" - def color_param(:fg, :cyan), do: "36" - def color_param(:fg, :white), do: "37" - - # Bright foreground colors - def color_param(:fg, :bright_black), do: "90" - def color_param(:fg, :bright_red), do: "91" - def color_param(:fg, :bright_green), do: "92" - def color_param(:fg, :bright_yellow), do: "93" - def color_param(:fg, :bright_blue), do: "94" - def color_param(:fg, :bright_magenta), do: "95" - def color_param(:fg, :bright_cyan), do: "96" - def color_param(:fg, :bright_white), do: "97" - - # Named background colors - def color_param(:bg, :black), do: "40" - def color_param(:bg, :red), do: "41" - def color_param(:bg, :green), do: "42" - def color_param(:bg, :yellow), do: "43" - def color_param(:bg, :blue), do: "44" - def color_param(:bg, :magenta), do: "45" - def color_param(:bg, :cyan), do: "46" - def color_param(:bg, :white), do: "47" - - # Bright background colors - def color_param(:bg, :bright_black), do: "100" - def color_param(:bg, :bright_red), do: "101" - def color_param(:bg, :bright_green), do: "102" - def color_param(:bg, :bright_yellow), do: "103" - def color_param(:bg, :bright_blue), do: "104" - def color_param(:bg, :bright_magenta), do: "105" - def color_param(:bg, :bright_cyan), do: "106" - def color_param(:bg, :bright_white), do: "107" - - # 256-color palette - def color_param(:fg, n) when is_integer(n) and n >= 0 and n <= 255, do: "38;5;#{n}" - def color_param(:bg, n) when is_integer(n) and n >= 0 and n <= 255, do: "48;5;#{n}" - - # True color RGB - def color_param(:fg, {r, g, b}) - when is_integer(r) and is_integer(g) and is_integer(b) do - "38;2;#{r};#{g};#{b}" - end - - def color_param(:bg, {r, g, b}) - when is_integer(r) and is_integer(g) and is_integer(b) do - "48;2;#{r};#{g};#{b}" - end - - # Nil/unknown colors - def color_param(_type, nil), do: nil - def color_param(_type, _unknown), do: nil - - @doc """ - Returns SGR parameter string for an attribute. - - Used when building combined sequences. - - ## Examples - - iex> SGR.attr_param(:bold) - "1" - - iex> SGR.attr_param(:underline) - "4" - """ - @spec attr_param(atom()) :: String.t() | nil - def attr_param(:bold), do: "1" - def attr_param(:dim), do: "2" - def attr_param(:italic), do: "3" - def attr_param(:underline), do: "4" - def attr_param(:blink), do: "5" - def attr_param(:reverse), do: "7" - def attr_param(:hidden), do: "8" - def attr_param(:strikethrough), do: "9" - def attr_param(_unknown), do: nil - - @doc """ - Returns SGR parameter string to turn off an attribute. - - Used when removing specific attributes without full reset. - - ## Examples - - iex> SGR.attr_off_param(:bold) - "22" - - iex> SGR.attr_off_param(:underline) - "24" - """ - @spec attr_off_param(atom()) :: String.t() | nil - def attr_off_param(:bold), do: "22" - def attr_off_param(:dim), do: "22" - def attr_off_param(:italic), do: "23" - def attr_off_param(:underline), do: "24" - def attr_off_param(:blink), do: "25" - def attr_off_param(:reverse), do: "27" - def attr_off_param(:hidden), do: "28" - def attr_off_param(:strikethrough), do: "29" - def attr_off_param(_unknown), do: nil - - @doc """ - Builds a combined SGR sequence from a list of parameters. - - ## Examples - - iex> SGR.build_sequence(["1", "31"]) - ["\\e[", ["1", ";", "31"], "m"] - - iex> SGR.build_sequence([]) - [] - """ - @spec build_sequence([String.t()]) :: iolist() - def build_sequence([]), do: [] - - def build_sequence(params) when is_list(params) do - filtered = Enum.reject(params, &is_nil/1) - - if filtered == [] do - [] - else - [@csi, Enum.intersperse(filtered, ";"), "m"] - end - end - - # =========================================================================== - # Sequence Mode - Returns complete escape sequences - # =========================================================================== - - @doc """ - Returns complete SGR escape sequence for a color. - - Used for direct terminal output. - - ## Examples - - iex> SGR.color_sequence(:fg, :red) |> IO.iodata_to_binary() - "\\e[31m" - - iex> SGR.color_sequence(:fg, :default) |> IO.iodata_to_binary() - "\\e[39m" - """ - @spec color_sequence(:fg | :bg, color :: term()) :: iolist() - def color_sequence(type, color) do - case color_param(type, color) do - nil -> [] - param -> [@csi, param, "m"] - end - end - - @doc """ - Returns complete SGR escape sequence for an attribute. - - Used for direct terminal output. - - ## Examples - - iex> SGR.attr_sequence(:bold) |> IO.iodata_to_binary() - "\\e[1m" - """ - @spec attr_sequence(atom()) :: iolist() - def attr_sequence(attr) do - case attr_param(attr) do - nil -> [] - param -> [@csi, param, "m"] - end - end - - @doc """ - Returns SGR reset sequence. - - Resets all attributes and colors to terminal defaults. - """ - @spec reset() :: iolist() - def reset, do: [@csi, "0m"] - - # =========================================================================== - # Utility Functions - # =========================================================================== - - @doc """ - Returns all supported named colors. - """ - @spec named_colors() :: [atom()] - def named_colors do - [ - :black, - :red, - :green, - :yellow, - :blue, - :magenta, - :cyan, - :white, - :bright_black, - :bright_red, - :bright_green, - :bright_yellow, - :bright_blue, - :bright_magenta, - :bright_cyan, - :bright_white - ] - end - - @doc """ - Returns all supported attributes. - """ - @spec supported_attrs() :: [atom()] - def supported_attrs do - [:bold, :dim, :italic, :underline, :blink, :reverse, :hidden, :strikethrough] - end - - @doc """ - Checks if a color value is valid. - """ - @spec valid_color?(term()) :: boolean() - def valid_color?(:default), do: true - - def valid_color?(color) - when color in [:black, :red, :green, :yellow, :blue, :magenta, :cyan, :white], do: true - - def valid_color?(color) - when color in [ - :bright_black, - :bright_red, - :bright_green, - :bright_yellow, - :bright_blue, - :bright_magenta, - :bright_cyan, - :bright_white - ], - do: true - - def valid_color?(n) when is_integer(n) and n >= 0 and n <= 255, do: true - - def valid_color?({r, g, b}) - when is_integer(r) and is_integer(g) and is_integer(b) and r >= 0 and r <= 255 and g >= 0 and - g <= 255 and b >= 0 and b <= 255, - do: true - - def valid_color?(_), do: false - - @doc """ - Checks if an attribute is valid. - """ - @spec valid_attr?(term()) :: boolean() - def valid_attr?(attr), do: attr in supported_attrs() -end diff --git a/lib/term_ui/shortcut.ex b/lib/term_ui/shortcut.ex deleted file mode 100644 index 738edfca..00000000 --- a/lib/term_ui/shortcut.ex +++ /dev/null @@ -1,341 +0,0 @@ -defmodule TermUI.Shortcut do - @moduledoc """ - Keyboard shortcut registry and matching. - - Provides a system for registering keyboard shortcuts with actions, - matching key events against registered shortcuts, and executing - the associated actions. - - ## Usage - - # Create a registry - {:ok, registry} = Shortcut.start_link() - - # Register shortcuts - Shortcut.register(registry, %Shortcut{ - key: :q, - modifiers: [:ctrl], - action: {:message, :root, :quit}, - scope: :global, - description: "Quit application" - }) - - # Match key event - case Shortcut.match(registry, key_event, context) do - {:ok, shortcut} -> Shortcut.execute(shortcut) - :no_match -> :ignore - end - """ - - use GenServer - - alias TermUI.Event - - # Dialyzer: Functions with unmatched return values in side-effect calls - @dialyzer {:nowarn_function, match: 3, handle_cast: 2, handle_call: 3, check_sequence_match: 2} - - @type scope :: :global | {:mode, atom()} | {:component, atom()} - - @type action :: - {:function, (-> any())} - | {:message, atom(), term()} - | {:command, term()} - - @type t :: %__MODULE__{ - key: atom() | String.t(), - modifiers: [atom()], - action: action(), - scope: scope(), - priority: integer(), - description: String.t() | nil, - sequence: [atom() | String.t()] | nil - } - - defstruct [ - :key, - :action, - :description, - :sequence, - modifiers: [], - scope: :global, - priority: 0 - ] - - # --- Public API --- - - @doc """ - Starts the shortcut registry. - """ - @spec start_link(keyword()) :: GenServer.on_start() - def start_link(opts \\ []) do - {name, opts} = Keyword.pop(opts, :name) - - if name do - GenServer.start_link(__MODULE__, opts, name: name) - else - GenServer.start_link(__MODULE__, opts) - end - end - - @doc """ - Registers a shortcut. - """ - @spec register(GenServer.server(), t()) :: :ok - def register(registry, %__MODULE__{} = shortcut) do - GenServer.call(registry, {:register, shortcut}) - end - - @doc """ - Unregisters a shortcut by key and modifiers. - """ - @spec unregister(GenServer.server(), atom() | String.t(), [atom()]) :: :ok - def unregister(registry, key, modifiers \\ []) do - GenServer.call(registry, {:unregister, key, modifiers}) - end - - @doc """ - Matches a key event against registered shortcuts. - - Returns `{:ok, shortcut}` if a match is found, or `:no_match`. - The context determines which scopes are active. - """ - @spec match(GenServer.server(), Event.Key.t(), map()) :: {:ok, t()} | :no_match - def match(registry, %Event.Key{} = event, context \\ %{}) do - GenServer.call(registry, {:match, event, context}) - end - - @doc """ - Executes a shortcut's action. - - Returns the result of the action execution. - """ - @spec execute(t()) :: term() - def execute(%__MODULE__{action: {:function, fun}}) when is_function(fun, 0) do - fun.() - end - - def execute(%__MODULE__{action: {:message, component_id, message}}) do - {:send_message, component_id, message} - end - - def execute(%__MODULE__{action: {:command, command}}) do - {:execute_command, command} - end - - @doc """ - Lists all registered shortcuts. - """ - @spec list(GenServer.server()) :: [t()] - def list(registry) do - GenServer.call(registry, :list) - end - - @doc """ - Lists shortcuts for a specific scope. - """ - @spec list_for_scope(GenServer.server(), scope()) :: [t()] - def list_for_scope(registry, scope) do - GenServer.call(registry, {:list_for_scope, scope}) - end - - @doc """ - Formats a shortcut for display. - - ## Examples - - iex> Shortcut.format(%Shortcut{key: :s, modifiers: [:ctrl]}) - "Ctrl+S" - - iex> Shortcut.format(%Shortcut{key: :q, modifiers: [:ctrl, :shift]}) - "Ctrl+Shift+Q" - """ - @spec format(t()) :: String.t() - def format(%__MODULE__{key: key, modifiers: modifiers}) do - parts = - modifiers - |> Enum.sort_by(&modifier_order/1) - |> Enum.map(&format_modifier/1) - - key_str = format_key(key) - Enum.join(parts ++ [key_str], "+") - end - - @doc """ - Clears the partial sequence state. - """ - @spec clear_sequence(GenServer.server()) :: :ok - def clear_sequence(registry) do - GenServer.cast(registry, :clear_sequence) - end - - # --- GenServer Callbacks --- - - @impl true - def init(_opts) do - state = %{ - shortcuts: [], - sequence_state: nil, - sequence_timer: nil - } - - {:ok, state} - end - - @impl true - def handle_call({:register, shortcut}, _from, state) do - shortcuts = [shortcut | state.shortcuts] - {:reply, :ok, %{state | shortcuts: shortcuts}} - end - - @impl true - def handle_call({:unregister, key, modifiers}, _from, state) do - modifiers = Enum.sort(modifiers) - - shortcuts = - Enum.reject(state.shortcuts, fn s -> - s.key == key and Enum.sort(s.modifiers) == modifiers - end) - - {:reply, :ok, %{state | shortcuts: shortcuts}} - end - - @impl true - def handle_call({:match, event, context}, _from, state) do - {result, state} = do_match(event, context, state) - {:reply, result, state} - end - - @impl true - def handle_call(:list, _from, state) do - {:reply, state.shortcuts, state} - end - - @impl true - def handle_call({:list_for_scope, scope}, _from, state) do - filtered = Enum.filter(state.shortcuts, fn s -> s.scope == scope end) - {:reply, filtered, state} - end - - @impl true - def handle_cast(:clear_sequence, state) do - if state.sequence_timer, do: Process.cancel_timer(state.sequence_timer) - {:noreply, %{state | sequence_state: nil, sequence_timer: nil}} - end - - @impl true - def handle_info(:sequence_timeout, state) do - {:noreply, %{state | sequence_state: nil, sequence_timer: nil}} - end - - # --- Private Functions --- - - defp do_match(event, context, state) do - # Check for sequence shortcuts first - {sequence_match, state} = check_sequence_match(event, state) - - case sequence_match do - {:ok, _} = result -> - {result, state} - - :no_match -> - # Check for regular shortcuts - result = check_regular_match(event, context, state.shortcuts) - {result, state} - end - end - - defp check_sequence_match(event, state) do - # Build current sequence - current_key = event.key - current_seq = (state.sequence_state || []) ++ [current_key] - - # Find sequence shortcuts that match or could match - sequence_shortcuts = - Enum.filter(state.shortcuts, fn s -> - s.sequence != nil and List.starts_with?(s.sequence, current_seq) - end) - - cond do - # Exact sequence match - Enum.any?(sequence_shortcuts, fn s -> s.sequence == current_seq end) -> - shortcut = Enum.find(sequence_shortcuts, fn s -> s.sequence == current_seq end) - - if state.sequence_timer, do: Process.cancel_timer(state.sequence_timer) - state = %{state | sequence_state: nil, sequence_timer: nil} - {{:ok, shortcut}, state} - - # Partial sequence match - wait for more keys - length(sequence_shortcuts) > 0 -> - if state.sequence_timer, do: Process.cancel_timer(state.sequence_timer) - timer = Process.send_after(self(), :sequence_timeout, 1000) - state = %{state | sequence_state: current_seq, sequence_timer: timer} - {:no_match, state} - - # No sequence match - true -> - if state.sequence_timer, do: Process.cancel_timer(state.sequence_timer) - state = %{state | sequence_state: nil, sequence_timer: nil} - {:no_match, state} - end - end - - defp check_regular_match(event, context, shortcuts) do - # Filter by key and modifiers - matching = - shortcuts - |> Enum.filter(fn s -> - s.sequence == nil and - matches_key?(s, event) and - matches_modifiers?(s, event) and - scope_active?(s.scope, context) - end) - |> Enum.sort_by(fn s -> -s.priority end) - - case matching do - [shortcut | _] -> {:ok, shortcut} - [] -> :no_match - end - end - - defp matches_key?(shortcut, event) do - shortcut.key == event.key or shortcut.key == :any - end - - defp matches_modifiers?(shortcut, event) do - required = MapSet.new(shortcut.modifiers) - actual = MapSet.new(event.modifiers) - MapSet.equal?(required, actual) - end - - defp scope_active?(:global, _context), do: true - - defp scope_active?({:mode, mode}, context) do - Map.get(context, :mode) == mode - end - - defp scope_active?({:component, component_id}, context) do - Map.get(context, :focused_component) == component_id - end - - defp modifier_order(:ctrl), do: 0 - defp modifier_order(:alt), do: 1 - defp modifier_order(:shift), do: 2 - defp modifier_order(:meta), do: 3 - defp modifier_order(_), do: 4 - - defp format_modifier(:ctrl), do: "Ctrl" - defp format_modifier(:alt), do: "Alt" - defp format_modifier(:shift), do: "Shift" - defp format_modifier(:meta), do: "Meta" - defp format_modifier(other), do: to_string(other) - - defp format_key(key) when is_atom(key) do - key - |> to_string() - |> String.upcase() - end - - defp format_key(key) when is_binary(key) do - String.upcase(key) - end -end diff --git a/lib/term_ui/spatial_index.ex b/lib/term_ui/spatial_index.ex deleted file mode 100644 index 7ed2543a..00000000 --- a/lib/term_ui/spatial_index.ex +++ /dev/null @@ -1,191 +0,0 @@ -defmodule TermUI.SpatialIndex do - @moduledoc """ - Spatial index for fast component lookup by screen position. - - The spatial index enables efficient routing of mouse events to - the correct component based on cursor coordinates. It maintains - a mapping of screen regions to component references. - - ## Usage - - # Register a component's bounds - SpatialIndex.update(:my_button, pid, %{x: 10, y: 5, width: 20, height: 3}) - - # Find component at position - {:ok, {:my_button, pid}} = SpatialIndex.find_at(15, 6) - - # Remove when unmounted - SpatialIndex.remove(:my_button) - - ## Z-Order - - When components overlap, the one with the highest z-index receives - mouse events. Default z-index is 0. Modals typically use higher values. - """ - - use GenServer - - @table_name :term_ui_spatial_index - - # Dialyzer: Functions with unmatched return values - @dialyzer {:nowarn_function, init: 1} - - # Client API - - @doc """ - Starts the spatial index. - """ - @spec start_link(keyword()) :: GenServer.on_start() - def start_link(opts \\ []) do - name = Keyword.get(opts, :name, __MODULE__) - GenServer.start_link(__MODULE__, opts, name: name) - end - - @doc """ - Updates a component's bounds in the index. - - ## Parameters - - - `id` - Component identifier - - `pid` - Component process - - `bounds` - Map with x, y, width, height - - `opts` - Options including `:z_index` - - ## Examples - - SpatialIndex.update(:button, pid, %{x: 0, y: 0, width: 10, height: 1}) - SpatialIndex.update(:modal, pid, bounds, z_index: 100) - """ - @spec update(term(), pid(), map(), keyword()) :: :ok - def update(id, pid, bounds, opts \\ []) do - z_index = Keyword.get(opts, :z_index, 0) - GenServer.call(__MODULE__, {:update, id, pid, bounds, z_index}) - end - - @doc """ - Removes a component from the index. - """ - @spec remove(term()) :: :ok - def remove(id) do - GenServer.call(__MODULE__, {:remove, id}) - end - - @doc """ - Finds the component at the given coordinates. - - Returns the topmost component (highest z-index) at the position. - - ## Returns - - - `{:ok, {id, pid}}` - Component found - - `{:error, :not_found}` - No component at position - """ - @spec find_at(integer(), integer()) :: {:ok, {term(), pid()}} | {:error, :not_found} - def find_at(x, y) do - # Direct ETS lookup for performance - results = - :ets.foldl( - fn {id, pid, bounds, z_index}, acc -> - if point_in_bounds?(x, y, bounds) do - [{id, pid, z_index} | acc] - else - acc - end - end, - [], - @table_name - ) - - case results do - [] -> - {:error, :not_found} - - matches -> - # Return highest z-index - {id, pid, _z} = - Enum.max_by(matches, fn {_id, _pid, z} -> z end) - - {:ok, {id, pid}} - end - end - - @doc """ - Finds all components at the given coordinates. - - Returns all overlapping components sorted by z-index (highest first). - """ - @spec find_all_at(integer(), integer()) :: [{term(), pid(), integer()}] - def find_all_at(x, y) do - :ets.foldl( - fn {id, pid, bounds, z_index}, acc -> - if point_in_bounds?(x, y, bounds) do - [{id, pid, z_index} | acc] - else - acc - end - end, - [], - @table_name - ) - |> Enum.sort_by(fn {_id, _pid, z} -> z end, :desc) - end - - @doc """ - Gets the bounds for a component. - """ - @spec get_bounds(term()) :: {:ok, map()} | {:error, :not_found} - def get_bounds(id) do - case :ets.lookup(@table_name, id) do - [{^id, _pid, bounds, _z}] -> {:ok, bounds} - [] -> {:error, :not_found} - end - end - - @doc """ - Clears all entries from the index. - """ - @spec clear() :: :ok - def clear do - GenServer.call(__MODULE__, :clear) - end - - @doc """ - Returns the number of indexed components. - """ - @spec count() :: non_neg_integer() - def count do - :ets.info(@table_name, :size) - end - - # Server Callbacks - - @impl true - def init(_opts) do - :ets.new(@table_name, [:set, :public, :named_table, read_concurrency: true]) - {:ok, %{}} - end - - @impl true - def handle_call({:update, id, pid, bounds, z_index}, _from, state) do - :ets.insert(@table_name, {id, pid, bounds, z_index}) - {:reply, :ok, state} - end - - @impl true - def handle_call({:remove, id}, _from, state) do - :ets.delete(@table_name, id) - {:reply, :ok, state} - end - - @impl true - def handle_call(:clear, _from, state) do - :ets.delete_all_objects(@table_name) - {:reply, :ok, state} - end - - # Private Functions - - defp point_in_bounds?(x, y, %{x: bx, y: by, width: w, height: h}) do - x >= bx and x < bx + w and y >= by and y < by + h - end -end diff --git a/lib/term_ui/stateful_component.ex b/lib/term_ui/stateful_component.ex deleted file mode 100644 index 7aa628c3..00000000 --- a/lib/term_ui/stateful_component.ex +++ /dev/null @@ -1,321 +0,0 @@ -defmodule TermUI.StatefulComponent do - @moduledoc """ - Behaviour for stateful, interactive components. - - StatefulComponent extends the base Component behaviour with state management - and event handling. Use this for components that need to maintain internal - state and respond to user input. - - ## Basic Usage - - defmodule MyApp.Counter do - use TermUI.StatefulComponent - - @impl true - def init(props) do - {:ok, %{count: props[:initial] || 0}} - end - - @impl true - def handle_event(%KeyEvent{key: :up}, state) do - {:ok, %{state | count: state.count + 1}} - end - - def handle_event(%KeyEvent{key: :down}, state) do - {:ok, %{state | count: state.count - 1}} - end - - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, _area) do - text("Count: \#{state.count}") - end - end - - ## Lifecycle - - 1. `init/1` - Initialize state from props - 2. `handle_event/2` - Process input events - 3. `render/2` - Render current state - - ## Commands - - Event handlers can return commands for side effects: - - def handle_event(%KeyEvent{key: :enter}, state) do - {:ok, state, [{:send, parent_pid, {:submitted, state.value}}]} - end - - ## Optional Callbacks - - - `terminate/2` - Cleanup when component stops - - `handle_info/2` - Handle non-event messages - - `handle_call/3` - Handle synchronous calls - """ - - alias TermUI.Component.RenderNode - - # Type definitions - - @typedoc "Component state - any term" - @type state :: term() - - @typedoc "Component props" - @type props :: map() - - @typedoc "Available rendering area" - @type rect :: %{x: integer(), y: integer(), width: integer(), height: integer()} - - @typedoc "Render tree output" - @type render_tree :: RenderNode.t() | [render_tree()] | String.t() - - @typedoc "Event types from user input" - @type event :: term() - - @typedoc "Commands for side effects" - @type command :: - {:send, pid(), term()} - | {:timer, non_neg_integer(), term()} - | {:focus, term()} - | term() - - @typedoc "Event handler return value" - @type event_result :: - {:ok, state()} - | {:ok, state(), [command()]} - | {:stop, reason :: term(), state()} - - # Required callbacks - - @doc """ - Initializes component state from props. - - Called once when the component starts. Returns initial state. - - ## Parameters - - - `props` - Initial properties passed to the component - - ## Returns - - - `{:ok, state}` - Initial state - - `{:ok, state, commands}` - Initial state with startup commands - - `{:stop, reason}` - Fail to initialize - - ## Examples - - @impl true - def init(props) do - {:ok, %{ - text: props[:text] || "", - cursor: 0 - }} - end - """ - @callback init(props()) :: {:ok, state()} | {:ok, state(), [command()]} | {:stop, term()} - - @doc """ - Handles input events and updates state. - - Called when the component receives a keyboard, mouse, or focus event. - Returns updated state and optional commands. - - ## Parameters - - - `event` - The input event (KeyEvent, MouseEvent, FocusEvent) - - `state` - Current component state - - ## Returns - - - `{:ok, new_state}` - Updated state - - `{:ok, new_state, commands}` - Updated state with commands - - `{:stop, reason, state}` - Stop the component - - ## Examples - - @impl true - def handle_event(%KeyEvent{key: :enter}, state) do - {:ok, state, [{:send, state.parent, {:submit, state.value}}]} - end - - def handle_event(%KeyEvent{char: char}, state) when char != nil do - {:ok, %{state | text: state.text <> char}} - end - - def handle_event(_event, state) do - {:ok, state} - end - """ - @callback handle_event(event(), state()) :: event_result() - - @doc """ - Renders the component's current state. - - Called after state changes to produce the visual output. - Unlike stateless components, receives state instead of props. - - ## Parameters - - - `state` - Current component state - - `area` - Available rendering area - - ## Returns - - A render tree (RenderNode, list, or string). - - ## Examples - - @impl true - def render(state, _area) do - text(state.text) - end - """ - @callback render(state(), rect()) :: render_tree() - - # Optional callbacks - - @doc """ - Called when the component is mounted to the active tree. - - Mount is the appropriate place for setup requiring the component - to be "live": registering event handlers, starting timers, fetching data. - - ## Parameters - - - `state` - Current component state after init - - ## Returns - - - `{:ok, new_state}` - Mount successful - - `{:ok, new_state, commands}` - Mount with commands - - `{:stop, reason}` - Mount failed - """ - @callback mount(state()) :: {:ok, state()} | {:ok, state(), [command()]} | {:stop, term()} - - @doc """ - Called when the component's props change. - - The parent passes new props, triggering this callback. - Update may modify state based on new props. - - ## Parameters - - - `new_props` - The new props from parent - - `state` - Current component state - - ## Returns - - - `{:ok, new_state}` - Update successful - - `{:ok, new_state, commands}` - Update with commands - """ - @callback update(new_props :: props(), state()) :: - {:ok, state()} | {:ok, state(), [command()]} - - @doc """ - Called when the component is unmounted from the tree. - - This is the appropriate place for cleanup: canceling timers, - closing files, unregistering handlers. - - ## Parameters - - - `state` - Current component state - """ - @callback unmount(state()) :: :ok - - @doc """ - Handles component termination. - - Called when the component is stopping. Use for cleanup. - - ## Parameters - - - `reason` - Why the component is stopping - - `state` - Final component state - """ - @callback terminate(reason :: term(), state()) :: term() - - @doc """ - Handles non-event messages. - - Called for messages that aren't input events, like timer callbacks - or messages from other processes. - - ## Parameters - - - `message` - The received message - - `state` - Current component state - - ## Returns - - Same as `handle_event/2`. - """ - @callback handle_info(message :: term(), state()) :: event_result() - - @doc """ - Handles synchronous calls. - - For request-response patterns where the caller needs a reply. - - ## Parameters - - - `request` - The request term - - `from` - Caller identifier for reply - - `state` - Current component state - - ## Returns - - - `{:reply, response, new_state}` - Reply and update state - - `{:reply, response, new_state, commands}` - Reply with commands - - `{:noreply, new_state}` - Don't reply yet - """ - @callback handle_call(request :: term(), from :: term(), state()) :: - {:reply, term(), state()} - | {:reply, term(), state(), [command()]} - | {:noreply, state()} - | {:noreply, state(), [command()]} - - @optional_callbacks mount: 1, - update: 2, - unmount: 1, - terminate: 2, - handle_info: 2, - handle_call: 3 - - @doc false - defmacro __using__(_opts) do - quote do - @behaviour TermUI.StatefulComponent - - alias TermUI.Component.RenderNode - alias TermUI.Renderer.Style - - import TermUI.Component.Helpers - - # Default implementations for optional callbacks - - @doc false - def mount(state), do: {:ok, state} - - @doc false - def update(_new_props, state), do: {:ok, state} - - @doc false - def unmount(_state), do: :ok - - @doc false - def terminate(_reason, _state), do: :ok - - @doc false - def handle_info(_message, state), do: {:ok, state} - - @doc false - def handle_call(_request, _from, state), do: {:reply, :ok, state} - - defoverridable mount: 1, update: 2, unmount: 1, terminate: 2, handle_info: 2, handle_call: 3 - end - end -end diff --git a/lib/term_ui/style.ex b/lib/term_ui/style.ex index 57e02e87..b128dee8 100644 --- a/lib/term_ui/style.ex +++ b/lib/term_ui/style.ex @@ -75,7 +75,20 @@ defmodule TermUI.Style do attrs: MapSet.t(attr()) } - defstruct fg: nil, bg: nil, attrs: MapSet.new() + @valid_attributes [:bold, :dim, :italic, :underline, :blink, :reverse, :hidden, :strikethrough] + + @schema Zoi.struct(__MODULE__, %{ + fg: Zoi.any() |> Zoi.default(nil), + bg: Zoi.any() |> Zoi.default(nil), + attrs: Zoi.map_set(Zoi.enum(@valid_attributes)) |> Zoi.default(MapSet.new()) + }) + + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @doc "Returns the Zoi schema for terminal styles." + @spec schema() :: Zoi.schema() + def schema, do: @schema # Named color mappings for conversion @named_colors [ @@ -128,6 +141,10 @@ defmodule TermUI.Style do %__MODULE__{} end + @doc "Creates a style from options." + @spec new(keyword() | map()) :: t() + def new(opts), do: from(opts) + @doc """ Creates a style from a keyword list or map. @@ -207,6 +224,12 @@ defmodule TermUI.Style do @spec strikethrough(t()) :: t() def strikethrough(style), do: add_attr(style, :strikethrough) + @doc "Adds one text attribute." + @spec add_attr(t(), attr()) :: t() + def add_attr(style, attr) when attr in @valid_attributes do + %{style | attrs: MapSet.put(style.attrs, attr)} + end + @doc """ Removes an attribute from the style. """ @@ -231,6 +254,28 @@ defmodule TermUI.Style do MapSet.member?(style.attrs, attr) end + @doc "Returns true when two styles have the same visible values." + @spec equal?(t(), t()) :: boolean() + def equal?(%__MODULE__{} = left, %__MODULE__{} = right) do + left.fg == right.fg and left.bg == right.bg and MapSet.equal?(left.attrs, right.attrs) + end + + @doc "Returns true when a style has no color or attribute." + @spec empty?(t()) :: boolean() + def empty?(%__MODULE__{} = style) do + is_nil(style.fg) and is_nil(style.bg) and MapSet.size(style.attrs) == 0 + end + + @doc "Creates a cell with this style." + @spec to_cell(t(), String.t()) :: TermUI.Cell.t() + def to_cell(%__MODULE__{} = style, grapheme) do + TermUI.Cell.new(grapheme, + fg: normalize_cell_color(style.fg), + bg: normalize_cell_color(style.bg), + attrs: MapSet.to_list(style.attrs) + ) + end + # Public API - Merging and Inheritance @doc """ @@ -406,9 +451,10 @@ defmodule TermUI.Style do # Private helpers - defp add_attr(style, attr) do - %{style | attrs: MapSet.put(style.attrs, attr)} - end + defp normalize_cell_color(nil), do: :default + defp normalize_cell_color({:rgb, red, green, blue}), do: {red, green, blue} + defp normalize_cell_color({:indexed, index}), do: index + defp normalize_cell_color(color), do: color defp color_cube_index(value) do # Map 0-255 to 0-5 diff --git a/lib/term_ui/term_utils.ex b/lib/term_ui/term_utils.ex index 2a6acfb0..a85d19e6 100644 --- a/lib/term_ui/term_utils.ex +++ b/lib/term_ui/term_utils.ex @@ -263,7 +263,9 @@ defmodule TermUI.TermUtils do message = String.downcase(output) String.contains?(message, "inappropriate ioctl for device") or - String.contains?(message, "not a tty") + String.contains?(message, "not a tty") or + String.contains?(message, "not a terminal") or + String.contains?(message, "isn't a terminal") end # Separate function to have access to timeout variable in catch block diff --git a/lib/term_ui/terminal.ex b/lib/term_ui/terminal.ex deleted file mode 100644 index cabf446d..00000000 --- a/lib/term_ui/terminal.ex +++ /dev/null @@ -1,677 +0,0 @@ -defmodule TermUI.Terminal do - @moduledoc """ - Main terminal management GenServer for TermUI. - - Provides raw mode activation, alternate screen buffer management, - terminal restoration, and size detection using OTP 28's native - raw mode support. - """ - - use GenServer - require Logger - - alias TermUI.ANSI - alias TermUI.Terminal.SizeDetector - alias TermUI.Terminal.State - alias TermUI.TerminalOutput - alias TermUI.TermUtils - - # Dialyzer: unmatched_return, pattern_match_cov, guard_fail warnings - @dialyzer {:nowarn_function, - init: 1, - handle_call: 3, - handle_cast: 2, - handle_info: 2, - terminate: 2, - do_restore: 1, - io_has_terminal?: 0, - check_tty: 0, - apply_stty_raw_settings: 0, - terminal?: 0, - do_enable_raw_mode: 0} - - @ets_table :term_ui_terminal_state - - # Comprehensive mouse disable - disables ALL mouse modes defensively - # This is kept as a constant for performance in cleanup paths - @all_mouse_off "\e[?1006l\e[?1003l\e[?1002l\e[?1000l" - - # Client API - - @doc """ - Starts the Terminal GenServer. - """ - @spec start_link(keyword()) :: GenServer.on_start() - def start_link(opts \\ []) do - GenServer.start_link(__MODULE__, opts, name: __MODULE__) - end - - @doc """ - Returns a child specification for starting the terminal in a supervisor. - """ - @spec child_spec(keyword()) :: Supervisor.child_spec() - def child_spec(opts \\ []) do - %{ - id: __MODULE__, - start: {__MODULE__, :start_link, [opts]}, - restart: :permanent, - shutdown: 5000, - type: :worker - } - end - - @doc """ - Enables raw mode on the terminal. - - Calls OTP 28's `shell.start_interactive({:noshell, :raw})` and configures - the terminal for TUI operation. - - Returns `{:ok, state}` on success or `{:error, reason}` on failure. - """ - @spec enable_raw_mode() :: {:ok, State.t()} | {:error, term()} - def enable_raw_mode do - GenServer.call(__MODULE__, :enable_raw_mode) - end - - @doc """ - Disables raw mode and restores original terminal settings. - - Returns `:ok` on success or `{:error, reason}` on failure. - """ - @spec disable_raw_mode() :: :ok | {:error, term()} - def disable_raw_mode do - GenServer.call(__MODULE__, :disable_raw_mode) - end - - @doc """ - Enters the alternate screen buffer. - - The alternate screen preserves the user's shell history while the TUI runs. - """ - @spec enter_alternate_screen() :: :ok | {:error, term()} - def enter_alternate_screen do - GenServer.call(__MODULE__, :enter_alternate_screen) - end - - @doc """ - Leaves the alternate screen buffer and restores the original screen. - """ - @spec leave_alternate_screen() :: :ok | {:error, term()} - def leave_alternate_screen do - GenServer.call(__MODULE__, :leave_alternate_screen) - end - - @doc """ - Hides the cursor. - """ - @spec hide_cursor() :: :ok - def hide_cursor do - GenServer.call(__MODULE__, :hide_cursor) - end - - @doc """ - Shows the cursor. - """ - @spec show_cursor() :: :ok - def show_cursor do - GenServer.call(__MODULE__, :show_cursor) - end - - @doc """ - Gets the current terminal size. - - Returns `{:ok, {rows, cols}}` or `{:error, reason}`. - """ - @spec get_terminal_size() :: {:ok, {pos_integer(), pos_integer()}} | {:error, term()} - def get_terminal_size do - GenServer.call(__MODULE__, :get_terminal_size) - end - - @doc """ - Registers a process to receive terminal resize notifications. - - The registered process will receive `{:terminal_resize, {rows, cols}}` messages. - """ - @spec register_resize_callback(pid()) :: :ok - def register_resize_callback(pid \\ self()) do - GenServer.call(__MODULE__, {:register_resize_callback, pid}) - end - - @doc """ - Unregisters a process from resize notifications. - """ - @spec unregister_resize_callback(pid()) :: :ok - def unregister_resize_callback(pid \\ self()) do - GenServer.call(__MODULE__, {:unregister_resize_callback, pid}) - end - - @doc """ - Performs complete terminal restoration. - - This restores all terminal modifications in the correct sequence: - 1. Show cursor - 2. Leave alternate screen - 3. Disable raw mode - 4. Restore original settings - """ - @spec restore() :: :ok | {:error, term()} - def restore do - GenServer.call(__MODULE__, :restore) - end - - @doc """ - Checks if the terminal is currently in raw mode. - """ - @spec raw_mode?() :: boolean() - def raw_mode? do - GenServer.call(__MODULE__, :raw_mode?) - end - - @doc """ - Gets the current terminal state. - """ - @spec get_state() :: State.t() - def get_state do - GenServer.call(__MODULE__, :get_state) - end - - @doc """ - Enables mouse tracking with the specified mode. - - ## Modes - - - `:click` - Report button press and release only - - `:drag` - Also report mouse motion while button is pressed - - `:all` - Report all mouse motion (generates many events) - - Also enables SGR extended mode for accurate coordinates. - """ - @spec enable_mouse_tracking(:click | :drag | :all) :: :ok - def enable_mouse_tracking(mode \\ :click) when mode in [:click, :drag, :all] do - GenServer.call(__MODULE__, {:enable_mouse_tracking, mode}) - end - - @doc """ - Disables mouse tracking. - """ - @spec disable_mouse_tracking() :: :ok - def disable_mouse_tracking do - GenServer.call(__MODULE__, :disable_mouse_tracking) - end - - # Server callbacks - - @impl true - def init(_opts) do - Process.flag(:trap_exit, true) - check_previous_crash() - create_ets_table() - - # Check if raw mode is already active (e.g., activated by Backend.Selector) - # We detect this by checking if :shell.start_interactive returns {:error, :already_started} - raw_mode_active = - try do - case :shell.start_interactive({:noshell, :raw}) do - :ok -> - # Raw mode was just activated, disable it and re-enable via Terminal API - # to ensure consistent state management - do_disable_raw_mode(nil) - :ets.insert(@ets_table, {:raw_mode_active, false}) - false - - {:error, :already_started} -> - # Shell is already in raw mode (activated by Selector or externally) - # Mark as active in our state - :ets.insert(@ets_table, {:raw_mode_active, true}) - true - - {:error, _reason} -> - false - end - rescue - _ -> false - end - - state = - if raw_mode_active do - # Raw mode is already active, but we don't have the original settings - # This is OK - we'll use default restoration on shutdown - %{State.new() | raw_mode_active: true} - else - State.new() - end - - {:ok, state} - end - - @impl true - def handle_call(:enable_raw_mode, _from, state) do - if state.raw_mode_active do - {:reply, {:ok, state}, state} - else - case do_enable_raw_mode() do - {:ok, original_settings} -> - :ets.insert(@ets_table, {:raw_mode_active, true}) - - new_state = %{state | raw_mode_active: true, original_settings: original_settings} - {:reply, {:ok, new_state}, new_state} - - {:error, _reason} = error -> - {:reply, error, state} - end - end - end - - @impl true - def handle_call(:disable_raw_mode, _from, state) do - if state.raw_mode_active do - do_disable_raw_mode(state.original_settings) - :ets.insert(@ets_table, {:raw_mode_active, false}) - new_state = %{state | raw_mode_active: false, original_settings: nil} - {:reply, :ok, new_state} - else - {:reply, :ok, state} - end - end - - @impl true - def handle_call(:enter_alternate_screen, _from, state) do - if state.alternate_screen_active do - {:reply, :ok, state} - else - write_to_terminal(ANSI.enter_alternate_screen()) - new_state = %{state | alternate_screen_active: true} - {:reply, :ok, new_state} - end - end - - @impl true - def handle_call(:leave_alternate_screen, _from, state) do - if state.alternate_screen_active do - write_to_terminal(ANSI.leave_alternate_screen()) - new_state = %{state | alternate_screen_active: false} - {:reply, :ok, new_state} - else - {:reply, :ok, state} - end - end - - @impl true - def handle_call(:hide_cursor, _from, state) do - write_to_terminal(ANSI.cursor_hide()) - new_state = %{state | cursor_visible: false} - {:reply, :ok, new_state} - end - - @impl true - def handle_call(:show_cursor, _from, state) do - write_to_terminal(ANSI.cursor_show()) - new_state = %{state | cursor_visible: true} - {:reply, :ok, new_state} - end - - @impl true - def handle_call(:get_terminal_size, _from, state) do - case do_get_terminal_size() do - {:ok, {rows, cols}} = result -> - new_state = %{state | size: {rows, cols}} - {:reply, result, new_state} - - error -> - {:reply, error, state} - end - end - - @impl true - def handle_call({:register_resize_callback, pid}, _from, state) do - callbacks = [pid | state.resize_callbacks] |> Enum.uniq() - new_state = %{state | resize_callbacks: callbacks} - {:reply, :ok, new_state} - end - - @impl true - def handle_call({:unregister_resize_callback, pid}, _from, state) do - callbacks = Enum.reject(state.resize_callbacks, &(&1 == pid)) - new_state = %{state | resize_callbacks: callbacks} - {:reply, :ok, new_state} - end - - @impl true - def handle_call(:restore, _from, state) do - new_state = do_restore(state) - {:reply, :ok, new_state} - end - - @impl true - def handle_call(:raw_mode?, _from, state) do - {:reply, state.raw_mode_active, state} - end - - @impl true - def handle_call(:get_state, _from, state) do - {:reply, state, state} - end - - @impl true - def handle_call({:enable_mouse_tracking, mode}, _from, state) do - # Skip mouse tracking on WSL/ConPTY -- mouse-off sequences are silently - # ignored by ConPTY, so enabling mouse tracking leads to escape code leaks - if TerminalOutput.needs_hard_reset?() do - {:reply, :ok, state} - else - # First disable any existing tracking - if state.mouse_tracking != :off do - disable_current_mouse_mode(state.mouse_tracking) - end - - # Enable new tracking mode with SGR - # Map user-friendly mode names to ANSI protocol modes: - # :click -> :normal (1000), :drag -> :button (1002), :all -> :all (1003) - ansi_mode = - case mode do - :click -> :normal - :drag -> :button - :all -> :all - end - - write_to_terminal(ANSI.enable_mouse_tracking(ansi_mode)) - write_to_terminal(ANSI.enable_sgr_mouse()) - - new_state = %{state | mouse_tracking: mode} - {:reply, :ok, new_state} - end - end - - @impl true - def handle_call(:disable_mouse_tracking, _from, state) do - if state.mouse_tracking != :off do - disable_current_mouse_mode(state.mouse_tracking) - write_to_terminal(ANSI.disable_sgr_mouse()) - end - - new_state = %{state | mouse_tracking: :off} - {:reply, :ok, new_state} - end - - @impl true - def handle_info({:EXIT, _pid, :normal}, state) do - {:noreply, state} - end - - @impl true - def handle_info({:EXIT, _pid, :shutdown}, state) do - Logger.debug("Terminal GenServer received shutdown, performing cleanup") - do_restore(state) - {:stop, :shutdown, state} - end - - @impl true - def handle_info({:EXIT, _pid, reason}, state) do - Logger.warning("Terminal GenServer received EXIT: #{inspect(reason)}, performing cleanup") - do_restore(state) - {:stop, reason, state} - end - - @impl true - def handle_info(:sigwinch, state) do - case do_get_terminal_size() do - {:ok, {rows, cols}} -> - new_state = %{state | size: {rows, cols}} - - for pid <- new_state.resize_callbacks do - send(pid, {:terminal_resize, {rows, cols}}) - end - - {:noreply, new_state} - - {:error, _reason} -> - {:noreply, state} - end - end - - @impl true - def handle_info(_msg, state) do - {:noreply, state} - end - - @impl true - def terminate(reason, state) do - Logger.debug("Terminal GenServer terminating: #{inspect(reason)}") - do_restore(state) - :ok - end - - # Private functions - - defp do_enable_raw_mode do - if terminal?() do - # Save original terminal settings first - original_settings = save_terminal_settings() - - try do - # OTP 28 raw mode activation - # This sets character-at-a-time mode with no echo - case :shell.start_interactive({:noshell, :raw}) do - :ok -> - # Apply additional stty settings to ensure full raw mode - # This guarantees echo is disabled and input is unbuffered - apply_stty_raw_settings() - {:ok, original_settings} - - {:error, reason} -> - # Try stty fallback - case apply_stty_raw_settings() do - :ok -> - {:ok, original_settings} - - {:error, _stty_reason} -> - {:error, reason} - end - end - rescue - _e in UndefinedFunctionError -> - # Not OTP 28+, use stty fallback - case apply_stty_raw_settings() do - :ok -> - {:ok, original_settings} - - {:error, reason} -> - {:error, - {:otp_version, "OTP 28+ required and stty fallback failed: #{inspect(reason)}"}} - end - - e -> - {:error, {:raw_mode_failed, Exception.message(e)}} - catch - kind, reason -> - {:error, {kind, reason}} - end - else - {:error, :not_a_terminal} - end - end - - defp save_terminal_settings do - case TermUtils.safe_stty(["-g"]) do - {:ok, output} -> - # Validate output format before storing - case TermUtils.validate_stty_settings(output) do - :ok -> output - {:error, _} -> nil - end - - _ -> - nil - end - rescue - _ -> nil - end - - defp apply_stty_raw_settings do - # Apply comprehensive raw mode settings: - # -echo: disable echoing of input characters - # -icanon: disable canonical mode (line-at-a-time) - # min 1: minimum number of characters for read - # time 0: timeout in tenths of a second (0 = no timeout) - # -isig: disable signal generation (Ctrl+C etc handled by app) - # -ixon: disable XON/XOFF flow control - case TermUtils.safe_stty(["raw", "-echo", "-isig", "-ixon", "min", "1", "time", "0"]) do - {:ok, _output} -> - :ok - - {:error, reason} -> - {:error, {:stty_failed, reason}} - end - end - - defp do_disable_raw_mode(original_settings) do - # First try to restore original settings if we have them - if is_binary(original_settings) and original_settings != "" do - restore_terminal_settings(original_settings) - else - # Fallback: use stty sane to restore reasonable defaults - restore_stty_sane() - end - - :ok - rescue - _ -> :ok - catch - _, _ -> :ok - end - - defp restore_terminal_settings(settings) do - # Restore original settings - settings was validated when captured - # We pass it as a single argument which stty accepts for restoration - case TermUtils.safe_stty([settings]) do - {:ok, _} -> :ok - {:error, _} -> :ok - end - end - - defp restore_stty_sane do - # Restore terminal to reasonable defaults - case TermUtils.safe_stty(["sane"]) do - {:ok, _} -> :ok - {:error, _} -> :ok - end - end - - # Delegates to SizeDetector for consistent size detection across modules. - defp do_get_terminal_size do - SizeDetector.auto_detect() - end - - defp do_restore(state) do - # Phase 1: Direct-to-TTY write (most reliable, bypasses Erlang IO) - TerminalOutput.write_to_tty(TerminalOutput.cleanup_sequence()) - - # Phase 2: Erlang IO backup (in case /dev/tty write failed) - write_to_terminal(@all_mouse_off) - - if not state.cursor_visible do - write_to_terminal(ANSI.cursor_show()) - end - - # Reset terminal attributes before leaving alt screen - write_to_terminal(ANSI.reset()) - - if state.alternate_screen_active do - write_to_terminal(ANSI.leave_alternate_screen()) - end - - # Phase 3: Cooked mode (before stty so stty gets final say) - ensure_cooked_mode() - - # Phase 4: Restore original stty settings - if state.raw_mode_active do - do_disable_raw_mode(state.original_settings) - end - - # Phase 5: Cleanup ONLCR persistent_term - TerminalOutput.disable_onlcr() - - if :ets.whereis(@ets_table) != :undefined do - :ets.insert(@ets_table, {:raw_mode_active, false}) - end - - State.new() - end - - defp disable_current_mouse_mode(mode) do - # Map user-friendly mode names to ANSI protocol modes - ansi_mode = - case mode do - :click -> :normal - :drag -> :button - :all -> :all - _ -> nil - end - - if ansi_mode do - write_to_terminal(ANSI.disable_mouse_tracking(ansi_mode)) - end - end - - defp write_to_terminal(data) do - TerminalOutput.write(data) - rescue - _ -> :ok - end - - defp ensure_cooked_mode do - :shell.start_interactive({:noshell, :cooked}) - rescue - _ -> :ok - catch - _, _ -> :ok - end - - defp terminal? do - # Try multiple methods to detect if we have a terminal - # This is important for SSH sessions where standard_io may not report terminal correctly - cond do - # Method 1: Check :io.getopts for terminal key - io_has_terminal?() -> true - # Method 2: Check if /dev/tty exists and is accessible (Unix/Linux/macOS) - File.exists?("/dev/tty") -> true - # Method 3: Check if stdout is a tty using test command - check_tty() -> true - # No terminal detected - true -> false - end - end - - defp io_has_terminal? do - case :io.getopts(:standard_io) do - {:ok, opts} -> Keyword.get(opts, :terminal, false) == true - _ -> false - end - end - - defp check_tty do - case TermUtils.safe_test(["-t", "0"]) do - {:ok, _} -> true - _ -> false - end - end - - defp create_ets_table do - if :ets.whereis(@ets_table) == :undefined do - :ets.new(@ets_table, [:named_table, :public, :set]) - end - end - - defp check_previous_crash do - if :ets.whereis(@ets_table) != :undefined do - case :ets.lookup(@ets_table, :raw_mode_active) do - [{:raw_mode_active, true}] -> - Logger.warning("Detected unclean termination from previous run, resetting terminal") - TerminalOutput.write_to_tty(TerminalOutput.cleanup_sequence()) - - _ -> - :ok - end - end - end -end diff --git a/lib/term_ui/terminal/escape_parser.ex b/lib/term_ui/terminal/escape_parser.ex index ad9840c9..7854523f 100644 --- a/lib/term_ui/terminal/escape_parser.ex +++ b/lib/term_ui/terminal/escape_parser.ex @@ -1,19 +1,5 @@ defmodule TermUI.Terminal.EscapeParser do - @moduledoc """ - Parses terminal escape sequences into Event structs. - - Handles CSI sequences (ESC[...), SS3 sequences (ESCO...), and control - characters. Returns parsed events and any remaining unparsed bytes. - - ## Supported Sequences - - - Arrow keys: ESC[A/B/C/D - - Function keys: F1-F12 (both SS3 and CSI variants) - - Home/End/Insert/Delete/PageUp/PageDown - - Ctrl+key: 0x01-0x1A - - Alt+key: ESC followed by key - - Regular printable characters - """ + @moduledoc false import Bitwise @@ -41,7 +27,7 @@ defmodule TermUI.Terminal.EscapeParser do Returns `{events, remaining}` where events is a list of Event.Key structs and remaining is bytes that couldn't be parsed yet (partial sequences). """ - @spec parse(binary()) :: {[Event.Key.t()], binary()} + @spec parse(binary()) :: {[Event.t()], binary()} def parse(<<>>), do: {[], <<>>} def parse(input) when is_binary(input) do @@ -97,7 +83,7 @@ defmodule TermUI.Terminal.EscapeParser do # Regular printable ASCII defp parse_bytes(<>, events) when char in 32..126 do char_str = <> - event = Event.key(char_str, char: char_str) + event = Event.text(char_str) parse_bytes(rest, [event | events]) end @@ -106,7 +92,7 @@ defmodule TermUI.Terminal.EscapeParser do case input do <> -> char_str = <> - event = Event.key(char_str, char: char_str) + event = Event.text(char_str) parse_bytes(rest, [event | events]) _ -> @@ -120,7 +106,7 @@ defmodule TermUI.Terminal.EscapeParser do case input do <> -> char_str = <> - event = Event.key(char_str, char: char_str) + event = Event.text(char_str) parse_bytes(rest, [event | events]) _ -> @@ -133,7 +119,7 @@ defmodule TermUI.Terminal.EscapeParser do case input do <> -> char_str = <> - event = Event.key(char_str, char: char_str) + event = Event.text(char_str) parse_bytes(rest, [event | events]) _ -> @@ -164,7 +150,7 @@ defmodule TermUI.Terminal.EscapeParser do # Alt+key (ESC followed by printable character) defp parse_escape_sequence(<>) when char in 32..126 do char_str = <> - event = Event.key(char_str, char: char_str, modifiers: [:alt]) + event = Event.key(char_str, modifiers: [:alt]) {:ok, event, rest} end @@ -325,7 +311,7 @@ defmodule TermUI.Terminal.EscapeParser do {:ok, event, rest} :error -> - {:ok, Event.key(:unknown), input} + {:ok, Event.key(:unknown), rest} end :incomplete -> @@ -368,8 +354,8 @@ defmodule TermUI.Terminal.EscapeParser do {cx, ""} <- Integer.parse(cx_str), {cy, ""} <- Integer.parse(cy_str), true <- cb >= 0 and cb <= 255, - true <- cx >= 0 and cx <= @max_mouse_coordinate, - true <- cy >= 0 and cy <= @max_mouse_coordinate do + true <- cx >= 1 and cx <= @max_mouse_coordinate, + true <- cy >= 1 and cy <= @max_mouse_coordinate do {:ok, cb, cx, cy} else _ -> :error diff --git a/lib/term_ui/terminal/input_reader.ex b/lib/term_ui/terminal/input_reader.ex deleted file mode 100644 index 45e664f7..00000000 --- a/lib/term_ui/terminal/input_reader.ex +++ /dev/null @@ -1,206 +0,0 @@ -defmodule TermUI.Terminal.InputReader do - @moduledoc """ - GenServer that reads keyboard input from stdin and sends events to a target process. - - Uses a port to read from stdin in a non-blocking way. Parses escape sequences - and emits Event.Key structs to the configured target process. - - ## Usage - - {:ok, reader} = InputReader.start_link(target: self()) - # Events will be sent as {:input, %Event.Key{}} - - ## Escape Sequence Handling - - Some sequences are ambiguous (ESC alone vs ESC followed by another key). - The reader uses a timeout (default 50ms) to disambiguate - if no more bytes - arrive within the timeout, a lone ESC is emitted. - """ - - use GenServer - - alias TermUI.Event - alias TermUI.Terminal.EscapeParser - - # Dialyzer: handle_info(:escape_timeout, ...) calls Event.key with string args - # Key.new/2 spec says atom() but the function works with strings too - @dialyzer {:nowarn_function, handle_info: 2, cancel_timer: 1} - - @escape_timeout 50 - - defstruct [:target, :port, :buffer, :timer_ref] - - @type t :: %__MODULE__{ - target: pid(), - port: port() | nil, - buffer: binary(), - timer_ref: reference() | nil - } - - # Client API - - @doc """ - Starts the InputReader. - - ## Options - - - `:target` - PID to receive events (required) - - `:name` - GenServer name (optional) - """ - @spec start_link(keyword()) :: GenServer.on_start() - def start_link(opts) do - target = Keyword.fetch!(opts, :target) - name = Keyword.get(opts, :name) - - gen_opts = if name, do: [name: name], else: [] - GenServer.start_link(__MODULE__, target, gen_opts) - end - - @doc """ - Stops the InputReader. - """ - @spec stop(GenServer.server()) :: :ok - def stop(server) do - GenServer.stop(server) - end - - # Server Callbacks - - @impl true - def init(target) do - state = %__MODULE__{ - target: target, - port: nil, - buffer: <<>>, - timer_ref: nil - } - - # Spawn a process that reads from standard_io using Erlang's IO system - # This integrates with OTP's terminal handling and works cross-platform - parent = self() - reader_pid = spawn_link(fn -> io_reader_loop(parent) end) - - # Store reader_pid in port field (repurposing the field) - {:ok, %{state | port: reader_pid}} - end - - # Reader loop that uses Erlang's IO system - # This runs in a separate process because IO.getn blocks - defp io_reader_loop(parent) do - # Read one character at a time from standard_io - # In raw mode, this returns immediately without waiting for Enter - case IO.getn("", 1) do - :eof -> - send(parent, {:io_data, :eof}) - - {:error, reason} -> - send(parent, {:io_data, {:error, reason}}) - - data when is_binary(data) -> - send(parent, {:io_data, data}) - io_reader_loop(parent) - end - end - - @impl true - def handle_info({:io_data, :eof}, state) do - {:stop, :normal, state} - end - - @impl true - def handle_info({:io_data, {:error, _reason}}, state) do - {:stop, :normal, state} - end - - @impl true - def handle_info({:io_data, data}, state) when is_binary(data) do - # Cancel any pending escape timeout - state = cancel_timer(state) - - # Add new data to buffer - buffer = state.buffer <> data - - # Parse what we can - {events, remaining} = EscapeParser.parse(buffer) - - # Send events to target - Enum.each(events, fn event -> - send(state.target, {:input, event}) - end) - - # If we have a partial escape sequence, set timeout - state = - if EscapeParser.partial_sequence?(remaining) do - timer_ref = Process.send_after(self(), :escape_timeout, @escape_timeout) - %{state | buffer: remaining, timer_ref: timer_ref} - else - %{state | buffer: remaining} - end - - {:noreply, state} - end - - @impl true - def handle_info(:escape_timeout, state) do - # Timeout waiting for more escape sequence bytes - # Emit what we have as individual keys - - buffer = state.buffer - - events = - cond do - # Lone ESC - buffer == <<0x1B>> -> - [Event.key(:escape)] - - # ESC[ without terminator - emit ESC and [ - buffer == <<0x1B, ?[>> -> - [Event.key(:escape), Event.key("[")] - - # ESC O without terminator - buffer == <<0x1B, ?O>> -> - [Event.key(:escape), Event.key("O")] - - # Other partial sequences - just emit ESC and try to parse rest - String.starts_with?(buffer, <<0x1B>>) -> - <<0x1B, rest::binary>> = buffer - {rest_events, _} = EscapeParser.parse(rest) - [Event.key(:escape) | rest_events] - - true -> - [] - end - - Enum.each(events, fn event -> - send(state.target, {:input, event}) - end) - - {:noreply, %{state | buffer: <<>>, timer_ref: nil}} - end - - @impl true - def handle_info(_msg, state) do - {:noreply, state} - end - - @impl true - def terminate(_reason, state) do - # Kill reader process if running (port field now holds the reader pid) - reader_pid = state.port - - if is_pid(reader_pid) and Process.alive?(reader_pid) do - Process.exit(reader_pid, :shutdown) - end - - :ok - end - - # Private functions - - defp cancel_timer(%{timer_ref: nil} = state), do: state - - defp cancel_timer(%{timer_ref: ref} = state) do - Process.cancel_timer(ref) - %{state | timer_ref: nil} - end -end diff --git a/lib/term_ui/terminal/size_detector.ex b/lib/term_ui/terminal/size_detector.ex index 8014eb82..71c297c6 100644 --- a/lib/term_ui/terminal/size_detector.ex +++ b/lib/term_ui/terminal/size_detector.ex @@ -1,37 +1,5 @@ defmodule TermUI.Terminal.SizeDetector do - @moduledoc """ - Terminal size detection utilities. - - This module provides centralized terminal size detection that can be used - by both the Terminal module and backend implementations. It attempts multiple - methods in order of reliability: - - 1. Erlang `:io` module (most reliable when available) - 2. LINES/COLUMNS environment variables - 3. `stty size` command (last resort) - - All methods validate dimensions against practical bounds to prevent resource - exhaustion from malicious input. - - ## Size Format - - All functions return size as `{rows, cols}` (height, width) to match standard - terminal conventions where rows come first. - - ## Example - - iex> SizeDetector.detect() - {:ok, {24, 80}} - - iex> SizeDetector.detect(size: {40, 120}) - {:ok, {40, 120}} - - ## Bounds Checking - - Detected sizes are validated against `max_dimension/0` (9999) to prevent - integer overflow or resource exhaustion attacks through environment variables - or malicious terminal responses. - """ + @moduledoc false alias TermUI.TermUtils # Maximum terminal dimension (rows or columns). diff --git a/lib/term_ui/terminal/state.ex b/lib/term_ui/terminal/state.ex deleted file mode 100644 index c5cb438d..00000000 --- a/lib/term_ui/terminal/state.ex +++ /dev/null @@ -1,47 +0,0 @@ -defmodule TermUI.Terminal.State do - @moduledoc """ - Terminal state structure tracking raw mode status, original settings, - and active features (mouse tracking, bracketed paste, alternate screen). - """ - - # Dialyzer: Functions return specific struct types - @dialyzer {:nowarn_function, new: 0, new: 2} - - @type t :: %__MODULE__{ - raw_mode_active: boolean(), - alternate_screen_active: boolean(), - cursor_visible: boolean(), - mouse_tracking: :off | :x10 | :normal | :button | :all, - bracketed_paste: boolean(), - focus_events: boolean(), - original_settings: term() | nil, - size: {rows :: pos_integer(), cols :: pos_integer()} | nil, - resize_callbacks: [pid()] - } - - defstruct raw_mode_active: false, - alternate_screen_active: false, - cursor_visible: true, - mouse_tracking: :off, - bracketed_paste: false, - focus_events: false, - original_settings: nil, - size: nil, - resize_callbacks: [] - - @doc """ - Creates a new terminal state with default values. - """ - @spec new() :: t() - def new do - %__MODULE__{} - end - - @doc """ - Creates a new terminal state with the given size. - """ - @spec new(pos_integer(), pos_integer()) :: t() - def new(rows, cols) when is_integer(rows) and is_integer(cols) and rows > 0 and cols > 0 do - %__MODULE__{size: {rows, cols}} - end -end diff --git a/lib/term_ui/terminal_output.ex b/lib/term_ui/terminal_output.ex index 0ade935d..d84a60af 100644 --- a/lib/term_ui/terminal_output.ex +++ b/lib/term_ui/terminal_output.ex @@ -6,7 +6,7 @@ defmodule TermUI.TerminalOutput do @onlcr_key {__MODULE__, :onlcr_active} @tty_path ~c"/dev/tty" - @spec write(iodata()) :: :ok + @spec write(iodata()) :: :ok | {:error, term()} def write(data) do if enabled?() do IO.write(maybe_translate_newlines(data)) @@ -14,7 +14,9 @@ defmodule TermUI.TerminalOutput do :ok end rescue - _ -> :ok + exception -> {:error, exception} + catch + kind, reason -> {:error, {kind, reason}} end @spec enable_onlcr() :: :ok @@ -68,6 +70,7 @@ defmodule TermUI.TerminalOutput do @spec cleanup_sequence() :: String.t() def cleanup_sequence do "\e[?1006l\e[?1003l\e[?1002l\e[?1000l" <> + "\e[?2004l\e[?1004l" <> "\e[?25h" <> "\e[0m" <> "\e[?1049l" diff --git a/lib/term_ui/test/assertions.ex b/lib/term_ui/test/assertions.ex deleted file mode 100644 index 428f5a6a..00000000 --- a/lib/term_ui/test/assertions.ex +++ /dev/null @@ -1,529 +0,0 @@ -defmodule TermUI.Test.Assertions do - @moduledoc """ - TUI-specific assertion helpers for testing. - - Provides assertions for checking rendered content, styles, component state, - and focus. Assertions produce clear failure messages showing expected vs actual. - - ## Usage - - use TermUI.Test.Assertions - - # Content assertions - assert_text(renderer, 1, 1, "Hello") - assert_text_contains(renderer, 1, 1, 80, "Error") - refute_text(renderer, 1, 1, "Goodbye") - - # Style assertions - assert_style(renderer, 1, 1, fg: :red) - assert_attr(renderer, 1, 1, :bold) - - # State assertions - assert_state(state, [:counter, :value], 42) - """ - - @doc """ - Imports all assertion macros. - - ## Example - - defmodule MyTest do - use ExUnit.Case - use TermUI.Test.Assertions - - test "renders correctly" do - {:ok, renderer} = TestRenderer.new(24, 80) - assert_text(renderer, 1, 1, "Hello") - end - end - """ - defmacro __using__(_opts) do - quote do - import TermUI.Test.Assertions - end - end - - alias TermUI.Test.TestRenderer - - @doc """ - Asserts that text appears at the given position. - - ## Examples - - assert_text(renderer, 1, 1, "Hello") - """ - defmacro assert_text(renderer, row, col, expected) do - quote do - renderer = unquote(renderer) - row = unquote(row) - col = unquote(col) - expected = unquote(expected) - width = String.length(expected) - - actual = TestRenderer.get_text_at(renderer, row, col, width) - - if actual == expected do - true - else - raise ExUnit.AssertionError, - message: """ - Text assertion failed at (#{row}, #{col}) - - Expected: #{inspect(expected)} - Actual: #{inspect(actual)} - - Context (row #{row}): #{inspect(TestRenderer.get_row_text(renderer, row) |> String.trim_trailing())} - """ - end - end - end - - @doc """ - Asserts that text does not appear at the given position. - """ - defmacro refute_text(renderer, row, col, text) do - quote do - renderer = unquote(renderer) - row = unquote(row) - col = unquote(col) - text = unquote(text) - width = String.length(text) - - actual = TestRenderer.get_text_at(renderer, row, col, width) - - if actual != text do - true - else - raise ExUnit.AssertionError, - message: """ - Text refutation failed at (#{row}, #{col}) - - Did not expect: #{inspect(text)} - But found: #{inspect(actual)} - """ - end - end - end - - @doc """ - Asserts that a region contains the expected text. - - ## Examples - - assert_text_contains(renderer, 1, 1, 80, "Error") - """ - defmacro assert_text_contains(renderer, row, col, width, expected) do - quote do - renderer = unquote(renderer) - row = unquote(row) - col = unquote(col) - width = unquote(width) - expected = unquote(expected) - - actual = TestRenderer.get_text_at(renderer, row, col, width) - - if String.contains?(actual, expected) do - true - else - raise ExUnit.AssertionError, - message: """ - Text contains assertion failed at (#{row}, #{col}) with width #{width} - - Expected to contain: #{inspect(expected)} - Actual content: #{inspect(actual)} - """ - end - end - end - - @doc """ - Asserts that a region does not contain the text. - """ - defmacro refute_text_contains(renderer, row, col, width, text) do - quote do - renderer = unquote(renderer) - row = unquote(row) - col = unquote(col) - width = unquote(width) - text = unquote(text) - - actual = TestRenderer.get_text_at(renderer, row, col, width) - - if String.contains?(actual, text) do - raise ExUnit.AssertionError, - message: """ - Text contains refutation failed at (#{row}, #{col}) with width #{width} - - Did not expect to contain: #{inspect(text)} - Actual content: #{inspect(actual)} - """ - else - true - end - end - end - - @doc """ - Asserts that text exists somewhere in the buffer. - - ## Examples - - assert_text_exists(renderer, "Error") - """ - defmacro assert_text_exists(renderer, text) do - quote do - renderer = unquote(renderer) - text = unquote(text) - - positions = TestRenderer.find_text(renderer, text) - - if length(positions) > 0 do - true - else - raise ExUnit.AssertionError, - message: """ - Text existence assertion failed - - Expected to find: #{inspect(text)} - But text was not found in buffer. - - Buffer content: - #{TestRenderer.to_string(renderer)} - """ - end - end - end - - @doc """ - Asserts that text does not exist anywhere in the buffer. - """ - defmacro refute_text_exists(renderer, text) do - quote do - renderer = unquote(renderer) - text = unquote(text) - - positions = TestRenderer.find_text(renderer, text) - - if positions == [] do - true - else - raise ExUnit.AssertionError, - message: """ - Text existence refutation failed - - Expected not to find: #{inspect(text)} - But found at positions: #{inspect(positions)} - """ - end - end - end - - @doc """ - Asserts that a cell has the expected style. - - ## Options - - - `:fg` - Expected foreground color - - `:bg` - Expected background color - - `:attrs` - Expected attributes (list or MapSet) - - ## Examples - - assert_style(renderer, 1, 1, fg: :red) - assert_style(renderer, 1, 1, fg: :red, bg: :white) - assert_style(renderer, 1, 1, attrs: [:bold, :underline]) - """ - defmacro assert_style(renderer, row, col, expected) do - quote do - renderer = unquote(renderer) - row = unquote(row) - col = unquote(col) - expected = unquote(expected) - - actual_style = TestRenderer.get_style_at(renderer, row, col) - - errors = - Enum.reduce(expected, [], fn - {:fg, expected_fg}, acc -> - if actual_style.fg == expected_fg do - acc - else - ["fg: expected #{inspect(expected_fg)}, got #{inspect(actual_style.fg)}" | acc] - end - - {:bg, expected_bg}, acc -> - if actual_style.bg == expected_bg do - acc - else - ["bg: expected #{inspect(expected_bg)}, got #{inspect(actual_style.bg)}" | acc] - end - - {:attrs, expected_attrs}, acc -> - expected_set = MapSet.new(List.wrap(expected_attrs)) - actual_set = actual_style.attrs - - if MapSet.equal?(expected_set, actual_set) do - acc - else - [ - "attrs: expected #{inspect(MapSet.to_list(expected_set))}, got #{inspect(MapSet.to_list(actual_set))}" - | acc - ] - end - end) - - if errors == [] do - true - else - raise ExUnit.AssertionError, - message: """ - Style assertion failed at (#{row}, #{col}) - - #{Enum.join(Enum.reverse(errors), "\n")} - """ - end - end - end - - @doc """ - Asserts that a cell has a specific attribute. - - ## Examples - - assert_attr(renderer, 1, 1, :bold) - """ - defmacro assert_attr(renderer, row, col, attr) do - quote do - renderer = unquote(renderer) - row = unquote(row) - col = unquote(col) - attr = unquote(attr) - - style = TestRenderer.get_style_at(renderer, row, col) - - if MapSet.member?(style.attrs, attr) do - true - else - raise ExUnit.AssertionError, - message: """ - Attribute assertion failed at (#{row}, #{col}) - - Expected attribute: #{inspect(attr)} - Actual attributes: #{inspect(MapSet.to_list(style.attrs))} - """ - end - end - end - - @doc """ - Asserts that a cell does not have a specific attribute. - """ - defmacro refute_attr(renderer, row, col, attr) do - quote do - renderer = unquote(renderer) - row = unquote(row) - col = unquote(col) - attr = unquote(attr) - - style = TestRenderer.get_style_at(renderer, row, col) - - if MapSet.member?(style.attrs, attr) do - raise ExUnit.AssertionError, - message: """ - Attribute refutation failed at (#{row}, #{col}) - - Did not expect attribute: #{inspect(attr)} - But found in attributes: #{inspect(MapSet.to_list(style.attrs))} - """ - else - true - end - end - end - - @doc """ - Asserts state at a path matches expected value. - - ## Examples - - assert_state(%{counter: %{value: 42}}, [:counter, :value], 42) - assert_state(state, [:items], [1, 2, 3]) - """ - defmacro assert_state(state, path, expected) do - quote do - state = unquote(state) - path = unquote(path) - expected = unquote(expected) - - actual = get_in(state, path) - - if actual == expected do - true - else - raise ExUnit.AssertionError, - message: """ - State assertion failed at path #{inspect(path)} - - Expected: #{inspect(expected)} - Actual: #{inspect(actual)} - """ - end - end - end - - @doc """ - Asserts state at a path does not match value. - """ - defmacro refute_state(state, path, value) do - quote do - state = unquote(state) - path = unquote(path) - value = unquote(value) - - actual = get_in(state, path) - - if actual != value do - true - else - raise ExUnit.AssertionError, - message: """ - State refutation failed at path #{inspect(path)} - - Did not expect: #{inspect(value)} - But found: #{inspect(actual)} - """ - end - end - end - - @doc """ - Asserts state at a path exists (is not nil). - """ - defmacro assert_state_exists(state, path) do - quote do - state = unquote(state) - path = unquote(path) - - actual = get_in(state, path) - - if actual != nil do - true - else - raise ExUnit.AssertionError, - message: """ - State existence assertion failed at path #{inspect(path)} - - Expected value to exist but got nil - """ - end - end - end - - @doc """ - Asserts that a snapshot matches the current buffer. - - ## Examples - - snapshot = TestRenderer.snapshot(renderer) - # ... operations ... - assert_snapshot(renderer, snapshot) - """ - defmacro assert_snapshot(renderer, snapshot) do - quote do - renderer = unquote(renderer) - snapshot = unquote(snapshot) - - if TestRenderer.matches_snapshot?(renderer, snapshot) do - true - else - diffs = TestRenderer.diff_snapshot(renderer, snapshot) - diff_count = length(diffs) - - sample_list = Enum.take(diffs, 5) - formatted = Enum.map(sample_list, &unquote(__MODULE__).format_diff/1) - sample_diffs = Enum.join(formatted, "\n") - - raise ExUnit.AssertionError, - message: """ - Snapshot assertion failed - - #{diff_count} cells differ#{if diff_count > 5, do: " (showing first 5)", else: ""}: - #{sample_diffs} - - Expected: - #{TestRenderer.snapshot_to_string(snapshot)} - - Actual: - #{TestRenderer.to_string(renderer)} - """ - end - end - end - - @doc """ - Asserts that buffer is empty (all spaces with default style). - """ - defmacro assert_empty(renderer) do - quote do - renderer = unquote(renderer) - {rows, cols} = TestRenderer.dimensions(renderer) - - non_empty = - for row <- 1..rows, - col <- 1..cols, - !cell_empty?(TestRenderer.get_cell(renderer, row, col)) do - {row, col} - end - - if non_empty == [] do - true - else - raise ExUnit.AssertionError, - message: """ - Empty buffer assertion failed - - Buffer has #{length(non_empty)} non-empty cells - First few: #{inspect(Enum.take(non_empty, 5))} - """ - end - end - end - - @doc false - def cell_empty?(cell) do - cell.char == " " and - cell.fg == :default and - cell.bg == :default and - MapSet.size(cell.attrs) == 0 - end - - @doc false - def format_diff({row, col, expected, actual}) do - " (#{row}, #{col}): expected #{inspect(expected.char)}, got #{inspect(actual.char)}" - end - - @doc """ - Asserts row matches expected text (trimming trailing spaces). - """ - defmacro assert_row(renderer, row, expected) do - quote do - renderer = unquote(renderer) - row = unquote(row) - expected = unquote(expected) - - actual = TestRenderer.get_row_text(renderer, row) |> String.trim_trailing() - - if actual == expected do - true - else - raise ExUnit.AssertionError, - message: """ - Row assertion failed for row #{row} - - Expected: #{inspect(expected)} - Actual: #{inspect(actual)} - """ - end - end - end -end diff --git a/lib/term_ui/test/component_harness.ex b/lib/term_ui/test/component_harness.ex deleted file mode 100644 index 646aa60d..00000000 --- a/lib/term_ui/test/component_harness.ex +++ /dev/null @@ -1,337 +0,0 @@ -defmodule TermUI.Test.ComponentHarness do - @moduledoc """ - Test harness for isolated component testing. - - Mounts a component in isolation with a test renderer, allowing - event simulation and state/render inspection. - - ## Usage - - # Mount component - {:ok, harness} = ComponentHarness.mount_test(MyButton, label: "Click me") - - # Render - harness = ComponentHarness.render(harness) - - # Send events - harness = ComponentHarness.send_event(harness, Event.key(:enter)) - - # Inspect state and render - state = ComponentHarness.get_state(harness) - renderer = ComponentHarness.get_renderer(harness) - - # Cleanup - ComponentHarness.unmount(harness) - - ## Component Interface - - Components must implement these callbacks: - - `init/1` - Initialize state from props - - `render/1` - Render component to nodes - - `handle_event/2` (optional) - Handle events - - ## Example Component - - defmodule Counter do - def init(props) do - %{count: Keyword.get(props, :initial, 0)} - end - - def render(state) do - text("Count: \#{state.count}") - end - - def handle_event(%Event.Key{key: :up}, state) do - {:noreply, %{state | count: state.count + 1}} - end - - def handle_event(_event, state) do - {:noreply, state} - end - end - """ - - alias TermUI.Test.TestRenderer - - @type t :: %__MODULE__{ - module: module(), - state: term(), - renderer: TestRenderer.t(), - props: keyword(), - events: [term()], - renders: [term()], - area: map() - } - - defstruct module: nil, - state: nil, - renderer: nil, - props: [], - events: [], - renders: [], - area: %{width: 80, height: 24} - - @doc """ - Mounts a component in isolation for testing. - - ## Options - - - `:width` - Renderer width (default: 80) - - `:height` - Renderer height (default: 24) - - `:props` - Initial props to pass to component - - ## Examples - - {:ok, harness} = ComponentHarness.mount_test(MyButton, label: "Click") - {:ok, harness} = ComponentHarness.mount_test(MyWidget, width: 40, height: 10) - """ - @spec mount_test(module(), keyword()) :: {:ok, t()} | {:error, term()} - def mount_test(module, opts \\ []) do - width = Keyword.get(opts, :width, 80) - height = Keyword.get(opts, :height, 24) - props = Keyword.delete(opts, :width) |> Keyword.delete(:height) - - with {:ok, renderer} <- TestRenderer.new(height, width), - {:ok, state} <- init_component(module, props) do - harness = %__MODULE__{ - module: module, - state: state, - renderer: renderer, - props: props, - events: [], - renders: [], - area: %{width: width, height: height} - } - - {:ok, harness} - end - end - - defp init_component(module, props) do - if function_exported?(module, :init, 1) do - {:ok, module.init(props)} - else - {:ok, %{}} - end - end - - @doc """ - Unmounts the component and cleans up resources. - """ - @spec unmount(t()) :: :ok - def unmount(%__MODULE__{renderer: renderer}) do - TestRenderer.destroy(renderer) - end - - @doc """ - Renders the component to the test renderer. - - Returns the updated harness with render result stored. - """ - @spec render(t()) :: t() - def render(%__MODULE__{} = harness) do - if function_exported?(harness.module, :render, 1) do - render_result = harness.module.render(harness.state) - - # Store render result for inspection - harness = %{harness | renders: [render_result | harness.renders]} - - # Render to buffer if result is renderable - harness = render_to_buffer(harness, render_result) - - harness - else - harness - end - end - - defp render_to_buffer(harness, render_result) do - # Clear buffer first - TestRenderer.clear(harness.renderer) - - # Simple render - just handle basic text nodes for now - render_node(harness.renderer, render_result, 1, 1) - - harness - end - - defp render_node(renderer, %{type: :text, content: content}, row, col) do - TestRenderer.write_string(renderer, row, col, content) - end - - defp render_node(renderer, %{type: :stack, direction: :vertical, children: children}, row, col) do - Enum.reduce(children, row, fn child, current_row -> - render_node(renderer, child, current_row, col) - current_row + 1 - end) - end - - defp render_node( - renderer, - %{type: :stack, direction: :horizontal, children: children}, - row, - col - ) do - Enum.reduce(children, col, fn child, current_col -> - width = render_node(renderer, child, row, current_col) - current_col + width - end) - end - - defp render_node(renderer, content, row, col) when is_binary(content) do - TestRenderer.write_string(renderer, row, col, content) - end - - defp render_node(_renderer, _node, _row, _col), do: 0 - - @doc """ - Sends an event to the component. - - Returns the updated harness with new state. - """ - @spec send_event(t(), term()) :: t() - def send_event(%__MODULE__{} = harness, event) do - harness = %{harness | events: [event | harness.events]} - - if function_exported?(harness.module, :handle_event, 2) do - case harness.module.handle_event(event, harness.state) do - {:noreply, new_state} -> - %{harness | state: new_state} - - {:noreply, new_state, _commands} -> - %{harness | state: new_state} - - {:reply, _reply, new_state} -> - %{harness | state: new_state} - - _ -> - harness - end - else - harness - end - end - - @doc """ - Sends multiple events in sequence. - """ - @spec send_events(t(), [term()]) :: t() - def send_events(%__MODULE__{} = harness, events) when is_list(events) do - Enum.reduce(events, harness, fn event, acc -> - send_event(acc, event) - end) - end - - @doc """ - Gets the current component state. - """ - @spec get_state(t()) :: term() - def get_state(%__MODULE__{state: state}), do: state - - @doc """ - Gets the test renderer for inspection. - """ - @spec get_renderer(t()) :: TestRenderer.t() - def get_renderer(%__MODULE__{renderer: renderer}), do: renderer - - @doc """ - Gets the most recent render result. - """ - @spec get_render(t()) :: term() | nil - def get_render(%__MODULE__{renders: []}), do: nil - def get_render(%__MODULE__{renders: [latest | _]}), do: latest - - @doc """ - Gets all render results (most recent first). - """ - @spec get_renders(t()) :: [term()] - def get_renders(%__MODULE__{renders: renders}), do: renders - - @doc """ - Gets all events sent (most recent first). - """ - @spec get_events(t()) :: [term()] - def get_events(%__MODULE__{events: events}), do: events - - @doc """ - Gets the render area dimensions. - """ - @spec get_area(t()) :: map() - def get_area(%__MODULE__{area: area}), do: area - - @doc """ - Updates component state directly (for testing edge cases). - - Use sparingly - prefer sending events for realistic testing. - """ - @spec update_state(t(), (term() -> term())) :: t() - def update_state(%__MODULE__{} = harness, fun) when is_function(fun, 1) do - %{harness | state: fun.(harness.state)} - end - - @doc """ - Sets component state directly. - """ - @spec set_state(t(), term()) :: t() - def set_state(%__MODULE__{} = harness, new_state) do - %{harness | state: new_state} - end - - @doc """ - Gets state value at path. - """ - @spec get_state_at(t(), [atom() | String.t()]) :: term() - def get_state_at(%__MODULE__{state: state}, path) do - get_in(state, path) - end - - @doc """ - Checks if state has changed since last render. - """ - @spec state_changed?(t()) :: true - def state_changed?(%__MODULE__{renders: [], state: _}), do: true - - def state_changed?(%__MODULE__{} = _harness) do - # Would need to track previous state for this - true - end - - @doc """ - Simulates a render cycle: render -> wait -> check. - - Renders the component and returns the harness for assertions. - """ - @spec render_cycle(t()) :: t() - def render_cycle(%__MODULE__{} = harness) do - harness - |> render() - end - - @doc """ - Simulates an event cycle: send event -> render -> check. - """ - @spec event_cycle(t(), term()) :: t() - def event_cycle(%__MODULE__{} = harness, event) do - harness - |> send_event(event) - |> render() - end - - @doc """ - Resets the harness to initial state. - """ - @spec reset(t()) :: {:ok, t()} - def reset(%__MODULE__{} = harness) do - with {:ok, state} <- init_component(harness.module, harness.props) do - TestRenderer.clear(harness.renderer) - - {:ok, - %{ - harness - | state: state, - events: [], - renders: [] - }} - end - end -end diff --git a/lib/term_ui/test/event_simulator.ex b/lib/term_ui/test/event_simulator.ex deleted file mode 100644 index 5738027e..00000000 --- a/lib/term_ui/test/event_simulator.ex +++ /dev/null @@ -1,258 +0,0 @@ -defmodule TermUI.Test.EventSimulator do - @moduledoc """ - Event simulation for testing TUI components. - - Provides functions to create synthetic events for testing without - actual terminal input. Events can be injected into components or - test harnesses. - - ## Usage - - # Simulate key press - event = EventSimulator.simulate_key(:enter) - event = EventSimulator.simulate_key(:a, char: "a") - event = EventSimulator.simulate_key(:c, modifiers: [:ctrl]) - - # Simulate mouse click - event = EventSimulator.simulate_click(10, 20) - event = EventSimulator.simulate_click(10, 20, :right) - - # Simulate typing a string - events = EventSimulator.simulate_type("Hello") - - # Simulate sequence of keys - events = EventSimulator.simulate_sequence([:tab, :tab, :enter]) - """ - - alias TermUI.Event - alias TermUI.Event.Focus - alias TermUI.Event.Key - alias TermUI.Event.Mouse - alias TermUI.Event.Paste - alias TermUI.Event.Resize - - # Dialyzer: Functions return specific event types - @dialyzer {:nowarn_function, simulate_shortcut: 1, simulate_navigation: 2} - - @doc """ - Simulates a key press event. - - ## Options - - - `:char` - Character produced by key (e.g., "a" for :a key) - - `:modifiers` - List of modifiers ([:ctrl], [:shift], [:alt], etc.) - - `:timestamp` - Event timestamp (defaults to current time) - - ## Examples - - EventSimulator.simulate_key(:enter) - EventSimulator.simulate_key(:a, char: "a") - EventSimulator.simulate_key(:c, modifiers: [:ctrl]) - """ - @spec simulate_key(atom(), keyword()) :: Key.t() - def simulate_key(key, opts \\ []) do - Event.key(key, opts) - end - - @doc """ - Simulates a mouse click event. - - ## Examples - - EventSimulator.simulate_click(10, 20) - EventSimulator.simulate_click(10, 20, :right) - EventSimulator.simulate_click(10, 20, :left, modifiers: [:ctrl]) - """ - @spec simulate_click(integer(), integer(), Mouse.button(), keyword()) :: Mouse.t() - def simulate_click(x, y, button \\ :left, opts \\ []) do - Event.mouse(:click, button, x, y, opts) - end - - @doc """ - Simulates a mouse double-click event. - """ - @spec simulate_double_click(integer(), integer(), Mouse.button(), keyword()) :: Mouse.t() - def simulate_double_click(x, y, button \\ :left, opts \\ []) do - Event.mouse(:double_click, button, x, y, opts) - end - - @doc """ - Simulates a mouse move event. - - ## Examples - - EventSimulator.simulate_move(15, 25) - """ - @spec simulate_move(integer(), integer(), keyword()) :: Mouse.t() - def simulate_move(x, y, opts \\ []) do - Event.mouse(:move, nil, x, y, opts) - end - - @doc """ - Simulates a mouse drag event. - """ - @spec simulate_drag(integer(), integer(), Mouse.button(), keyword()) :: Mouse.t() - def simulate_drag(x, y, button \\ :left, opts \\ []) do - Event.mouse(:drag, button, x, y, opts) - end - - @doc """ - Simulates a scroll up event. - """ - @spec simulate_scroll_up(integer(), integer(), keyword()) :: Mouse.t() - def simulate_scroll_up(x, y, opts \\ []) do - Event.mouse(:scroll_up, nil, x, y, opts) - end - - @doc """ - Simulates a scroll down event. - """ - @spec simulate_scroll_down(integer(), integer(), keyword()) :: Mouse.t() - def simulate_scroll_down(x, y, opts \\ []) do - Event.mouse(:scroll_down, nil, x, y, opts) - end - - @doc """ - Simulates typing a string. - - Returns a list of key events, one for each character. - - ## Examples - - events = EventSimulator.simulate_type("Hello") - length(events) - # => 5 - """ - @spec simulate_type(String.t(), keyword()) :: [Key.t()] - def simulate_type(string, opts \\ []) when is_binary(string) do - string - |> String.graphemes() - |> Enum.map(fn char -> - key = char_to_key(char) - modifiers = if needs_shift?(char), do: [:shift], else: [] - base_modifiers = Keyword.get(opts, :modifiers, []) - Event.key(key, char: char, modifiers: base_modifiers ++ modifiers) - end) - end - - @doc """ - Simulates a sequence of key presses. - - Each element can be an atom (key name) or {key, opts} tuple. - - ## Examples - - events = EventSimulator.simulate_sequence([:tab, :tab, :enter]) - events = EventSimulator.simulate_sequence([ - {:a, char: "a"}, - :tab, - :enter - ]) - """ - @spec simulate_sequence([atom() | {atom(), keyword()}]) :: [Key.t()] - def simulate_sequence(keys) when is_list(keys) do - Enum.map(keys, fn - {key, opts} -> Event.key(key, opts) - key when is_atom(key) -> Event.key(key) - end) - end - - @doc """ - Simulates a focus gained event. - """ - @spec simulate_focus_gained(keyword()) :: Focus.t() - def simulate_focus_gained(opts \\ []) do - Event.focus(:gained, opts) - end - - @doc """ - Simulates a focus lost event. - """ - @spec simulate_focus_lost(keyword()) :: Focus.t() - def simulate_focus_lost(opts \\ []) do - Event.focus(:lost, opts) - end - - @doc """ - Simulates a terminal resize event. - """ - @spec simulate_resize(pos_integer(), pos_integer(), keyword()) :: Resize.t() - def simulate_resize(width, height, opts \\ []) do - Event.resize(width, height, opts) - end - - @doc """ - Simulates a paste event. - """ - @spec simulate_paste(String.t(), keyword()) :: Paste.t() - def simulate_paste(content, opts \\ []) do - Event.paste(content, opts) - end - - @doc """ - Simulates common keyboard shortcuts. - - ## Examples - - EventSimulator.simulate_shortcut(:copy) # Ctrl+C - EventSimulator.simulate_shortcut(:paste) # Ctrl+V - EventSimulator.simulate_shortcut(:save) # Ctrl+S - EventSimulator.simulate_shortcut(:quit) # Ctrl+Q - """ - @spec simulate_shortcut(atom()) :: Key.t() - def simulate_shortcut(:copy), do: Event.key(:c, modifiers: [:ctrl]) - def simulate_shortcut(:paste), do: Event.key(:v, modifiers: [:ctrl]) - def simulate_shortcut(:cut), do: Event.key(:x, modifiers: [:ctrl]) - def simulate_shortcut(:save), do: Event.key(:s, modifiers: [:ctrl]) - def simulate_shortcut(:quit), do: Event.key(:q, modifiers: [:ctrl]) - def simulate_shortcut(:undo), do: Event.key(:z, modifiers: [:ctrl]) - def simulate_shortcut(:redo), do: Event.key(:z, modifiers: [:ctrl, :shift]) - def simulate_shortcut(:select_all), do: Event.key(:a, modifiers: [:ctrl]) - - @doc """ - Simulates pressing a function key. - - ## Examples - - EventSimulator.simulate_function_key(1) # F1 - EventSimulator.simulate_function_key(12) # F12 - """ - @spec simulate_function_key(1..12) :: Key.t() - def simulate_function_key(n) when n >= 1 and n <= 12 do - key = String.to_atom("f#{n}") - Event.key(key) - end - - @doc """ - Simulates navigation keys. - - ## Examples - - EventSimulator.simulate_navigation(:up) - EventSimulator.simulate_navigation(:page_down) - EventSimulator.simulate_navigation(:home) - """ - @spec simulate_navigation(atom(), keyword()) :: Key.t() - def simulate_navigation(direction, opts \\ []) - when direction in [:up, :down, :left, :right, :home, :end, :page_up, :page_down] do - Event.key(direction, opts) - end - - # Private helpers - - defp char_to_key(char) do - cond do - char == " " -> :space - char == "\t" -> :tab - char == "\n" -> :enter - char =~ ~r/^[a-z]$/ -> String.to_atom(char) - char =~ ~r/^[A-Z]$/ -> String.to_atom(String.downcase(char)) - char =~ ~r/^[0-9]$/ -> String.to_atom(char) - true -> :char - end - end - - defp needs_shift?(char) do - char =~ ~r/^[A-Z!@#$%^&*()_+{}|:"<>?~]$/ - end -end diff --git a/lib/term_ui/test/test_renderer.ex b/lib/term_ui/test/test_renderer.ex deleted file mode 100644 index 3aa7dd45..00000000 --- a/lib/term_ui/test/test_renderer.ex +++ /dev/null @@ -1,314 +0,0 @@ -defmodule TermUI.Test.TestRenderer do - @moduledoc """ - Test renderer that captures output to a buffer for inspection. - - The test renderer implements a screen buffer interface without actual - terminal output. Tests can inspect rendered content, styles, and positions. - - ## Usage - - {:ok, renderer} = TestRenderer.new(24, 80) - TestRenderer.set_cell(renderer, 1, 1, Cell.new("X", fg: :red)) - - # Inspect rendered content - text = TestRenderer.get_text_at(renderer, 1, 1, 5) - style = TestRenderer.get_style_at(renderer, 1, 1) - - # Snapshot comparison - snapshot = TestRenderer.snapshot(renderer) - assert TestRenderer.matches_snapshot?(renderer, snapshot) - - ## Buffer Coordinates - - Rows and columns are 1-indexed to match terminal conventions. - """ - - alias TermUI.Renderer.Buffer - alias TermUI.Renderer.Cell - - @type t :: %__MODULE__{ - buffer: Buffer.t(), - rows: pos_integer(), - cols: pos_integer() - } - - defstruct buffer: nil, - rows: 0, - cols: 0 - - @doc """ - Creates a new test renderer with given dimensions. - - ## Examples - - {:ok, renderer} = TestRenderer.new(24, 80) - """ - @spec new(pos_integer(), pos_integer()) :: {:ok, t()} | {:error, term()} - def new(rows, cols) when is_integer(rows) and rows > 0 and is_integer(cols) and cols > 0 do - case Buffer.new(rows, cols) do - {:ok, buffer} -> - {:ok, %__MODULE__{buffer: buffer, rows: rows, cols: cols}} - - error -> - error - end - end - - @doc """ - Destroys the test renderer and frees resources. - """ - @spec destroy(t()) :: :ok - def destroy(%__MODULE__{buffer: buffer}) do - Buffer.destroy(buffer) - end - - @doc """ - Gets the cell at the given position. - """ - @spec get_cell(t(), pos_integer(), pos_integer()) :: Cell.t() - def get_cell(%__MODULE__{buffer: buffer}, row, col) do - Buffer.get_cell(buffer, row, col) - end - - @doc """ - Sets the cell at the given position. - """ - @spec set_cell(t(), pos_integer(), pos_integer(), Cell.t()) :: :ok | {:error, :out_of_bounds} - def set_cell(%__MODULE__{buffer: buffer}, row, col, cell) do - Buffer.set_cell(buffer, row, col, cell) - end - - @doc """ - Sets multiple cells at once. - """ - @spec set_cells(t(), [{pos_integer(), pos_integer(), Cell.t()}]) :: :ok - def set_cells(%__MODULE__{buffer: buffer}, cells) do - Buffer.set_cells(buffer, cells) - end - - @doc """ - Writes a string starting at the given position. - - Returns the number of columns written. - """ - @spec write_string(t(), pos_integer(), pos_integer(), String.t(), keyword()) :: - non_neg_integer() - def write_string(%__MODULE__{buffer: buffer}, row, col, string, opts \\ []) do - Buffer.write_string(buffer, row, col, string, opts) - end - - @doc """ - Clears the entire buffer. - """ - @spec clear(t()) :: :ok - def clear(%__MODULE__{buffer: buffer}) do - Buffer.clear(buffer) - end - - @doc """ - Gets text at a position with specified width. - - Returns the characters in cells from (row, col) to (row, col + width - 1). - - ## Examples - - text = TestRenderer.get_text_at(renderer, 1, 1, 5) - # => "Hello" - """ - @spec get_text_at(t(), pos_integer(), pos_integer(), pos_integer()) :: String.t() - def get_text_at(%__MODULE__{buffer: buffer}, row, col, width) - when is_integer(width) and width > 0 do - Enum.map_join(col..(col + width - 1), "", fn c -> - cell = Buffer.get_cell(buffer, row, c) - # Skip wide character placeholders - if cell.wide_placeholder, do: "", else: cell.char - end) - end - - @doc """ - Gets the style at a position. - - Returns a map with fg, bg, and attrs. - - ## Examples - - style = TestRenderer.get_style_at(renderer, 1, 1) - # => %{fg: :red, bg: :default, attrs: MapSet.new([:bold])} - """ - @spec get_style_at(t(), pos_integer(), pos_integer()) :: map() - def get_style_at(%__MODULE__{buffer: buffer}, row, col) do - cell = Buffer.get_cell(buffer, row, col) - - %{ - fg: cell.fg, - bg: cell.bg, - attrs: cell.attrs - } - end - - @doc """ - Gets an entire row as text. - - ## Examples - - row_text = TestRenderer.get_row_text(renderer, 1) - # => "Hello, World! " - """ - @spec get_row_text(t(), pos_integer()) :: String.t() - def get_row_text(%__MODULE__{} = renderer, row) do - get_text_at(renderer, row, 1, renderer.cols) - end - - @doc """ - Checks if text appears at a position. - - ## Examples - - TestRenderer.text_at?(renderer, 1, 1, "Hello") - # => true - """ - @spec text_at?(t(), pos_integer(), pos_integer(), String.t()) :: boolean() - def text_at?(%__MODULE__{} = renderer, row, col, expected) do - actual = get_text_at(renderer, row, col, String.length(expected)) - actual == expected - end - - @doc """ - Checks if text contains expected substring at a position. - """ - @spec text_contains?(t(), pos_integer(), pos_integer(), pos_integer(), String.t()) :: boolean() - def text_contains?(%__MODULE__{} = renderer, row, col, width, expected) do - actual = get_text_at(renderer, row, col, width) - String.contains?(actual, expected) - end - - @doc """ - Searches for text in the entire buffer. - - Returns list of {row, col} positions where text was found. - - ## Examples - - positions = TestRenderer.find_text(renderer, "Error") - # => [{5, 10}, {12, 3}] - """ - @spec find_text(t(), String.t()) :: [{pos_integer(), pos_integer()}] - def find_text(%__MODULE__{} = renderer, text) do - text_len = String.length(text) - - for row <- 1..renderer.rows, - col <- 1..(renderer.cols - text_len + 1), - text_at?(renderer, row, col, text) do - {row, col} - end - end - - @doc """ - Creates a snapshot of the current buffer state. - - Snapshots can be compared for equality or saved for regression testing. - - ## Examples - - snapshot = TestRenderer.snapshot(renderer) - """ - @spec snapshot(t()) :: map() - def snapshot(%__MODULE__{buffer: buffer, rows: rows, cols: cols}) do - cells = - for row <- 1..rows, col <- 1..cols, into: %{} do - cell = Buffer.get_cell(buffer, row, col) - {{row, col}, cell_to_map(cell)} - end - - %{ - rows: rows, - cols: cols, - cells: cells - } - end - - defp cell_to_map(%Cell{} = cell) do - %{ - char: cell.char, - fg: cell.fg, - bg: cell.bg, - attrs: MapSet.to_list(cell.attrs) |> Enum.sort() - } - end - - @doc """ - Checks if current buffer matches a snapshot. - - ## Examples - - snapshot = TestRenderer.snapshot(renderer) - # ... modify renderer ... - TestRenderer.matches_snapshot?(renderer, snapshot) - # => false - """ - @spec matches_snapshot?(t(), map()) :: boolean() - def matches_snapshot?(%__MODULE__{} = renderer, snapshot) do - current = snapshot(renderer) - current == snapshot - end - - @doc """ - Compares current buffer with snapshot and returns differences. - - Returns list of {row, col, expected, actual} tuples for differing cells. - """ - @spec diff_snapshot(t(), map()) :: [{pos_integer(), pos_integer(), map(), map()}] - def diff_snapshot(%__MODULE__{} = renderer, snapshot) do - current = snapshot(renderer) - - for row <- 1..renderer.rows, - col <- 1..renderer.cols, - current.cells[{row, col}] != snapshot.cells[{row, col}] do - {row, col, snapshot.cells[{row, col}], current.cells[{row, col}]} - end - end - - @doc """ - Converts snapshot to a printable string representation. - - Useful for test failure output. - """ - @spec snapshot_to_string(map()) :: String.t() - def snapshot_to_string(snapshot) do - for row <- 1..snapshot.rows do - for col <- 1..snapshot.cols, into: "" do - cell = snapshot.cells[{row, col}] - cell.char - end - end - |> Enum.join("\n") - end - - @doc """ - Converts current buffer to a printable string. - """ - @spec to_string(t()) :: String.t() - def to_string(%__MODULE__{} = renderer) do - for row <- 1..renderer.rows do - get_row_text(renderer, row) |> String.trim_trailing() - end - |> Enum.join("\n") - |> String.trim_trailing("\n") - end - - @doc """ - Gets buffer dimensions. - """ - @spec dimensions(t()) :: {pos_integer(), pos_integer()} - def dimensions(%__MODULE__{rows: rows, cols: cols}) do - {rows, cols} - end - - @doc """ - Checks if position is within buffer bounds. - """ - @spec in_bounds?(t(), pos_integer(), pos_integer()) :: boolean() - def in_bounds?(%__MODULE__{rows: rows, cols: cols}, row, col) do - row >= 1 and row <= rows and col >= 1 and col <= cols - end -end diff --git a/lib/term_ui/theme.ex b/lib/term_ui/theme.ex deleted file mode 100644 index 0eab79b9..00000000 --- a/lib/term_ui/theme.ex +++ /dev/null @@ -1,737 +0,0 @@ -defmodule TermUI.Theme do - @moduledoc """ - Theme system for application-wide visual consistency. - - Themes define colors, semantic meanings, and component style defaults. - The theme system supports runtime switching and notifies subscribers of changes. - - ## Theme Structure - - A theme contains: - - `:name` - Theme identifier (e.g., `:dark`, `:light`) - - `:colors` - Base colors (background, foreground, primary, etc.) - - `:semantic` - Semantic colors (success, warning, error, etc.) - - `:components` - Per-component style defaults - - ## Built-in Themes - - - `:dark` - Dark background with light text (default) - - `:light` - Light background with dark text - - `:high_contrast` - High contrast for accessibility - - ## Examples - - # Start theme server - Theme.start_link(theme: :dark) - - # Get current theme - theme = Theme.get_theme() - - # Switch themes at runtime - Theme.set_theme(:light) - - # Subscribe to theme changes - Theme.subscribe() - receive do - {:theme_changed, new_theme} -> handle_change(new_theme) - end - - # Get colors - bg = Theme.get_color(:background) - error = Theme.get_semantic(:error) - - # Get component style - style = Theme.get_component_style(:button, :focused) - """ - - use GenServer - - alias TermUI.Renderer.Style - - @type color :: Style.color() - - @type colors :: %{ - background: color(), - foreground: color(), - primary: color(), - secondary: color(), - accent: color() - } - - @type semantic :: %{ - success: color(), - warning: color(), - error: color(), - info: color(), - muted: color(), - help: color(), - placeholder: color() - } - - @type component_styles :: %{ - atom() => %{atom() => Style.t()} - } - - @type t :: %__MODULE__{ - name: atom(), - colors: colors(), - semantic: semantic(), - components: component_styles() - } - - defstruct name: :custom, - colors: %{}, - semantic: %{}, - components: %{} - - # ETS table for fast reads - @ets_table :term_ui_theme - - # Dialyzer-typed style helpers to avoid opaque type warnings - # These helpers provide type constraints for Style operations - # We suppress dialyzer warnings because we know the color atoms are valid - - @dialyzer {:nowarn_function, - fg_style: 1, - fg_bg_style: 2, - fg_bold: 1, - fg_bg_bold: 2, - fg_bold_underline: 1, - fg_dim: 1, - fg_underline: 1, - fg_bg_reverse: 2, - fg_bg_bold_reverse: 2, - fg_bg_underline: 2, - style_from_theme: 4} - - @doc false - @spec fg_style(atom()) :: Style.t() - defp fg_style(color) when is_atom(color), do: Style.new() |> Style.fg(color) - - @doc false - @spec fg_bg_style(atom(), atom()) :: Style.t() - defp fg_bg_style(fg_color, bg_color) - when is_atom(fg_color) and is_atom(bg_color), - do: Style.new() |> Style.fg(fg_color) |> Style.bg(bg_color) - - @doc false - @spec fg_bold(atom()) :: Style.t() - defp fg_bold(color) when is_atom(color), do: Style.new() |> Style.fg(color) |> Style.bold() - - @doc false - @spec fg_bg_bold(atom(), atom()) :: Style.t() - defp fg_bg_bold(fg_color, bg_color) - when is_atom(fg_color) and is_atom(bg_color), - do: Style.new() |> Style.fg(fg_color) |> Style.bg(bg_color) |> Style.bold() - - @doc false - @spec fg_bold_underline(atom()) :: Style.t() - defp fg_bold_underline(color) when is_atom(color), - do: Style.new() |> Style.fg(color) |> Style.bold() |> Style.underline() - - @doc false - @spec fg_dim(atom()) :: Style.t() - defp fg_dim(color) when is_atom(color), do: Style.new() |> Style.fg(color) |> Style.dim() - - @doc false - @spec fg_underline(atom()) :: Style.t() - defp fg_underline(color) when is_atom(color), - do: Style.new() |> Style.fg(color) |> Style.underline() - - @doc false - @spec fg_bg_reverse(atom(), atom()) :: Style.t() - defp fg_bg_reverse(fg_color, bg_color) - when is_atom(fg_color) and is_atom(bg_color), - do: Style.new() |> Style.fg(fg_color) |> Style.bg(bg_color) |> Style.reverse() - - @doc false - @spec fg_bg_bold_reverse(atom(), atom()) :: Style.t() - defp fg_bg_bold_reverse(fg_color, bg_color) - when is_atom(fg_color) and is_atom(bg_color), - do: - Style.new() - |> Style.fg(fg_color) - |> Style.bg(bg_color) - |> Style.bold() - |> Style.reverse() - - @doc false - @spec fg_bg_underline(atom(), atom()) :: Style.t() - defp fg_bg_underline(fg_color, bg_color) - when is_atom(fg_color) and is_atom(bg_color), - do: Style.new() |> Style.fg(fg_color) |> Style.bg(bg_color) |> Style.underline() - - # Built-in theme definitions as functions (to avoid compile-time struct issues) - - defp dark_theme do - %__MODULE__{ - name: :dark, - colors: %{ - background: :black, - foreground: :white, - primary: :blue, - secondary: :cyan, - accent: :magenta - }, - semantic: %{ - success: :green, - warning: :yellow, - error: :red, - info: :cyan, - muted: :bright_black, - help: :white, - placeholder: :bright_black - }, - components: %{ - button: %{ - normal: fg_bg_style(:white, :bright_black), - focused: fg_bg_bold(:white, :blue), - disabled: fg_bg_style(:bright_black, :black) - }, - text_input: %{ - normal: fg_bg_style(:white, :bright_black), - focused: fg_bg_style(:white, :blue), - disabled: fg_bg_style(:bright_black, :black) - }, - text: %{ - normal: fg_style(:white), - muted: fg_style(:bright_black), - emphasis: fg_bold(:white) - }, - border: %{ - normal: fg_style(:bright_black), - focused: fg_style(:blue), - accent: fg_style(:magenta) - }, - item: %{ - normal: fg_style(:white), - selected: fg_bg_bold_reverse(:black, :cyan), - focused: fg_bg_bold(:white, :blue) - }, - divider: %{ - normal: fg_style(:white), - focused: fg_bg_bold_reverse(:white, :cyan) - }, - status: %{ - running: fg_style(:green), - warning: fg_bold(:yellow), - error: fg_underline(:red), - terminated: fg_underline(:red), - unknown: fg_dim(:white) - } - } - } - end - - defp light_theme do - %__MODULE__{ - name: :light, - colors: %{ - background: :white, - foreground: :black, - primary: :blue, - secondary: :cyan, - accent: :magenta - }, - semantic: %{ - success: :green, - warning: :yellow, - error: :red, - info: :blue, - muted: :bright_black, - help: :black, - placeholder: :bright_black - }, - components: %{ - button: %{ - normal: fg_bg_style(:black, :white), - focused: fg_bg_bold(:white, :blue), - disabled: fg_bg_style(:bright_black, :white) - }, - text_input: %{ - normal: fg_bg_style(:black, :white), - focused: fg_bg_style(:black, :cyan), - disabled: fg_bg_style(:bright_black, :white) - }, - text: %{ - normal: fg_style(:black), - muted: fg_style(:bright_black), - emphasis: fg_bold(:black) - }, - border: %{ - normal: fg_style(:bright_black), - focused: fg_style(:blue), - accent: fg_style(:magenta) - }, - item: %{ - normal: fg_style(:black), - selected: fg_bg_reverse(:black, :cyan), - focused: fg_bg_bold(:white, :blue) - }, - divider: %{ - normal: fg_style(:black), - focused: fg_bg_bold_reverse(:black, :cyan) - }, - status: %{ - running: fg_style(:green), - warning: fg_bold(:yellow), - error: fg_underline(:red), - terminated: fg_underline(:red), - unknown: fg_dim(:black) - } - } - } - end - - defp high_contrast_theme do - %__MODULE__{ - name: :high_contrast, - colors: %{ - background: :black, - foreground: :bright_white, - primary: :bright_cyan, - secondary: :bright_yellow, - accent: :bright_magenta - }, - semantic: %{ - success: :bright_green, - warning: :bright_yellow, - error: :bright_red, - info: :bright_cyan, - muted: :white, - help: :bright_white, - placeholder: :white - }, - components: %{ - button: %{ - normal: fg_bg_bold(:bright_white, :black), - focused: fg_bg_bold(:black, :bright_cyan), - disabled: fg_bg_style(:white, :black) - }, - text_input: %{ - normal: fg_bg_underline(:bright_white, :black), - focused: fg_bg_bold(:black, :bright_yellow), - disabled: fg_bg_style(:white, :black) - }, - text: %{ - normal: fg_style(:bright_white), - muted: fg_style(:white), - emphasis: fg_bold(:bright_yellow) - }, - border: %{ - normal: fg_style(:white), - focused: fg_bold(:bright_cyan), - accent: fg_style(:bright_magenta) - }, - item: %{ - normal: fg_style(:bright_white), - selected: fg_bg_bold_reverse(:black, :bright_cyan), - focused: fg_bg_bold(:black, :bright_yellow) - }, - divider: %{ - normal: fg_style(:white), - focused: fg_bg_bold_reverse(:white, :bright_cyan) - }, - status: %{ - running: fg_bold(:bright_green), - warning: fg_bold(:bright_yellow), - error: fg_bold_underline(:bright_red), - terminated: fg_bold_underline(:bright_red), - unknown: fg_dim(:bright_white) - } - } - } - end - - defp builtin_themes do - %{ - dark: dark_theme(), - light: light_theme(), - high_contrast: high_contrast_theme() - } - end - - # Public API - Server Management - - @doc """ - Starts the theme server. - - ## Options - - - `:theme` - Initial theme (atom name or Theme struct, default `:dark`) - - `:name` - GenServer name (default `#{__MODULE__}`) - - ## Examples - - Theme.start_link(theme: :dark) - Theme.start_link(theme: :light, name: MyApp.Theme) - """ - @spec start_link(keyword()) :: GenServer.on_start() - def start_link(opts \\ []) do - name = Keyword.get(opts, :name, __MODULE__) - GenServer.start_link(__MODULE__, opts, name: name) - end - - @doc """ - Gets the current theme. - """ - @spec get_theme(GenServer.server()) :: t() - def get_theme(server \\ __MODULE__) do - table = ets_table(server) - - case :ets.whereis(table) do - :undefined -> - # Table doesn't exist, check if GenServer is running - case Process.whereis(server) do - nil -> - # Neither table nor GenServer exist, return default theme - dark_theme() - - _pid -> - # GenServer is running, call it - try do - GenServer.call(server, :get_theme) - catch - :exit, _ -> dark_theme() - end - end - - _ref -> - # Table exists, try to lookup - case :ets.lookup(table, :current_theme) do - [{:current_theme, theme}] -> theme - [] -> GenServer.call(server, :get_theme) - end - end - end - - @doc """ - Sets the current theme. - - Accepts a theme name atom (for built-in themes) or a Theme struct. - Notifies all subscribers of the change. - - ## Examples - - Theme.set_theme(:light) - Theme.set_theme(%Theme{name: :custom, ...}) - """ - @spec set_theme(atom() | t(), GenServer.server()) :: :ok | {:error, term()} - def set_theme(theme, server \\ __MODULE__) - - def set_theme(name, server) when is_atom(name) do - case get_builtin(name) do - {:ok, theme} -> GenServer.call(server, {:set_theme, theme}) - {:error, _} = error -> error - end - end - - def set_theme(%__MODULE__{} = theme, server) do - GenServer.call(server, {:set_theme, theme}) - end - - @doc """ - Subscribes the calling process to theme change notifications. - - Subscribers receive `{:theme_changed, theme}` messages when the theme changes. - """ - @spec subscribe(GenServer.server()) :: :ok - def subscribe(server \\ __MODULE__) do - GenServer.call(server, {:subscribe, self()}) - end - - @doc """ - Unsubscribes the calling process from theme change notifications. - """ - @spec unsubscribe(GenServer.server()) :: :ok - def unsubscribe(server \\ __MODULE__) do - GenServer.call(server, {:unsubscribe, self()}) - end - - # Public API - Theme Values - - @doc """ - Gets a base color from the current theme. - - ## Examples - - Theme.get_color(:background) # => :black - Theme.get_color(:primary) # => :blue - """ - @spec get_color(atom(), GenServer.server()) :: color() | nil - def get_color(name, server \\ __MODULE__) do - theme = get_theme(server) - Map.get(theme.colors, name) - end - - @doc """ - Gets a semantic color from the current theme. - - ## Examples - - Theme.get_semantic(:error) # => :red - Theme.get_semantic(:success) # => :green - """ - @spec get_semantic(atom(), GenServer.server()) :: color() | nil - def get_semantic(name, server \\ __MODULE__) do - theme = get_theme(server) - Map.get(theme.semantic, name) - end - - @doc """ - Gets a component style from the current theme. - - ## Examples - - Theme.get_component_style(:button, :focused) - Theme.get_component_style(:text_input, :normal) - """ - @spec get_component_style(atom(), atom(), GenServer.server()) :: Style.t() | nil - def get_component_style(component, variant, server \\ __MODULE__) do - theme = get_theme(server) - - case Map.get(theme.components, component) do - nil -> nil - variants -> Map.get(variants, variant) - end - end - - @doc """ - Creates a style from theme values with optional overrides. - - Useful for components that want to use theme defaults but allow customization. - - ## Examples - - # Use theme button style as base, override foreground - style = Theme.style_from_theme(:button, :normal, fg: :red) - """ - @spec style_from_theme(atom(), atom(), keyword(), GenServer.server()) :: Style.t() - def style_from_theme(component, variant, overrides \\ [], server \\ __MODULE__) do - base = get_component_style(component, variant, server) || Style.new() - override_style = Style.new(overrides) - Style.merge(base, override_style) - end - - # Public API - Theme Management - - @doc """ - Gets a built-in theme by name. - """ - @spec get_builtin(atom()) :: {:ok, t()} | {:error, :not_found} - def get_builtin(name) do - case Map.get(builtin_themes(), name) do - nil -> {:error, :not_found} - theme -> {:ok, theme} - end - end - - @doc """ - Lists all available built-in themes. - """ - @spec list_builtin() :: [atom()] - def list_builtin do - Map.keys(builtin_themes()) - end - - @doc """ - Creates a theme from a keyword list or map, merging with a base theme. - - ## Options - - - `:base` - Base theme to merge with (default `:dark`) - - `:name` - Theme name - - `:colors` - Color overrides - - `:semantic` - Semantic color overrides - - `:components` - Component style overrides - - ## Examples - - # Create custom theme based on dark - {:ok, theme} = Theme.from( - base: :dark, - name: :my_theme, - colors: %{primary: :magenta} - ) - """ - @spec from(keyword() | map()) :: {:ok, t()} | {:error, term()} - def from(opts) when is_list(opts) or is_map(opts) do - opts = if is_map(opts), do: Map.to_list(opts), else: opts - - base_name = Keyword.get(opts, :base, :dark) - - case get_builtin(base_name) do - {:ok, base} -> - theme = merge_theme(base, opts) - {:ok, theme} - - {:error, _} -> - {:error, {:invalid_base_theme, base_name}} - end - end - - @doc """ - Validates a theme struct. - - Returns `:ok` if valid, `{:error, reasons}` if invalid. - """ - @spec validate(t()) :: :ok | {:error, [String.t()]} - def validate(%__MODULE__{} = theme) do - errors = [] - - # Check required color fields - required_colors = [:background, :foreground, :primary, :secondary, :accent] - - missing_colors = - Enum.filter(required_colors, fn key -> - not Map.has_key?(theme.colors, key) - end) - - errors = - if missing_colors != [] do - ["Missing required colors: #{inspect(missing_colors)}" | errors] - else - errors - end - - # Check required semantic fields - required_semantic = [:success, :warning, :error, :info, :muted, :help, :placeholder] - - missing_semantic = - Enum.filter(required_semantic, fn key -> - not Map.has_key?(theme.semantic, key) - end) - - errors = - if missing_semantic != [] do - ["Missing required semantic colors: #{inspect(missing_semantic)}" | errors] - else - errors - end - - if errors == [] do - :ok - else - {:error, Enum.reverse(errors)} - end - end - - # GenServer Callbacks - - @impl true - def init(opts) do - # Create ETS table for fast reads - table = create_ets_table(opts) - - # Get initial theme - initial_theme = resolve_initial_theme(opts) - - # Store in ETS - :ets.insert(table, {:current_theme, initial_theme}) - - state = %{ - table: table, - theme: initial_theme, - subscribers: MapSet.new() - } - - {:ok, state} - end - - @impl true - def handle_call(:get_theme, _from, state) do - {:reply, state.theme, state} - end - - def handle_call({:set_theme, theme}, _from, state) do - # Update ETS - :ets.insert(state.table, {:current_theme, theme}) - - # Notify subscribers - notify_subscribers(state.subscribers, theme) - - {:reply, :ok, %{state | theme: theme}} - end - - def handle_call({:subscribe, pid}, _from, state) do - Process.monitor(pid) - {:reply, :ok, %{state | subscribers: MapSet.put(state.subscribers, pid)}} - end - - def handle_call({:unsubscribe, pid}, _from, state) do - {:reply, :ok, %{state | subscribers: MapSet.delete(state.subscribers, pid)}} - end - - @impl true - def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do - {:noreply, %{state | subscribers: MapSet.delete(state.subscribers, pid)}} - end - - # Private Helpers - - defp create_ets_table(opts) do - name = Keyword.get(opts, :name, __MODULE__) - table_name = ets_table(name) - - :ets.new(table_name, [:named_table, :public, read_concurrency: true]) - end - - defp ets_table(server) when is_atom(server) do - :"#{server}_ets" - end - - defp ets_table(pid) when is_pid(pid) do - @ets_table - end - - defp resolve_initial_theme(opts) do - case Keyword.get(opts, :theme, :dark) do - name when is_atom(name) -> - case get_builtin(name) do - {:ok, theme} -> theme - {:error, _} -> dark_theme() - end - - %__MODULE__{} = theme -> - theme - end - end - - defp merge_theme(base, opts) do - name = Keyword.get(opts, :name, base.name) - colors = deep_merge(base.colors, Keyword.get(opts, :colors, %{})) - semantic = deep_merge(base.semantic, Keyword.get(opts, :semantic, %{})) - components = merge_components(base.components, Keyword.get(opts, :components, %{})) - - %__MODULE__{ - name: name, - colors: colors, - semantic: semantic, - components: components - } - end - - defp deep_merge(base, override) when is_map(base) and is_map(override) do - Map.merge(base, override) - end - - defp deep_merge(base, override) when is_map(base) and is_list(override) do - Map.merge(base, Map.new(override)) - end - - defp merge_components(base, override) when is_map(base) and is_map(override) do - Map.merge(base, override, fn _key, base_variants, override_variants -> - Map.merge(base_variants, override_variants) - end) - end - - defp merge_components(base, override) when is_map(base) and is_list(override) do - merge_components(base, Map.new(override)) - end - - defp notify_subscribers(subscribers, theme) do - Enum.each(subscribers, fn pid -> - send(pid, {:theme_changed, theme}) - end) - end -end diff --git a/lib/term_ui/view_cache.ex b/lib/term_ui/view_cache.ex deleted file mode 100644 index 9f5478f7..00000000 --- a/lib/term_ui/view_cache.ex +++ /dev/null @@ -1,189 +0,0 @@ -defmodule TermUI.ViewCache do - @moduledoc """ - View memoization cache for skipping renders when state is unchanged. - - The view cache stores the last state hash and render tree for a component. - When a component's state hasn't changed, we return the cached render tree - instead of re-calling the view function. - - ## Usage - - cache = ViewCache.new() - - # Check if view needs recalculating - case ViewCache.get(cache, state) do - {:hit, render_tree} -> - # Use cached result - {render_tree, cache} - - :miss -> - # Calculate and cache - render_tree = Component.view(state) - cache = ViewCache.put(cache, state, render_tree) - {render_tree, cache} - end - - ## Performance Considerations - - State hashing uses `:erlang.phash2/1` which is fast but may have collisions. - For most UI state this is acceptable—the worst case is a redundant render. - """ - - # Dialyzer: Functions return specific struct types - @dialyzer {:nowarn_function, new: 0} - - @type state :: term() - @type render_tree :: term() - @type state_hash :: integer() - - @type t :: %__MODULE__{ - state_hash: state_hash() | nil, - render_tree: render_tree() | nil, - hits: non_neg_integer(), - misses: non_neg_integer(), - last_render_time_us: non_neg_integer() - } - - defstruct state_hash: nil, - render_tree: nil, - hits: 0, - misses: 0, - last_render_time_us: 0 - - # Performance warning threshold in microseconds - @slow_view_threshold_us 1000 - - @doc """ - Creates a new view cache. - """ - @spec new() :: t() - def new do - %__MODULE__{} - end - - @doc """ - Looks up a cached render tree for the given state. - - Returns `{:hit, render_tree}` if state matches cache, - or `:miss` if view needs recalculating. - """ - @spec get(t(), state()) :: {:hit, render_tree()} | :miss - def get(%__MODULE__{state_hash: nil}, _state), do: :miss - - def get(%__MODULE__{state_hash: cached_hash, render_tree: tree}, state) do - if hash_state(state) == cached_hash do - {:hit, tree} - else - :miss - end - end - - @doc """ - Stores a render tree for the given state. - """ - @spec put(t(), state(), render_tree()) :: t() - def put(%__MODULE__{} = cache, state, render_tree) do - %{ - cache - | state_hash: hash_state(state), - render_tree: render_tree - } - end - - @doc """ - Records a cache hit and returns updated cache. - """ - @spec record_hit(t()) :: t() - def record_hit(%__MODULE__{} = cache) do - %{cache | hits: cache.hits + 1} - end - - @doc """ - Records a cache miss and render time. - """ - @spec record_miss(t(), non_neg_integer()) :: t() - def record_miss(%__MODULE__{} = cache, render_time_us) do - %{ - cache - | misses: cache.misses + 1, - last_render_time_us: render_time_us - } - end - - @doc """ - Invalidates the cache, forcing next view to recalculate. - """ - @spec invalidate(t()) :: t() - def invalidate(%__MODULE__{} = cache) do - %{cache | state_hash: nil, render_tree: nil} - end - - @doc """ - Returns cache statistics. - """ - @spec stats(t()) :: %{hits: non_neg_integer(), misses: non_neg_integer(), hit_rate: float()} - def stats(%__MODULE__{hits: hits, misses: misses}) do - total = hits + misses - hit_rate = if total > 0, do: hits / total * 100, else: 0.0 - - %{ - hits: hits, - misses: misses, - hit_rate: hit_rate - } - end - - @doc """ - Checks if the last render was slow and returns a warning if so. - """ - @spec check_performance(t()) :: :ok | {:slow_view, non_neg_integer()} - def check_performance(%__MODULE__{last_render_time_us: time}) - when time > @slow_view_threshold_us do - {:slow_view, time} - end - - def check_performance(_cache), do: :ok - - @doc """ - Memoizes a view function call. - - Calls the view function only if state has changed, otherwise returns cached result. - Also records timing and warns about slow views. - """ - @spec memoize(t(), state(), (state() -> render_tree())) :: {render_tree(), t()} - def memoize(%__MODULE__{} = cache, state, view_fun) do - case get(cache, state) do - {:hit, render_tree} -> - cache = record_hit(cache) - {render_tree, cache} - - :miss -> - {time_us, render_tree} = :timer.tc(fn -> view_fun.(state) end) - - cache = - cache - |> put(state, render_tree) - |> record_miss(time_us) - - case check_performance(cache) do - {:slow_view, time} -> - require Logger - - Logger.warning( - "Slow view function: #{time}µs (threshold: #{@slow_view_threshold_us}µs)" - ) - - :ok -> - :ok - end - - {render_tree, cache} - end - end - - # Private functions - - defp hash_state(state) do - :erlang.phash2(state) - end -end diff --git a/lib/term_ui/widget.ex b/lib/term_ui/widget.ex new file mode 100644 index 00000000..243084a8 --- /dev/null +++ b/lib/term_ui/widget.ex @@ -0,0 +1,56 @@ +defmodule TermUI.Widget do + @moduledoc """ + The contract for pure, embedded widgets. + + A widget is not a process. Its parent stores its state, sends events to + `update/2`, and composes the `TermUI.Frame` from `view/2` into the + application frame. Widget messages are data for the parent application. + + All supplied widgets use this lifecycle: + + state = MyWidget.init(options) + {state, messages} = MyWidget.update(event, state) + {state, messages} = TermUI.Widget.mouse(MyWidget, local_mouse, state, {width, height}) + frame = MyWidget.view(state, {width, height}) + + Use `TermUI.Frame.overlay/4` to compose widget frames. No widget starts a + process or uses a global registry. The optional `mouse/3` callback receives + zero-based local coordinates and widget dimensions. `mouse/4` calls + `update/2` when a widget does not implement that callback. + """ + + alias TermUI.Event + + @type state :: term() + @type message :: term() + @type dimensions :: {width :: pos_integer(), height :: pos_integer()} + + @callback init(keyword()) :: state() + @callback update(Event.t(), state()) :: {state(), [message()]} + @callback view(state(), dimensions()) :: TermUI.Frame.t() + @callback mouse(Event.Mouse.t(), state(), dimensions()) :: {state(), [message()]} + + @optional_callbacks mouse: 3 + + @doc "Renders one widget through the common contract." + @spec view(module(), state(), dimensions()) :: TermUI.Frame.t() + def view(module, state, {width, height} = dimensions) + when is_atom(module) and width > 0 and height > 0 do + case module.view(state, dimensions) do + %TermUI.Frame{} = frame -> + frame + + other -> + raise ArgumentError, "widget view returned #{inspect(other)}, expected TermUI.Frame" + end + end + + @doc "Delivers a routed local mouse event with the widget dimensions." + @spec mouse(module(), Event.Mouse.t(), state(), dimensions()) :: {state(), [message()]} + def mouse(module, %Event.Mouse{} = event, state, {width, height} = dimensions) + when is_atom(module) and width > 0 and height > 0 do + if function_exported?(module, :mouse, 3), + do: module.mouse(event, state, dimensions), + else: module.update(event, state) + end +end diff --git a/lib/term_ui/widget/alert_dialog.ex b/lib/term_ui/widget/alert_dialog.ex new file mode 100644 index 00000000..a6496222 --- /dev/null +++ b/lib/term_ui/widget/alert_dialog.ex @@ -0,0 +1,51 @@ +defmodule TermUI.Widget.AlertDialog do + @moduledoc "A pure information, warning, error, or confirmation dialog." + + @behaviour TermUI.Widget + + alias TermUI.Widget.Dialog + + @type alert_type :: :info | :warning | :error | :confirm + @type t :: %__MODULE__{type: alert_type(), dialog: Dialog.t()} + @schema Zoi.struct(__MODULE__, %{ + type: Zoi.enum([:info, :warning, :error, :confirm]) |> Zoi.default(:info), + dialog: Zoi.struct(Dialog) |> Zoi.default(%Dialog{}) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts) do + type = Keyword.get(opts, :type, :info) + buttons = Keyword.get(opts, :buttons, default_buttons(type)) + title = Keyword.get(opts, :title, default_title(type)) + + dialog = + Dialog.init( + title: title, + content: Keyword.get(opts, :message, ""), + buttons: buttons, + dismiss_message: :cancel + ) + + %__MODULE__{type: type, dialog: dialog} + end + + @impl true + def update(event, state) do + {dialog, messages} = Dialog.update(event, state.dialog) + {%{state | dialog: dialog}, messages} + end + + @impl true + def view(state, dimensions), do: Dialog.view(state.dialog, dimensions) + + defp default_buttons(:confirm), + do: [%{id: :yes, label: "Yes", message: :confirm}, %{id: :no, label: "No", message: :cancel}] + + defp default_buttons(_type), do: [%{id: :ok, label: "OK", message: :ok}] + defp default_title(:warning), do: "Warning" + defp default_title(:error), do: "Error" + defp default_title(:confirm), do: "Confirm" + defp default_title(:info), do: "Information" +end diff --git a/lib/term_ui/widget/bar_chart.ex b/lib/term_ui/widget/bar_chart.ex new file mode 100644 index 00000000..166b60c1 --- /dev/null +++ b/lib/term_ui/widget/bar_chart.ex @@ -0,0 +1,77 @@ +defmodule TermUI.Widget.BarChart do + @moduledoc "A pure horizontal bar chart." + + @behaviour TermUI.Widget + + alias TermUI.Style + alias TermUI.Widget.{ChartHelpers, Helpers} + + @type datum :: %{ + required(:label) => String.t(), + required(:value) => number(), + optional(:color) => atom() + } + @type t :: %__MODULE__{ + data: [datum()], + minimum: number() | nil, + maximum: number() | nil, + show_values: boolean() + } + @schema Zoi.struct(__MODULE__, %{ + data: Zoi.array() |> Zoi.default([]), + minimum: Zoi.any() |> Zoi.default(nil), + maximum: Zoi.any() |> Zoi.default(nil), + show_values: Zoi.boolean() |> Zoi.default(true) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts), + do: %__MODULE__{ + data: opts |> Keyword.get(:data, []) |> Enum.map(&normalize/1), + minimum: Keyword.get(opts, :min), + maximum: Keyword.get(opts, :max), + show_values: Keyword.get(opts, :show_values, true) + } + + @impl true + def update(_event, state), do: {state, []} + + @impl true + def view(state, {width, height} = dimensions) do + data = Enum.take(state.data, height) + values = Enum.map(data, & &1.value) + + {minimum, maximum} = + ChartHelpers.range(values, + min: state.minimum || Enum.min(values, fn -> 0 end), + max: state.maximum || Enum.max(values, fn -> 1 end) + ) + + label_width = + data |> Enum.map(&String.length(&1.label)) |> Enum.max(fn -> 0 end) |> min(div(width, 3)) + + rows = + Enum.map(data, fn datum -> + value_text = if state.show_values, do: " " <> ChartHelpers.number(datum.value), else: "" + bar_width = max(width - label_width - String.length(value_text) - 1, 1) + fill = round(ChartHelpers.normalize(datum.value, minimum, maximum) * bar_width) + + [ + Helpers.align(datum.label, label_width, :right), + " ", + {String.duplicate("█", fill), Style.new(fg: datum.color)}, + String.duplicate(" ", max(bar_width - fill, 0)), + value_text + ] + end) + + Helpers.frame(rows, dimensions) + end + + defp normalize(%{label: label, value: value} = datum), + do: %{label: to_string(label), value: value, color: Map.get(datum, :color, :cyan)} + + defp normalize({label, value}), do: %{label: to_string(label), value: value, color: :cyan} +end diff --git a/lib/term_ui/widget/block.ex b/lib/term_ui/widget/block.ex index 3a52d8a7..e42ba639 100644 --- a/lib/term_ui/widget/block.ex +++ b/lib/term_ui/widget/block.ex @@ -1,280 +1,77 @@ defmodule TermUI.Widget.Block do - @moduledoc """ - A container widget that draws a border around its content. + @moduledoc "A bordered content block that produces a frame." + + @behaviour TermUI.Widget + + alias TermUI.{Frame, Style} + alias TermUI.Widget.Helpers + + @type t :: %__MODULE__{ + rows: [Frame.row()], + title: String.t() | nil, + padding: non_neg_integer(), + style: Style.t(), + border_style: Style.t() + } + + @schema Zoi.struct(__MODULE__, %{ + rows: Zoi.array() |> Zoi.default([]), + title: Zoi.any() |> Zoi.default(nil), + padding: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0), + style: Zoi.struct(Style) |> Zoi.default(%Style{}), + border_style: Zoi.struct(Style) |> Zoi.default(%Style{fg: :bright_black}) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) - Block is the fundamental layout container. It renders a border, - optional title, and manages the layout of children within - its bordered area. - - ## Usage - - Block.render(%{ - border: :single, - title: "Panel" - }, state, area) - - ## Props - - - `:border` - Border style: `:none`, `:single`, `:double`, `:rounded`, `:thick` - - `:title` - Optional title text - - `:title_align` - Title alignment: `:left`, `:center`, `:right` - - `:padding` - Padding inside border (integer or map with :top, :right, :bottom, :left) - - `:style` - Border style options - """ - - use TermUI.Container - - alias TermUI.Component.RenderNode - alias TermUI.Renderer.Style - - # Dialyzer: Suppress opaque type warnings for Style helpers - # Dialyzer: no_return warnings for functions that don't return - # no_opaque: Style contains MapSet which triggers false positive call_without_opaque warnings - @dialyzer [ - :no_opaque, - nowarn_function: [ - build_style: 1, - positioned_cell_safe: 4, - do_render_top: 5, - render_bottom_border: 3, - render_side_borders: 3 - ] - ] - - # Border character sets - @borders %{ - none: %{tl: " ", tr: " ", bl: " ", br: " ", h: " ", v: " "}, - single: %{tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│"}, - double: %{tl: "╔", tr: "╗", bl: "╚", br: "╝", h: "═", v: "║"}, - rounded: %{tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│"}, - thick: %{tl: "┏", tr: "┓", bl: "┗", br: "┛", h: "━", v: "┃"} - } - - @doc """ - Initializes the block state. - """ - @impl true - def init(props) do - {:ok, %{props: props}} - end - - @doc """ - Returns children to render. - """ - @impl true - def children(_state) do - # Block delegates children management to parent - [] - end - - @doc """ - Calculates layout for children within the block. - """ @impl true - def layout(children, area, _state) do - # Children get the inner area (after border and padding) - Enum.map(children, fn child -> - {child, area} - end) + def init(opts) do + %__MODULE__{ + rows: normalize_content(Keyword.get(opts, :content, Keyword.get(opts, :rows, []))), + title: Keyword.get(opts, :title), + padding: max(Keyword.get(opts, :padding, 0), 0), + style: Keyword.get(opts, :style, Style.new()), + border_style: Keyword.get(opts, :border_style, Style.new(fg: :bright_black)) + } end - @doc """ - Handles events for the block. - """ @impl true - def handle_event(_event, state) do - {:ok, state} - end + def update(_event, state), do: {state, []} - @doc """ - Renders the block border and content area. - """ @impl true - def render(state, area) do - props = state.props - border_type = Map.get(props, :border, :single) - title = Map.get(props, :title) - title_align = Map.get(props, :title_align, :left) - style_opts = Map.get(props, :style, %{}) - - style = build_style(style_opts) - border_chars = Map.get(@borders, border_type, @borders.single) - - cells = render_border(border_chars, title, title_align, area, style) - - RenderNode.cells(cells) + def view(state, {width, _height} = dimensions) do + inner_width = max(width - 2 - state.padding * 2, 0) + padding = String.duplicate(" ", state.padding) + + rows = + List.duplicate("", state.padding) ++ + Enum.flat_map(state.rows, fn row -> + row + |> plain_text() + |> Frame.wrap(max(inner_width, 1)) + |> Enum.map(fn line -> [{padding, state.style}, {line, state.style}, padding] end) + end) ++ List.duplicate("", state.padding) + + rows = Helpers.border(rows, dimensions, title: state.title, border_style: state.border_style) + Helpers.frame(rows, dimensions) end - @doc """ - Calculates the inner area after border and padding. - """ - def inner_area(props, area) do - border_type = Map.get(props, :border, :single) - padding = normalize_padding(Map.get(props, :padding, 0)) + @doc "Replaces the block content." + @spec set_content(t(), String.t() | [Frame.row()]) :: t() + def set_content(state, content), do: %{state | rows: normalize_content(content)} - # Border takes 1 cell on each side (unless :none) - border_offset = if border_type == :none, do: 0, else: 1 + defp normalize_content(content) when is_binary(content), + do: String.split(content, "\n", trim: false) - %{ - x: area.x + border_offset + padding.left, - y: area.y + border_offset + padding.top, - width: max(0, area.width - 2 * border_offset - padding.left - padding.right), - height: max(0, area.height - 2 * border_offset - padding.top - padding.bottom) - } - end + defp normalize_content(content) when is_list(content), do: content + defp normalize_content(content), do: [to_string(content)] - # Private Functions + defp plain_text(row) when is_binary(row), do: row - defp render_border(chars, title, title_align, area, style) do - cells = [] - - # Top border - cells = cells ++ render_top_border(chars, title, title_align, area, style) - - # Side borders - cells = cells ++ render_side_borders(chars, area, style) - - # Bottom border - cells = cells ++ render_bottom_border(chars, area, style) - - cells - end - - defp render_top_border(chars, title, title_align, area, style) do - if area.height < 1 || area.width < 1, - do: [], - else: do_render_top(chars, title, title_align, area, style) - end - - defp do_render_top(chars, nil, _title_align, area, style) do - # No title - just border - [positioned_cell_safe(0, 0, chars.tl, style)] ++ - for(x <- 1..(area.width - 2), do: positioned_cell_safe(x, 0, chars.h, style)) ++ - [positioned_cell_safe(area.width - 1, 0, chars.tr, style)] - end - - defp do_render_top(chars, title, title_align, area, style) do - # With title - inner_width = area.width - 2 - - if inner_width < 1 do - [ - positioned_cell_safe(0, 0, chars.tl, style), - positioned_cell_safe(area.width - 1, 0, chars.tr, style) - ] - else - title_text = String.slice(title, 0, inner_width) - title_len = String.length(title_text) - remaining = inner_width - title_len - - {left_pad, right_pad} = - case title_align do - :left -> {0, remaining} - :right -> {remaining, 0} - :center -> {div(remaining, 2), remaining - div(remaining, 2)} - end - - top_cells = [positioned_cell_safe(0, 0, chars.tl, style)] - - # Left padding - top_cells = - if left_pad > 0 do - top_cells ++ for(x <- 1..left_pad, do: positioned_cell_safe(x, 0, chars.h, style)) - else - top_cells - end - - # Title - top_cells = - top_cells ++ - (title_text - |> String.graphemes() - |> Enum.with_index() - |> Enum.map(fn {char, i} -> - positioned_cell_safe(1 + left_pad + i, 0, char, style) - end)) - - # Right padding - top_cells = - if right_pad > 0 do - top_cells ++ - for i <- 0..(right_pad - 1) do - positioned_cell_safe(1 + left_pad + title_len + i, 0, chars.h, style) - end - else - top_cells - end - - top_cells ++ [positioned_cell_safe(area.width - 1, 0, chars.tr, style)] - end - end - - defp render_side_borders(chars, area, style) do - if area.height < 3 || area.width < 2 do - [] - else - for y <- 1..(area.height - 2) do - [ - positioned_cell_safe(0, y, chars.v, style), - positioned_cell_safe(area.width - 1, y, chars.v, style) - ] - end - |> List.flatten() - end - end - - defp render_bottom_border(chars, area, style) do - if area.height < 2 || area.width < 1 do - [] - else - y = area.height - 1 - - [positioned_cell_safe(0, y, chars.bl, style)] ++ - for(x <- 1..(area.width - 2), do: positioned_cell_safe(x, y, chars.h, style)) ++ - [positioned_cell_safe(area.width - 1, y, chars.br, style)] - end - end - - defp normalize_padding(padding) when is_integer(padding) do - %{top: padding, right: padding, bottom: padding, left: padding} - end - - defp normalize_padding(padding) when is_map(padding) do - %{ - top: Map.get(padding, :top, 0), - right: Map.get(padding, :right, 0), - bottom: Map.get(padding, :bottom, 0), - left: Map.get(padding, :left, 0) - } - end - - defp normalize_padding(_), do: %{top: 0, right: 0, bottom: 0, left: 0} - - # ---------------------------------------------------------------------------- - # Style Helper Functions - # ---------------------------------------------------------------------------- - - @spec positioned_cell_safe(integer(), integer(), String.t(), Style.t()) :: RenderNode.t() - defp positioned_cell_safe(x, y, char, style), - do: positioned_cell(x, y, char, style) - - # ---------------------------------------------------------------------------- - # Style Building - # ---------------------------------------------------------------------------- - - defp build_style(opts) when is_map(opts) do - style_list = - opts - |> Enum.map(fn - {:fg, color} -> {:fg, color} - {:bg, color} -> {:bg, color} - {:bold, true} -> {:attrs, [:bold]} - _ -> nil - end) - |> Enum.reject(&is_nil/1) - - Style.new(style_list) + defp plain_text(row) when is_list(row) do + Enum.map_join(row, fn + {text, %Style{}} -> IO.iodata_to_binary(text) + text -> IO.iodata_to_binary(text) + end) end - - defp build_style(_), do: Style.new() end diff --git a/lib/term_ui/widget/button.ex b/lib/term_ui/widget/button.ex index 786d0ac0..afb964e2 100644 --- a/lib/term_ui/widget/button.ex +++ b/lib/term_ui/widget/button.ex @@ -1,194 +1,95 @@ defmodule TermUI.Widget.Button do - @moduledoc """ - An interactive button widget. + @moduledoc "A pure keyboard and mouse button." + + @behaviour TermUI.Widget + + alias TermUI.{Event, Style} + alias TermUI.Widget.Helpers + + @type t :: %__MODULE__{ + id: term(), + label: String.t(), + focused: boolean(), + pressed: boolean(), + disabled: boolean(), + message: term(), + style: Style.t(), + focus_style: Style.t(), + disabled_style: Style.t() + } + + @schema Zoi.struct(__MODULE__, %{ + id: Zoi.any() |> Zoi.default(nil), + label: Zoi.string() |> Zoi.default("Button"), + focused: Zoi.boolean() |> Zoi.default(false), + pressed: Zoi.boolean() |> Zoi.default(false), + disabled: Zoi.boolean() |> Zoi.default(false), + message: Zoi.any() |> Zoi.default(nil), + style: Zoi.struct(Style) |> Zoi.default(%Style{}), + focus_style: + Zoi.struct(Style) + |> Zoi.default(%Style{fg: :cyan, attrs: MapSet.new([:bold])}), + disabled_style: Zoi.struct(Style) |> Zoi.default(%Style{fg: :bright_black}) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) - Button responds to Enter/Space keys when focused and mouse clicks. - It displays visual feedback for different states. - - ## Usage - - Button.render(%{ - label: "Submit", - on_click: fn -> send(self(), :submitted) end - }, state, area) - - ## Props - - - `:label` - Button text (required) - - `:on_click` - Callback function invoked on activation - - `:disabled` - Whether button is disabled (default: `false`) - - `:style` - Style options - - `:focused_style` - Style when focused - - `:pressed_style` - Style when pressed - """ - - use TermUI.StatefulComponent - - alias TermUI.Component.RenderNode - alias TermUI.Event - alias TermUI.Renderer.Style - - # Dialyzer: Suppress opaque type warnings for Style helpers - @dialyzer {:nowarn_function, build_style: 1, positioned_cell_safe: 4, render: 2} - - @doc """ - Initializes the button state. - """ @impl true - def init(props) do - state = %{ - pressed: false, - hovered: false, - disabled: Map.get(props, :disabled, false), - props: props + def init(opts) do + id = Keyword.get(opts, :id) + + %__MODULE__{ + id: id, + label: opts |> Keyword.get(:label, "Button") |> to_string(), + focused: Keyword.get(opts, :focused, false), + disabled: Keyword.get(opts, :disabled, false), + message: Keyword.get(opts, :message, {:pressed, id}), + style: Keyword.get(opts, :style, Style.new()), + focus_style: Keyword.get(opts, :focus_style, Style.new(fg: :cyan, attrs: [:bold])), + disabled_style: Keyword.get(opts, :disabled_style, Style.new(fg: :bright_black)) } - - {:ok, state} end - @doc """ - Handles events for the button. - """ @impl true - def handle_event(%Event.Key{key: key}, state) when key in [:enter, :space] do - if state.disabled do - {:ok, state} - else - {:ok, %{state | pressed: true}, [{:send, self(), :click}]} - end - end - - def handle_event(%Event.Mouse{action: :click}, state) do - if state.disabled do - {:ok, state} - else - {:ok, %{state | pressed: true}, [{:send, self(), :click}]} - end - end - - def handle_event(%Event.Mouse{action: :press}, state) do - if state.disabled do - {:ok, state} - else - {:ok, %{state | pressed: true}} - end - end - - def handle_event(%Event.Mouse{action: :release}, state) do - {:ok, %{state | pressed: false}} - end - - def handle_event(%Event.Focus{action: :gained}, state) do - {:ok, state} - end + def update(_event, %{disabled: true} = state), do: {state, []} + def update(%Event.Key{key: key}, state) when key in [:enter, :space], do: press(state) + def update(%Event.Text{text: " "}, state), do: press(state) - def handle_event(%Event.Focus{action: :lost}, state) do - {:ok, %{state | pressed: false}} - end + def update(%Event.Mouse{action: :press, button: :left}, state), + do: {%{state | pressed: true}, []} - def handle_event(_event, state) do - {:ok, state} - end + def update(%Event.Mouse{action: :release, button: :left}, state), + do: press(%{state | pressed: false}) - @doc """ - Handles messages to the button. - """ - @impl true - def handle_info(:click, state) do - # Invoke on_click callback - props = state.props - on_click = Map.get(props, :on_click) + def update(%Event.Focus{action: :gained}, state), do: {%{state | focused: true}, []} - if is_function(on_click, 0) do - on_click.() - end + def update(%Event.Focus{action: :lost}, state), + do: {%{state | focused: false, pressed: false}, []} - {:ok, %{state | pressed: false}} - end + def update(_event, state), do: {state, []} - def handle_info(_msg, state) do - {:ok, state} - end - - @doc """ - Renders the button. - """ @impl true - def render(state, area) do - props = state.props - label = Map.get(props, :label, "Button") - disabled = state.disabled - - style = get_style(props, state) - - # Center the label - text = center_text(label, area.width) - - cells = - text - |> String.graphemes() - |> Enum.with_index() - |> Enum.filter(fn {_char, x} -> x < area.width end) - |> Enum.map(fn {char, x} -> - cell_style = - if disabled do - build_style(%{fg: :bright_black}) - else - style - end - - positioned_cell_safe(x, 0, char, cell_style) - end) - - RenderNode.cells(cells) + def view(state, {width, _height} = dimensions) do + style = + cond do + state.disabled -> state.disabled_style + state.focused or state.pressed -> state.focus_style + true -> state.style + end + + marker = if state.pressed, do: "<", else: "[" + end_marker = if state.pressed, do: ">", else: "]" + + row = [ + {Helpers.align(marker <> " " <> state.label <> " " <> end_marker, width, :center), style} + ] + + Helpers.frame([row], dimensions) end - # Private Functions - - defp get_style(props, state) do - if state.pressed do - build_style(Map.get(props, :pressed_style, %{fg: :black, bg: :white})) - else - build_style(Map.get(props, :style, %{})) - end - end + @doc "Sets keyboard focus." + @spec focus(t(), boolean()) :: t() + def focus(state, focused \\ true), do: %{state | focused: focused} - defp build_style(opts) when is_map(opts) do - style_list = - opts - |> Enum.map(fn - {:fg, color} -> {:fg, color} - {:bg, color} -> {:bg, color} - {:bold, true} -> {:attrs, [:bold]} - _ -> nil - end) - |> Enum.reject(&is_nil/1) - - Style.new(style_list) - end - - defp build_style(_), do: Style.new() - - # ---------------------------------------------------------------------------- - # Style Helper Functions - # ---------------------------------------------------------------------------- - - @spec positioned_cell_safe(integer(), integer(), String.t(), Style.t()) :: RenderNode.t() - defp positioned_cell_safe(x, y, char, style), - do: positioned_cell(x, y, char, style) - - # ---------------------------------------------------------------------------- - # Utility Functions - # ---------------------------------------------------------------------------- - - defp center_text(text, width) do - len = String.length(text) - - if len >= width do - String.slice(text, 0, width) - else - padding = div(width - len, 2) - text |> String.pad_leading(len + padding) |> String.pad_trailing(width) - end - end + defp press(state), do: {%{state | pressed: false}, [state.message]} end diff --git a/lib/term_ui/widget/canvas.ex b/lib/term_ui/widget/canvas.ex new file mode 100644 index 00000000..8f210c0f --- /dev/null +++ b/lib/term_ui/widget/canvas.ex @@ -0,0 +1,184 @@ +defmodule TermUI.Widget.Canvas do + @moduledoc "A pure character and braille-dot drawing canvas." + + @behaviour TermUI.Widget + + import Bitwise, only: [<<<: 2] + + alias TermUI.{Cell, Frame, Style} + + @dialyzer {:nowarn_function, clear: 1} + + @braille_base 0x2800 + @dot_bits %{ + {0, 0} => 0, + {0, 1} => 1, + {0, 2} => 2, + {1, 0} => 3, + {1, 1} => 4, + {1, 2} => 5, + {0, 3} => 6, + {1, 3} => 7 + } + + @type t :: %__MODULE__{ + width: pos_integer(), + height: pos_integer(), + cells: map(), + dots: MapSet.t({non_neg_integer(), non_neg_integer()}), + style: Style.t() + } + @schema Zoi.struct(__MODULE__, %{ + width: Zoi.integer() |> Zoi.positive() |> Zoi.default(1), + height: Zoi.integer() |> Zoi.positive() |> Zoi.default(1), + cells: Zoi.map() |> Zoi.default(%{}), + dots: Zoi.map_set() |> Zoi.default(MapSet.new()), + style: Zoi.struct(Style) |> Zoi.default(%Style{}) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts), + do: %__MODULE__{ + width: max(Keyword.get(opts, :width, 1), 1), + height: max(Keyword.get(opts, :height, 1), 1), + style: Keyword.get(opts, :style, Style.new()) + } + + @impl true + def update(_event, state), do: {state, []} + + @impl true + def view(state, {width, height}) do + frame = Frame.new(width, height) + + frame = + Enum.reduce(state.cells, frame, fn {{x, y}, cell}, acc -> + Frame.put_cell(acc, y + 1, x + 1, cell) + end) + + Enum.reduce(braille_cells(state.dots), frame, fn {{x, y}, char}, acc -> + Frame.put_cell(acc, y + 1, x + 1, Style.to_cell(state.style, char)) + end) + end + + @doc "Clears characters and braille dots." + @spec clear(t()) :: t() + def clear(state), do: %{state | cells: %{}, dots: MapSet.new()} + + @doc "Writes one zero-based character cell." + @spec set_char(t(), non_neg_integer(), non_neg_integer(), String.t(), Style.t() | nil) :: t() + def set_char(state, x, y, char, style \\ nil) when x >= 0 and y >= 0 do + if x < state.width and y < state.height do + cell = Style.to_cell(style || state.style, char) + + %{ + state + | cells: + if(Cell.empty?(cell), + do: Map.delete(state.cells, {x, y}), + else: Map.put(state.cells, {x, y}, cell) + ) + } + else + state + end + end + + @doc "Draws text from one zero-based position." + @spec draw_text(t(), non_neg_integer(), non_neg_integer(), iodata(), Style.t() | nil) :: t() + def draw_text(state, x, y, text, style \\ nil), + do: + text + |> IO.iodata_to_binary() + |> String.graphemes() + |> Enum.with_index(x) + |> Enum.reduce(state, fn {char, column}, acc -> set_char(acc, column, y, char, style) end) + + @doc "Draws a character line with Bresenham's algorithm." + @spec draw_line(t(), integer(), integer(), integer(), integer(), String.t()) :: t() + def draw_line(state, x0, y0, x1, y1, char \\ "•") do + points(x0, y0, x1, y1) |> Enum.reduce(state, fn {x, y}, acc -> set_char(acc, x, y, char) end) + end + + @doc "Draws a rectangle." + @spec draw_rect(t(), non_neg_integer(), non_neg_integer(), pos_integer(), pos_integer()) :: t() + def draw_rect(state, x, y, width, height) do + state + |> draw_line(x, y, x + width - 1, y, "─") + |> draw_line(x, y + height - 1, x + width - 1, y + height - 1, "─") + |> draw_line(x, y, x, y + height - 1, "│") + |> draw_line(x + width - 1, y, x + width - 1, y + height - 1, "│") + |> set_char(x, y, "┌") + |> set_char(x + width - 1, y, "┐") + |> set_char(x, y + height - 1, "└") + |> set_char(x + width - 1, y + height - 1, "┘") + end + + @doc "Sets one zero-based braille dot." + @spec set_dot(t(), non_neg_integer(), non_neg_integer()) :: t() + def set_dot(state, x, y), do: %{state | dots: MapSet.put(state.dots, {x, y})} + + @doc "Clears one zero-based braille dot." + @spec clear_dot(t(), non_neg_integer(), non_neg_integer()) :: t() + def clear_dot(state, x, y), do: %{state | dots: MapSet.delete(state.dots, {x, y})} + + @doc "Draws a line in braille-dot coordinates." + @spec draw_braille_line(t(), integer(), integer(), integer(), integer()) :: t() + def draw_braille_line(state, x0, y0, x1, y1), + do: + points(x0, y0, x1, y1) + |> Enum.reduce(state, fn {x, y}, acc -> + if x >= 0 and y >= 0, do: set_dot(acc, x, y), else: acc + end) + + @doc "Returns braille-dot dimensions." + @spec braille_resolution(t()) :: {pos_integer(), pos_integer()} + def braille_resolution(state), do: {state.width * 2, state.height * 4} + + @doc "Resizes and clips canvas data." + @spec resize(t(), pos_integer(), pos_integer()) :: t() + def resize(state, width, height) do + cells = Map.filter(state.cells, fn {{x, y}, _cell} -> x < width and y < height end) + + dots = + Enum.reduce(state.dots, MapSet.new(), fn {x, y} = dot, acc -> + if x < width * 2 and y < height * 4, do: MapSet.put(acc, dot), else: acc + end) + + %{state | width: width, height: height, cells: cells, dots: dots} + end + + defp braille_cells(dots) do + Enum.reduce(dots, %{}, fn {x, y}, cells -> + position = {div(x, 2), div(y, 4)} + bit = Map.fetch!(@dot_bits, {rem(x, 2), rem(y, 4)}) + Map.update(cells, position, 1 <<< bit, &Bitwise.bor(&1, 1 <<< bit)) + end) + |> Map.new(fn {position, bits} -> {position, <<@braille_base + bits::utf8>>} end) + end + + defp points(x0, y0, x1, y1), + do: + do_points( + {x0, y0}, + {x1, y1}, + {abs(x1 - x0), if(x0 < x1, do: 1, else: -1), -abs(y1 - y0), if(y0 < y1, do: 1, else: -1)}, + abs(x1 - x0) - abs(y1 - y0), + [] + ) + + defp do_points({x, y}, {x1, y1} = destination, {dx, sx, dy, sy} = steps, error, points) do + points = [{x, y} | points] + + if x == x1 and y == y1 do + Enum.reverse(points) + else + doubled = 2 * error + {x, error} = if doubled >= dy, do: {x + sx, error + dy}, else: {x, error} + {y, error} = if doubled <= dx, do: {y + sy, error + dx}, else: {y, error} + do_points({x, y}, destination, steps, error, points) + end + end +end diff --git a/lib/term_ui/widget/chart_helpers.ex b/lib/term_ui/widget/chart_helpers.ex new file mode 100644 index 00000000..d24032ad --- /dev/null +++ b/lib/term_ui/widget/chart_helpers.ex @@ -0,0 +1,23 @@ +defmodule TermUI.Widget.ChartHelpers do + @moduledoc false + + @spec range([number()], keyword()) :: {number(), number()} + def range(values, opts \\ []) + def range([], _opts), do: {0, 1} + + def range(values, opts) do + minimum = Keyword.get(opts, :min, Enum.min(values)) + maximum = Keyword.get(opts, :max, Enum.max(values)) + if minimum == maximum, do: {minimum, maximum + 1}, else: {minimum, maximum} + end + + @spec normalize(number(), number(), number()) :: float() + def normalize(_value, minimum, maximum) when maximum <= minimum, do: 0.0 + + def normalize(value, minimum, maximum), + do: ((value - minimum) / (maximum - minimum)) |> max(0.0) |> min(1.0) + + @spec number(number()) :: String.t() + def number(value) when is_integer(value), do: Integer.to_string(value) + def number(value) when is_float(value), do: :erlang.float_to_binary(value, decimals: 2) +end diff --git a/lib/term_ui/widget/cluster_dashboard.ex b/lib/term_ui/widget/cluster_dashboard.ex new file mode 100644 index 00000000..0beb2251 --- /dev/null +++ b/lib/term_ui/widget/cluster_dashboard.ex @@ -0,0 +1,78 @@ +defmodule TermUI.Widget.ClusterDashboard do + @moduledoc "A pure cluster snapshot dashboard. It performs no RPC or node monitoring." + + @behaviour TermUI.Widget + + alias TermUI.Event + alias TermUI.Widget.{Table, Tabs} + alias TermUI.Widget.Table.Column + + @type snapshot :: %{ + required(:node) => term(), + optional(:status) => term(), + optional(:processes) => non_neg_integer(), + optional(:memory) => non_neg_integer(), + optional(:uptime) => term() + } + @type t :: %__MODULE__{nodes: [snapshot()], tabs: Tabs.t(), table: Table.t()} + @schema Zoi.struct(__MODULE__, %{ + nodes: Zoi.array() |> Zoi.default([]), + tabs: Zoi.struct(Tabs) |> Zoi.default(%Tabs{}), + table: Zoi.struct(Table) |> Zoi.default(%Table{}) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts) do + nodes = Keyword.get(opts, :nodes, []) + + %__MODULE__{ + nodes: nodes, + tabs: Tabs.init(tabs: [{:nodes, "Nodes"}, {:help, "Help"}], selected: :nodes), + table: table(nodes) + } + end + + @impl true + def update(%Event.Text{text: "r"}, state), do: {state, [:refresh_requested]} + + def update(event, state) do + {tabs, tab_messages} = Tabs.update(event, state.tabs) + {table, table_messages} = Table.update(event, state.table) + {%{state | tabs: tabs, table: table}, tab_messages ++ table_messages} + end + + @impl true + def view(state, {width, height}) do + case Tabs.selected(state.tabs) do + %{id: :help} -> + TermUI.Frame.from_rows( + ["R: request refresh", "Arrows: move", "Enter: select node"], + width, + height + ) + + _tab -> + Table.view(state.table, {width, height}) + end + end + + @doc "Replaces cluster snapshots supplied by the parent." + @spec set_nodes(t(), [snapshot()]) :: t() + def set_nodes(state, nodes), + do: %{state | nodes: nodes, table: Table.set_rows(state.table, nodes)} + + defp table(nodes), + do: + Table.init( + columns: [ + Column.new(:node, "Node"), + Column.new(:status, "Status", width: 10), + Column.new(:processes, "Processes", align: :right), + Column.new(:memory, "Memory", align: :right), + Column.new(:uptime, "Uptime", align: :right) + ], + rows: nodes + ) +end diff --git a/lib/term_ui/widget/command_palette.ex b/lib/term_ui/widget/command_palette.ex new file mode 100644 index 00000000..162d610f --- /dev/null +++ b/lib/term_ui/widget/command_palette.ex @@ -0,0 +1,92 @@ +defmodule TermUI.Widget.CommandPalette do + @moduledoc "A pure searchable command palette." + + @behaviour TermUI.Widget + + alias TermUI.Event + alias TermUI.Widget.{Dialog, PickList} + + @type t :: %__MODULE__{picker: PickList.t(), title: String.t(), visible: boolean()} + @schema Zoi.struct(__MODULE__, %{ + picker: Zoi.struct(PickList) |> Zoi.default(%PickList{}), + title: Zoi.string() |> Zoi.default("Commands"), + visible: Zoi.boolean() |> Zoi.default(false) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts) do + %__MODULE__{ + picker: + PickList.init( + items: Keyword.get(opts, :commands, []), + prompt: Keyword.get(opts, :prompt, "> ") + ), + title: Keyword.get(opts, :title, "Commands"), + visible: Keyword.get(opts, :visible, false) + } + end + + @impl true + def update(_event, %{visible: false} = state), do: {state, []} + + def update(event, state) do + {picker, messages} = PickList.update(event, state.picker) + finish(state, picker, messages) + end + + @impl true + def mouse(_event, %{visible: false} = state, _dimensions), do: {state, []} + + def mouse(%Event.Mouse{x: x, y: y} = event, state, {width, height}) do + if x >= 1 and x < width - 1 and y >= 1 and y < height - 1 do + inner_dimensions = {max(width - 2, 1), max(height - 2, 1)} + local_event = %{event | x: x - 1, y: y - 1} + {picker, messages} = PickList.mouse(local_event, state.picker, inner_dimensions) + finish(state, picker, messages) + else + {state, []} + end + end + + defp finish(state, picker, messages) do + messages = + Enum.map(messages, fn + {:picked, command} -> {:command, command} + message -> message + end) + + visible = :cancel not in messages + {%{state | picker: picker, visible: visible}, messages} + end + + @impl true + def view(%{visible: false}, dimensions), + do: TermUI.Frame.new(elem(dimensions, 0), elem(dimensions, 1)) + + def view(state, dimensions) do + content = + PickList.view( + state.picker, + {max(elem(dimensions, 0) - 2, 1), max(elem(dimensions, 1) - 2, 1)} + ) + + dialog = + Dialog.init( + title: state.title, + content: Enum.map(1..content.height, &TermUI.Frame.row_text(content, &1)), + buttons: [] + ) + + Dialog.view(dialog, dimensions) + end + + @doc "Shows and resets the palette query." + @spec show(t()) :: t() + def show(state), do: %{state | visible: true, picker: %{state.picker | query: "", cursor: 0}} + + @doc "Hides the palette." + @spec hide(t()) :: t() + def hide(state), do: %{state | visible: false} +end diff --git a/lib/term_ui/widget/context_menu.ex b/lib/term_ui/widget/context_menu.ex new file mode 100644 index 00000000..a106e474 --- /dev/null +++ b/lib/term_ui/widget/context_menu.ex @@ -0,0 +1,50 @@ +defmodule TermUI.Widget.ContextMenu do + @moduledoc "A pure positioned context menu built on `TermUI.Widget.Menu`." + + @behaviour TermUI.Widget + + alias TermUI.Widget.Menu + + @type t :: %__MODULE__{menu: Menu.t(), position: {non_neg_integer(), non_neg_integer()}} + @schema Zoi.struct(__MODULE__, %{ + menu: Zoi.struct(Menu) |> Zoi.default(%Menu{}), + position: + Zoi.tuple( + {Zoi.integer() |> Zoi.non_negative(), Zoi.integer() |> Zoi.non_negative()} + ) + |> Zoi.default({0, 0}) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + defdelegate action(id, label, opts \\ []), to: Menu + defdelegate separator(), to: Menu + + @impl true + def init(opts) do + %__MODULE__{menu: Menu.init(opts), position: Keyword.get(opts, :position, {0, 0})} + end + + @impl true + def update(event, state) do + {menu, messages} = Menu.update(event, state.menu) + {%{state | menu: menu}, messages} + end + + @impl true + def mouse(event, state, dimensions) do + {menu, messages} = Menu.mouse(event, state.menu, dimensions) + {%{state | menu: menu}, messages} + end + + @impl true + def view(state, dimensions), do: Menu.view(state.menu, dimensions) + + @doc "Returns the requested zero-based overlay position." + @spec position(t()) :: {non_neg_integer(), non_neg_integer()} + def position(state), do: state.position + + @doc "Moves the context menu." + @spec move_to(t(), {non_neg_integer(), non_neg_integer()}) :: t() + def move_to(state, position), do: %{state | position: position} +end diff --git a/lib/term_ui/widget/dialog.ex b/lib/term_ui/widget/dialog.ex new file mode 100644 index 00000000..d6f6b738 --- /dev/null +++ b/lib/term_ui/widget/dialog.ex @@ -0,0 +1,163 @@ +defmodule TermUI.Widget.Dialog do + @moduledoc "A pure bordered dialog with content and action buttons." + + @behaviour TermUI.Widget + + alias TermUI.{Event, Frame, Style} + alias TermUI.Widget.Helpers + + @type button :: %{ + required(:id) => term(), + required(:label) => String.t(), + required(:message) => term(), + optional(:disabled) => boolean() + } + @type t :: %__MODULE__{ + title: String.t(), + content: [Frame.row()], + buttons: [button()], + focused: non_neg_integer(), + visible: boolean(), + dismiss_message: term() + } + @schema Zoi.struct(__MODULE__, %{ + title: Zoi.string() |> Zoi.default(""), + content: Zoi.array() |> Zoi.default([]), + buttons: Zoi.array() |> Zoi.default([]), + focused: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0), + visible: Zoi.boolean() |> Zoi.default(true), + dismiss_message: Zoi.any() |> Zoi.default(:dismissed) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts) do + %__MODULE__{ + title: opts |> Keyword.get(:title, "") |> to_string(), + content: normalize_content(Keyword.get(opts, :content, "")), + buttons: opts |> Keyword.get(:buttons, []) |> Enum.map(&normalize_button/1), + focused: max(Keyword.get(opts, :focused, 0), 0), + visible: Keyword.get(opts, :visible, true), + dismiss_message: Keyword.get(opts, :dismiss_message, :dismissed) + } + end + + @impl true + def update(_event, %{visible: false} = state), do: {state, []} + + def update(%Event.Key{key: :escape}, state), + do: {%{state | visible: false}, [state.dismiss_message]} + + def update(%Event.Key{key: key}, state) when key in [:left, :up], do: move(state, -1) + def update(%Event.Key{key: key}, state) when key in [:right, :down, :tab], do: move(state, 1) + def update(%Event.Key{key: key}, state) when key in [:enter, :space], do: activate(state) + def update(%Event.Text{text: " "}, state), do: activate(state) + def update(_event, state), do: {state, []} + + @impl true + def mouse(_event, %{visible: false} = state, _dimensions), do: {state, []} + + def mouse(%Event.Mouse{action: action, button: :left, x: x, y: y}, state, {width, height}) + when action in [:press, :release] do + if y == height - 2 do + case button_at(state.buttons, x - 1, width - 2) do + nil -> + {state, []} + + index -> + state = %{state | focused: index} + activate_on_release(action, state) + end + else + {state, []} + end + end + + def mouse(event, state, _dimensions), do: update(event, state) + + @impl true + def view(%{visible: false}, dimensions), do: Helpers.frame([], dimensions) + + def view(state, {width, height} = dimensions) do + body_height = max(height - 3, 0) + button_style = Style.new(fg: :bright_black) + focused_style = Style.new(fg: :black, bg: :cyan, attrs: [:bold]) + + button_row = + state.buttons + |> Enum.with_index() + |> Enum.flat_map(fn {button, index} -> + style = if index == state.focused, do: focused_style, else: button_style + [{"[ " <> button.label <> " ]", style}, " "] + end) + + rows = + Enum.take(state.content, body_height) ++ + List.duplicate("", max(body_height - length(state.content), 0)) ++ [button_row] + + bordered = Helpers.border(rows, dimensions, title: state.title) + Helpers.frame(bordered, {width, height}) + end + + @doc "Shows the dialog." + @spec show(t()) :: t() + def show(state), do: %{state | visible: true} + + @doc "Hides the dialog." + @spec hide(t()) :: t() + def hide(state), do: %{state | visible: false} + + defp move(%{buttons: []} = state, _delta), do: {state, []} + + defp move(state, delta) do + count = length(state.buttons) + {%{state | focused: rem(state.focused + delta + count, count)}, []} + end + + defp activate(state) do + case Enum.at(state.buttons, state.focused) do + %{disabled: true} -> {state, []} + %{message: message} -> {state, [message]} + _other -> {state, []} + end + end + + defp activate_on_release(:release, state), do: activate(state) + defp activate_on_release(:press, state), do: {state, []} + + defp button_at(buttons, x, inner_width) when x >= 0 and x < inner_width do + buttons + |> Enum.with_index() + |> Enum.reduce_while({:after, 0}, fn {button, index}, {:after, start} -> + finish = start + Helpers.text_width("[ " <> button.label <> " ]") + 1 + + if x < finish, + do: {:halt, {:found, index}}, + else: {:cont, {:after, finish}} + end) + |> case do + {:found, index} -> index + {:after, _finish} -> nil + end + end + + defp button_at(_buttons, _x, _inner_width), do: nil + + defp normalize_content(content) when is_binary(content), + do: String.split(content, "\n", trim: false) + + defp normalize_content(content) when is_list(content), do: content + defp normalize_content(content), do: [to_string(content)] + + defp normalize_button(%{id: id, label: label} = button), + do: %{ + id: id, + label: to_string(label), + message: Map.get(button, :message, {:selected, id}), + disabled: Map.get(button, :disabled, false) + } + + defp normalize_button({id, label}), + do: %{id: id, label: to_string(label), message: {:selected, id}, disabled: false} +end diff --git a/lib/term_ui/widget/diff_viewer.ex b/lib/term_ui/widget/diff_viewer.ex new file mode 100644 index 00000000..7d46e523 --- /dev/null +++ b/lib/term_ui/widget/diff_viewer.ex @@ -0,0 +1,446 @@ +defmodule TermUI.Widget.DiffViewer do + @moduledoc """ + A pure scrollable text diff viewer. + + Initialize it with `:before` and `:after` text, or with a prebuilt + `:unified_diff`. The viewer supports `:unified` and `:split` modes. Press + `s` to switch modes. Arrow, Page Up, Page Down, Home, End, and mouse-wheel + events control scrolling. + """ + + @behaviour TermUI.Widget + + @dialyzer {:nowarn_function, split_cell: 4} + + alias TermUI.{Event, Frame, Style} + alias TermUI.Widget.Helpers + + @type row :: %{ + kind: :context | :added | :removed | :changed | :hunk | :header | :fold, + old_number: pos_integer() | nil, + new_number: pos_integer() | nil, + old_text: String.t() | nil, + new_text: String.t() | nil, + text: String.t() | nil + } + + @type t :: %__MODULE__{ + rows: [row()], + mode: :unified | :split, + scroll: non_neg_integer() | :end, + page_size: pos_integer(), + old_label: String.t(), + new_label: String.t(), + context: non_neg_integer() + } + + @schema Zoi.struct(__MODULE__, %{ + rows: Zoi.array() |> Zoi.default([]), + mode: Zoi.enum([:unified, :split]) |> Zoi.default(:unified), + scroll: + Zoi.union([Zoi.integer() |> Zoi.non_negative(), Zoi.literal(:end)]) + |> Zoi.default(0), + page_size: Zoi.integer() |> Zoi.positive() |> Zoi.default(20), + old_label: Zoi.string() |> Zoi.default("before"), + new_label: Zoi.string() |> Zoi.default("after"), + context: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(3) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts) do + maximum = max(Keyword.get(opts, :max_lines, 5_000), 1) + context = max(Keyword.get(opts, :context, 3), 0) + + rows = + case Keyword.fetch(opts, :unified_diff) do + {:ok, diff} -> diff |> to_string() |> parse_unified(maximum) + :error -> compare(Keyword.get(opts, :before, ""), Keyword.get(opts, :after, ""), maximum) + end + |> collapse_context(context) + + %__MODULE__{ + rows: rows, + mode: Keyword.get(opts, :mode, :unified), + page_size: max(Keyword.get(opts, :page_size, 20), 1), + old_label: opts |> Keyword.get(:old_label, "before") |> to_string(), + new_label: opts |> Keyword.get(:new_label, "after") |> to_string(), + context: context + } + end + + @impl true + def update(%Event.Key{key: :up}, state), do: scroll(state, -1) + def update(%Event.Key{key: :down}, state), do: scroll(state, 1) + def update(%Event.Key{key: :page_up}, state), do: scroll(state, -state.page_size) + def update(%Event.Key{key: :page_down}, state), do: scroll(state, state.page_size) + def update(%Event.Key{key: :home}, state), do: {%{state | scroll: 0}, []} + def update(%Event.Key{key: :end}, state), do: {%{state | scroll: :end}, []} + def update(%Event.Text{text: "s"}, state), do: toggle_mode(state) + def update(%Event.Text{text: "u"}, state), do: {%{state | mode: :unified}, [{:mode, :unified}]} + def update(%Event.Mouse{action: :scroll_up}, state), do: scroll(state, -3) + def update(%Event.Mouse{action: :scroll_down}, state), do: scroll(state, 3) + def update(_event, state), do: {state, []} + + @impl true + def view(state, {width, height} = dimensions) do + rendered = + case state.mode do + :split -> split_rows(state, width) + :unified -> unified_rows(state, width) + end + + offset = + if state.scroll == :end, + do: max(length(rendered) - height, 0), + else: min(state.scroll, max(length(rendered) - height, 0)) + + Frame.from_rows( + Enum.slice(rendered, offset, height), + elem(dimensions, 0), + elem(dimensions, 1) + ) + end + + @doc "Builds comparison rows from two texts." + @spec compare(String.t(), String.t(), pos_integer()) :: [row()] + def compare(before, after_text, maximum \\ 5_000) do + before_lines = before |> to_string() |> split_lines(maximum) + after_lines = after_text |> to_string() |> split_lines(maximum) + + before_lines + |> List.myers_difference(after_lines) + |> build_rows(1, 1, []) + |> Enum.reverse() + end + + @doc "Replaces the compared texts." + @spec set_texts(t(), String.t(), String.t(), pos_integer()) :: t() + def set_texts(state, before, after_text, maximum \\ 5_000) do + %{ + state + | rows: compare(before, after_text, maximum) |> collapse_context(state.context), + scroll: 0 + } + end + + defp build_rows([], _old_number, _new_number, rows), do: rows + + defp build_rows([{:eq, lines} | rest], old_number, new_number, rows) do + {rows, old_number, new_number} = + Enum.reduce(lines, {rows, old_number, new_number}, fn line, + {rows, old_number, new_number} -> + {[ + %{ + kind: :context, + old_number: old_number, + new_number: new_number, + old_text: line, + new_text: line, + text: nil + } + | rows + ], old_number + 1, new_number + 1} + end) + + build_rows(rest, old_number, new_number, rows) + end + + defp build_rows([{:del, removed}, {:ins, added} | rest], old_number, new_number, rows) do + {rows, old_number, new_number} = pair_changes(removed, added, old_number, new_number, rows) + build_rows(rest, old_number, new_number, rows) + end + + defp build_rows([{:del, removed} | rest], old_number, new_number, rows) do + {rows, old_number} = + Enum.reduce(removed, {rows, old_number}, fn line, {rows, number} -> + {[ + %{ + kind: :removed, + old_number: number, + new_number: nil, + old_text: line, + new_text: nil, + text: nil + } + | rows + ], number + 1} + end) + + build_rows(rest, old_number, new_number, rows) + end + + defp build_rows([{:ins, added} | rest], old_number, new_number, rows) do + {rows, new_number} = + Enum.reduce(added, {rows, new_number}, fn line, {rows, number} -> + {[ + %{ + kind: :added, + old_number: nil, + new_number: number, + old_text: nil, + new_text: line, + text: nil + } + | rows + ], number + 1} + end) + + build_rows(rest, old_number, new_number, rows) + end + + defp pair_changes(removed, added, old_number, new_number, rows) do + count = max(length(removed), length(added)) + + Enum.reduce(0..(count - 1), {rows, old_number, new_number}, fn index, + {rows, old_number, new_number} -> + old_text = Enum.at(removed, index) + new_text = Enum.at(added, index) + + kind = + cond do + old_text && new_text -> :changed + old_text -> :removed + true -> :added + end + + row = %{ + kind: kind, + old_number: if(old_text, do: old_number), + new_number: if(new_text, do: new_number), + old_text: old_text, + new_text: new_text, + text: nil + } + + {[row | rows], old_number + if(old_text, do: 1, else: 0), + new_number + if(new_text, do: 1, else: 0)} + end) + end + + defp parse_unified(diff, maximum) do + {rows, _old_number, _new_number} = + diff + |> split_lines(maximum) + |> Enum.reduce({[], nil, nil}, fn line, {rows, old_number, new_number} -> + cond do + String.starts_with?(line, "@@") -> + {old_number, new_number} = hunk_numbers(line) + + {rows ++ + [ + %{ + kind: :hunk, + old_number: nil, + new_number: nil, + old_text: nil, + new_text: nil, + text: line + } + ], old_number, new_number} + + String.starts_with?(line, ["---", "+++"]) -> + {rows ++ + [ + %{ + kind: :header, + old_number: nil, + new_number: nil, + old_text: nil, + new_text: nil, + text: line + } + ], old_number, new_number} + + String.starts_with?(line, "+") -> + row = %{ + kind: :added, + old_number: nil, + new_number: new_number, + old_text: nil, + new_text: String.trim_leading(line, "+"), + text: nil + } + + {rows ++ [row], old_number, increment(new_number)} + + String.starts_with?(line, "-") -> + row = %{ + kind: :removed, + old_number: old_number, + new_number: nil, + old_text: String.trim_leading(line, "-"), + new_text: nil, + text: nil + } + + {rows ++ [row], increment(old_number), new_number} + + true -> + text = String.trim_leading(line, " ") + + row = %{ + kind: :context, + old_number: old_number, + new_number: new_number, + old_text: text, + new_text: text, + text: nil + } + + {rows ++ [row], increment(old_number), increment(new_number)} + end + end) + + rows + end + + defp unified_rows(state, width) do + header = [ + [{"--- " <> state.old_label, Style.new(fg: :red, attrs: [:bold])}], + [{"+++ " <> state.new_label, Style.new(fg: :green, attrs: [:bold])}] + ] + + body = + Enum.flat_map(state.rows, fn row -> + case row.kind do + :changed -> + [ + unified_line(row.old_number, nil, "-", row.old_text, :removed, width), + unified_line(nil, row.new_number, "+", row.new_text, :added, width) + ] + + :removed -> + [unified_line(row.old_number, nil, "-", row.old_text, :removed, width)] + + :added -> + [unified_line(nil, row.new_number, "+", row.new_text, :added, width)] + + :context -> + [unified_line(row.old_number, row.new_number, " ", row.old_text, :context, width)] + + kind when kind in [:hunk, :header, :fold] -> + [[{Frame.fit(row.text, width), row_style(kind)}]] + end + end) + + header ++ body + end + + defp split_rows(state, width) do + left_width = max(div(width - 1, 2), 1) + right_width = max(width - left_width - 1, 1) + header_style = Style.new(fg: :cyan, attrs: [:bold]) + + header = [ + [ + {Frame.fit(state.old_label, left_width), header_style}, + {"│", Style.new(fg: :bright_black)}, + {Frame.fit(state.new_label, right_width), header_style} + ] + ] + + body = + Enum.map(state.rows, fn row -> + if row.kind in [:hunk, :header, :fold] do + [{Frame.fit(row.text, width), row_style(row.kind)}] + else + left = + split_cell( + row.old_number, + row.old_text, + left_width, + (row.kind in [:removed, :changed] && :removed) || :context + ) + + right = + split_cell( + row.new_number, + row.new_text, + right_width, + (row.kind in [:added, :changed] && :added) || :context + ) + + left ++ [{"│", Style.new(fg: :bright_black)}] ++ right + end + end) + + header ++ body + end + + defp unified_line(old_number, new_number, marker, text, kind, width) do + number = number(old_number, 4) <> " " <> number(new_number, 4) <> " " + + [[{number, Style.new(fg: :bright_black)}, {marker <> (text || ""), row_style(kind)}]] + |> List.first() + |> Helpers.fit_row(width) + end + + defp split_cell(number_value, text, width, kind) do + prefix = number(number_value, 4) <> " " + + Helpers.fit_row( + [{prefix, Style.new(fg: :bright_black)}, {text || "", row_style(kind)}], + width + ) + end + + defp collapse_context(rows, context) do + rows + |> Enum.chunk_by(&(&1.kind == :context)) + |> Enum.flat_map(fn chunk -> + if chunk != [] and hd(chunk).kind == :context and length(chunk) > context * 2 + 1 do + Enum.take(chunk, context) ++ + [ + %{ + kind: :fold, + old_number: nil, + new_number: nil, + old_text: nil, + new_text: nil, + text: "… #{length(chunk) - context * 2} unchanged lines …" + } + ] ++ Enum.take(chunk, -context) + else + chunk + end + end) + end + + defp toggle_mode(state) do + mode = if state.mode == :unified, do: :split, else: :unified + {%{state | mode: mode, scroll: 0}, [{:mode, mode}]} + end + + defp scroll(state, delta) do + scroll = if state.scroll == :end, do: length(state.rows), else: state.scroll + scroll = max(scroll + delta, 0) + {%{state | scroll: scroll}, [{:scrolled, scroll}]} + end + + defp row_style(:added), do: Style.new(fg: :green) + defp row_style(:removed), do: Style.new(fg: :red) + defp row_style(:hunk), do: Style.new(fg: :cyan) + defp row_style(:header), do: Style.new(attrs: [:bold]) + defp row_style(:fold), do: Style.new(fg: :bright_black, attrs: [:italic]) + defp row_style(_kind), do: Style.new() + defp number(nil, width), do: String.duplicate(" ", width) + defp number(value, width), do: value |> Integer.to_string() |> String.pad_leading(width) + + defp split_lines(text, maximum), + do: text |> String.split("\n", trim: false) |> Enum.take(maximum) + + defp increment(nil), do: nil + defp increment(number), do: number + 1 + + defp hunk_numbers(line) do + case Regex.run(~r/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/, line) do + [_all, old_number, new_number] -> + {String.to_integer(old_number), String.to_integer(new_number)} + + _match -> + {nil, nil} + end + end +end diff --git a/lib/term_ui/widget/form_builder.ex b/lib/term_ui/widget/form_builder.ex new file mode 100644 index 00000000..c559d54b --- /dev/null +++ b/lib/term_ui/widget/form_builder.ex @@ -0,0 +1,225 @@ +defmodule TermUI.Widget.FormBuilder do + @moduledoc "A pure form with text, checkbox, and select fields." + + @behaviour TermUI.Widget + + alias TermUI.{Event, Style} + alias TermUI.Widget.Helpers + + @type field :: %{ + required(:id) => term(), + required(:label) => String.t(), + required(:type) => :text | :checkbox | :select, + optional(:options) => [term()], + optional(:required) => boolean() + } + @type t :: %__MODULE__{ + fields: [field()], + values: map(), + active: non_neg_integer(), + errors: map(), + submit_label: String.t() + } + @schema Zoi.struct(__MODULE__, %{ + fields: Zoi.array() |> Zoi.default([]), + values: Zoi.map() |> Zoi.default(%{}), + active: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0), + errors: Zoi.map() |> Zoi.default(%{}), + submit_label: Zoi.string() |> Zoi.default("Submit") + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts) do + fields = opts |> Keyword.get(:fields, []) |> Enum.map(&normalize_field/1) + + defaults = + Map.new(fields, fn field -> {field.id, Map.get(field, :default, default_value(field))} end) + + %__MODULE__{ + fields: fields, + values: Map.merge(defaults, Keyword.get(opts, :values, %{})), + submit_label: Keyword.get(opts, :submit_label, "Submit") + } + end + + @impl true + def update(%Event.Key{key: :tab}, state), do: move(state, 1) + def update(%Event.Key{key: :up}, state), do: move(state, -1) + def update(%Event.Key{key: :down}, state), do: move(state, 1) + def update(%Event.Key{key: :space}, state), do: toggle_or_cycle(state) + def update(%Event.Text{text: " "}, state), do: toggle_or_insert_space(state) + def update(%Event.Key{key: :left}, state), do: cycle(state, -1) + def update(%Event.Key{key: :right}, state), do: cycle(state, 1) + def update(%Event.Key{key: :backspace}, state), do: edit_text(state, &drop_last/1) + def update(%Event.Text{text: text}, state), do: edit_text(state, &(&1 <> clean(text))) + def update(%Event.Paste{content: text}, state), do: edit_text(state, &(&1 <> clean(text))) + + def update(%Event.Key{key: :enter}, state) do + case validate(state) do + %{errors: errors} = checked when map_size(errors) == 0 -> + {checked, [{:submit, checked.values}]} + + checked -> + {checked, [{:invalid, checked.errors}]} + end + end + + def update(_event, state), do: {state, []} + + @impl true + def mouse(%Event.Mouse{action: action, button: :left, y: y}, state, {_width, height}) + when action in [:press, :release] do + case field_at(state, y, height) do + nil -> + {state, []} + + index -> + state = %{state | active: index} + if action == :release, do: activate_field(state), else: {state, []} + end + end + + def mouse(event, state, _dimensions), do: update(event, state) + + @impl true + def view(state, {_width, height} = dimensions) do + label_style = Style.new(fg: :cyan) + active_style = Style.new(attrs: [:reverse]) + error_style = Style.new(fg: :red) + + rows = + state.fields + |> Enum.with_index() + |> Enum.flat_map(fn {field, index} -> + value = Map.get(state.values, field.id) + value_text = render_value(field, value) + style = if index == state.active, do: active_style, else: Style.new() + row = [{field.label <> ": ", label_style}, {value_text, style}] + + case Map.get(state.errors, field.id) do + nil -> [row] + error -> [row, [{" " <> error, error_style}]] + end + end) + + Helpers.frame(Enum.take(rows, height), dimensions) + end + + @doc "Validates required fields and returns updated error data." + @spec validate(t()) :: t() + def validate(state) do + errors = + Enum.reduce(state.fields, %{}, fn field, errors -> + value = Map.get(state.values, field.id) + + if Map.get(field, :required, false) and value in [nil, "", false], + do: Map.put(errors, field.id, "is required"), + else: errors + end) + + %{state | errors: errors} + end + + @doc "Sets one field value." + @spec put_value(t(), term(), term()) :: t() + def put_value(state, id, value), do: %{state | values: Map.put(state.values, id, value)} + + defp move(%{fields: []} = state, _delta), do: {state, []} + + defp move(state, delta), + do: + {%{state | active: rem(state.active + delta + length(state.fields), length(state.fields))}, + []} + + defp toggle_or_cycle(state) do + case Enum.at(state.fields, state.active) do + %{type: :checkbox, id: id} -> changed(state, id, not Map.get(state.values, id, false)) + %{type: :select} -> cycle(state, 1) + _field -> {state, []} + end + end + + defp toggle_or_insert_space(state) do + case Enum.at(state.fields, state.active) do + %{type: type} when type in [:checkbox, :select] -> toggle_or_cycle(state) + _field -> edit_text(state, &(&1 <> " ")) + end + end + + defp activate_field(state) do + case Enum.at(state.fields, state.active) do + %{type: type} when type in [:checkbox, :select] -> toggle_or_cycle(state) + _field -> {state, []} + end + end + + defp field_at(state, y, height) when y >= 0 and y < height do + state.fields + |> Enum.with_index() + |> Enum.reduce_while(0, fn {field, index}, row -> + next_row = row + if(Map.has_key?(state.errors, field.id), do: 2, else: 1) + + if y == row, + do: {:halt, {:found, index}}, + else: {:cont, next_row} + end) + |> case do + {:found, index} -> index + _row -> nil + end + end + + defp field_at(_state, _y, _height), do: nil + + defp cycle(state, delta) do + case Enum.at(state.fields, state.active) do + %{type: :select, id: id, options: options} when options != [] -> + current = Enum.find_index(options, &(&1 == Map.get(state.values, id))) || 0 + + changed( + state, + id, + Enum.at(options, rem(current + delta + length(options), length(options))) + ) + + _field -> + {state, []} + end + end + + defp edit_text(state, fun) do + case Enum.at(state.fields, state.active) do + %{type: :text, id: id} -> changed(state, id, fun.(to_string(Map.get(state.values, id, "")))) + _field -> {state, []} + end + end + + defp changed(state, id, value), + do: + {%{state | values: Map.put(state.values, id, value), errors: Map.delete(state.errors, id)}, + [{:changed, id, value}]} + + defp render_value(%{type: :checkbox}, true), do: "[x]" + defp render_value(%{type: :checkbox}, _value), do: "[ ]" + defp render_value(%{type: :select}, value), do: "‹ " <> to_string(value || "") <> " ›" + defp render_value(_field, value), do: to_string(value || "") + defp default_value(%{type: :checkbox}), do: false + defp default_value(%{type: :select, options: [first | _]}), do: first + defp default_value(_field), do: "" + + defp normalize_field(%{id: id, label: label} = field), + do: + field + |> Map.put(:id, id) + |> Map.put(:label, to_string(label)) + |> Map.put_new(:type, :text) + |> Map.put_new(:options, []) + + defp normalize_field({id, label}), + do: %{id: id, label: to_string(label), type: :text, options: []} + + defp clean(text), do: String.replace(text, ~r/[\x00-\x1F\x7F]/u, "") + defp drop_last(text), do: text |> String.graphemes() |> Enum.drop(-1) |> Enum.join() +end diff --git a/lib/term_ui/widget/gauge.ex b/lib/term_ui/widget/gauge.ex new file mode 100644 index 00000000..b34ed4f4 --- /dev/null +++ b/lib/term_ui/widget/gauge.ex @@ -0,0 +1,83 @@ +defmodule TermUI.Widget.Gauge do + @moduledoc "A pure horizontal or vertical value gauge." + + @behaviour TermUI.Widget + + alias TermUI.Style + alias TermUI.Widget.{ChartHelpers, Helpers} + + @type t :: %__MODULE__{ + value: number(), + minimum: number(), + maximum: number(), + label: String.t() | nil, + orientation: :horizontal | :vertical, + zones: [{number(), atom()}] + } + @schema Zoi.struct(__MODULE__, %{ + value: Zoi.number() |> Zoi.default(0), + minimum: Zoi.number() |> Zoi.default(0), + maximum: Zoi.number() |> Zoi.default(100), + label: Zoi.any() |> Zoi.default(nil), + orientation: Zoi.enum([:horizontal, :vertical]) |> Zoi.default(:horizontal), + zones: Zoi.array() |> Zoi.default([{0.8, :red}, {0.6, :yellow}, {0.0, :green}]) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts), + do: %__MODULE__{ + value: Keyword.get(opts, :value, 0), + minimum: Keyword.get(opts, :min, 0), + maximum: Keyword.get(opts, :max, 100), + label: Keyword.get(opts, :label), + orientation: Keyword.get(opts, :orientation, :horizontal), + zones: Keyword.get(opts, :zones, [{0.8, :red}, {0.6, :yellow}, {0.0, :green}]) + } + + @impl true + def update(_event, state), do: {state, []} + + @impl true + def view(%{orientation: :vertical} = state, {width, height} = dimensions) do + ratio = ratio(state) + fill = round(height * ratio) + style = Style.new(fg: color(state, ratio)) + + rows = + for row <- 1..height, + do: [{if(row > height - fill, do: "█", else: "░") |> String.duplicate(width), style}] + + Helpers.frame(rows, dimensions) + end + + def view(state, {width, _height} = dimensions) do + ratio = ratio(state) + label = if state.label, do: state.label <> " ", else: "" + value = " " <> ChartHelpers.number(state.value) + bar_width = max(width - String.length(label <> value), 1) + fill = round(bar_width * ratio) + style = Style.new(fg: color(state, ratio)) + + row = [ + label, + {String.duplicate("█", fill) <> String.duplicate("░", max(bar_width - fill, 0)), style}, + value + ] + + Helpers.frame([row], dimensions) + end + + @doc "Sets the gauge value." + @spec set_value(t(), number()) :: t() + def set_value(state, value), do: %{state | value: value} + + defp ratio(state), do: ChartHelpers.normalize(state.value, state.minimum, state.maximum) + + defp color(state, ratio), + do: + state.zones + |> Enum.sort_by(&elem(&1, 0), :desc) + |> Enum.find_value(:green, fn {threshold, color} -> if ratio >= threshold, do: color end) +end diff --git a/lib/term_ui/widget/helpers.ex b/lib/term_ui/widget/helpers.ex new file mode 100644 index 00000000..ecc9bc49 --- /dev/null +++ b/lib/term_ui/widget/helpers.ex @@ -0,0 +1,129 @@ +defmodule TermUI.Widget.Helpers do + @moduledoc false + + alias TermUI.{DisplayWidth, Frame, Style} + + @spec frame([Frame.row()], TermUI.Widget.dimensions(), keyword()) :: Frame.t() + def frame(rows, {width, height}, opts \\ []) do + Frame.from_rows(rows, width, height, opts) + end + + @spec clamp(integer(), integer(), integer()) :: integer() + def clamp(value, minimum, maximum), do: value |> max(minimum) |> min(maximum) + + @spec text_width(iodata()) :: non_neg_integer() + def text_width(text), do: max(DisplayWidth.width(IO.iodata_to_binary(text)), 0) + + @spec align(iodata(), non_neg_integer(), :left | :center | :right) :: String.t() + def align(_text, 0, _alignment), do: "" + + def align(text, width, alignment) do + text = Frame.fit(text, width) + used = text |> String.trim_trailing() |> DisplayWidth.width() |> max(0) + room = max(width - used, 0) + + case alignment do + :right -> String.duplicate(" ", room) <> String.trim_trailing(text) + :center -> String.duplicate(" ", div(room, 2)) <> String.trim_trailing(text) + :left -> text + end + |> Frame.fit(width) + end + + @spec border([Frame.row()], TermUI.Widget.dimensions(), keyword()) :: [Frame.row()] + def border(rows, dimensions, opts \\ []) + + def border(rows, {width, height}, opts) when width > 1 and height > 1 do + title = Keyword.get(opts, :title) + border_style = Keyword.get(opts, :border_style, Style.new(fg: :bright_black)) + + border = + Keyword.get(opts, :border, %{ + top_left: "┌", + top_right: "┐", + bottom_left: "└", + bottom_right: "┘", + horizontal: "─", + vertical: "│" + }) + + inner_width = max(width - 2, 0) + inner_height = max(height - 2, 0) + + top_text = + case title do + nil -> + String.duplicate(border.horizontal, inner_width) + + text -> + label = " #{text} " + label <> String.duplicate(border.horizontal, max(inner_width - text_width(label), 0)) + end + + top = [ + {border.top_left <> Frame.fit(top_text, inner_width) <> border.top_right, border_style} + ] + + bottom = [ + {border.bottom_left <> + String.duplicate(border.horizontal, inner_width) <> border.bottom_right, border_style} + ] + + body = + rows + |> Enum.take(inner_height) + |> then(&(&1 ++ List.duplicate("", max(inner_height - length(&1), 0)))) + |> Enum.map(fn row -> + [{border.vertical, border_style}] ++ + fit_row(row, inner_width) ++ [{border.vertical, border_style}] + end) + + [top | body] ++ [bottom] + end + + def border(rows, _dimensions, _opts), do: rows + + @spec normalize_row(Frame.row()) :: [Frame.span()] + def normalize_row(row) when is_binary(row), do: [row] + def normalize_row(row) when is_list(row), do: row + def normalize_row(other), do: [to_string(other)] + + @spec fit_row(Frame.row(), non_neg_integer()) :: [Frame.span()] + def fit_row(_row, 0), do: [] + + def fit_row(row, width) do + {spans, used} = + row + |> normalize_row() + |> Enum.reduce_while({[], 0}, fn span, {rendered, used} -> + fit_span(span, rendered, used, width - used) + end) + + Enum.reverse(spans) ++ [String.duplicate(" ", max(width - used, 0))] + end + + defp fit_span(_span, rendered, used, remaining) when remaining <= 0, + do: {:halt, {rendered, used}} + + defp fit_span(span, rendered, used, remaining) do + {text, style} = split_span(span) + {visible, visible_width} = DisplayWidth.truncate(IO.iodata_to_binary(text), remaining) + rendered_span = if style, do: {visible, style}, else: visible + {:cont, {[rendered_span | rendered], used + visible_width}} + end + + @spec page([term()], non_neg_integer(), non_neg_integer()) :: [term()] + def page(items, offset, height), do: Enum.slice(items, max(offset, 0), max(height, 0)) + + @spec max_scroll(non_neg_integer(), non_neg_integer()) :: non_neg_integer() + def max_scroll(content_height, viewport_height), do: max(content_height - viewport_height, 0) + + @spec scroll(non_neg_integer(), integer(), non_neg_integer(), non_neg_integer()) :: + non_neg_integer() + def scroll(offset, delta, content_height, viewport_height) do + clamp(offset + delta, 0, max_scroll(content_height, viewport_height)) + end + + defp split_span({text, %Style{} = style}), do: {text, style} + defp split_span(text), do: {text, nil} +end diff --git a/lib/term_ui/widget/label.ex b/lib/term_ui/widget/label.ex index 1690b1e8..aac51e6b 100644 --- a/lib/term_ui/widget/label.ex +++ b/lib/term_ui/widget/label.ex @@ -1,170 +1,50 @@ defmodule TermUI.Widget.Label do - @moduledoc """ - A stateless widget for displaying text. + @moduledoc "A pure text label with wrapping, alignment, and style." + + @behaviour TermUI.Widget + + alias TermUI.{Frame, Style} + alias TermUI.Widget.Helpers + + @type t :: %__MODULE__{ + text: String.t(), + align: :left | :center | :right, + wrap: boolean(), + style: Style.t() + } + @schema Zoi.struct(__MODULE__, %{ + text: Zoi.string() |> Zoi.default(""), + align: Zoi.enum([:left, :center, :right]) |> Zoi.default(:left), + wrap: Zoi.boolean() |> Zoi.default(true), + style: Zoi.struct(Style) |> Zoi.default(%Style{}) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) - Label is the simplest widget - it renders text with optional styling, - alignment, wrapping, and truncation. - - ## Usage - - Label.render(%{text: "Hello, World!"}, area) - - Label.render(%{ - text: "Centered text", - align: :center, - style: %{fg: :blue, bold: true} - }, area) - - ## Props - - - `:text` - The text to display (required) - - `:align` - Text alignment: `:left`, `:center`, `:right` (default: `:left`) - - `:wrap` - Whether to wrap text (default: `false`) - - `:truncate` - Whether to truncate with ellipsis (default: `true`) - - `:style` - Style options (fg, bg, bold, etc.) - """ - - use TermUI.Component - - alias TermUI.Component.RenderNode - alias TermUI.Renderer.Style - - # Dialyzer: Suppress opaque type warnings for Style helpers - # no_opaque: Style contains MapSet which triggers false positive call_without_opaque warnings - @dialyzer [:no_opaque, nowarn_function: [build_style: 1, positioned_cell_safe: 4, describe: 0]] - - @doc """ - Renders the label text within the given area. - """ @impl true - def render(props, area) do - text = Map.get(props, :text, "") - align = Map.get(props, :align, :left) - wrap = Map.get(props, :wrap, false) - truncate = Map.get(props, :truncate, true) - style_opts = Map.get(props, :style, %{}) - - style = build_style(style_opts) - - lines = - if wrap do - wrap_text(text, area.width) - else - [text] - end - - cells = - lines - |> Enum.with_index() - |> Enum.flat_map(fn {line, y} -> - if y < area.height do - render_line(line, y, area.width, align, truncate, style) - else - [] - end - end) - - RenderNode.cells(cells) + def init(opts) do + %__MODULE__{ + text: opts |> Keyword.get(:text, "") |> to_string(), + align: Keyword.get(opts, :align, :left), + wrap: Keyword.get(opts, :wrap, true), + style: Keyword.get(opts, :style, Style.new()) + } end - @doc """ - Returns a description of this component. - """ @impl true - def describe do - "Label widget for displaying text" - end - - # Private Functions - - defp build_style(opts) when is_map(opts) do - style_list = - opts - |> Enum.map(fn - {:fg, color} -> {:fg, color} - {:bg, color} -> {:bg, color} - {:bold, true} -> {:attrs, [:bold]} - {:italic, true} -> {:attrs, [:italic]} - {:underline, true} -> {:attrs, [:underline]} - _ -> nil - end) - |> Enum.reject(&is_nil/1) - - Style.new(style_list) - end + def update(_event, state), do: {state, []} - defp build_style(_), do: Style.new() - - # ---------------------------------------------------------------------------- - # Style Helper Functions - # ---------------------------------------------------------------------------- - - @spec positioned_cell_safe(integer(), integer(), String.t(), Style.t()) :: RenderNode.t() - defp positioned_cell_safe(x, y, char, style), - do: positioned_cell(x, y, char, style) - - # ---------------------------------------------------------------------------- - # Rendering - # ---------------------------------------------------------------------------- - - defp render_line(text, y, width, align, truncate, style) do - # Process the text for display - display_text = - if truncate && String.length(text) > width do - do_truncate(text, width) - else - text - end - - # Align the text - aligned = align_text(display_text, width, align) - - # Create cells for each character - aligned - |> String.graphemes() - |> Enum.with_index() - |> Enum.map(fn {char, x} -> - positioned_cell_safe(x, y, char, style) - end) - end - - defp do_truncate(text, width) when width <= 3 do - String.slice(text, 0, width) - end - - defp do_truncate(text, width) do - if String.length(text) > width do - String.slice(text, 0, width - 1) <> "…" - else - text - end - end - - defp align_text(text, width, :left) do - String.pad_trailing(text, width) - end - - defp align_text(text, width, :right) do - String.pad_leading(text, width) - end + @impl true + def view(state, {width, _height} = dimensions) do + lines = if state.wrap, do: Frame.wrap(state.text, width), else: [state.text] - defp align_text(text, width, :center) do - len = String.length(text) + rows = + Enum.map(lines, fn line -> [{Helpers.align(line, width, state.align), state.style}] end) - if len >= width do - text - else - padding = div(width - len, 2) - text |> String.pad_leading(len + padding) |> String.pad_trailing(width) - end + Helpers.frame(rows, dimensions) end - defp wrap_text(text, width) when width <= 0, do: [text] - - defp wrap_text(text, width) do - text - |> String.graphemes() - |> Enum.chunk_every(width) - |> Enum.map(&Enum.join/1) - end + @doc "Replaces the label text." + @spec set_text(t(), iodata()) :: t() + def set_text(state, text), do: %{state | text: IO.iodata_to_binary(text)} end diff --git a/lib/term_ui/widget/line_chart.ex b/lib/term_ui/widget/line_chart.ex new file mode 100644 index 00000000..c5b9a1e9 --- /dev/null +++ b/lib/term_ui/widget/line_chart.ex @@ -0,0 +1,92 @@ +defmodule TermUI.Widget.LineChart do + @moduledoc "A pure line chart rendered on a character canvas." + + @behaviour TermUI.Widget + + alias TermUI.Widget.{Canvas, ChartHelpers} + + @type t :: %__MODULE__{ + series: [[number()]], + colors: [atom()], + minimum: number() | nil, + maximum: number() | nil + } + @schema Zoi.struct(__MODULE__, %{ + series: Zoi.array() |> Zoi.default([]), + colors: Zoi.array(Zoi.atom()) |> Zoi.default([:cyan, :green, :yellow, :magenta]), + minimum: Zoi.any() |> Zoi.default(nil), + maximum: Zoi.any() |> Zoi.default(nil) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts) do + series = + case Keyword.get(opts, :series, []) do + [] -> [] + [first | _] = values when is_number(first) -> [values] + series -> series + end + + colors = + case Keyword.get(opts, :colors, [:cyan, :green, :yellow, :magenta]) do + [] -> [:cyan] + colors -> colors + end + + %__MODULE__{ + series: series, + colors: colors, + minimum: Keyword.get(opts, :min), + maximum: Keyword.get(opts, :max) + } + end + + @impl true + def update(_event, state), do: {state, []} + + @impl true + def view(state, {width, height} = dimensions) do + values = List.flatten(state.series) + + {minimum, maximum} = + ChartHelpers.range(values, + min: state.minimum || Enum.min(values, fn -> 0 end), + max: state.maximum || Enum.max(values, fn -> 1 end) + ) + + canvas = Canvas.init(width: width, height: height) + + canvas = + state.series + |> Enum.with_index() + |> Enum.reduce(canvas, fn {series, series_index}, canvas -> + color = Enum.at(state.colors, rem(series_index, length(state.colors))) + style = TermUI.Style.new(fg: color) + + points = + series + |> Enum.take(-width) + |> Enum.with_index() + |> Enum.map(fn {value, x} -> + {x, + height - 1 - + round(ChartHelpers.normalize(value, minimum, maximum) * max(height - 1, 0))} + end) + + canvas = + Enum.reduce(points, %{canvas | style: style}, fn {x, y}, acc -> + Canvas.set_char(acc, x, y, "•") + end) + + points + |> Enum.chunk_every(2, 1, :discard) + |> Enum.reduce(canvas, fn [{x0, y0}, {x1, y1}], acc -> + Canvas.draw_line(acc, x0, y0, x1, y1, "•") + end) + end) + + Canvas.view(canvas, dimensions) + end +end diff --git a/lib/term_ui/widget/line_input.ex b/lib/term_ui/widget/line_input.ex new file mode 100644 index 00000000..d6c8d527 --- /dev/null +++ b/lib/term_ui/widget/line_input.ex @@ -0,0 +1,98 @@ +defmodule TermUI.Widget.LineInput do + @moduledoc "A labeled and validated pure single-line input." + + @behaviour TermUI.Widget + + alias TermUI.{DisplayWidth, Event, Frame, Style} + alias TermUI.Widget.TextInput + + @type t :: %__MODULE__{ + label: String.t() | nil, + prompt: String.t(), + input: TextInput.t(), + validator: (String.t() -> :ok | {:error, String.t()}) | nil, + error: String.t() | nil + } + @schema Zoi.struct(__MODULE__, %{ + label: Zoi.any() |> Zoi.default(nil), + prompt: Zoi.string() |> Zoi.default("> "), + input: Zoi.struct(TextInput) |> Zoi.default(%TextInput{}), + validator: Zoi.any() |> Zoi.default(nil), + error: Zoi.any() |> Zoi.default(nil) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts), + do: %__MODULE__{ + label: Keyword.get(opts, :label), + prompt: Keyword.get(opts, :prompt, "> "), + input: TextInput.init(opts), + validator: Keyword.get(opts, :validator) + } + + @impl true + def update(event, state) do + {input, messages} = TextInput.update(event, state.input) + + state = %{ + state + | input: input, + error: if(Enum.any?(messages, &match?({:changed, _}, &1)), do: nil, else: state.error) + } + + if Enum.any?(messages, &match?({:submit, _}, &1)) do + validate(state) + else + {state, messages} + end + end + + @impl true + def mouse(%Event.Mouse{y: y} = event, state, {width, _height}) do + input_row = if state.label, do: 1, else: 0 + + if y == input_row do + prompt_width = max(DisplayWidth.width(state.prompt), 0) + input_width = max(width - prompt_width, 1) + local_event = %{event | x: max(event.x - prompt_width, 0), y: 0} + {input, messages} = TextInput.mouse(local_event, state.input, {input_width, 1}) + {%{state | input: input}, messages} + else + update(event, state) + end + end + + @impl true + def view(state, {width, height}) do + label_rows = + if state.label, do: [[{state.label, Style.new(fg: :cyan, attrs: [:bold])}]], else: [] + + label_height = length(label_rows) + prompt_width = max(DisplayWidth.width(state.prompt), 0) + input_width = max(width - prompt_width, 1) + {spans, cursor} = TextInput.row_spans(state.input, input_width) + input_row = [state.prompt | List.wrap(spans)] + + error_rows = + if state.error && height > label_height + 1, + do: [[{" " <> state.error, Style.new(fg: :red)}]], + else: [] + + Frame.from_rows(label_rows ++ [input_row] ++ error_rows, width, height, + cursor: {prompt_width + cursor, label_height + 1} + ) + end + + @doc "Validates the current value." + @spec validate(t()) :: {t(), [term()]} + def validate(%{validator: nil} = state), do: {state, [{:submit, state.input.value}]} + + def validate(state) do + case state.validator.(state.input.value) do + :ok -> {%{state | error: nil}, [{:submit, state.input.value}]} + {:error, error} -> {%{state | error: error}, [{:invalid, error}]} + end + end +end diff --git a/lib/term_ui/widget/list.ex b/lib/term_ui/widget/list.ex index 3b2cd09c..f35b066b 100644 --- a/lib/term_ui/widget/list.ex +++ b/lib/term_ui/widget/list.ex @@ -1,251 +1,170 @@ defmodule TermUI.Widget.List do - @moduledoc """ - A scrollable list widget with selection support. + @moduledoc "A pure, scrollable item list with single or multiple selection." + + @behaviour TermUI.Widget + + alias TermUI.{Event, Style} + alias TermUI.Widget.Helpers + + @dialyzer {:nowarn_function, set_items: 2} + + @type item :: term() + @type t :: %__MODULE__{ + items: [item()], + cursor: non_neg_integer(), + offset: non_neg_integer(), + selected: MapSet.t(non_neg_integer()), + mode: :single | :multiple, + page_size: pos_integer(), + marker: String.t(), + style: Style.t(), + cursor_style: Style.t(), + selected_style: Style.t() + } + + @schema Zoi.struct(__MODULE__, %{ + items: Zoi.array() |> Zoi.default([]), + cursor: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0), + offset: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0), + selected: Zoi.map_set() |> Zoi.default(MapSet.new()), + mode: Zoi.enum([:single, :multiple]) |> Zoi.default(:single), + page_size: Zoi.integer() |> Zoi.positive() |> Zoi.default(10), + marker: Zoi.string() |> Zoi.default("> "), + style: Zoi.struct(Style) |> Zoi.default(%Style{}), + cursor_style: + Zoi.struct(Style) + |> Zoi.default(%Style{fg: :cyan, attrs: MapSet.new([:bold])}), + selected_style: Zoi.struct(Style) |> Zoi.default(%Style{fg: :green}) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) - List displays items and allows navigation with arrow keys. - Supports single and multi-select modes. - - ## Usage - - List.render(%{ - items: ["Apple", "Banana", "Cherry"], - on_select: fn item -> IO.puts("Selected: \#{item}") end - }, state, area) - - ## Props - - - `:items` - List of items to display (required) - - `:on_select` - Callback when selection changes - - `:multi_select` - Enable multi-select mode (default: `false`) - - `:highlight_style` - Style for selected items - - `:style` - Default item style - """ - - use TermUI.StatefulComponent - - alias TermUI.Component.RenderNode - alias TermUI.Event - alias TermUI.Renderer.Style - - # Dialyzer: Suppress opaque type warnings for Style helpers - # no_opaque: Style contains MapSet which triggers false positive call_without_opaque warnings - @dialyzer [:no_opaque, nowarn_function: [build_style: 1, positioned_cell_safe: 4]] - - @doc """ - Initializes the list state. - """ @impl true - def init(props) do - items = Map.get(props, :items, []) - - state = %{ - selected_index: 0, - selected_indices: MapSet.new(), - scroll_offset: 0, - item_count: length(items), - props: props + def init(opts) do + %__MODULE__{ + items: Keyword.get(opts, :items, []), + cursor: max(Keyword.get(opts, :cursor, 0), 0), + mode: Keyword.get(opts, :mode, :single), + page_size: max(Keyword.get(opts, :page_size, 10), 1), + marker: Keyword.get(opts, :marker, "> "), + style: Keyword.get(opts, :style, Style.new()), + cursor_style: Keyword.get(opts, :cursor_style, Style.new(fg: :cyan, attrs: [:bold])), + selected_style: Keyword.get(opts, :selected_style, Style.new(fg: :green)) } - - {:ok, state} + |> normalize_cursor() end - @doc """ - Handles events for the list. - """ @impl true - def handle_event(%Event.Key{key: :up}, state) do - new_index = max(0, state.selected_index - 1) - {:ok, %{state | selected_index: new_index}} - end + def update(%Event.Key{key: :up}, state), do: move(state, -1) + def update(%Event.Key{key: :down}, state), do: move(state, 1) + def update(%Event.Key{key: :home}, state), do: move_to(state, 0) + def update(%Event.Key{key: :end}, state), do: move_to(state, length(state.items) - 1) + def update(%Event.Key{key: :page_up}, state), do: move(state, -state.page_size) + def update(%Event.Key{key: :page_down}, state), do: move(state, state.page_size) + def update(%Event.Key{key: :space}, %{mode: :multiple} = state), do: toggle(state) + def update(%Event.Text{text: " "}, %{mode: :multiple} = state), do: toggle(state) + def update(%Event.Key{key: :enter}, state), do: select(state) + def update(_event, state), do: {state, []} - def handle_event(%Event.Key{key: :down}, state) do - new_index = min(state.item_count - 1, state.selected_index + 1) - {:ok, %{state | selected_index: max(0, new_index)}} + @impl true + def mouse(%Event.Mouse{action: action, button: :left, y: y}, state, {_width, height}) + when action in [:press, :release] do + offset = visible_offset(state.cursor, state.offset, height) + index = offset + y + + if y >= 0 and y < height and index < length(state.items) do + state = %{state | cursor: index, offset: offset} + + cond do + action == :press -> {state, []} + state.mode == :multiple -> toggle(state) + true -> select(state) + end + else + {state, []} + end end - def handle_event(%Event.Key{key: :home}, state) do - {:ok, %{state | selected_index: 0}} - end + def mouse(event, state, _dimensions), do: update(event, state) - def handle_event(%Event.Key{key: :end}, state) do - {:ok, %{state | selected_index: max(0, state.item_count - 1)}} - end + @impl true + def view(state, {_width, height} = dimensions) do + offset = visible_offset(state.cursor, state.offset, height) - def handle_event(%Event.Key{key: :page_up}, state) do - new_index = max(0, state.selected_index - 10) - {:ok, %{state | selected_index: new_index}} - end + rows = + state.items + |> Enum.slice(offset, height) + |> Enum.with_index(offset) + |> Enum.map(&render_item(&1, state)) - def handle_event(%Event.Key{key: :page_down}, state) do - new_index = min(state.item_count - 1, state.selected_index + 10) - {:ok, %{state | selected_index: max(0, new_index)}} + Helpers.frame(rows, dimensions) end - def handle_event(%Event.Key{key: :enter}, state) do - # Trigger selection callback - {:ok, state, [{:send, self(), {:select, state.selected_index}}]} - end + @doc "Replaces all items and keeps the cursor in range." + @spec set_items(t(), [item()]) :: t() + def set_items(state, items), + do: normalize_cursor(%{state | items: items, selected: MapSet.new()}) - def handle_event(%Event.Key{key: :space}, state) do - # Toggle selection in multi-select mode - {:ok, state, [{:send, self(), {:toggle, state.selected_index}}]} - end + @doc "Returns the item under the cursor." + @spec current(t()) :: item() | nil + def current(state), do: Enum.at(state.items, state.cursor) - def handle_event(_event, state) do - {:ok, state} - end + defp render_item({item, index}, state) do + cursor? = index == state.cursor + selected? = MapSet.member?(state.selected, index) - @doc """ - Handles messages to the list. - """ - @impl true - def handle_info({:select, index}, state) do - props = state.props - items = Map.get(props, :items, []) - on_select = Map.get(props, :on_select) - - if is_function(on_select, 1) && index < length(items) do - item = Enum.at(items, index) - on_select.(item) - end + prefix = + if cursor?, do: state.marker, else: String.duplicate(" ", String.length(state.marker)) - {:ok, state} + check = selection_mark(state.mode, selected?) + style = item_style(state, cursor?, selected?) + [{prefix <> check <> item_label(item), style}] end - def handle_info({:toggle, index}, state) do - props = state.props - multi_select = Map.get(props, :multi_select, false) - - if multi_select do - selected = - if MapSet.member?(state.selected_indices, index) do - MapSet.delete(state.selected_indices, index) - else - MapSet.put(state.selected_indices, index) - end - - {:ok, %{state | selected_indices: selected}} - else - {:ok, state} - end - end + defp selection_mark(:multiple, true), do: "[x] " + defp selection_mark(:multiple, false), do: "[ ] " + defp selection_mark(_mode, _selected?), do: "" - def handle_info({:set_items, items}, state) do - count = length(items) - new_index = min(state.selected_index, max(0, count - 1)) + defp item_style(state, true, _selected?), do: state.cursor_style + defp item_style(state, false, true), do: state.selected_style + defp item_style(state, false, false), do: state.style - {:ok, %{state | item_count: count, selected_index: new_index}} - end + defp move(state, delta), do: move_to(state, state.cursor + delta) - def handle_info(_msg, state) do - {:ok, state} + defp move_to(state, cursor) do + state = %{state | cursor: Helpers.clamp(cursor, 0, max(length(state.items) - 1, 0))} + offset = visible_offset(state.cursor, state.offset, state.page_size) + {%{state | offset: offset}, []} end - @doc """ - Renders the list. - """ - @impl true - def render(state, area) do - props = state.props - items = Map.get(props, :items, []) - multi_select = Map.get(props, :multi_select, false) - style_opts = Map.get(props, :style, %{}) - highlight_opts = Map.get(props, :highlight_style, %{fg: :black, bg: :white}) - - style = build_style(style_opts) - highlight_style = build_style(highlight_opts) - - # Calculate scroll offset to keep selection visible - scroll_offset = calculate_scroll(state.selected_index, state.scroll_offset, area.height) - - # Render visible items - cells = - items - |> Enum.with_index() - |> Enum.drop(scroll_offset) - |> Enum.take(area.height) - |> Enum.with_index() - |> Enum.flat_map(fn {{item, item_index}, display_y} -> - is_selected = item_index == state.selected_index - is_multi_selected = MapSet.member?(state.selected_indices, item_index) - - item_style = get_item_style(is_selected, is_multi_selected, highlight_style, style) - text = format_item_text(item, multi_select, is_multi_selected) - - render_item(text, display_y, area.width, item_style) - end) - - RenderNode.cells(cells) + defp select(state) do + case current(state) do + nil -> {state, []} + item -> {%{state | selected: MapSet.new([state.cursor])}, [{:selected, item}]} + end end - # Private Functions + defp toggle(state) do + selected = + if MapSet.member?(state.selected, state.cursor), + do: MapSet.delete(state.selected, state.cursor), + else: MapSet.put(state.selected, state.cursor) - defp get_item_style(true, _is_multi_selected, highlight_style, _style), do: highlight_style - defp get_item_style(_is_selected, true, highlight_style, _style), do: highlight_style - defp get_item_style(_is_selected, _is_multi_selected, _highlight_style, style), do: style - - defp format_item_text(item, true, true), do: "[x] " <> to_string(item) - defp format_item_text(item, true, false), do: "[ ] " <> to_string(item) - defp format_item_text(item, false, _is_multi_selected), do: to_string(item) - - defp render_item(text, y, width, style) do - display_text = - if String.length(text) > width do - String.slice(text, 0, width - 1) <> "…" - else - String.pad_trailing(text, width) - end - - display_text - |> String.graphemes() - |> Enum.with_index() - |> Enum.filter(fn {_char, x} -> x < width end) - |> Enum.map(fn {char, x} -> - positioned_cell_safe(x, y, char, style) - end) + item = current(state) + {%{state | selected: selected}, if(item, do: [{:toggled, item}], else: [])} end - # ---------------------------------------------------------------------------- - # Style Helper Functions - # ---------------------------------------------------------------------------- - - @spec positioned_cell_safe(integer(), integer(), String.t(), Style.t()) :: RenderNode.t() - defp positioned_cell_safe(x, y, char, style), - do: positioned_cell(x, y, char, style) + defp normalize_cursor(state), + do: %{state | cursor: min(state.cursor, max(length(state.items) - 1, 0))} - # ---------------------------------------------------------------------------- - # Utility Functions - # ---------------------------------------------------------------------------- + defp visible_offset(cursor, offset, _height) when cursor < offset, do: cursor - defp calculate_scroll(selected, current_scroll, visible_height) do - cond do - # Selection above visible area - selected < current_scroll -> - selected + defp visible_offset(cursor, offset, height) when cursor >= offset + height, + do: cursor - height + 1 - # Selection below visible area - selected >= current_scroll + visible_height -> - selected - visible_height + 1 - - # Selection visible - true -> - current_scroll - end - end - - defp build_style(opts) when is_map(opts) do - style_list = - opts - |> Enum.map(fn - {:fg, color} -> {:fg, color} - {:bg, color} -> {:bg, color} - {:bold, true} -> {:attrs, [:bold]} - _ -> nil - end) - |> Enum.reject(&is_nil/1) - - Style.new(style_list) - end + defp visible_offset(_cursor, offset, _height), do: offset - defp build_style(_), do: Style.new() + defp item_label(%{label: label}), do: to_string(label) + defp item_label({_id, label}), do: to_string(label) + defp item_label(item), do: to_string(item) end diff --git a/lib/term_ui/widget/log_viewer.ex b/lib/term_ui/widget/log_viewer.ex new file mode 100644 index 00000000..585019fd --- /dev/null +++ b/lib/term_ui/widget/log_viewer.ex @@ -0,0 +1,131 @@ +defmodule TermUI.Widget.LogViewer do + @moduledoc "A pure bounded and scrollable log viewer." + + @behaviour TermUI.Widget + + alias TermUI.{Event, Frame, Style} + alias TermUI.Widget.Helpers + + @type entry :: %{ + required(:message) => String.t(), + optional(:level) => atom(), + optional(:timestamp) => term() + } + @type t :: %__MODULE__{ + entries: [entry()], + limit: pos_integer(), + offset: non_neg_integer(), + follow: boolean(), + filter: String.t() | nil, + page_size: pos_integer() + } + @schema Zoi.struct(__MODULE__, %{ + entries: Zoi.array() |> Zoi.default([]), + limit: Zoi.integer() |> Zoi.positive() |> Zoi.default(10_000), + offset: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0), + follow: Zoi.boolean() |> Zoi.default(true), + filter: Zoi.any() |> Zoi.default(nil), + page_size: Zoi.integer() |> Zoi.positive() |> Zoi.default(20) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts) do + %__MODULE__{ + entries: + opts + |> Keyword.get(:entries, []) + |> Enum.map(&normalize/1) + |> Enum.take(-max(Keyword.get(opts, :limit, 10_000), 1)), + limit: max(Keyword.get(opts, :limit, 10_000), 1), + follow: Keyword.get(opts, :follow, true), + filter: Keyword.get(opts, :filter), + page_size: max(Keyword.get(opts, :page_size, 20), 1) + } + end + + @impl true + def update(%Event.Key{key: :up}, state), do: scroll(state, -1) + def update(%Event.Key{key: :down}, state), do: scroll(state, 1) + def update(%Event.Key{key: :page_up}, state), do: scroll(state, -state.page_size) + def update(%Event.Key{key: :page_down}, state), do: scroll(state, state.page_size) + def update(%Event.Key{key: :home}, state), do: {%{state | offset: 0, follow: false}, []} + + def update(%Event.Key{key: :end}, state), + do: {%{state | offset: max(length(filtered(state)) - state.page_size, 0), follow: true}, []} + + def update(%Event.Text{text: "f"}, state), + do: {%{state | follow: not state.follow}, [{:follow, not state.follow}]} + + def update(_event, state), do: {state, []} + + @impl true + def view(state, {width, height} = dimensions) do + entries = filtered(state) + + offset = + if state.follow, + do: max(length(entries) - height, 0), + else: min(state.offset, max(length(entries) - height, 0)) + + rows = + entries + |> Enum.slice(offset, height) + |> Enum.flat_map(fn entry -> + prefix = prefix(entry) + style = level_style(Map.get(entry, :level, :info)) + Frame.wrap(prefix <> entry.message, width) |> Enum.map(&[{&1, style}]) + end) + |> Enum.take(height) + + Helpers.frame(rows, dimensions) + end + + @doc "Appends one entry and applies the configured bound." + @spec append(t(), entry() | iodata()) :: t() + def append(state, entry) do + entries = Enum.take(state.entries ++ [normalize(entry)], -state.limit) + + %{ + state + | entries: entries, + offset: + if(state.follow, do: max(length(entries) - state.page_size, 0), else: state.offset) + } + end + + @doc "Changes the case-insensitive text filter." + @spec set_filter(t(), String.t() | nil) :: t() + def set_filter(state, filter), do: %{state | filter: filter, offset: 0} + + defp scroll(state, delta) do + offset = Helpers.scroll(state.offset, delta, length(filtered(state)), state.page_size) + + {%{ + state + | offset: offset, + follow: offset == Helpers.max_scroll(length(filtered(state)), state.page_size) + }, [{:scrolled, offset}]} + end + + defp filtered(%{filter: filter} = state) when filter in [nil, ""], do: state.entries + + defp filtered(state), + do: + Enum.filter( + state.entries, + &String.contains?(String.downcase(&1.message), String.downcase(state.filter)) + ) + + defp normalize(%{message: message} = entry), + do: entry |> Map.put(:message, to_string(message)) |> Map.put_new(:level, :info) + + defp normalize(message), do: %{message: IO.iodata_to_binary(message), level: :info} + defp prefix(%{timestamp: timestamp}) when not is_nil(timestamp), do: "#{timestamp} " + defp prefix(_entry), do: "" + defp level_style(:debug), do: Style.new(fg: :bright_black) + defp level_style(:warning), do: Style.new(fg: :yellow) + defp level_style(:error), do: Style.new(fg: :red) + defp level_style(_level), do: Style.new() +end diff --git a/lib/term_ui/widget/markdown_viewer.ex b/lib/term_ui/widget/markdown_viewer.ex new file mode 100644 index 00000000..b5998353 --- /dev/null +++ b/lib/term_ui/widget/markdown_viewer.ex @@ -0,0 +1,127 @@ +defmodule TermUI.Widget.MarkdownViewer do + @moduledoc "A pure, scrollable MDEx Markdown viewer with selectable code blocks." + + @behaviour TermUI.Widget + + alias TermUI.{Event, Frame, Markdown} + + @type t :: %__MODULE__{ + content: String.t(), + scroll: non_neg_integer() | :end, + page_size: pos_integer(), + elements: [Markdown.element()], + focused: non_neg_integer(), + content_limit: pos_integer() + } + + @schema Zoi.struct(__MODULE__, %{ + content: Zoi.string() |> Zoi.default(""), + scroll: + Zoi.union([Zoi.integer() |> Zoi.non_negative(), Zoi.literal(:end)]) + |> Zoi.default(0), + page_size: Zoi.integer() |> Zoi.positive() |> Zoi.default(20), + elements: Zoi.array() |> Zoi.default([]), + focused: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0), + content_limit: Zoi.integer() |> Zoi.positive() |> Zoi.default(2_000_000) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts) do + content_limit = max(Keyword.get(opts, :content_limit, 2_000_000), 1) + content = opts |> Keyword.get(:content, "") |> to_string() |> retain_tail(content_limit) + + %__MODULE__{ + content: content, + page_size: max(Keyword.get(opts, :page_size, 20), 1), + elements: Markdown.code_blocks(content), + content_limit: content_limit + } + end + + @impl true + def update(%Event.Key{key: :up}, state), do: scroll(state, -1) + def update(%Event.Key{key: :down}, state), do: scroll(state, 1) + def update(%Event.Key{key: :page_up}, state), do: scroll(state, -state.page_size) + def update(%Event.Key{key: :page_down}, state), do: scroll(state, state.page_size) + def update(%Event.Key{key: :home}, state), do: {%{state | scroll: 0}, []} + def update(%Event.Key{key: :end}, state), do: {%{state | scroll: :end}, []} + + def update(%Event.Key{key: :tab, modifiers: modifiers}, state), + do: focus(state, if(:shift in modifiers, do: -1, else: 1)) + + def update(%Event.Key{key: :enter}, state), do: copy_focused(state) + def update(%Event.Text{text: "c"}, state), do: copy_focused(state) + def update(%Event.Mouse{action: :scroll_up}, state), do: scroll(state, -3) + def update(%Event.Mouse{action: :scroll_down}, state), do: scroll(state, 3) + def update(_event, state), do: {state, []} + + @impl true + def view(state, {width, height} = dimensions) do + focused_id = state.elements |> Enum.at(state.focused) |> then(&if(&1, do: &1.id)) + result = Markdown.render_with_elements(state.content, width, focused_element_id: focused_id) + + offset = + if state.scroll == :end, + do: max(result.content_height - height, 0), + else: min(state.scroll, max(result.content_height - height, 0)) + + rows = Enum.slice(result.lines, offset, height) + Frame.from_rows(rows, elem(dimensions, 0), elem(dimensions, 1)) + end + + @doc "Replaces Markdown content and resets navigation." + @spec set_content(t(), String.t()) :: t() + def set_content(state, content) do + content = retain_tail(content, state.content_limit) + %{state | content: content, scroll: 0, focused: 0, elements: Markdown.code_blocks(content)} + end + + @doc "Appends a Markdown fragment within the configured content bound." + @spec append(t(), String.t()) :: t() + def append(state, fragment), + do: + set_content( + %{state | scroll: :end}, + retain_tail(state.content <> fragment, state.content_limit) + ) + |> Map.put(:scroll, :end) + + defp scroll(state, delta) do + scroll = if state.scroll == :end, do: 0, else: state.scroll + scroll = max(scroll + delta, 0) + {%{state | scroll: scroll}, [{:scrolled, scroll}]} + end + + defp focus(%{elements: []} = state, _delta), do: {state, []} + + defp focus(state, delta) do + focused = rem(state.focused + delta + length(state.elements), length(state.elements)) + element = Enum.at(state.elements, focused) + {%{state | focused: focused, scroll: element.start_line}, [{:focused, element}]} + end + + defp copy_focused(state) do + case Enum.at(state.elements, state.focused) do + nil -> {state, []} + element -> {state, [{:copy, element.content}]} + end + end + + defp retain_tail(content, limit) when byte_size(content) <= limit, do: content + + defp retain_tail(content, limit) do + content + |> binary_part(byte_size(content) - limit, limit) + |> valid_utf8_tail() + end + + defp valid_utf8_tail(<<>>), do: "" + + defp valid_utf8_tail(content) do + if String.valid?(content), + do: content, + else: content |> binary_part(1, byte_size(content) - 1) |> valid_utf8_tail() + end +end diff --git a/lib/term_ui/widget/menu.ex b/lib/term_ui/widget/menu.ex new file mode 100644 index 00000000..b7be0695 --- /dev/null +++ b/lib/term_ui/widget/menu.ex @@ -0,0 +1,196 @@ +defmodule TermUI.Widget.Menu do + @moduledoc "A pure keyboard menu with actions, separators, and disabled items." + + @behaviour TermUI.Widget + + alias TermUI.{Event, Style} + alias TermUI.Widget.Helpers + + @type item :: %{ + required(:id) => term(), + required(:label) => String.t(), + optional(:disabled) => boolean(), + optional(:separator) => boolean(), + optional(:shortcut) => String.t() | nil, + optional(:message) => term() + } + @type t :: %__MODULE__{ + items: [item()], + cursor: non_neg_integer(), + title: String.t() | nil, + visible: boolean() + } + @schema Zoi.struct(__MODULE__, %{ + items: Zoi.array() |> Zoi.default([]), + cursor: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0), + title: Zoi.any() |> Zoi.default(nil), + visible: Zoi.boolean() |> Zoi.default(true) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @doc "Creates one menu action." + @spec action(term(), iodata(), keyword()) :: item() + def action(id, label, opts \\ []) do + %{ + id: id, + label: IO.iodata_to_binary(label), + disabled: Keyword.get(opts, :disabled, false), + separator: false, + shortcut: Keyword.get(opts, :shortcut), + message: Keyword.get(opts, :message, {:selected, id}) + } + end + + @doc "Creates one separator." + @spec separator() :: item() + def separator, + do: %{id: make_ref(), label: "", disabled: true, separator: true, shortcut: nil, message: nil} + + @impl true + def init(opts) do + items = opts |> Keyword.get(:items, []) |> Enum.map(&normalize_item/1) + + %__MODULE__{ + items: items, + cursor: first_enabled(items), + title: Keyword.get(opts, :title), + visible: Keyword.get(opts, :visible, true) + } + end + + @impl true + def update(_event, %{visible: false} = state), do: {state, []} + def update(%Event.Key{key: :up}, state), do: move(state, -1) + def update(%Event.Key{key: :down}, state), do: move(state, 1) + + def update(%Event.Key{key: :home}, state), + do: {%{state | cursor: first_enabled(state.items)}, []} + + def update(%Event.Key{key: :end}, state), do: {%{state | cursor: last_enabled(state.items)}, []} + def update(%Event.Key{key: key}, state) when key in [:enter, :space], do: activate(state) + def update(%Event.Text{text: " "}, state), do: activate(state) + def update(%Event.Key{key: :escape}, state), do: {%{state | visible: false}, [:dismissed]} + def update(_event, state), do: {state, []} + + @impl true + def mouse(_event, %{visible: false} = state, _dimensions), do: {state, []} + + def mouse(%Event.Mouse{action: action, button: :left, y: y}, state, {width, height}) + when action in [:press, :release] do + border_offset = if width > 1 and height > 1, do: 1, else: 0 + index = y - border_offset + + item = if index >= 0, do: Enum.at(state.items, index) + + if enabled?(item) do + state = %{state | cursor: index} + if action == :release, do: activate(state), else: {state, []} + else + {state, []} + end + end + + def mouse(event, state, _dimensions), do: update(event, state) + + @impl true + def view(%{visible: false}, dimensions), do: Helpers.frame([], dimensions) + + def view(state, {width, height} = dimensions) do + cursor_style = Style.new(attrs: [:reverse]) + disabled_style = Style.new(fg: :bright_black) + separator_style = Style.new(fg: :bright_black) + inner_width = max(width - 2, 1) + + rows = + state.items + |> Enum.with_index() + |> Enum.map( + &render_item( + &1, + inner_width, + state.cursor, + cursor_style, + disabled_style, + separator_style + ) + ) + + rows = Helpers.border(rows, dimensions, title: state.title) + Helpers.frame(Enum.take(rows, height), dimensions) + end + + @doc "Shows the menu." + @spec show(t()) :: t() + def show(state), do: %{state | visible: true} + + @doc "Hides the menu." + @spec hide(t()) :: t() + def hide(state), do: %{state | visible: false} + + @doc "Returns the current action." + @spec current(t()) :: item() | nil + def current(state), do: Enum.at(state.items, state.cursor) + + defp render_item({%{separator: true}, _index}, width, _cursor, _cursor_style, _disabled, style), + do: [{String.duplicate("─", width), style}] + + defp render_item({item, index}, width, cursor, cursor_style, disabled_style, _separator) do + shortcut = if item.shortcut, do: " " <> item.shortcut, else: "" + label_width = max(width - String.length(shortcut) - 2, 1) + text = " " <> Helpers.align(item.label, label_width, :left) <> shortcut + + style = + cond do + item.disabled -> disabled_style + index == cursor -> cursor_style + true -> Style.new() + end + + [{text, style}] + end + + defp activate(state) do + case current(state) do + %{disabled: false, separator: false, message: message} -> {state, [message]} + _other -> {state, []} + end + end + + defp move(%{items: []} = state, _delta), do: {state, []} + + defp move(state, delta) do + count = length(state.items) + + cursor = + 1..count + |> Enum.reduce_while(state.cursor, fn step, _cursor -> + candidate = rem(state.cursor + delta * step + count * step, count) + + if enabled?(Enum.at(state.items, candidate)), + do: {:halt, candidate}, + else: {:cont, state.cursor} + end) + + {%{state | cursor: cursor}, []} + end + + defp enabled?(%{disabled: false, separator: false}), do: true + defp enabled?(_item), do: false + defp first_enabled(items), do: Enum.find_index(items, &enabled?/1) || 0 + + defp last_enabled(items), + do: + items + |> Enum.with_index() + |> Enum.reverse() + |> Enum.find_value(0, fn {item, index} -> if enabled?(item), do: index end) + + defp normalize_item(%{separator: true} = item), do: item + + defp normalize_item(%{id: _id, label: label} = item), + do: action(item.id, label, Map.to_list(Map.drop(item, [:id, :label]))) + + defp normalize_item({id, label}), do: action(id, label) + defp normalize_item(label), do: action(label, label) +end diff --git a/lib/term_ui/widget/pick_list.ex b/lib/term_ui/widget/pick_list.ex index 8c817f79..ea4a88bc 100644 --- a/lib/term_ui/widget/pick_list.ex +++ b/lib/term_ui/widget/pick_list.ex @@ -1,503 +1,127 @@ defmodule TermUI.Widget.PickList do - @moduledoc """ - A modal pick-list widget for selecting from a list of items. + @moduledoc "A pure searchable pick list." + + @behaviour TermUI.Widget + + alias TermUI.{Event, Style} + alias TermUI.Widget.Helpers + + @type t :: %__MODULE__{ + items: [term()], + query: String.t(), + cursor: non_neg_integer(), + page_size: pos_integer(), + prompt: String.t() + } + + @schema Zoi.struct(__MODULE__, %{ + items: Zoi.array() |> Zoi.default([]), + query: Zoi.string() |> Zoi.default(""), + cursor: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0), + page_size: Zoi.integer() |> Zoi.positive() |> Zoi.default(8), + prompt: Zoi.string() |> Zoi.default("Filter: ") + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) - PickList displays a centered modal overlay with a scrollable list, - keyboard navigation, and type-ahead filtering. Used for provider - and model selection dialogs. - - ## Usage - - PickList.render(%{ - items: ["Apple", "Banana", "Cherry"], - title: "Select Fruit", - on_select: fn item -> IO.puts("Selected: \#{item}") end, - on_cancel: fn -> IO.puts("Cancelled") end - }, state, area) - - ## Props - - - `:items` - List of items to display (required) - - `:title` - Modal title (optional) - - `:on_select` - Callback when item selected `fn item -> ... end` - - `:on_cancel` - Callback when cancelled `fn -> ... end` - - `:width` - Modal width (default: 40) - - `:height` - Modal height (default: 10) - - `:style` - Border/text style options - - `:highlight_style` - Style for selected item (default: inverted colors) - - ## Keyboard Controls - - - `Up/Down` - Navigate items - - `Page Up/Down` - Jump 10 items - - `Home/End` - Jump to first/last item - - `Enter` - Confirm selection - - `Escape` - Cancel - - Typing - Filter items (type-ahead search) - - `Backspace` - Remove filter character - """ - - use TermUI.StatefulComponent - - alias TermUI.Component.RenderNode - alias TermUI.Event - alias TermUI.Renderer.Style - - # Dialyzer: Suppress opaque type warnings for Style helpers - # Dialyzer: unused_fun and no_return warnings for private helper functions - @dialyzer {:nowarn_function, - build_style: 1, - positioned_cell_safe: 4, - render_border: 6, - render_items: 1, - render_item_list: 1, - render_item_cells: 5, - render_empty_items: 4, - truncate_item_text: 2, - render_status_line: 5, - item_style: 4, - render_filter_line: 5, - render: 2} - - # Border characters (single style) - @border %{tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│"} - - @doc """ - Initializes the pick-list state. - """ @impl true - def init(props) do - items = Map.get(props, :items, []) - - state = %{ - selected_index: 0, - scroll_offset: 0, - filter_text: "", - filtered_items: items, - original_items: items, - props: props + def init(opts) do + %__MODULE__{ + items: Keyword.get(opts, :items, []), + query: Keyword.get(opts, :query, ""), + cursor: 0, + page_size: max(Keyword.get(opts, :page_size, 8), 1), + prompt: Keyword.get(opts, :prompt, "Filter: ") } - - {:ok, state} end - @doc """ - Handles keyboard events for the pick-list. - """ @impl true - def handle_event(%Event.Key{key: :up}, state) do - new_index = max(0, state.selected_index - 1) - new_scroll = adjust_scroll(new_index, state.scroll_offset, visible_height(state)) - {:ok, %{state | selected_index: new_index, scroll_offset: new_scroll}} - end - - def handle_event(%Event.Key{key: :down}, state) do - max_index = max(0, length(state.filtered_items) - 1) - new_index = min(max_index, state.selected_index + 1) - new_scroll = adjust_scroll(new_index, state.scroll_offset, visible_height(state)) - {:ok, %{state | selected_index: new_index, scroll_offset: new_scroll}} - end - - def handle_event(%Event.Key{key: :page_up}, state) do - new_index = max(0, state.selected_index - 10) - new_scroll = adjust_scroll(new_index, state.scroll_offset, visible_height(state)) - {:ok, %{state | selected_index: new_index, scroll_offset: new_scroll}} - end - - def handle_event(%Event.Key{key: :page_down}, state) do - max_index = max(0, length(state.filtered_items) - 1) - new_index = min(max_index, state.selected_index + 10) - new_scroll = adjust_scroll(new_index, state.scroll_offset, visible_height(state)) - {:ok, %{state | selected_index: new_index, scroll_offset: new_scroll}} - end - - def handle_event(%Event.Key{key: :home}, state) do - {:ok, %{state | selected_index: 0, scroll_offset: 0}} - end - - def handle_event(%Event.Key{key: :end}, state) do - max_index = max(0, length(state.filtered_items) - 1) - new_scroll = adjust_scroll(max_index, 0, visible_height(state)) - {:ok, %{state | selected_index: max_index, scroll_offset: new_scroll}} - end - - def handle_event(%Event.Key{key: :enter}, state) do - if length(state.filtered_items) > 0 do - item = Enum.at(state.filtered_items, state.selected_index) - {:ok, state, [{:send, self(), {:select, item}}]} - else - {:ok, state} + def update(%Event.Text{text: text}, state), do: change_query(state, state.query <> clean(text)) + + def update(%Event.Paste{content: text}, state), + do: change_query(state, state.query <> clean(text)) + + def update(%Event.Key{key: :backspace}, state), do: change_query(state, drop_last(state.query)) + def update(%Event.Key{key: :escape}, %{query: ""} = state), do: {state, [:cancel]} + def update(%Event.Key{key: :escape}, state), do: change_query(state, "") + def update(%Event.Key{key: :up}, state), do: move(state, -1) + def update(%Event.Key{key: :down}, state), do: move(state, 1) + def update(%Event.Key{key: :page_up}, state), do: move(state, -state.page_size) + def update(%Event.Key{key: :page_down}, state), do: move(state, state.page_size) + def update(%Event.Key{key: :home}, state), do: {%{state | cursor: 0}, []} + def update(%Event.Key{key: :end}, state), do: move_to_end(state) + + def update(%Event.Key{key: :enter}, state) do + case Enum.at(filtered(state), state.cursor) do + nil -> {state, []} + item -> {state, [{:picked, item}]} end end - def handle_event(%Event.Key{key: :escape}, state) do - {:ok, state, [{:send, self(), :cancel}]} - end + def update(_event, state), do: {state, []} - def handle_event(%Event.Key{key: :backspace}, state) do - if state.filter_text != "" do - new_filter = String.slice(state.filter_text, 0..-2//1) - new_state = apply_filter(state, new_filter) - {:ok, new_state} - else - {:ok, state} - end - end - - def handle_event(%Event.Key{char: char}, state) when is_binary(char) and char != "" do - # Type-ahead filtering - new_filter = state.filter_text <> char - new_state = apply_filter(state, new_filter) - {:ok, new_state} - end - - def handle_event(_event, state) do - {:ok, state} - end - - @doc """ - Handles messages to the pick-list. - """ @impl true - def handle_info({:select, item}, state) do - props = state.props - on_select = Map.get(props, :on_select) - - if is_function(on_select, 1) do - on_select.(item) + def mouse(%Event.Mouse{action: action, button: :left, y: y}, state, {_width, height}) + when action in [:press, :release] do + list_height = max(height - 1, 0) + offset = visible_offset(state.cursor, list_height) + index = offset + y - 1 + + if y >= 1 and y < height and index < length(filtered(state)) do + state = %{state | cursor: index} + if action == :release, do: update(Event.key(:enter), state), else: {state, []} + else + {state, []} end - - {:ok, state} end - def handle_info(:cancel, state) do - props = state.props - on_cancel = Map.get(props, :on_cancel) - - if is_function(on_cancel, 0) do - on_cancel.() - end - - {:ok, state} - end + def mouse(event, state, _dimensions), do: update(event, state) - def handle_info({:set_items, items}, state) do - new_state = %{state | original_items: items} - new_state = apply_filter(new_state, state.filter_text) - {:ok, new_state} - end - - def handle_info(_msg, state) do - {:ok, state} - end - - @doc """ - Renders the pick-list modal. - """ @impl true - def render(state, area) do - props = state.props - title = Map.get(props, :title, "Select") - modal_width = Map.get(props, :width, 40) - modal_height = Map.get(props, :height, 10) - style_opts = Map.get(props, :style, %{}) - highlight_opts = Map.get(props, :highlight_style, %{fg: :black, bg: :white}) - - style = build_style(style_opts) - highlight_style = build_style(highlight_opts) - - # Calculate modal position (centered) - modal_x = div(area.width - modal_width, 2) - modal_y = div(area.height - modal_height, 2) - - # Ensure modal fits in area - modal_width = min(modal_width, area.width) - modal_height = min(modal_height, area.height) - - cells = [] - - # Render border - cells = cells ++ render_border(title, modal_x, modal_y, modal_width, modal_height, style) - - # Render filter line if filtering - {cells, content_start_y, content_height} = - if state.filter_text != "" do - filter_cells = render_filter_line(state.filter_text, modal_x, modal_y, modal_width, style) - {cells ++ filter_cells, modal_y + 2, modal_height - 4} - else - {cells, modal_y + 1, modal_height - 3} - end - - # Render items - cells = - cells ++ - render_items(%{ - items: state.filtered_items, - selected_index: state.selected_index, - scroll_offset: state.scroll_offset, - x: modal_x + 1, - y: content_start_y, - width: modal_width - 2, - height: content_height, - style: style, - highlight_style: highlight_style - }) - - # Render status line - cells = - cells ++ render_status_line(state, modal_x, modal_y + modal_height - 2, modal_width, style) - - RenderNode.cells(cells) - end - - # Private Functions - - defp apply_filter(state, filter_text) do - filtered = - if filter_text == "" do - state.original_items - else - filter_lower = String.downcase(filter_text) - - Enum.filter(state.original_items, fn item -> - String.downcase(to_string(item)) |> String.contains?(filter_lower) - end) - end - - # Reset selection when filter changes - %{ - state - | filter_text: filter_text, - filtered_items: filtered, - selected_index: 0, - scroll_offset: 0 - } - end - - defp visible_height(state) do - props = state.props - modal_height = Map.get(props, :height, 10) - # Account for border (2), status line (1), and filter line if present - filter_offset = if state.filter_text != "", do: 1, else: 0 - max(1, modal_height - 3 - filter_offset) - end - - defp adjust_scroll(selected_index, current_scroll, visible_height) do - cond do - selected_index < current_scroll -> - selected_index - - selected_index >= current_scroll + visible_height -> - selected_index - visible_height + 1 - - true -> - current_scroll - end - end - - defp render_border(title, x, y, width, height, style) do - cells = [] - - # Top border with title - title_text = String.slice(title, 0, width - 4) - title_padded = " " <> title_text <> " " - title_start = 2 - - cells = cells ++ [positioned_cell_safe(x, y, @border.tl, style)] - - cells = - cells ++ - for(i <- 1..(title_start - 1), do: positioned_cell_safe(x + i, y, @border.h, style)) - - cells = - cells ++ - (title_padded - |> String.graphemes() - |> Enum.with_index() - |> Enum.map(fn {char, i} -> - positioned_cell_safe(x + title_start + i, y, char, style) + def view(state, {_width, height} = dimensions) do + items = filtered(state) + list_height = max(height - 1, 0) + offset = visible_offset(state.cursor, list_height) + query_style = Style.new(fg: :cyan, attrs: [:bold]) + selected_style = Style.new(fg: :black, bg: :cyan, attrs: [:bold]) + + rows = + [[{state.prompt, query_style}, state.query]] ++ + (items + |> Enum.slice(offset, list_height) + |> Enum.with_index(offset) + |> Enum.map(fn {item, index} -> + prefix = if index == state.cursor, do: "> ", else: " " + style = if index == state.cursor, do: selected_style, else: Style.new() + [{prefix <> item_label(item), style}] end)) - title_end = title_start + String.length(title_padded) - - cells = - cells ++ - for(i <- title_end..(width - 2), do: positioned_cell_safe(x + i, y, @border.h, style)) - - cells = cells ++ [positioned_cell_safe(x + width - 1, y, @border.tr, style)] - - # Side borders - cells = - (cells ++ - for row <- 1..(height - 2) do - [ - positioned_cell_safe(x, y + row, @border.v, style), - positioned_cell_safe(x + width - 1, y + row, @border.v, style) - ] - end) - |> List.flatten() - - # Bottom border - cells = cells ++ [positioned_cell_safe(x, y + height - 1, @border.bl, style)] - - cells = - cells ++ - for( - i <- 1..(width - 2), - do: positioned_cell_safe(x + i, y + height - 1, @border.h, style) - ) - - cells = cells ++ [positioned_cell_safe(x + width - 1, y + height - 1, @border.br, style)] - - cells - end - - defp render_filter_line(filter_text, modal_x, modal_y, modal_width, style) do - inner_width = modal_width - 2 - filter_display = "Filter: " <> filter_text - filter_display = String.slice(filter_display, 0, inner_width) - filter_display = String.pad_trailing(filter_display, inner_width) - - filter_display - |> String.graphemes() - |> Enum.with_index() - |> Enum.map(fn {char, i} -> - positioned_cell_safe(modal_x + 1 + i, modal_y + 1, char, style) - end) - end - - defp render_items(params) do - %{ - items: items, - selected_index: _selected_index, - scroll_offset: _scroll_offset, - x: x, - y: y, - width: width, - height: _height, - style: style, - highlight_style: _highlight_style - } = params - - if items == [] do - render_empty_items(x, y, width, style) - else - render_item_list(params) - end - end - - defp render_empty_items(x, y, width, style) do - msg = "(No items)" - msg = String.pad_leading(msg, div(width + String.length(msg), 2)) - msg = String.pad_trailing(msg, width) - - msg - |> String.graphemes() - |> Enum.with_index() - |> Enum.map(fn {char, i} -> - positioned_cell_safe(x + i, y, char, style) - end) + Helpers.frame(rows, dimensions, cursor: {String.length(state.prompt <> state.query) + 1, 1}) end - defp render_item_list(params) do - %{ - items: items, - selected_index: selected_index, - scroll_offset: scroll_offset, - x: x, - y: y, - width: width, - height: height, - style: style, - highlight_style: highlight_style - } = params + @doc "Returns the items that match the current query." + @spec filtered(t()) :: [term()] + def filtered(%{query: ""} = state), do: state.items - items - |> Enum.with_index() - |> Enum.drop(scroll_offset) - |> Enum.take(height) - |> Enum.with_index() - |> Enum.flat_map(fn {{item, item_index}, display_y} -> - item_style = item_style(item_index, selected_index, highlight_style, style) - render_item_cells(item, x, y + display_y, width, item_style) - end) + def filtered(state) do + query = String.downcase(state.query) + Enum.filter(state.items, &String.contains?(String.downcase(item_label(&1)), query)) end - defp item_style(item_index, selected_index, highlight_style, style) do - if item_index == selected_index, do: highlight_style, else: style - end - - defp render_item_cells(item, x, y, width, style) do - item_text = to_string(item) - item_text = truncate_item_text(item_text, width) - - item_text - |> String.graphemes() - |> Enum.with_index() - |> Enum.map(fn {char, i} -> - positioned_cell_safe(x + i, y, char, style) - end) - end - - defp truncate_item_text(text, width) do - if String.length(text) > width do - String.slice(text, 0, width - 1) <> "…" - else - String.pad_trailing(text, width) - end - end - - defp render_status_line(state, modal_x, y, modal_width, style) do - inner_width = modal_width - 2 - total = length(state.filtered_items) - - status = - if total == 0 do - if state.filter_text != "" do - "No matches" - else - "Empty list" - end - else - "Item #{state.selected_index + 1} of #{total}" - end - - status = String.pad_leading(status, div(inner_width + String.length(status), 2)) - status = String.pad_trailing(status, inner_width) - - status - |> String.graphemes() - |> Enum.with_index() - |> Enum.map(fn {char, i} -> - positioned_cell_safe(modal_x + 1 + i, y, char, style) - end) - end - - # ---------------------------------------------------------------------------- - # Style Helper Functions - # ---------------------------------------------------------------------------- - - @spec positioned_cell_safe(integer(), integer(), String.t(), Style.t()) :: RenderNode.t() - defp positioned_cell_safe(x, y, char, style), - do: positioned_cell(x, y, char, style) - - # ---------------------------------------------------------------------------- - # Style Building - # ---------------------------------------------------------------------------- - - defp build_style(opts) when is_map(opts) do - style_list = - opts - |> Enum.map(fn - {:fg, color} -> {:fg, color} - {:bg, color} -> {:bg, color} - {:bold, true} -> {:attrs, [:bold]} - _ -> nil - end) - |> Enum.reject(&is_nil/1) + defp change_query(state, query), + do: {%{state | query: query, cursor: 0}, [{:query_changed, query}]} - Style.new(style_list) + defp move(state, delta) do + maximum = max(length(filtered(state)) - 1, 0) + {%{state | cursor: Helpers.clamp(state.cursor + delta, 0, maximum)}, []} end - defp build_style(_), do: Style.new() + defp move_to_end(state), do: {%{state | cursor: max(length(filtered(state)) - 1, 0)}, []} + defp visible_offset(_cursor, 0), do: 0 + defp visible_offset(cursor, height), do: max(cursor - height + 1, 0) + defp clean(text), do: String.replace(text, ~r/[\x00-\x1F\x7F]/u, "") + defp drop_last(text), do: text |> String.graphemes() |> Enum.drop(-1) |> Enum.join() + defp item_label(%{label: label}), do: to_string(label) + defp item_label({_id, label}), do: to_string(label) + defp item_label(item), do: to_string(item) end diff --git a/lib/term_ui/widget/process_monitor.ex b/lib/term_ui/widget/process_monitor.ex new file mode 100644 index 00000000..e0805a86 --- /dev/null +++ b/lib/term_ui/widget/process_monitor.ex @@ -0,0 +1,97 @@ +defmodule TermUI.Widget.ProcessMonitor do + @moduledoc "A pure process-snapshot table. It does not inspect processes itself." + + @behaviour TermUI.Widget + + alias TermUI.Event + alias TermUI.Widget.Table + alias TermUI.Widget.Table.Column + + @type snapshot :: %{ + required(:pid) => term(), + optional(:name) => term(), + optional(:memory) => non_neg_integer(), + optional(:reductions) => non_neg_integer(), + optional(:message_queue_len) => non_neg_integer() + } + @type t :: %__MODULE__{ + snapshots: [snapshot()], + table: Table.t(), + sort: atom(), + descending: boolean() + } + @schema Zoi.struct(__MODULE__, %{ + snapshots: Zoi.array() |> Zoi.default([]), + table: Zoi.struct(Table) |> Zoi.default(%Table{}), + sort: Zoi.atom() |> Zoi.default(:memory), + descending: Zoi.boolean() |> Zoi.default(true) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts) do + snapshots = Keyword.get(opts, :snapshots, []) + sort = Keyword.get(opts, :sort, :memory) + descending = Keyword.get(opts, :descending, true) + + %__MODULE__{ + snapshots: snapshots, + sort: sort, + descending: descending, + table: table(sorted(snapshots, sort, descending)) + } + end + + @impl true + def update(%Event.Text{text: "r"}, state), do: {state, [:refresh_requested]} + def update(%Event.Text{text: "m"}, state), do: sort_by(state, :memory) + def update(%Event.Text{text: "q"}, state), do: sort_by(state, :message_queue_len) + def update(%Event.Text{text: "c"}, state), do: sort_by(state, :reductions) + + def update(event, state) do + {table, messages} = Table.update(event, state.table) + {%{state | table: table}, messages} + end + + @impl true + def view(state, dimensions), do: Table.view(state.table, dimensions) + + @doc "Replaces process snapshots supplied by the parent." + @spec set_snapshots(t(), [snapshot()]) :: t() + def set_snapshots(state, snapshots), + do: %{ + state + | snapshots: snapshots, + table: state.table |> Table.set_rows(sorted(snapshots, state.sort, state.descending)) + } + + defp sort_by(state, key) do + descending = if state.sort == key, do: not state.descending, else: true + + state = %{ + state + | sort: key, + descending: descending, + table: Table.set_rows(state.table, sorted(state.snapshots, key, descending)) + } + + {state, [{:sorted, key, descending}]} + end + + defp sorted(snapshots, key, true), do: Enum.sort_by(snapshots, &Map.get(&1, key, 0), :desc) + defp sorted(snapshots, key, false), do: Enum.sort_by(snapshots, &Map.get(&1, key, 0), :asc) + + defp table(rows) do + Table.init( + columns: [ + Column.new(:pid, "PID", width: 16), + Column.new(:name, "Name"), + Column.new(:memory, "Memory", align: :right), + Column.new(:reductions, "Reds", align: :right), + Column.new(:message_queue_len, "Queue", align: :right) + ], + rows: rows + ) + end +end diff --git a/lib/term_ui/widget/progress.ex b/lib/term_ui/widget/progress.ex index 6831ea26..950f430c 100644 --- a/lib/term_ui/widget/progress.ex +++ b/lib/term_ui/widget/progress.ex @@ -1,178 +1,86 @@ defmodule TermUI.Widget.Progress do - @moduledoc """ - A widget for displaying progress bars and spinners. + @moduledoc "A pure determinate or indeterminate progress bar." + + @behaviour TermUI.Widget + + alias TermUI.Style + alias TermUI.Widget.Helpers + + @type t :: %__MODULE__{ + value: number(), + minimum: number(), + maximum: number(), + label: String.t() | nil, + show_percent: boolean(), + indeterminate: boolean(), + phase: non_neg_integer() + } + + @schema Zoi.struct(__MODULE__, %{ + value: Zoi.number() |> Zoi.default(0), + minimum: Zoi.number() |> Zoi.default(0), + maximum: Zoi.number() |> Zoi.default(100), + label: Zoi.any() |> Zoi.default(nil), + show_percent: Zoi.boolean() |> Zoi.default(true), + indeterminate: Zoi.boolean() |> Zoi.default(false), + phase: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) - Progress supports two modes: - - Bar mode: Shows a filled bar proportional to progress value - - Spinner mode: Shows an animated indicator for indeterminate progress - - ## Usage - - # Bar mode - Progress.render(%{value: 0.5}, state, area) - - # With percentage - Progress.render(%{value: 0.75, show_percentage: true}, state, area) - - # Spinner mode - Progress.render(%{mode: :spinner}, state, area) - - ## Props - - - `:value` - Progress value 0.0 to 1.0 (default: 0.0) - - `:mode` - `:bar` or `:spinner` (default: `:bar`) - - `:show_percentage` - Show percentage text (default: `false`) - - `:filled_char` - Character for filled portion (default: `"█"`) - - `:empty_char` - Character for empty portion (default: `"░"`) - - `:style` - Style options for the bar - """ - - use TermUI.StatefulComponent - - alias TermUI.Component.RenderNode - alias TermUI.Renderer.Style - - # Dialyzer: Suppress opaque type warnings for Style helpers - # no_opaque: Style contains MapSet which triggers false positive call_without_opaque warnings - @dialyzer [:no_opaque, nowarn_function: [build_style: 1, positioned_cell_safe: 4]] - - @spinner_frames ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] - - @doc """ - Initializes the progress widget state. - """ @impl true - def init(props) do - state = %{ - value: Map.get(props, :value, 0.0), - mode: Map.get(props, :mode, :bar), - spinner_frame: 0, - props: props + def init(opts) do + %__MODULE__{ + value: Keyword.get(opts, :value, 0), + minimum: Keyword.get(opts, :min, 0), + maximum: Keyword.get(opts, :max, 100), + label: Keyword.get(opts, :label), + show_percent: Keyword.get(opts, :show_percent, true), + indeterminate: Keyword.get(opts, :indeterminate, false) } - - {:ok, state} end - @doc """ - Handles events for the progress widget. - """ @impl true - def handle_event({:set_value, value}, state) do - {:ok, %{state | value: clamp(value, 0.0, 1.0)}} - end + def update(_event, state), do: {state, []} - def handle_event(:tick, state) do - # Advance spinner frame - next_frame = rem(state.spinner_frame + 1, length(@spinner_frames)) - {:ok, %{state | spinner_frame: next_frame}} - end - - def handle_event(_event, state) do - {:ok, state} - end - - @doc """ - Renders the progress indicator. - """ @impl true - def render(state, area) do - props = state.props - mode = Map.get(props, :mode, :bar) - style_opts = Map.get(props, :style, %{}) - style = build_style(style_opts) - - cells = - case mode do - :bar -> render_bar(props, state, area, style) - :spinner -> render_spinner(state, area, style) - end + def view(state, {width, _height} = dimensions) do + percent = percentage(state) - RenderNode.cells(cells) - end + suffix = + if state.show_percent and not state.indeterminate, do: " #{round(percent * 100)}%", else: "" - # Private Functions + prefix = if state.label, do: state.label <> " ", else: "" + bar_width = max(width - String.length(prefix <> suffix) - 2, 1) - defp render_bar(props, state, area, style) do - value = state.value - show_percentage = Map.get(props, :show_percentage, false) - filled_char = Map.get(props, :filled_char, "█") - empty_char = Map.get(props, :empty_char, "░") + filled = + if state.indeterminate, do: rem(state.phase, bar_width), else: round(percent * bar_width) - # Calculate bar width (reserve space for percentage if shown) - bar_width = - if show_percentage do - # " 100%" - max(1, area.width - 5) - else - area.width - end - - filled_width = round(value * bar_width) - empty_width = bar_width - filled_width - - # Build bar string bar = - String.duplicate(filled_char, filled_width) <> - String.duplicate(empty_char, empty_width) - - # Add percentage if requested - display = - if show_percentage do - percentage = round(value * 100) - bar <> " #{percentage}%" + if state.indeterminate do + String.duplicate("░", filled) <> + "█" <> String.duplicate("░", max(bar_width - filled - 1, 0)) else - bar + String.duplicate("█", filled) <> String.duplicate("░", max(bar_width - filled, 0)) end - # Create cells - display - |> String.graphemes() - |> Enum.with_index() - |> Enum.filter(fn {_char, x} -> x < area.width end) - |> Enum.map(fn {char, x} -> - positioned_cell_safe(x, 0, char, style) - end) + row = [prefix, {"[" <> bar <> "]", Style.new(fg: :green)}, suffix] + Helpers.frame([row], dimensions) end - defp render_spinner(state, area, style) do - frame = Enum.at(@spinner_frames, state.spinner_frame) + @doc "Sets the current value." + @spec set_value(t(), number()) :: t() + def set_value(state, value), do: %{state | value: value} - if area.width > 0 do - [positioned_cell_safe(0, 0, frame, style)] - else - [] - end - end - - # ---------------------------------------------------------------------------- - # Style Helper Functions - # ---------------------------------------------------------------------------- - - @spec positioned_cell_safe(integer(), integer(), String.t(), Style.t()) :: RenderNode.t() - defp positioned_cell_safe(x, y, char, style), - do: positioned_cell(x, y, char, style) - - # ---------------------------------------------------------------------------- - # Style Building - # ---------------------------------------------------------------------------- - - defp build_style(opts) when is_map(opts) do - style_list = - opts - |> Enum.map(fn - {:fg, color} -> {:fg, color} - {:bg, color} -> {:bg, color} - {:bold, true} -> {:attrs, [:bold]} - _ -> nil - end) - |> Enum.reject(&is_nil/1) - - Style.new(style_list) - end + @doc "Advances an indeterminate progress bar." + @spec tick(t()) :: t() + def tick(state), do: %{state | phase: state.phase + 1} - defp build_style(_), do: Style.new() + defp percentage(%{maximum: maximum, minimum: minimum}) when maximum <= minimum, do: 0.0 - defp clamp(value, min, max) do - value |> max(min) |> min(max) + defp percentage(state) do + ((state.value - state.minimum) / (state.maximum - state.minimum)) + |> max(0.0) + |> min(1.0) end end diff --git a/lib/term_ui/widget/scroll_bar.ex b/lib/term_ui/widget/scroll_bar.ex new file mode 100644 index 00000000..c9c81727 --- /dev/null +++ b/lib/term_ui/widget/scroll_bar.ex @@ -0,0 +1,134 @@ +defmodule TermUI.Widget.ScrollBar do + @moduledoc "A pure vertical or horizontal scrollbar." + + @behaviour TermUI.Widget + + alias TermUI.{Event, Style} + alias TermUI.Widget.Helpers + + @type t :: %__MODULE__{ + orientation: :vertical | :horizontal, + content_size: non_neg_integer(), + viewport_size: non_neg_integer(), + offset: non_neg_integer(), + dragging: boolean() + } + + @schema Zoi.struct(__MODULE__, %{ + orientation: Zoi.enum([:vertical, :horizontal]) |> Zoi.default(:vertical), + content_size: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0), + viewport_size: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0), + offset: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0), + dragging: Zoi.boolean() |> Zoi.default(false) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts) do + %__MODULE__{ + orientation: Keyword.get(opts, :orientation, :vertical), + content_size: max(Keyword.get(opts, :content_size, 0), 0), + viewport_size: max(Keyword.get(opts, :viewport_size, 0), 0), + offset: max(Keyword.get(opts, :offset, 0), 0), + dragging: false + } + |> normalize() + end + + @impl true + def update(%Event.Key{key: key}, state) when key in [:up, :left], do: move(state, -1) + def update(%Event.Key{key: key}, state) when key in [:down, :right], do: move(state, 1) + def update(%Event.Key{key: :page_up}, state), do: move(state, -state.viewport_size) + def update(%Event.Key{key: :page_down}, state), do: move(state, state.viewport_size) + def update(_event, state), do: {state, []} + + @impl true + def mouse(%Event.Mouse{action: :press, button: :left} = event, state, dimensions) do + state + |> Map.put(:dragging, true) + |> move_to_pointer(event, dimensions) + end + + def mouse( + %Event.Mouse{action: :drag, button: :left} = event, + %{dragging: true} = state, + dimensions + ), + do: move_to_pointer(state, event, dimensions) + + def mouse(%Event.Mouse{action: :release, button: :left}, state, _dimensions), + do: {%{state | dragging: false}, []} + + def mouse(event, state, _dimensions), do: update(event, state) + + @impl true + def view(%{orientation: :horizontal} = state, {width, _height} = dimensions) do + {start, thumb} = thumb(state, width) + + track = + String.duplicate("─", start) <> + String.duplicate("━", thumb) <> String.duplicate("─", max(width - start - thumb, 0)) + + Helpers.frame([[{track, Style.new(fg: :bright_black)}]], dimensions) + end + + def view(state, {_width, height} = dimensions) do + {start, thumb} = thumb(state, height) + + rows = + for index <- 0..(height - 1) do + char = if index >= start and index < start + thumb, do: "┃", else: "│" + [{char, Style.new(fg: :bright_black)}] + end + + Helpers.frame(rows, dimensions) + end + + @doc "Updates the measured content, viewport, and offset." + @spec set(t(), non_neg_integer(), non_neg_integer(), non_neg_integer()) :: t() + def set(state, content_size, viewport_size, offset) do + normalize(%{ + state + | content_size: max(content_size, 0), + viewport_size: max(viewport_size, 0), + offset: max(offset, 0) + }) + end + + defp move(state, delta) do + state = normalize(%{state | offset: state.offset + delta}) + {state, [{:scrolled, state.offset}]} + end + + defp move_to_pointer(state, event, dimensions) do + {position, track_size} = pointer_position(state.orientation, event, dimensions) + maximum_offset = max(state.content_size - state.viewport_size, 0) + denominator = max(track_size - 1, 1) + + offset = + round(Helpers.clamp(position, 0, max(track_size - 1, 0)) / denominator * maximum_offset) + + state = normalize(%{state | offset: offset}) + {state, [{:scrolled, state.offset}]} + end + + defp pointer_position(:horizontal, event, {width, _height}), do: {event.x, width} + defp pointer_position(:vertical, event, {_width, height}), do: {event.y, height} + + defp normalize(state), + do: %{ + state + | offset: Helpers.clamp(state.offset, 0, max(state.content_size - state.viewport_size, 0)) + } + + defp thumb(_state, track_size) when track_size <= 0, do: {0, 0} + defp thumb(%{content_size: size} = _state, track_size) when size <= 0, do: {0, track_size} + + defp thumb(state, track_size) do + thumb_size = max(round(track_size * min(state.viewport_size / state.content_size, 1.0)), 1) + maximum_offset = max(state.content_size - state.viewport_size, 1) + start = round((track_size - thumb_size) * state.offset / maximum_offset) + {start, thumb_size} + end +end diff --git a/lib/term_ui/widget/sparkline.ex b/lib/term_ui/widget/sparkline.ex new file mode 100644 index 00000000..0466007f --- /dev/null +++ b/lib/term_ui/widget/sparkline.ex @@ -0,0 +1,64 @@ +defmodule TermUI.Widget.Sparkline do + @moduledoc "A pure one-row sparkline for numeric samples." + + @behaviour TermUI.Widget + + alias TermUI.Style + alias TermUI.Widget.{ChartHelpers, Helpers} + + @levels String.graphemes("▁▂▃▄▅▆▇█") + @type t :: %__MODULE__{ + values: [number()], + minimum: number() | nil, + maximum: number() | nil, + style: Style.t(), + label: String.t() | nil + } + @schema Zoi.struct(__MODULE__, %{ + values: Zoi.array(Zoi.number()) |> Zoi.default([]), + minimum: Zoi.any() |> Zoi.default(nil), + maximum: Zoi.any() |> Zoi.default(nil), + style: Zoi.struct(Style) |> Zoi.default(%Style{fg: :cyan}), + label: Zoi.any() |> Zoi.default(nil) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts), + do: %__MODULE__{ + values: Keyword.get(opts, :values, []), + minimum: Keyword.get(opts, :min), + maximum: Keyword.get(opts, :max), + style: Keyword.get(opts, :style, Style.new(fg: :cyan)), + label: Keyword.get(opts, :label) + } + + @impl true + def update(_event, state), do: {state, []} + + @impl true + def view(state, {width, _height} = dimensions) do + prefix = if state.label, do: state.label <> " ", else: "" + sample_width = max(width - String.length(prefix), 0) + values = Enum.take(state.values, -sample_width) + + {minimum, maximum} = + ChartHelpers.range(values, + min: state.minimum || Enum.min(values, fn -> 0 end), + max: state.maximum || Enum.max(values, fn -> 1 end) + ) + + chars = + Enum.map_join(values, fn value -> + Enum.at(@levels, round(ChartHelpers.normalize(value, minimum, maximum) * 7)) + end) + + Helpers.frame([[prefix, {chars, state.style}]], dimensions) + end + + @doc "Appends one value and retains at most limit values." + @spec push(t(), number(), pos_integer()) :: t() + def push(state, value, limit \\ 1_000), + do: %{state | values: Enum.take(state.values ++ [value], -max(limit, 1))} +end diff --git a/lib/term_ui/widget/split_pane.ex b/lib/term_ui/widget/split_pane.ex new file mode 100644 index 00000000..d1cc7081 --- /dev/null +++ b/lib/term_ui/widget/split_pane.ex @@ -0,0 +1,121 @@ +defmodule TermUI.Widget.SplitPane do + @moduledoc "A pure horizontal or vertical frame composition widget." + + @behaviour TermUI.Widget + + alias TermUI.{Event, Frame, Style} + + @type content :: + Frame.t() + | [Frame.row()] + | String.t() + | {module(), term()} + | (TermUI.Widget.dimensions() -> Frame.t()) + @type t :: %__MODULE__{ + first: content(), + second: content(), + direction: :horizontal | :vertical, + ratio: float(), + dragging: boolean() + } + @schema Zoi.struct(__MODULE__, %{ + first: Zoi.any() |> Zoi.default([]), + second: Zoi.any() |> Zoi.default([]), + direction: Zoi.enum([:horizontal, :vertical]) |> Zoi.default(:horizontal), + ratio: Zoi.number() |> Zoi.gte(0.1) |> Zoi.lte(0.9) |> Zoi.default(0.5), + dragging: Zoi.boolean() |> Zoi.default(false) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts) do + %__MODULE__{ + first: Keyword.get(opts, :first, []), + second: Keyword.get(opts, :second, []), + direction: Keyword.get(opts, :direction, :horizontal), + ratio: opts |> Keyword.get(:ratio, 0.5) |> max(0.1) |> min(0.9), + dragging: false + } + end + + @impl true + def update(_event, state), do: {state, []} + + @impl true + def mouse(%Event.Mouse{action: :press, button: :left} = event, state, dimensions) do + if separator?(state, event, dimensions), + do: {%{state | dragging: true}, []}, + else: {state, []} + end + + def mouse( + %Event.Mouse{action: :drag, button: :left} = event, + %{dragging: true} = state, + dimensions + ) do + ratio = pointer_ratio(state.direction, event, dimensions) + state = %{state | ratio: ratio} + {state, [{:resized, ratio}]} + end + + def mouse(%Event.Mouse{action: :release, button: :left}, state, _dimensions), + do: {%{state | dragging: false}, []} + + def mouse(event, state, _dimensions), do: update(event, state) + + @impl true + def view(%{direction: :vertical} = state, {width, height}) do + first_height = max(round((height - 1) * state.ratio), 1) + second_height = max(height - first_height - 1, 1) + + base = + Frame.new(width, height) + |> Frame.put_row(first_height + 1, [ + {String.duplicate("─", width), Style.new(fg: :bright_black)} + ]) + + base + |> Frame.overlay(resolve(state.first, {width, first_height}), 1, 1) + |> Frame.overlay(resolve(state.second, {width, second_height}), 1, first_height + 2) + end + + def view(state, {width, height}) do + first_width = max(round((width - 1) * state.ratio), 1) + second_width = max(width - first_width - 1, 1) + separator = Style.to_cell(Style.new(fg: :bright_black), "│") + + base = + Enum.reduce(1..height, Frame.new(width, height), fn row, frame -> + Frame.put_cell(frame, row, first_width + 1, separator) + end) + + base + |> Frame.overlay(resolve(state.first, {first_width, height}), 1, 1) + |> Frame.overlay(resolve(state.second, {second_width, height}), first_width + 2, 1) + end + + defp resolve(%Frame{} = frame, _dimensions), do: frame + + defp resolve({module, widget_state}, dimensions), + do: TermUI.Widget.view(module, widget_state, dimensions) + + defp resolve(fun, dimensions) when is_function(fun, 1), do: fun.(dimensions) + + defp resolve(content, {width, height}) when is_binary(content), + do: Frame.from_rows(String.split(content, "\n", trim: false), width, height) + + defp resolve(rows, {width, height}) when is_list(rows), do: Frame.from_rows(rows, width, height) + + defp separator?(%{direction: :horizontal, ratio: ratio}, event, {width, _height}), + do: event.x == max(round((width - 1) * ratio), 1) + + defp separator?(%{direction: :vertical, ratio: ratio}, event, {_width, height}), + do: event.y == max(round((height - 1) * ratio), 1) + + defp pointer_ratio(:horizontal, event, {width, _height}), + do: (event.x / max(width - 1, 1)) |> max(0.1) |> min(0.9) + + defp pointer_ratio(:vertical, event, {_width, height}), + do: (event.y / max(height - 1, 1)) |> max(0.1) |> min(0.9) +end diff --git a/lib/term_ui/widget/stream.ex b/lib/term_ui/widget/stream.ex new file mode 100644 index 00000000..0c519762 --- /dev/null +++ b/lib/term_ui/widget/stream.ex @@ -0,0 +1,91 @@ +defmodule TermUI.Widget.Stream do + @moduledoc "A pure bounded stream view. The parent supplies new items." + + @behaviour TermUI.Widget + + alias TermUI.{Event, Frame, Style} + alias TermUI.Widget.Helpers + + @type t :: %__MODULE__{ + items: [term()], + limit: pos_integer(), + paused: boolean(), + offset: non_neg_integer(), + page_size: pos_integer(), + formatter: (term() -> iodata()) + } + @schema Zoi.struct(__MODULE__, %{ + items: Zoi.array() |> Zoi.default([]), + limit: Zoi.integer() |> Zoi.positive() |> Zoi.default(1_000), + paused: Zoi.boolean() |> Zoi.default(false), + offset: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0), + page_size: Zoi.integer() |> Zoi.positive() |> Zoi.default(20), + formatter: Zoi.function() |> Zoi.default(&__MODULE__.default_format/1) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts), + do: %__MODULE__{ + items: Enum.take(Keyword.get(opts, :items, []), -max(Keyword.get(opts, :limit, 1_000), 1)), + limit: max(Keyword.get(opts, :limit, 1_000), 1), + page_size: max(Keyword.get(opts, :page_size, 20), 1), + formatter: Keyword.get(opts, :formatter, &__MODULE__.default_format/1) + } + + @impl true + def update(%Event.Key{key: :space}, state), + do: {%{state | paused: not state.paused}, [{:paused, not state.paused}]} + + def update(%Event.Text{text: " "}, state), + do: {%{state | paused: not state.paused}, [{:paused, not state.paused}]} + + def update(%Event.Key{key: :up}, state), do: scroll(state, -1) + def update(%Event.Key{key: :down}, state), do: scroll(state, 1) + def update(%Event.Key{key: :page_up}, state), do: scroll(state, -state.page_size) + def update(%Event.Key{key: :page_down}, state), do: scroll(state, state.page_size) + + def update(%Event.Key{key: :end}, state), + do: {%{state | offset: max(length(state.items) - state.page_size, 0)}, []} + + def update(_event, state), do: {state, []} + + @impl true + def view(state, {width, height} = dimensions) do + status = + if state.paused, + do: [{" PAUSED ", Style.new(fg: :black, bg: :yellow, attrs: [:bold])}], + else: [{" LIVE ", Style.new(fg: :black, bg: :green, attrs: [:bold])}] + + body_height = max(height - 1, 0) + + offset = + if state.paused, + do: min(state.offset, max(length(state.items) - body_height, 0)), + else: max(length(state.items) - body_height, 0) + + rows = + state.items + |> Enum.slice(offset, body_height) + |> Enum.flat_map(fn item -> + item |> state.formatter.() |> IO.iodata_to_binary() |> Frame.wrap(width) + end) + |> Enum.take(body_height) + + Helpers.frame([status | rows], dimensions) + end + + @doc "Appends one stream item unless the view is paused." + @spec push(t(), term()) :: t() + def push(%{paused: true} = state, _item), do: state + def push(state, item), do: %{state | items: Enum.take(state.items ++ [item], -state.limit)} + + @doc false + def default_format(item), do: to_string(item) + + defp scroll(state, delta) do + offset = Helpers.scroll(state.offset, delta, length(state.items), state.page_size) + {%{state | offset: offset, paused: true}, [{:scrolled, offset}]} + end +end diff --git a/lib/term_ui/widget/stream_widget.ex b/lib/term_ui/widget/stream_widget.ex new file mode 100644 index 00000000..ae386911 --- /dev/null +++ b/lib/term_ui/widget/stream_widget.ex @@ -0,0 +1,10 @@ +defmodule TermUI.Widget.StreamWidget do + @moduledoc "Compatibility name for the pure `TermUI.Widget.Stream` view." + + @behaviour TermUI.Widget + + defdelegate init(opts), to: TermUI.Widget.Stream + defdelegate update(event, state), to: TermUI.Widget.Stream + defdelegate view(state, dimensions), to: TermUI.Widget.Stream + defdelegate push(state, item), to: TermUI.Widget.Stream +end diff --git a/lib/term_ui/widget/supervision_tree.ex b/lib/term_ui/widget/supervision_tree.ex new file mode 100644 index 00000000..95f0c755 --- /dev/null +++ b/lib/term_ui/widget/supervision_tree.ex @@ -0,0 +1,37 @@ +defmodule TermUI.Widget.SupervisionTree do + @moduledoc "A pure supervision-tree view built from parent-supplied snapshots." + + @behaviour TermUI.Widget + + alias TermUI.Widget.TreeView + + @type t :: %__MODULE__{tree: TreeView.t()} + @schema Zoi.struct(__MODULE__, %{ + tree: Zoi.struct(TreeView) |> Zoi.default(%TreeView{}) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts), + do: %__MODULE__{ + tree: + TreeView.init( + nodes: Keyword.get(opts, :nodes, []), + expanded: Keyword.get(opts, :expanded, []) + ) + } + + @impl true + def update(event, state) do + {tree, messages} = TreeView.update(event, state.tree) + {%{state | tree: tree}, messages} + end + + @impl true + def view(state, dimensions), do: TreeView.view(state.tree, dimensions) + + @doc "Replaces the parent-supplied supervision snapshot." + @spec set_nodes(t(), [TreeView.tree_node()]) :: t() + def set_nodes(state, nodes), do: %{state | tree: %{state.tree | nodes: nodes}} +end diff --git a/lib/term_ui/widget/supervision_tree_viewer.ex b/lib/term_ui/widget/supervision_tree_viewer.ex new file mode 100644 index 00000000..966362da --- /dev/null +++ b/lib/term_ui/widget/supervision_tree_viewer.ex @@ -0,0 +1,10 @@ +defmodule TermUI.Widget.SupervisionTreeViewer do + @moduledoc "Compatibility name for the pure `TermUI.Widget.SupervisionTree` view." + + @behaviour TermUI.Widget + + defdelegate init(opts), to: TermUI.Widget.SupervisionTree + defdelegate update(event, state), to: TermUI.Widget.SupervisionTree + defdelegate view(state, dimensions), to: TermUI.Widget.SupervisionTree + defdelegate set_nodes(state, nodes), to: TermUI.Widget.SupervisionTree +end diff --git a/lib/term_ui/widget/table.ex b/lib/term_ui/widget/table.ex new file mode 100644 index 00000000..94b8a4a1 --- /dev/null +++ b/lib/term_ui/widget/table.ex @@ -0,0 +1,178 @@ +defmodule TermUI.Widget.Table do + @moduledoc "A pure scrollable table with column definitions and row selection." + + @behaviour TermUI.Widget + + alias TermUI.{Event, Style} + alias TermUI.Widget.Helpers + alias TermUI.Widget.Table.Column + + @dialyzer {:nowarn_function, view: 2, render_cells: 4} + + @type t :: %__MODULE__{ + columns: [Column.t()], + rows: [term()], + cursor: non_neg_integer(), + offset: non_neg_integer(), + page_size: pos_integer(), + show_header: boolean() + } + + @schema Zoi.struct(__MODULE__, %{ + columns: Zoi.array(Zoi.struct(Column)) |> Zoi.default([]), + rows: Zoi.array() |> Zoi.default([]), + cursor: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0), + offset: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0), + page_size: Zoi.integer() |> Zoi.positive() |> Zoi.default(10), + show_header: Zoi.boolean() |> Zoi.default(true) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts) do + %__MODULE__{ + columns: opts |> Keyword.get(:columns, []) |> Enum.map(&normalize_column/1), + rows: Keyword.get(opts, :rows, []), + page_size: max(Keyword.get(opts, :page_size, 10), 1), + show_header: Keyword.get(opts, :show_header, true) + } + end + + @impl true + def update(%Event.Key{key: :up}, state), do: move(state, -1) + def update(%Event.Key{key: :down}, state), do: move(state, 1) + def update(%Event.Key{key: :home}, state), do: move_to(state, 0) + def update(%Event.Key{key: :end}, state), do: move_to(state, length(state.rows) - 1) + def update(%Event.Key{key: :page_up}, state), do: move(state, -state.page_size) + def update(%Event.Key{key: :page_down}, state), do: move(state, state.page_size) + + def update(%Event.Key{key: :enter}, state) do + case Enum.at(state.rows, state.cursor) do + nil -> {state, []} + row -> {state, [{:selected, row}]} + end + end + + def update(_event, state), do: {state, []} + + @impl true + def mouse(%Event.Mouse{action: action, button: :left, y: y}, state, {_width, height}) + when action in [:press, :release] do + header_height = if state.show_header, do: 1, else: 0 + body_height = max(height - header_height, 0) + offset = visible_offset(state.cursor, state.offset, body_height) + index = offset + y - header_height + + if y >= header_height and y < height and index < length(state.rows) do + state = %{state | cursor: index, offset: offset} + + if action == :release, + do: update(Event.key(:enter), state), + else: {state, []} + else + {state, []} + end + end + + def mouse(event, state, _dimensions), do: update(event, state) + + @impl true + def view(state, {width, height} = dimensions) do + widths = column_widths(state.columns, width) + header_height = if state.show_header, do: 1, else: 0 + body_height = max(height - header_height, 0) + offset = visible_offset(state.cursor, state.offset, body_height) + header_style = Style.new(fg: :cyan, attrs: [:bold, :underline]) + cursor_style = Style.new(attrs: [:reverse]) + + header = + if state.show_header, + do: [ + render_cells(Enum.map(state.columns, & &1.label), state.columns, widths, header_style) + ], + else: [] + + body = + state.rows + |> Enum.slice(offset, body_height) + |> Enum.with_index(offset) + |> Enum.map(fn {row, index} -> + values = Enum.map(state.columns, &cell_value(row, &1.key)) + + render_cells( + values, + state.columns, + widths, + if(index == state.cursor, do: cursor_style, else: Style.new()) + ) + end) + + Helpers.frame(header ++ body, dimensions) + end + + @doc "Replaces all table rows." + @spec set_rows(t(), [term()]) :: t() + def set_rows(state, rows), + do: %{state | rows: rows, cursor: min(state.cursor, max(length(rows) - 1, 0))} + + defp move(state, delta), do: move_to(state, state.cursor + delta) + + defp move_to(state, cursor) do + cursor = Helpers.clamp(cursor, 0, max(length(state.rows) - 1, 0)) + offset = visible_offset(cursor, state.offset, state.page_size) + {%{state | cursor: cursor, offset: offset}, []} + end + + defp visible_offset(_cursor, offset, 0), do: offset + defp visible_offset(cursor, offset, _height) when cursor < offset, do: cursor + + defp visible_offset(cursor, offset, height) when cursor >= offset + height, + do: cursor - height + 1 + + defp visible_offset(_cursor, offset, _height), do: offset + + defp render_cells(values, columns, widths, style) do + values + |> Enum.zip(columns) + |> Enum.zip(widths) + |> Enum.with_index() + |> Enum.flat_map(fn {{{value, column}, cell_width}, index} -> + separator = if index == length(widths) - 1, do: "", else: " │ " + + [ + {Helpers.align(to_string(value || ""), cell_width, column.align), style}, + {separator, Style.new(fg: :bright_black)} + ] + end) + end + + defp column_widths(columns, width) do + separators = max(length(columns) - 1, 0) * 3 + available = max(width - separators, length(columns)) + + fixed = + Enum.reduce(columns, 0, fn + %{width: column_width}, sum when is_integer(column_width) -> sum + column_width + _, sum -> sum + end) + + automatic = Enum.count(columns, &(&1.width == :auto)) + + auto_width = + if automatic > 0, do: max(div(max(available - fixed, automatic), automatic), 1), else: 1 + + Enum.map(columns, fn %{width: column_width} -> + if is_integer(column_width), do: column_width, else: auto_width + end) + end + + defp cell_value(row, key) when is_map(row), do: Map.get(row, key, Map.get(row, to_string(key))) + defp cell_value(row, key) when is_list(row) and is_integer(key), do: Enum.at(row, key) + defp cell_value(row, key) when is_tuple(row) and is_integer(key), do: elem(row, key) + defp cell_value(_row, _key), do: nil + + defp normalize_column(%Column{} = column), do: column + defp normalize_column({key, label}), do: Column.new(key, label) + defp normalize_column(key), do: Column.new(key, to_string(key)) +end diff --git a/lib/term_ui/widget/table/column.ex b/lib/term_ui/widget/table/column.ex new file mode 100644 index 00000000..03056911 --- /dev/null +++ b/lib/term_ui/widget/table/column.ex @@ -0,0 +1,36 @@ +defmodule TermUI.Widget.Table.Column do + @moduledoc "A column definition for `TermUI.Widget.Table`." + + @type t :: %__MODULE__{ + key: term(), + label: String.t(), + width: pos_integer() | :auto, + align: :left | :center | :right + } + + @schema Zoi.struct(__MODULE__, %{ + key: Zoi.any(), + label: Zoi.string(), + width: + Zoi.union([Zoi.integer() |> Zoi.positive(), Zoi.literal(:auto)]) + |> Zoi.default(:auto), + align: Zoi.enum([:left, :center, :right]) |> Zoi.default(:left) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @doc "Returns the Zoi schema for table columns." + @spec schema() :: Zoi.schema() + def schema, do: @schema + + @doc "Creates a column definition." + @spec new(term(), iodata(), keyword()) :: t() + def new(key, label, opts \\ []) do + %__MODULE__{ + key: key, + label: IO.iodata_to_binary(label), + width: Keyword.get(opts, :width, :auto), + align: Keyword.get(opts, :align, :left) + } + end +end diff --git a/lib/term_ui/widget/tabs.ex b/lib/term_ui/widget/tabs.ex new file mode 100644 index 00000000..201407db --- /dev/null +++ b/lib/term_ui/widget/tabs.ex @@ -0,0 +1,157 @@ +defmodule TermUI.Widget.Tabs do + @moduledoc "A pure tab strip with optional frame or row content." + + @behaviour TermUI.Widget + + alias TermUI.{Event, Frame, Style} + alias TermUI.Widget.Helpers + + @type tab :: %{ + required(:id) => term(), + required(:label) => String.t(), + optional(:content) => term(), + optional(:disabled) => boolean() + } + @type t :: %__MODULE__{tabs: [tab()], selected: non_neg_integer(), focused: non_neg_integer()} + @schema Zoi.struct(__MODULE__, %{ + tabs: Zoi.array() |> Zoi.default([]), + selected: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0), + focused: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts) do + tabs = opts |> Keyword.get(:tabs, []) |> Enum.map(&normalize_tab/1) + selected = selected_index(tabs, Keyword.get(opts, :selected), 0) + %__MODULE__{tabs: tabs, selected: selected, focused: selected} + end + + @impl true + def update(%Event.Key{key: :left}, state), do: move(state, -1) + def update(%Event.Key{key: :right}, state), do: move(state, 1) + def update(%Event.Key{key: :home}, state), do: focus_index(state, 0) + def update(%Event.Key{key: :end}, state), do: focus_index(state, length(state.tabs) - 1) + def update(%Event.Key{key: key}, state) when key in [:enter, :space], do: select_focused(state) + def update(%Event.Text{text: " "}, state), do: select_focused(state) + def update(_event, state), do: {state, []} + + @impl true + def mouse(%Event.Mouse{action: action, button: :left, x: x, y: 0}, state, _dimensions) + when action in [:press, :release] do + case tab_at(state.tabs, x) do + nil -> + {state, []} + + index -> + state = %{state | focused: index} + if action == :release, do: select_focused(state), else: {state, []} + end + end + + def mouse(event, state, _dimensions), do: update(event, state) + + @impl true + def view(state, {width, height}) do + normal = Style.new(fg: :bright_black) + active = Style.new(fg: :cyan, attrs: [:bold, :underline]) + focused = Style.new(attrs: [:reverse]) + + header = + state.tabs + |> Enum.with_index() + |> Enum.flat_map(fn {tab, index} -> + style = + cond do + index == state.focused -> focused + index == state.selected -> active + true -> normal + end + + [{" " <> tab.label <> " ", style}, " "] + end) + + base = Frame.from_rows([header], width, height) + + case Enum.at(state.tabs, state.selected) do + nil -> base + tab -> overlay_content(base, Map.get(tab, :content), width, max(height - 1, 1)) + end + end + + @doc "Returns the selected tab or nil." + @spec selected(t()) :: tab() | nil + def selected(state), do: Enum.at(state.tabs, state.selected) + + @doc "Selects a tab by id." + @spec select(t(), term()) :: t() + def select(state, id) do + index = selected_index(state.tabs, id, state.selected) + %{state | selected: index, focused: index} + end + + defp move(%{tabs: []} = state, _delta), do: {state, []} + + defp move(state, delta) do + count = length(state.tabs) + next = rem(state.focused + delta + count, count) + focus_index(state, next) + end + + defp focus_index(state, index) do + index = Helpers.clamp(index, 0, max(length(state.tabs) - 1, 0)) + {%{state | focused: index}, [{:focused, Enum.at(state.tabs, index)}]} + end + + defp select_focused(state) do + tab = Enum.at(state.tabs, state.focused) + + if tab && not Map.get(tab, :disabled, false), + do: {%{state | selected: state.focused}, [{:selected, tab.id}]}, + else: {state, []} + end + + defp overlay_content(base, %Frame{} = content, _width, _height), + do: Frame.overlay(base, content, 1, 2) + + defp overlay_content(base, content, width, height) do + rows = + cond do + is_binary(content) -> String.split(content, "\n", trim: false) + is_list(content) -> content + is_nil(content) -> [] + true -> [to_string(content)] + end + + Frame.overlay(base, Helpers.frame(rows, {width, height}), 1, 2) + end + + defp normalize_tab(%{id: _id, label: label} = tab), do: %{tab | label: to_string(label)} + defp normalize_tab({id, label}), do: %{id: id, label: to_string(label)} + defp normalize_tab(label), do: %{id: label, label: to_string(label)} + + defp tab_at(tabs, x) when x >= 0 do + tabs + |> Enum.with_index() + |> Enum.reduce_while({:after, 0}, fn {tab, index}, {:after, start} -> + next = start + Helpers.text_width(" " <> tab.label <> " ") + 1 + + if x < next, + do: {:halt, {:found, index}}, + else: {:cont, {:after, next}} + end) + |> case do + {:found, index} -> index + {:after, _end} -> nil + end + end + + defp tab_at(_tabs, _x), do: nil + + defp selected_index(tabs, nil, fallback), + do: Helpers.clamp(fallback, 0, max(length(tabs) - 1, 0)) + + defp selected_index(tabs, id, fallback), + do: Enum.find_index(tabs, &(&1.id == id)) || selected_index(tabs, nil, fallback) +end diff --git a/lib/term_ui/widget/text_area.ex b/lib/term_ui/widget/text_area.ex new file mode 100644 index 00000000..659211a9 --- /dev/null +++ b/lib/term_ui/widget/text_area.ex @@ -0,0 +1,387 @@ +defmodule TermUI.Widget.TextArea do + @moduledoc """ + A pure multiline Unicode text editor with automatic cursor scrolling. + + `init/1` accepts `:value`, `:placeholder`, `:max_length`, and + `:selection_style`. Text and paste events replace the selected grapheme + range. Ctrl+Enter emits `{:submit, value}`. Other edits emit + `{:changed, value}`. + + Shift with navigation keys changes the selection. Ctrl+A selects all text. + Ctrl+C returns `{:copy, text}`. Ctrl+X also removes the selection. Mouse + press and drag use zero-based local coordinates from `mouse/3`. + """ + + @behaviour TermUI.Widget + + alias TermUI.{DisplayWidth, Event, Frame, Selection, Style} + alias TermUI.Widget.Helpers + + @type t :: %__MODULE__{ + value: String.t(), + cursor: non_neg_integer(), + placeholder: String.t(), + max_length: pos_integer() | :infinity, + selection: Selection.t(), + selection_style: Style.t() + } + @schema Zoi.struct(__MODULE__, %{ + value: Zoi.string() |> Zoi.default(""), + cursor: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0), + placeholder: Zoi.string() |> Zoi.default(""), + max_length: + Zoi.union([Zoi.integer() |> Zoi.positive(), Zoi.literal(:infinity)]) + |> Zoi.default(:infinity), + selection: Zoi.struct(Selection) |> Zoi.default(%Selection{}), + selection_style: + Zoi.struct(Style) + |> Zoi.default(%Style{attrs: MapSet.new([:reverse])}) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts) do + value = Keyword.get(opts, :value, "") + + %__MODULE__{ + value: value, + cursor: length(String.graphemes(value)), + placeholder: Keyword.get(opts, :placeholder, ""), + max_length: Keyword.get(opts, :max_length, :infinity), + selection_style: Keyword.get(opts, :selection_style, Style.new(attrs: [:reverse])) + } + end + + @impl true + def update(%Event.Text{text: text}, state), do: insert(state, text) + def update(%Event.Paste{content: text}, state), do: insert(state, text) + + def update(%Event.Key{key: "a", modifiers: modifiers}, state) do + if :ctrl in modifiers, + do: {%{state | selection: Selection.select_all(state.selection, state.value)}, []}, + else: {state, []} + end + + def update(%Event.Key{key: "c", modifiers: modifiers}, state) do + if :ctrl in modifiers, do: copy_selection(state), else: {state, []} + end + + def update(%Event.Key{key: "x", modifiers: modifiers}, state) do + if :ctrl in modifiers, do: cut_selection(state), else: {state, []} + end + + def update(%Event.Key{key: :enter, modifiers: modifiers}, state) do + if :ctrl in modifiers, do: {state, [{:submit, state.value}]}, else: insert(state, "\n") + end + + def update(%Event.Key{key: :left, modifiers: modifiers}, state), + do: horizontal(state, -1, modifiers) + + def update(%Event.Key{key: :right, modifiers: modifiers}, state), + do: horizontal(state, 1, modifiers) + + def update(%Event.Key{key: :home, modifiers: modifiers}, state), + do: navigate(state, line_start(state), modifiers) + + def update(%Event.Key{key: :end, modifiers: modifiers}, state), + do: navigate(state, line_end(state), modifiers) + + def update(%Event.Key{key: :up, modifiers: modifiers}, state), + do: navigate(state, vertical(state, -1), modifiers) + + def update(%Event.Key{key: :down, modifiers: modifiers}, state), + do: navigate(state, vertical(state, 1), modifiers) + + def update(%Event.Key{key: :backspace}, state) do + cond do + not Selection.empty?(state.selection) -> delete_selection(state) + state.cursor == 0 -> {state, []} + true -> delete_before_cursor(state) + end + end + + def update(%Event.Key{key: :delete}, state) do + cond do + not Selection.empty?(state.selection) -> delete_selection(state) + state.cursor < count(state.value) -> delete_at_cursor(state) + true -> {state, []} + end + end + + def update(_event, state), do: {state, []} + + @impl true + def view(state, {width, height}) do + text = + if state.value == "" and state.placeholder != "", do: state.placeholder, else: state.value + + rows = Frame.wrap(text, width) + layout = text_layout(state.value, width) + {cursor_column, cursor_row} = Map.fetch!(layout.cursors, state.cursor) + rows = rows ++ List.duplicate("", max(cursor_row - length(rows), 0)) + offset = max(cursor_row - height, 0) + visible = Enum.slice(rows, offset, height) + + visible + |> Frame.from_rows(width, height, cursor: {cursor_column, cursor_row - offset}) + |> apply_selection(state, layout, offset) + end + + @impl true + def mouse(%Event.Mouse{action: :press, button: :left, modifiers: modifiers} = event, state, { + width, + height + }) do + position = cursor_at(state, width, height, event.x, event.y) + + selection = + if :shift in modifiers and Selection.active?(state.selection), + do: Selection.extend(state.selection, position), + else: Selection.start(state.selection, position) + + {%{state | cursor: position, selection: selection}, []} + end + + def mouse(%Event.Mouse{action: :drag, button: :left} = event, state, {width, height}) do + position = cursor_at(state, width, height, event.x, event.y) + + selection = + if Selection.active?(state.selection), + do: Selection.extend(state.selection, position), + else: state.selection |> Selection.start(state.cursor) |> Selection.extend(position) + + {%{state | cursor: position, selection: selection}, []} + end + + def mouse(event, state, _dimensions), do: update(event, state) + + @doc "Returns the current value." + @spec value(t()) :: String.t() + def value(state), do: state.value + + @doc "Replaces the current value and moves the cursor to the end." + @spec set_value(t(), String.t()) :: t() + def set_value(state, value), + do: %{state | value: value, cursor: count(value), selection: Selection.clear(state.selection)} + + defp insert(state, text) do + inserted = + text + |> String.replace("\r\n", "\n") + |> String.replace("\r", "\n") + |> String.replace("\t", " ") + |> String.replace(~r/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u, "") + |> String.graphemes() + + {value, cursor} = + if Selection.empty?(state.selection) do + {state.value, state.cursor} + else + {value, cursor, _selection} = Selection.replace(state.selection, state.value, "") + {value, cursor} + end + + {before, after_cursor} = Enum.split(String.graphemes(value), cursor) + all = limit(before ++ inserted ++ after_cursor, state.max_length) + cursor = min(length(before) + length(inserted), length(all)) + + changed(%{ + state + | value: Enum.join(all), + cursor: cursor, + selection: Selection.clear(state.selection) + }) + end + + defp horizontal(state, delta, modifiers) do + target = + if :shift not in modifiers and not Selection.empty?(state.selection) do + {start, finish} = Selection.range(state.selection) + if delta < 0, do: start, else: finish + else + state.cursor + delta + end + + navigate(state, target, modifiers) + end + + defp navigate(state, target, modifiers) do + target = Helpers.clamp(target, 0, count(state.value)) + + selection = + if :shift in modifiers do + if Selection.active?(state.selection), + do: Selection.extend(state.selection, target), + else: state.selection |> Selection.start(state.cursor) |> Selection.extend(target) + else + Selection.clear(state.selection) + end + + {%{state | cursor: target, selection: selection}, []} + end + + defp copy_selection(state) do + if Selection.empty?(state.selection), + do: {state, []}, + else: {state, [{:copy, Selection.extract(state.selection, state.value)}]} + end + + defp cut_selection(state) do + if Selection.empty?(state.selection) do + {state, []} + else + copied = Selection.extract(state.selection, state.value) + {value, cursor, selection} = Selection.replace(state.selection, state.value, "") + state = %{state | value: value, cursor: cursor, selection: selection} + {state, [{:copy, copied}, {:changed, value}]} + end + end + + defp delete_selection(state) do + {value, cursor, selection} = Selection.replace(state.selection, state.value, "") + changed(%{state | value: value, cursor: cursor, selection: selection}) + end + + defp delete_before_cursor(state) do + graphemes = String.graphemes(state.value) + + changed(%{ + state + | value: graphemes |> List.delete_at(state.cursor - 1) |> Enum.join(), + cursor: state.cursor - 1 + }) + end + + defp delete_at_cursor(state) do + value = state.value |> String.graphemes() |> List.delete_at(state.cursor) |> Enum.join() + changed(%{state | value: value}) + end + + defp changed(state), do: {state, [{:changed, state.value}]} + defp count(text), do: text |> String.graphemes() |> length() + defp limit(graphemes, :infinity), do: graphemes + defp limit(graphemes, maximum), do: Enum.take(graphemes, maximum) + + defp line_start(state) do + before = Enum.take(String.graphemes(state.value), state.cursor) + + case Enum.find_index(Enum.reverse(before), &(&1 == "\n")) do + nil -> 0 + distance -> state.cursor - distance - 1 + end + end + + defp line_end(state) do + after_cursor = Enum.drop(String.graphemes(state.value), state.cursor) + + case Enum.find_index(after_cursor, &(&1 == "\n")) do + nil -> count(state.value) + distance -> state.cursor + distance + end + end + + defp vertical(state, delta) do + lines = String.split(state.value, "\n", trim: false) + before = Enum.take(String.graphemes(state.value), state.cursor) |> Enum.join() + row = before |> String.split("\n", trim: false) |> length() |> Kernel.-(1) + + column = + before |> String.split("\n", trim: false) |> List.last() |> String.graphemes() |> length() + + target_row = Helpers.clamp(row + delta, 0, length(lines) - 1) + prefix = lines |> Enum.take(target_row) |> Enum.map(&(count(&1) + 1)) |> Enum.sum() + prefix + min(column, lines |> Enum.at(target_row) |> count()) + end + + defp text_layout(text, width) do + text + |> String.graphemes() + |> Enum.with_index() + |> Enum.reduce( + %{cursors: %{0 => {1, 1}}, cells: [], column: 1, row: 1, soft_wrapped: false}, + fn + {"\n", index}, layout -> + next_row = if layout.soft_wrapped, do: layout.row, else: layout.row + 1 + next = {1, next_row} + + %{ + layout + | cursors: Map.put(layout.cursors, index + 1, next), + column: 1, + row: next_row, + soft_wrapped: false + } + + {grapheme, index}, layout -> + grapheme_width = max(DisplayWidth.width(grapheme), 1) + + {column, row} = + if layout.column > 1 and layout.column - 1 + grapheme_width > width, + do: {1, layout.row + 1}, + else: {layout.column, layout.row} + + next = + if column + grapheme_width > width, + do: {1, row + 1}, + else: {column + grapheme_width, row} + + %{ + layout + | cursors: Map.put(layout.cursors, index + 1, next), + cells: [{index, grapheme, column, row} | layout.cells], + column: elem(next, 0), + row: elem(next, 1), + soft_wrapped: elem(next, 1) > row + } + end + ) + |> Map.update!(:cells, &Enum.reverse/1) + end + + defp apply_selection(frame, state, layout, offset) do + if Selection.empty?(state.selection) do + frame + else + Enum.reduce(layout.cells, frame, &put_selected_cell(&1, &2, state, offset)) + end + end + + defp put_selected_cell({index, grapheme, column, row}, frame, state, offset) do + visible_row = row - offset + + if Selection.contains?(state.selection, index) and visible_row >= 1 and + visible_row <= frame.height do + Frame.put_cell( + frame, + visible_row, + column, + Style.to_cell(state.selection_style, grapheme) + ) + else + frame + end + end + + defp cursor_at(state, width, height, x, y) do + layout = text_layout(state.value, width) + {_cursor_column, cursor_row} = Map.fetch!(layout.cursors, state.cursor) + offset = max(cursor_row - height, 0) + target_row = offset + max(y, 0) + 1 + target_column = max(x, 0) + 1 + + points = + layout.cursors + |> Enum.filter(fn {_index, {_column, row}} -> row == target_row end) + + case points do + [] -> + if target_row <= 1, do: 0, else: count(state.value) + + candidates -> + candidates + |> Enum.min_by(fn {index, {column, _row}} -> {abs(column - target_column), index} end) + |> elem(0) + end + end +end diff --git a/lib/term_ui/widget/text_input.ex b/lib/term_ui/widget/text_input.ex index 2f2a4390..ffa1f8ef 100644 --- a/lib/term_ui/widget/text_input.ex +++ b/lib/term_ui/widget/text_input.ex @@ -1,268 +1,339 @@ defmodule TermUI.Widget.TextInput do @moduledoc """ - A single-line text input widget. + A pure, single-line text input widget. - TextInput allows users to type text, navigate with arrow keys, - and delete with backspace/delete. + The parent application owns the returned state and calls `update/2` for + normalized terminal events. The update result is `{state, messages}`. + Changes emit `{:changed, value}` and Enter emits `{:submit, value}`. - ## Usage + `init/1` accepts `:value`, `:placeholder`, `:max_length`, and + `:selection_style`. `view/2` returns a one-row frame with a visible cursor. + `row/2` returns plain fitted text and its one-based cursor column. + `row_spans/2` retains selection styles for manual composition. - TextInput.render(%{ - placeholder: "Enter name...", - on_change: fn value -> IO.puts("Value: \#{value}") end, - on_submit: fn value -> IO.puts("Submitted: \#{value}") end - }, state, area) + Shift with Left, Right, Home, or End changes the selection. Ctrl+A selects + all text. Ctrl+C returns `{:copy, text}`. Ctrl+X also removes the selection + and returns `{:changed, value}`. Mouse press and drag use zero-based local + columns from `mouse/3`. - ## Props + ## Example - - `:value` - Initial value (default: `""`) - - `:placeholder` - Placeholder text when empty - - `:on_change` - Callback when value changes - - `:on_submit` - Callback when Enter pressed - - `:max_length` - Maximum input length - - `:style` - Input style - - `:cursor_style` - Cursor character style + input = TermUI.Widget.TextInput.init(placeholder: "Name", max_length: 80) + {input, messages} = TermUI.Widget.TextInput.update(event, input) + frame = TermUI.Widget.TextInput.view(input, {40, 1}) """ - use TermUI.StatefulComponent + @behaviour TermUI.Widget + + alias TermUI.{DisplayWidth, Event, Frame, Selection, Style} + alias TermUI.Widget.Helpers + + @type t :: %__MODULE__{ + value: String.t(), + cursor: non_neg_integer(), + placeholder: String.t(), + max_length: pos_integer() | :infinity, + selection: Selection.t(), + selection_style: Style.t() + } + + @schema Zoi.struct(__MODULE__, %{ + value: Zoi.string() |> Zoi.default(""), + cursor: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0), + placeholder: Zoi.string() |> Zoi.default(""), + max_length: + Zoi.union([Zoi.integer() |> Zoi.positive(), Zoi.literal(:infinity)]) + |> Zoi.default(:infinity), + selection: Zoi.struct(Selection) |> Zoi.default(%Selection{}), + selection_style: + Zoi.struct(Style) + |> Zoi.default(%Style{attrs: MapSet.new([:reverse])}) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) - alias TermUI.Component.RenderNode - alias TermUI.Event - alias TermUI.Renderer.Style - - # Dialyzer: Suppress opaque type warnings for Style helpers - # no_opaque: Style contains MapSet which triggers false positive call_without_opaque warnings - @dialyzer [:no_opaque, nowarn_function: [build_style: 1, positioned_cell_safe: 4]] - - @doc """ - Initializes the text input state. - """ @impl true - def init(props) do - value = Map.get(props, :value, "") + def init(opts) do + value = Keyword.get(opts, :value, "") - state = %{ + %__MODULE__{ value: value, - cursor: String.length(value), - scroll_offset: 0, - props: props + cursor: length(String.graphemes(value)), + placeholder: Keyword.get(opts, :placeholder, ""), + max_length: Keyword.get(opts, :max_length, :infinity), + selection_style: Keyword.get(opts, :selection_style, Style.new(attrs: [:reverse])) } - - {:ok, state} end - @doc """ - Handles events for the text input. - """ @impl true - def handle_event(%Event.Key{key: :left}, state) do - new_cursor = max(0, state.cursor - 1) - {:ok, %{state | cursor: new_cursor}} - end + def update(%Event.Text{text: text}, state), do: insert(state, text) + def update(%Event.Paste{content: text}, state), do: insert(state, text) - def handle_event(%Event.Key{key: :right}, state) do - new_cursor = min(String.length(state.value), state.cursor + 1) - {:ok, %{state | cursor: new_cursor}} + def update(%Event.Key{key: "a", modifiers: modifiers}, state) do + if :ctrl in modifiers, + do: {%{state | selection: Selection.select_all(state.selection, state.value)}, []}, + else: {state, []} end - def handle_event(%Event.Key{key: :home}, state) do - {:ok, %{state | cursor: 0}} + def update(%Event.Key{key: "c", modifiers: modifiers}, state) do + if :ctrl in modifiers, do: copy_selection(state), else: {state, []} end - def handle_event(%Event.Key{key: :end}, state) do - {:ok, %{state | cursor: String.length(state.value)}} + def update(%Event.Key{key: "x", modifiers: modifiers}, state) do + if :ctrl in modifiers, do: cut_selection(state), else: {state, []} end - def handle_event(%Event.Key{key: :backspace}, state) do - if state.cursor > 0 do - {before, after_cursor} = String.split_at(state.value, state.cursor) - new_value = String.slice(before, 0..-2//1) <> after_cursor - new_cursor = state.cursor - 1 + def update(%Event.Key{key: :left, modifiers: modifiers}, state), + do: horizontal(state, -1, modifiers) - {:ok, %{state | value: new_value, cursor: new_cursor}, - [{:send, self(), {:changed, new_value}}]} - else - {:ok, state} - end - end + def update(%Event.Key{key: :right, modifiers: modifiers}, state), + do: horizontal(state, 1, modifiers) - def handle_event(%Event.Key{key: :delete}, state) do - if state.cursor < String.length(state.value) do - {before, after_cursor} = String.split_at(state.value, state.cursor) - new_value = before <> String.slice(after_cursor, 1..-1//1) + def update(%Event.Key{key: :home, modifiers: modifiers}, state), + do: navigate(state, 0, modifiers) - {:ok, %{state | value: new_value}, [{:send, self(), {:changed, new_value}}]} - else - {:ok, state} + def update(%Event.Key{key: :end, modifiers: modifiers}, state), + do: navigate(state, grapheme_count(state.value), modifiers) + + def update(%Event.Key{key: :backspace}, state) do + cond do + not Selection.empty?(state.selection) -> delete_selection(state) + state.cursor == 0 -> {state, []} + true -> delete_before_cursor(state) end end - def handle_event(%Event.Key{key: :enter}, state) do - {:ok, state, [{:send, self(), {:submit, state.value}}]} + def update(%Event.Key{key: :delete}, state) do + cond do + not Selection.empty?(state.selection) -> delete_selection(state) + state.cursor < grapheme_count(state.value) -> delete_at_cursor(state) + true -> {state, []} + end end - def handle_event(%Event.Key{char: char}, state) when is_binary(char) and char != "" do - # Insert character at cursor - {before, after_cursor} = String.split_at(state.value, state.cursor) - new_value = before <> char <> after_cursor - new_cursor = state.cursor + String.length(char) + def update(%Event.Key{key: :enter}, state), do: {state, [{:submit, state.value}]} + def update(_event, state), do: {state, []} - {:ok, %{state | value: new_value, cursor: new_cursor}, - [{:send, self(), {:changed, new_value}}]} + @impl true + def view(state, {width, height}) when width > 0 and height > 0 do + {content, cursor_column} = row_spans(state, width) + Frame.from_rows([content], width, height, cursor: {cursor_column, 1}) end - def handle_event(_event, state) do - {:ok, state} + @impl true + def mouse(%Event.Mouse{action: :press, button: :left, x: x, modifiers: modifiers}, state, { + width, + _height + }) do + position = cursor_at(state, width, x) + + selection = + if :shift in modifiers and Selection.active?(state.selection), + do: Selection.extend(state.selection, position), + else: state.selection |> Selection.start(position) + + {%{state | cursor: position, selection: selection}, []} end - @doc """ - Handles messages to the text input. - """ - @impl true - def handle_info({:changed, value}, state) do - props = state.props - on_change = Map.get(props, :on_change) - max_length = Map.get(props, :max_length) - - # Enforce max length - final_value = - if max_length && String.length(value) > max_length do - String.slice(value, 0, max_length) - else - value - end + def mouse(%Event.Mouse{action: :drag, button: :left, x: x}, state, {width, _height}) do + position = cursor_at(state, width, x) - if is_function(on_change, 1) do - on_change.(final_value) - end + selection = + if Selection.active?(state.selection), + do: Selection.extend(state.selection, position), + else: state.selection |> Selection.start(state.cursor) |> Selection.extend(position) - if final_value != value do - {:ok, %{state | value: final_value, cursor: min(state.cursor, String.length(final_value))}} - else - {:ok, state} - end + {%{state | cursor: position, selection: selection}, []} end - def handle_info({:submit, value}, state) do - props = state.props - on_submit = Map.get(props, :on_submit) + def mouse(event, state, _dimensions), do: update(event, state) - if is_function(on_submit, 1) do - on_submit.(value) - end - - {:ok, state} + @doc "Returns the fitted row and its one-based cursor column." + @spec row(t(), pos_integer()) :: {String.t(), pos_integer()} + def row(state, width) when is_integer(width) and width > 0 do + layout = row_layout(state, width) + {Frame.fit(layout.text, width), layout.cursor_column} end - def handle_info({:set_value, value}, state) do - {:ok, %{state | value: value, cursor: String.length(value)}} - end + @doc "Returns styled fitted content and its one-based cursor column." + @spec row_spans(t(), pos_integer()) :: {Frame.row(), pos_integer()} + def row_spans(state, width) when is_integer(width) and width > 0 do + layout = row_layout(state, width) - def handle_info(_msg, state) do - {:ok, state} + content = + if state.value == "" do + layout.text + else + Enum.map(layout.entries, fn {grapheme, index} -> + {grapheme, style_at(state, index)} + end) + end + + {content, layout.cursor_column} end - @doc """ - Renders the text input. - """ - @impl true - def render(state, area) do - props = state.props - placeholder = Map.get(props, :placeholder, "") - style_opts = Map.get(props, :style, %{}) - cursor_style_opts = Map.get(props, :cursor_style, %{bg: :white, fg: :black}) + defp insert(state, text) do + inserted = text |> String.replace(~r/[\x00-\x1F\x7F]/u, "") |> String.graphemes() - style = build_style(style_opts) - cursor_style = build_style(cursor_style_opts) + {value, cursor} = + if Selection.empty?(state.selection) do + {state.value, state.cursor} + else + {value, cursor, _selection} = Selection.replace(state.selection, state.value, "") + {value, cursor} + end - # Determine what to display - {display_text, show_cursor} = - if state.value == "" do - {placeholder, false} + {before, after_cursor} = Enum.split(String.graphemes(value), cursor) + + value_graphemes = + (before ++ inserted ++ after_cursor) + |> limit(state.max_length) + + value = Enum.join(value_graphemes) + cursor = min(length(before) + length(inserted), length(value_graphemes)) + changed(%{state | value: value, cursor: cursor, selection: Selection.clear(state.selection)}) + end + + defp horizontal(state, delta, modifiers) do + target = + if :shift not in modifiers and not Selection.empty?(state.selection) do + {start, finish} = Selection.range(state.selection) + if delta < 0, do: start, else: finish else - {state.value, true} + state.cursor + delta end - # Calculate scroll to keep cursor visible - scroll_offset = calculate_scroll(state.cursor, state.scroll_offset, area.width) + navigate(state, target, modifiers) + end - # Create visible portion - visible_text = - display_text - |> String.slice(scroll_offset, area.width) - |> String.pad_trailing(area.width) + defp navigate(state, target, modifiers) do + target = Helpers.clamp(target, 0, grapheme_count(state.value)) - # Render cells - cursor_pos = state.cursor - scroll_offset + selection = + if :shift in modifiers do + if Selection.active?(state.selection), + do: Selection.extend(state.selection, target), + else: state.selection |> Selection.start(state.cursor) |> Selection.extend(target) + else + Selection.clear(state.selection) + end - cells = - visible_text - |> String.graphemes() - |> Enum.with_index() - |> Enum.map(fn {char, x} -> - cell_style = get_cell_style(x, cursor_pos, show_cursor, cursor_style, state.value, style) - positioned_cell_safe(x, 0, char, cell_style) - end) + {%{state | cursor: target, selection: selection}, []} + end - RenderNode.cells(cells) + defp copy_selection(state) do + if Selection.empty?(state.selection), + do: {state, []}, + else: {state, [{:copy, Selection.extract(state.selection, state.value)}]} end - # Private Functions + defp cut_selection(state) do + if Selection.empty?(state.selection) do + {state, []} + else + copied = Selection.extract(state.selection, state.value) + {value, cursor, selection} = Selection.replace(state.selection, state.value, "") + state = %{state | value: value, cursor: cursor, selection: selection} + {state, [{:copy, copied}, {:changed, value}]} + end + end - defp get_cell_style(x, cursor_pos, true, cursor_style, _value, _style) when x == cursor_pos do - cursor_style + defp delete_selection(state) do + {value, cursor, selection} = Selection.replace(state.selection, state.value, "") + changed(%{state | value: value, cursor: cursor, selection: selection}) end - defp get_cell_style(_x, _cursor_pos, _show_cursor, _cursor_style, "", _style) do - # Placeholder style (dimmed) - Style.new(fg: :bright_black) + defp delete_before_cursor(state) do + graphemes = String.graphemes(state.value) + value = graphemes |> List.delete_at(state.cursor - 1) |> Enum.join() + changed(%{state | value: value, cursor: state.cursor - 1}) end - defp get_cell_style(_x, _cursor_pos, _show_cursor, _cursor_style, _value, style) do - style + defp delete_at_cursor(state) do + value = state.value |> String.graphemes() |> List.delete_at(state.cursor) |> Enum.join() + changed(%{state | value: value}) end - # ---------------------------------------------------------------------------- - # Style Helper Functions - # ---------------------------------------------------------------------------- + defp changed(state), do: {state, [{:changed, state.value}]} + defp grapheme_count(text), do: text |> String.graphemes() |> length() + defp limit(graphemes, :infinity), do: graphemes - @spec positioned_cell_safe(integer(), integer(), String.t(), Style.t()) :: RenderNode.t() - defp positioned_cell_safe(x, y, char, style), - do: positioned_cell(x, y, char, style) + defp limit(graphemes, count) when is_integer(count) and count > 0, + do: Enum.take(graphemes, count) - # ---------------------------------------------------------------------------- - # Utility Functions - # ---------------------------------------------------------------------------- + defp visible_before_cursor(graphemes, width) do + Enum.reduce(Enum.reverse(graphemes), {[], 0}, fn grapheme, {visible, used} = acc -> + grapheme_width = max(DisplayWidth.width(grapheme), 0) - defp calculate_scroll(cursor, current_scroll, visible_width) do - cond do - # Cursor before visible area - cursor < current_scroll -> - cursor + if used + grapheme_width <= width, + do: {[grapheme | visible], used + grapheme_width}, + else: acc + end) + end + + defp take_width(graphemes, width) do + graphemes + |> Enum.reduce_while({[], 0}, fn grapheme, {visible, used} -> + grapheme_width = max(DisplayWidth.width(grapheme), 0) + + if used + grapheme_width <= width do + {:cont, {[grapheme | visible], used + grapheme_width}} + else + {:halt, {visible, used}} + end + end) + |> elem(0) + |> Enum.reverse() + end - # Cursor after visible area - cursor >= current_scroll + visible_width -> - cursor - visible_width + 1 + defp row_layout(%{value: ""} = state, width) do + %{text: Frame.fit(state.placeholder, width), entries: [], cursor_column: 1, start: 0} + end - # Cursor visible - true -> - current_scroll + defp row_layout(state, width) do + {before, after_cursor} = Enum.split(String.graphemes(state.value), state.cursor) + {visible_before, before_width} = visible_before_cursor(before, width - 1) + room = max(width - before_width, 0) + visible_after = take_width(after_cursor, room) + start = state.cursor - length(visible_before) + visible = visible_before ++ visible_after + + %{ + text: Enum.join(visible), + entries: Enum.with_index(visible, start), + cursor_column: min(before_width + 1, width), + start: start + } + end + + defp cursor_at(state, width, x) do + layout = row_layout(state, width) + x = Helpers.clamp(x, 0, width - 1) + + layout.entries + |> Enum.reduce_while({layout.start, 0}, fn {grapheme, index}, {_position, column} -> + grapheme_width = max(DisplayWidth.width(grapheme), 1) + + if x < column + grapheme_width do + {:halt, position_in_grapheme(index, grapheme_width, x - column)} + else + {:cont, {index + 1, column + grapheme_width}} + end + end) + |> case do + {position, _column} -> position + position -> position end end - defp build_style(opts) when is_map(opts) do - style_list = - opts - |> Enum.map(fn - {:fg, color} -> {:fg, color} - {:bg, color} -> {:bg, color} - {:bold, true} -> {:attrs, [:bold]} - _ -> nil - end) - |> Enum.reject(&is_nil/1) - - Style.new(style_list) + defp style_at(state, index) do + if Selection.contains?(state.selection, index), + do: state.selection_style, + else: Style.new() end - defp build_style(_), do: Style.new() + defp position_in_grapheme(index, width, offset) when width > 1 and offset >= div(width, 2), + do: index + 1 + + defp position_in_grapheme(index, _width, _offset), do: index end diff --git a/lib/term_ui/widget/text_input/line.ex b/lib/term_ui/widget/text_input/line.ex new file mode 100644 index 00000000..568fbd62 --- /dev/null +++ b/lib/term_ui/widget/text_input/line.ex @@ -0,0 +1,10 @@ +defmodule TermUI.Widget.TextInput.Line do + @moduledoc "Compatibility name for `TermUI.Widget.LineInput`." + + @behaviour TermUI.Widget + + defdelegate init(opts), to: TermUI.Widget.LineInput + defdelegate update(event, state), to: TermUI.Widget.LineInput + defdelegate view(state, dimensions), to: TermUI.Widget.LineInput + defdelegate validate(state), to: TermUI.Widget.LineInput +end diff --git a/lib/term_ui/widget/toast.ex b/lib/term_ui/widget/toast.ex new file mode 100644 index 00000000..b4e1ef42 --- /dev/null +++ b/lib/term_ui/widget/toast.ex @@ -0,0 +1,110 @@ +defmodule TermUI.Widget.Toast do + @moduledoc "A pure dismissible notification and bounded toast collection." + + @behaviour TermUI.Widget + + alias TermUI.{Event, Style} + alias TermUI.Widget.Helpers + + @type toast_type :: :info | :success | :warning | :error + @type t :: %__MODULE__{ + id: term(), + message: String.t(), + type: toast_type(), + visible: boolean(), + duration: pos_integer() | :infinity, + elapsed: non_neg_integer() + } + @schema Zoi.struct(__MODULE__, %{ + id: Zoi.any() |> Zoi.default(nil), + message: Zoi.string() |> Zoi.default(""), + type: Zoi.enum([:info, :success, :warning, :error]) |> Zoi.default(:info), + visible: Zoi.boolean() |> Zoi.default(true), + duration: + Zoi.union([Zoi.integer() |> Zoi.positive(), Zoi.literal(:infinity)]) + |> Zoi.default(5_000), + elapsed: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts), + do: %__MODULE__{ + id: Keyword.get(opts, :id, make_ref()), + message: opts |> Keyword.get(:message, "") |> to_string(), + type: Keyword.get(opts, :type, :info), + duration: Keyword.get(opts, :duration, 5_000) + } + + @impl true + def update(%Event.Key{key: :escape}, state), + do: {%{state | visible: false}, [{:dismissed, state.id}]} + + def update(%Event.Mouse{action: :release, button: :left}, state), + do: {%{state | visible: false}, [{:dismissed, state.id}]} + + def update(_event, state), do: {state, []} + + @impl true + def view(%{visible: false}, dimensions), do: Helpers.frame([], dimensions) + + def view(state, dimensions) do + {icon, color} = + case state.type do + :success -> {"✓", :green} + :warning -> {"!", :yellow} + :error -> {"×", :red} + :info -> {"i", :cyan} + end + + rows = + Helpers.border( + [[{icon <> " ", Style.new(fg: color, attrs: [:bold])}, state.message]], + dimensions + ) + + Helpers.frame(rows, dimensions) + end + + @doc "Advances the toast clock and hides an expired toast." + @spec tick(t(), non_neg_integer()) :: t() + def tick(%{duration: :infinity} = state, _elapsed), do: state + + def tick(state, elapsed) do + elapsed = state.elapsed + max(elapsed, 0) + %{state | elapsed: elapsed, visible: elapsed < state.duration} + end + + defmodule Manager do + @moduledoc "A pure bounded toast collection." + alias TermUI.Widget.Toast + + @type t :: %__MODULE__{toasts: [Toast.t()], limit: pos_integer()} + @schema Zoi.struct(__MODULE__, %{ + toasts: Zoi.array(Zoi.struct(Toast)) |> Zoi.default([]), + limit: Zoi.integer() |> Zoi.positive() |> Zoi.default(5) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @spec new(keyword()) :: t() + def new(opts \\ []), do: %__MODULE__{limit: max(Keyword.get(opts, :limit, 5), 1)} + + @spec add(t(), iodata(), Toast.toast_type(), keyword()) :: t() + def add(manager, message, type \\ :info, opts \\ []) do + toast = Toast.init(Keyword.merge(opts, message: message, type: type)) + %{manager | toasts: Enum.take([toast | manager.toasts], manager.limit)} + end + + @spec tick(t(), non_neg_integer()) :: t() + def tick(manager, elapsed), + do: %{ + manager + | toasts: + manager.toasts + |> Enum.map(&Toast.tick(&1, elapsed)) + |> Enum.filter(& &1.visible) + } + end +end diff --git a/lib/term_ui/widget/tree_view.ex b/lib/term_ui/widget/tree_view.ex new file mode 100644 index 00000000..8236e0d7 --- /dev/null +++ b/lib/term_ui/widget/tree_view.ex @@ -0,0 +1,230 @@ +defmodule TermUI.Widget.TreeView do + @moduledoc "A pure expandable tree with keyboard navigation and selection." + + @behaviour TermUI.Widget + + alias TermUI.{Event, Style} + alias TermUI.Widget.Helpers + + @type tree_node :: %{ + required(:id) => term(), + required(:label) => String.t(), + required(:children) => [tree_node()], + optional(:disabled) => boolean() + } + @type t :: %__MODULE__{ + nodes: [tree_node()], + cursor: non_neg_integer(), + offset: non_neg_integer(), + expanded: MapSet.t(term()), + selected: MapSet.t(term()), + page_size: pos_integer() + } + + @schema Zoi.struct(__MODULE__, %{ + nodes: Zoi.array() |> Zoi.default([]), + cursor: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0), + offset: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0), + expanded: Zoi.map_set() |> Zoi.default(MapSet.new()), + selected: Zoi.map_set() |> Zoi.default(MapSet.new()), + page_size: Zoi.integer() |> Zoi.positive() |> Zoi.default(10) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @doc "Creates a tree leaf." + @spec leaf(term(), iodata(), keyword()) :: tree_node() + def leaf(id, label, opts \\ []), + do: %{ + id: id, + label: IO.iodata_to_binary(label), + children: [], + disabled: Keyword.get(opts, :disabled, false) + } + + @doc "Creates a tree branch." + @spec branch(term(), iodata(), [tree_node()], keyword()) :: tree_node() + def branch(id, label, children, opts \\ []), + do: %{ + id: id, + label: IO.iodata_to_binary(label), + children: children, + disabled: Keyword.get(opts, :disabled, false) + } + + @impl true + def init(opts) do + %__MODULE__{ + nodes: Keyword.get(opts, :nodes, []), + expanded: MapSet.new(Keyword.get(opts, :expanded, [])), + selected: MapSet.new(Keyword.get(opts, :selected, [])), + page_size: max(Keyword.get(opts, :page_size, 10), 1) + } + end + + @impl true + def update(%Event.Key{key: :up}, state), do: move(state, -1) + def update(%Event.Key{key: :down}, state), do: move(state, 1) + def update(%Event.Key{key: :home}, state), do: move_to(state, 0) + def update(%Event.Key{key: :end}, state), do: move_to(state, length(visible(state)) - 1) + def update(%Event.Key{key: :page_up}, state), do: move(state, -state.page_size) + def update(%Event.Key{key: :page_down}, state), do: move(state, state.page_size) + def update(%Event.Key{key: :right}, state), do: expand_current(state) + def update(%Event.Key{key: :left}, state), do: collapse_current(state) + def update(%Event.Key{key: :enter}, state), do: toggle_current(state) + def update(%Event.Key{key: :space}, state), do: select_current(state) + def update(%Event.Text{text: " "}, state), do: select_current(state) + def update(_event, state), do: {state, []} + + @impl true + def mouse(%Event.Mouse{action: action, button: :left, x: x, y: y}, state, {_width, height}) + when action in [:press, :release] do + nodes = visible(state) + offset = visible_offset(state.cursor, state.offset, height) + index = offset + y + + case Enum.at(nodes, index) do + {node, depth} when y >= 0 and y < height -> + state = %{state | cursor: index, offset: offset} + + cond do + action == :press -> {state, []} + node.children != [] and x <= depth * 2 + 1 -> toggle_current(state) + true -> select_current(state) + end + + _other -> + {state, []} + end + end + + def mouse(event, state, _dimensions), do: update(event, state) + + @impl true + def view(state, {_width, height} = dimensions) do + visible = visible(state) + offset = visible_offset(state.cursor, state.offset, height) + cursor_style = Style.new(attrs: [:reverse]) + selected_style = Style.new(fg: :green) + branch_style = Style.new(fg: :cyan) + + rows = + visible + |> Enum.slice(offset, height) + |> Enum.with_index(offset) + |> Enum.map(fn {{node, depth}, index} -> + branch? = node.children != [] + + glyph = + cond do + not branch? -> "•" + MapSet.member?(state.expanded, node.id) -> "▾" + true -> "▸" + end + + selected? = MapSet.member?(state.selected, node.id) + + style = + cond do + index == state.cursor -> cursor_style + selected? -> selected_style + branch? -> branch_style + true -> Style.new() + end + + [{String.duplicate(" ", depth) <> glyph <> " " <> node.label, style}] + end) + + Helpers.frame(rows, dimensions) + end + + @doc "Returns visible nodes and their depth." + @spec visible(t()) :: [{tree_node(), non_neg_integer()}] + def visible(state), do: flatten(state.nodes, state.expanded, 0) + + @doc "Replaces the children of one node without performing I/O." + @spec set_children(t(), term(), [tree_node()]) :: t() + def set_children(state, id, children), + do: %{state | nodes: replace_children(state.nodes, id, children)} + + defp move(state, delta), do: move_to(state, state.cursor + delta) + + defp move_to(state, cursor) do + cursor = Helpers.clamp(cursor, 0, max(length(visible(state)) - 1, 0)) + offset = visible_offset(cursor, state.offset, state.page_size) + {%{state | cursor: cursor, offset: offset}, []} + end + + defp expand_current(state) do + case current(state) do + {%{children: [_ | _], id: id}, _depth} -> + {%{state | expanded: MapSet.put(state.expanded, id)}, [{:expanded, id}]} + + _other -> + {state, []} + end + end + + defp collapse_current(state) do + case current(state) do + {%{id: id}, _depth} -> + {%{state | expanded: MapSet.delete(state.expanded, id)}, [{:collapsed, id}]} + + _other -> + {state, []} + end + end + + defp toggle_current(state) do + case current(state) do + {%{children: [_ | _], id: id}, _depth} -> + if MapSet.member?(state.expanded, id), + do: collapse_current(state), + else: expand_current(state) + + {%{id: id} = node, _depth} -> + {state, [{:activated, id, node}]} + + _other -> + {state, []} + end + end + + defp select_current(state) do + case current(state) do + {%{id: id}, _depth} -> + {%{state | selected: MapSet.put(state.selected, id)}, [{:selected, id}]} + + _other -> + {state, []} + end + end + + defp current(state), do: Enum.at(visible(state), state.cursor) + defp visible_offset(_cursor, offset, 0), do: offset + defp visible_offset(cursor, offset, _height) when cursor < offset, do: cursor + + defp visible_offset(cursor, offset, height) when cursor >= offset + height, + do: cursor - height + 1 + + defp visible_offset(_cursor, offset, _height), do: offset + + defp flatten(nodes, expanded, depth) do + Enum.flat_map(nodes, fn node -> + children = + if MapSet.member?(expanded, node.id), + do: flatten(node.children, expanded, depth + 1), + else: [] + + [{node, depth} | children] + end) + end + + defp replace_children(nodes, id, children) do + Enum.map(nodes, fn node -> + if node.id == id, + do: %{node | children: children}, + else: %{node | children: replace_children(node.children, id, children)} + end) + end +end diff --git a/lib/term_ui/widget/viewport.ex b/lib/term_ui/widget/viewport.ex new file mode 100644 index 00000000..267a8175 --- /dev/null +++ b/lib/term_ui/widget/viewport.ex @@ -0,0 +1,140 @@ +defmodule TermUI.Widget.Viewport do + @moduledoc "A pure viewport for vertically and horizontally scrollable text rows." + + @behaviour TermUI.Widget + + alias TermUI.{DisplayWidth, Event, Frame} + alias TermUI.Widget.Helpers + + @type t :: %__MODULE__{ + rows: [Frame.row()], + scroll_x: non_neg_integer(), + scroll_y: non_neg_integer(), + page_size: pos_integer(), + follow_end: boolean() + } + + @schema Zoi.struct(__MODULE__, %{ + rows: Zoi.array() |> Zoi.default([]), + scroll_x: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0), + scroll_y: Zoi.integer() |> Zoi.non_negative() |> Zoi.default(0), + page_size: Zoi.integer() |> Zoi.positive() |> Zoi.default(10), + follow_end: Zoi.boolean() |> Zoi.default(false) + }) + @enforce_keys Zoi.Struct.enforce_keys(@schema) + defstruct Zoi.Struct.struct_fields(@schema) + + @impl true + def init(opts) do + %__MODULE__{ + rows: normalize_content(Keyword.get(opts, :content, [])), + scroll_x: max(Keyword.get(opts, :scroll_x, 0), 0), + scroll_y: max(Keyword.get(opts, :scroll_y, 0), 0), + page_size: max(Keyword.get(opts, :page_size, 10), 1), + follow_end: Keyword.get(opts, :follow_end, false) + } + end + + @impl true + def update(%Event.Key{key: :up}, state), do: scroll(state, 0, -1) + def update(%Event.Key{key: :down}, state), do: scroll(state, 0, 1) + def update(%Event.Key{key: :left}, state), do: scroll(state, -1, 0) + def update(%Event.Key{key: :right}, state), do: scroll(state, 1, 0) + def update(%Event.Key{key: :page_up}, state), do: scroll(state, 0, -state.page_size) + def update(%Event.Key{key: :page_down}, state), do: scroll(state, 0, state.page_size) + + def update(%Event.Key{key: :home, modifiers: modifiers}, state) do + if :ctrl in modifiers, do: {%{state | scroll_y: 0}, []}, else: {%{state | scroll_x: 0}, []} + end + + def update(%Event.Key{key: :end, modifiers: modifiers}, state) do + if :ctrl in modifiers, + do: {%{state | scroll_y: max(length(state.rows) - state.page_size, 0)}, []}, + else: {state, []} + end + + def update(%Event.Mouse{action: :scroll_up}, state), do: scroll(state, 0, -3) + def update(%Event.Mouse{action: :scroll_down}, state), do: scroll(state, 0, 3) + def update(_event, state), do: {state, []} + + @impl true + def view(state, {width, height} = dimensions) do + offset = + if state.follow_end, + do: max(length(state.rows) - height, 0), + else: min(state.scroll_y, max(length(state.rows) - height, 0)) + + rows = + state.rows + |> Enum.slice(offset, height) + |> Enum.map(fn row -> + row |> plain_text() |> drop_width(state.scroll_x) |> Frame.fit(width) + end) + + Helpers.frame(rows, dimensions) + end + + @doc "Replaces the content rows and keeps the scroll position valid." + @spec set_content(t(), String.t() | [Frame.row()]) :: t() + def set_content(state, content) do + rows = normalize_content(content) + + scroll_y = + if state.follow_end, + do: max(length(rows) - state.page_size, 0), + else: min(state.scroll_y, max(length(rows) - 1, 0)) + + %{state | rows: rows, scroll_y: scroll_y} + end + + @doc "Returns the horizontal and vertical scroll offsets." + @spec position(t()) :: {non_neg_integer(), non_neg_integer()} + def position(state), do: {state.scroll_x, state.scroll_y} + + defp scroll(state, dx, dy) do + max_x = + state.rows + |> Enum.map(&plain_text/1) + |> Enum.map(&DisplayWidth.width/1) + |> Enum.max(fn -> 0 end) + + max_y = max(length(state.rows) - state.page_size, 0) + + next = %{ + state + | scroll_x: Helpers.clamp(state.scroll_x + dx, 0, max_x), + scroll_y: Helpers.clamp(state.scroll_y + dy, 0, max_y), + follow_end: false + } + + {next, [{:scrolled, {next.scroll_x, next.scroll_y}}]} + end + + defp normalize_content(content) when is_binary(content), + do: String.split(content, "\n", trim: false) + + defp normalize_content(content) when is_list(content), do: content + defp normalize_content(content), do: [to_string(content)] + defp plain_text(row) when is_binary(row), do: row + + defp plain_text(row), + do: + Enum.map_join(row, fn + {text, _style} -> IO.iodata_to_binary(text) + text -> IO.iodata_to_binary(text) + end) + + defp drop_width(text, 0), do: text + defp drop_width(text, width), do: text |> String.graphemes() |> do_drop_width(width) + + defp do_drop_width(graphemes, width) when width <= 0, do: Enum.join(graphemes) + defp do_drop_width([], _width), do: "" + + defp do_drop_width([grapheme | rest], width) do + grapheme_width = max(DisplayWidth.width(grapheme), 0) + + if grapheme_width <= width, + do: do_drop_width(rest, width - grapheme_width), + else: " " <> Enum.join(rest) + end +end diff --git a/lib/term_ui/widgets/alert_dialog.ex b/lib/term_ui/widgets/alert_dialog.ex deleted file mode 100644 index 4608918a..00000000 --- a/lib/term_ui/widgets/alert_dialog.ex +++ /dev/null @@ -1,634 +0,0 @@ -defmodule TermUI.Widgets.AlertDialog do - @moduledoc """ - Alert dialog widget for standardized messages and confirmations. - - Alert dialog is a specialized dialog with predefined button configurations - and visual icons for different message types. - - ## Usage - - AlertDialog.new( - type: :confirm, - title: "Delete File", - message: "Are you sure you want to delete this file?", - on_result: fn result -> handle_result(result) end - ) - - ## Alert Types - - - `:info` - Information message (i icon, OK button) - - `:success` - Success message (✓ icon, OK button) - - `:warning` - Warning message (⚠ icon, OK button) - - `:error` - Error message (✗ icon, OK button) - - `:confirm` - Confirmation dialog (? icon, Yes/No buttons) - - `:ok_cancel` - OK/Cancel dialog (OK/Cancel buttons) - - ## Keyboard Navigation - - - Tab/Shift+Tab: Move between buttons - - Enter/Space: Activate focused button - - Escape: Close (same as Cancel/No) - - Y: Yes (in confirm dialogs) - - N: No (in confirm dialogs) - - ## Mouse Support - - In raw mode, dialog buttons can be clicked with the mouse. Clicking a button - produces the same result as pressing Enter on that button. Mouse events are - ignored in TTY mode. - - **Important**: For accurate mouse click detection, you must call `update_area/2` - with the current terminal dimensions before mouse events occur. - - - Left click on button: Activate the button - - ## Example with Mouse Support - - # In your component: - def init(_opts), do: %{alert: nil} - - def update(:show_confirm, state) do - props = AlertDialog.new(type: :confirm, title: "Confirm", message: "Proceed?") - {:ok, alert} = AlertDialog.init(props) - - # Set terminal area for accurate mouse clicks - alert = AlertDialog.update_area(alert, %{width: 80, height: 24}) - - {%{state | alert: alert}, []} - end - - def update({:alert_event, event}, state) do - {:ok, new_alert} = AlertDialog.handle_event(event, state.alert) - {%{state | alert: new_alert}, []} - end - - def view(state) do - if state.alert do - area = %{width: 80, height: 24} - {:overlay, main_content(), AlertDialog.render(state.alert, area)} - else - main_content() - end - end - """ - - use TermUI.StatefulComponent - - alias TermUI.CharacterSet - alias TermUI.Event - alias TermUI.PersistentTerms - alias TermUI.Renderer.Style - - # Dialyzer: Functions return specific map types - @dialyzer {:nowarn_function, new: 1, show: 1, hide: 1} - - # Icon keys mapped to CharacterSet fields (or literal strings for ?) - @type_icon_keys %{ - info: :info, - success: :check, - warning: :warning, - error: :cross_mark, - confirm: :literal_question, - ok_cancel: :literal_question - } - - @type_buttons %{ - info: [%{id: :ok, label: "OK", default: true}], - success: [%{id: :ok, label: "OK", default: true}], - warning: [%{id: :ok, label: "OK", default: true}], - error: [%{id: :ok, label: "OK", default: true}], - confirm: [ - %{id: :no, label: "No"}, - %{id: :yes, label: "Yes", default: true} - ], - ok_cancel: [ - %{id: :cancel, label: "Cancel"}, - %{id: :ok, label: "OK", default: true} - ] - } - - @doc """ - Creates new AlertDialog widget props. - - ## Options - - - `:type` - Alert type (required): :info, :success, :warning, :error, :confirm, :ok_cancel - - `:title` - Dialog title (required) - - `:message` - Message to display (required) - - `:on_result` - Callback with result (:ok, :cancel, :yes, :no) - - `:width` - Dialog width (default: 50) - - `:background_style` - Style for the dialog background (default: black background) - - `:border_style` - Style for the border and title (default: cyan foreground) - - `:icon_style` - Style for the icon - - `:message_style` - Style for the message - - `:button_style` - Style for buttons - - `:focused_button_style` - Style for focused button - """ - @spec new(keyword()) :: map() - def new(opts) do - type = Keyword.fetch!(opts, :type) - - %{ - type: type, - title: Keyword.fetch!(opts, :title), - message: Keyword.fetch!(opts, :message), - buttons: Map.get(@type_buttons, type, [%{id: :ok, label: "OK"}]), - icon_key: Map.get(@type_icon_keys, type, nil), - width: Keyword.get(opts, :width, 50), - on_result: Keyword.get(opts, :on_result), - background_style: Keyword.get(opts, :background_style, default_background_style()), - border_style: Keyword.get(opts, :border_style, default_border_style()), - icon_style: Keyword.get(opts, :icon_style), - message_style: Keyword.get(opts, :message_style), - button_style: Keyword.get(opts, :button_style), - focused_button_style: Keyword.get(opts, :focused_button_style) - } - end - - # Default styles for the dialog - defp default_background_style do - Style.new(bg: :black) - end - - defp default_border_style do - Style.new(fg: :cyan) - end - - @impl true - def init(props) do - state = %{ - alert_type: props.type, - title: props.title, - message: props.message, - buttons: props.buttons, - icon_key: props.icon_key, - width: props.width, - focused_button: get_default_focus(props.buttons), - on_result: props.on_result, - background_style: props.background_style, - border_style: props.border_style, - icon_style: props.icon_style, - message_style: props.message_style, - button_style: props.button_style, - focused_button_style: props.focused_button_style, - visible: true, - # Store terminal area for accurate button click detection - # Can be updated with update_area/2 - terminal_area: {80, 24} - } - - {:ok, state} - end - - @impl true - def handle_event(%Event.Key{key: :escape}, state) do - # Escape acts as Cancel/No - result = if state.alert_type == :confirm, do: :no, else: :cancel - handle_result(state, result) - end - - def handle_event(%Event.Key{key: :tab, modifiers: modifiers}, state) do - direction = if :shift in modifiers, do: -1, else: 1 - state = move_button_focus(state, direction) - {:ok, state} - end - - def handle_event(%Event.Key{key: key}, state) when key in [:enter, " "] do - handle_result(state, state.focused_button) - end - - def handle_event(%Event.Key{key: :left}, state) do - state = move_button_focus(state, -1) - {:ok, state} - end - - def handle_event(%Event.Key{key: :right}, state) do - state = move_button_focus(state, 1) - {:ok, state} - end - - # Shortcut keys for confirm dialogs - def handle_event(%Event.Key{key: "y"}, state) when state.alert_type == :confirm do - handle_result(state, :yes) - end - - def handle_event(%Event.Key{key: "n"}, state) when state.alert_type == :confirm do - handle_result(state, :no) - end - - def handle_event(event, state) do - # ESC key handling - more permissive pattern match - case event do - %TermUI.Event.Key{key: key} when key == :escape or key == :ESC -> - result = if state.alert_type == :confirm, do: :no, else: :cancel - handle_result(state, result) - - %TermUI.Event.Mouse{action: :press, button: :left, x: x, y: y} -> - handle_mouse_click(x, y, state) - - %TermUI.Event.Mouse{} -> - # Ignore other mouse events (not clicks) - {:ok, state} - - _ -> - {:ok, state} - end - end - - defp handle_mouse_click(x, y, state) do - if PersistentTerms.backend_mode() == :raw do - {area_width, area_height} = state.terminal_area - - button_bounds = - calculate_button_bounds_for_size( - state.width, - state.buttons, - state.focused_button, - area_width, - area_height - ) - - case find_button_at_position(button_bounds, x, y) do - nil -> {:ok, state} - button_id -> handle_result(state, button_id) - end - else - {:ok, state} - end - end - - @impl true - def render(%{visible: false}, _area), do: empty() - - @impl true - def render(state, area) do - # Calculate dialog position (centered) - dialog_width = state.width - dialog_height = calculate_height(state) - - pos_x = max(0, div(area.width - dialog_width, 2)) - pos_y = max(0, div(area.height - dialog_height, 2)) - - # Render dialog content - dialog = render_dialog(state, dialog_width) - - # Return as overlay with background fill - %{ - type: :overlay, - content: dialog, - x: pos_x, - y: pos_y, - z: 100, - # Provide dimensions and background for opaque fill - width: dialog_width, - height: dialog_height, - bg: state.background_style - } - end - - # Private functions - - defp get_default_focus(buttons) do - default = Enum.find(buttons, fn b -> Map.get(b, :default, false) end) - - if default do - default.id - else - case buttons do - [first | _] -> first.id - [] -> nil - end - end - end - - defp move_button_focus(state, direction) do - button_ids = Enum.map(state.buttons, & &1.id) - - case Enum.find_index(button_ids, &(&1 == state.focused_button)) do - nil -> - state - - current_idx -> - new_idx = rem(current_idx + direction + length(button_ids), length(button_ids)) - %{state | focused_button: Enum.at(button_ids, new_idx)} - end - end - - defp handle_result(state, result) do - if state.on_result do - state.on_result.(result) - end - - # Update focused_button to the result so get_focused_button returns the clicked button - {:ok, %{state | visible: false, focused_button: result}} - end - - defp calculate_height(state) do - # Title (1) + icon+message (1) + buttons (1) + borders (4) + padding (2) - message_lines = String.split(state.message, "\n") |> length() - 6 + message_lines - end - - # Calculate button bounds for a given terminal size and focus state - # Returns a map with button positions for click detection - defp calculate_button_bounds_for_size( - dialog_width, - buttons, - focused_button, - area_width, - area_height - ) do - # Base height for single-line message - dialog_height = 7 - - # Dialog position on screen (centered) - dialog_x = max(0, div(area_width - dialog_width, 2)) - dialog_y = max(0, div(area_height - dialog_height, 2)) - - # Button row is at: top_border(1) + title(1) + separator(1) + content(1) + separator(1) = 5 - button_row_in_dialog = 5 - button_y = dialog_y + button_row_in_dialog - - # Calculate button positions within the button line - # width inside borders - inner_width = dialog_width - 4 - - # Build button texts - focused button gets brackets - button_texts = - Enum.map(buttons, fn button -> - label = button.label - - if button.id == focused_button do - "[ " <> label <> " ]" - else - " " <> label <> " " - end - end) - - buttons_line = Enum.join(button_texts, " ") - - # Center buttons - padding = inner_width - String.length(buttons_line) - left_pad = max(0, div(padding, 2)) - - # Buttons start at: dialog_x + v_line(1) + space(1) + left_pad - buttons_start_x = dialog_x + 2 + left_pad - - # Build bounds map for each button - {bounds_list, _} = - Enum.map_reduce(buttons, {button_texts, buttons_start_x}, fn button, {texts, current_x} -> - [button_text | remaining_texts] = texts - button_width = String.length(button_text) - - bounds = %{ - id: button.id, - x: current_x, - y: button_y, - width: button_width, - height: 1 - } - - # +1 for space between buttons - {bounds, {remaining_texts, current_x + button_width + 1}} - end) - - %{ - button_y: button_y, - buttons: bounds_list - } - end - - # Find which button was clicked based on pre-calculated bounds - defp find_button_at_position(button_bounds, click_x, click_y) do - if click_y == button_bounds.button_y do - find_button_by_x(button_bounds.buttons, click_x) - else - nil - end - end - - defp find_button_by_x(buttons, click_x) do - Enum.find_value(buttons, fn bounds -> - if click_x >= bounds.x and click_x < bounds.x + bounds.width, do: bounds.id - end) - end - - defp render_dialog(state, width) do - chars = CharacterSet.current_charset() - - # Border - with border_style - top_border = - text(chars.tl <> String.duplicate(chars.h_line, width - 2) <> chars.tr) - |> styled(state.border_style) - - bottom_border = - text(chars.bl <> String.duplicate(chars.h_line, width - 2) <> chars.br) - |> styled(state.border_style) - - # Title - with border_style - title = render_title(state, width, chars) |> styled(state.border_style) - - # Separator - with border_style - separator = - text(chars.t_right <> String.duplicate(chars.h_line, width - 2) <> chars.t_left) - |> styled(state.border_style) - - # Icon and message - with background style handled in render_content - content = render_content(state, width, chars) - - # Buttons - with background style handled in render_buttons - buttons = render_buttons(state, width, chars) - - stack(:vertical, [ - top_border, - title, - separator, - content, - separator, - buttons, - bottom_border - ]) - end - - # Merge multiple styles, with later styles taking precedence - defp merge_style_options(styles) do - styles - |> Enum.reject(&is_nil/1) - |> Enum.reduce(fn style, acc -> - Style.merge(acc, style) - end) - end - - defp merge_style_options(style1, style2) do - merge_style_options([style1, style2]) - end - - defp render_title(state, width, chars) do - # Get icon from charset (or use "?" for confirm/ok_cancel) - icon = - case state.icon_key do - :literal_question -> "?" - nil -> "" - key -> Map.get(chars, key, "") - end - - # Include icon in title if present - # Extra space after icon to account for unicode width variations - title_text = - if icon != "" do - icon <> " " <> state.title - else - state.title - end - - padding = width - String.length(title_text) - 4 - left_pad = div(padding, 2) - right_pad = padding - left_pad - - line = - chars.v_line <> - " " <> - String.duplicate(" ", left_pad) <> - title_text <> - String.duplicate(" ", right_pad) <> - " " <> chars.v_line - - text(line) - end - - defp render_content(state, width, chars) do - # Message only (icon is now in title) - message = state.message - - # Pad to width - inner_width = width - 4 - padded = String.pad_trailing(message, inner_width) - padded = String.slice(padded, 0, inner_width) - - line = chars.v_line <> " " <> padded <> " " <> chars.v_line - - # Merge message_style with background_style - style = merge_style_options(state.message_style, state.background_style) - - if style do - styled(text(line), style) - else - text(line) - end - end - - defp render_buttons(state, width, chars) do - button_texts = - Enum.map(state.buttons, fn button -> - label = button.label - - if button.id == state.focused_button do - "[ " <> label <> " ]" - else - " " <> label <> " " - end - end) - - buttons_line = Enum.join(button_texts, " ") - - # Center buttons - inner_width = width - 4 - padding = inner_width - String.length(buttons_line) - left_pad = max(0, div(padding, 2)) - - line = - chars.v_line <> - " " <> - String.duplicate(" ", left_pad) <> - buttons_line <> - String.duplicate(" ", max(0, inner_width - left_pad - String.length(buttons_line))) <> - " " <> chars.v_line - - # Merge button_style or focused_button_style with background_style - button_style = - if state.focused_button_style do - state.focused_button_style - else - state.button_style - end - - style = merge_style_options(button_style, state.background_style) - - if style do - styled(text(line), style) - else - text(line) - end - end - - # Public API - - @doc """ - Gets whether the alert is visible. - """ - @spec visible?(map()) :: boolean() - def visible?(state) do - state.visible - end - - @doc """ - Shows the alert. - """ - @spec show(map()) :: map() - def show(state) do - %{state | visible: true} - end - - @doc """ - Hides the alert. - """ - @spec hide(map()) :: map() - def hide(state) do - %{state | visible: false} - end - - @doc """ - Gets the alert type. - """ - @spec get_type(map()) :: atom() - def get_type(state) do - state.alert_type - end - - @doc """ - Gets the currently focused button. - """ - @spec get_focused_button(map()) :: term() - def get_focused_button(state) do - state.focused_button - end - - @doc """ - Updates the message. - """ - @spec set_message(map(), String.t()) :: map() - def set_message(state, message) do - %{state | message: message} - end - - @doc """ - Updates the terminal area for accurate mouse click detection. - - Call this when the terminal is resized or before rendering to ensure - mouse clicks are detected at the correct positions. - - ## Example - - # In your app's view/1, track the area: - def view(state) do - area = %{width: 80, height: 24} - # Update alert with current area before rendering - alert = AlertDialog.update_area(state.alert, area) - {:overlay, main_content, AlertDialog.render(alert, area)} - end - """ - @spec update_area(map(), %{width: pos_integer(), height: pos_integer()}) :: map() - def update_area(state, area) do - %{state | terminal_area: {area.width, area.height}} - end -end diff --git a/lib/term_ui/widgets/bar_chart.ex b/lib/term_ui/widgets/bar_chart.ex deleted file mode 100644 index 02dfe157..00000000 --- a/lib/term_ui/widgets/bar_chart.ex +++ /dev/null @@ -1,271 +0,0 @@ -defmodule TermUI.Widgets.BarChart do - @moduledoc """ - Bar chart widget for displaying comparative values. - - Renders horizontal or vertical bars proportional to data values. - Supports multiple series, labels, and color coding. - - ## Usage - - BarChart.render( - data: [ - %{label: "Sales", value: 150}, - %{label: "Revenue", value: 200}, - %{label: "Profit", value: 75} - ], - direction: :horizontal, - width: 40, - show_values: true - ) - - ## Options - - - `:data` - List of data points with label and value - - `:direction` - :horizontal or :vertical (default: :horizontal) - - `:width` - Chart width in characters (max: #{TermUI.Widgets.VisualizationHelper.max_width()}) - - `:height` - Chart height for vertical charts (max: #{TermUI.Widgets.VisualizationHelper.max_height()}) - - `:show_values` - Display value labels (default: true) - - `:show_labels` - Display bar labels (default: true) - - `:bar_char` - Character for bars (default: "█") - - `:empty_char` - Character for empty space (default: " ") - - `:colors` - List of colors for series - """ - - import TermUI.Component.RenderNode - alias TermUI.CharacterSet - alias TermUI.Widgets.VisualizationHelper, as: VizHelper - - @max_label_length 50 - - @doc """ - Renders a bar chart. - - ## Options - - - `:data` - List of `%{label: String.t(), value: number()}` (required) - - `:direction` - :horizontal or :vertical (default: :horizontal) - - `:width` - Chart width (default: 40, max: #{VizHelper.max_width()}) - - `:height` - Chart height for vertical (default: 10, max: #{VizHelper.max_height()}) - - `:show_values` - Show value labels (default: true) - - `:show_labels` - Show bar labels (default: true) - - `:bar_char` - Bar character (default: "█") - - `:colors` - List of colors for bars - - `:style` - Style for the chart - """ - @spec render(keyword()) :: TermUI.Component.RenderNode.t() - def render(opts) do - data = Keyword.get(opts, :data, []) - - case VizHelper.validate_bar_data(data) do - :ok when data == [] -> - empty() - - :ok -> - chars = CharacterSet.current_charset() - direction = Keyword.get(opts, :direction, :horizontal) - width = opts |> Keyword.get(:width, 40) |> VizHelper.clamp_width() - height = opts |> Keyword.get(:height, 10) |> VizHelper.clamp_height() - show_values = Keyword.get(opts, :show_values, true) - show_labels = Keyword.get(opts, :show_labels, true) - bar_char = Keyword.get(opts, :bar_char, chars.bar_full) - colors = Keyword.get(opts, :colors, []) - style = Keyword.get(opts, :style) - - case direction do - :horizontal -> - render_horizontal(data, width, show_values, show_labels, bar_char, colors, style) - - :vertical -> - render_vertical( - data, - width, - height, - show_values, - show_labels, - bar_char, - colors, - style - ) - - _ -> - render_horizontal(data, width, show_values, show_labels, bar_char, colors, style) - end - - {:error, _msg} -> - # Return empty for invalid data rather than crashing - empty() - end - end - - defp render_horizontal(data, width, show_values, show_labels, bar_char, colors, style) do - values = Enum.map(data, & &1.value) - max_value = Enum.max(values, fn -> 0 end) - - max_label_len = - if show_labels do - data - |> Enum.map(&String.length(&1.label)) - |> Enum.max(fn -> 0 end) - |> min(@max_label_length) - else - 0 - end - - # Calculate bar width with bounds checking - value_width = if show_values, do: 8, else: 0 - bar_width = max(1, width - max_label_len - value_width - 2) - - rows = - data - |> Enum.with_index() - |> Enum.map(fn {item, index} -> - # Label (truncated if needed) - label = - if show_labels do - truncated = String.slice(item.label, 0, @max_label_length) - String.pad_trailing(truncated, max_label_len) <> " " - else - "" - end - - # Bar - bar_length = VizHelper.normalize_and_scale(item.value, 0, max_value, bar_width) - bar_length = min(bar_length, bar_width) - - bar = VizHelper.safe_duplicate(bar_char, bar_length) - empty_part = String.duplicate(" ", bar_width - bar_length) - - # Value - value_str = - if show_values do - " " <> VizHelper.format_number(item.value) - else - "" - end - - line = label <> bar <> empty_part <> value_str - - # Apply color if specified - color = VizHelper.cycle_color(colors, index) - node = text(line) - VizHelper.maybe_style(node, color) - end) - - result = stack(:vertical, rows) - VizHelper.maybe_style(result, style) - end - - defp render_vertical(data, _width, height, show_values, show_labels, bar_char, colors, style) do - # Get character set for empty character - chars = CharacterSet.current_charset() - empty_char = chars.bar_empty - - values = Enum.map(data, & &1.value) - max_value = Enum.max(values, fn -> 0 end) - - # Calculate bar heights - bar_heights = - Enum.map(data, fn item -> - VizHelper.normalize_and_scale(item.value, 0, max_value, height) - end) - - # Build rows from top to bottom - rows = - for row <- (height - 1)..0//-1 do - chars = - data - |> Enum.with_index() - |> Enum.map(fn {_item, index} -> - bar_height = Enum.at(bar_heights, index) - build_bar_char(row, bar_height, index, bar_char, empty_char, colors) - end) - - # Join chars with spacing - line_parts = Enum.map(chars, &style_bar_char/1) - - stack(:horizontal, line_parts) - end - - # Add value labels - value_row = - if show_values do - value_strs = - Enum.map(data, fn item -> - VizHelper.format_number(item.value) |> String.pad_leading(3) - end) - - [text(Enum.join(value_strs, " "))] - else - [] - end - - # Add labels - label_row = - if show_labels do - labels = - Enum.map(data, fn item -> - String.slice(item.label, 0, 3) |> String.pad_leading(3) - end) - - [text(Enum.join(labels, " "))] - else - [] - end - - result = stack(:vertical, rows ++ value_row ++ label_row) - VizHelper.maybe_style(result, style) - end - - defp build_bar_char(row, bar_height, index, bar_char, _empty_char, colors) - when row < bar_height do - color = VizHelper.cycle_color(colors, index) - {bar_char, color} - end - - defp build_bar_char(_row, _bar_height, _index, _bar_char, _empty_char, _colors) do - {" ", nil} - end - - defp style_bar_char({char, color}) do - padded = " " <> char <> " " - node = text(padded) - VizHelper.maybe_style(node, color) - end - - @doc """ - Creates a simple horizontal bar for a single value. - - ## Options - - - `:value` - Current value (required) - - `:max` - Maximum value (required) - - `:width` - Bar width (default: 20, max: #{VizHelper.max_width()}) - - `:bar_char` - Bar character (default: "█") - - `:empty_char` - Empty character (default: "░") - """ - @spec bar(keyword()) :: TermUI.Component.RenderNode.t() - def bar(opts) do - chars = CharacterSet.current_charset() - value = Keyword.get(opts, :value, 0) - max = Keyword.get(opts, :max, 100) - width = opts |> Keyword.get(:width, 20) |> VizHelper.clamp_width() - bar_char = Keyword.get(opts, :bar_char, chars.bar_full) - empty_char = Keyword.get(opts, :empty_char, chars.bar_empty) - - case {VizHelper.validate_number(value), VizHelper.validate_number(max)} do - {:ok, :ok} -> - filled = VizHelper.normalize_and_scale(value, 0, max, width) - filled = min(filled, width) - empty_count = width - filled - - text( - VizHelper.safe_duplicate(bar_char, filled) <> - VizHelper.safe_duplicate(empty_char, empty_count) - ) - - _ -> - # Invalid input, return empty bar - text(VizHelper.safe_duplicate(empty_char, width)) - end - end -end diff --git a/lib/term_ui/widgets/canvas.ex b/lib/term_ui/widgets/canvas.ex deleted file mode 100644 index 885ac980..00000000 --- a/lib/term_ui/widgets/canvas.ex +++ /dev/null @@ -1,529 +0,0 @@ -defmodule TermUI.Widgets.Canvas do - @moduledoc """ - Canvas widget for custom drawing with direct buffer access. - - Canvas provides a drawing surface with primitives for lines, rectangles, - text, and Braille graphics. Useful for custom visualizations, charts, - diagrams, and other graphics that don't fit standard widget patterns. - - ## Usage - - Canvas.new( - width: 40, - height: 20, - on_draw: fn canvas -> - canvas - |> Canvas.draw_rect(0, 0, 10, 5, "─", "│", "┌", "┐", "└", "┘") - |> Canvas.draw_text(2, 2, "Hello") - end - ) - - ## Features - - - Direct character buffer access - - Drawing primitives: line, rect, text - - Braille graphics for sub-character resolution - - Clear and fill operations - - Custom render callback - - ## Braille Graphics - - Each character cell contains a 2x4 Braille dot matrix, providing - higher resolution for plotting and charts. - """ - - use TermUI.StatefulComponent - - alias TermUI.CharacterSet - - # Dialyzer: Functions return specific map types - # Dialyzer: draw_* functions call CharacterSet.current_charset() which returns specific struct - # Need to cover both arities due to default arguments - @dialyzer {:nowarn_function, - new: 1, - clear: 1, - clear_braille: 1, - draw: 3, - draw_hline: 4, - draw_hline: 5, - draw_vline: 4, - draw_vline: 5, - draw_line: 5, - draw_line: 6} - - # Braille patterns - @braille_base 0x2800 - - # Dot bit positions in Braille character (2 columns x 4 rows) - @dot_bits %{ - {0, 0} => 0x01, - {0, 1} => 0x02, - {0, 2} => 0x04, - {1, 0} => 0x08, - {1, 1} => 0x10, - {1, 2} => 0x20, - {0, 3} => 0x40, - {1, 3} => 0x80 - } - - @doc """ - Creates new Canvas widget props. - - ## Options - - - `:width` - Canvas width in characters (default: 40) - - `:height` - Canvas height in characters (default: 20) - - `:default_char` - Character to fill canvas (default: " ") - - `:on_draw` - Callback function to draw on canvas - """ - @spec new(keyword()) :: map() - def new(opts) do - %{ - width: Keyword.get(opts, :width, 40), - height: Keyword.get(opts, :height, 20), - default_char: Keyword.get(opts, :default_char, " "), - on_draw: Keyword.get(opts, :on_draw) - } - end - - @impl true - def init(props) do - state = %{ - width: props.width, - height: props.height, - default_char: props.default_char, - on_draw: props.on_draw, - buffer: create_buffer(props.width, props.height, props.default_char), - braille_buffer: %{} - } - - {:ok, state} - end - - @impl true - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, _area) do - # Apply on_draw callback if provided - state = - if state.on_draw do - state.on_draw.(state) - else - state - end - - # Merge braille buffer into main buffer - buffer = merge_braille_buffer(state) - - # Convert buffer to render nodes - lines = - for y <- 0..(state.height - 1) do - row = - for x <- 0..(state.width - 1) do - Map.get(buffer, {x, y}, state.default_char) - end - - text(Enum.join(row)) - end - - stack(:vertical, lines) - end - - # Buffer operations - - defp create_buffer(width, height, char) do - for x <- 0..(width - 1), - y <- 0..(height - 1), - into: %{} do - {{x, y}, char} - end - end - - defp merge_braille_buffer(state) do - # Convert braille dots to characters and merge with buffer - braille_chars = - state.braille_buffer - |> Enum.group_by(fn {{x, y, _dx, _dy}, _set} -> {div(x, 2), div(y, 4)} end) - |> Enum.map(fn {{cx, cy}, dots} -> - pattern = calculate_braille_pattern(dots) - char = <<@braille_base + pattern::utf8>> - {{cx, cy}, char} - end) - |> Map.new() - - Map.merge(state.buffer, braille_chars) - end - - defp calculate_braille_pattern(dots) do - Enum.reduce(dots, 0, fn {{x, y, _dx, _dy}, set}, acc -> - accumulate_dot_bit(acc, x, y, set) - end) - end - - defp accumulate_dot_bit(acc, _x, _y, false), do: acc - - defp accumulate_dot_bit(acc, x, y, true) do - # Use actual position within cell - actual_x = rem(x, 2) - actual_y = rem(y, 4) - bit = Map.get(@dot_bits, {actual_x, actual_y}, 0) - Bitwise.bor(acc, bit) - end - - # Drawing primitives - - @doc """ - Clears the canvas with the default character. - """ - @spec clear(map()) :: map() - def clear(state) do - %{ - state - | buffer: create_buffer(state.width, state.height, state.default_char), - braille_buffer: %{} - } - end - - @doc """ - Fills the canvas with a character. - """ - @spec fill(map(), String.t()) :: map() - def fill(state, char) do - %{state | buffer: create_buffer(state.width, state.height, char), braille_buffer: %{}} - end - - @doc """ - Sets a character at a position. - """ - @spec set_char(map(), integer(), integer(), String.t()) :: map() - def set_char(state, x, y, char) do - if x >= 0 and x < state.width and y >= 0 and y < state.height do - %{state | buffer: Map.put(state.buffer, {x, y}, char)} - else - state - end - end - - @doc """ - Gets a character at a position. - """ - @spec get_char(map(), integer(), integer()) :: String.t() | nil - def get_char(state, x, y) do - Map.get(state.buffer, {x, y}) - end - - @doc """ - Draws text at a position. - """ - @spec draw_text(map(), integer(), integer(), String.t()) :: map() - def draw_text(state, x, y, text) do - text - |> String.graphemes() - |> Enum.with_index() - |> Enum.reduce(state, fn {char, i}, acc -> - set_char(acc, x + i, y, char) - end) - end - - @doc """ - Draws a horizontal line. - """ - @spec draw_hline(map(), integer(), integer(), integer(), String.t()) :: map() - def draw_hline(state, x, y, length, char \\ nil) do - char = char || CharacterSet.current_charset().h_line - - Enum.reduce(0..(length - 1), state, fn i, acc -> - set_char(acc, x + i, y, char) - end) - end - - @doc """ - Draws a vertical line. - """ - @spec draw_vline(map(), integer(), integer(), integer(), String.t()) :: map() - def draw_vline(state, x, y, length, char \\ nil) do - char = char || CharacterSet.current_charset().v_line - - Enum.reduce(0..(length - 1), state, fn i, acc -> - set_char(acc, x, y + i, char) - end) - end - - @doc """ - Draws a line between two points using Bresenham's algorithm. - """ - @spec draw_line(map(), integer(), integer(), integer(), integer(), String.t()) :: map() - def draw_line(state, x1, y1, x2, y2, char \\ nil) do - char = char || CharacterSet.current_charset().dot - dx = abs(x2 - x1) - dy = abs(y2 - y1) - sx = if x1 < x2, do: 1, else: -1 - sy = if y1 < y2, do: 1, else: -1 - - line_state = %{ - x: x1, - y: y1, - target_x: x2, - target_y: y2, - dx: dx, - dy: dy, - sx: sx, - sy: sy, - err: dx - dy, - char: char - } - - draw_line_impl(state, line_state) - end - - defp draw_line_impl(state, line_state) do - state = set_char(state, line_state.x, line_state.y, line_state.char) - - if line_state.x == line_state.target_x and line_state.y == line_state.target_y do - state - else - e2 = 2 * line_state.err - - {new_x, new_err} = - if e2 > -line_state.dy do - {line_state.x + line_state.sx, line_state.err - line_state.dy} - else - {line_state.x, line_state.err} - end - - {new_y, new_err} = - if e2 < line_state.dx do - {line_state.y + line_state.sy, new_err + line_state.dx} - else - {line_state.y, new_err} - end - - new_line_state = %{line_state | x: new_x, y: new_y, err: new_err} - draw_line_impl(state, new_line_state) - end - end - - @doc """ - Draws a rectangle outline. - - ## Border Options - - The `border` map can contain: - - `:h` - Horizontal character (default: "─") - - `:v` - Vertical character (default: "│") - - `:tl` - Top-left corner (default: "┌") - - `:tr` - Top-right corner (default: "┐") - - `:bl` - Bottom-left corner (default: "└") - - `:br` - Bottom-right corner (default: "┘") - """ - @spec draw_rect(map(), integer(), integer(), integer(), integer(), map()) :: map() - def draw_rect(state, x, y, width, height, border \\ %{}) do - chars = CharacterSet.current_charset() - h = Map.get(border, :h, chars.h_line) - v = Map.get(border, :v, chars.v_line) - tl = Map.get(border, :tl, chars.tl) - tr = Map.get(border, :tr, chars.tr) - bl = Map.get(border, :bl, chars.bl) - br = Map.get(border, :br, chars.br) - - # Top edge - state = set_char(state, x, y, tl) - state = draw_hline(state, x + 1, y, width - 2, h) - state = set_char(state, x + width - 1, y, tr) - - # Side edges - state = draw_vline(state, x, y + 1, height - 2, v) - state = draw_vline(state, x + width - 1, y + 1, height - 2, v) - - # Bottom edge - state = set_char(state, x, y + height - 1, bl) - state = draw_hline(state, x + 1, y + height - 1, width - 2, h) - set_char(state, x + width - 1, y + height - 1, br) - end - - @doc """ - Fills a rectangle with a character. - """ - @spec fill_rect(map(), integer(), integer(), integer(), integer(), String.t()) :: map() - def fill_rect(state, x, y, width, height, char) do - for dx <- 0..(width - 1), - dy <- 0..(height - 1), - reduce: state do - acc -> set_char(acc, x + dx, y + dy, char) - end - end - - # Braille drawing - - @doc """ - Sets a Braille dot at sub-character position. - - Each character cell is 2 dots wide and 4 dots high. - """ - @spec set_dot(map(), integer(), integer()) :: map() - def set_dot(state, x, y) do - key = {x, y, 0, 0} - %{state | braille_buffer: Map.put(state.braille_buffer, key, true)} - end - - @doc """ - Clears a Braille dot at sub-character position. - """ - @spec clear_dot(map(), integer(), integer()) :: map() - def clear_dot(state, x, y) do - key = {x, y, 0, 0} - %{state | braille_buffer: Map.delete(state.braille_buffer, key)} - end - - @doc """ - Draws a Braille line between two points. - - Coordinates are in sub-character (dot) space: - - X resolution: width * 2 - - Y resolution: height * 4 - """ - @spec draw_braille_line(map(), integer(), integer(), integer(), integer()) :: map() - def draw_braille_line(state, x1, y1, x2, y2) do - dx = abs(x2 - x1) - dy = abs(y2 - y1) - sx = if x1 < x2, do: 1, else: -1 - sy = if y1 < y2, do: 1, else: -1 - - line_state = %{ - x: x1, - y: y1, - target_x: x2, - target_y: y2, - dx: dx, - dy: dy, - sx: sx, - sy: sy, - err: dx - dy - } - - draw_braille_line_impl(state, line_state) - end - - defp draw_braille_line_impl(state, line_state) do - new_state = set_dot(state, line_state.x, line_state.y) - braille_line_step(new_state, line_state) - end - - defp braille_line_step(state, %{x: x, y: y, target_x: x, target_y: y}), do: state - - defp braille_line_step(state, line_state) do - e2 = 2 * line_state.err - - {new_x, new_err} = - update_x_position(e2, line_state.x, line_state.sx, line_state.err, line_state.dy) - - {new_y, final_err} = - update_y_position(e2, line_state.y, line_state.sy, new_err, line_state.dx) - - new_line_state = %{line_state | x: new_x, y: new_y, err: final_err} - draw_braille_line_impl(state, new_line_state) - end - - defp update_x_position(e2, x, sx, err, dy) when e2 > -dy, do: {x + sx, err - dy} - defp update_x_position(_e2, x, _sx, err, _dy), do: {x, err} - - defp update_y_position(e2, y, sy, err, dx) when e2 < dx, do: {y + sy, err + dx} - defp update_y_position(_e2, y, _sy, err, _dx), do: {y, err} - - @doc """ - Converts dots to a Braille character. - - Takes a list of {x, y} coordinates within a 2x4 cell. - """ - @spec dots_to_braille([{integer(), integer()}]) :: String.t() - def dots_to_braille(dots) do - pattern = - Enum.reduce(dots, 0, fn {x, y}, acc -> - bit = Map.get(@dot_bits, {x, y}, 0) - Bitwise.bor(acc, bit) - end) - - <<@braille_base + pattern::utf8>> - end - - @doc """ - Returns empty Braille character. - """ - @spec empty_braille() :: String.t() - def empty_braille, do: <<@braille_base::utf8>> - - @doc """ - Returns full Braille character (all dots set). - """ - @spec full_braille() :: String.t() - def full_braille, do: <<@braille_base + 0xFF::utf8>> - - @doc """ - Clears all Braille dots. - """ - @spec clear_braille(map()) :: map() - def clear_braille(state) do - %{state | braille_buffer: %{}} - end - - @doc """ - Gets the Braille resolution (dots) for the canvas. - """ - @spec braille_resolution(map()) :: {integer(), integer()} - def braille_resolution(state) do - {state.width * 2, state.height * 4} - end - - # Public API - - @doc """ - Updates the canvas dimensions. - """ - @spec resize(map(), integer(), integer()) :: map() - def resize(state, width, height) do - %{ - state - | width: width, - height: height, - buffer: create_buffer(width, height, state.default_char), - braille_buffer: %{} - } - end - - @doc """ - Creates a canvas and draws on it with a function. - """ - @spec draw(integer(), integer(), (map() -> map())) :: map() - def draw(width, height, draw_fn) do - state = %{ - width: width, - height: height, - default_char: " ", - on_draw: nil, - buffer: create_buffer(width, height, " "), - braille_buffer: %{} - } - - draw_fn.(state) - end - - @doc """ - Renders the canvas state to a list of strings. - """ - @spec to_strings(map()) :: [String.t()] - def to_strings(state) do - buffer = merge_braille_buffer(state) - - for y <- 0..(state.height - 1) do - row = - for x <- 0..(state.width - 1) do - Map.get(buffer, {x, y}, state.default_char) - end - - Enum.join(row) - end - end -end diff --git a/lib/term_ui/widgets/cluster_dashboard.ex b/lib/term_ui/widgets/cluster_dashboard.ex deleted file mode 100644 index eb9d1134..00000000 --- a/lib/term_ui/widgets/cluster_dashboard.ex +++ /dev/null @@ -1,1123 +0,0 @@ -defmodule TermUI.Widgets.ClusterDashboard do - @moduledoc """ - ClusterDashboard widget for visualizing distributed Erlang clusters. - - ClusterDashboard displays cluster connectivity, node health metrics, - cross-node process registries, and connection events. It provides - tools for monitoring and debugging distributed BEAM applications. - - ## Usage - - ClusterDashboard.new( - update_interval: 2000, - show_health_metrics: true, - show_pg_groups: true - ) - - ## Features - - - Connected nodes list with status indicators - - Node health metrics (CPU, memory, scheduler utilization) - - Cross-node process registry (:global names) - - PG group membership visualization - - Network partition detection and alerts - - Node connection/disconnection event log - - RPC interface for remote node inspection - - ## Keyboard Controls - - - Up/Down: Navigate node/item list - - PageUp/PageDown: Scroll by page - - Enter: Toggle details panel - - r: Refresh now - - g: Show :global names view - - p: Show :pg groups view - - n: Show nodes view - - e: Show events view - - i: Inspect selected node (RPC details) - - Escape: Close details - """ - - use TermUI.StatefulComponent - - alias TermUI.CharacterSet - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Theme - - # Dialyzer: Suppress opaque type warnings for Style helpers and contract warnings for specific types - @dialyzer {:nowarn_function, - fg_semantic: 1, - fg_color: 1, - fg_bold_semantic: 1, - fg_bg_bold_semantic: 2, - fg_bold_help: 0, - new: 1, - refresh: 1, - set_interval: 2, - handle_info: 2, - unmount: 1} - - @type view_mode :: :nodes | :globals | :pg_groups | :events - @type node_status :: :connected | :disconnected | :local - - @type node_info :: %{ - node: node(), - status: node_status(), - connected_at: DateTime.t() | nil, - metrics: map() | nil - } - - @type node_event :: %{ - node: node(), - event: :nodeup | :nodedown, - timestamp: DateTime.t() - } - - @default_interval 2000 - @page_size 10 - @max_events 50 - @rpc_timeout 5000 - - # ---------------------------------------------------------------------------- - # Style Helper Functions - # ---------------------------------------------------------------------------- - - @spec fg_semantic(atom()) :: Style.t() - defp fg_semantic(color) when is_atom(color), - do: Style.new() |> Style.fg(color) - - @spec fg_color(atom()) :: Style.t() - defp fg_color(color) when is_atom(color), - do: Style.new() |> Style.fg(color) - - @spec fg_bold_semantic(atom()) :: Style.t() - defp fg_bold_semantic(color) when is_atom(color), - do: Style.new() |> Style.fg(color) |> Style.bold() - - @spec fg_bg_bold_semantic(atom(), atom()) :: Style.t() - defp fg_bg_bold_semantic(fg, bg) when is_atom(fg) and is_atom(bg), - do: Style.new() |> Style.fg(fg) |> Style.bg(bg) |> Style.bold() - - @spec fg_bold_help() :: Style.t() - defp fg_bold_help do - Style.new() |> Style.fg(Theme.get_semantic(:help)) |> Style.dim() - end - - # ---------------------------------------------------------------------------- - # Props - # ---------------------------------------------------------------------------- - - @doc """ - Creates new ClusterDashboard widget props. - - ## Options - - - `:update_interval` - Refresh interval in ms (default: 2000) - - `:show_health_metrics` - Fetch and show CPU/memory/load (default: true) - - `:show_pg_groups` - Show :pg process groups (default: true) - - `:show_global_names` - Show :global registered names (default: true) - - `:on_node_select` - Callback when node is selected - """ - @spec new(keyword()) :: map() - def new(opts \\ []) do - %{ - update_interval: Keyword.get(opts, :update_interval, @default_interval), - show_health_metrics: Keyword.get(opts, :show_health_metrics, true), - show_pg_groups: Keyword.get(opts, :show_pg_groups, true), - show_global_names: Keyword.get(opts, :show_global_names, true), - on_node_select: Keyword.get(opts, :on_node_select) - } - end - - # ---------------------------------------------------------------------------- - # StatefulComponent Callbacks - # ---------------------------------------------------------------------------- - - @impl true - def init(props) do - state = %{ - # View state - view_mode: :nodes, - selected_idx: 0, - scroll_offset: 0, - show_details: false, - - # Data - nodes: [], - global_names: [], - pg_groups: [], - events: [], - - # Partition detection - known_nodes: MapSet.new(), - partition_alert: nil, - - # Settings - update_interval: props.update_interval, - show_health_metrics: props.show_health_metrics, - show_pg_groups: props.show_pg_groups, - show_global_names: props.show_global_names, - timer_ref: nil, - - # Callbacks - on_node_select: props.on_node_select, - - # Viewport - viewport_height: 15, - viewport_width: 80 - } - - # Fetch initial data - nodes = fetch_nodes(state) - global_names = if state.show_global_names, do: fetch_global_names(), else: [] - pg_groups = if state.show_pg_groups, do: fetch_pg_groups(), else: [] - known = MapSet.new(Enum.map(nodes, & &1.node)) - - state = %{ - state - | nodes: nodes, - global_names: global_names, - pg_groups: pg_groups, - known_nodes: known - } - - {:ok, state} - end - - @impl true - def mount(state) do - # Start node monitoring - :ok = start_node_monitoring() - - # Start refresh timer - timer_ref = schedule_refresh(state.update_interval) - {:ok, %{state | timer_ref: timer_ref}} - end - - @impl true - def unmount(state) do - if state.timer_ref do - Process.cancel_timer(state.timer_ref) - end - - stop_node_monitoring() - :ok - end - - # ---------------------------------------------------------------------------- - # Event Handling - # ---------------------------------------------------------------------------- - - @impl true - def handle_event(%Event.Key{key: :up}, state) do - move_selection(state, -1) - end - - def handle_event(%Event.Key{key: :down}, state) do - move_selection(state, 1) - end - - def handle_event(%Event.Key{key: :page_up}, state) do - move_selection(state, -@page_size) - end - - def handle_event(%Event.Key{key: :page_down}, state) do - move_selection(state, @page_size) - end - - def handle_event(%Event.Key{key: :home}, state) do - {:ok, %{state | selected_idx: 0, scroll_offset: 0}} - end - - def handle_event(%Event.Key{key: :end}, state) do - count = get_item_count(state) - last = max(0, count - 1) - scroll = max(0, count - state.viewport_height) - {:ok, %{state | selected_idx: last, scroll_offset: scroll}} - end - - # Enter - toggle details - def handle_event(%Event.Key{key: :enter}, state) do - {:ok, %{state | show_details: not state.show_details}} - end - - # r - refresh - def handle_event(%Event.Key{char: "r"}, state) do - refresh(state) - end - - # n - nodes view - def handle_event(%Event.Key{char: "n"}, state) do - {:ok, %{state | view_mode: :nodes, selected_idx: 0, scroll_offset: 0, show_details: false}} - end - - # g - global names view - def handle_event(%Event.Key{char: "g"}, state) do - {:ok, %{state | view_mode: :globals, selected_idx: 0, scroll_offset: 0, show_details: false}} - end - - # p - pg groups view - def handle_event(%Event.Key{char: "p"}, state) do - {:ok, - %{state | view_mode: :pg_groups, selected_idx: 0, scroll_offset: 0, show_details: false}} - end - - # e - events view - def handle_event(%Event.Key{char: "e"}, state) do - {:ok, %{state | view_mode: :events, selected_idx: 0, scroll_offset: 0, show_details: false}} - end - - # i - inspect node (RPC) - def handle_event(%Event.Key{char: "i"}, state) when state.view_mode == :nodes do - node_info = Enum.at(state.nodes, state.selected_idx) - - if node_info && node_info.status == :connected do - {:ok, %{state | show_details: true}} - else - {:ok, state} - end - end - - def handle_event(%Event.Key{char: "i"}, state), do: {:ok, state} - - # Escape - close details / clear alert - def handle_event(%Event.Key{key: :escape}, state) do - cond do - state.show_details -> - {:ok, %{state | show_details: false}} - - state.partition_alert -> - {:ok, %{state | partition_alert: nil}} - - true -> - {:ok, state} - end - end - - def handle_event(_event, state) do - {:ok, state} - end - - # ---------------------------------------------------------------------------- - # Message Handling - # ---------------------------------------------------------------------------- - - @impl true - def handle_info(:refresh, state) do - state = do_refresh(state) - timer_ref = schedule_refresh(state.update_interval) - {:ok, %{state | timer_ref: timer_ref}} - end - - # Node monitoring events - def handle_info({:nodeup, node}, state) do - event = %{node: node, event: :nodeup, timestamp: DateTime.utc_now()} - events = [event | state.events] |> Enum.take(@max_events) - - # Update known nodes - known = MapSet.put(state.known_nodes, node) - - # Refresh node list - nodes = fetch_nodes(state) - - {:ok, %{state | nodes: nodes, events: events, known_nodes: known, partition_alert: nil}} - end - - def handle_info({:nodedown, node}, state) do - event = %{node: node, event: :nodedown, timestamp: DateTime.utc_now()} - events = [event | state.events] |> Enum.take(@max_events) - - # Check for partition (multiple nodes down in quick succession) - recent_downs = - events - |> Enum.filter(fn e -> - e.event == :nodedown && - DateTime.diff(DateTime.utc_now(), e.timestamp, :second) < 5 - end) - |> length() - - partition_alert = - if recent_downs >= 2 do - "Potential network partition detected! #{recent_downs} nodes disconnected" - else - state.partition_alert - end - - # Refresh node list - nodes = fetch_nodes(state) - - {:ok, %{state | nodes: nodes, events: events, partition_alert: partition_alert}} - end - - def handle_info(_msg, state) do - {:ok, state} - end - - # ---------------------------------------------------------------------------- - # Data Fetching - # ---------------------------------------------------------------------------- - - defp fetch_nodes(state) do - # Get local node info - local_node = %{ - node: node(), - status: :local, - connected_at: nil, - metrics: if(state.show_health_metrics, do: fetch_local_metrics(), else: nil) - } - - # Get connected nodes - connected = - Node.list() - |> Enum.map(fn n -> - %{ - node: n, - status: :connected, - connected_at: nil, - metrics: if(state.show_health_metrics, do: fetch_remote_metrics(n), else: nil) - } - end) - - [local_node | connected] - end - - defp fetch_local_metrics do - %{ - memory: :erlang.memory(), - process_count: length(Process.list()), - scheduler_count: :erlang.system_info(:schedulers_online), - uptime: get_uptime(), - otp_release: :erlang.system_info(:otp_release) |> to_string() - } - end - - defp fetch_remote_metrics(node) do - case :rpc.call(node, :erlang, :memory, [], @rpc_timeout) do - {:badrpc, _reason} -> - nil - - memory -> - process_count = - case :rpc.call(node, Process, :list, [], @rpc_timeout) do - {:badrpc, _} -> 0 - list -> length(list) - end - - scheduler_count = - case :rpc.call(node, :erlang, :system_info, [:schedulers_online], @rpc_timeout) do - {:badrpc, _} -> 0 - count -> count - end - - %{ - memory: memory, - process_count: process_count, - scheduler_count: scheduler_count, - uptime: nil, - otp_release: nil - } - end - rescue - _ -> nil - catch - _, _ -> nil - end - - defp fetch_global_names do - :global.registered_names() - |> Enum.map(fn name -> - pid = :global.whereis_name(name) - - node = - if is_pid(pid) do - node(pid) - else - :unknown - end - - %{name: name, pid: pid, node: node} - end) - |> Enum.sort_by(& &1.name) - rescue - _ -> [] - catch - _, _ -> [] - end - - defp fetch_pg_groups do - # Try OTP 23+ :pg module - groups = :pg.which_groups() - - Enum.map(groups, fn group -> - members = :pg.get_members(group) - - nodes = - members - |> Enum.map(&node/1) - |> Enum.uniq() - - %{ - group: group, - member_count: length(members), - nodes: nodes - } - end) - |> Enum.sort_by(& &1.group) - rescue - _ -> [] - catch - :exit, {:noproc, _} -> - # :pg not started - [] - - _, _ -> - [] - end - - defp get_uptime do - {uptime_ms, _} = :erlang.statistics(:wall_clock) - div(uptime_ms, 1000) - end - - # ---------------------------------------------------------------------------- - # Node Monitoring - # ---------------------------------------------------------------------------- - - defp start_node_monitoring do - :net_kernel.monitor_nodes(true) - rescue - _ -> :ok - catch - _, _ -> :ok - end - - defp stop_node_monitoring do - :net_kernel.monitor_nodes(false) - rescue - _ -> :ok - catch - _, _ -> :ok - end - - # ---------------------------------------------------------------------------- - # Navigation - # ---------------------------------------------------------------------------- - - defp move_selection(state, delta) do - count = get_item_count(state) - - if count == 0 do - {:ok, state} - else - new_idx = state.selected_idx + delta - new_idx = max(0, min(new_idx, count - 1)) - - new_scroll = - cond do - new_idx < state.scroll_offset -> - new_idx - - new_idx >= state.scroll_offset + state.viewport_height -> - new_idx - state.viewport_height + 1 - - true -> - state.scroll_offset - end - - new_state = %{state | selected_idx: new_idx, scroll_offset: max(0, new_scroll)} - - # Call on_node_select callback for nodes view - maybe_notify_node_select(state, new_idx) - - {:ok, new_state} - end - end - - defp maybe_notify_node_select(state, new_idx) do - if state.view_mode == :nodes && state.on_node_select && new_idx != state.selected_idx do - node_info = Enum.at(state.nodes, new_idx) - if node_info, do: state.on_node_select.(node_info) - end - end - - defp get_item_count(state) do - case state.view_mode do - :nodes -> length(state.nodes) - :globals -> length(state.global_names) - :pg_groups -> length(state.pg_groups) - :events -> length(state.events) - end - end - - # ---------------------------------------------------------------------------- - # Timer - # ---------------------------------------------------------------------------- - - defp schedule_refresh(interval) do - Process.send_after(self(), :refresh, interval) - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Force refresh the cluster data. - """ - @spec refresh(map()) :: {:ok, map()} - def refresh(state) do - {:ok, do_refresh(state)} - end - - defp do_refresh(state) do - nodes = fetch_nodes(state) - global_names = if state.show_global_names, do: fetch_global_names(), else: [] - pg_groups = if state.show_pg_groups, do: fetch_pg_groups(), else: [] - known = MapSet.new(Enum.map(nodes, & &1.node)) - - %{ - state - | nodes: nodes, - global_names: global_names, - pg_groups: pg_groups, - known_nodes: known - } - end - - @doc """ - Set the update interval. - """ - @spec set_interval(map(), non_neg_integer()) :: {:ok, map()} - def set_interval(state, interval) when interval > 0 do - if state.timer_ref do - Process.cancel_timer(state.timer_ref) - end - - timer_ref = schedule_refresh(interval) - {:ok, %{state | update_interval: interval, timer_ref: timer_ref}} - end - - @doc """ - Get currently selected node. - """ - @spec get_selected_node(map()) :: node_info() | nil - def get_selected_node(state) when state.view_mode == :nodes do - Enum.at(state.nodes, state.selected_idx) - end - - def get_selected_node(_state), do: nil - - @doc """ - Get node count. - """ - @spec node_count(map()) :: non_neg_integer() - def node_count(state), do: length(state.nodes) - - @doc """ - Check if cluster is distributed. - """ - @spec distributed?(map()) :: boolean() - def distributed?(state), do: length(state.nodes) > 1 - - @doc """ - Perform RPC call to a node with timeout. - """ - @spec rpc_call(node(), module(), atom(), list()) :: term() | {:error, term()} - def rpc_call(node, module, function, args) do - case :rpc.call(node, module, function, args, @rpc_timeout) do - {:badrpc, reason} -> {:error, reason} - result -> result - end - rescue - e -> {:error, e} - catch - _, e -> {:error, e} - end - - # ---------------------------------------------------------------------------- - # Rendering - # ---------------------------------------------------------------------------- - - @impl true - def render(state, area) do - # Get character set for help text arrows - chars = CharacterSet.current_charset() - - # Update viewport dimensions - detail_height = if state.show_details, do: 8, else: 0 - - state = %{ - state - | viewport_height: area.height - 5 - detail_height, - viewport_width: area.width - } - - # Build render tree - alert = render_alert(state) - header = render_header(state) - content = render_content(state) - details = if state.show_details, do: render_details(state), else: [] - footer = render_footer(state, chars) - - all = alert ++ [header] ++ content ++ details ++ footer - - stack(:vertical, all) - end - - defp render_alert(state) do - if state.partition_alert do - alert_style = - fg_bg_bold_semantic(Theme.get_color(:background), Theme.get_semantic(:error)) - - [text(state.partition_alert, alert_style)] - else - [] - end - end - - defp render_header(state) do - connected_count = length(Node.list()) - local = node() - - mode_label = - case state.view_mode do - :nodes -> "Nodes" - :globals -> "Global Names" - :pg_groups -> "PG Groups" - :events -> "Events" - end - - dist_status = if node() == :nonode@nohost, do: " (not distributed)", else: "" - - header_text = - "Cluster: #{local}#{dist_status} | Connected: #{connected_count} | View: #{mode_label}" - - header_style = fg_bold_semantic(Theme.get_semantic(:info)) - text(header_text, header_style) - end - - defp render_content(state) do - case state.view_mode do - :nodes -> render_nodes_view(state) - :globals -> render_globals_view(state) - :pg_groups -> render_pg_groups_view(state) - :events -> render_events_view(state) - end - end - - defp render_nodes_view(state) do - # Header row - header_line = - String.pad_trailing("Node", 30) <> - String.pad_trailing("Status", 12) <> - String.pad_leading("Processes", 12) <> - String.pad_leading("Memory", 12) - - header = text(header_line, Style.new(attrs: [:bold, :underline])) - - # Node rows - visible_nodes = - state.nodes - |> Enum.drop(state.scroll_offset) - |> Enum.take(state.viewport_height) - - rows = - visible_nodes - |> Enum.with_index() - |> Enum.map(fn {node_info, idx} -> - actual_idx = idx + state.scroll_offset - render_node_row(node_info, actual_idx, state) - end) - - # Padding - padding_count = max(0, state.viewport_height - length(rows)) - padding = List.duplicate(text("", nil), padding_count) - - [header | rows ++ padding] - end - - defp render_node_row(node_info, idx, state) do - is_selected = idx == state.selected_idx - - line = format_node_line(node_info) - style = node_row_style(node_info.status, is_selected) - - text(line, style) - end - - defp format_node_line(node_info) do - node_str = format_node_name(node_info.node) - status_str = format_node_status(node_info.status) - {proc_str, mem_str} = format_node_metrics(node_info.metrics) - - node_str <> status_str <> proc_str <> mem_str - end - - defp format_node_name(node) do - node_name = truncate(to_string(node), 29) - String.pad_trailing(node_name, 30) - end - - defp format_node_status(status) do - case status do - :local -> String.pad_trailing("[local]", 12) - :connected -> String.pad_trailing("connected", 12) - :disconnected -> String.pad_trailing("DOWN", 12) - end - end - - defp format_node_metrics(nil) do - {String.pad_leading("-", 12), String.pad_leading("-", 12)} - end - - defp format_node_metrics(metrics) do - proc = String.pad_leading(Integer.to_string(metrics.process_count), 12) - mem = String.pad_leading(format_bytes(metrics.memory[:total] || 0), 12) - {proc, mem} - end - - defp node_row_style(:local, false) do - fg_semantic(Theme.get_semantic(:success)) - end - - defp node_row_style(:disconnected, _is_selected) do - fg_semantic(Theme.get_semantic(:error)) - end - - defp node_row_style(_status, true) do - Theme.get_component_style(:item, :selected) - end - - defp node_row_style(_status, false), do: nil - - defp render_globals_view(state) do - header_line = - String.pad_trailing("Name", 30) <> - String.pad_trailing("Node", 25) <> - String.pad_trailing("PID", 20) - - header = text(header_line, Style.new(attrs: [:bold, :underline])) - - if Enum.empty?(state.global_names) do - empty_style = fg_semantic(Theme.get_semantic(:muted)) - [header, text(" (no global names registered)", empty_style)] - else - visible = - state.global_names - |> Enum.drop(state.scroll_offset) - |> Enum.take(state.viewport_height) - - rows = - visible - |> Enum.with_index() - |> Enum.map(fn {item, idx} -> - render_global_row(item, idx, state.scroll_offset, state.selected_idx) - end) - - [header | rows] - end - end - - defp render_global_row(item, idx, scroll_offset, selected_idx) do - actual_idx = idx + scroll_offset - is_selected = actual_idx == selected_idx - - name_str = String.pad_trailing(truncate(inspect(item.name), 29), 30) - node_str = String.pad_trailing(truncate(to_string(item.node), 24), 25) - pid_str = String.pad_trailing(inspect(item.pid), 20) - - line = name_str <> node_str <> pid_str - style = row_style(is_selected) - text(line, style) - end - - defp row_style(true), do: Theme.get_component_style(:item, :selected) - defp row_style(false), do: nil - - defp render_pg_groups_view(state) do - header_line = - String.pad_trailing("Group", 30) <> - String.pad_leading("Members", 10) <> - String.pad_trailing(" Nodes", 35) - - header = text(header_line, Style.new(attrs: [:bold, :underline])) - - if Enum.empty?(state.pg_groups) do - empty_style = fg_semantic(Theme.get_semantic(:muted)) - [header, text(" (no :pg groups - is :pg started?)", empty_style)] - else - visible = - state.pg_groups - |> Enum.drop(state.scroll_offset) - |> Enum.take(state.viewport_height) - - rows = - visible - |> Enum.with_index() - |> Enum.map(fn {item, idx} -> - render_pg_group_row(item, idx, state.scroll_offset, state.selected_idx) - end) - - [header | rows] - end - end - - defp render_pg_group_row(item, idx, scroll_offset, selected_idx) do - actual_idx = idx + scroll_offset - is_selected = actual_idx == selected_idx - - group_str = String.pad_trailing(truncate(inspect(item.group), 29), 30) - count_str = String.pad_leading(Integer.to_string(item.member_count), 10) - nodes_str = Enum.map_join(item.nodes, ", ", &to_string/1) - nodes_str = " " <> truncate(nodes_str, 33) - - line = group_str <> count_str <> nodes_str - style = row_style(is_selected) - text(line, style) - end - - defp render_events_view(state) do - header_line = - String.pad_trailing("Time", 12) <> - String.pad_trailing("Event", 12) <> - String.pad_trailing("Node", 40) - - header = text(header_line, Style.new(attrs: [:bold, :underline])) - - if Enum.empty?(state.events) do - empty_style = fg_semantic(Theme.get_semantic(:muted)) - [header, text(" (no events yet)", empty_style)] - else - visible = - state.events - |> Enum.drop(state.scroll_offset) - |> Enum.take(state.viewport_height) - - rows = - visible - |> Enum.with_index() - |> Enum.map(fn {event, idx} -> - render_event_row(event, idx, state.scroll_offset, state.selected_idx) - end) - - [header | rows] - end - end - - defp render_event_row(event, idx, scroll_offset, selected_idx) do - actual_idx = idx + scroll_offset - is_selected = actual_idx == selected_idx - - time_str = format_time(event.timestamp) - time_str = String.pad_trailing(time_str, 12) - - event_str = - case event.event do - :nodeup -> String.pad_trailing("UP", 12) - :nodedown -> String.pad_trailing("DOWN", 12) - end - - node_str = String.pad_trailing(truncate(to_string(event.node), 39), 40) - - line = time_str <> event_str <> node_str - style = event_row_style(is_selected, event.event) - text(line, style) - end - - defp event_row_style(true, _event_type), do: Theme.get_component_style(:item, :selected) - defp event_row_style(false, :nodedown), do: fg_semantic(Theme.get_semantic(:error)) - defp event_row_style(false, :nodeup), do: fg_semantic(Theme.get_semantic(:success)) - defp event_row_style(_, _), do: nil - - defp render_details(state) do - border_style = fg_color(Theme.get_color(:primary)) - border = text(String.duplicate("-", 60), border_style) - - case state.view_mode do - :nodes -> render_node_details(state, border) - :globals -> render_global_details(state, border) - :pg_groups -> render_pg_group_details(state, border) - :events -> render_event_details(state, border) - end - end - - defp render_node_details(state, border) do - node_info = Enum.at(state.nodes, state.selected_idx) - - if node_info && node_info.metrics do - metrics = node_info.metrics - - uptime_str = - if metrics.uptime do - format_duration(metrics.uptime) - else - "-" - end - - otp_str = metrics.otp_release || "-" - - [ - border, - text("Node: #{node_info.node}", Style.new(attrs: [:bold])), - text("Status: #{node_info.status}", nil), - text("Processes: #{metrics.process_count}", nil), - text("Schedulers: #{metrics.scheduler_count}", nil), - text("Memory (total): #{format_bytes(metrics.memory[:total] || 0)}", nil), - text("Memory (processes): #{format_bytes(metrics.memory[:processes] || 0)}", nil), - text("Uptime: #{uptime_str} | OTP: #{otp_str}", nil), - border - ] - else - empty_style = fg_semantic(Theme.get_semantic(:muted)) - - [ - border, - text("No details available", empty_style), - text("", nil), - text("", nil), - text("", nil), - text("", nil), - text("", nil), - text("", nil), - border - ] - end - end - - defp render_global_details(state, border) do - item = Enum.at(state.global_names, state.selected_idx) - - if item do - [ - border, - text("Global Name: #{inspect(item.name)}", Style.new(attrs: [:bold])), - text("PID: #{inspect(item.pid)}", nil), - text("Node: #{item.node}", nil), - text("", nil), - text("", nil), - text("", nil), - text("", nil), - border - ] - else - render_empty_details(border) - end - end - - defp render_pg_group_details(state, border) do - item = Enum.at(state.pg_groups, state.selected_idx) - - if item do - nodes_str = Enum.map_join(item.nodes, ", ", &to_string/1) - - [ - border, - text("Group: #{inspect(item.group)}", Style.new(attrs: [:bold])), - text("Member count: #{item.member_count}", nil), - text("Nodes: #{nodes_str}", nil), - text("", nil), - text("", nil), - text("", nil), - text("", nil), - border - ] - else - render_empty_details(border) - end - end - - defp render_event_details(state, border) do - event = Enum.at(state.events, state.selected_idx) - - if event do - [ - border, - text("Event: #{event.event}", Style.new(attrs: [:bold])), - text("Node: #{event.node}", nil), - text("Time: #{DateTime.to_string(event.timestamp)}", nil), - text("", nil), - text("", nil), - text("", nil), - text("", nil), - border - ] - else - render_empty_details(border) - end - end - - defp render_empty_details(border) do - empty_style = fg_semantic(Theme.get_semantic(:muted)) - - [ - border, - text("No item selected", empty_style), - text("", nil), - text("", nil), - text("", nil), - text("", nil), - text("", nil), - text("", nil), - border - ] - end - - defp render_footer(_state, chars) do - help_text = - "[#{chars.arrow_up}#{chars.arrow_down}] Select [Enter] Details [n] Nodes [g] Globals [p] PG [e] Events [r] Refresh" - - help_style = fg_bold_help() - [text(help_text, help_style)] - end - - # ---------------------------------------------------------------------------- - # Formatting Helpers - # ---------------------------------------------------------------------------- - - defp truncate(str, max_len) do - if String.length(str) > max_len do - String.slice(str, 0, max_len - 1) <> "…" - else - str - end - end - - defp format_bytes(b) when b >= 1024 * 1024 * 1024 do - "#{Float.round(b / (1024 * 1024 * 1024), 1)}GB" - end - - defp format_bytes(b) when b >= 1024 * 1024 do - "#{Float.round(b / (1024 * 1024), 1)}MB" - end - - defp format_bytes(b) when b >= 1024 do - "#{Float.round(b / 1024, 1)}KB" - end - - defp format_bytes(b), do: "#{b}B" - - defp format_time(datetime) do - Calendar.strftime(datetime, "%H:%M:%S") - end - - defp format_duration(seconds) when seconds >= 86_400 do - days = div(seconds, 86_400) - hours = div(rem(seconds, 86_400), 3600) - "#{days}d #{hours}h" - end - - defp format_duration(seconds) when seconds >= 3600 do - hours = div(seconds, 3600) - mins = div(rem(seconds, 3600), 60) - "#{hours}h #{mins}m" - end - - defp format_duration(seconds) when seconds >= 60 do - mins = div(seconds, 60) - secs = rem(seconds, 60) - "#{mins}m #{secs}s" - end - - defp format_duration(seconds), do: "#{seconds}s" -end diff --git a/lib/term_ui/widgets/command_palette.ex b/lib/term_ui/widgets/command_palette.ex deleted file mode 100644 index efe171ea..00000000 --- a/lib/term_ui/widgets/command_palette.ex +++ /dev/null @@ -1,262 +0,0 @@ -defmodule TermUI.Widgets.CommandPalette do - @moduledoc """ - Simple command dropdown for filtering and selecting commands. - - Shows a list of commands filtered by prefix as the user types. - Similar to typing `/` in Claude Code to see available slash commands. - - ## Usage - - # Define commands - commands = [ - %{id: :help, label: "/help", action: fn -> :ok end}, - %{id: :save, label: "/save", action: fn -> :ok end}, - %{id: :quit, label: "/quit", action: fn -> :ok end} - ] - - # Create and show palette - props = CommandPalette.new(commands: commands) - {:ok, palette} = CommandPalette.init(props) - - # Render dropdown when visible - if CommandPalette.visible?(palette) do - CommandPalette.render(palette, area) - end - - ## Keyboard Navigation - - - Type to filter by prefix - - Up/Down: Navigate through results - - Enter: Execute selected command - - Escape: Close dropdown - - Backspace: Delete character - - ## Monochrome Compatibility - - This widget is fully functional in monochrome terminals: - - Selected items use reverse video for visibility - - Filter input uses bold text for focus indication - - All visual states remain distinguishable without color - - The widget automatically uses theme component styles which include - monochrome-visible attributes (reverse, bold). - """ - - use TermUI.StatefulComponent - - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Theme - - # Dialyzer: Functions return specific map types - @dialyzer {:nowarn_function, new: 1, show: 1, hide: 1, toggle: 1} - - @doc """ - Creates new CommandPalette widget props. - - ## Options - - - `:commands` - List of command maps (required). Each command has: - - `:id` - Unique identifier (atom) - - `:label` - Display text (string) - - `:action` - Function to execute (fn -> ... end) - - `:max_visible` - Maximum visible results (default: 8) - """ - @spec new(keyword()) :: map() - def new(opts) do - %{ - commands: Keyword.fetch!(opts, :commands), - max_visible: Keyword.get(opts, :max_visible, 8) - } - end - - @impl true - def init(props) do - state = %{ - commands: props.commands, - filtered: props.commands, - query: "", - selected: 0, - scroll: 0, - visible: true, - max_visible: props.max_visible - } - - {:ok, state} - end - - @impl true - def handle_event(%Event.Key{key: :escape}, state) do - {:ok, %{state | visible: false}} - end - - def handle_event(%Event.Key{key: :enter}, state) do - case Enum.at(state.filtered, state.selected) do - nil -> - {:ok, %{state | visible: false}} - - command -> - # Insert command label as the query, close dropdown (don't execute) - {:ok, %{state | query: command.label, visible: false}} - end - end - - def handle_event(%Event.Key{key: :up}, state) do - new_selected = max(0, state.selected - 1) - {:ok, update_scroll(%{state | selected: new_selected})} - end - - def handle_event(%Event.Key{key: :down}, state) do - max_idx = max(0, length(state.filtered) - 1) - new_selected = min(max_idx, state.selected + 1) - {:ok, update_scroll(%{state | selected: new_selected})} - end - - def handle_event(%Event.Key{key: :backspace}, state) do - new_query = String.slice(state.query, 0..-2//1) - {:ok, filter_commands(%{state | query: new_query})} - end - - def handle_event(%Event.Key{key: key}, state) when is_binary(key) and byte_size(key) == 1 do - new_query = state.query <> key - {:ok, filter_commands(%{state | query: new_query})} - end - - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, _area) do - if state.visible do - render_dropdown(state) - else - empty() - end - end - - # Filter commands by prefix match - defp filter_commands(state) do - filtered = - if state.query == "" do - state.commands - else - query = String.downcase(state.query) - - Enum.filter(state.commands, fn cmd -> - String.downcase(cmd.label) |> String.contains?(query) - end) - end - - %{state | filtered: filtered, selected: 0, scroll: 0} - end - - # Keep selection visible in scroll window - defp update_scroll(state) do - scroll = - cond do - state.selected < state.scroll -> - state.selected - - state.selected >= state.scroll + state.max_visible -> - state.selected - state.max_visible + 1 - - true -> - state.scroll - end - - %{state | scroll: scroll} - end - - # Render the dropdown list - defp render_dropdown(state) do - visible_commands = - state.filtered - |> Enum.drop(state.scroll) - |> Enum.take(state.max_visible) - |> Enum.with_index(state.scroll) - - if visible_commands == [] do - text(" (no matches) ", Style.new(fg: :bright_black)) - else - # Calculate max label width for consistent padding - # Add extra padding to ensure we overwrite any previous content - max_label_width = - state.filtered - |> Enum.map(fn cmd -> String.length(cmd.label) end) - |> Enum.max(fn -> 0 end) - - # Pad to at least 30 chars to clear any previous content on the line - min_width = max(max_label_width, 30) - - rows = - Enum.map(visible_commands, fn {cmd, idx} -> - render_command_row(cmd, idx, state.selected, min_width) - end) - - stack(:vertical, rows) - end - end - - defp render_command_row(cmd, idx, selected_idx, min_width) do - padded_label = String.pad_trailing(cmd.label, min_width) - text_line = " " <> padded_label - - if idx == selected_idx do - text(text_line, Theme.get_component_style(:item, :selected)) - else - text(text_line, nil) - end - end - - # Public API - - @doc """ - Shows the command palette. - """ - @spec show(map()) :: map() - def show(state) do - %{state | visible: true, query: "", selected: 0, scroll: 0} - |> filter_commands() - end - - @doc """ - Hides the command palette. - """ - @spec hide(map()) :: map() - def hide(state) do - %{state | visible: false} - end - - @doc """ - Toggles the command palette visibility. - """ - @spec toggle(map()) :: map() - def toggle(state) do - if state.visible, do: hide(state), else: show(state) - end - - @doc """ - Checks if the palette is visible. - """ - @spec visible?(map()) :: boolean() - def visible?(state) do - state.visible - end - - @doc """ - Gets the currently selected command. - """ - @spec get_selected(map()) :: map() | nil - def get_selected(state) do - Enum.at(state.filtered, state.selected) - end - - @doc """ - Gets the current query. - """ - @spec get_query(map()) :: String.t() - def get_query(state) do - state.query - end -end diff --git a/lib/term_ui/widgets/context_menu.ex b/lib/term_ui/widgets/context_menu.ex deleted file mode 100644 index 97a78d3e..00000000 --- a/lib/term_ui/widgets/context_menu.ex +++ /dev/null @@ -1,342 +0,0 @@ -defmodule TermUI.Widgets.ContextMenu do - @moduledoc """ - Context menu widget for displaying floating menus at cursor position. - - Context menu appears at a specific position (usually on right-click) and - displays a list of actions. It automatically closes on selection, escape, - or clicking outside. - - ## Usage - - ContextMenu.new( - items: [ - ContextMenu.action(:cut, "Cut", shortcut: "Ctrl+X"), - ContextMenu.action(:copy, "Copy", shortcut: "Ctrl+C"), - ContextMenu.action(:paste, "Paste", shortcut: "Ctrl+V"), - ContextMenu.separator(), - ContextMenu.action(:select_all, "Select All", shortcut: "Ctrl+A") - ], - position: {x, y}, - on_select: fn id -> handle_action(id) end, - on_close: fn -> handle_close() end - ) - - ## Features - - - Floating overlay at specified position - - Keyboard navigation (Up/Down/Enter/Escape) - - Closes on selection or escape - - Closes on click outside menu bounds - - Z-order above other content - - ## Callback Error Handling - - The `on_select` and `on_close` callbacks are executed synchronously within - the menu's event handling process. If a callback raises an exception, the - widget process will crash and be restarted by its supervisor. - - **Best Practices:** - - Callbacks should not raise exceptions - - Use try/catch within callbacks for error handling - - Return quickly to avoid blocking the UI - - Dispatch long-running work to separate processes - - Example: - - on_select: fn id -> - try do - handle_menu_action(id) - rescue - e -> Logger.error("Menu action failed: \#{inspect(e)}") - end - end - """ - - use TermUI.StatefulComponent - - alias TermUI.CharacterSet - alias TermUI.Event - alias TermUI.Widgets.ContextMenu.Behavior - - # Dialyzer: Functions return specific map types - @dialyzer {:nowarn_function, action: 3, separator: 0, new: 1, show: 1, hide: 1} - - # Item constructors - - @doc """ - Creates an action menu item. - """ - @spec action(term(), String.t(), keyword()) :: map() - def action(id, label, opts \\ []) do - %{ - type: :action, - id: id, - label: label, - shortcut: Keyword.get(opts, :shortcut), - disabled: Keyword.get(opts, :disabled, false) - } - end - - @doc """ - Creates a separator. - """ - @spec separator() :: map() - def separator do - %{type: :separator, id: make_ref()} - end - - @doc """ - Creates new ContextMenu widget props. - - ## Options - - - `:items` - List of menu items (required) - - `:position` - {x, y} tuple for menu position (required) - - `:on_select` - Callback when item is selected: `fn item_id -> ... end` - Called synchronously. Should not raise exceptions. - - `:on_close` - Callback when menu is closed: `fn -> ... end` - Called synchronously. Should not raise exceptions. - - `:item_style` - Style for normal items - - `:selected_style` - Style for focused item - - `:disabled_style` - Style for disabled items - """ - @spec new(keyword()) :: map() - def new(opts) do - %{ - items: Keyword.fetch!(opts, :items), - position: Keyword.fetch!(opts, :position), - on_select: Keyword.get(opts, :on_select), - on_close: Keyword.get(opts, :on_close), - item_style: Keyword.get(opts, :item_style), - selected_style: Keyword.get(opts, :selected_style), - disabled_style: Keyword.get(opts, :disabled_style) - } - end - - @impl true - def init(props) do - # Build ID-to-item map for O(1) lookups - item_map = Map.new(props.items, fn item -> {item.id, item} end) - - state = %{ - items: props.items, - item_map: item_map, - position: props.position, - cursor: Behavior.find_first_selectable(props.items), - on_select: props.on_select, - on_close: props.on_close, - item_style: props.item_style, - selected_style: props.selected_style, - disabled_style: props.disabled_style, - visible: true - } - - {:ok, state} - end - - @impl true - def handle_event(%Event.Key{key: :up}, state) do - state = Behavior.move_cursor(state, -1) - {:ok, state} - end - - def handle_event(%Event.Key{key: :down}, state) do - state = Behavior.move_cursor(state, 1) - {:ok, state} - end - - def handle_event(%Event.Key{key: key}, state) when key in [:enter, " "] do - state = Behavior.select_at_cursor(state) - {:ok, state} - end - - def handle_event(%Event.Key{key: :escape}, state) do - state = Behavior.close_menu(state) - {:ok, state} - end - - def handle_event(%Event.Mouse{action: :press, x: x, y: y}, state) do - {pos_x, pos_y} = state.position - menu_width = calculate_width(state.items) - menu_height = length(state.items) - - # Check if click is inside menu bounds - if x >= pos_x and x < pos_x + menu_width and - y >= pos_y and y < pos_y + menu_height do - # Click inside menu - select item - relative_y = y - pos_y - item = Enum.at(state.items, relative_y) - - if item && Behavior.selectable?(item) do - state = %{state | cursor: item.id} - state = Behavior.select_at_cursor(state) - {:ok, state} - else - {:ok, state} - end - else - # Click outside menu - close - state = Behavior.close_menu(state) - {:ok, state} - end - end - - # Mouse move/drag - highlight item under cursor - def handle_event(%Event.Mouse{action: action, x: x, y: y}, state) - when action in [:move, :drag] do - {pos_x, pos_y} = state.position - menu_width = calculate_width(state.items) - menu_height = length(state.items) - - # Check if mouse is inside menu bounds - if x >= pos_x and x < pos_x + menu_width and - y >= pos_y and y < pos_y + menu_height do - relative_y = y - pos_y - item = Enum.at(state.items, relative_y) - - if item && Behavior.selectable?(item) do - {:ok, %{state | cursor: item.id}} - else - {:ok, state} - end - else - {:ok, state} - end - end - - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, _area) do - if state.visible do - # Get character set for menu separators - chars = CharacterSet.current_charset() - {pos_x, pos_y} = state.position - width = calculate_width(state.items) - - rows = - Enum.map(state.items, fn item -> - render_item(state, item, width, chars) - end) - - content = stack(:vertical, rows) - - # Return overlay structure for positioning - # The renderer will handle placing this at the specified position - %{ - type: :overlay, - content: content, - x: pos_x, - y: pos_y, - z: 100 - } - else - empty() - end - end - - # Private functions - - defp calculate_width(items) do - items - |> Enum.map(fn item -> - case item.type do - :separator -> - 3 - - _ -> - label_len = String.length(item.label) - shortcut_len = String.length(Map.get(item, :shortcut, "") || "") - # prefix + label + gap + shortcut - 2 + label_len + 2 + shortcut_len - end - end) - |> Enum.max(fn -> 10 end) - end - - defp render_item(state, item, width, _chars) do - case item.type do - :separator -> - chars = CharacterSet.current_charset() - text(String.duplicate(chars.h_line, width)) - - _ -> - render_action_item(state, item, width) - end - end - - defp render_action_item(state, item, width) do - # Main label - label = " " <> item.label - - # Shortcut aligned right - shortcut = Map.get(item, :shortcut, "") || "" - padding = width - String.length(label) - String.length(shortcut) - padding = max(1, padding) - - full_text = label <> String.duplicate(" ", padding) <> shortcut - - # Determine style - style = - cond do - Map.get(item, :disabled, false) -> - state.disabled_style - - item.id == state.cursor -> - state.selected_style - - true -> - state.item_style - end - - if style do - styled(text(full_text), style) - else - text(full_text) - end - end - - # Public API - - @doc """ - Gets whether the context menu is visible. - """ - @spec visible?(map()) :: boolean() - def visible?(state) do - state.visible - end - - @doc """ - Shows the context menu. - """ - @spec show(map()) :: map() - def show(state) do - %{state | visible: true} - end - - @doc """ - Hides the context menu. - """ - @spec hide(map()) :: map() - def hide(state) do - %{state | visible: false} - end - - @doc """ - Updates the position of the context menu. - """ - @spec set_position(map(), {non_neg_integer(), non_neg_integer()}) :: map() - def set_position(state, position) do - %{state | position: position} - end - - @doc """ - Gets the currently focused item ID. - """ - @spec get_cursor(map()) :: term() - def get_cursor(state) do - state.cursor - end -end diff --git a/lib/term_ui/widgets/context_menu/behavior.ex b/lib/term_ui/widgets/context_menu/behavior.ex deleted file mode 100644 index 8780e30f..00000000 --- a/lib/term_ui/widgets/context_menu/behavior.ex +++ /dev/null @@ -1,275 +0,0 @@ -defmodule TermUI.Widgets.ContextMenu.Behavior do - @moduledoc """ - Shared behavior for context menu variants. - - This module provides common functionality for both positioned (`ContextMenu`) - and inline (`ContextMenu.Inline`) menu implementations. It extracts shared - logic for item selection, cursor management, and menu actions to eliminate - code duplication and ensure consistent behavior across menu types. - - This is not a formal Elixir `@behaviour` but rather a collection of utility - functions used by multiple menu implementations. - - ## Shared Functionality - - - **Item Selection:** Determining which items can be selected - - **Cursor Management:** Moving cursor between selectable items - - **Menu Actions:** Selecting items and closing menus - - ## Usage - - Menu implementations should alias this module and delegate to its functions: - - alias TermUI.Widgets.ContextMenu.Behavior - - def init(props) do - state = %{ - items: props.items, - cursor: Behavior.find_first_selectable(props.items), - # ... - } - {:ok, state} - end - - def handle_event(%Event.Key{key: :down}, state) do - state = Behavior.move_cursor(state, 1) - {:ok, state} - end - """ - - # Dialyzer: Functions return specific map types - @dialyzer {:nowarn_function, close_menu: 1, select_at_cursor: 1} - - # ---------------------------------------------------------------------------- - # Item Selection - # ---------------------------------------------------------------------------- - - @doc """ - Returns whether an item can be selected. - - An item is selectable if it's an action type and not disabled. Separators - and disabled action items are not selectable. - - ## Examples - - iex> Behavior.selectable?(%{type: :action, disabled: false}) - true - - iex> Behavior.selectable?(%{type: :action, disabled: true}) - false - - iex> Behavior.selectable?(%{type: :separator}) - false - """ - @spec selectable?(map()) :: boolean() - def selectable?(item) do - item.type == :action and not Map.get(item, :disabled, false) - end - - @doc """ - Finds the ID of the first selectable item in a list. - - Returns `nil` if no selectable items exist. This is useful for initializing - the cursor position to the first available action. - - ## Examples - - iex> items = [ - ...> %{type: :separator}, - ...> %{type: :action, id: :copy, disabled: false}, - ...> %{type: :action, id: :paste, disabled: false} - ...> ] - iex> Behavior.find_first_selectable(items) - :copy - - iex> Behavior.find_first_selectable([%{type: :separator}]) - nil - """ - @spec find_first_selectable([map()]) :: term() | nil - def find_first_selectable(items) do - items - |> Enum.find(&selectable?/1) - |> case do - nil -> nil - item -> item.id - end - end - - # ---------------------------------------------------------------------------- - # Cursor Management - # ---------------------------------------------------------------------------- - - @doc """ - Moves the cursor in the specified direction among selectable items. - - Direction is `+1` for next item, `-1` for previous item. Movement is clamped - at boundaries (does not wrap around). Non-selectable items (separators, disabled - actions) are automatically skipped. - - ## Parameters - - - `state` - State map containing `:items` and `:cursor` keys - - `direction` - Integer offset: `-1` for previous, `+1` for next - - ## Returns - - Updated state with new cursor position. If cursor is at a boundary and movement - would go beyond it, cursor remains unchanged. - - ## Examples - - state = %{ - items: [ - %{type: :action, id: :copy}, - %{type: :action, id: :paste} - ], - cursor: :copy - } - - # Move to next item - new_state = Behavior.move_cursor(state, 1) - # new_state.cursor == :paste - - # At boundary, cursor stays in place - new_state = Behavior.move_cursor(new_state, 1) - # new_state.cursor == :paste (unchanged) - """ - @spec move_cursor(map(), integer()) :: map() - def move_cursor(state, direction) do - selectable_items = Enum.filter(state.items, &selectable?/1) - - case Enum.find_index(selectable_items, fn item -> item.id == state.cursor end) do - nil -> - state - - current_idx -> - new_idx = current_idx + direction - new_idx = max(0, min(new_idx, length(selectable_items) - 1)) - item = Enum.at(selectable_items, new_idx) - %{state | cursor: item.id} - end - end - - # ---------------------------------------------------------------------------- - # Menu Actions - # ---------------------------------------------------------------------------- - - @doc """ - Selects the item at the current cursor position. - - Invokes the `on_select` callback if the item is selectable (action type and - not disabled), then closes the menu. If the cursor is on a non-selectable - item, no action is taken. - - ## Parameters - - - `state` - State map containing: - - `:items` - List of menu items - - `:cursor` - ID of currently focused item - - `:on_select` - Callback function `fn id -> ... end` (optional) - - ## Returns - - Updated state with menu closed (`:visible` set to `false`). - - ## Callback Execution - - The `on_select` callback is executed synchronously. If the callback raises an - exception, the widget process will crash and restart. Callbacks should handle - their own errors to avoid disrupting the UI. - - ## Examples - - state = %{ - items: [%{type: :action, id: :copy, disabled: false}], - cursor: :copy, - on_select: fn id -> IO.puts("Selected: \#{id}") end, - visible: true - } - - new_state = Behavior.select_at_cursor(state) - # Prints: "Selected: copy" - # new_state.visible == false - """ - @spec select_at_cursor(map()) :: map() - def select_at_cursor(state) do - # Use O(1) map lookup if available, otherwise fall back to O(n) list search - item = - case Map.get(state, :item_map) do - nil -> Enum.find(state.items, fn item -> item.id == state.cursor end) - item_map -> Map.get(item_map, state.cursor) - end - - case item do - %{type: :action} = item -> - if state.on_select && not Map.get(item, :disabled, false) do - safe_callback(state.on_select, [item.id], "on_select") - end - - close_menu(state) - - _ -> - state - end - end - - @doc """ - Closes the menu and invokes the `on_close` callback. - - Sets the `:visible` state to `false` and calls the `on_close` callback if - provided. This is typically called when the menu is dismissed via escape key - or click outside. - - ## Parameters - - - `state` - State map containing: - - `:on_close` - Callback function `fn -> ... end` (optional) - - `:visible` - Current visibility state - - ## Returns - - Updated state with `:visible` set to `false`. - - ## Callback Execution - - The `on_close` callback is executed synchronously before setting visibility. - If the callback raises an exception, the widget process will crash and restart. - - ## Examples - - state = %{ - on_close: fn -> IO.puts("Menu closed") end, - visible: true - } - - new_state = Behavior.close_menu(state) - # Prints: "Menu closed" - # new_state.visible == false - """ - @spec close_menu(map()) :: map() - def close_menu(state) do - if state.on_close do - safe_callback(state.on_close, [], "on_close") - end - - %{state | visible: false} - end - - # ---------------------------------------------------------------------------- - # Private: Safe Callback Execution - # ---------------------------------------------------------------------------- - - @doc false - @spec safe_callback(function(), list(), String.t()) :: :ok | {:error, term()} - defp safe_callback(callback, args, callback_name) do - apply(callback, args) - :ok - rescue - e -> - require Logger - - Logger.error("ContextMenu #{callback_name} callback error: #{inspect(e)}") - - {:error, e} - end -end diff --git a/lib/term_ui/widgets/context_menu/factory.ex b/lib/term_ui/widgets/context_menu/factory.ex deleted file mode 100644 index 05bce204..00000000 --- a/lib/term_ui/widgets/context_menu/factory.ex +++ /dev/null @@ -1,241 +0,0 @@ -defmodule TermUI.Widgets.ContextMenu.Factory do - @moduledoc """ - Factory for creating context menus with automatic mode selection. - - This module provides a unified way to create context menus that automatically - selects between positioned (mouse) and inline (keyboard) modes based on - terminal capabilities and provided options. - - ## Usage - - # Auto-detect: uses positioned if position provided, inline otherwise - {:ok, {module, props}} = Factory.create( - items: [ - ContextMenu.action(:copy, "Copy"), - ContextMenu.action(:paste, "Paste") - ], - position: {10, 5}, # Optional - triggers positioned mode - on_select: fn id -> handle_action(id) end - ) - - # Force inline mode - {:ok, {module, props}} = Factory.create( - items: items, - mode: :inline, - on_select: on_select - ) - - # Force positioned mode (requires position) - {:ok, {module, props}} = Factory.create( - items: items, - mode: :positioned, - position: {x, y}, - on_select: on_select - ) - - ## Mode Selection - - The factory selects a menu mode based on: - - 1. **Explicit mode** - If `:mode` option is provided: - - `:inline` - Always use `ContextMenu.Inline` - - `:positioned` - Always use `ContextMenu` (requires `:position`) - - `:auto` - Auto-detect based on position and capabilities (default) - - 2. **Auto-detection** (`:mode == :auto` or not specified): - - If `:position` is provided → use positioned `ContextMenu` - - If no position and mouse not supported → use `ContextMenu.Inline` - - If no position but mouse supported → returns error (caller should provide position) - - ## Return Value - - Returns `{:ok, {module, props}}` where: - - `module` is either `TermUI.Widgets.ContextMenu` or `TermUI.Widgets.ContextMenu.Inline` - - `props` are the initialized props for that module - - Or `{:error, reason}` if the configuration is invalid. - """ - - alias TermUI.Capabilities - alias TermUI.Widgets.ContextMenu - alias TermUI.Widgets.ContextMenu.Inline - - @type mode :: :auto | :positioned | :inline - - # Dialyzer: Functions return specific types - @dialyzer {:nowarn_function, create: 1, create!: 1, determine_mode: 1, build_props: 3} - - @type option :: - {:items, [map()]} - | {:position, {non_neg_integer(), non_neg_integer()}} - | {:mode, mode()} - | {:on_select, (term() -> any())} - | {:on_close, (-> any())} - | {:orientation, :horizontal | :vertical} - | {:item_style, term()} - | {:selected_style, term()} - | {:disabled_style, term()} - | {:number_style, term()} - - @doc """ - Creates a context menu with automatic mode selection. - - ## Options - - - `:items` - List of menu items (required). Use `ContextMenu.action/3` and - `ContextMenu.separator/0` to create items. - - `:position` - `{x, y}` tuple for positioned mode. If provided and mode is - `:auto`, positioned mode will be used. - - `:mode` - Explicit mode selection: - - `:auto` - Auto-detect based on position and capabilities (default) - - `:positioned` - Force positioned mode (requires `:position`) - - `:inline` - Force inline mode - - `:on_select` - Callback when item is selected: `fn id -> ... end` - - `:on_close` - Callback when menu is closed: `fn -> ... end` - - `:orientation` - For inline mode: `:horizontal` (default) or `:vertical` - - `:item_style` - Style for normal items - - `:selected_style` - Style for focused item - - `:disabled_style` - Style for disabled items - - `:number_style` - For inline mode: style for `[n]` prefix - - ## Returns - - - `{:ok, {module, props}}` - The module and props to use - - `{:error, :missing_items}` - Items not provided - - `{:error, :missing_position}` - Positioned mode requires position - - `{:error, :position_required}` - Auto mode with mouse support but no position - """ - @spec create(keyword()) :: {:ok, {module(), map()}} | {:error, atom()} - def create(opts) do - opts - |> fetch_and_validate_items() - |> fetch_and_validate_mode(opts) - |> build_menu_props(opts) - end - - defp fetch_and_validate_items(opts) do - fetch_items(opts) - end - - defp fetch_and_validate_mode({:error, _} = error, _opts), do: error - - defp fetch_and_validate_mode({:ok, items}, opts) do - case determine_mode(opts) do - {:ok, mode} -> {:ok, items, mode} - {:error, _} = error -> error - end - end - - defp build_menu_props({:error, _} = error, _opts), do: error - - defp build_menu_props({:ok, items, mode}, opts) do - build_props(mode, items, opts) - end - - @doc """ - Creates a context menu, raising on error. - - Same as `create/1` but raises `ArgumentError` on invalid configuration. - """ - @spec create!(keyword()) :: {module(), map()} - def create!(opts) do - case create(opts) do - {:ok, result} -> - result - - {:error, :missing_items} -> - raise ArgumentError, "ContextMenu.Factory.create!/1 requires :items option" - - {:error, :missing_position} -> - raise ArgumentError, "positioned mode requires :position option" - - {:error, :position_required} -> - raise ArgumentError, - "mouse is supported but no position provided; " <> - "provide :position or use mode: :inline" - end - end - - @doc """ - Returns whether the terminal supports mouse tracking. - - This is used for auto-detection when no position is provided. - """ - @spec mouse_supported?() :: boolean() - def mouse_supported? do - Capabilities.supports_mouse?() - end - - # Private implementation - - @spec fetch_items(keyword()) :: {:ok, [map()]} | {:error, :missing_items} - defp fetch_items(opts) do - case Keyword.fetch(opts, :items) do - {:ok, items} when is_list(items) -> {:ok, items} - _ -> {:error, :missing_items} - end - end - - @spec determine_mode(keyword()) :: {:ok, mode()} | {:error, atom()} - defp determine_mode(opts) do - mode = Keyword.get(opts, :mode, :auto) - position = Keyword.get(opts, :position) - - case {mode, position} do - {:inline, _} -> - {:ok, :inline} - - {:positioned, nil} -> - {:error, :missing_position} - - {:positioned, _pos} -> - {:ok, :positioned} - - {:auto, {_x, _y}} -> - {:ok, :positioned} - - {:auto, nil} -> - resolve_auto_mode() - end - end - - defp resolve_auto_mode do - if mouse_supported?() do - {:error, :position_required} - else - {:ok, :inline} - end - end - - @spec build_props(mode(), [map()], keyword()) :: {:ok, {module(), map()}} - defp build_props(:positioned, items, opts) do - props = - ContextMenu.new( - items: items, - position: Keyword.fetch!(opts, :position), - on_select: Keyword.get(opts, :on_select), - on_close: Keyword.get(opts, :on_close), - item_style: Keyword.get(opts, :item_style), - selected_style: Keyword.get(opts, :selected_style), - disabled_style: Keyword.get(opts, :disabled_style) - ) - - {:ok, {ContextMenu, props}} - end - - defp build_props(:inline, items, opts) do - props = - Inline.new( - items: items, - on_select: Keyword.get(opts, :on_select), - on_close: Keyword.get(opts, :on_close), - orientation: Keyword.get(opts, :orientation, :horizontal), - item_style: Keyword.get(opts, :item_style), - selected_style: Keyword.get(opts, :selected_style), - disabled_style: Keyword.get(opts, :disabled_style), - number_style: Keyword.get(opts, :number_style) - ) - - {:ok, {Inline, props}} - end -end diff --git a/lib/term_ui/widgets/context_menu/inline.ex b/lib/term_ui/widgets/context_menu/inline.ex deleted file mode 100644 index 7377c41c..00000000 --- a/lib/term_ui/widgets/context_menu/inline.ex +++ /dev/null @@ -1,358 +0,0 @@ -defmodule TermUI.Widgets.ContextMenu.Inline do - @moduledoc """ - Inline context menu variant for keyboard-only environments. - - Unlike the standard ContextMenu which appears at a mouse position, the Inline - variant renders in place with numbered items for direct selection. This makes - it ideal for TTY mode where mouse positioning may not be available. - - ## Usage - - ContextMenu.Inline.new( - items: [ - ContextMenu.action(:copy, "Copy"), - ContextMenu.action(:paste, "Paste"), - ContextMenu.action(:delete, "Delete") - ], - on_select: fn id -> handle_action(id) end, - on_close: fn -> handle_close() end - ) - - ## Rendering - - Items are rendered with numbered prefixes: - - [1] Copy [2] Paste [3] Delete - - In vertical orientation: - - [1] Copy - [2] Paste - [3] Delete - - ## Keyboard Controls - - - **Number keys (1-9)**: Directly select the numbered item - - **Up/Down** (vertical) or **Left/Right** (horizontal): Navigate between items - - **Enter/Space**: Select the currently focused item - - **Escape**: Close the menu without selecting - - ## Notes - - - Separators and disabled items are not numbered - - Maximum of 9 items can be numbered (items 10+ require arrow navigation) - - Only selectable items (non-disabled actions) get numbers - - ## Callback Error Handling - - Callbacks (`on_select`, `on_close`) are executed synchronously. If a callback - raises an exception, the widget process will crash and restart. Callbacks - should handle their own errors to avoid disrupting the UI. - - See `TermUI.Widgets.ContextMenu` moduledoc for callback best practices. - """ - - use TermUI.StatefulComponent - - alias TermUI.CharacterSet - alias TermUI.Event - alias TermUI.Widgets.ContextMenu.Behavior - - # Dialyzer: Functions return specific map types - @dialyzer {:nowarn_function, new: 1, show: 1, hide: 1, handle_info: 2, execute_menu_action: 2} - - @type orientation :: :horizontal | :vertical - - # ---------------------------------------------------------------------------- - # Props - # ---------------------------------------------------------------------------- - - @doc """ - Creates new ContextMenu.Inline widget props. - - ## Options - - - `:items` - List of menu items (required). Use `ContextMenu.action/3` and - `ContextMenu.separator/0` to create items. - - `:on_select` - Callback when item is selected: `fn id -> ... end` - Executed synchronously. Should not raise exceptions. - - `:on_close` - Callback when menu is closed without selection: `fn -> ... end` - Executed synchronously. Should not raise exceptions. - - `:orientation` - `:horizontal` (side by side) or `:vertical` (stacked). - Default: `:horizontal` - - `:item_style` - Style for normal items - - `:selected_style` - Style for focused item - - `:disabled_style` - Style for disabled items - - `:number_style` - Style for the `[n]` prefix - """ - @spec new(keyword()) :: map() - def new(opts) do - %{ - items: Keyword.fetch!(opts, :items), - on_select: Keyword.get(opts, :on_select), - on_close: Keyword.get(opts, :on_close), - orientation: Keyword.get(opts, :orientation, :horizontal), - item_style: Keyword.get(opts, :item_style), - selected_style: Keyword.get(opts, :selected_style), - disabled_style: Keyword.get(opts, :disabled_style), - number_style: Keyword.get(opts, :number_style) - } - end - - # ---------------------------------------------------------------------------- - # StatefulComponent Callbacks - # ---------------------------------------------------------------------------- - - @impl true - def init(props) do - # Build number-to-item mapping for selectable items (1-9 only) - {number_map, _} = build_number_map(props.items) - - # Build ID-to-item map for O(1) lookups - item_map = Map.new(props.items, fn item -> {item.id, item} end) - - state = %{ - items: props.items, - item_map: item_map, - cursor: Behavior.find_first_selectable(props.items), - on_select: props.on_select, - on_close: props.on_close, - orientation: props.orientation, - item_style: props.item_style, - selected_style: props.selected_style, - disabled_style: props.disabled_style, - number_style: props.number_style, - number_map: number_map, - visible: true - } - - {:ok, state} - end - - @impl true - def handle_event(%Event.Key{key: key}, state) - when key in [:up, :left] do - state = Behavior.move_cursor(state, -1) - {:ok, state} - end - - def handle_event(%Event.Key{key: key}, state) - when key in [:down, :right] do - state = Behavior.move_cursor(state, 1) - {:ok, state} - end - - def handle_event(%Event.Key{key: key}, state) when key in [:enter, " "] do - state = Behavior.select_at_cursor(state) - {:ok, state} - end - - def handle_event(%Event.Key{key: :escape}, state) do - state = Behavior.close_menu(state) - {:ok, state} - end - - # Handle number keys 1-9 for direct selection - def handle_event(%Event.Key{key: key}, state) - when key in ["1", "2", "3", "4", "5", "6", "7", "8", "9"] do - number = String.to_integer(key) - state = select_by_number(state, number) - {:ok, state} - end - - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, _area) do - if state.visible do - # Use cached number_map from state (built during init/1) - items_with_numbers = - state.items - |> Enum.map(fn item -> - number = find_number_for_item(state.number_map, item) - render_item(state, item, number) - end) - - case state.orientation do - :horizontal -> - # Join items with spacing - spaced_items = - items_with_numbers - |> Enum.intersperse(text(" ")) - |> List.flatten() - - stack(:horizontal, spaced_items) - - :vertical -> - stack(:vertical, items_with_numbers) - end - else - empty() - end - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Gets whether the menu is visible. - """ - @spec visible?(map()) :: boolean() - def visible?(state) do - state.visible - end - - @doc """ - Shows the menu. - """ - @spec show(map()) :: map() - def show(state) do - %{state | visible: true} - end - - @doc """ - Hides the menu. - """ - @spec hide(map()) :: map() - def hide(state) do - %{state | visible: false} - end - - @doc """ - Gets the currently focused item ID. - """ - @spec get_cursor(map()) :: term() - def get_cursor(state) do - state.cursor - end - - # ---------------------------------------------------------------------------- - # Private: Number Mapping - # ---------------------------------------------------------------------------- - - # Builds a map from number (1-9) to item ID for selectable items - @spec build_number_map([map()]) :: {%{pos_integer() => term()}, pos_integer()} - defp build_number_map(items) do - items - |> Enum.reduce({%{}, 1}, fn item, {map, num} -> - if Behavior.selectable?(item) and num <= 9 do - {Map.put(map, num, item.id), num + 1} - else - {map, num} - end - end) - end - - # Finds the number assigned to an item (or nil if not numbered) - @spec find_number_for_item(%{pos_integer() => term()}, map()) :: pos_integer() | nil - defp find_number_for_item(number_map, item) do - Enum.find_value(number_map, fn - {num, id} when id == item.id -> num - _ -> nil - end) - end - - # ---------------------------------------------------------------------------- - # Private: Selection - # ---------------------------------------------------------------------------- - - @spec select_by_number(map(), pos_integer()) :: map() - defp select_by_number(state, number) do - case Map.get(state.number_map, number) do - nil -> - # Number not mapped to any item - state - - item_id -> - # Use O(1) map lookup instead of O(n) Enum.find - handle_menu_item_selection(state, item_id) - end - end - - defp handle_menu_item_selection(state, item_id) do - case Map.get(state.item_map, item_id) do - %{type: :action} = item -> - execute_menu_action(state, item) - - _ -> - state - end - end - - defp execute_menu_action(state, item) do - if state.on_select && not Map.get(item, :disabled, false) do - safe_callback(state.on_select, [item.id], "on_select") - end - - Behavior.close_menu(state) - end - - # Safe callback execution with error handling - defp safe_callback(callback, args, callback_name) do - apply(callback, args) - :ok - rescue - e -> - require Logger - Logger.error("ContextMenu.Inline #{callback_name} callback error: #{inspect(e)}") - {:error, e} - end - - # ---------------------------------------------------------------------------- - # Private: Rendering - # ---------------------------------------------------------------------------- - - @spec render_item(map(), map(), pos_integer() | nil) :: term() - defp render_item(state, item, number) do - case item.type do - :separator -> - render_separator(state) - - _ -> - render_action_item(state, item, number) - end - end - - defp render_separator(state) do - chars = CharacterSet.current_charset() - - case state.orientation do - :horizontal -> text(chars.v_line) - :vertical -> text(String.duplicate(chars.h_line, 3)) - end - end - - defp render_action_item(state, item, number) do - # Build the number prefix - prefix = - if number do - "[#{number}] " - else - " " - end - - label = prefix <> item.label - - # Determine style - style = - cond do - Map.get(item, :disabled, false) -> - state.disabled_style - - item.id == state.cursor -> - state.selected_style - - true -> - state.item_style - end - - if style do - styled(text(label), style) - else - text(label) - end - end -end diff --git a/lib/term_ui/widgets/dialog.ex b/lib/term_ui/widgets/dialog.ex deleted file mode 100644 index 5dd820ff..00000000 --- a/lib/term_ui/widgets/dialog.ex +++ /dev/null @@ -1,524 +0,0 @@ -defmodule TermUI.Widgets.Dialog do - @moduledoc """ - Dialog widget for modal overlays. - - Dialog appears centered over the application with a backdrop, traps focus, - and handles Escape for cancellation. Use for confirmations, forms, and - important messages. - - ## Usage - - Dialog.new( - title: "Confirm Delete", - content: delete_confirmation_content(), - buttons: [ - %{id: :cancel, label: "Cancel"}, - %{id: :confirm, label: "Delete", style: :danger} - ], - on_close: fn -> dismiss_dialog() end, - on_confirm: fn button_id -> handle_action(button_id) end - ) - - ## Features - - - Centered display with customizable width/height - - Semi-transparent backdrop - - Focus trapping (Tab cycles within dialog) - - Escape to close - - Button navigation and selection - - ## Keyboard Navigation - - - Tab/Shift+Tab: Move between buttons - - Enter/Space: Activate focused button - - Escape: Close dialog - - ## Mouse Support - - In raw mode, dialog buttons can be clicked with the mouse. Clicking a button - produces the same result as pressing Enter on that button. Mouse events are - ignored in TTY mode. - - - Left click on button: Activate the button - """ - - use TermUI.StatefulComponent - - alias TermUI.CharacterSet - alias TermUI.Event - alias TermUI.PersistentTerms - alias TermUI.Renderer.Style - alias TermUI.Theme - - # Dialyzer: Suppress opaque type warnings for Style helpers and contract warnings for specific map types - @dialyzer {:nowarn_function, bg_theme: 1, new: 1, show: 1, hide: 1, set_content: 2} - - @doc """ - Creates new Dialog widget props. - - ## Options - - - `:title` - Dialog title (required) - - `:content` - Dialog body content (render node) - - `:buttons` - List of button definitions - - `:width` - Dialog width (default: 40) - - `:on_close` - Callback when dialog is closed - - `:on_confirm` - Callback when button is activated - - `:closeable` - Whether Escape closes dialog (default: true) - - `:title_style` - Style for title bar - - `:content_style` - Style for content area - - `:button_style` - Style for buttons - - `:focused_button_style` - Style for focused button - """ - @spec new(keyword()) :: map() - def new(opts) do - %{ - title: Keyword.fetch!(opts, :title), - content: Keyword.get(opts, :content, empty()), - buttons: Keyword.get(opts, :buttons, [%{id: :ok, label: "OK"}]), - width: Keyword.get(opts, :width, 40), - on_close: Keyword.get(opts, :on_close), - on_confirm: Keyword.get(opts, :on_confirm), - closeable: Keyword.get(opts, :closeable, true), - title_style: Keyword.get(opts, :title_style), - content_style: Keyword.get(opts, :content_style), - button_style: Keyword.get(opts, :button_style), - focused_button_style: Keyword.get(opts, :focused_button_style) - } - end - - # ---------------------------------------------------------------------------- - # Style Helper Functions - # ---------------------------------------------------------------------------- - - @spec bg_theme(atom()) :: Style.t() - defp bg_theme(color) when is_atom(color), - do: Style.new() |> Style.bg(color) - - # ---------------------------------------------------------------------------- - # StatefulComponent Callbacks - # ---------------------------------------------------------------------------- - - @impl true - def init(props) do - state = %{ - title: props.title, - content: props.content, - buttons: props.buttons, - width: props.width, - focused_button: get_default_focus(props.buttons), - on_close: props.on_close, - on_confirm: props.on_confirm, - closeable: props.closeable, - title_style: props.title_style, - content_style: props.content_style, - button_style: props.button_style, - focused_button_style: props.focused_button_style, - visible: true - } - - {:ok, state} - end - - @impl true - def handle_event(%Event.Key{key: :escape}, state) do - if state.closeable do - close_dialog(state) - else - {:ok, state} - end - end - - def handle_event(%Event.Key{key: :tab, modifiers: modifiers}, state) do - # Focus trapping - cycle through buttons - direction = if :shift in modifiers, do: -1, else: 1 - state = move_button_focus(state, direction) - {:ok, state} - end - - def handle_event(%Event.Key{key: key}, state) when key in [:enter, " "] do - # Activate focused button and close dialog - if state.on_confirm && state.focused_button do - state.on_confirm.(state.focused_button) - end - - {:ok, %{state | visible: false}} - end - - def handle_event(%Event.Key{key: :left}, state) do - state = move_button_focus(state, -1) - {:ok, state} - end - - def handle_event(%Event.Key{key: :right}, state) do - state = move_button_focus(state, 1) - {:ok, state} - end - - def handle_event(%Event.Mouse{action: :press, button: :left, x: x, y: y}, state) do - # Only handle mouse events in raw mode - if PersistentTerms.backend_mode() == :raw do - handle_button_click(state, x, y) - else - # Ignore mouse events in TTY mode - {:ok, state} - end - end - - def handle_event(_event, state) do - {:ok, state} - end - - # ---------------------------------------------------------------------------- - # Private Helpers for Event Handling - # ---------------------------------------------------------------------------- - - defp handle_button_click(state, x, y) do - case find_button_at_position(state, x, y) do - nil -> - {:ok, state} - - button_id -> - activate_button(state, button_id) - end - end - - defp activate_button(state, button_id) do - if state.on_confirm do - state.on_confirm.(button_id) - end - - {:ok, %{state | focused_button: button_id, visible: false}} - end - - @impl true - def render(%{visible: false}, _area), do: empty() - - def render(state, area) do - # Calculate dialog position (centered) - dialog_width = state.width - dialog_height = calculate_height(state) - - pos_x = max(0, div(area.width - dialog_width, 2)) - pos_y = max(0, div(area.height - dialog_height, 2)) - - # Render dialog content - dialog = render_dialog(state, dialog_width) - - # Return as overlay with opaque background - %{ - type: :overlay, - content: dialog, - x: pos_x, - y: pos_y, - z: 100, - # Provide dimensions and background for opaque fill - width: dialog_width, - height: dialog_height, - bg: bg_theme(Theme.get_color(:background)) - } - end - - # Private functions - - defp get_default_focus(buttons) do - # Focus on first button, or one marked as default - default = Enum.find(buttons, fn b -> Map.get(b, :default, false) end) - - if default do - default.id - else - case buttons do - [first | _] -> first.id - [] -> nil - end - end - end - - defp move_button_focus(state, direction) do - button_ids = Enum.map(state.buttons, & &1.id) - - case Enum.find_index(button_ids, &(&1 == state.focused_button)) do - nil -> - state - - current_idx -> - new_idx = rem(current_idx + direction + length(button_ids), length(button_ids)) - %{state | focused_button: Enum.at(button_ids, new_idx)} - end - end - - defp close_dialog(state) do - if state.on_close do - state.on_close.() - end - - {:ok, %{state | visible: false}} - end - - defp find_button_at_position(state, click_x, click_y) do - button_row_y = calculate_button_row_y(state) - - if click_y == button_row_y do - find_button_by_x_position(state, click_x) - else - nil - end - end - - defp calculate_button_row_y(state) do - _area_width = 80 - area_height = 24 - _dialog_width = state.width - dialog_height = calculate_height(state) - - dialog_y = max(0, div(area_height - dialog_height, 2)) - content_lines = estimate_content_lines(state.content) - button_row_in_dialog = 4 + content_lines - - dialog_y + button_row_in_dialog - end - - defp find_button_by_x_position(state, click_x) do - dialog_x = calculate_dialog_x(state.width) - inner_width = state.width - 4 - button_texts = build_button_texts(state) - left_pad = calculate_button_padding(button_texts, inner_width) - buttons_start_x = dialog_x + 2 + left_pad - - find_button_at_x(state.buttons, button_texts, buttons_start_x, click_x) - end - - defp calculate_dialog_x(dialog_width) do - max(0, div(80 - dialog_width, 2)) - end - - defp build_button_texts(state) do - Enum.map(state.buttons, fn button -> - label = button.label - - if button.id == state.focused_button do - "[ " <> label <> " ]" - else - " " <> label <> " " - end - end) - end - - defp calculate_button_padding(button_texts, inner_width) do - buttons_line = Enum.join(button_texts, " ") - max(0, div(inner_width - String.length(buttons_line), 2)) - end - - defp find_button_at_x(buttons, button_texts, start_x, click_x) do - # Iterate through buttons to find which one contains click_x - Enum.reduce_while(buttons, {button_texts, start_x}, fn button, {texts, current_x} -> - [button_text | remaining_texts] = texts - button_width = String.length(button_text) - - if click_x >= current_x and click_x < current_x + button_width do - {:halt, button.id} - else - # +1 for space between buttons - {:cont, {remaining_texts, current_x + button_width + 1}} - end - end) - end - - defp calculate_height(state) do - # Title (1) + border (2) + content (estimated 3) + buttons (1) + padding (2) - content_lines = estimate_content_lines(state.content) - 3 + content_lines + 2 - end - - defp estimate_content_lines(content) do - case content do - %{type: :text, content: text} -> - String.split(text, "\n") |> length() - - %{type: :stack, direction: :vertical, children: children} -> - length(children) - - _ -> - 3 - end - end - - defp render_dialog(state, width) do - chars = CharacterSet.current_charset() - - # Title bar - title = render_title(state, width, chars) - - # Content area - content = render_content(state, width, chars) - - # Button bar - buttons = render_buttons(state, chars) - - # Border - top_border = text(chars.tl <> String.duplicate(chars.h_line, width - 2) <> chars.tr) - bottom_border = text(chars.bl <> String.duplicate(chars.h_line, width - 2) <> chars.br) - - stack(:vertical, [ - top_border, - title, - render_separator(width, chars), - content, - render_separator(width, chars), - buttons, - bottom_border - ]) - end - - defp render_title(state, width, chars) do - # Center title in available space - title_text = state.title - padding = width - String.length(title_text) - 4 - left_pad = div(padding, 2) - right_pad = padding - left_pad - - line = - chars.v_line <> - " " <> - String.duplicate(" ", left_pad) <> - title_text <> - String.duplicate(" ", right_pad) <> - " " <> chars.v_line - - if state.title_style do - styled(text(line), state.title_style) - else - text(line) - end - end - - defp render_separator(width, chars) do - text(chars.t_right <> String.duplicate(chars.h_line, width - 2) <> chars.t_left) - end - - defp render_content(state, width, chars) do - # Extract text from content node - content_text = - case state.content do - %{type: :text, content: t} -> t - %{type: :empty} -> "" - _ -> "" - end - - # Split into lines and render each with borders - inner_width = width - 4 - lines = String.split(content_text, "\n") - - content_lines = - Enum.map(lines, fn line_text -> - padded = String.pad_trailing(line_text, inner_width) - padded = String.slice(padded, 0, inner_width) - line = chars.v_line <> " " <> padded <> " " <> chars.v_line - - if state.content_style do - styled(text(line), state.content_style) - else - text(line) - end - end) - - stack(:vertical, content_lines) - end - - defp render_buttons(state, chars) do - button_texts = - Enum.map(state.buttons, fn button -> - label = button.label - - if button.id == state.focused_button do - "[ " <> label <> " ]" - else - " " <> label <> " " - end - end) - - buttons_line = Enum.join(button_texts, " ") - - # Center buttons - inner_width = state.width - 4 - padding = inner_width - String.length(buttons_line) - left_pad = div(padding, 2) - - line = - chars.v_line <> - " " <> - String.duplicate(" ", left_pad) <> - buttons_line <> - String.duplicate(" ", inner_width - left_pad - String.length(buttons_line)) <> - " " <> chars.v_line - - if state.focused_button_style do - styled(text(line), state.focused_button_style) - else - text(line) - end - end - - # Public API - - @doc """ - Gets whether the dialog is visible. - """ - @spec visible?(map()) :: boolean() - def visible?(state) do - state.visible - end - - @doc """ - Shows the dialog. - """ - @spec show(map()) :: map() - def show(state) do - %{state | visible: true} - end - - @doc """ - Hides the dialog. - """ - @spec hide(map()) :: map() - def hide(state) do - %{state | visible: false} - end - - @doc """ - Gets the currently focused button ID. - """ - @spec get_focused_button(map()) :: term() - def get_focused_button(state) do - state.focused_button - end - - @doc """ - Sets focus to a specific button. - """ - @spec focus_button(map(), term()) :: map() - def focus_button(state, button_id) do - if Enum.any?(state.buttons, &(&1.id == button_id)) do - %{state | focused_button: button_id} - else - state - end - end - - @doc """ - Updates the dialog content. - """ - @spec set_content(map(), term()) :: map() - def set_content(state, content) do - %{state | content: content} - end - - @doc """ - Updates the dialog title. - """ - @spec set_title(map(), String.t()) :: map() - def set_title(state, title) do - %{state | title: title} - end -end diff --git a/lib/term_ui/widgets/form_builder.ex b/lib/term_ui/widgets/form_builder.ex deleted file mode 100644 index 3d9c3d53..00000000 --- a/lib/term_ui/widgets/form_builder.ex +++ /dev/null @@ -1,896 +0,0 @@ -defmodule TermUI.Widgets.FormBuilder do - @moduledoc """ - FormBuilder widget for structured forms with multiple field types. - - Provides comprehensive form handling with validation, navigation, - conditional fields, and field grouping. - - ## Usage - - FormBuilder.new( - fields: [ - %{id: :username, type: :text, label: "Username", required: true}, - %{id: :password, type: :password, label: "Password", required: true}, - %{id: :remember, type: :checkbox, label: "Remember me"}, - %{id: :role, type: :select, label: "Role", - options: [{"admin", "Admin"}, {"user", "User"}]} - ], - on_submit: fn values -> handle_submit(values) end, - on_change: fn field_id, value -> handle_change(field_id, value) end - ) - - ## Field Types - - - `:text` - Single line text input - - `:password` - Masked text input - - `:checkbox` - Boolean toggle - - `:radio` - Single selection from options - - `:select` - Dropdown single selection - - `:multi_select` - Multiple selection from options - - ## Keyboard Navigation - - - Tab/Shift+Tab: Move between fields - - Up/Down: Navigate options (radio/select/multi_select) - - Space: Toggle checkbox, select option - - Enter: Submit form (when on submit button) - - Escape: Cancel editing - """ - - use TermUI.StatefulComponent - - alias TermUI.CharacterSet - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Theme - alias TermUI.Widgets.WidgetHelpers, as: Helpers - - # Dialyzer: Suppress opaque type warnings for Style helpers and contract warnings for specific map types - @dialyzer {:nowarn_function, - fg_semantic: 1, new: 1, set_values: 2, valid?: 1, validate: 1, reset: 1} - - @type field_type :: :text | :password | :checkbox | :radio | :select | :multi_select - - @type field_def :: %{ - id: atom(), - type: field_type(), - label: String.t(), - options: [{term(), String.t()}] | nil, - required: boolean(), - validators: [(term() -> :ok | {:error, String.t()})], - visible_when: (map() -> boolean()) | nil, - group: atom() | nil, - placeholder: String.t() | nil, - default: term() | nil - } - - @type group_def :: %{ - id: atom(), - label: String.t(), - collapsible: boolean() - } - - # ---------------------------------------------------------------------------- - # Style Helper Functions - # ---------------------------------------------------------------------------- - - @spec fg_semantic(atom()) :: Style.t() - defp fg_semantic(color) when is_atom(color), - do: Style.new() |> Style.fg(color) - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Creates new FormBuilder widget props. - - ## Options - - - `:fields` - List of field definitions (required) - - `:groups` - List of group definitions for organizing fields - - `:on_submit` - Callback when form is submitted - - `:on_change` - Callback when any field value changes - - `:values` - Initial field values - - `:show_submit_button` - Whether to show submit button (default: true) - - `:submit_label` - Label for submit button (default: "Submit") - - `:validate_on_blur` - Validate when field loses focus (default: true) - """ - @spec new(keyword()) :: map() - def new(opts) do - fields = Keyword.fetch!(opts, :fields) - - %{ - fields: normalize_fields(fields), - groups: Keyword.get(opts, :groups, []), - on_submit: Keyword.get(opts, :on_submit), - on_change: Keyword.get(opts, :on_change), - initial_values: Keyword.get(opts, :values, %{}), - show_submit_button: Keyword.get(opts, :show_submit_button, true), - submit_label: Keyword.get(opts, :submit_label, "Submit"), - validate_on_blur: Keyword.get(opts, :validate_on_blur, true), - label_width: Keyword.get(opts, :label_width, 15), - field_width: Keyword.get(opts, :field_width, 30) - } - end - - defp normalize_fields(fields) do - Enum.map(fields, fn field -> - Map.merge( - %{ - required: false, - validators: [], - visible_when: nil, - group: nil, - placeholder: nil, - default: nil, - options: nil - }, - field - ) - end) - end - - @impl true - def init(props) do - # Initialize values with defaults - initial_values = - Enum.reduce(props.fields, props.initial_values, fn field, acc -> - if Map.has_key?(acc, field.id) do - acc - else - default_value = field.default || get_default_for_type(field.type) - Map.put(acc, field.id, default_value) - end - end) - - # Get first visible field for focus - first_field = get_first_visible_field(props.fields, initial_values) - - state = %{ - fields: props.fields, - groups: props.groups, - values: initial_values, - errors: %{}, - focused_field: first_field, - focused_option: 0, - collapsed_groups: MapSet.new(), - on_submit: props.on_submit, - on_change: props.on_change, - show_submit_button: props.show_submit_button, - submit_label: props.submit_label, - validate_on_blur: props.validate_on_blur, - label_width: props.label_width, - field_width: props.field_width, - editing_text: nil, - submit_focused: false - } - - {:ok, state} - end - - @impl true - def update(new_props, state) do - # Update fields and groups from new props - state = - state - |> Map.put(:fields, normalize_fields(new_props.fields)) - |> Map.put(:groups, new_props.groups) - |> Map.put(:show_submit_button, new_props.show_submit_button) - |> Map.put(:submit_label, new_props.submit_label) - |> Map.put(:label_width, new_props.label_width) - |> Map.put(:field_width, new_props.field_width) - - # Update values with any new initial values, keeping existing values - initial_values = new_props.initial_values || %{} - values = Map.merge(initial_values, state.values) - state = %{state | values: values} - - {:ok, state} - end - - defp get_default_for_type(:checkbox), do: false - defp get_default_for_type(:multi_select), do: [] - defp get_default_for_type(_), do: "" - - defp get_first_visible_field(fields, values) do - fields - |> Enum.filter(&field_visible?(&1, values)) - |> List.first() - |> case do - nil -> nil - field -> field.id - end - end - - @impl true - def handle_event(%Event.Key{key: :tab, modifiers: modifiers}, state) do - direction = if :shift in modifiers, do: -1, else: 1 - state = navigate_field(state, direction) - {:ok, state} - end - - def handle_event(%Event.Key{key: :up}, state) do - field = get_field(state, state.focused_field) - - if field && field.type in [:radio, :select, :multi_select] do - state = navigate_option(state, -1) - {:ok, state} - else - state = navigate_field(state, -1) - {:ok, state} - end - end - - def handle_event(%Event.Key{key: :down}, state) do - field = get_field(state, state.focused_field) - - if field && field.type in [:radio, :select, :multi_select] do - state = navigate_option(state, 1) - {:ok, state} - else - state = navigate_field(state, 1) - {:ok, state} - end - end - - def handle_event(%Event.Key{key: " "}, state) do - handle_space_key(state) - end - - def handle_event(%Event.Key{key: :enter}, state) do - if state.submit_focused do - submit_form(state) - else - field = get_field(state, state.focused_field) - - if field && field.type in [:radio, :select] do - state = select_current_option(state) - {:ok, state} - else - # Move to next field or submit - state = navigate_field(state, 1) - {:ok, state} - end - end - end - - def handle_event(%Event.Key{key: :backspace}, state) do - field = get_field(state, state.focused_field) - - if field && field.type in [:text, :password] do - state = delete_char(state) - {:ok, state} - else - {:ok, state} - end - end - - def handle_event(%Event.Key{char: char}, state) when is_binary(char) and char != "" do - field = get_field(state, state.focused_field) - - if field && field.type in [:text, :password] do - state = append_char(state, char) - {:ok, state} - else - {:ok, state} - end - end - - def handle_event(%Event.Key{key: :escape}, state) do - # Cancel editing or blur focus - {:ok, state} - end - - def handle_event(_event, state) do - {:ok, state} - end - - # ---------------------------------------------------------------------------- - # Private Helpers for Event Handling - # ---------------------------------------------------------------------------- - - defp handle_space_key(%{submit_focused: true} = state), do: submit_form(state) - - defp handle_space_key(state) do - field = get_field(state, state.focused_field) - handle_space_on_field(field, state) - end - - defp handle_space_on_field(%{type: :checkbox} = field, state) do - state = toggle_checkbox(state, field.id) - {:ok, state} - end - - defp handle_space_on_field(%{type: type}, state) when type in [:radio, :select] do - state = select_current_option(state) - {:ok, state} - end - - defp handle_space_on_field(%{type: type}, state) when type in [:text, :password] do - state = append_char(state, " ") - {:ok, state} - end - - defp handle_space_on_field(%{type: :multi_select} = field, state) do - current_values = Map.get(state.values, field.id, []) - {value, _label} = Enum.at(field.options, state.focused_option, {"", ""}) - - updated_values = - if value in current_values do - List.delete(current_values, value) - else - [value | current_values] - end - - state = update_value(state, field.id, updated_values) - {:ok, state} - end - - defp handle_space_on_field(_field, state), do: {:ok, state} - - @impl true - def render(state, area) do - # Get character set for group indicators - chars = CharacterSet.current_charset() - - # Group fields by their group - grouped_fields = group_fields(state) - - # Render each group - rendered_groups = - Enum.flat_map(grouped_fields, fn {group_id, fields} -> - render_group(state, group_id, fields, area, chars) - end) - - # Add submit button if enabled - elements = - if state.show_submit_button do - rendered_groups ++ [render_submit_button(state)] - else - rendered_groups - end - - stack(:vertical, elements) - end - - # Navigation helpers - - defp navigate_field(state, direction) do - visible_fields = - state.fields - |> Enum.filter(&field_visible?(&1, state.values)) - |> Enum.map(& &1.id) - - all_focusable = - if state.show_submit_button do - visible_fields ++ [:__submit__] - else - visible_fields - end - - current = - if state.submit_focused do - :__submit__ - else - state.focused_field - end - - current_idx = Enum.find_index(all_focusable, &(&1 == current)) || 0 - new_idx = rem(current_idx + direction + length(all_focusable), length(all_focusable)) - new_focus = Enum.at(all_focusable, new_idx) - - # Validate on blur if enabled - state = - if state.validate_on_blur && state.focused_field && !state.submit_focused do - validate_field(state, state.focused_field) - else - state - end - - if new_focus == :__submit__ do - %{state | submit_focused: true, focused_option: 0} - else - # Reset focused_option to 0 when navigating to a new field - %{state | focused_field: new_focus, submit_focused: false, focused_option: 0} - end - end - - defp navigate_option(state, direction) do - field = get_field(state, state.focused_field) - - if field && field.options do - option_count = length(field.options) - new_idx = rem(state.focused_option + direction + option_count, option_count) - %{state | focused_option: new_idx} - else - state - end - end - - # Field operations - - defp toggle_checkbox(state, field_id) do - current = Map.get(state.values, field_id, false) - update_value(state, field_id, !current) - end - - defp select_current_option(state) do - field = get_field(state, state.focused_field) - - if field && field.options do - {value, _label} = Enum.at(field.options, state.focused_option, {"", ""}) - update_value(state, field.id, value) - else - state - end - end - - defp append_char(state, char) do - current = Map.get(state.values, state.focused_field, "") - update_value(state, state.focused_field, current <> char) - end - - defp delete_char(state) do - current = Map.get(state.values, state.focused_field, "") - - if String.length(current) > 0 do - new_value = String.slice(current, 0..-2//1) - update_value(state, state.focused_field, new_value) - else - state - end - end - - defp update_value(state, field_id, value) do - state = %{state | values: Map.put(state.values, field_id, value)} - - # Call on_change callback with error handling - if state.on_change do - try do - state.on_change.(field_id, value) - rescue - e -> - require Logger - - Logger.error( - "FormBuilder on_change callback error for field #{field_id}: #{inspect(e)}" - ) - end - end - - state - end - - # Validation - - defp validate_field(state, field_id) do - field = get_field(state, field_id) - value = Map.get(state.values, field_id) - - errors = run_validators(field, value) - %{state | errors: Map.put(state.errors, field_id, errors)} - end - - defp validate_all(state) do - errors = - state.fields - |> Enum.filter(&field_visible?(&1, state.values)) - |> Enum.reduce(%{}, fn field, acc -> - value = Map.get(state.values, field.id) - field_errors = run_validators(field, value) - Map.put(acc, field.id, field_errors) - end) - - %{state | errors: errors} - end - - defp run_validators(field, value) do - errors = [] - - # Required validation - errors = - if field.required && empty_value?(value) do - ["This field is required" | errors] - else - errors - end - - # Custom validators - Enum.reduce(field.validators, errors, fn validator, acc -> - case validator.(value) do - :ok -> acc - {:error, msg} -> [msg | acc] - end - end) - |> Enum.reverse() - end - - defp empty_value?(""), do: true - defp empty_value?(nil), do: true - defp empty_value?([]), do: true - defp empty_value?(false), do: false - defp empty_value?(_), do: false - - defp has_errors?(state) do - Enum.any?(state.errors, fn {_field_id, errors} -> errors != [] end) - end - - # Submit - - defp submit_form(state) do - state = validate_all(state) - - if has_errors?(state) do - {:ok, state} - else - # Call on_submit callback with error handling - if state.on_submit do - try do - state.on_submit.(state.values) - rescue - e -> - require Logger - Logger.error("FormBuilder on_submit callback error: #{inspect(e)}") - end - end - - {:ok, state} - end - end - - # Visibility - - defp field_visible?(field, values) do - case field.visible_when do - nil -> true - condition when is_function(condition, 1) -> condition.(values) - _ -> true - end - end - - # Grouping - - defp group_fields(state) do - # First, ungrouped fields - ungrouped = - state.fields - |> Enum.filter(&(is_nil(&1.group) && field_visible?(&1, state.values))) - - # Then, grouped fields - grouped = - state.groups - |> Enum.map(fn group -> - fields = - state.fields - |> Enum.filter(&(&1.group == group.id && field_visible?(&1, state.values))) - - {group, fields} - end) - |> Enum.filter(fn {_group, fields} -> fields != [] end) - - [{nil, ungrouped} | grouped] - |> Enum.filter(fn {_group, fields} -> fields != [] end) - end - - # Rendering - - defp render_group(state, nil, fields, _area, _chars) do - # Ungrouped fields - just render them - Enum.flat_map(fields, &render_field(state, &1)) - end - - defp render_group(state, group, fields, _area, _chars) when is_map(group) do - collapsed = MapSet.member?(state.collapsed_groups, group.id) - - header = render_group_header(group, collapsed) - - if collapsed do - [header] - else - body = Enum.flat_map(fields, &render_field(state, &1)) - [header | body] - end - end - - defp render_group_header(group, collapsed) do - chars = CharacterSet.current_charset() - indicator = if collapsed, do: chars.triangle_right, else: chars.triangle_down - text("#{indicator} #{group.label}") - end - - defp render_field(state, field) do - focused = state.focused_field == field.id && !state.submit_focused - value = Map.get(state.values, field.id) - errors = Map.get(state.errors, field.id, []) - - label_width = state.label_width - _field_width = state.field_width - - # Build label - label_text = Helpers.pad_and_truncate(field.label, label_width) - - required_marker = if field.required, do: "*", else: " " - label = "#{label_text}#{required_marker} " - - # Build field content based on type - field_content = render_field_content(field, value, state, focused) - - # Combine into row - row = - stack(:horizontal, [ - text(Helpers.focus_indicator(focused)), - text(label), - field_content - ]) - - # Add error messages - if errors != [] do - error_style = fg_semantic(Theme.get_semantic(:error)) - - error_rows = - Enum.map(errors, fn err -> - padding = String.duplicate(" ", label_width + 5) - styled(text("#{padding}! #{err}"), error_style) - end) - - [row | error_rows] - else - [row] - end - end - - defp render_field_content(field, value, state, focused) do - case field.type do - :text -> - render_text_field(field, value, state.field_width, focused) - - :password -> - render_password_field(field, value, state.field_width, focused) - - :checkbox -> - render_checkbox_field(value, focused) - - :radio -> - render_radio_field(field, value, state.focused_option, focused) - - :select -> - render_select_field(field, value, state.focused_option, focused) - - :multi_select -> - render_multi_select_field(field, value, state.focused_option, focused) - end - end - - defp render_text_field(field, value, width, focused) do - display_value = - if value == "" && field.placeholder do - field.placeholder - else - value - end - - content = "[#{Helpers.pad_and_truncate(display_value, width)}]" - Helpers.text_focused(content, focused) - end - - defp render_password_field(field, value, width, focused) do - masked = String.duplicate("*", String.length(value)) - - display_value = - if masked == "" && field.placeholder do - field.placeholder - else - masked - end - - content = "[#{Helpers.pad_and_truncate(display_value, width)}]" - Helpers.text_focused(content, focused) - end - - defp render_checkbox_field(value, focused) do - checkbox = if value, do: "[x]", else: "[ ]" - Helpers.text_focused(checkbox, focused) - end - - defp render_radio_field(field, selected_value, focused_option, focused) do - options = - field.options - |> Enum.with_index() - |> Enum.map(fn {{value, label}, idx} -> - selected = value == selected_value - option_focused = focused && idx == focused_option - - indicator = if selected, do: "(o)", else: "( )" - content = "#{indicator} #{label}" - - Helpers.text_focused(content, option_focused) - end) - - stack(:horizontal, Enum.intersperse(options, text(" "))) - end - - defp render_select_field(field, selected_value, focused_option, focused) do - # Show selected value with dropdown indicator - selected_label = - case Enum.find(field.options, fn {v, _l} -> v == selected_value end) do - {_, label} -> label - nil -> "(select)" - end - - if focused do - render_select_options(field, selected_value, focused_option) - else - text("[#{selected_label} v]") - end - end - - defp render_select_options(field, selected_value, focused_option) do - options = - field.options - |> Enum.with_index() - |> Enum.map(fn {{value, label}, idx} -> - render_select_option(value, label, idx, selected_value, focused_option) - end) - - stack(:vertical, options) - end - - defp render_select_option(value, label, idx, selected_value, focused_option) do - option_focused = idx == focused_option - selected = value == selected_value - - prefix = if selected, do: "* ", else: " " - content = "#{prefix}#{label}" - - Helpers.text_focused(content, option_focused) - end - - defp render_multi_select_field(field, selected_values, focused_option, focused) do - selected_values = selected_values || [] - - options = - field.options - |> Enum.with_index() - |> Enum.map(fn {{value, label}, idx} -> - selected = value in selected_values - option_focused = focused && idx == focused_option - - checkbox = if selected, do: "[x]", else: "[ ]" - content = "#{checkbox} #{label}" - - Helpers.text_focused(content, option_focused) - end) - - stack(:vertical, options) - end - - defp render_submit_button(state) do - label = "[ #{state.submit_label} ]" - content = Helpers.text_focused(label, state.submit_focused) - padding = String.duplicate(" ", state.label_width + 3) - - stack(:vertical, [ - text(""), - stack(:horizontal, [text(padding), content]) - ]) - end - - # Helpers - - defp get_field(state, field_id) do - Enum.find(state.fields, &(&1.id == field_id)) - end - - # Public API - - @doc """ - Gets the current form values. - """ - @spec get_values(map()) :: map() - def get_values(state) do - state.values - end - - @doc """ - Gets the value of a specific field. - """ - @spec get_value(map(), atom()) :: term() - def get_value(state, field_id) do - Map.get(state.values, field_id) - end - - @doc """ - Sets the value of a specific field. - """ - @spec set_value(map(), atom(), term()) :: map() - def set_value(state, field_id, value) do - %{state | values: Map.put(state.values, field_id, value)} - end - - @doc """ - Sets multiple field values at once. - """ - @spec set_values(map(), map()) :: map() - def set_values(state, values) do - %{state | values: Map.merge(state.values, values)} - end - - @doc """ - Gets all validation errors. - """ - @spec get_errors(map()) :: map() - def get_errors(state) do - state.errors - end - - @doc """ - Checks if the form has any validation errors. - """ - @spec valid?(map()) :: boolean() - def valid?(state) do - !has_errors?(validate_all(state)) - end - - @doc """ - Validates all fields and returns updated state. - """ - @spec validate(map()) :: map() - def validate(state) do - validate_all(state) - end - - @doc """ - Focuses a specific field. - """ - @spec focus_field(map(), atom()) :: map() - def focus_field(state, field_id) do - if Enum.any?(state.fields, &(&1.id == field_id)) do - %{state | focused_field: field_id, submit_focused: false} - else - state - end - end - - @doc """ - Gets the currently focused field. - """ - @spec get_focused_field(map()) :: atom() | nil - def get_focused_field(state) do - state.focused_field - end - - @doc """ - Toggles a group's collapsed state. - """ - @spec toggle_group(map(), atom()) :: map() - def toggle_group(state, group_id) do - if MapSet.member?(state.collapsed_groups, group_id) do - %{state | collapsed_groups: MapSet.delete(state.collapsed_groups, group_id)} - else - %{state | collapsed_groups: MapSet.put(state.collapsed_groups, group_id)} - end - end - - @doc """ - Resets the form to initial values. - """ - @spec reset(map()) :: map() - def reset(state) do - initial_values = - Enum.reduce(state.fields, %{}, fn field, acc -> - default_value = field.default || get_default_for_type(field.type) - Map.put(acc, field.id, default_value) - end) - - first_field = get_first_visible_field(state.fields, initial_values) - - %{ - state - | values: initial_values, - errors: %{}, - focused_field: first_field, - submit_focused: false - } - end -end diff --git a/lib/term_ui/widgets/gauge.ex b/lib/term_ui/widgets/gauge.ex deleted file mode 100644 index 168f8b6b..00000000 --- a/lib/term_ui/widgets/gauge.ex +++ /dev/null @@ -1,329 +0,0 @@ -defmodule TermUI.Widgets.Gauge do - @moduledoc """ - Gauge widget for displaying a single value within a range. - - Shows value as a bar or arc with min/max labels and optional - color zones for visual feedback. - - ## Usage - - Gauge.render( - value: 75, - min: 0, - max: 100, - width: 30, - zones: [ - {0, :green}, - {60, :yellow}, - {80, :red} - ] - ) - - ## Display Types - - - `:bar` - Horizontal bar (default) - - `:arc` - Semi-circular arc using block characters - """ - - import TermUI.Component.RenderNode - alias TermUI.CharacterSet - alias TermUI.Renderer.Style - alias TermUI.Theme - alias TermUI.Widgets.VisualizationHelper, as: VizHelper - - # Dialyzer: Suppress opaque type warnings for Style helpers - @dialyzer {:nowarn_function, fg_semantic: 1} - - @doc """ - Renders a gauge. - - ## Options - - - `:value` - Current value (required) - - `:min` - Minimum value (default: 0) - - `:max` - Maximum value (default: 100) - - `:width` - Gauge width (default: 40, max: #{VizHelper.max_width()}) - - `:type` - :bar or :arc (default: :bar) - - `:show_value` - Show numeric value (default: true) - - `:show_range` - Show min/max labels (default: true) - - `:zones` - List of {threshold, style} for color zones - - `:label` - Label for the gauge - - `:bar_char` - Character for filled portion - - `:empty_char` - Character for empty portion - """ - @spec render(keyword()) :: TermUI.Component.RenderNode.t() - def render(opts) do - value = Keyword.get(opts, :value, 0) - - case VizHelper.validate_number(value) do - :ok -> - do_render(value, opts) - - {:error, _msg} -> - empty() - end - end - - # ---------------------------------------------------------------------------- - # Style Helper Functions - # ---------------------------------------------------------------------------- - - @spec fg_semantic(atom()) :: Style.t() - defp fg_semantic(color) when is_atom(color), - do: Style.new() |> Style.fg(color) - - # ---------------------------------------------------------------------------- - # Rendering - # ---------------------------------------------------------------------------- - - defp do_render(value, opts) do - chars = CharacterSet.current_charset() - min = Keyword.get(opts, :min, 0) - max = Keyword.get(opts, :max, 100) - width = opts |> Keyword.get(:width, 40) |> VizHelper.clamp_width() - # Support both :type and :style_type for backward compatibility - gauge_type = Keyword.get(opts, :type, Keyword.get(opts, :style_type, :bar)) - show_value = Keyword.get(opts, :show_value, true) - show_range = Keyword.get(opts, :show_range, true) - zones = Keyword.get(opts, :zones, []) - label = Keyword.get(opts, :label) - bar_char = Keyword.get(opts, :bar_char, chars.bar_full) - empty_char = Keyword.get(opts, :empty_char, chars.bar_empty) - - case gauge_type do - :bar -> - bar_opts = %{ - value: value, - min: min, - max: max, - width: width, - show_value: show_value, - show_range: show_range, - zones: zones, - label: label, - bar_char: bar_char, - empty_char: empty_char - } - - render_bar(bar_opts) - - :arc -> - render_arc(value, min, max, width, show_value, zones, label) - - _ -> - # Default to bar for unknown types - bar_opts = %{ - value: value, - min: min, - max: max, - width: width, - show_value: show_value, - show_range: show_range, - zones: zones, - label: label, - bar_char: bar_char, - empty_char: empty_char - } - - render_bar(bar_opts) - end - end - - defp render_bar(opts) do - value = opts.value - min = opts.min - max = opts.max - width = opts.width - show_value = opts.show_value - show_range = opts.show_range - zones = opts.zones - label = opts.label - bar_char = opts.bar_char - empty_char = opts.empty_char - - # Calculate fill - normalized = VizHelper.normalize(value, min, max) - filled_width = round(normalized * width) - empty_width = width - filled_width - - # Build bar with safe duplicate - filled = VizHelper.safe_duplicate(bar_char, filled_width) - empty_part = VizHelper.safe_duplicate(empty_char, empty_width) - - # Apply zone color - zone_style = VizHelper.find_zone(value, zones) - - bar = - text(filled) - |> VizHelper.maybe_style(zone_style) - - # Build components - parts = [] - - # Label row - parts = - if label do - [text(label) | parts] - else - parts - end - - # Bar row - bar_row = stack(:horizontal, [bar, text(empty_part)]) - parts = [bar_row | parts] - - # Range/value row - bottom_parts = [] - - bottom_parts = - if show_range do - [text(VizHelper.format_number(min)) | bottom_parts] - else - bottom_parts - end - - bottom_parts = - if show_value do - value_str = VizHelper.format_number(value) - # Center the value - padding = - if show_range do - max(0, div(width - String.length(value_str), 2)) - else - 0 - end - - [text(VizHelper.safe_duplicate(" ", padding) <> value_str) | bottom_parts] - else - bottom_parts - end - - bottom_parts = - if show_range do - max_str = VizHelper.format_number(max) - padding = width - String.length(max_str) - padding = if show_value, do: div(padding, 2), else: padding - [text(VizHelper.safe_duplicate(" ", max(0, padding)) <> max_str) | bottom_parts] - else - bottom_parts - end - - parts = - if Enum.empty?(bottom_parts) do - parts - else - bottom_row = stack(:horizontal, Enum.reverse(bottom_parts)) - [bottom_row | parts] - end - - stack(:vertical, Enum.reverse(parts)) - end - - defp render_arc(value, min, max, width, show_value, _zones, label) do - chars = CharacterSet.current_charset() - - # Simple arc using block characters - normalized = VizHelper.normalize(value, min, max) - - # Calculate position on arc with bounds checking - arc_position = round(normalized * (width - 2)) - arc_position = max(0, min(arc_position, width - 3)) - - # Build arc visualization with safe duplicate - top = chars.tl_round <> VizHelper.safe_duplicate(chars.h_line, width - 2) <> chars.tr_round - - # Middle shows value position - right_padding = max(0, width - arc_position - 3) - - indicator_line = - VizHelper.safe_duplicate(" ", arc_position) <> - chars.triangle_down <> - VizHelper.safe_duplicate(" ", right_padding) - - middle = chars.v_line <> indicator_line <> chars.v_line - - bottom = chars.bl_round <> VizHelper.safe_duplicate(chars.h_line, width - 2) <> chars.br_round - - parts = [text(top), text(middle), text(bottom)] - - # Add value display - parts = - if show_value do - value_str = VizHelper.format_number(value) - padding = max(0, div(width - String.length(value_str), 2)) - value_row = text(VizHelper.safe_duplicate(" ", padding) <> value_str) - parts ++ [value_row] - else - parts - end - - # Add label - parts = - if label do - label_row = text(label) - [label_row | parts] - else - parts - end - - stack(:vertical, parts) - end - - @doc """ - Creates a simple percentage gauge. - - ## Examples - - Gauge.percentage(75, width: 20) - """ - @spec percentage(number(), keyword()) :: TermUI.Component.RenderNode.t() - def percentage(value, opts \\ []) do - opts = - Keyword.merge( - [ - value: value, - min: 0, - max: 100, - show_value: true, - show_range: false - ], - opts - ) - - render(opts) - end - - @doc """ - Creates a gauge with traffic light colors (green/yellow/red). - - Uses theme-based semantic colors for visual feedback: - - Green zone (0-warning): success - - Yellow zone (warning-danger): warning - - Red zone (danger+): error - - ## Options - - - `:value` - Current value (required) - - `:warning` - Yellow zone threshold (default: 60) - - `:danger` - Red zone threshold (default: 80) - - `:zones` - Override with custom zones - """ - @spec traffic_light(keyword()) :: TermUI.Component.RenderNode.t() - def traffic_light(opts) do - value = Keyword.get(opts, :value, 0) - warning = Keyword.get(opts, :warning, 60) - danger = Keyword.get(opts, :danger, 80) - - # Create theme-based default zones - default_zones = [ - {0, fg_semantic(Theme.get_semantic(:success))}, - {warning, fg_semantic(Theme.get_semantic(:warning))}, - {danger, fg_semantic(Theme.get_semantic(:error))} - ] - - zones = Keyword.get(opts, :zones, default_zones) - - opts = opts |> Keyword.put(:value, value) |> Keyword.merge(zones: zones) - render(opts) - end -end diff --git a/lib/term_ui/widgets/line_chart.ex b/lib/term_ui/widgets/line_chart.ex deleted file mode 100644 index 49aeec82..00000000 --- a/lib/term_ui/widgets/line_chart.ex +++ /dev/null @@ -1,308 +0,0 @@ -defmodule TermUI.Widgets.LineChart do - @moduledoc """ - Line chart widget using Braille patterns for sub-character resolution. - - Each Braille character cell is 2 dots wide and 4 dots tall, enabling - smooth line rendering in text mode. Perfect for time series visualization. - - ## Usage - - LineChart.render( - series: [ - %{data: [1, 3, 5, 2, 8], color: :blue}, - %{data: [2, 4, 3, 6, 4], color: :red} - ], - width: 40, - height: 10 - ) - - ## Braille Patterns - - Braille patterns use Unicode range U+2800 to U+28FF. - Each cell has 8 dots arranged as: - ``` - 1 4 - 2 5 - 3 6 - 7 8 - ``` - """ - - import TermUI.Component.RenderNode - alias TermUI.CharacterSet - alias TermUI.Widgets.VisualizationHelper, as: VizHelper - - # Braille base character - @braille_base 0x2800 - - # Dot positions in braille cell (column, row) -> bit - @dot_bits %{ - # dot 1 - {0, 0} => 0x01, - # dot 2 - {0, 1} => 0x02, - # dot 3 - {0, 2} => 0x04, - # dot 4 - {1, 0} => 0x08, - # dot 5 - {1, 1} => 0x10, - # dot 6 - {1, 2} => 0x20, - # dot 7 - {0, 3} => 0x40, - # dot 8 - {1, 3} => 0x80 - } - - @doc """ - Renders a line chart using Braille patterns. - - ## Options - - - `:series` - List of series with data and optional color - - `:data` - Single series data (alternative to :series) - - `:width` - Chart width in characters (default: 40, max: #{VizHelper.max_width()}) - - `:height` - Chart height in characters (default: 10, max: #{VizHelper.max_height()}) - - `:min` - Minimum Y value (default: auto) - - `:max` - Maximum Y value (default: auto) - - `:show_axis` - Show axis lines (default: false) - - `:style` - Style for the chart - """ - @spec render(keyword()) :: TermUI.Component.RenderNode.t() - def render(opts) do - series = get_series(opts) - width = opts |> Keyword.get(:width, 40) |> VizHelper.clamp_width() - height = opts |> Keyword.get(:height, 10) |> VizHelper.clamp_height() - show_axis = Keyword.get(opts, :show_axis, false) - style = Keyword.get(opts, :style) - - if Enum.empty?(series) do - empty() - else - # Validate series data BEFORE accessing s.data to avoid crashes - case VizHelper.validate_series_data(series) do - :ok -> - render_validated_series(series, width, height, show_axis, style, opts) - - {:error, _msg} -> - # Return empty for invalid data rather than crashing - empty() - end - end - end - - defp render_validated_series(series, width, height, show_axis, style, opts) do - if Enum.all?(series, fn s -> Enum.empty?(s.data) end) do - empty() - else - do_render(series, width, height, show_axis, style, opts) - end - end - - defp get_series(opts) do - case Keyword.get(opts, :series) do - nil -> - case Keyword.get(opts, :data) do - nil -> [] - data when is_list(data) -> [%{data: data, color: nil}] - _ -> [] - end - - series when is_list(series) -> - series - - _ -> - [] - end - end - - defp do_render(series, width, height, show_axis, style, opts) do - # Get all values for scaling - all_values = series |> Enum.flat_map(& &1.data) - {min, max} = VizHelper.calculate_range(all_values, opts) - - # Create canvas (width * 2 dots, height * 4 dots) - canvas_width = width * 2 - canvas_height = height * 4 - - # Initialize empty canvas with private table (avoids name conflicts) - canvas = :ets.new(:canvas, [:set, :private]) - - try do - # Draw each series - Enum.each(series, fn s -> - draw_series(canvas, s.data, canvas_width, canvas_height, min, max) - end) - - # Convert canvas to braille characters - rows = - for y <- 0..(height - 1) do - chars_row = - for x <- 0..(width - 1) do - pattern = get_cell_pattern(canvas, x, y) - <<@braille_base + pattern::utf8>> - end - - Enum.join(chars_row) - end - - # Build render tree - row_nodes = Enum.map(rows, &text/1) - - result = - if show_axis do - # Add axis - chars = CharacterSet.current_charset() - axis_row = text(chars.bl <> VizHelper.safe_duplicate(chars.h_line, width - 1)) - stack(:vertical, row_nodes ++ [axis_row]) - else - stack(:vertical, row_nodes) - end - - VizHelper.maybe_style(result, style) - after - # Always clean up ETS table, even if an exception occurs - :ets.delete(canvas) - end - end - - defp draw_series(canvas, data, canvas_width, canvas_height, min, max) do - data_len = length(data) - - points = - data - |> Enum.with_index() - |> Enum.map(fn {value, index} -> - x = - if data_len > 1 do - round(index / (data_len - 1) * (canvas_width - 1)) - else - div(canvas_width, 2) - end - - y = value_to_y(value, min, max, canvas_height) - {x, y} - end) - - # Draw lines between consecutive points - points - |> Enum.chunk_every(2, 1, :discard) - |> Enum.each(fn [{x1, y1}, {x2, y2}] -> - draw_line(canvas, x1, y1, x2, y2) - end) - - # Also draw single points - Enum.each(points, fn {x, y} -> - set_dot(canvas, x, y) - end) - end - - defp value_to_y(value, min, max, canvas_height) do - normalized = VizHelper.normalize(value, min, max) - # Invert Y (0 is top) - round((1 - normalized) * (canvas_height - 1)) - end - - defp draw_line(canvas, x1, y1, x2, y2) do - # Bresenham's line algorithm - dx = abs(x2 - x1) - dy = abs(y2 - y1) - sx = if x1 < x2, do: 1, else: -1 - sy = if y1 < y2, do: 1, else: -1 - err = dx - dy - - line_state = %{ - x: x1, - y: y1, - target_x: x2, - target_y: y2, - dx: dx, - dy: dy, - sx: sx, - sy: sy, - err: err - } - - draw_line_loop(canvas, line_state) - end - - defp draw_line_loop(canvas, line_state) do - set_dot(canvas, line_state.x, line_state.y) - - if line_state.x == line_state.target_x and line_state.y == line_state.target_y do - :ok - else - e2 = 2 * line_state.err - - {new_x, new_err} = - if e2 > -line_state.dy do - {line_state.x + line_state.sx, line_state.err - line_state.dy} - else - {line_state.x, line_state.err} - end - - {new_y, new_err} = - if e2 < line_state.dx do - {line_state.y + line_state.sy, new_err + line_state.dx} - else - {line_state.y, new_err} - end - - new_line_state = %{line_state | x: new_x, y: new_y, err: new_err} - draw_line_loop(canvas, new_line_state) - end - end - - defp set_dot(canvas, x, y) when x >= 0 and y >= 0 do - :ets.insert(canvas, {{x, y}, true}) - end - - defp set_dot(_canvas, _x, _y), do: :ok - - defp get_cell_pattern(canvas, cell_x, cell_y) do - # Get the 2x4 dots for this cell - base_x = cell_x * 2 - base_y = cell_y * 4 - - Enum.reduce(@dot_bits, 0, fn {{dx, dy}, bit}, acc -> - if :ets.lookup(canvas, {base_x + dx, base_y + dy}) != [] do - Bitwise.bor(acc, bit) - else - acc - end - end) - end - - @doc """ - Converts coordinates to a single Braille character. - - Useful for drawing individual points. - """ - @spec dots_to_braille([{0 | 1, 0..3}]) :: String.t() - def dots_to_braille(dots) do - pattern = - Enum.reduce(dots, 0, fn {x, y}, acc -> - bit = Map.get(@dot_bits, {x, y}, 0) - Bitwise.bor(acc, bit) - end) - - <<@braille_base + pattern::utf8>> - end - - @doc """ - Returns an empty Braille character. - """ - @spec empty_braille() :: String.t() - def empty_braille do - <<@braille_base::utf8>> - end - - @doc """ - Returns a full Braille character (all dots). - """ - @spec full_braille() :: String.t() - def full_braille do - <<@braille_base + 0xFF::utf8>> - end -end diff --git a/lib/term_ui/widgets/log_viewer.ex b/lib/term_ui/widgets/log_viewer.ex deleted file mode 100644 index 4cf71a9c..00000000 --- a/lib/term_ui/widgets/log_viewer.ex +++ /dev/null @@ -1,1102 +0,0 @@ -defmodule TermUI.Widgets.LogViewer do - @moduledoc """ - LogViewer widget for displaying real-time logs with virtual scrolling. - - LogViewer efficiently displays large log files (millions of lines) using - virtual scrolling, with support for search, filtering, syntax highlighting, - and bookmarking. - - ## Usage - - LogViewer.new( - lines: log_lines, - tail_mode: true, - highlight_levels: true - ) - - ## Features - - - Virtual scrolling for efficient rendering of large datasets - - Tail mode for live log monitoring - - Search with regex support and match highlighting - - Syntax highlighting for log levels and timestamps - - Filtering by level, source, or pattern - - Line bookmarking - - Selection and copy functionality - - Wrap/truncate toggle for long lines - - ## Keyboard Controls - - - Up/Down: Move cursor - - PageUp/PageDown: Scroll by page - - Home/End: Jump to first/last line - - /: Start search - - n/N: Next/previous search match - - f: Toggle filter mode - - b: Toggle bookmark on current line - - B: Jump to next bookmark - - t: Toggle tail mode - - w: Toggle wrap mode - - Space: Start/extend selection - - Escape: Clear search/filter/selection - """ - - use TermUI.StatefulComponent - - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Theme - - # Dialyzer: Suppress opaque type warnings for Style helpers and contract warnings for specific map types - @dialyzer {:nowarn_function, - fg_semantic: 1, - fg_color: 1, - fg_dim_semantic: 1, - fg_bg_semantic: 2, - new: 1, - add_line: 2, - clear: 1, - clear_filter: 1} - - @type log_level :: - :debug | :info | :notice | :warning | :error | :critical | :alert | :emergency - - @type log_entry :: %{ - id: non_neg_integer(), - timestamp: DateTime.t() | nil, - level: log_level() | nil, - source: String.t() | nil, - message: String.t(), - raw: String.t() - } - - @type filter_spec :: %{ - levels: [log_level()] | nil, - source: String.t() | nil, - pattern: Regex.t() | String.t() | nil, - bookmarks_only: boolean() - } - - @type search_state :: %{ - pattern: Regex.t() | String.t(), - matches: [non_neg_integer()], - current_match: non_neg_integer(), - highlight: boolean() - } - - # NOTE: Regex patterns defined as functions rather than module attributes because - # compiled Regex structs contain references that cannot be injected into function bodies. - defp level_patterns do - [ - {:emergency, ~r/\b(EMERGENCY|EMERG)\b/i}, - {:alert, ~r/\b(ALERT)\b/i}, - {:critical, ~r/\b(CRITICAL|CRIT|FATAL)\b/i}, - {:error, ~r/\b(ERROR|ERR)\b/i}, - {:warning, ~r/\b(WARNING|WARN)\b/i}, - {:notice, ~r/\b(NOTICE)\b/i}, - {:info, ~r/\b(INFO)\b/i}, - {:debug, ~r/\b(DEBUG|DBG)\b/i} - ] - end - - defp timestamp_pattern, - do: ~r/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/ - - defp source_pattern, do: ~r/\[([^\]]+)\]/ - - @page_size 20 - - # ---------------------------------------------------------------------------- - # Style Helper Functions - # ---------------------------------------------------------------------------- - - @spec fg_semantic(atom()) :: Style.t() - defp fg_semantic(color) when is_atom(color), - do: Style.new() |> Style.fg(color) - - @spec fg_color(atom()) :: Style.t() - defp fg_color(color) when is_atom(color), - do: Style.new() |> Style.fg(color) - - @spec fg_dim_semantic(atom()) :: Style.t() - defp fg_dim_semantic(color) when is_atom(color), - do: Style.new() |> Style.fg(color) |> Style.dim() - - @spec fg_bg_semantic(atom(), atom()) :: Style.t() - defp fg_bg_semantic(fg, bg) when is_atom(fg) and is_atom(bg), - do: Style.new() |> Style.fg(fg) |> Style.bg(bg) - - # ---------------------------------------------------------------------------- - # Props - # ---------------------------------------------------------------------------- - - @doc """ - Creates new LogViewer widget props. - - ## Options - - - `:lines` - Initial log lines (strings or log entries) - - `:max_lines` - Maximum lines to keep in buffer (default: 100_000) - - `:tail_mode` - Auto-scroll to new lines (default: true) - - `:wrap_lines` - Wrap long lines (default: false) - - `:show_line_numbers` - Display line numbers (default: true) - - `:show_timestamps` - Display timestamps column (default: false) - - `:show_levels` - Display level column (default: true) - - `:highlight_levels` - Color-code by level (default: true) - - `:on_select` - Callback when lines are selected - - `:on_copy` - Callback when copy is requested - - `:parser` - Custom log parser function - """ - @spec new(keyword()) :: map() - def new(opts) do - %{ - lines: Keyword.get(opts, :lines, []), - max_lines: Keyword.get(opts, :max_lines, 100_000), - tail_mode: Keyword.get(opts, :tail_mode, true), - wrap_lines: Keyword.get(opts, :wrap_lines, false), - show_line_numbers: Keyword.get(opts, :show_line_numbers, true), - show_timestamps: Keyword.get(opts, :show_timestamps, false), - show_levels: Keyword.get(opts, :show_levels, true), - highlight_levels: Keyword.get(opts, :highlight_levels, true), - on_select: Keyword.get(opts, :on_select), - on_copy: Keyword.get(opts, :on_copy), - parser: Keyword.get(opts, :parser, &default_parser/1) - } - end - - # ---------------------------------------------------------------------------- - # StatefulComponent Callbacks - # ---------------------------------------------------------------------------- - - @impl true - def init(props) do - # Parse initial lines - lines = - props.lines - |> Enum.with_index() - |> Enum.map(fn {line, idx} -> - parse_line(line, idx, props.parser) - end) - - state = %{ - lines: lines, - max_lines: props.max_lines, - scroll_offset: 0, - cursor: 0, - selection_start: nil, - selection_end: nil, - bookmarks: MapSet.new(), - filter: nil, - filtered_indices: nil, - search: nil, - search_input: nil, - filter_input: nil, - tail_mode: props.tail_mode, - wrap_lines: props.wrap_lines, - show_line_numbers: props.show_line_numbers, - show_timestamps: props.show_timestamps, - show_levels: props.show_levels, - highlight_levels: props.highlight_levels, - on_select: props.on_select, - on_copy: props.on_copy, - parser: props.parser, - viewport_height: 20, - viewport_width: 80, - last_area: nil - } - - # Start at bottom if tail mode - state = - if props.tail_mode and length(lines) > 0 do - %{ - state - | cursor: length(lines) - 1, - scroll_offset: max(0, length(lines) - state.viewport_height) - } - else - state - end - - {:ok, state} - end - - @impl true - def handle_event(%Event.Key{key: :up}, state) - when state.search_input == nil and state.filter_input == nil do - move_cursor(state, -1) - end - - def handle_event(%Event.Key{key: :down}, state) - when state.search_input == nil and state.filter_input == nil do - move_cursor(state, 1) - end - - def handle_event(%Event.Key{key: :page_up}, state) - when state.search_input == nil and state.filter_input == nil do - move_cursor(state, -@page_size) - end - - def handle_event(%Event.Key{key: :page_down}, state) - when state.search_input == nil and state.filter_input == nil do - move_cursor(state, @page_size) - end - - def handle_event(%Event.Key{key: :home}, state) - when state.search_input == nil and state.filter_input == nil do - goto_line(state, 0) - end - - def handle_event(%Event.Key{key: :end}, state) - when state.search_input == nil and state.filter_input == nil do - visible_lines = get_visible_line_indices(state) - goto_line(state, length(visible_lines) - 1) - end - - # Start search - def handle_event(%Event.Key{char: "/"}, state) - when state.search_input == nil and state.filter_input == nil do - {:ok, %{state | search_input: ""}} - end - - # Search input mode - def handle_event(%Event.Key{key: :enter}, state) when state.search_input != nil do - execute_search(state, state.search_input) - end - - def handle_event(%Event.Key{key: :escape}, state) when state.search_input != nil do - {:ok, %{state | search_input: nil}} - end - - def handle_event(%Event.Key{key: :backspace}, state) when state.search_input != nil do - input = String.slice(state.search_input, 0..-2//1) - {:ok, %{state | search_input: input}} - end - - def handle_event(%Event.Key{char: char}, state) - when state.search_input != nil and char != nil do - {:ok, %{state | search_input: state.search_input <> char}} - end - - # Filter input mode - def handle_event(%Event.Key{key: :enter}, state) when state.filter_input != nil do - execute_filter(state, state.filter_input) - end - - def handle_event(%Event.Key{key: :escape}, state) when state.filter_input != nil do - {:ok, %{state | filter_input: nil}} - end - - def handle_event(%Event.Key{key: :backspace}, state) when state.filter_input != nil do - input = String.slice(state.filter_input, 0..-2//1) - {:ok, %{state | filter_input: input}} - end - - def handle_event(%Event.Key{char: char}, state) - when state.filter_input != nil and char != nil do - {:ok, %{state | filter_input: state.filter_input <> char}} - end - - # Next/previous search match - def handle_event(%Event.Key{char: "n"}, state) - when state.search != nil and state.search_input == nil do - next_search_match(state, 1) - end - - def handle_event(%Event.Key{char: "N"}, state) - when state.search != nil and state.search_input == nil do - next_search_match(state, -1) - end - - # Toggle filter mode - def handle_event(%Event.Key{char: "f"}, state) - when state.search_input == nil and state.filter_input == nil do - if state.filter do - # Clear filter - {:ok, %{state | filter: nil, filtered_indices: nil}} - else - # Start filter input - {:ok, %{state | filter_input: ""}} - end - end - - # Toggle bookmark - def handle_event(%Event.Key{char: "b"}, state) - when state.search_input == nil and state.filter_input == nil do - line_idx = get_actual_line_index(state, state.cursor) - - bookmarks = - if MapSet.member?(state.bookmarks, line_idx) do - MapSet.delete(state.bookmarks, line_idx) - else - MapSet.put(state.bookmarks, line_idx) - end - - {:ok, %{state | bookmarks: bookmarks}} - end - - # Jump to next bookmark - def handle_event(%Event.Key{char: "B"}, state) - when state.search_input == nil and state.filter_input == nil do - jump_to_next_bookmark(state) - end - - # Toggle tail mode - def handle_event(%Event.Key{char: "t"}, state) - when state.search_input == nil and state.filter_input == nil do - state = %{state | tail_mode: not state.tail_mode} - - state = - if state.tail_mode do - # Jump to end when enabling tail mode - visible_lines = get_visible_line_indices(state) - last = max(0, length(visible_lines) - 1) - - %{ - state - | cursor: last, - scroll_offset: max(0, length(visible_lines) - state.viewport_height) - } - else - state - end - - {:ok, state} - end - - # Toggle wrap mode - def handle_event(%Event.Key{char: "w"}, state) - when state.search_input == nil and state.filter_input == nil do - {:ok, %{state | wrap_lines: not state.wrap_lines}} - end - - # Selection with Space - def handle_event(%Event.Key{char: " "}, state) - when state.search_input == nil and state.filter_input == nil do - line_idx = get_actual_line_index(state, state.cursor) - - state = - if is_nil(state.selection_start) do - %{state | selection_start: line_idx, selection_end: line_idx} - else - %{state | selection_end: line_idx} - end - - {:ok, state} - end - - # Copy with 'y' - def handle_event(%Event.Key{char: "y"}, state) - when state.search_input == nil and state.filter_input == nil do - if state.selection_start != nil and state.on_copy do - text = get_selected_text(state) - state.on_copy.(text) - end - - {:ok, state} - end - - # Clear search/filter/selection with Escape - def handle_event(%Event.Key{key: :escape}, state) - when state.search_input == nil and state.filter_input == nil do - state = - cond do - state.selection_start != nil -> - %{state | selection_start: nil, selection_end: nil} - - state.search != nil -> - %{state | search: nil} - - state.filter != nil -> - %{state | filter: nil, filtered_indices: nil} - - true -> - state - end - - {:ok, state} - end - - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, area) do - # Update viewport dimensions - state = %{ - state - | viewport_height: area.height - 2, - viewport_width: area.width, - last_area: area - } - - visible_lines = get_visible_line_indices(state) - total_lines = length(visible_lines) - - # Calculate visible range - start_idx = state.scroll_offset - end_idx = min(start_idx + state.viewport_height, total_lines) - - # Build line renders - line_renders = - start_idx..(end_idx - 1)//1 - |> Enum.map(fn visible_idx -> - actual_idx = Enum.at(visible_lines, visible_idx, visible_idx) - line = Enum.at(state.lines, actual_idx) - - if line do - render_line(state, line, visible_idx, actual_idx) - else - text("", nil) - end - end) - - # Add status bar - status_bar = render_status_bar(state, total_lines) - - # Add input bar if in input mode - input_bar = render_input_bar(state) - - stack(:vertical, line_renders ++ [status_bar] ++ input_bar) - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Adds a single log line to the viewer. - """ - @spec add_line(map(), String.t() | log_entry()) :: map() - def add_line(state, line) do - add_lines(state, [line]) - end - - @doc """ - Adds multiple log lines to the viewer. - """ - @spec add_lines(map(), [String.t() | log_entry()]) :: map() - def add_lines(state, new_lines) do - base_id = length(state.lines) - - parsed_lines = - new_lines - |> Enum.with_index() - |> Enum.map(fn {line, idx} -> - parse_line(line, base_id + idx, state.parser) - end) - - lines = state.lines ++ parsed_lines - - # Trim if exceeds max - lines = - if length(lines) > state.max_lines do - Enum.drop(lines, length(lines) - state.max_lines) - else - lines - end - - # Update filtered indices if filter is active - state = - if state.filter do - filtered = filter_lines(lines, state.filter, state.bookmarks) - %{state | filtered_indices: filtered} - else - state - end - - # Auto-scroll if in tail mode - state = - if state.tail_mode do - visible_lines = get_visible_line_indices(%{state | lines: lines}) - new_cursor = max(0, length(visible_lines) - 1) - new_offset = max(0, length(visible_lines) - state.viewport_height) - %{state | cursor: new_cursor, scroll_offset: new_offset} - else - state - end - - %{state | lines: lines} - end - - @doc """ - Clears all log lines. - """ - @spec clear(map()) :: map() - def clear(state) do - %{ - state - | lines: [], - cursor: 0, - scroll_offset: 0, - selection_start: nil, - selection_end: nil, - search: nil, - filtered_indices: nil - } - end - - @doc """ - Gets the currently selected text. - """ - @spec get_selected_text(map()) :: String.t() - def get_selected_text(state) do - if state.selection_start != nil and state.selection_end != nil do - start_idx = min(state.selection_start, state.selection_end) - end_idx = max(state.selection_start, state.selection_end) - - state.lines - |> Enum.slice(start_idx..end_idx) - |> Enum.map_join("\n", & &1.raw) - else - "" - end - end - - @doc """ - Sets a filter on the log viewer. - """ - @spec set_filter(map(), filter_spec()) :: map() - def set_filter(state, filter) do - filtered = filter_lines(state.lines, filter, state.bookmarks) - %{state | filter: filter, filtered_indices: filtered, cursor: 0, scroll_offset: 0} - end - - @doc """ - Clears the current filter. - """ - @spec clear_filter(map()) :: map() - def clear_filter(state) do - %{state | filter: nil, filtered_indices: nil} - end - - @doc """ - Starts a search with the given pattern. - """ - @spec search(map(), String.t()) :: map() - def search(state, pattern) do - {:ok, state} = execute_search(state, pattern) - state - end - - @doc """ - Jumps to a specific line number. - """ - @spec goto_line(map(), non_neg_integer()) :: {:ok, map()} - def goto_line(state, line_num) do - visible_lines = get_visible_line_indices(state) - max_line = max(0, length(visible_lines) - 1) - new_cursor = max(0, min(line_num, max_line)) - - # Adjust scroll to keep cursor visible - scroll_offset = - cond do - new_cursor < state.scroll_offset -> - new_cursor - - new_cursor >= state.scroll_offset + state.viewport_height -> - new_cursor - state.viewport_height + 1 - - true -> - state.scroll_offset - end - - # Disable tail mode on manual navigation - {:ok, %{state | cursor: new_cursor, scroll_offset: scroll_offset, tail_mode: false}} - end - - @doc """ - Gets the list of bookmarked line indices. - """ - @spec get_bookmarks(map()) :: [non_neg_integer()] - def get_bookmarks(state) do - MapSet.to_list(state.bookmarks) |> Enum.sort() - end - - @doc """ - Gets the current search matches. - """ - @spec get_search_matches(map()) :: [non_neg_integer()] - def get_search_matches(state) do - if state.search do - state.search.matches - else - [] - end - end - - @doc """ - Checks if tail mode is enabled. - """ - @spec tail_mode?(map()) :: boolean() - def tail_mode?(state), do: state.tail_mode - - @doc """ - Gets the total number of lines. - """ - @spec line_count(map()) :: non_neg_integer() - def line_count(state), do: length(state.lines) - - @doc """ - Gets the number of visible lines (after filtering). - """ - @spec visible_line_count(map()) :: non_neg_integer() - def visible_line_count(state) do - length(get_visible_line_indices(state)) - end - - # ---------------------------------------------------------------------------- - # Private: Navigation - # ---------------------------------------------------------------------------- - - defp move_cursor(state, delta) do - visible_lines = get_visible_line_indices(state) - max_cursor = max(0, length(visible_lines) - 1) - new_cursor = max(0, min(state.cursor + delta, max_cursor)) - - # Adjust scroll to keep cursor visible - scroll_offset = - cond do - new_cursor < state.scroll_offset -> - new_cursor - - new_cursor >= state.scroll_offset + state.viewport_height -> - new_cursor - state.viewport_height + 1 - - true -> - state.scroll_offset - end - - # Disable tail mode on manual navigation (except down at end) - tail_mode = - if delta < 0 do - false - else - state.tail_mode - end - - {:ok, %{state | cursor: new_cursor, scroll_offset: scroll_offset, tail_mode: tail_mode}} - end - - defp get_actual_line_index(state, visible_cursor) do - visible_lines = get_visible_line_indices(state) - Enum.at(visible_lines, visible_cursor, visible_cursor) - end - - defp get_visible_line_indices(state) do - if state.filtered_indices do - state.filtered_indices - else - Enum.to_list(0..(length(state.lines) - 1)//1) - end - end - - # ---------------------------------------------------------------------------- - # Private: Search - # ---------------------------------------------------------------------------- - - defp execute_search(state, pattern) when pattern == "" do - {:ok, %{state | search: nil, search_input: nil}} - end - - defp execute_search(state, pattern) do - regex = - case Regex.compile(pattern, "i") do - {:ok, r} -> r - {:error, _} -> ~r/#{Regex.escape(pattern)}/i - end - - matches = - state.lines - |> Enum.with_index() - |> Enum.filter(fn {line, _idx} -> - Regex.match?(regex, line.raw) - end) - |> Enum.map(fn {_line, idx} -> idx end) - - search_state = %{ - pattern: regex, - matches: matches, - current_match: 0, - highlight: true - } - - # Jump to first match if any - state = - if length(matches) > 0 do - first_match = hd(matches) - visible_lines = get_visible_line_indices(state) - visible_idx = Enum.find_index(visible_lines, &(&1 == first_match)) || 0 - - %{ - state - | cursor: visible_idx, - scroll_offset: max(0, visible_idx - div(state.viewport_height, 2)) - } - else - state - end - - {:ok, %{state | search: search_state, search_input: nil}} - end - - defp next_search_match(state, direction) do - if state.search && length(state.search.matches) > 0 do - matches = state.search.matches - current = state.search.current_match - next_idx = rem(current + direction + length(matches), length(matches)) - match_line = Enum.at(matches, next_idx) - - visible_lines = get_visible_line_indices(state) - visible_idx = Enum.find_index(visible_lines, &(&1 == match_line)) || 0 - - search = %{state.search | current_match: next_idx} - new_scroll = max(0, visible_idx - div(state.viewport_height, 2)) - - {:ok, - %{state | search: search, cursor: visible_idx, scroll_offset: new_scroll, tail_mode: false}} - else - {:ok, state} - end - end - - # ---------------------------------------------------------------------------- - # Private: Filtering - # ---------------------------------------------------------------------------- - - defp execute_filter(state, pattern) when pattern == "" do - {:ok, %{state | filter: nil, filtered_indices: nil, filter_input: nil}} - end - - defp execute_filter(state, pattern) do - # Simple pattern filter on message - regex = - case Regex.compile(pattern, "i") do - {:ok, r} -> r - {:error, _} -> ~r/#{Regex.escape(pattern)}/i - end - - filter = %{ - levels: nil, - source: nil, - pattern: regex, - bookmarks_only: false - } - - filtered = filter_lines(state.lines, filter, state.bookmarks) - - {:ok, - %{ - state - | filter: filter, - filtered_indices: filtered, - filter_input: nil, - cursor: 0, - scroll_offset: 0 - }} - end - - defp filter_lines(lines, filter, bookmarks) do - lines - |> Enum.with_index() - |> Enum.filter(fn {line, idx} -> - matches_filter?(line, idx, filter, bookmarks) - end) - |> Enum.map(fn {_line, idx} -> idx end) - end - - defp matches_filter?(line, idx, filter, bookmarks) do - level_match?(line, filter) and - source_match?(line, filter) and - pattern_match?(line, filter) and - bookmark_match?(idx, filter, bookmarks) - end - - defp level_match?(line, filter) do - filter.levels == nil or line.level in filter.levels - end - - defp source_match?(line, filter) do - filter.source == nil or - (line.source != nil and String.contains?(line.source, filter.source)) - end - - defp pattern_match?(line, filter) do - filter.pattern == nil or matches_regex_pattern?(line, filter) or - matches_string_pattern?(line, filter) - end - - defp matches_regex_pattern?(line, %{pattern: %Regex{} = regex}), - do: Regex.match?(regex, line.raw) - - defp matches_regex_pattern?(_line, _filter), do: false - - defp matches_string_pattern?(line, %{pattern: pattern}) when is_binary(pattern) do - String.contains?(line.raw, pattern) - end - - defp matches_string_pattern?(_line, _filter), do: false - - defp bookmark_match?(idx, filter, bookmarks) do - not filter.bookmarks_only or MapSet.member?(bookmarks, idx) - end - - # ---------------------------------------------------------------------------- - # Private: Bookmarks - # ---------------------------------------------------------------------------- - - defp jump_to_next_bookmark(state) do - if MapSet.size(state.bookmarks) == 0 do - {:ok, state} - else - current_actual = get_actual_line_index(state, state.cursor) - sorted = Enum.sort(MapSet.to_list(state.bookmarks)) - - # Find next bookmark after current position - next = - Enum.find(sorted, fn b -> b > current_actual end) || - hd(sorted) - - visible_lines = get_visible_line_indices(state) - visible_idx = Enum.find_index(visible_lines, &(&1 == next)) || state.cursor - new_scroll = max(0, visible_idx - div(state.viewport_height, 2)) - - {:ok, %{state | cursor: visible_idx, scroll_offset: new_scroll, tail_mode: false}} - end - end - - # ---------------------------------------------------------------------------- - # Private: Parsing - # ---------------------------------------------------------------------------- - - defp parse_line(line, id, parser) when is_binary(line) do - parser.(line) |> Map.put(:id, id) - end - - defp parse_line(%{} = entry, id, _parser) do - Map.put(entry, :id, id) - end - - @doc false - def default_parser(line) do - timestamp = extract_timestamp(line) - level = extract_level(line) - source = extract_source(line) - - %{ - timestamp: timestamp, - level: level, - source: source, - message: line, - raw: line - } - end - - defp extract_timestamp(line) do - case Regex.run(timestamp_pattern(), line) do - [match | _] -> - case DateTime.from_iso8601(match) do - {:ok, dt, _} -> dt - _ -> nil - end - - nil -> - nil - end - end - - defp extract_level(line) do - Enum.find_value(level_patterns(), fn {level, pattern} -> - if Regex.match?(pattern, line), do: level, else: nil - end) - end - - defp extract_source(line) do - case Regex.run(source_pattern(), line) do - [_, source | _] -> source - nil -> nil - end - end - - # ---------------------------------------------------------------------------- - # Private: Rendering - # ---------------------------------------------------------------------------- - - defp render_line(state, line, visible_idx, actual_idx) do - is_cursor = visible_idx == state.cursor - is_selected = in_selection?(state, actual_idx) - is_bookmarked = MapSet.member?(state.bookmarks, actual_idx) - is_search_match = state.search && actual_idx in state.search.matches - - # Build line parts - parts = [] - - # Line number - parts = - if state.show_line_numbers do - num_str = String.pad_leading("#{actual_idx + 1}", 5) - num_style = fg_dim_semantic(Theme.get_semantic(:muted)) - parts ++ [text(num_str <> " ", num_style)] - else - parts - end - - # Bookmark indicator - parts = - if is_bookmarked do - bookmark_style = fg_semantic(Theme.get_semantic(:warning)) - parts ++ [text("*", bookmark_style)] - else - parts ++ [text(" ", nil)] - end - - # Level indicator - parts = - if state.show_levels && line.level do - level_str = String.pad_trailing(level_abbrev(line.level), 5) - level_style = fg_color(level_color(line.level)) - parts ++ [text(level_str <> " ", level_style)] - else - parts - end - - # Message - message = truncate_line(line.raw, state) - message_style = get_message_style(state, line, is_cursor, is_selected, is_search_match) - parts = parts ++ [text(message, message_style)] - - stack(:horizontal, parts) - end - - defp level_color(:debug), do: Theme.get_semantic(:info) - defp level_color(:info), do: Theme.get_semantic(:success) - defp level_color(:notice), do: Theme.get_color(:primary) - defp level_color(:warning), do: Theme.get_semantic(:warning) - defp level_color(:error), do: Theme.get_semantic(:error) - defp level_color(:critical), do: Theme.get_color(:accent) - defp level_color(:alert), do: Theme.get_semantic(:error) - defp level_color(:emergency), do: Theme.get_semantic(:error) - defp level_color(_), do: Theme.get_color(:foreground) - - defp level_abbrev(:debug), do: "DEBUG" - defp level_abbrev(:info), do: "INFO" - defp level_abbrev(:notice), do: "NOTIC" - defp level_abbrev(:warning), do: "WARN" - defp level_abbrev(:error), do: "ERROR" - defp level_abbrev(:critical), do: "CRIT" - defp level_abbrev(:alert), do: "ALERT" - defp level_abbrev(:emergency), do: "EMERG" - defp level_abbrev(_), do: "" - - defp truncate_line(line, state) do - max_width = state.viewport_width - 15 - - if state.wrap_lines do - line - else - if String.length(line) > max_width do - String.slice(line, 0, max_width - 3) <> "..." - else - line - end - end - end - - defp get_message_style(state, line, is_cursor, is_selected, is_search_match) do - base_color = - if state.highlight_levels && line.level do - level_color(line.level) - else - Theme.get_color(:foreground) - end - - cond do - is_cursor -> - Theme.get_component_style(:item, :focused) - - is_selected -> - Theme.get_component_style(:item, :selected) - - is_search_match -> - fg_bg_semantic(base_color, Theme.get_semantic(:warning)) - - true -> - fg_color(base_color) - end - end - - defp in_selection?(state, line_idx) do - if state.selection_start != nil and state.selection_end != nil do - start_idx = min(state.selection_start, state.selection_end) - end_idx = max(state.selection_start, state.selection_end) - line_idx >= start_idx and line_idx <= end_idx - else - false - end - end - - defp render_status_bar(state, _total_lines) do - visible_lines = get_visible_line_indices(state) - actual_idx = get_actual_line_index(state, state.cursor) - - parts = [ - "Line #{actual_idx + 1}/#{length(state.lines)}" - ] - - parts = - if state.filter do - parts ++ [" | Filtered: #{length(visible_lines)}"] - else - parts - end - - parts = - if state.search do - match_count = length(state.search.matches) - current = state.search.current_match + 1 - parts ++ [" | Search: #{current}/#{match_count}"] - else - parts - end - - parts = - if MapSet.size(state.bookmarks) > 0 do - parts ++ [" | Bookmarks: #{MapSet.size(state.bookmarks)}"] - else - parts - end - - parts = - if state.tail_mode do - parts ++ [" | TAIL"] - else - parts - end - - parts = - if state.wrap_lines do - parts ++ [" | WRAP"] - else - parts - end - - status = Enum.join(parts, "") - status_style = fg_dim_semantic(Theme.get_semantic(:info)) - text(status, status_style) - end - - defp render_input_bar(state) do - cond do - state.search_input != nil -> - search_style = fg_semantic(Theme.get_semantic(:warning)) - [text("Search: " <> state.search_input <> "_", search_style)] - - state.filter_input != nil -> - filter_style = fg_semantic(Theme.get_semantic(:success)) - [text("Filter: " <> state.filter_input <> "_", filter_style)] - - true -> - [] - end - end -end diff --git a/lib/term_ui/widgets/markdown_viewer.ex b/lib/term_ui/widgets/markdown_viewer.ex deleted file mode 100644 index ad8bbf76..00000000 --- a/lib/term_ui/widgets/markdown_viewer.ex +++ /dev/null @@ -1,319 +0,0 @@ -defmodule TermUI.Widgets.MarkdownViewer do - @moduledoc """ - A scrollable markdown viewer component for TermUI. - - Renders markdown content with syntax highlighting, scrolling support, - and interactive code blocks that can be focused and copied. - - ## Usage - - MarkdownViewer.new(content: "# Hello\\n\\nThis is **bold** text.") - - ## Keyboard Navigation - - - `↑` / `↓` - Scroll up/down by line - - `Page Up` / `Page Down` - Scroll by page - - `Home` / `End` - Jump to top/bottom - - `Tab` - Cycle focus through code blocks - - `Enter` / `c` - Copy focused code block - - ## Props - - - `:content` - Markdown content to display (required) - - `:width` - Display width (default: 80) - - `:height` - Display height (default: 24) - - `:on_copy` - Callback called when code block is copied (optional) - - """ - - use TermUI.StatefulComponent - - alias TermUI.Component.RenderNode - alias TermUI.Event - alias TermUI.Markdown - - # Dialyzer: Functions return specific map types - @dialyzer {:nowarn_function, new: 1} - - @doc """ - Creates new MarkdownViewer props. - """ - @spec new(keyword()) :: map() - def new(opts) do - %{ - content: Keyword.get(opts, :content, ""), - width: Keyword.get(opts, :width, 80), - height: Keyword.get(opts, :height, 24), - on_copy: Keyword.get(opts, :on_copy) - } - end - - @impl true - def init(props) do - state = %{ - content: props.content, - width: props.width, - height: props.height, - scroll_y: 0, - on_copy: props.on_copy, - render_cache: nil, - content_height: 0, - elements: [], - focused_element_index: 0, - focused_element_id: nil - } - - {:ok, refresh_render_cache(state)} - end - - @impl true - def update(new_props, state) do - state = - state - |> maybe_update_content(new_props) - |> maybe_update_size(new_props) - - {:ok, state} - end - - @impl true - def handle_event(%Event.Key{key: :up}, state) do - scroll_by(state, -1) - end - - def handle_event(%Event.Key{key: :down}, state) do - scroll_by(state, 1) - end - - def handle_event(%Event.Key{key: :page_up}, state) do - scroll_by(state, -state.height) - end - - def handle_event(%Event.Key{key: :page_down}, state) do - scroll_by(state, state.height) - end - - def handle_event(%Event.Key{key: :home}, state) do - scroll_to_line(state, 0) - end - - def handle_event(%Event.Key{key: :end}, state) do - max_scroll = max(0, state.content_height - state.height) - scroll_to_line(state, max_scroll) - end - - def handle_event(%Event.Key{key: :tab, modifiers: []}, state) do - focus_next_code_block(state) - end - - def handle_event(%Event.Key{key: :tab, modifiers: [:shift]}, state) do - focus_prev_code_block(state) - end - - def handle_event(%Event.Key{key: :enter}, state) do - copy_focused_code_block(state) - end - - def handle_event(%Event.Key{char: ?c}, state) do - copy_focused_code_block(state) - end - - def handle_event(%Event.Mouse{action: :scroll_up}, state) do - scroll_by(state, -3) - end - - def handle_event(%Event.Mouse{action: :scroll_down}, state) do - scroll_by(state, 3) - end - - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, _area) do - %{lines: lines} = - state.render_cache || %{lines: [[{"", nil}]], elements: [], content_height: 1} - - start_line = state.scroll_y - - visible_lines = - lines - |> Enum.slice(start_line, state.height) - |> Enum.map(&render_line_to_node/1) - - if visible_lines == [] do - RenderNode.text("") - else - RenderNode.stack(:vertical, visible_lines) - end - end - - # Public API - - @doc """ - Sets the markdown content. - """ - @spec set_content(pid(), String.t()) :: :ok - def set_content(pid, content) when is_pid(pid) do - GenServer.call(pid, {:set_content, content}) - end - - def set_content(_pid, _content), do: :ok - - # GenServer callbacks - - @impl true - def handle_call({:set_content, content}, _from, state) do - state = refresh_render_cache(%{state | content: content, scroll_y: 0}) - {:reply, :ok, state} - end - - def handle_call(_request, _from, state) do - {:reply, :ok, state} - end - - # Private helpers - - defp maybe_update_content(state, %{content: content}) when is_binary(content) do - if content != state.content do - refresh_render_cache(%{state | content: content}) - else - state - end - end - - defp maybe_update_content(state, _new_props), do: state - - defp maybe_update_size(state, new_props) do - width = Map.get(new_props, :width, state.width) - height = Map.get(new_props, :height, state.height) - - if width != state.width or height != state.height do - state = %{state | width: width, height: height} - - if width != state.width do - refresh_render_cache(state) - else - state - end - else - state - end - end - - defp refresh_render_cache(state) do - %{lines: lines, elements: elements, content_height: height} = - Markdown.render_with_elements(state.content, state.width, - focused_element_id: state.focused_element_id - ) - - max_scroll = max(0, height - state.height) - scroll_y = min(state.scroll_y, max_scroll) - - %{ - state - | render_cache: %{lines: lines, elements: elements, content_height: height}, - content_height: height, - elements: elements, - scroll_y: scroll_y - } - end - - defp scroll_by(state, delta) do - new_y = clamp_scroll(state.scroll_y + delta, state.content_height, state.height) - {:ok, %{state | scroll_y: new_y}} - end - - defp scroll_to_line(state, y) do - new_y = clamp_scroll(y, state.content_height, state.height) - {:ok, %{state | scroll_y: new_y}} - end - - defp clamp_scroll(scroll, content_height, viewport_height) do - max_scroll = max(0, content_height - viewport_height) - min(max(0, scroll), max_scroll) - end - - defp focus_next_code_block(state) do - elements = state.elements - - if elements == [] do - {:ok, state} - else - new_index = rem(state.focused_element_index + 1, length(elements)) - focus_element_at_index(state, new_index) - end - end - - defp focus_prev_code_block(state) do - elements = state.elements - - if elements == [] do - {:ok, state} - else - count = length(elements) - new_index = rem(state.focused_element_index - 1 + count, count) - focus_element_at_index(state, new_index) - end - end - - defp focus_element_at_index(state, index) do - element = Enum.at(state.elements, index) - - if element do - state = %{state | focused_element_index: index} - element_id = element.id - - state = - if state.focused_element_id != element_id do - new_cache = - Markdown.render_with_elements(state.content, state.width, - focused_element_id: element_id - ) - - %{state | focused_element_id: element_id, render_cache: new_cache} - else - state - end - - target_line = element.start_line - scroll_to_line(state, target_line) - else - {:ok, state} - end - end - - defp copy_focused_code_block(state) do - if state.elements == [] do - {:ok, state} - else - element = Enum.at(state.elements, state.focused_element_index) - - if element do - if state.on_copy do - state.on_copy.(element.content) - end - - {:ok, state} - else - {:ok, state} - end - end - end - - defp render_line_to_node([]), do: RenderNode.text("", nil) - - defp render_line_to_node([{text, style}]) do - RenderNode.text(text, style) - end - - defp render_line_to_node(segments) when is_list(segments) do - nodes = - Enum.map(segments, fn {text, style} -> - RenderNode.text(text, style) - end) - - RenderNode.stack(:horizontal, nodes) - end -end diff --git a/lib/term_ui/widgets/menu.ex b/lib/term_ui/widgets/menu.ex deleted file mode 100644 index ba00285b..00000000 --- a/lib/term_ui/widgets/menu.ex +++ /dev/null @@ -1,488 +0,0 @@ -defmodule TermUI.Widgets.Menu do - @moduledoc """ - Menu widget for displaying hierarchical actions. - - Menu displays a list of items that can be actions, submenus, separators, - or checkboxes. Supports keyboard navigation and shortcut display. - - ## Usage - - Menu.new( - items: [ - Menu.action(:new, "New File", shortcut: "Ctrl+N"), - Menu.action(:open, "Open...", shortcut: "Ctrl+O"), - Menu.separator(), - Menu.submenu(:recent, "Recent Files", [ - Menu.action(:file1, "document.txt"), - Menu.action(:file2, "notes.md") - ]), - Menu.separator(), - Menu.checkbox(:autosave, "Auto Save", checked: true), - Menu.action(:exit, "Exit", shortcut: "Ctrl+Q") - ], - on_select: fn id -> handle_menu_action(id) end - ) - - ## Item Types - - - `:action` - Selectable menu item - - `:submenu` - Item with nested menu items - - `:separator` - Visual divider - - `:checkbox` - Toggleable item with check state - - ## Keyboard Navigation - - - Up/Down: Move between items - - Enter/Space: Select item or expand submenu - - Left: Collapse submenu - - Right: Expand submenu - - Escape: Close menu - """ - - use TermUI.StatefulComponent - - alias TermUI.CharacterSet - alias TermUI.Event - - # Dialyzer: Functions return specific map types - @dialyzer {:nowarn_function, - action: 3, submenu: 3, separator: 0, checkbox: 3, new: 1, expand: 2, collapse: 2} - - @type item_type :: :action | :submenu | :separator | :checkbox - - # Item constructors - - @doc """ - Creates an action menu item. - """ - @spec action(term(), String.t(), keyword()) :: map() - def action(id, label, opts \\ []) do - %{ - type: :action, - id: id, - label: label, - shortcut: Keyword.get(opts, :shortcut), - disabled: Keyword.get(opts, :disabled, false) - } - end - - @doc """ - Creates a submenu item. - """ - @spec submenu(term(), String.t(), [map()]) :: map() - def submenu(id, label, children) do - %{ - type: :submenu, - id: id, - label: label, - children: children - } - end - - @doc """ - Creates a separator. - """ - @spec separator() :: map() - def separator do - %{type: :separator, id: make_ref()} - end - - @doc """ - Creates a checkbox item. - """ - @spec checkbox(term(), String.t(), keyword()) :: map() - def checkbox(id, label, opts \\ []) do - %{ - type: :checkbox, - id: id, - label: label, - checked: Keyword.get(opts, :checked, false), - disabled: Keyword.get(opts, :disabled, false) - } - end - - @doc """ - Creates new Menu widget props. - - ## Options - - - `:items` - List of menu items (required) - - `:on_select` - Callback when item is selected - - `:on_toggle` - Callback when checkbox is toggled - - `:width` - Menu width (default: auto) - - `:item_style` - Style for normal items - - `:selected_style` - Style for focused item - - `:disabled_style` - Style for disabled items - """ - @spec new(keyword()) :: map() - def new(opts) do - %{ - items: Keyword.fetch!(opts, :items), - on_select: Keyword.get(opts, :on_select), - on_toggle: Keyword.get(opts, :on_toggle), - width: Keyword.get(opts, :width), - item_style: Keyword.get(opts, :item_style), - selected_style: Keyword.get(opts, :selected_style), - disabled_style: Keyword.get(opts, :disabled_style) - } - end - - @impl true - def init(props) do - flat_items = flatten_items(props.items) - - state = %{ - items: props.items, - flat_items: flat_items, - cursor: find_first_selectable(flat_items), - expanded: MapSet.new(), - on_select: props.on_select, - on_toggle: props.on_toggle, - width: props.width, - item_style: props.item_style, - selected_style: props.selected_style, - disabled_style: props.disabled_style - } - - {:ok, state} - end - - @impl true - def handle_event(%Event.Key{key: :up}, state) do - state = move_cursor(state, -1) - {:ok, state} - end - - def handle_event(%Event.Key{key: :down}, state) do - state = move_cursor(state, 1) - {:ok, state} - end - - def handle_event(%Event.Key{key: :right}, state) do - # Expand submenu - state = expand_at_cursor(state) - {:ok, state} - end - - def handle_event(%Event.Key{key: :left}, state) do - # Collapse submenu - state = collapse_at_cursor(state) - {:ok, state} - end - - def handle_event(%Event.Key{key: key}, state) when key in [:enter, " "] do - state = select_at_cursor(state) - {:ok, state} - end - - def handle_event(%Event.Key{key: :escape}, state) do - # Signal to close menu - {:ok, state, [{:send, self(), :menu_close}]} - end - - def handle_event(%Event.Mouse{action: :click, y: y}, state) do - # Select item at y position - visible = get_visible_items(state) - - if y >= 0 and y < length(visible) do - {item, _depth} = Enum.at(visible, y) - state = %{state | cursor: item.id} - state = select_at_cursor(state) - {:ok, state} - else - {:ok, state} - end - end - - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, _area) do - # Get character set for menu indicators - chars = CharacterSet.current_charset() - - visible = get_visible_items(state) - width = state.width || calculate_width(visible) - - rows = - Enum.map(visible, fn {item, depth} -> - render_item(state, item, depth, width, chars) - end) - - stack(:vertical, rows) - end - - # Private functions - - defp flatten_items(items, depth \\ 0) do - Enum.flat_map(items, fn item -> - case item.type do - :submenu -> - [{item, depth} | flatten_items(item.children, depth + 1)] - - _ -> - [{item, depth}] - end - end) - end - - defp find_first_selectable(flat_items) do - flat_items - |> Enum.find(fn {item, _} -> selectable?(item) end) - |> case do - nil -> nil - {item, _} -> item.id - end - end - - defp selectable?(item) do - item.type in [:action, :submenu, :checkbox] and not Map.get(item, :disabled, false) - end - - defp move_cursor(state, direction) do - visible = get_visible_items(state) - selectable_items = Enum.filter(visible, fn {item, _} -> selectable?(item) end) - - case Enum.find_index(selectable_items, fn {item, _} -> item.id == state.cursor end) do - nil -> - state - - current_idx -> - new_idx = current_idx + direction - new_idx = max(0, min(new_idx, length(selectable_items) - 1)) - {item, _} = Enum.at(selectable_items, new_idx) - %{state | cursor: item.id} - end - end - - defp expand_at_cursor(state) do - case find_item(state.items, state.cursor) do - %{type: :submenu} = _item -> - %{state | expanded: MapSet.put(state.expanded, state.cursor)} - - _ -> - state - end - end - - defp collapse_at_cursor(state) do - %{state | expanded: MapSet.delete(state.expanded, state.cursor)} - end - - defp select_at_cursor(state) do - case find_item(state.items, state.cursor) do - %{type: :action} = item -> - if state.on_select && not Map.get(item, :disabled, false) do - state.on_select.(item.id) - end - - state - - %{type: :submenu} -> - expand_at_cursor(state) - - %{type: :checkbox} = item -> - if Map.get(item, :disabled, false) do - state - else - toggle_checkbox(state, item.id) - end - - _ -> - state - end - end - - defp toggle_checkbox(state, item_id) do - items = - update_item(state.items, item_id, fn item -> - %{item | checked: not item.checked} - end) - - if state.on_toggle do - new_item = find_item(items, item_id) - state.on_toggle.(item_id, new_item.checked) - end - - flat_items = flatten_items(items) - %{state | items: items, flat_items: flat_items} - end - - defp find_item(items, id) do - Enum.find_value(items, fn item -> - cond do - item.id == id -> item - item.type == :submenu -> find_item(item.children, id) - true -> nil - end - end) - end - - defp update_item(items, id, update_fn) do - Enum.map(items, fn item -> - cond do - item.id == id -> - update_fn.(item) - - item.type == :submenu -> - %{item | children: update_item(item.children, id, update_fn)} - - true -> - item - end - end) - end - - defp get_visible_items(state) do - get_visible_items(state.items, state.expanded, 0) - end - - defp get_visible_items(items, expanded, depth) do - Enum.flat_map(items, fn item -> - get_item_visibility(item, expanded, depth) - end) - end - - defp get_item_visibility(%{type: :submenu} = item, expanded, depth) do - if MapSet.member?(expanded, item.id) do - [{item, depth} | get_visible_items(item.children, expanded, depth + 1)] - else - [{item, depth}] - end - end - - defp get_item_visibility(item, _expanded, depth) do - [{item, depth}] - end - - defp calculate_width(visible) do - visible - |> Enum.map(fn {item, depth} -> - case item.type do - :separator -> - 3 - - _ -> - label_len = String.length(item.label) - shortcut_len = String.length(Map.get(item, :shortcut, "") || "") - indent = depth * 2 - # prefix (checkbox/arrow) + label + gap + shortcut - 4 + indent + label_len + 2 + shortcut_len - end - end) - |> Enum.max(fn -> 10 end) - end - - defp render_item(state, item, depth, width, chars) do - case item.type do - :separator -> - chars = CharacterSet.current_charset() - text(String.duplicate(chars.h_line, width)) - - _ -> - render_selectable_item(state, item, depth, width, chars) - end - end - - defp render_selectable_item(state, item, depth, width, chars) do - indent = String.duplicate(" ", depth) - prefix = get_item_prefix(item, state, chars) - - # Main label - label = indent <> prefix <> item.label - - # Shortcut aligned right - shortcut = Map.get(item, :shortcut, "") || "" - padding = width - String.length(label) - String.length(shortcut) - padding = max(1, padding) - - full_text = label <> String.duplicate(" ", padding) <> shortcut - - # Determine style - style = get_item_style(item, state) - - if style do - styled(text(full_text), style) - else - text(full_text) - end - end - - defp get_item_prefix(%{type: :checkbox, checked: true}, _state, chars) do - "[#{chars.check}] " - end - - defp get_item_prefix(%{type: :checkbox}, _state, _chars), do: "[ ] " - - defp get_item_prefix(%{type: :submenu, id: id}, state, _chars) do - chars = CharacterSet.current_charset() - - if MapSet.member?(state.expanded, id) do - "#{chars.triangle_down} " - else - "#{chars.triangle_right} " - end - end - - defp get_item_prefix(_item, _state, _chars), do: " " - - defp get_item_style(item, state) do - cond do - Map.get(item, :disabled, false) -> - state.disabled_style - - item.id == state.cursor -> - state.selected_style - - true -> - state.item_style - end - end - - # Public API - - @doc """ - Gets the currently focused item ID. - """ - @spec get_cursor(map()) :: term() - def get_cursor(state) do - state.cursor - end - - @doc """ - Expands a submenu by ID. - """ - @spec expand(map(), term()) :: map() - def expand(state, submenu_id) do - %{state | expanded: MapSet.put(state.expanded, submenu_id)} - end - - @doc """ - Collapses a submenu by ID. - """ - @spec collapse(map(), term()) :: map() - def collapse(state, submenu_id) do - %{state | expanded: MapSet.delete(state.expanded, submenu_id)} - end - - @doc """ - Checks if a submenu is expanded. - """ - @spec expanded?(map(), term()) :: boolean() - def expanded?(state, submenu_id) do - MapSet.member?(state.expanded, submenu_id) - end - - @doc """ - Gets checkbox state. - """ - @spec checked?(map(), term()) :: boolean() - def checked?(state, item_id) do - case find_item(state.items, item_id) do - %{type: :checkbox, checked: checked} -> checked - _ -> false - end - end -end diff --git a/lib/term_ui/widgets/process_monitor.ex b/lib/term_ui/widgets/process_monitor.ex deleted file mode 100644 index 3ceeba5b..00000000 --- a/lib/term_ui/widgets/process_monitor.ex +++ /dev/null @@ -1,1063 +0,0 @@ -defmodule TermUI.Widgets.ProcessMonitor do - @moduledoc """ - ProcessMonitor widget for live BEAM process inspection. - - ProcessMonitor displays live process information including PID, name, - reductions, memory, and message queue depth. It provides controls for - process management and debugging. - - ## Usage - - ProcessMonitor.new( - update_interval: 1000, - show_system_processes: false - ) - - ## Features - - - Live process list with PID, name, reductions, memory - - Configurable update interval - - Message queue depth display with warnings - - Process links/monitors visualization - - Stack trace display - - Process actions (kill, suspend, resume) - - Sorting by any field - - Filtering by name/module - - ## Keyboard Controls - - - Up/Down: Move selection - - PageUp/PageDown: Scroll by page - - Enter: Toggle details panel - - r: Refresh now - - s: Cycle sort field - - S: Toggle sort direction - - /: Start filter input - - k: Kill selected process (with confirmation) - - p: Pause/resume selected process - - l: Show links/monitors - - t: Show stack trace - - Escape: Clear filter/close details - """ - - use TermUI.StatefulComponent - - alias TermUI.CharacterSet - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Theme - - # Dialyzer: Suppress opaque type warnings for Style helpers and contract warnings for specific map types - @dialyzer {:nowarn_function, - fg_semantic: 1, - fg_color: 1, - fg_bold_semantic: 1, - fg_bold_help: 0, - new: 1, - refresh: 1, - set_interval: 2, - set_sort: 3, - handle_info: 2, - unmount: 1} - - @type sort_field :: :pid | :name | :reductions | :memory | :queue | :status - @type sort_direction :: :asc | :desc - - @type process_info :: %{ - pid: pid(), - registered_name: atom() | nil, - initial_call: {module(), atom(), arity()} | nil, - current_function: {module(), atom(), arity()} | nil, - reductions: non_neg_integer(), - memory: non_neg_integer(), - message_queue_len: non_neg_integer(), - status: atom(), - links: [pid()], - monitors: [term()], - monitored_by: [pid()], - stack_trace: [term()] | nil - } - - @type thresholds :: %{ - queue_warning: non_neg_integer(), - queue_critical: non_neg_integer(), - memory_warning: non_neg_integer(), - memory_critical: non_neg_integer() - } - - @default_interval 1000 - @page_size 20 - - @default_thresholds %{ - queue_warning: 1000, - queue_critical: 10_000, - memory_warning: 50 * 1024 * 1024, - memory_critical: 200 * 1024 * 1024 - } - - @sort_fields [:pid, :name, :reductions, :memory, :queue, :status] - - # System process patterns to optionally hide - # NOTE: Defined as a function rather than a module attribute because compiled - # Regex structs contain references that cannot be injected into function bodies. - defp system_patterns do - [ - ~r/^:application_controller$/, - ~r/^:kernel_sup$/, - ~r/^:code_server$/, - ~r/^:file_server/, - ~r/^:init$/, - ~r/^:logger/, - ~r/^:erl_prim_loader$/ - ] - end - - # ---------------------------------------------------------------------------- - # Style Helper Functions - # ---------------------------------------------------------------------------- - - @spec fg_semantic(atom()) :: Style.t() - defp fg_semantic(color) when is_atom(color), - do: Style.new() |> Style.fg(color) - - @spec fg_color(atom()) :: Style.t() - defp fg_color(color) when is_atom(color), - do: Style.new() |> Style.fg(color) - - @spec fg_bold_semantic(atom()) :: Style.t() - defp fg_bold_semantic(color) when is_atom(color), - do: Style.new() |> Style.fg(color) |> Style.bold() - - @spec fg_bold_help() :: Style.t() - defp fg_bold_help do - Style.new() |> Style.fg(Theme.get_semantic(:help)) |> Style.dim() - end - - # ---------------------------------------------------------------------------- - # Props - # ---------------------------------------------------------------------------- - - @doc """ - Creates new ProcessMonitor widget props. - - ## Options - - - `:update_interval` - Refresh interval in ms (default: 1000) - - `:show_system_processes` - Include system processes (default: false) - - `:thresholds` - Warning thresholds map - - `:on_select` - Callback when process is selected - - `:on_action` - Callback when action is performed - """ - @spec new(keyword()) :: map() - def new(opts) do - %{ - update_interval: Keyword.get(opts, :update_interval, @default_interval), - show_system_processes: Keyword.get(opts, :show_system_processes, false), - thresholds: Keyword.get(opts, :thresholds, @default_thresholds), - on_select: Keyword.get(opts, :on_select), - on_action: Keyword.get(opts, :on_action) - } - end - - # ---------------------------------------------------------------------------- - # StatefulComponent Callbacks - # ---------------------------------------------------------------------------- - - @impl true - def init(props) do - state = %{ - # Process data - processes: [], - selected_idx: 0, - scroll_offset: 0, - - # Sorting - sort_field: :reductions, - sort_direction: :desc, - - # Filtering - filter: nil, - filter_input: nil, - - # Display modes - show_details: false, - detail_mode: :info, - - # Confirmation - pending_action: nil, - - # Settings - update_interval: props.update_interval, - show_system_processes: props.show_system_processes, - thresholds: props.thresholds, - timer_ref: nil, - - # Callbacks - on_select: props.on_select, - on_action: props.on_action, - - # Viewport - viewport_height: 20, - viewport_width: 80, - last_area: nil - } - - # Fetch initial process list - processes = fetch_processes(state) - state = %{state | processes: processes} - - {:ok, state} - end - - @impl true - def mount(state) do - # Start refresh timer - timer_ref = schedule_refresh(state.update_interval) - {:ok, %{state | timer_ref: timer_ref}} - end - - @impl true - def unmount(state) do - if state.timer_ref do - Process.cancel_timer(state.timer_ref) - end - - :ok - end - - # ---------------------------------------------------------------------------- - # Event Handling - # ---------------------------------------------------------------------------- - - @impl true - def handle_event(%Event.Key{key: :up}, state) when state.filter_input == nil do - move_selection(state, -1) - end - - def handle_event(%Event.Key{key: :down}, state) when state.filter_input == nil do - move_selection(state, 1) - end - - def handle_event(%Event.Key{key: :page_up}, state) when state.filter_input == nil do - move_selection(state, -@page_size) - end - - def handle_event(%Event.Key{key: :page_down}, state) when state.filter_input == nil do - move_selection(state, @page_size) - end - - def handle_event(%Event.Key{key: :home}, state) when state.filter_input == nil do - {:ok, %{state | selected_idx: 0, scroll_offset: 0}} - end - - def handle_event(%Event.Key{key: :end}, state) when state.filter_input == nil do - last = max(0, length(state.processes) - 1) - scroll = max(0, length(state.processes) - state.viewport_height) - {:ok, %{state | selected_idx: last, scroll_offset: scroll}} - end - - # Enter - toggle details - def handle_event(%Event.Key{key: :enter}, state) - when state.filter_input == nil and state.pending_action == nil do - {:ok, %{state | show_details: not state.show_details, detail_mode: :info}} - end - - # r - refresh - def handle_event(%Event.Key{char: "r"}, state) when state.filter_input == nil do - refresh(state) - end - - # s - cycle sort field - def handle_event(%Event.Key{char: "s"}, state) when state.filter_input == nil do - current_idx = Enum.find_index(@sort_fields, &(&1 == state.sort_field)) - next_idx = rem(current_idx + 1, length(@sort_fields)) - next_field = Enum.at(@sort_fields, next_idx) - processes = sort_processes(state.processes, next_field, state.sort_direction) - {:ok, %{state | sort_field: next_field, processes: processes}} - end - - # S - toggle sort direction - def handle_event(%Event.Key{char: "S"}, state) when state.filter_input == nil do - new_dir = if state.sort_direction == :asc, do: :desc, else: :asc - processes = sort_processes(state.processes, state.sort_field, new_dir) - {:ok, %{state | sort_direction: new_dir, processes: processes}} - end - - # / - start filter - def handle_event(%Event.Key{char: "/"}, state) when state.filter_input == nil do - {:ok, %{state | filter_input: ""}} - end - - # k - kill process - def handle_event(%Event.Key{char: "k"}, state) - when state.filter_input == nil and state.pending_action == nil do - if length(state.processes) > 0 do - {:ok, %{state | pending_action: :kill}} - else - {:ok, state} - end - end - - # p - pause/suspend process - def handle_event(%Event.Key{char: "p"}, state) - when state.filter_input == nil and state.pending_action == nil do - process = Enum.at(state.processes, state.selected_idx) - handle_suspend_action(state, process) - end - - # l - show links - def handle_event(%Event.Key{char: "l"}, state) when state.filter_input == nil do - {:ok, %{state | show_details: true, detail_mode: :links}} - end - - # t - show stack trace - def handle_event(%Event.Key{char: "t"}, state) when state.filter_input == nil do - {:ok, %{state | show_details: true, detail_mode: :trace}} - end - - # Escape - clear filter or close details - def handle_event(%Event.Key{key: :escape}, state) do - cond do - state.pending_action != nil -> - {:ok, %{state | pending_action: nil}} - - state.filter_input != nil -> - {:ok, %{state | filter_input: nil}} - - state.show_details -> - {:ok, %{state | show_details: false}} - - state.filter != nil -> - processes = fetch_processes(%{state | filter: nil}) - {:ok, %{state | filter: nil, processes: processes, selected_idx: 0, scroll_offset: 0}} - - true -> - {:ok, state} - end - end - - # Confirmation: y = yes - def handle_event(%Event.Key{char: "y"}, state) when state.pending_action != nil do - process = Enum.at(state.processes, state.selected_idx) - - if process do - case state.pending_action do - :kill -> kill_process(state, process.pid) - :suspend -> suspend_process(state, process.pid) - _ -> {:ok, %{state | pending_action: nil}} - end - else - {:ok, %{state | pending_action: nil}} - end - end - - # Confirmation: n = no - def handle_event(%Event.Key{char: "n"}, state) when state.pending_action != nil do - {:ok, %{state | pending_action: nil}} - end - - # Filter input mode - def handle_event(%Event.Key{key: :enter}, state) when state.filter_input != nil do - filter = if state.filter_input == "", do: nil, else: state.filter_input - processes = fetch_processes(%{state | filter: filter}) - - {:ok, - %{ - state - | filter: filter, - filter_input: nil, - processes: processes, - selected_idx: 0, - scroll_offset: 0 - }} - end - - def handle_event(%Event.Key{key: :backspace}, state) when state.filter_input != nil do - input = String.slice(state.filter_input, 0..-2//1) - {:ok, %{state | filter_input: input}} - end - - def handle_event(%Event.Key{char: char}, state) - when state.filter_input != nil and char != nil do - {:ok, %{state | filter_input: state.filter_input <> char}} - end - - def handle_event(_event, state) do - {:ok, state} - end - - # ---------------------------------------------------------------------------- - # Private Helpers for Event Handling - # ---------------------------------------------------------------------------- - - defp handle_suspend_action(state, nil), do: {:ok, state} - - defp handle_suspend_action(state, process) do - if process.status == :suspended do - resume_process(state, process.pid) - else - {:ok, %{state | pending_action: :suspend}} - end - end - - # ---------------------------------------------------------------------------- - # Message Handling - # ---------------------------------------------------------------------------- - - @impl true - def handle_info(:refresh, state) do - processes = fetch_processes(state) - timer_ref = schedule_refresh(state.update_interval) - {:ok, %{state | processes: processes, timer_ref: timer_ref}} - end - - def handle_info(_msg, state) do - {:ok, state} - end - - # ---------------------------------------------------------------------------- - # Process Fetching - # ---------------------------------------------------------------------------- - - defp fetch_processes(state) do - Process.list() - |> Enum.map(&get_process_info/1) - |> Enum.reject(&is_nil/1) - |> maybe_filter_system(state.show_system_processes) - |> maybe_apply_filter(state.filter) - |> sort_processes(state.sort_field, state.sort_direction) - end - - defp get_process_info(pid) do - info = - Process.info(pid, [ - :registered_name, - :initial_call, - :current_function, - :reductions, - :memory, - :message_queue_len, - :status, - :links, - :monitors, - :monitored_by - ]) - - if info do - %{ - pid: pid, - registered_name: info[:registered_name], - initial_call: info[:initial_call], - current_function: info[:current_function], - reductions: info[:reductions] || 0, - memory: info[:memory] || 0, - message_queue_len: info[:message_queue_len] || 0, - status: info[:status] || :unknown, - links: info[:links] || [], - monitors: info[:monitors] || [], - monitored_by: info[:monitored_by] || [], - stack_trace: nil - } - else - nil - end - rescue - _ -> nil - catch - _, _ -> nil - end - - defp maybe_filter_system(processes, true), do: processes - - defp maybe_filter_system(processes, false) do - Enum.reject(processes, fn p -> - name = process_name(p) - Enum.any?(system_patterns(), &Regex.match?(&1, name)) - end) - end - - defp maybe_apply_filter(processes, nil), do: processes - - defp maybe_apply_filter(processes, filter) do - pattern = - case Regex.compile(filter, [:caseless]) do - {:ok, regex} -> regex - _ -> nil - end - - if pattern do - Enum.filter(processes, fn p -> - name = process_name(p) - Regex.match?(pattern, name) - end) - else - Enum.filter(processes, fn p -> - name = process_name(p) - String.contains?(String.downcase(name), String.downcase(filter)) - end) - end - end - - defp process_name(process) do - cond do - process.registered_name -> - inspect(process.registered_name) - - process.initial_call -> - {m, f, a} = process.initial_call - "#{inspect(m)}.#{f}/#{a}" - - true -> - inspect(process.pid) - end - end - - # ---------------------------------------------------------------------------- - # Sorting - # ---------------------------------------------------------------------------- - - defp sort_processes(processes, field, direction) do - sorted = - case field do - :pid -> - Enum.sort_by(processes, & &1.pid, fn a, b -> - :erlang.pid_to_list(a) <= :erlang.pid_to_list(b) - end) - - :name -> - Enum.sort_by(processes, &process_name/1) - - :reductions -> - Enum.sort_by(processes, & &1.reductions) - - :memory -> - Enum.sort_by(processes, & &1.memory) - - :queue -> - Enum.sort_by(processes, & &1.message_queue_len) - - :status -> - Enum.sort_by(processes, & &1.status) - end - - if direction == :desc, do: Enum.reverse(sorted), else: sorted - end - - # ---------------------------------------------------------------------------- - # Navigation - # ---------------------------------------------------------------------------- - - defp move_selection(state, delta) do - count = length(state.processes) - - if count == 0 do - {:ok, state} - else - new_idx = state.selected_idx + delta - new_idx = max(0, min(new_idx, count - 1)) - - new_scroll = - cond do - new_idx < state.scroll_offset -> - new_idx - - new_idx >= state.scroll_offset + state.viewport_height -> - new_idx - state.viewport_height + 1 - - true -> - state.scroll_offset - end - - new_state = %{state | selected_idx: new_idx, scroll_offset: max(0, new_scroll)} - - maybe_notify_select(state, new_idx) - {:ok, new_state} - end - end - - defp maybe_notify_select(state, new_idx) do - if state.on_select && new_idx != state.selected_idx do - process = Enum.at(state.processes, new_idx) - if process, do: state.on_select.(process) - end - end - - # ---------------------------------------------------------------------------- - # Process Actions - # ---------------------------------------------------------------------------- - - defp kill_process(state, pid) do - Process.exit(pid, :kill) - - if state.on_action do - state.on_action.({:killed, pid}) - end - - # Refresh after action - processes = fetch_processes(state) - new_idx = min(state.selected_idx, max(0, length(processes) - 1)) - {:ok, %{state | pending_action: nil, processes: processes, selected_idx: new_idx}} - rescue - _ -> {:ok, %{state | pending_action: nil}} - end - - defp suspend_process(state, pid) do - :erlang.suspend_process(pid) - - if state.on_action do - state.on_action.({:suspended, pid}) - end - - processes = fetch_processes(state) - {:ok, %{state | pending_action: nil, processes: processes}} - rescue - _ -> {:ok, %{state | pending_action: nil}} - end - - defp resume_process(state, pid) do - :erlang.resume_process(pid) - - if state.on_action do - state.on_action.({:resumed, pid}) - end - - processes = fetch_processes(state) - {:ok, %{state | processes: processes}} - rescue - _ -> {:ok, state} - end - - # ---------------------------------------------------------------------------- - # Timer - # ---------------------------------------------------------------------------- - - defp schedule_refresh(interval) do - Process.send_after(self(), :refresh, interval) - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Force refresh the process list. - """ - @spec refresh(map()) :: {:ok, map()} - def refresh(state) do - processes = fetch_processes(state) - {:ok, %{state | processes: processes}} - end - - @doc """ - Set the update interval. - """ - @spec set_interval(map(), non_neg_integer()) :: {:ok, map()} - def set_interval(state, interval) when interval > 0 do - if state.timer_ref do - Process.cancel_timer(state.timer_ref) - end - - timer_ref = schedule_refresh(interval) - {:ok, %{state | update_interval: interval, timer_ref: timer_ref}} - end - - @doc """ - Set sorting options. - """ - @spec set_sort(map(), sort_field(), sort_direction()) :: {:ok, map()} - def set_sort(state, field, direction) - when field in @sort_fields and direction in [:asc, :desc] do - processes = sort_processes(state.processes, field, direction) - {:ok, %{state | sort_field: field, sort_direction: direction, processes: processes}} - end - - @doc """ - Set filter pattern. - """ - @spec set_filter(map(), String.t() | nil) :: {:ok, map()} - def set_filter(state, filter) do - processes = fetch_processes(%{state | filter: filter}) - {:ok, %{state | filter: filter, processes: processes, selected_idx: 0, scroll_offset: 0}} - end - - @doc """ - Get currently selected process. - """ - @spec get_selected(map()) :: process_info() | nil - def get_selected(state) do - Enum.at(state.processes, state.selected_idx) - end - - @doc """ - Get process count. - """ - @spec process_count(map()) :: non_neg_integer() - def process_count(state), do: length(state.processes) - - @doc """ - Get stack trace for a process. - """ - @spec get_stack_trace(pid()) :: [term()] | nil - def get_stack_trace(pid) do - case Process.info(pid, :current_stacktrace) do - {:current_stacktrace, trace} -> trace - _ -> nil - end - rescue - _ -> nil - catch - _, _ -> nil - end - - # ---------------------------------------------------------------------------- - # Rendering - # ---------------------------------------------------------------------------- - - @impl true - def render(state, area) do - # Get character set for arrows and indicators - chars = CharacterSet.current_charset() - - # Update viewport dimensions - detail_height = if state.show_details, do: 8, else: 0 - - state = %{ - state - | viewport_height: area.height - 4 - detail_height, - viewport_width: area.width, - last_area: area - } - - # Build render tree - header = render_header(state, chars) - process_list = render_process_list(state) - details = if state.show_details, do: render_details(state), else: [] - footer = render_footer(state, chars) - confirmation = render_confirmation(state) - - content = [header] ++ process_list ++ details ++ footer ++ confirmation - - stack(:vertical, content) - end - - defp render_header(state, chars) do - sort_indicator = if state.sort_direction == :asc, do: chars.arrow_up, else: chars.arrow_down - sort_label = "#{state.sort_field}#{sort_indicator}" - - filter_label = - if state.filter do - " | Filter: #{state.filter}" - else - "" - end - - header_text = "Processes: #{length(state.processes)} | Sort: #{sort_label}#{filter_label}" - - header_style = fg_bold_semantic(Theme.get_semantic(:info)) - text(header_text, header_style) - end - - defp render_process_list(state) do - # Column widths - pid_w = 15 - name_w = 30 - red_w = 12 - mem_w = 10 - queue_w = 8 - status_w = 10 - - # Header row - header_line = - String.pad_trailing("PID", pid_w) <> - String.pad_trailing("Name", name_w) <> - String.pad_leading("Reductions", red_w) <> - String.pad_leading("Memory", mem_w) <> - String.pad_leading("Queue", queue_w) <> - String.pad_trailing(" Status", status_w) - - header = text(header_line, Style.new(attrs: [:bold, :underline])) - - # Process rows - visible_processes = - state.processes - |> Enum.drop(state.scroll_offset) - |> Enum.take(state.viewport_height) - - rows = - visible_processes - |> Enum.with_index() - |> Enum.map(fn {process, idx} -> - actual_idx = idx + state.scroll_offset - - render_process_row( - process, - actual_idx, - state, - {pid_w, name_w, red_w, mem_w, queue_w, status_w} - ) - end) - - # Pad with empty lines - padding_count = max(0, state.viewport_height - length(rows)) - padding = List.duplicate(text("", nil), padding_count) - - [header | rows ++ padding] - end - - defp render_process_row(process, idx, state, {pid_w, name_w, red_w, mem_w, queue_w, status_w}) do - is_selected = idx == state.selected_idx - - # Format fields with threshold indicators for accessibility - pid_str = String.pad_trailing(inspect(process.pid), pid_w) - name_str = String.pad_trailing(truncate(process_name(process), name_w - 1), name_w) - red_str = String.pad_leading(format_number(process.reductions), red_w) - - mem_str = - String.pad_leading(format_memory_with_indicator(process.memory, state.thresholds), mem_w) - - queue_str = - String.pad_leading( - format_queue_with_indicator(process.message_queue_len, state.thresholds), - queue_w - ) - - status_str = String.pad_trailing(" #{process.status}", status_w) - - line = pid_str <> name_str <> red_str <> mem_str <> queue_str <> status_str - - # Determine style with Theme API - style = - cond do - is_selected -> - Theme.get_component_style(:item, :selected) - - process.message_queue_len >= state.thresholds.queue_critical -> - fg_bold_semantic(Theme.get_semantic(:error)) - - process.message_queue_len >= state.thresholds.queue_warning -> - fg_semantic(Theme.get_semantic(:warning)) - - process.memory >= state.thresholds.memory_critical -> - fg_bold_semantic(Theme.get_semantic(:error)) - - process.memory >= state.thresholds.memory_warning -> - fg_semantic(Theme.get_semantic(:warning)) - - process.status == :suspended -> - fg_color(Theme.get_color(:accent)) - - true -> - nil - end - - text(line, style) - end - - defp render_details(state) do - process = Enum.at(state.processes, state.selected_idx) - - if process do - case state.detail_mode do - :info -> render_info_details(process, state) - :links -> render_links_details(process) - :trace -> render_trace_details(process) - end - else - empty_style = fg_semantic(Theme.get_semantic(:muted)) - [text("No process selected", empty_style)] - end - end - - defp render_info_details(process, _state) do - border = text(String.duplicate("-", 60), fg_color(Theme.get_color(:primary))) - - lines = [ - border, - text("PID: #{inspect(process.pid)}", nil), - text("Name: #{process_name(process)}", nil), - text("Current: #{format_mfa(process.current_function)}", nil), - text("Initial: #{format_mfa(process.initial_call)}", nil), - text("Status: #{process.status}", nil), - text( - "Links: #{length(process.links)} | Monitors: #{length(process.monitors)} | Monitored by: #{length(process.monitored_by)}", - nil - ), - border - ] - - lines - end - - defp render_links_details(process) do - border = text(String.duplicate("-", 60), fg_color(Theme.get_color(:primary))) - - links_text = - if length(process.links) > 0 do - process.links - |> Enum.take(5) - |> Enum.map_join(", ", &inspect/1) - else - "(none)" - end - - monitors_text = - if length(process.monitors) > 0 do - process.monitors - |> Enum.take(5) - |> Enum.map_join(", ", &inspect/1) - else - "(none)" - end - - monitored_by_text = - if length(process.monitored_by) > 0 do - process.monitored_by - |> Enum.take(5) - |> Enum.map_join(", ", &inspect/1) - else - "(none)" - end - - [ - border, - text("Links: #{links_text}", nil), - text("Monitors: #{monitors_text}", nil), - text("Monitored by: #{monitored_by_text}", nil), - text("", nil), - text("", nil), - text("", nil), - border - ] - end - - defp render_trace_details(process) do - border = text(String.duplicate("-", 60), fg_color(Theme.get_color(:primary))) - - trace = get_stack_trace(process.pid) - - trace_lines = - if trace && length(trace) > 0 do - trace - |> Enum.take(6) - |> Enum.map(fn {m, f, a, loc} -> - file = Keyword.get(loc, :file, "?") - line = Keyword.get(loc, :line, "?") - text(" #{m}.#{f}/#{a} (#{file}:#{line})", nil) - end) - else - empty_style = fg_semantic(Theme.get_semantic(:muted)) - [text(" (no stack trace available)", empty_style)] - end - - [border, text("Stack Trace:", Style.new(attrs: [:bold]))] ++ trace_lines ++ [border] - end - - defp render_footer(state, chars) do - input_line = - if state.filter_input != nil do - filter_style = fg_semantic(Theme.get_semantic(:warning)) - [text("Filter: #{state.filter_input}_", filter_style)] - else - [] - end - - help_text = - "[#{chars.arrow_up}#{chars.arrow_down}] Select [Enter] Details [s/S] Sort [/] Filter [k] Kill [p] Pause [l] Links [t] Trace [r] Refresh" - - help_style = fg_bold_help() - input_line ++ [text(help_text, help_style)] - end - - defp render_confirmation(state) do - if state.pending_action do - process = Enum.at(state.processes, state.selected_idx) - - action_text = - case state.pending_action do - :kill -> "Kill" - :suspend -> "Suspend" - _ -> "Perform action on" - end - - if process do - confirm_style = fg_bold_semantic(Theme.get_semantic(:error)) - - [ - text("", nil), - text( - "#{action_text} #{inspect(process.pid)} (#{process_name(process)})? [y/n]", - confirm_style - ) - ] - else - [] - end - else - [] - end - end - - # ---------------------------------------------------------------------------- - # Formatting Helpers - # ---------------------------------------------------------------------------- - - defp truncate(str, max_len) do - if String.length(str) > max_len do - String.slice(str, 0, max_len - 1) <> "…" - else - str - end - end - - defp format_number(n) when n >= 1_000_000_000 do - "#{Float.round(n / 1_000_000_000, 1)}B" - end - - defp format_number(n) when n >= 1_000_000 do - "#{Float.round(n / 1_000_000, 1)}M" - end - - defp format_number(n) when n >= 1_000 do - "#{Float.round(n / 1_000, 1)}K" - end - - defp format_number(n), do: Integer.to_string(n) - - defp format_bytes(b) when b >= 1024 * 1024 * 1024 do - "#{Float.round(b / (1024 * 1024 * 1024), 1)}GB" - end - - defp format_bytes(b) when b >= 1024 * 1024 do - "#{Float.round(b / (1024 * 1024), 1)}MB" - end - - defp format_bytes(b) when b >= 1024 do - "#{Float.round(b / 1024, 1)}KB" - end - - defp format_bytes(b), do: "#{b}B" - - defp format_mfa(nil), do: "-" - defp format_mfa({m, f, a}), do: "#{inspect(m)}.#{f}/#{a}" - - # Threshold indicator helpers for accessibility - defp format_queue_with_indicator(queue_len, thresholds) do - base = Integer.to_string(queue_len) - - cond do - queue_len >= thresholds.queue_critical -> "#{base}[H]" - queue_len >= thresholds.queue_warning -> "#{base}[M]" - true -> base - end - end - - defp format_memory_with_indicator(memory, thresholds) do - base = format_bytes(memory) - - cond do - memory >= thresholds.memory_critical -> "#{base}[H]" - memory >= thresholds.memory_warning -> "#{base}[M]" - true -> base - end - end -end diff --git a/lib/term_ui/widgets/scroll_bar.ex b/lib/term_ui/widgets/scroll_bar.ex deleted file mode 100644 index d8c38c4f..00000000 --- a/lib/term_ui/widgets/scroll_bar.ex +++ /dev/null @@ -1,346 +0,0 @@ -defmodule TermUI.Widgets.ScrollBar do - @moduledoc """ - Standalone scroll bar widget. - - ScrollBar provides a visual indicator and interactive control for scrolling. - Can be used independently or integrated with other scrollable widgets. - - ## Usage - - ScrollBar.new( - orientation: :vertical, - total: 100, - visible: 20, - position: 0, - length: 20, - on_scroll: fn pos -> handle_scroll(pos) end - ) - - ## Features - - - Vertical and horizontal orientations - - Proportional thumb size based on visible/total ratio - - Track click for page scrolling - - Drag scrolling for smooth navigation - - Customizable appearance - - ## Mouse Interaction - - - Click on thumb: Start dragging - - Click on track: Page scroll toward click - - Drag thumb: Smooth scrolling - """ - - use TermUI.StatefulComponent - - alias TermUI.CharacterSet - alias TermUI.Event - - # Dialyzer: Functions return specific map types - @dialyzer {:nowarn_function, new: 1, vertical: 1, horizontal: 1} - - @doc """ - Creates new ScrollBar widget props. - - ## Options - - - `:orientation` - :vertical or :horizontal (default: :vertical) - - `:total` - Total content size (default: 100) - - `:visible` - Visible content size (default: 20) - - `:position` - Current scroll position (default: 0) - - `:length` - Bar length in characters (default: 20) - - `:on_scroll` - Callback when position changes - - `:track_char` - Character for track (default from CharacterSet) - - `:thumb_char` - Character for thumb (default from CharacterSet) - - `:min_thumb_size` - Minimum thumb size (default: 1) - """ - @spec new(keyword()) :: map() - def new(opts) do - chars = CharacterSet.current_charset() - - %{ - orientation: Keyword.get(opts, :orientation, :vertical), - total: Keyword.get(opts, :total, 100), - visible: Keyword.get(opts, :visible, 20), - position: Keyword.get(opts, :position, 0), - length: Keyword.get(opts, :length, 20), - on_scroll: Keyword.get(opts, :on_scroll), - track_char: Keyword.get(opts, :track_char, chars.bar_empty), - thumb_char: Keyword.get(opts, :thumb_char, chars.bar_full), - min_thumb_size: Keyword.get(opts, :min_thumb_size, 1) - } - end - - @impl true - def init(props) do - state = %{ - orientation: props.orientation, - total: props.total, - visible: props.visible, - position: clamp_position(props.position, props.total, props.visible), - length: props.length, - on_scroll: props.on_scroll, - track_char: props.track_char, - thumb_char: props.thumb_char, - min_thumb_size: props.min_thumb_size, - dragging: false, - drag_offset: 0 - } - - {:ok, state} - end - - @impl true - def handle_event(%Event.Mouse{action: :click, x: x, y: y}, state) do - pos = if state.orientation == :vertical, do: y, else: x - - {thumb_pos, thumb_size} = thumb_metrics(state) - - if pos >= thumb_pos and pos < thumb_pos + thumb_size do - # Click on thumb - start dragging - drag_offset = pos - thumb_pos - {:ok, %{state | dragging: true, drag_offset: drag_offset}} - else - # Click on track - page scroll - if pos < thumb_pos do - scroll_by(state, -state.visible) - else - scroll_by(state, state.visible) - end - end - end - - def handle_event(%Event.Mouse{action: :drag, x: x, y: y}, state) do - if state.dragging do - pos = if state.orientation == :vertical, do: y, else: x - handle_drag(state, pos) - else - {:ok, state} - end - end - - def handle_event(%Event.Mouse{action: :release}, state) do - {:ok, %{state | dragging: false, drag_offset: 0}} - end - - def handle_event(%Event.Key{key: :up}, state) when state.orientation == :vertical do - scroll_by(state, -1) - end - - def handle_event(%Event.Key{key: :down}, state) when state.orientation == :vertical do - scroll_by(state, 1) - end - - def handle_event(%Event.Key{key: :left}, state) when state.orientation == :horizontal do - scroll_by(state, -1) - end - - def handle_event(%Event.Key{key: :right}, state) when state.orientation == :horizontal do - scroll_by(state, 1) - end - - def handle_event(%Event.Key{key: :page_up}, state) do - scroll_by(state, -state.visible) - end - - def handle_event(%Event.Key{key: :page_down}, state) do - scroll_by(state, state.visible) - end - - def handle_event(%Event.Key{key: :home}, state) do - scroll_to(state, 0) - end - - def handle_event(%Event.Key{key: :end}, state) do - max_pos = max(0, state.total - state.visible) - scroll_to(state, max_pos) - end - - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, _area) do - {thumb_pos, thumb_size} = thumb_metrics(state) - - chars = - for i <- 0..(state.length - 1) do - if i >= thumb_pos and i < thumb_pos + thumb_size do - state.thumb_char - else - state.track_char - end - end - - case state.orientation do - :vertical -> - lines = Enum.map(chars, &text/1) - stack(:vertical, lines) - - :horizontal -> - text(Enum.join(chars)) - end - end - - # Private functions - - defp clamp_position(position, total, visible) do - max_pos = max(0, total - visible) - min(max(0, position), max_pos) - end - - defp thumb_metrics(state) do - if state.total <= state.visible do - # Content fits in viewport - full thumb - {0, state.length} - else - # Calculate proportional thumb size - visible_fraction = state.visible / state.total - thumb_size = max(state.min_thumb_size, round(state.length * visible_fraction)) - - # Calculate thumb position - max_pos = state.total - state.visible - scroll_fraction = if max_pos > 0, do: state.position / max_pos, else: 0.0 - track_space = state.length - thumb_size - thumb_pos = round(track_space * scroll_fraction) - - {thumb_pos, thumb_size} - end - end - - defp scroll_by(state, delta) do - new_pos = state.position + delta - scroll_to(state, new_pos) - end - - defp scroll_to(state, position) do - new_pos = clamp_position(position, state.total, state.visible) - - if new_pos != state.position do - new_state = %{state | position: new_pos} - - if state.on_scroll do - state.on_scroll.(new_pos) - end - - {:ok, new_state} - else - {:ok, state} - end - end - - defp handle_drag(state, pos) do - # Convert position to scroll value - {_thumb_pos, thumb_size} = thumb_metrics(state) - track_space = state.length - thumb_size - - if track_space > 0 do - # Adjust for drag offset - adjusted_pos = pos - state.drag_offset - scroll_fraction = adjusted_pos / track_space - - max_pos = state.total - state.visible - new_pos = round(scroll_fraction * max_pos) - - scroll_to(state, new_pos) - else - {:ok, state} - end - end - - # Public API - - @doc """ - Gets the current scroll position. - """ - @spec get_position(map()) :: integer() - def get_position(state) do - state.position - end - - @doc """ - Sets the scroll position. - """ - @spec set_position(map(), integer()) :: map() - def set_position(state, position) do - %{state | position: clamp_position(position, state.total, state.visible)} - end - - @doc """ - Updates the content dimensions. - """ - @spec set_dimensions(map(), integer(), integer()) :: map() - def set_dimensions(state, total, visible) do - %{ - state - | total: total, - visible: visible, - position: clamp_position(state.position, total, visible) - } - end - - @doc """ - Gets the scroll fraction (0.0 - 1.0). - """ - @spec get_fraction(map()) :: float() - def get_fraction(state) do - max_pos = max(0, state.total - state.visible) - - if max_pos > 0 do - state.position / max_pos - else - 0.0 - end - end - - @doc """ - Sets scroll by fraction (0.0 - 1.0). - """ - @spec set_fraction(map(), float()) :: map() - def set_fraction(state, fraction) do - max_pos = max(0, state.total - state.visible) - position = round(fraction * max_pos) - set_position(state, position) - end - - @doc """ - Returns true if scrolling is possible (content exceeds visible). - """ - @spec can_scroll?(map()) :: boolean() - def can_scroll?(state) do - state.total > state.visible - end - - @doc """ - Returns the visible fraction (thumb size ratio). - """ - @spec visible_fraction(map()) :: float() - def visible_fraction(state) do - if state.total > 0 do - min(1.0, state.visible / state.total) - else - 1.0 - end - end - - @doc """ - Creates a simple vertical scroll bar. - """ - @spec vertical(keyword()) :: map() - def vertical(opts) do - opts - |> Keyword.put(:orientation, :vertical) - |> new() - end - - @doc """ - Creates a simple horizontal scroll bar. - """ - @spec horizontal(keyword()) :: map() - def horizontal(opts) do - opts - |> Keyword.put(:orientation, :horizontal) - |> new() - end -end diff --git a/lib/term_ui/widgets/sparkline.ex b/lib/term_ui/widgets/sparkline.ex deleted file mode 100644 index b86febf7..00000000 --- a/lib/term_ui/widgets/sparkline.ex +++ /dev/null @@ -1,235 +0,0 @@ -defmodule TermUI.Widgets.Sparkline do - @moduledoc """ - Sparkline widget for compact inline trend visualization. - - Uses vertical bar characters (▁▂▃▄▅▆▇█) to display values in minimal space. - Perfect for inline data display within text. - - ## Usage - - Sparkline.render( - values: [1, 3, 5, 2, 8, 4, 6], - min: 0, - max: 10 - ) - - ## Bar Characters - - The sparkline uses 8 levels of vertical bar characters: - ▁ (1/8), ▂ (2/8), ▃ (3/8), ▄ (4/8), ▅ (5/8), ▆ (6/8), ▇ (7/8), █ (8/8) - - ## Monochrome Compatibility - - Sparklines are inherently monochrome-compatible as they use character height - to convey value magnitude. The 8-level bar characters provide clear visual - differentiation without requiring color. Optional `:color_ranges` enhance - readability in color terminals but are not required for functionality. - """ - - import TermUI.Component.RenderNode - alias TermUI.CharacterSet - alias TermUI.Widgets.VisualizationHelper, as: VizHelper - - @doc """ - Renders a sparkline from values. - - ## Options - - - `:values` - List of numeric values (required) - - `:min` - Minimum value for scaling (default: auto) - - `:max` - Maximum value for scaling (default: auto) - - `:style` - Style for the sparkline - - `:color_ranges` - List of {threshold, color} for value-based coloring - """ - @spec render(keyword()) :: TermUI.Component.RenderNode.t() - def render(opts) do - values = Keyword.get(opts, :values, []) - - case VizHelper.validate_number_list(values) do - :ok when values == [] -> - empty() - - :ok -> - do_render(values, opts) - - {:error, _msg} -> - # Return empty for invalid data - empty() - end - end - - defp do_render(values, opts) do - {min, max} = VizHelper.calculate_range(values, opts) - style = Keyword.get(opts, :style) - color_ranges = Keyword.get(opts, :color_ranges, []) - - chars = - values - |> Enum.map(fn value -> - {value_to_bar(value, min, max), value} - end) - - result = - if Enum.empty?(color_ranges) do - render_simple(chars) - else - render_colored(chars, color_ranges) - end - - VizHelper.maybe_style(result, style) - end - - defp render_simple(chars) do - char_list = Enum.map(chars, &elem(&1, 0)) - line = Enum.join(char_list) - text(line) - end - - defp render_colored(chars, color_ranges) do - parts = - Enum.map(chars, fn {char, value} -> - style_char_with_color(char, value, color_ranges) - end) - - stack(:horizontal, parts) - end - - defp style_char_with_color(char, value, color_ranges) do - color = VizHelper.find_zone(value, color_ranges) - node = text(char) - VizHelper.maybe_style(node, color) - end - - @doc """ - Converts a single value to its sparkline bar character. - - ## Examples - - iex> Sparkline.value_to_bar(5, 0, 10) - "▄" - - iex> Sparkline.value_to_bar(10, 0, 10) - "█" - - iex> Sparkline.value_to_bar(0, 0, 10) - "▁" - """ - @spec value_to_bar(number(), number(), number()) :: String.t() - def value_to_bar(value, min, max) when is_number(value) and is_number(min) and is_number(max) do - bars = bar_characters() - bar_count = length(bars) - - if max > min do - # Normalize value to 0-1 range - normalized = VizHelper.normalize(value, min, max) - - # Map to bar index (0 to bar_count - 1) - index = round(normalized * (bar_count - 1)) - Enum.at(bars, index) - else - # When min == max, return middle bar - Enum.at(bars, div(bar_count, 2)) - end - end - - def value_to_bar(_value, _min, _max) do - # Invalid input, return middle bar - bars = bar_characters() - Enum.at(bars, div(length(bars), 2)) - end - - @doc """ - Returns the list of bar characters used by sparklines. - Uses CharacterSet for proper ASCII/Unicode degradation. - """ - @spec bar_characters() :: [String.t()] - def bar_characters do - CharacterSet.current_charset().sparkline_levels - end - - @doc """ - Creates a sparkline string from values (returns string, not render node). - - ## Options - - - `:min` - Minimum value (default: auto) - - `:max` - Maximum value (default: auto) - """ - @spec to_sparkline([number()], keyword()) :: String.t() - def to_sparkline(values, opts \\ []) - - def to_sparkline([], _opts), do: "" - - def to_sparkline(values, opts) when is_list(values) do - case VizHelper.validate_number_list(values) do - :ok -> - {min, max} = VizHelper.calculate_range(values, opts) - Enum.map_join(values, "", &value_to_bar(&1, min, max)) - - {:error, _} -> - "" - end - end - - def to_sparkline(_, _), do: "" - - # Keep old name for backward compatibility - @doc false - @spec to_string([number()], keyword()) :: String.t() - def to_string(values, opts \\ []), do: to_sparkline(values, opts) - - @doc """ - Renders a labeled sparkline with min/max indicators. - - ## Options - - - `:values` - List of numeric values (required) - - `:label` - Label for the sparkline - - `:show_range` - Show min/max values (default: true) - """ - @spec render_labeled(keyword()) :: TermUI.Component.RenderNode.t() - def render_labeled(opts) do - values = Keyword.get(opts, :values, []) - label = Keyword.get(opts, :label, "") - show_range = Keyword.get(opts, :show_range, true) - - case VizHelper.validate_number_list(values) do - :ok when values == [] -> - empty() - - :ok -> - {min, max} = VizHelper.calculate_range(values) - sparkline = to_sparkline(values, min: min, max: max) - - parts = [] - - parts = - if label != "" do - [text(label <> " ") | parts] - else - parts - end - - parts = - if show_range do - [text(VizHelper.format_number(min) <> " ") | parts] - else - parts - end - - parts = [text(sparkline) | parts] - - parts = - if show_range do - [text(" " <> VizHelper.format_number(max)) | parts] - else - parts - end - - stack(:horizontal, Enum.reverse(parts)) - - {:error, _} -> - empty() - end - end -end diff --git a/lib/term_ui/widgets/split_pane.ex b/lib/term_ui/widgets/split_pane.ex deleted file mode 100644 index bf64c2a5..00000000 --- a/lib/term_ui/widgets/split_pane.ex +++ /dev/null @@ -1,969 +0,0 @@ -defmodule TermUI.Widgets.SplitPane do - @moduledoc """ - SplitPane widget for resizable multi-pane layouts. - - SplitPane divides space between two or more panes with draggable dividers, - enabling complex layouts like IDE editors with sidebars and bottom panels. - - ## Usage - - SplitPane.new( - orientation: :horizontal, - panes: [ - %{id: :left, content: sidebar(), size: 0.25, min_size: 10}, - %{id: :right, content: main_content(), size: 0.75} - ] - ) - - ## Features - - - Horizontal and vertical split orientations - - Draggable dividers (keyboard and mouse) - - Min/max size constraints per pane - - Collapsible panes - - Nested splits for complex layouts - - Layout state persistence - - ## Keyboard Controls - - ### With Focused Divider (use Tab to focus) - - - Tab: Move focus between dividers - - Left/Up: Move divider left/up (decrease pane before) - - Right/Down: Move divider right/down (increase pane before) - - Shift+Left/Up: Move divider by larger step - - Shift+Right/Down: Move divider by larger step - - Enter: Toggle collapse of pane after divider - - Home: Move divider to minimum position - - End: Move divider to maximum position - - ### Without Focused Divider (TTY-friendly) - - - Ctrl+Left: Decrease first pane width (horizontal split) - - Ctrl+Right: Increase first pane width (horizontal split) - - Ctrl+Up: Decrease first pane height (vertical split) - - Ctrl+Down: Increase first pane height (vertical split) - - These Ctrl+arrow shortcuts always target the first divider, making them - useful in TTY mode where mouse interaction may not be available. - """ - - use TermUI.StatefulComponent - - alias TermUI.CharacterSet - alias TermUI.Event - alias TermUI.Theme - - @type orientation :: :horizontal | :vertical - - @type pane_spec :: %{ - id: term(), - content: term(), - size: number(), - min_size: non_neg_integer() | nil, - max_size: non_neg_integer() | nil, - collapsed: boolean() - } - - @type pane :: %{ - id: term(), - content: term(), - size: number(), - min_size: non_neg_integer() | nil, - max_size: non_neg_integer() | nil, - collapsed: boolean(), - computed_size: non_neg_integer() - } - - @resize_step 1 - @large_resize_step 5 - - # Dialyzer: Functions return specific map types - @dialyzer {:nowarn_function, new: 1, collapse: 2, expand: 2, toggle: 2, set_content: 3} - - # ---------------------------------------------------------------------------- - # Pane Constructors - # ---------------------------------------------------------------------------- - - @doc """ - Creates a pane specification. - - ## Options - - - `:size` - Size as float (0.0-1.0 proportion) or integer (fixed chars/lines) - - `:min_size` - Minimum size in characters/lines - - `:max_size` - Maximum size in characters/lines - - `:collapsed` - Whether pane starts collapsed (default: false) - """ - @spec pane(term(), term(), keyword()) :: pane_spec() - def pane(id, content, opts \\ []) do - %{ - id: id, - content: content, - size: Keyword.get(opts, :size, 1.0), - min_size: Keyword.get(opts, :min_size), - max_size: Keyword.get(opts, :max_size), - collapsed: Keyword.get(opts, :collapsed, false) - } - end - - # ---------------------------------------------------------------------------- - # Props - # ---------------------------------------------------------------------------- - - # Default configuration for Ctrl+arrow resize - @default_ctrl_resize_step 0.05 - @default_min_ratio 0.1 - @default_max_ratio 0.9 - - @doc """ - Creates new SplitPane widget props. - - ## Options - - - `:orientation` - `:horizontal` (side by side) or `:vertical` (stacked) (default: :horizontal) - - `:panes` - List of pane specifications (required) - - `:divider_size` - Divider thickness in characters (default: 1) - - `:divider_style` - Style for dividers - - `:focused_divider_style` - Style for focused divider - - `:resizable` - Whether dividers can be dragged (default: true) - - `:on_resize` - Callback when panes are resized: `fn panes -> ... end` - - `:on_collapse` - Callback when pane is collapsed/expanded: `fn {id, collapsed} -> ... end` - - `:persist_key` - Key for layout persistence (optional) - - `:ctrl_resize_step` - Step size for Ctrl+arrow resize as ratio 0.0-1.0 (default: 0.05 = 5%) - - `:min_ratio` - Minimum ratio for first pane when using Ctrl+arrows (default: 0.1 = 10%) - - `:max_ratio` - Maximum ratio for first pane when using Ctrl+arrows (default: 0.9 = 90%) - """ - @spec new(keyword()) :: map() - def new(opts) do - # Validate and normalize Ctrl+arrow resize configuration - {ctrl_resize_step, min_ratio, max_ratio} = - validate_resize_config( - Keyword.get(opts, :ctrl_resize_step, @default_ctrl_resize_step), - Keyword.get(opts, :min_ratio, @default_min_ratio), - Keyword.get(opts, :max_ratio, @default_max_ratio) - ) - - %{ - orientation: Keyword.get(opts, :orientation, :horizontal), - panes: Keyword.fetch!(opts, :panes), - divider_size: Keyword.get(opts, :divider_size, 1), - divider_style: Keyword.get(opts, :divider_style), - focused_divider_style: Keyword.get(opts, :focused_divider_style), - resizable: Keyword.get(opts, :resizable, true), - on_resize: Keyword.get(opts, :on_resize), - on_collapse: Keyword.get(opts, :on_collapse), - persist_key: Keyword.get(opts, :persist_key), - ctrl_resize_step: ctrl_resize_step, - min_ratio: min_ratio, - max_ratio: max_ratio - } - end - - # Validates and normalizes resize configuration options. - # Returns defaults if values are invalid. - @spec validate_resize_config(term(), term(), term()) :: {float(), float(), float()} - defp validate_resize_config(step, min_r, max_r) do - step = validate_resize_step(step) - min_r = validate_min_ratio(min_r) - max_r = validate_max_ratio(max_r) - - validate_ratio_order(step, min_r, max_r) - end - - defp validate_resize_step(step) do - if is_number(step) and step > 0 and step <= 1.0, do: step, else: @default_ctrl_resize_step - end - - defp validate_min_ratio(min_r) do - if is_number(min_r) and min_r >= 0.0 and min_r < 1.0, do: min_r, else: @default_min_ratio - end - - defp validate_max_ratio(max_r) do - if is_number(max_r) and max_r > 0.0 and max_r <= 1.0, do: max_r, else: @default_max_ratio - end - - defp validate_ratio_order(step, min_r, max_r) do - if min_r >= max_r do - {@default_ctrl_resize_step, @default_min_ratio, @default_max_ratio} - else - {step, min_r, max_r} - end - end - - # ---------------------------------------------------------------------------- - # StatefulComponent Callbacks - # ---------------------------------------------------------------------------- - - @impl true - def init(props) do - panes = - props.panes - |> Enum.map(fn pane_spec -> - Map.merge(pane_spec, %{computed_size: 0}) - end) - - # Set theme-based default styles if not provided - divider_style = props.divider_style || Theme.get_component_style(:divider, :normal) - - focused_divider_style = - props.focused_divider_style || Theme.get_component_style(:divider, :focused) - - state = %{ - orientation: props.orientation, - panes: panes, - divider_size: props.divider_size, - divider_style: divider_style, - focused_divider_style: focused_divider_style, - resizable: props.resizable, - focused_divider: nil, - dragging: false, - drag_start: nil, - drag_divider: nil, - on_resize: props.on_resize, - on_collapse: props.on_collapse, - persist_key: props.persist_key, - # Ctrl+arrow resize configuration (validated in new/1) - ctrl_resize_step: props.ctrl_resize_step, - min_ratio: props.min_ratio, - max_ratio: props.max_ratio, - # Will be set on first render - total_size: 0, - last_area: nil - } - - {:ok, state} - end - - @impl true - def handle_event(%Event.Key{key: :tab, modifiers: modifiers}, state) when state.resizable do - if :shift in modifiers do - handle_shift_tab(state) - else - handle_tab(state) - end - end - - # Arrow keys for resizing (focused divider) - def handle_event(%Event.Key{key: key, modifiers: modifiers}, state) - when key in [:left, :up] and state.focused_divider != nil and state.resizable do - step = if :shift in modifiers, do: @large_resize_step, else: @resize_step - move_divider(state, state.focused_divider, -step) - end - - def handle_event(%Event.Key{key: key, modifiers: modifiers}, state) - when key in [:right, :down] and state.focused_divider != nil and state.resizable do - step = if :shift in modifiers, do: @large_resize_step, else: @resize_step - move_divider(state, state.focused_divider, step) - end - - # Ctrl+Arrow keys for resizing (no focus required - targets first divider) - # Useful in TTY mode where mouse click to focus divider may not be available - def handle_event(%Event.Key{key: key, modifiers: modifiers}, state) - when key in [:left, :up] and state.focused_divider == nil and state.resizable do - if :ctrl in modifiers do - # Ctrl+Left/Up: decrease first pane size - move_divider_by_ratio(state, 0, -state.ctrl_resize_step) - else - {:ok, state} - end - end - - def handle_event(%Event.Key{key: key, modifiers: modifiers}, state) - when key in [:right, :down] and state.focused_divider == nil and state.resizable do - if :ctrl in modifiers do - # Ctrl+Right/Down: increase first pane size - move_divider_by_ratio(state, 0, state.ctrl_resize_step) - else - {:ok, state} - end - end - - # Home/End for min/max positions - def handle_event(%Event.Key{key: :home}, state) - when state.focused_divider != nil and state.resizable do - move_divider_to_min(state, state.focused_divider) - end - - def handle_event(%Event.Key{key: :end}, state) - when state.focused_divider != nil and state.resizable do - move_divider_to_max(state, state.focused_divider) - end - - # Enter to toggle collapse - def handle_event(%Event.Key{key: :enter}, state) when state.focused_divider != nil do - toggle_collapse(state, state.focused_divider + 1) - end - - # Mouse click on divider - def handle_event(%Event.Mouse{action: :click, x: x, y: y}, state) when state.resizable do - case divider_at(state, x, y) do - nil -> - {:ok, %{state | focused_divider: nil}} - - divider_index -> - pos = if state.orientation == :horizontal, do: x, else: y - - {:ok, - %{ - state - | focused_divider: divider_index, - dragging: true, - drag_start: pos, - drag_divider: divider_index - }} - end - end - - # Mouse drag - def handle_event(%Event.Mouse{action: :drag, x: x, y: y}, state) - when state.dragging and state.resizable do - pos = if state.orientation == :horizontal, do: x, else: y - delta = pos - state.drag_start - state = %{state | drag_start: pos} - move_divider(state, state.drag_divider, delta) - end - - # Mouse release - def handle_event(%Event.Mouse{action: :release}, state) do - {:ok, %{state | dragging: false, drag_start: nil, drag_divider: nil}} - end - - # Double-click to toggle collapse - def handle_event(%Event.Mouse{action: :double_click, x: x, y: y}, state) do - case divider_at(state, x, y) do - nil -> {:ok, state} - divider_index -> toggle_collapse(state, divider_index + 1) - end - end - - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, area) do - # Recalculate sizes based on current area - state = compute_pane_sizes(state, area) - - case state.orientation do - :horizontal -> render_horizontal(state, area) - :vertical -> render_vertical(state, area) - end - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Gets the current layout state for persistence. - - Returns a map of pane IDs to their sizes and collapsed states. - """ - @spec get_layout(map()) :: map() - def get_layout(state) do - state.panes - |> Enum.map(fn pane -> - {pane.id, %{size: pane.size, collapsed: pane.collapsed}} - end) - |> Map.new() - end - - @doc """ - Restores layout from a saved state. - """ - @spec set_layout(map(), map()) :: map() - def set_layout(state, layout) do - panes = - Enum.map(state.panes, fn pane -> - case Map.get(layout, pane.id) do - nil -> - pane - - saved -> - %{ - pane - | size: Map.get(saved, :size, pane.size), - collapsed: Map.get(saved, :collapsed, pane.collapsed) - } - end - end) - - %{state | panes: panes} - end - - @doc """ - Collapses a pane by ID. - """ - @spec collapse(map(), term()) :: map() - def collapse(state, pane_id) do - update_pane(state, pane_id, fn pane -> %{pane | collapsed: true} end) - end - - @doc """ - Expands a collapsed pane by ID. - """ - @spec expand(map(), term()) :: map() - def expand(state, pane_id) do - update_pane(state, pane_id, fn pane -> %{pane | collapsed: false} end) - end - - @doc """ - Toggles collapse state of a pane by ID. - """ - @spec toggle(map(), term()) :: map() - def toggle(state, pane_id) do - update_pane(state, pane_id, fn pane -> %{pane | collapsed: not pane.collapsed} end) - end - - @doc """ - Sets the size of a pane by ID. - """ - @spec set_pane_size(map(), term(), number()) :: map() - def set_pane_size(state, pane_id, size) do - update_pane(state, pane_id, fn pane -> %{pane | size: size} end) - end - - @doc """ - Gets a list of pane IDs. - """ - @spec get_pane_ids(map()) :: [term()] - def get_pane_ids(state) do - Enum.map(state.panes, & &1.id) - end - - @doc """ - Gets the focused divider index (0-indexed), or nil if none focused. - """ - @spec get_focused_divider(map()) :: non_neg_integer() | nil - def get_focused_divider(state) do - state.focused_divider - end - - @doc """ - Updates content of a pane by ID. - """ - @spec set_content(map(), term(), term()) :: map() - def set_content(state, pane_id, content) do - update_pane(state, pane_id, fn pane -> %{pane | content: content} end) - end - - # ---------------------------------------------------------------------------- - # Private: Size Calculation - # ---------------------------------------------------------------------------- - - defp compute_pane_sizes(state, area) do - total_size = - if state.orientation == :horizontal do - area.width - else - area.height - end - - num_dividers = length(state.panes) - 1 - divider_space = num_dividers * state.divider_size - available_space = max(0, total_size - divider_space) - - # Separate collapsed and visible panes - {visible_panes, collapsed_indices} = - state.panes - |> Enum.with_index() - |> Enum.reduce({[], []}, fn {pane, idx}, {visible, collapsed} -> - if pane.collapsed do - {visible, [idx | collapsed]} - else - {[{pane, idx} | visible], collapsed} - end - end) - - visible_panes = Enum.reverse(visible_panes) - collapsed_indices = Enum.reverse(collapsed_indices) - - # Calculate sizes for visible panes - computed_sizes = distribute_space(visible_panes, available_space) - - # Build final panes list with computed sizes - panes = - state.panes - |> Enum.with_index() - |> Enum.map(fn {pane, idx} -> - compute_pane_size(pane, idx, collapsed_indices, visible_panes, computed_sizes) - end) - - %{state | panes: panes, total_size: total_size, last_area: area} - end - - defp compute_pane_size(pane, idx, collapsed_indices, visible_panes, computed_sizes) do - if idx in collapsed_indices do - %{pane | computed_size: 0} - else - visible_idx = Enum.find_index(visible_panes, fn {_, i} -> i == idx end) - size = Enum.at(computed_sizes, visible_idx, 0) - %{pane | computed_size: size} - end - end - - defp distribute_space([], _available), do: [] - - defp distribute_space(visible_panes, available_space) do - # First pass: calculate proportional sizes - total_proportion = - visible_panes - |> Enum.map(fn {pane, _} -> normalize_size(pane.size) end) - |> Enum.sum() - - total_proportion = max(total_proportion, 0.001) - - initial_sizes = - Enum.map(visible_panes, fn {pane, _} -> - proportion = normalize_size(pane.size) / total_proportion - round(available_space * proportion) - end) - - # Second pass: apply min/max constraints - constrained_sizes = - visible_panes - |> Enum.zip(initial_sizes) - |> Enum.map(fn {{pane, _}, size} -> - size - |> apply_min_constraint(pane.min_size) - |> apply_max_constraint(pane.max_size) - end) - - # Third pass: redistribute any remaining space - total_assigned = Enum.sum(constrained_sizes) - remaining = available_space - total_assigned - - if remaining != 0 and length(constrained_sizes) > 0 do - redistribute_space(visible_panes, constrained_sizes, remaining) - else - constrained_sizes - end - end - - defp normalize_size(size) when is_float(size) and size > 0 and size <= 1, do: size - defp normalize_size(size) when is_integer(size) and size > 0, do: size / 100.0 - defp normalize_size(_), do: 1.0 - - defp apply_min_constraint(size, nil), do: size - defp apply_min_constraint(size, min_size), do: max(size, min_size) - - defp apply_max_constraint(size, nil), do: size - defp apply_max_constraint(size, max_size), do: min(size, max_size) - - defp redistribute_space(visible_panes, sizes, remaining) do - flexible_indices = find_flexible_indices(visible_panes, sizes, remaining) - - if flexible_indices == [] do - sizes - else - redistribute_to_flexible_panes(sizes, flexible_indices, remaining) - end - end - - defp find_flexible_indices(visible_panes, sizes, remaining) do - visible_panes - |> Enum.with_index() - |> Enum.filter(fn {{pane, _}, idx} -> - pane_is_flexible?(pane, Enum.at(sizes, idx), remaining) - end) - |> Enum.map(fn {_, idx} -> idx end) - end - - defp pane_is_flexible?(pane, current_size, remaining) do - cond do - remaining > 0 -> pane.max_size == nil or current_size < pane.max_size - remaining < 0 -> pane.min_size == nil or current_size > pane.min_size - true -> false - end - end - - defp redistribute_to_flexible_panes(sizes, flexible_indices, remaining) do - flexible_count = length(flexible_indices) - per_pane = div(remaining, flexible_count) - leftover = rem(remaining, flexible_count) - - sizes - |> Enum.with_index() - |> Enum.map(fn {size, idx} -> - redistribute_pane_size(size, idx, flexible_indices, per_pane, leftover) - end) - end - - defp redistribute_pane_size(size, idx, flexible_indices, per_pane, leftover) do - if idx in flexible_indices do - extra = calculate_extra_for_pane(idx, flexible_indices, per_pane, leftover) - max(0, size + extra) - else - size - end - end - - defp calculate_extra_for_pane(idx, flexible_indices, per_pane, leftover) do - if idx == hd(flexible_indices), do: per_pane + leftover, else: per_pane - end - - # ---------------------------------------------------------------------------- - # Private: Rendering - # ---------------------------------------------------------------------------- - - defp render_horizontal(state, area) do - children = build_children(state, area, &render_vertical_divider/3, area.height) - stack(:horizontal, children) - end - - defp render_vertical(state, area) do - children = build_children(state, area, &render_horizontal_divider/3, area.width) - stack(:vertical, children) - end - - # Consolidated child building - parameterized by divider renderer and size - @spec build_children(map(), map(), function(), non_neg_integer()) :: [term()] - defp build_children(state, area, divider_fn, divider_size) do - pane_count = length(state.panes) - - state.panes - |> Enum.with_index() - |> Enum.flat_map(fn {pane, idx} -> - pane_element = - if pane.collapsed do - [] - else - [render_pane_content(pane, area, state.orientation)] - end - - # Add divider after each pane except the last - divider_element = - if idx < pane_count - 1 do - [divider_fn.(state, idx, divider_size)] - else - [] - end - - pane_element ++ divider_element - end) - end - - defp render_pane_content(pane, area, orientation) do - # Create a container with the computed size - pane_area = - if orientation == :horizontal do - %{area | width: pane.computed_size} - else - %{area | height: pane.computed_size} - end - - # Wrap content in a box with the pane's size - # Content can be a render node or needs to be wrapped - content = wrap_content(pane.content) - box(content, width: pane_area.width, height: pane_area.height) - end - - defp wrap_content(content) when is_list(content), do: content - defp wrap_content(%RenderNode{} = content), do: [content] - defp wrap_content(content) when is_binary(content), do: [text(content)] - defp wrap_content(content), do: [content] - - defp render_vertical_divider(state, divider_idx, height) do - chars = CharacterSet.current_charset() - is_focused = state.focused_divider == divider_idx - style = if is_focused, do: state.focused_divider_style, else: state.divider_style - char = if is_focused, do: chars.v_line_heavy, else: chars.v_line - - lines = - for _ <- 1..height do - char - end - - text(Enum.join(lines, "\n"), style) - end - - defp render_horizontal_divider(state, divider_idx, width) do - chars = CharacterSet.current_charset() - is_focused = state.focused_divider == divider_idx - style = if is_focused, do: state.focused_divider_style, else: state.divider_style - char = if is_focused, do: chars.h_line_heavy, else: chars.h_line - - text(String.duplicate(char, width), style) - end - - # ---------------------------------------------------------------------------- - # Private: Divider Movement - # ---------------------------------------------------------------------------- - - # Moves divider by a ratio (0.0-1.0) of total space, enforcing min/max ratio bounds. - # Used by Ctrl+arrow shortcuts. - @spec move_divider_by_ratio(map(), non_neg_integer(), float()) :: {:ok, map()} - defp move_divider_by_ratio(state, divider_idx, ratio_delta) do - pane_before = Enum.at(state.panes, divider_idx) - pane_after = Enum.at(state.panes, divider_idx + 1) - - cond do - is_nil(pane_before) or is_nil(pane_after) -> - {:ok, state} - - pane_before.collapsed or pane_after.collapsed -> - {:ok, state} - - true -> - apply_ratio_resize(state, divider_idx, pane_before, pane_after, ratio_delta) - end - end - - @spec apply_ratio_resize(map(), non_neg_integer(), pane(), pane(), float()) :: {:ok, map()} - defp apply_ratio_resize(state, divider_idx, pane_before, pane_after, ratio_delta) do - total_ratio = pane_before.size + pane_after.size - - # Guard against division by zero - if total_ratio <= 0 do - {:ok, state} - else - current_ratio = pane_before.size / total_ratio - new_ratio = current_ratio + ratio_delta - clamped_ratio = new_ratio |> max(state.min_ratio) |> min(state.max_ratio) - - if clamped_ratio == current_ratio do - {:ok, state} - else - panes = update_pane_ratios(state.panes, divider_idx, clamped_ratio, total_ratio) - maybe_call_resize_callback(%{state | panes: panes}) - end - end - end - - @spec update_pane_ratios([pane()], non_neg_integer(), float(), float()) :: [pane()] - defp update_pane_ratios(panes, divider_idx, new_ratio, total_ratio) do - panes - |> Enum.with_index() - |> Enum.map(fn - {pane, idx} when idx == divider_idx -> - %{pane | size: new_ratio * total_ratio} - - {pane, idx} when idx == divider_idx + 1 -> - %{pane | size: (1.0 - new_ratio) * total_ratio} - - {pane, _idx} -> - pane - end) - end - - defp move_divider(state, divider_idx, delta) do - pane_before = Enum.at(state.panes, divider_idx) - pane_after = Enum.at(state.panes, divider_idx + 1) - - if can_move_divider?(pane_before, pane_after) do - {size_before, size_after} = get_pane_sizes(state, pane_before, pane_after) - {new_size_before, new_size_after} = {size_before + delta, size_after - delta} - - {final_before, final_after} = - apply_resize_constraints(pane_before, pane_after, new_size_before, new_size_after) - - if final_before != size_before do - update_pane_sizes(state, divider_idx, final_before, final_after) - else - {:ok, state} - end - else - {:ok, state} - end - end - - defp can_move_divider?(pane_before, pane_after) do - pane_before && pane_after && not pane_before.collapsed && not pane_after.collapsed - end - - defp get_pane_sizes(state, pane_before, pane_after) do - if pane_before.computed_size == 0 and pane_after.computed_size == 0 do - total = if state.total_size > 0, do: state.total_size, else: 100 - {round(pane_before.size * total), round(pane_after.size * total)} - else - {pane_before.computed_size, pane_after.computed_size} - end - end - - defp update_pane_sizes(state, divider_idx, final_before, final_after) do - total = final_before + final_after - - panes = - state.panes - |> Enum.with_index() - |> Enum.map(fn {pane, idx} -> - cond do - idx == divider_idx -> %{pane | size: final_before / max(total, 1)} - idx == divider_idx + 1 -> %{pane | size: final_after / max(total, 1)} - true -> pane - end - end) - - state = %{state | panes: panes} - maybe_call_resize_callback(state) - end - - defp apply_resize_constraints(pane_before, pane_after, size_before, size_after) do - # Apply min constraints - size_before = apply_min_constraint(size_before, pane_before.min_size) - size_after = apply_min_constraint(size_after, pane_after.min_size) - - # Apply max constraints - size_before = apply_max_constraint(size_before, pane_before.max_size) - size_after = apply_max_constraint(size_after, pane_after.max_size) - - # Ensure neither goes negative - size_before = max(0, size_before) - size_after = max(0, size_after) - - {size_before, size_after} - end - - defp move_divider_to_min(state, divider_idx) do - pane_before = Enum.at(state.panes, divider_idx) - - if pane_before && not pane_before.collapsed do - min_size = pane_before.min_size || 1 - delta = min_size - pane_before.computed_size - move_divider(state, divider_idx, delta) - else - {:ok, state} - end - end - - defp move_divider_to_max(state, divider_idx) do - pane_after = Enum.at(state.panes, divider_idx + 1) - - if pane_after && not pane_after.collapsed do - min_size = pane_after.min_size || 1 - delta = pane_after.computed_size - min_size - move_divider(state, divider_idx, delta) - else - {:ok, state} - end - end - - # ---------------------------------------------------------------------------- - # Private: Collapse - # ---------------------------------------------------------------------------- - - defp toggle_collapse(state, pane_idx) do - pane = Enum.at(state.panes, pane_idx) - - if pane do - panes = - List.update_at(state.panes, pane_idx, fn p -> - %{p | collapsed: not p.collapsed} - end) - - state = %{state | panes: panes} - - if state.on_collapse do - state.on_collapse.({pane.id, not pane.collapsed}) - end - - {:ok, state} - else - {:ok, state} - end - end - - # ---------------------------------------------------------------------------- - # Private: Mouse Hit Testing - # ---------------------------------------------------------------------------- - - defp divider_at(state, x, y) do - pos = if state.orientation == :horizontal, do: x, else: y - find_divider_at_position(state, pos) - end - - # Consolidated divider hit testing - works for both orientations - @spec find_divider_at_position(map(), integer()) :: non_neg_integer() | nil - defp find_divider_at_position(state, pos) do - {_, result} = - state.panes - |> Enum.take(length(state.panes) - 1) - |> Enum.with_index() - |> Enum.reduce({0, nil}, fn {pane, idx}, {cumulative_pos, found} -> - pane_end = cumulative_pos + pane.computed_size - divider_start = pane_end - divider_end = divider_start + state.divider_size - - if found == nil && pos >= divider_start && pos < divider_end do - {divider_end, idx} - else - {divider_end, found} - end - end) - - result - end - - # ---------------------------------------------------------------------------- - # Private: Tab Navigation - # ---------------------------------------------------------------------------- - - defp handle_tab(state) do - num_dividers = length(state.panes) - 1 - - if num_dividers > 0 do - next_divider = - case state.focused_divider do - nil -> 0 - n when n >= num_dividers - 1 -> nil - n -> n + 1 - end - - {:ok, %{state | focused_divider: next_divider}} - else - {:ok, state} - end - end - - defp handle_shift_tab(state) do - num_dividers = length(state.panes) - 1 - - if num_dividers > 0 do - prev_divider = - case state.focused_divider do - nil -> num_dividers - 1 - 0 -> nil - n -> n - 1 - end - - {:ok, %{state | focused_divider: prev_divider}} - else - {:ok, state} - end - end - - # ---------------------------------------------------------------------------- - # Private: Helpers - # ---------------------------------------------------------------------------- - - defp update_pane(state, pane_id, update_fn) do - panes = - Enum.map(state.panes, fn pane -> - if pane.id == pane_id do - update_fn.(pane) - else - pane - end - end) - - %{state | panes: panes} - end - - defp maybe_call_resize_callback(state) do - if state.on_resize do - sizes = Enum.map(state.panes, fn p -> {p.id, p.size} end) - - try do - state.on_resize.(sizes) - rescue - e -> - require Logger - Logger.error("SplitPane on_resize callback error: #{inspect(e)}") - end - end - - {:ok, state} - end -end diff --git a/lib/term_ui/widgets/stream_widget.ex b/lib/term_ui/widgets/stream_widget.ex deleted file mode 100644 index 0749030f..00000000 --- a/lib/term_ui/widgets/stream_widget.ex +++ /dev/null @@ -1,693 +0,0 @@ -defmodule TermUI.Widgets.StreamWidget do - @moduledoc """ - StreamWidget for displaying backpressure-aware streaming data. - - StreamWidget can integrate with GenStage for demand-based data streaming, - providing controls for stream management and real-time statistics. - - ## Usage - - StreamWidget.new( - buffer_size: 1000, - overflow_strategy: :drop_oldest - ) - - ## Features - - - Backpressure-aware data streaming via GenStage integration - - Demand-based flow control - - Buffer management with configurable overflow strategies - - Pause/resume stream controls - - Rate limiting for rendering - - Real-time stream statistics (items/sec) - - ## Keyboard Controls - - - Space: Toggle pause/resume - - c: Clear buffer - - s: Toggle stats display - - Up/Down: Scroll through buffer - - PageUp/PageDown: Scroll by page - - Home/End: Jump to first/last item - - ## GenStage Integration - - The widget provides a companion consumer module that can be started - separately and sends items to the widget: - - {:ok, consumer} = StreamWidget.Consumer.start_link(widget_pid) - GenStage.sync_subscribe(consumer, to: producer) - """ - - use TermUI.StatefulComponent - - alias TermUI.Event - - @type overflow_strategy :: :drop_oldest | :drop_newest | :block | :sliding - - @type stream_state :: :idle | :running | :paused | :error - - @type stream_item :: %{ - id: non_neg_integer(), - timestamp: DateTime.t(), - data: any(), - metadata: map() - } - - @type stats :: %{ - items_received: non_neg_integer(), - items_dropped: non_neg_integer(), - items_per_second: float(), - buffer_size: non_neg_integer(), - buffer_capacity: non_neg_integer(), - last_update: DateTime.t() | nil - } - - # Dialyzer: Functions return specific struct types or specific map types - @dialyzer {:nowarn_function, - new: 1, add_item: 2, pause: 1, resume: 1, clear: 1, set_overflow_strategy: 2} - - @default_buffer_size 1000 - @default_demand 10 - @page_size 20 - @stats_window_ms 5000 - - # ---------------------------------------------------------------------------- - # Props - # ---------------------------------------------------------------------------- - - @doc """ - Creates new StreamWidget props. - - ## Options - - - `:buffer_size` - Maximum items in buffer (default: 1000) - - `:overflow_strategy` - What to do when buffer is full (default: :drop_oldest) - - `:demand` - How many items to request at a time (default: 10) - - `:show_stats` - Display statistics bar (default: true) - - `:render_rate_ms` - Minimum time between renders (default: 100) - - `:item_renderer` - Function to render each item (fn item -> String.t) - - `:on_item` - Callback when item is received - - `:on_error` - Callback when error occurs - """ - @spec new(keyword()) :: map() - def new(opts) do - %{ - buffer_size: Keyword.get(opts, :buffer_size, @default_buffer_size), - overflow_strategy: Keyword.get(opts, :overflow_strategy, :drop_oldest), - demand: Keyword.get(opts, :demand, @default_demand), - show_stats: Keyword.get(opts, :show_stats, true), - render_rate_ms: Keyword.get(opts, :render_rate_ms, 100), - item_renderer: Keyword.get(opts, :item_renderer, &default_item_renderer/1), - on_item: Keyword.get(opts, :on_item), - on_error: Keyword.get(opts, :on_error) - } - end - - defp default_item_renderer(item) do - case item do - %{data: data} when is_binary(data) -> data - %{data: data} -> inspect(data, limit: 50) - data when is_binary(data) -> data - data -> inspect(data, limit: 50) - end - end - - # ---------------------------------------------------------------------------- - # StatefulComponent Callbacks - # ---------------------------------------------------------------------------- - - @impl true - def init(props) do - state = %{ - # Buffer - buffer: :queue.new(), - buffer_count: 0, - buffer_size: props.buffer_size, - overflow_strategy: props.overflow_strategy, - - # Demand management - demand: props.demand, - pending_demand: 0, - consumer_pid: nil, - - # Stream state - stream_state: :idle, - paused: false, - - # Stats - stats: %{ - items_received: 0, - items_dropped: 0, - items_per_second: 0.0, - buffer_size: 0, - buffer_capacity: props.buffer_size, - last_update: nil - }, - stats_window: [], - show_stats: props.show_stats, - - # Rendering - scroll_offset: 0, - cursor: 0, - render_rate_ms: props.render_rate_ms, - last_render_time: nil, - pending_render: false, - item_renderer: props.item_renderer, - - # Callbacks - on_item: props.on_item, - on_error: props.on_error, - - # Viewport - viewport_height: 20, - viewport_width: 80, - last_area: nil, - - # Item ID counter - next_id: 0 - } - - {:ok, state} - end - - # ---------------------------------------------------------------------------- - # Event Handling - # ---------------------------------------------------------------------------- - - @impl true - def handle_event(%Event.Key{key: :up}, state) do - move_cursor(state, -1) - end - - def handle_event(%Event.Key{key: :down}, state) do - move_cursor(state, 1) - end - - def handle_event(%Event.Key{key: :page_up}, state) do - move_cursor(state, -@page_size) - end - - def handle_event(%Event.Key{key: :page_down}, state) do - move_cursor(state, @page_size) - end - - def handle_event(%Event.Key{key: :home}, state) do - {:ok, %{state | cursor: 0, scroll_offset: 0}} - end - - def handle_event(%Event.Key{key: :end}, state) do - last = max(0, state.buffer_count - 1) - scroll = max(0, state.buffer_count - state.viewport_height) - {:ok, %{state | cursor: last, scroll_offset: scroll}} - end - - # Space - toggle pause/resume - def handle_event(%Event.Key{char: " "}, state) do - toggle_pause(state) - end - - # c - clear buffer - def handle_event(%Event.Key{char: "c"}, state) do - do_clear(state) - end - - # s - toggle stats - def handle_event(%Event.Key{char: "s"}, state) do - {:ok, %{state | show_stats: not state.show_stats}} - end - - def handle_event(_event, state) do - {:ok, state} - end - - # ---------------------------------------------------------------------------- - # Message Handling - # ---------------------------------------------------------------------------- - - @impl true - def handle_info({:stream_items, items}, state) when is_list(items) do - handle_items(state, items) - end - - def handle_info({:stream_item, item}, state) do - handle_items(state, [item]) - end - - def handle_info({:consumer_started, pid}, state) do - {:ok, %{state | consumer_pid: pid, stream_state: :running}} - end - - def handle_info({:consumer_stopped, _reason}, state) do - if state.on_error do - state.on_error.(:consumer_stopped) - end - - {:ok, %{state | consumer_pid: nil, stream_state: :idle}} - end - - def handle_info({:request_demand, demand}, state) do - # Consumer is requesting to know how much demand we want - if state.consumer_pid do - send(state.consumer_pid, {:set_demand, calculate_demand(state, demand)}) - end - - {:ok, state} - end - - def handle_info(_msg, state) do - {:ok, state} - end - - # ---------------------------------------------------------------------------- - # Item Processing - # ---------------------------------------------------------------------------- - - defp handle_items(state, items) do - now = DateTime.utc_now() - {new_state, items_added, items_dropped} = add_items_to_buffer(state, items, now) - - # Update stats - stats_window = update_stats_window(new_state.stats_window, items_added, now) - items_per_second = calculate_items_per_second(stats_window, now) - - new_stats = %{ - new_state.stats - | items_received: new_state.stats.items_received + items_added, - items_dropped: new_state.stats.items_dropped + items_dropped, - items_per_second: items_per_second, - buffer_size: new_state.buffer_count, - last_update: now - } - - # Call on_item callback for each item - if new_state.on_item do - Enum.each(items, fn item -> new_state.on_item.(item) end) - end - - new_state = %{new_state | stats: new_stats, stats_window: stats_window} - - # Notify consumer about available demand if using block strategy - if new_state.consumer_pid && new_state.overflow_strategy == :block do - demand = calculate_demand(new_state, new_state.demand) - - if demand > 0 do - send(new_state.consumer_pid, {:set_demand, demand}) - end - end - - {:ok, new_state} - end - - # ---------------------------------------------------------------------------- - # Buffer Management - # ---------------------------------------------------------------------------- - - defp add_items_to_buffer(state, items, now) do - Enum.reduce(items, {state, 0, 0}, fn event, {acc_state, added, dropped} -> - item = create_item(event, acc_state.next_id, now) - - case add_to_buffer(acc_state, item) do - {:ok, new_state} -> - {%{new_state | next_id: new_state.next_id + 1}, added + 1, dropped} - - {:dropped, new_state} -> - {%{new_state | next_id: new_state.next_id + 1}, added, dropped + 1} - end - end) - end - - defp create_item(event, id, timestamp) do - %{ - id: id, - timestamp: timestamp, - data: event, - metadata: %{} - } - end - - defp add_to_buffer(state, item) do - if state.buffer_count >= state.buffer_size do - handle_overflow(state, item) - else - new_buffer = :queue.in(item, state.buffer) - {:ok, %{state | buffer: new_buffer, buffer_count: state.buffer_count + 1}} - end - end - - defp handle_overflow(state, item) do - case state.overflow_strategy do - :drop_oldest -> - {{:value, _dropped}, new_buffer} = :queue.out(state.buffer) - new_buffer = :queue.in(item, new_buffer) - {:ok, %{state | buffer: new_buffer}} - - :drop_newest -> - {:dropped, state} - - :block -> - # Don't add item, consumer should stop requesting - {:dropped, state} - - :sliding -> - # Same as drop_oldest - {{:value, _dropped}, new_buffer} = :queue.out(state.buffer) - new_buffer = :queue.in(item, new_buffer) - {:ok, %{state | buffer: new_buffer}} - end - end - - # ---------------------------------------------------------------------------- - # Demand Management - # ---------------------------------------------------------------------------- - - defp calculate_demand(state, requested) do - case state.overflow_strategy do - :block -> - available = state.buffer_size - state.buffer_count - min(available, requested) - - _ -> - requested - end - end - - # ---------------------------------------------------------------------------- - # Stats Calculation - # ---------------------------------------------------------------------------- - - defp update_stats_window(window, items_added, now) do - cutoff = DateTime.add(now, -@stats_window_ms, :millisecond) - - # Remove old entries and add new - window - |> Enum.filter(fn {ts, _count} -> DateTime.compare(ts, cutoff) == :gt end) - |> Kernel.++([{now, items_added}]) - end - - defp calculate_items_per_second(window, now) do - if Enum.empty?(window) do - 0.0 - else - total_items = Enum.reduce(window, 0, fn {_ts, count}, acc -> acc + count end) - {oldest_ts, _} = Enum.min_by(window, fn {ts, _} -> DateTime.to_unix(ts, :millisecond) end) - duration_ms = DateTime.diff(now, oldest_ts, :millisecond) - - if duration_ms > 0 do - total_items / (duration_ms / 1000.0) - else - 0.0 - end - end - end - - # ---------------------------------------------------------------------------- - # Navigation - # ---------------------------------------------------------------------------- - - defp move_cursor(state, delta) do - new_cursor = state.cursor + delta - new_cursor = max(0, min(new_cursor, state.buffer_count - 1)) - - # Adjust scroll if cursor is out of view - new_scroll = - cond do - new_cursor < state.scroll_offset -> - new_cursor - - new_cursor >= state.scroll_offset + state.viewport_height -> - new_cursor - state.viewport_height + 1 - - true -> - state.scroll_offset - end - - {:ok, %{state | cursor: new_cursor, scroll_offset: max(0, new_scroll)}} - end - - # ---------------------------------------------------------------------------- - # Pause/Resume - # ---------------------------------------------------------------------------- - - defp toggle_pause(state) do - new_paused = not state.paused - new_state = %{state | paused: new_paused} - - # Notify consumer - if state.consumer_pid do - if new_paused do - send(state.consumer_pid, :pause) - else - send(state.consumer_pid, :resume) - end - end - - {:ok, new_state} - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Add items directly (for non-GenStage sources). - """ - @spec add_items(map(), [any()]) :: {:ok, map()} - def add_items(state, items) do - handle_items(state, items) - end - - @doc """ - Add a single item directly. - """ - @spec add_item(map(), any()) :: {:ok, map()} - def add_item(state, item) do - add_items(state, [item]) - end - - @doc """ - Pause receiving items. - """ - @spec pause(map()) :: {:ok, map()} - def pause(state) do - if state.consumer_pid do - send(state.consumer_pid, :pause) - end - - {:ok, %{state | paused: true}} - end - - @doc """ - Resume receiving items. - """ - @spec resume(map()) :: {:ok, map()} - def resume(state) do - if state.consumer_pid do - send(state.consumer_pid, :resume) - end - - {:ok, %{state | paused: false}} - end - - @doc """ - Clear the buffer. - """ - @spec clear(map()) :: {:ok, map()} - def clear(state) do - do_clear(state) - end - - defp do_clear(state) do - {:ok, - %{ - state - | buffer: :queue.new(), - buffer_count: 0, - cursor: 0, - scroll_offset: 0, - stats: %{state.stats | buffer_size: 0} - }} - end - - @doc """ - Get current statistics. - """ - @spec get_stats(map()) :: stats() - def get_stats(state) do - state.stats - end - - @doc """ - Set buffer size. Will drop oldest items if new size is smaller. - """ - @spec set_buffer_size(map(), non_neg_integer()) :: {:ok, map()} - def set_buffer_size(state, new_size) when new_size > 0 do - new_state = - if new_size < state.buffer_count do - # Need to drop items - items_to_drop = state.buffer_count - new_size - new_buffer = drop_oldest_n(state.buffer, items_to_drop) - - %{ - state - | buffer: new_buffer, - buffer_count: new_size, - buffer_size: new_size, - stats: %{state.stats | buffer_capacity: new_size, buffer_size: new_size} - } - else - %{state | buffer_size: new_size, stats: %{state.stats | buffer_capacity: new_size}} - end - - {:ok, new_state} - end - - defp drop_oldest_n(queue, 0), do: queue - - defp drop_oldest_n(queue, n) do - case :queue.out(queue) do - {{:value, _}, new_queue} -> drop_oldest_n(new_queue, n - 1) - {:empty, queue} -> queue - end - end - - @doc """ - Set overflow strategy. - """ - @spec set_overflow_strategy(map(), overflow_strategy()) :: {:ok, map()} - def set_overflow_strategy(state, strategy) - when strategy in [:drop_oldest, :drop_newest, :block, :sliding] do - {:ok, %{state | overflow_strategy: strategy}} - end - - @doc """ - Get current buffer count. - """ - @spec buffer_count(map()) :: non_neg_integer() - def buffer_count(state), do: state.buffer_count - - @doc """ - Check if stream is paused. - """ - @spec paused?(map()) :: boolean() - def paused?(state), do: state.paused - - @doc """ - Get stream state. - """ - @spec stream_state(map()) :: stream_state() - def stream_state(state), do: state.stream_state - - @doc """ - Get buffer items as a list. - """ - @spec get_items(map()) :: [stream_item()] - def get_items(state), do: :queue.to_list(state.buffer) - - # ---------------------------------------------------------------------------- - # Rendering - # ---------------------------------------------------------------------------- - - @impl true - def render(state, area) do - # Update viewport dimensions - state = %{ - state - | viewport_height: area.height - if(state.show_stats, do: 2, else: 0), - viewport_width: area.width, - last_area: area - } - - content_height = state.viewport_height - items = get_visible_items(state, content_height) - - # Render items - item_lines = - items - |> Enum.with_index() - |> Enum.map(fn {item, idx} -> - render_item(item, idx + state.scroll_offset, state) - end) - - # Pad with empty lines if needed - item_lines = - if length(item_lines) < content_height do - padding = List.duplicate(text("", nil), content_height - length(item_lines)) - item_lines ++ padding - else - item_lines - end - - # Build render tree - if state.show_stats do - stack(:vertical, item_lines ++ [render_status_bar(state), render_stats_bar(state)]) - else - stack(:vertical, item_lines) - end - end - - defp get_visible_items(state, count) do - state.buffer - |> :queue.to_list() - |> Enum.drop(state.scroll_offset) - |> Enum.take(count) - end - - defp render_item(item, index, state) do - is_selected = index == state.cursor - content = state.item_renderer.(item) - - # Truncate to viewport width - content = - if String.length(content) > state.viewport_width do - String.slice(content, 0, state.viewport_width - 3) <> "..." - else - content - end - - if is_selected do - text(content, Style.new(background: :blue, foreground: :white)) - else - text(content, nil) - end - end - - defp render_status_bar(state) do - status = - case {state.stream_state, state.paused} do - {:idle, _} -> "IDLE" - {:running, true} -> "PAUSED" - {:running, false} -> "RUNNING" - {:error, _} -> "ERROR" - end - - overflow_label = - case state.overflow_strategy do - :drop_oldest -> "drop-old" - :drop_newest -> "drop-new" - :block -> "block" - :sliding -> "sliding" - end - - status_text = - "[#{status}] Buffer: #{state.buffer_count}/#{state.buffer_size} | Strategy: #{overflow_label}" - - text(status_text, Style.new(foreground: :cyan, bold: true)) - end - - defp render_stats_bar(state) do - stats = state.stats - - rate = - if stats.items_per_second >= 1000 do - "#{Float.round(stats.items_per_second / 1000, 1)}K/s" - else - "#{Float.round(stats.items_per_second, 1)}/s" - end - - stats_text = - "Received: #{stats.items_received} | Dropped: #{stats.items_dropped} | Rate: #{rate}" - - text(stats_text, Style.new(foreground: :yellow)) - end -end diff --git a/lib/term_ui/widgets/stream_widget/consumer.ex b/lib/term_ui/widgets/stream_widget/consumer.ex deleted file mode 100644 index abb4265f..00000000 --- a/lib/term_ui/widgets/stream_widget/consumer.ex +++ /dev/null @@ -1,128 +0,0 @@ -if Code.ensure_loaded?(GenStage) do - defmodule TermUI.Widgets.StreamWidget.Consumer do - @moduledoc """ - GenStage consumer for StreamWidget. - - This module provides a GenStage consumer that forwards events to a StreamWidget. - It handles backpressure by managing demand based on the widget's buffer state. - - ## Usage - - # Start the consumer linked to a widget process - {:ok, consumer} = StreamWidget.Consumer.start_link(widget_pid) - - # Subscribe to a producer - GenStage.sync_subscribe(consumer, to: producer) - - # Or subscribe with options - GenStage.sync_subscribe(consumer, to: producer, max_demand: 100, min_demand: 50) - """ - - use GenStage - - defstruct [:widget_pid, :widget_ref, :paused, :demand, :pending_demand] - - @default_demand 10 - - @doc """ - Starts a consumer linked to a widget process. - - ## Options - - - `:demand` - How many items to request at a time (default: 10) - """ - @spec start_link(pid(), keyword()) :: GenServer.on_start() - def start_link(widget_pid, opts \\ []) do - GenStage.start_link(__MODULE__, {widget_pid, opts}) - end - - @doc """ - Subscribe to a producer. - """ - @spec subscribe(GenServer.server(), GenStage.stage(), keyword()) :: - {:ok, reference()} | {:error, term()} - def subscribe(consumer, producer, opts \\ []) do - GenStage.sync_subscribe(consumer, [{:to, producer} | opts]) - end - - # ---------------------------------------------------------------------------- - # GenStage Callbacks - # ---------------------------------------------------------------------------- - - @impl true - def init({widget_pid, opts}) do - # Monitor the widget - ref = Process.monitor(widget_pid) - - # Notify widget that consumer started - send(widget_pid, {:consumer_started, self()}) - - state = %__MODULE__{ - widget_pid: widget_pid, - widget_ref: ref, - paused: false, - demand: Keyword.get(opts, :demand, @default_demand), - pending_demand: 0 - } - - {:consumer, state} - end - - @impl true - def handle_events(events, _from, state) do - unless state.paused do - # Forward events to widget - send(state.widget_pid, {:stream_items, events}) - end - - {:noreply, [], state} - end - - @impl true - def handle_info(:pause, state) do - {:noreply, [], %{state | paused: true}} - end - - def handle_info(:resume, state) do - {:noreply, [], %{state | paused: false}} - end - - def handle_info({:set_demand, _demand}, state) do - # Widget is telling us how much demand is available - # This is handled by GenStage's built-in demand management - {:noreply, [], state} - end - - def handle_info({:DOWN, ref, :process, _pid, reason}, %{widget_ref: ref} = state) do - {:stop, {:widget_down, reason}, state} - end - - def handle_info(_msg, state) do - {:noreply, [], state} - end - - @impl true - def terminate(reason, state) do - send(state.widget_pid, {:consumer_stopped, reason}) - :ok - end - end -else - defmodule TermUI.Widgets.StreamWidget.Consumer do - @moduledoc """ - Optional GenStage adapter for `TermUI.Widgets.StreamWidget`. - - Add `:gen_stage` to the host application to enable this adapter. The - StreamWidget direct push API does not require GenStage. - """ - - @error {:missing_dependency, :gen_stage} - - @spec start_link(pid(), keyword()) :: {:error, {:missing_dependency, :gen_stage}} - def start_link(_widget_pid, _opts \\ []), do: {:error, @error} - - @spec subscribe(GenServer.server(), GenServer.server(), keyword()) :: - {:error, {:missing_dependency, :gen_stage}} - def subscribe(_consumer, _producer, _opts \\ []), do: {:error, @error} - end -end diff --git a/lib/term_ui/widgets/supervision_tree_viewer.ex b/lib/term_ui/widgets/supervision_tree_viewer.ex deleted file mode 100644 index 2da6c8b5..00000000 --- a/lib/term_ui/widgets/supervision_tree_viewer.ex +++ /dev/null @@ -1,1194 +0,0 @@ -defmodule TermUI.Widgets.SupervisionTreeViewer do - @moduledoc """ - SupervisionTreeViewer widget for OTP supervision hierarchy visualization. - - SupervisionTreeViewer displays the supervision tree with live status indicators, - restart counts, and provides controls for process management and inspection. - - ## Usage - - SupervisionTreeViewer.new( - root: MyApp.Supervisor, - update_interval: 2000, - on_select: fn node -> handle_select(node) end - ) - - ## Features - - - Tree view of supervision hierarchy - - Live status indicators (running, restarting, terminated) - - Restart count and history display - - Supervisor strategy display - - Process state inspection - - Restart/terminate controls with confirmation - - Auto-refresh on supervision tree changes - - ## Keyboard Controls - - - Up/Down: Move selection - - Left: Collapse node or move to parent - - Right: Expand node or move to first child - - Enter: Toggle expand/collapse - - i: Show process info panel - - r: Restart selected process (with confirmation) - - k: Terminate selected process (with confirmation) - - R: Refresh tree - - /: Filter by name - - Escape: Clear filter/close panel - - ## Monochrome Compatibility - - This widget is fully functional in monochrome terminals: - - Process status indicated by both color AND text markers: - - `[R]` for running processes - - `[Y]` for restarting processes - - `[T]` for terminated processes - - `[U]` for undefined status - - Selected items use reverse video for visibility - - Error states (terminated) use underline for emphasis - - All critical information remains accessible without color - - The widget uses both theme component styles and explicit text indicators - for complete monochrome compatibility. - """ - - use TermUI.StatefulComponent - - alias TermUI.CharacterSet - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Theme - - # Dialyzer: Suppress opaque type warnings for Style helpers and contract warnings for specific map types - @dialyzer {:nowarn_function, - fg_semantic: 1, - fg_bold_semantic: 1, - fg_dim_semantic: 1, - fg_bold_help: 0, - collect_supervisor_ids: 1, - collect_children_supervisor_ids: 1, - new: 1, - refresh: 1, - set_root: 2, - expand_all: 1, - collapse_all: 1, - flatten_tree: 3} - - @type node_type :: :supervisor | :worker - @type node_status :: :running | :restarting | :terminated | :undefined - - @type sup_node :: %{ - id: term(), - pid: pid() | :restarting | :undefined, - name: atom() | nil, - type: node_type(), - status: node_status(), - child_spec: map() | nil, - strategy: atom() | nil, - restart_count: non_neg_integer(), - max_restarts: non_neg_integer() | nil, - max_seconds: non_neg_integer() | nil, - children: [sup_node()] | nil, - memory: non_neg_integer(), - reductions: non_neg_integer(), - message_queue_len: non_neg_integer(), - depth: non_neg_integer(), - parent_pid: pid() | nil - } - - @default_interval 2000 - @page_size 15 - - # ASCII-friendly icons for universal compatibility - defp get_status_icons do - %{ - running: "o", - restarting: "~", - terminated: "x", - undefined: "?" - } - end - - @status_text %{ - running: "[R]", - restarting: "[Y]", - terminated: "[T]", - undefined: "[U]" - } - - # ---------------------------------------------------------------------------- - # Style Helper Functions - # ---------------------------------------------------------------------------- - - @spec fg_semantic(atom()) :: Style.t() - defp fg_semantic(color) when is_atom(color), - do: Style.new() |> Style.fg(color) - - @spec fg_bold_semantic(atom()) :: Style.t() - defp fg_bold_semantic(color) when is_atom(color), - do: Style.new() |> Style.fg(color) |> Style.bold() - - @spec fg_dim_semantic(atom()) :: Style.t() - defp fg_dim_semantic(color) when is_atom(color), - do: Style.new() |> Style.fg(color) |> Style.dim() - - @spec fg_bold_help() :: Style.t() - defp fg_bold_help do - Style.new() |> Style.fg(Theme.get_semantic(:help)) |> Style.dim() - end - - # ---------------------------------------------------------------------------- - # Icon Functions - # ---------------------------------------------------------------------------- - - defp get_type_icons do - %{ - supervisor: "S", - worker: "W" - } - end - - defp get_strategy_display do - chars = CharacterSet.current_charset() - - %{ - one_for_one: "1:1", - one_for_all: "1:*", - rest_for_one: "1:#{chars.arrow_right}", - simple_one_for_one: "1:1+" - } - end - - # ---------------------------------------------------------------------------- - # Props - # ---------------------------------------------------------------------------- - - @doc """ - Creates new SupervisionTreeViewer widget props. - - ## Options - - - `:root` - Root supervisor (pid, registered name, or module) - required - - `:update_interval` - Refresh interval in ms (default: 2000) - - `:on_select` - Callback when node is selected: `fn node -> ... end` - - `:on_action` - Callback when action is performed: `fn {:restarted | :terminated, pid} -> ... end` - - `:show_workers` - Show worker processes (default: true) - - `:auto_expand` - Expand all nodes initially (default: true) - """ - @spec new(keyword()) :: map() - def new(opts) do - root = Keyword.fetch!(opts, :root) - - %{ - root: root, - update_interval: Keyword.get(opts, :update_interval, @default_interval), - on_select: Keyword.get(opts, :on_select), - on_action: Keyword.get(opts, :on_action), - show_workers: Keyword.get(opts, :show_workers, true), - auto_expand: Keyword.get(opts, :auto_expand, true) - } - end - - # ---------------------------------------------------------------------------- - # State Initialization - # ---------------------------------------------------------------------------- - - @doc """ - Initializes the SupervisionTreeViewer state. - """ - @impl true - def init(props) do - root_pid = resolve_supervisor(props.root) - tree = build_tree(root_pid, nil, 0, props.show_workers) - - expanded = - if props.auto_expand do - collect_supervisor_ids(tree) - else - MapSet.new() - end - - flattened = flatten_tree(tree, expanded, true) - - state = %{ - root: props.root, - root_pid: root_pid, - tree: tree, - flattened: flattened, - expanded: expanded, - selected_idx: 0, - scroll_offset: 0, - update_interval: props.update_interval, - on_select: props.on_select, - on_action: props.on_action, - show_workers: props.show_workers, - show_info: false, - pending_action: nil, - filter: nil, - filter_input: nil - } - - {:ok, state} - end - - # ---------------------------------------------------------------------------- - # Event Handling - # ---------------------------------------------------------------------------- - - @impl true - def handle_event(%Event.Key{key: key}, state) when key in [:up, :down] do - max_idx = max(0, length(state.flattened) - 1) - - new_idx = - case key do - :up -> max(0, state.selected_idx - 1) - :down -> min(max_idx, state.selected_idx + 1) - end - - state = %{state | selected_idx: new_idx} - state = maybe_call_on_select(state) - {:ok, state} - end - - def handle_event(%Event.Key{key: :page_up}, state) do - new_idx = max(0, state.selected_idx - @page_size) - state = %{state | selected_idx: new_idx} - state = maybe_call_on_select(state) - {:ok, state} - end - - def handle_event(%Event.Key{key: :page_down}, state) do - max_idx = max(0, length(state.flattened) - 1) - new_idx = min(max_idx, state.selected_idx + @page_size) - state = %{state | selected_idx: new_idx} - state = maybe_call_on_select(state) - {:ok, state} - end - - def handle_event(%Event.Key{key: :home}, state) do - state = %{state | selected_idx: 0} - state = maybe_call_on_select(state) - {:ok, state} - end - - def handle_event(%Event.Key{key: :end}, state) do - max_idx = max(0, length(state.flattened) - 1) - state = %{state | selected_idx: max_idx} - state = maybe_call_on_select(state) - {:ok, state} - end - - # Left - collapse or move to parent - def handle_event(%Event.Key{key: :left}, state) do - case get_selected(state) do - nil -> {:ok, state} - node -> handle_left_key(node, state) - end - end - - # Right - expand or move to first child - def handle_event(%Event.Key{key: :right}, state) do - case get_selected(state) do - nil -> {:ok, state} - node -> handle_right_key(node, state) - end - end - - # Enter - toggle expand/collapse - def handle_event(%Event.Key{key: :enter}, state) when state.filter_input != nil do - # Apply filter - filter = if state.filter_input == "", do: nil, else: state.filter_input - flattened = flatten_tree(state.tree, state.expanded, true) - - flattened = - if filter do - Enum.filter(flattened, fn node -> - name_str = node_display_name(node) - String.contains?(String.downcase(name_str), String.downcase(filter)) - end) - else - flattened - end - - {:ok, %{state | filter: filter, filter_input: nil, flattened: flattened, selected_idx: 0}} - end - - def handle_event(%Event.Key{key: :enter}, state) do - case get_selected(state) do - nil -> - {:ok, state} - - node -> - if node.type == :supervisor do - expanded = - if MapSet.member?(state.expanded, node.id) do - MapSet.delete(state.expanded, node.id) - else - MapSet.put(state.expanded, node.id) - end - - flattened = flatten_tree(state.tree, expanded, true) - {:ok, %{state | expanded: expanded, flattened: flattened}} - else - # Toggle info panel for workers - {:ok, %{state | show_info: not state.show_info}} - end - end - end - - # i - show info panel - def handle_event(%Event.Key{char: "i"}, state) do - {:ok, %{state | show_info: not state.show_info}} - end - - # R - force refresh - def handle_event(%Event.Key{char: "R"}, state) do - refresh(state) - end - - # r - restart process (with confirmation) - def handle_event(%Event.Key{char: "r"}, state) - when state.pending_action == nil and state.filter_input == nil do - case get_selected(state) do - nil -> {:ok, state} - _node -> {:ok, %{state | pending_action: :restart}} - end - end - - # k - terminate process (with confirmation) - def handle_event(%Event.Key{char: "k"}, state) - when state.pending_action == nil and state.filter_input == nil do - case get_selected(state) do - nil -> {:ok, state} - _node -> {:ok, %{state | pending_action: :terminate}} - end - end - - # y - confirm action - def handle_event(%Event.Key{char: "y"}, state) when state.pending_action != nil do - case get_selected(state) do - nil -> - {:ok, %{state | pending_action: nil}} - - node -> - result = execute_action(state.pending_action, node) - - if state.on_action do - state.on_action.(result) - end - - # Refresh after action - {:ok, state} = refresh(%{state | pending_action: nil}) - {:ok, state} - end - end - - # n - cancel action - def handle_event(%Event.Key{char: "n"}, state) when state.pending_action != nil do - {:ok, %{state | pending_action: nil}} - end - - # / - start filter input - def handle_event(%Event.Key{char: "/"}, state) when state.filter_input == nil do - {:ok, %{state | filter_input: ""}} - end - - # Filter input handling - def handle_event(%Event.Key{char: char}, state) - when state.filter_input != nil and char != nil do - {:ok, %{state | filter_input: state.filter_input <> char}} - end - - def handle_event(%Event.Key{key: :backspace}, state) when state.filter_input != nil do - new_input = - if String.length(state.filter_input) > 0 do - String.slice(state.filter_input, 0..-2//1) - else - state.filter_input - end - - {:ok, %{state | filter_input: new_input}} - end - - # Escape - close panel, clear filter, cancel action - def handle_event(%Event.Key{key: :escape}, state) do - cond do - state.pending_action != nil -> - {:ok, %{state | pending_action: nil}} - - state.filter_input != nil -> - {:ok, %{state | filter_input: nil}} - - state.show_info -> - {:ok, %{state | show_info: false}} - - state.filter != nil -> - flattened = flatten_tree(state.tree, state.expanded, true) - {:ok, %{state | filter: nil, flattened: flattened, selected_idx: 0}} - - true -> - {:ok, state} - end - end - - def handle_event(_event, state) do - {:ok, state} - end - - # ---------------------------------------------------------------------------- - # Private Helpers for Event Handling - # ---------------------------------------------------------------------------- - - defp handle_left_key(%{type: :supervisor, id: id}, state) do - if MapSet.member?(state.expanded, id) do - collapse_node(id, state) - else - move_to_parent(state) - end - end - - defp handle_left_key(_node, state), do: move_to_parent(state) - - defp collapse_node(node_id, state) do - expanded = MapSet.delete(state.expanded, node_id) - flattened = flatten_tree(state.tree, expanded, true) - {:ok, %{state | expanded: expanded, flattened: flattened}} - end - - defp move_to_parent(state) do - parent_idx = find_parent_idx(state.flattened, state.selected_idx) - - if parent_idx do - {:ok, %{state | selected_idx: parent_idx}} - else - {:ok, state} - end - end - - defp handle_right_key(%{type: :supervisor, id: id}, state) do - if MapSet.member?(state.expanded, id) do - move_to_first_child(state) - else - expand_node(id, state) - end - end - - defp handle_right_key(_node, state), do: {:ok, state} - - defp move_to_first_child(state) do - child_idx = state.selected_idx + 1 - - if child_idx < length(state.flattened) do - {:ok, %{state | selected_idx: child_idx}} - else - {:ok, state} - end - end - - defp expand_node(node_id, state) do - expanded = MapSet.put(state.expanded, node_id) - flattened = flatten_tree(state.tree, expanded, true) - {:ok, %{state | expanded: expanded, flattened: flattened}} - end - - # ---------------------------------------------------------------------------- - # Handle Info (Timer) - # ---------------------------------------------------------------------------- - - @impl true - def handle_info(:refresh, state) do - refresh(state) - end - - def handle_info(_msg, state) do - {:ok, state} - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Forces a refresh of the supervision tree. - """ - @spec refresh(map()) :: {:ok, map()} - def refresh(state) do - root_pid = resolve_supervisor(state.root) - tree = build_tree(root_pid, nil, 0, state.show_workers) - flattened = flatten_tree(tree, state.expanded, true) - - # Apply filter if active - flattened = - if state.filter do - Enum.filter(flattened, fn node -> - name_str = node_display_name(node) - String.contains?(String.downcase(name_str), String.downcase(state.filter)) - end) - else - flattened - end - - # Adjust selected_idx if out of bounds - max_idx = max(0, length(flattened) - 1) - selected_idx = min(state.selected_idx, max_idx) - - {:ok, - %{state | root_pid: root_pid, tree: tree, flattened: flattened, selected_idx: selected_idx}} - end - - @doc """ - Sets the root supervisor. - """ - @spec set_root(map(), term()) :: {:ok, map()} - def set_root(state, root) do - state = %{state | root: root, expanded: MapSet.new(), selected_idx: 0} - refresh(state) - end - - @doc """ - Gets the currently selected node. - """ - @spec get_selected(map()) :: sup_node() | nil - def get_selected(state) do - Enum.at(state.flattened, state.selected_idx) - end - - @doc """ - Expands all supervisor nodes. - """ - @spec expand_all(map()) :: {:ok, map()} - def expand_all(state) do - expanded = collect_supervisor_ids(state.tree) - flattened = flatten_tree(state.tree, expanded, true) - {:ok, %{state | expanded: expanded, flattened: flattened}} - end - - @doc """ - Collapses all nodes. - """ - @spec collapse_all(map()) :: {:ok, map()} - def collapse_all(state) do - expanded = MapSet.new() - flattened = flatten_tree(state.tree, expanded, true) - {:ok, %{state | expanded: expanded, flattened: flattened}} - end - - @doc """ - Gets the process state for the selected node. - """ - @spec get_process_state(map()) :: {:ok, term()} | {:error, term()} - def get_process_state(state) do - case get_selected(state) do - nil -> - {:error, :no_selection} - - node -> - if is_pid(node.pid) and Process.alive?(node.pid) do - try do - {:ok, :sys.get_state(node.pid, 1000)} - catch - :exit, reason -> {:error, reason} - end - else - {:error, :not_alive} - end - end - end - - # ---------------------------------------------------------------------------- - # Tree Building - # ---------------------------------------------------------------------------- - - defp resolve_supervisor(sup) when is_pid(sup), do: sup - defp resolve_supervisor(sup) when is_atom(sup), do: Process.whereis(sup) - - defp resolve_supervisor({:via, _, _} = sup) do - GenServer.whereis(sup) - end - - defp resolve_supervisor({:global, name}) do - :global.whereis_name(name) - end - - defp build_tree(nil, _parent_pid, _depth, _show_workers), do: nil - - defp build_tree(sup_pid, parent_pid, depth, show_workers) do - children = - try do - Supervisor.which_children(sup_pid) - catch - :exit, _ -> [] - end - - # Get supervisor info - {strategy, max_restarts, max_seconds} = get_supervisor_flags(sup_pid) - process_info = get_process_info(sup_pid) - - child_nodes = - children - |> Enum.map(fn {id, child_pid, type, _modules} -> - build_child_node(%{ - id: id, - child_pid: child_pid, - type: type, - parent_pid: sup_pid, - depth: depth + 1, - show_workers: show_workers - }) - end) - |> Enum.reject(&is_nil/1) - - %{ - id: sup_pid, - pid: sup_pid, - name: get_registered_name(sup_pid), - type: :supervisor, - status: :running, - child_spec: nil, - strategy: strategy, - restart_count: 0, - max_restarts: max_restarts, - max_seconds: max_seconds, - children: child_nodes, - memory: process_info[:memory] || 0, - reductions: process_info[:reductions] || 0, - message_queue_len: Keyword.get(process_info, :message_queue_len, 0), - depth: depth, - parent_pid: parent_pid - } - end - - defp build_child_node(params) do - %{ - id: id, - child_pid: child_pid, - type: type, - parent_pid: parent_pid, - depth: depth, - show_workers: show_workers - } = params - - case {type, child_pid} do - {:supervisor, pid} when is_pid(pid) -> - build_tree(pid, parent_pid, depth, show_workers) - - {:supervisor, :restarting} -> - build_status_node(id, :restarting, :supervisor, depth, parent_pid) - - {:supervisor, :undefined} -> - build_status_node(id, :undefined, :supervisor, depth, parent_pid) - - {:worker, pid} when is_pid(pid) -> - maybe_build_worker_node(id, pid, depth, parent_pid, show_workers) - - {:worker, :restarting} -> - maybe_build_status_worker(id, :restarting, depth, parent_pid, show_workers) - - {:worker, :undefined} -> - maybe_build_status_worker(id, :undefined, depth, parent_pid, show_workers) - end - end - - # Builds a node with a specific status (restarting or undefined). - defp build_status_node(id, status, type, depth, parent_pid) do - %{ - id: id, - pid: status, - name: nil, - type: type, - status: status, - child_spec: nil, - strategy: nil, - restart_count: 0, - max_restarts: nil, - max_seconds: nil, - children: nil, - memory: 0, - reductions: 0, - message_queue_len: 0, - depth: depth, - parent_pid: parent_pid - } - end - - # Builds a worker node with process info if show_workers is true. - defp maybe_build_worker_node(id, pid, depth, parent_pid, true) do - process_info = get_process_info(pid) - - %{ - id: id, - pid: pid, - name: get_registered_name(pid), - type: :worker, - status: :running, - child_spec: nil, - strategy: nil, - restart_count: 0, - max_restarts: nil, - max_seconds: nil, - children: nil, - memory: process_info[:memory] || 0, - reductions: process_info[:reductions] || 0, - message_queue_len: Keyword.get(process_info, :message_queue_len, 0), - depth: depth, - parent_pid: parent_pid - } - end - - defp maybe_build_worker_node(_id, _pid, _depth, _parent_pid, false), do: nil - - # Builds a worker node with a specific status if show_workers is true. - defp maybe_build_status_worker(id, status, depth, parent_pid, true) do - %{ - id: id, - pid: status, - name: nil, - type: :worker, - status: status, - child_spec: nil, - strategy: nil, - restart_count: 0, - max_restarts: nil, - max_seconds: nil, - children: nil, - memory: 0, - reductions: 0, - message_queue_len: 0, - depth: depth, - parent_pid: parent_pid - } - end - - defp maybe_build_status_worker(_id, _status, _depth, _parent_pid, false), do: nil - - defp get_supervisor_flags(sup_pid) do - # Try to get supervisor init args - case :sys.get_state(sup_pid, 500) do - %{strategy: strategy, intensity: intensity, period: period} -> - {strategy, intensity, period} - - # For older supervisor state format - state when is_tuple(state) -> - # Try to extract from supervisor internal state - {:one_for_one, nil, nil} - - _ -> - {:one_for_one, nil, nil} - end - catch - :exit, _ -> {:one_for_one, nil, nil} - end - - defp get_process_info(pid) when is_pid(pid) do - case Process.info(pid, [:memory, :reductions, :message_queue_len, :registered_name]) do - nil -> [] - info -> info - end - end - - defp get_process_info(_), do: [] - - defp get_registered_name(pid) when is_pid(pid) do - case Process.info(pid, :registered_name) do - {:registered_name, name} -> name - _ -> nil - end - end - - defp get_registered_name(_), do: nil - - # ---------------------------------------------------------------------------- - # Tree Flattening - # ---------------------------------------------------------------------------- - - defp flatten_tree(nil, _expanded, _visible), do: [] - - defp flatten_tree(node, expanded, visible) do - if visible do - children_visible = MapSet.member?(expanded, node.id) and node.children != nil - - children_nodes = - if children_visible and node.children do - Enum.flat_map(node.children, &flatten_tree(&1, expanded, true)) - else - [] - end - - [node | children_nodes] - else - [] - end - end - - @spec collect_supervisor_ids(term() | nil) :: MapSet.t() - defp collect_supervisor_ids(nil), do: MapSet.new() - - @spec collect_supervisor_ids(term()) :: MapSet.t() - defp collect_supervisor_ids(node) do - if node.type == :supervisor do - children_ids = collect_children_supervisor_ids(node.children) - MapSet.put(children_ids, node.id) - else - MapSet.new() - end - end - - defp collect_children_supervisor_ids(children) when is_list(children) do - Enum.reduce(children, MapSet.new(), fn child, acc -> - MapSet.union(acc, collect_supervisor_ids(child)) - end) - end - - defp collect_children_supervisor_ids(_), do: MapSet.new() - - defp find_parent_idx(flattened, current_idx) do - case Enum.at(flattened, current_idx) do - nil -> nil - current -> find_shallower_node_idx(flattened, current.depth, current_idx) - end - end - - defp find_shallower_node_idx(flattened, current_depth, current_idx) do - flattened - |> Enum.take(current_idx) - |> Enum.with_index() - |> Enum.reverse() - |> Enum.find_value(fn {node, idx} -> - if node.depth < current_depth, do: idx, else: nil - end) - end - - # ---------------------------------------------------------------------------- - # Actions - # ---------------------------------------------------------------------------- - - defp execute_action(:restart, node) do - case {node.parent_pid, node.id} do - {nil, _} -> - {:error, :no_parent} - - {parent_pid, child_id} -> - try do - case Supervisor.restart_child(parent_pid, child_id) do - {:ok, _pid} -> {:restarted, node.pid} - {:ok, _pid, _info} -> {:restarted, node.pid} - {:error, reason} -> {:error, reason} - end - catch - :exit, reason -> {:error, reason} - end - end - end - - defp execute_action(:terminate, node) do - case {node.parent_pid, node.id} do - {nil, _} -> - {:error, :no_parent} - - {parent_pid, child_id} -> - try do - case Supervisor.terminate_child(parent_pid, child_id) do - :ok -> {:terminated, node.pid} - {:error, reason} -> {:error, reason} - end - catch - :exit, reason -> {:error, reason} - end - end - end - - # ---------------------------------------------------------------------------- - # Helpers - # ---------------------------------------------------------------------------- - - defp status_style(status) do - case status do - :running -> Theme.get_component_style(:status, :running) - :restarting -> Theme.get_component_style(:status, :warning) - :terminated -> Theme.get_component_style(:status, :error) - :undefined -> Theme.get_component_style(:status, :unknown) - _ -> Theme.get_component_style(:status, :unknown) - end - end - - defp status_indicator(status, status_icons) do - icon = Map.get(status_icons, status, "?") - text = Map.get(@status_text, status, "[?]") - "#{icon} #{text}" - end - - defp maybe_call_on_select(state) do - if state.on_select do - case get_selected(state) do - nil -> state - node -> state.on_select.(node) - end - end - - state - end - - defp node_display_name(node) do - cond do - node.name != nil -> - inspect(node.name) - - is_pid(node.pid) -> - inspect(node.pid) - - true -> - inspect(node.id) - end - end - - # ---------------------------------------------------------------------------- - # Rendering - # ---------------------------------------------------------------------------- - - @impl true - def render(state, area) do - # Get character set for indicators - chars = CharacterSet.current_charset() - status_icons = get_status_icons() - type_icons = get_type_icons() - strategy_display = get_strategy_display() - - header = render_header(state) - tree_view = render_tree_view(state, area, chars, status_icons, type_icons, strategy_display) - filter_line = render_filter_line(state) - info_panel = render_info_panel(state, chars) - confirmation = render_confirmation(state) - footer = render_footer(state, chars) - - children = - [header, tree_view, filter_line, info_panel, confirmation, footer] - |> Enum.reject(&is_nil/1) - - stack(:vertical, children) - end - - defp render_header(state) do - root_name = - if state.tree do - node_display_name(state.tree) - else - "No supervisor" - end - - count = length(state.flattened) - - style = fg_bold_semantic(Theme.get_semantic(:info)) - - text( - "Supervision Tree: #{root_name} | Nodes: #{count}", - style - ) - end - - defp render_tree_view(state, area, chars, status_icons, type_icons, strategy_display) do - visible_height = min(area.height - 4, length(state.flattened)) - - # Calculate scroll offset to keep selected in view - scroll_offset = - cond do - state.selected_idx < state.scroll_offset -> - state.selected_idx - - state.selected_idx >= state.scroll_offset + visible_height -> - state.selected_idx - visible_height + 1 - - true -> - state.scroll_offset - end - - visible_nodes = - state.flattened - |> Enum.drop(scroll_offset) - |> Enum.take(visible_height) - |> Enum.with_index(scroll_offset) - - if Enum.empty?(visible_nodes) do - muted_color = Theme.get_semantic(:muted) - style = fg_dim_semantic(muted_color) - text(" No processes found", style) - else - lines = - Enum.map(visible_nodes, fn {node, idx} -> - render_node_line( - node, - idx == state.selected_idx, - state.expanded, - chars, - status_icons, - type_icons, - strategy_display - ) - end) - - stack(:vertical, lines) - end - end - - defp render_node_line( - node, - selected, - expanded, - chars, - status_icons, - type_icons, - strategy_display - ) do - indent = String.duplicate(" ", node.depth) - expand_indicator = expand_indicator(node, expanded, chars) - status_ind = status_indicator(node.status, status_icons) - type_icon = Map.get(type_icons, node.type, " ") - name = node_display_name(node) - strategy_str = strategy_string(node, strategy_display) - memory_str = memory_string(node) - - content = "#{indent}#{expand_indicator}#{type_icon} #{name}#{strategy_str}#{memory_str}" - full_content = "#{status_ind} #{content}" - - style = node_line_style(selected, node.status) - text(full_content, style) - end - - defp expand_indicator(node, expanded, chars) do - case {node.type, node.children} do - {:supervisor, children} when is_list(children) and length(children) > 0 -> - if MapSet.member?(expanded, node.id), - do: "#{chars.arrow_down} ", - else: "#{chars.arrow_right} " - - {:supervisor, _} -> - "#{chars.arrow_right} " - - _ -> - " " - end - end - - defp strategy_string(%{type: :supervisor, strategy: strategy}, strategy_display) - when is_binary(strategy) do - " [#{Map.get(strategy_display, strategy, "?")}]" - end - - defp strategy_string(_, _), do: "" - - defp memory_string(%{memory: memory}) when memory > 0, do: " #{format_bytes(memory)}" - defp memory_string(_), do: "" - - defp node_line_style(true, _status), do: Theme.get_component_style(:item, :selected) - defp node_line_style(false, status), do: status_style(status) - - defp render_filter_line(state) do - cond do - state.filter_input != nil -> - style = fg_semantic(Theme.get_semantic(:warning)) - text("Filter: #{state.filter_input}_", style) - - state.filter != nil -> - style = fg_dim_semantic(Theme.get_semantic(:warning)) - text("Filter: #{state.filter} (Esc to clear)", style) - - true -> - nil - end - end - - defp render_info_panel(state, chars) do - if state.show_info do - case get_selected(state) do - nil -> - nil - - node -> - info_style = fg_semantic(Theme.get_semantic(:info)) - - lines = [ - text( - "#{String.duplicate(chars.h_line, 3)} Process Info #{String.duplicate(chars.h_line, 3)}", - info_style - ), - text(" ID: #{inspect(node.id)}", nil), - text(" PID: #{inspect(node.pid)}", nil), - text(" Name: #{inspect(node.name)}", nil), - text(" Type: #{node.type}", nil), - text( - " Status: #{node.status}", - status_style(node.status) - ) - ] - - lines = add_supervisor_info(lines, node) - lines = add_node_metrics(lines, node) - - stack(:vertical, lines) - end - else - nil - end - end - - defp add_supervisor_info(lines, %{type: :supervisor} = node) do - lines ++ - [ - text(" Strategy: #{node.strategy || "unknown"}", nil), - text( - " Max restarts: #{node.max_restarts || "?"}/#{node.max_seconds || "?"}s", - nil - ) - ] - end - - defp add_supervisor_info(lines, _node), do: lines - - defp add_node_metrics(lines, node) do - lines ++ - [ - text(" Memory: #{format_bytes(node.memory)}", nil), - text(" Reductions: #{format_number(node.reductions)}", nil), - text(" Msg Queue: #{node.message_queue_len}", nil) - ] - end - - defp render_confirmation(state) do - case state.pending_action do - nil -> - nil - - :restart -> - node = get_selected(state) - name = if node, do: node_display_name(node), else: "?" - style = fg_bold_semantic(Theme.get_semantic(:warning)) - text("Restart #{name}? [y/n]", style) - - :terminate -> - node = get_selected(state) - name = if node, do: node_display_name(node), else: "?" - style = fg_bold_semantic(Theme.get_semantic(:error)) - text("Terminate #{name}? [y/n]", style) - end - end - - defp render_footer(_state, chars) do - style = fg_bold_help() - - text( - "[#{chars.arrow_up}#{chars.arrow_down}] Navigate [#{chars.arrow_left}#{chars.arrow_right}] Expand/Collapse [i] Info [r] Restart [k] Kill [R] Refresh [/] Filter", - style - ) - end - - # ---------------------------------------------------------------------------- - # Formatting Helpers - # ---------------------------------------------------------------------------- - - defp format_bytes(bytes) when bytes < 1024, do: "#{bytes}B" - defp format_bytes(bytes) when bytes < 1024 * 1024, do: "#{Float.round(bytes / 1024, 1)}KB" - defp format_bytes(bytes), do: "#{Float.round(bytes / 1024 / 1024, 1)}MB" - - defp format_number(n) when n < 1000, do: "#{n}" - defp format_number(n) when n < 1_000_000, do: "#{Float.round(n / 1000, 1)}K" - defp format_number(n), do: "#{Float.round(n / 1_000_000, 1)}M" -end diff --git a/lib/term_ui/widgets/table.ex b/lib/term_ui/widgets/table.ex deleted file mode 100644 index 1d8e3d78..00000000 --- a/lib/term_ui/widgets/table.ex +++ /dev/null @@ -1,583 +0,0 @@ -defmodule TermUI.Widgets.Table do - @moduledoc """ - Table widget for displaying tabular data. - - Table provides efficient display of large datasets with virtual scrolling, - column sorting, row selection, and flexible column layout. - - ## Usage - - Table.new( - columns: [ - Column.new(:name, "Name"), - Column.new(:age, "Age", width: Constraint.length(10), align: :right) - ], - data: [ - %{name: "Alice", age: 30}, - %{name: "Bob", age: 25} - ], - on_select: fn selected -> IO.inspect(selected) end - ) - - ## Features - - - **Virtual Scrolling**: Efficiently handles 10,000+ rows - - **Column Layout**: Fixed, proportional, and percentage widths - - **Selection**: Single or multi-selection with keyboard/mouse - - **Sorting**: Click headers to sort ascending/descending - - **Custom Rendering**: Format cells with render functions - - ## Selection Modes - - - `:none` - No selection allowed - - `:single` - One row at a time - - `:multi` - Multiple rows with Ctrl/Shift+click - - ## Keyboard Navigation - - - Arrow keys: Move selection - - Page Up/Down: Scroll by page - - Home/End: Jump to first/last row - - Enter: Confirm selection - - Space: Toggle selection (multi mode) - """ - - use TermUI.StatefulComponent - - alias TermUI.CharacterSet - alias TermUI.Event - alias TermUI.Layout.Constraint - alias TermUI.Widgets.Table.Column - - # Dialyzer: Functions return specific map types - @dialyzer {:nowarn_function, new: 1, clear_selection: 1} - - @type selection_mode :: :none | :single | :multi - @type sort_direction :: :asc | :desc | nil - - @doc """ - Creates a new Table widget. - - ## Options - - - `:columns` - List of Column specs (required) - - `:data` - List of row maps (required) - - `:selection_mode` - :none, :single, or :multi (default: :single) - - `:sortable` - Enable sorting (default: true) - - `:on_select` - Callback when selection changes - - `:on_sort` - Callback when sort changes - - `:header_style` - Style for header row - - `:row_style` - Style for data rows - - `:selected_style` - Style for selected rows - - `:alternating` - Alternating row backgrounds (default: false) - """ - @spec new(keyword()) :: map() - def new(opts) do - %{ - columns: Keyword.fetch!(opts, :columns), - data: Keyword.fetch!(opts, :data), - selection_mode: Keyword.get(opts, :selection_mode, :single), - sortable: Keyword.get(opts, :sortable, true), - on_select: Keyword.get(opts, :on_select), - on_sort: Keyword.get(opts, :on_sort), - header_style: Keyword.get(opts, :header_style), - row_style: Keyword.get(opts, :row_style), - selected_style: Keyword.get(opts, :selected_style), - alternating: Keyword.get(opts, :alternating, false) - } - end - - # StatefulComponent callbacks - - @impl true - def init(props) do - state = %{ - columns: props.columns, - data: props.data, - selection_mode: props.selection_mode, - sortable: props.sortable, - on_select: props.on_select, - on_sort: props.on_sort, - header_style: props.header_style, - row_style: props.row_style, - selected_style: props.selected_style, - alternating: props.alternating, - # State - selected: MapSet.new(), - cursor: 0, - scroll_offset: 0, - sort_column: nil, - sort_direction: nil, - column_widths: [], - # Computed - sorted_data: props.data, - visible_height: 10 - } - - {:ok, state} - end - - @impl true - def update(new_props, state) do - state = - state - |> Map.put(:columns, new_props.columns) - |> Map.put(:selection_mode, Map.get(new_props, :selection_mode, state.selection_mode)) - |> Map.put(:sortable, Map.get(new_props, :sortable, state.sortable)) - |> Map.put(:on_select, Map.get(new_props, :on_select, state.on_select)) - |> Map.put(:on_sort, Map.get(new_props, :on_sort, state.on_sort)) - |> Map.put(:header_style, Map.get(new_props, :header_style, state.header_style)) - |> Map.put(:row_style, Map.get(new_props, :row_style, state.row_style)) - |> Map.put(:selected_style, Map.get(new_props, :selected_style, state.selected_style)) - |> Map.put(:alternating, Map.get(new_props, :alternating, state.alternating)) - |> set_data(new_props.data) - - {:ok, state} - end - - @impl true - def handle_event(%Event.Key{key: :up}, state) do - state = move_cursor(state, -1) - {:ok, state} - end - - def handle_event(%Event.Key{key: :down}, state) do - state = move_cursor(state, 1) - {:ok, state} - end - - def handle_event(%Event.Key{key: :page_up}, state) do - state = move_cursor(state, -state.visible_height) - {:ok, state} - end - - def handle_event(%Event.Key{key: :page_down}, state) do - state = move_cursor(state, state.visible_height) - {:ok, state} - end - - def handle_event(%Event.Key{key: :home}, state) do - state = %{state | cursor: 0} - state = ensure_cursor_visible(state) - state = update_selection_single(state) - {:ok, state} - end - - def handle_event(%Event.Key{key: :end}, state) do - last_index = max(0, length(state.sorted_data) - 1) - state = %{state | cursor: last_index} - state = ensure_cursor_visible(state) - state = update_selection_single(state) - {:ok, state} - end - - def handle_event(%Event.Key{key: :enter}, state) do - commands = - if state.on_select do - selected = get_selected_rows(state) - [{:send, self(), {:table_select, selected}}] - else - [] - end - - {:ok, state, commands} - end - - def handle_event(%Event.Key{key: " "}, state) do - # Space toggles selection in multi mode - state = - if state.selection_mode == :multi do - toggle_selection(state, state.cursor) - else - state - end - - {:ok, state} - end - - def handle_event(%Event.Mouse{action: :click, y: y}, state) do - # Determine which row was clicked - # -1 for header - row_index = state.scroll_offset + y - 1 - - if row_index >= 0 and row_index < length(state.sorted_data) do - state = %{state | cursor: row_index} - state = update_selection_single(state) - {:ok, state} - else - {:ok, state} - end - end - - def handle_event(%Event.Mouse{action: :scroll, button: :scroll_up}, state) do - state = %{state | scroll_offset: max(0, state.scroll_offset - 3)} - {:ok, state} - end - - def handle_event(%Event.Mouse{action: :scroll, button: :scroll_down}, state) do - max_offset = max(0, length(state.sorted_data) - state.visible_height) - state = %{state | scroll_offset: min(max_offset, state.scroll_offset + 3)} - {:ok, state} - end - - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, area) do - # Update visible height based on area - # -1 for header - visible_height = max(1, area.height - 1) - state = %{state | visible_height: visible_height} - - # Calculate column widths - column_widths = calculate_column_widths(state.columns, area.width) - - # Render header - header = render_header(state, column_widths) - - # Render visible rows - visible_rows = get_visible_rows(state) - rows = render_rows(state, visible_rows, column_widths) - - stack(:vertical, [header | rows]) - end - - # Private functions - - defp move_cursor(state, delta) do - max_index = max(0, length(state.sorted_data) - 1) - new_cursor = state.cursor + delta - new_cursor = max(0, min(max_index, new_cursor)) - - state = %{state | cursor: new_cursor} - state = ensure_cursor_visible(state) - update_selection_single(state) - end - - defp ensure_cursor_visible(state) do - cond do - state.cursor < state.scroll_offset -> - %{state | scroll_offset: state.cursor} - - state.cursor >= state.scroll_offset + state.visible_height -> - %{state | scroll_offset: state.cursor - state.visible_height + 1} - - true -> - state - end - end - - defp update_selection_single(state) do - case state.selection_mode do - :none -> - state - - :single -> - %{state | selected: MapSet.new([state.cursor])} - - :multi -> - # In multi mode, cursor movement doesn't change selection - state - end - end - - defp toggle_selection(state, index) do - selected = - if MapSet.member?(state.selected, index) do - MapSet.delete(state.selected, index) - else - MapSet.put(state.selected, index) - end - - %{state | selected: selected} - end - - defp get_selected_rows(state) do - state.selected - |> MapSet.to_list() - |> Enum.sort() - |> Enum.map(&Enum.at(state.sorted_data, &1)) - |> Enum.reject(&is_nil/1) - end - - defp get_visible_rows(state) do - state.sorted_data - |> Enum.with_index() - |> Enum.slice(state.scroll_offset, state.visible_height) - end - - defp calculate_column_widths(columns, available_width) do - # Use constraint solver to distribute width - constraints = Enum.map(columns, & &1.width) - - # First pass: calculate fixed and percentage - {_fixed_total, remaining} = - Enum.reduce(constraints, {0, available_width}, fn constraint, {fixed, avail} -> - case constraint do - %Constraint.Length{value: v} -> - {fixed + v, avail - v} - - %Constraint.Percentage{value: p} -> - size = round(available_width * p / 100) - {fixed + size, avail - size} - - _ -> - {fixed, avail} - end - end) - - remaining = max(0, remaining) - - total_ratio = - Enum.reduce(constraints, 0, fn c, acc -> - case Constraint.unwrap(c) do - %Constraint.Ratio{value: r} -> acc + r - %Constraint.Fill{} -> acc + 1 - _ -> acc - end - end) - - # Calculate final widths - Enum.map(constraints, fn constraint -> - Constraint.resolve(constraint, available_width, - remaining: remaining, - total_ratio: total_ratio - ) - end) - end - - defp render_header(state, column_widths) do - # Get character set for sort arrows - chars = CharacterSet.current_charset() - - cells = - state.columns - |> Enum.zip(column_widths) - |> Enum.map(fn {column, width} -> - header_text = format_header_text(column.header, column.key, state, chars) - Column.align_text(header_text, width, column.align) - end) - - header_text = Enum.join(cells, " ") - - if state.header_style do - styled(text(header_text), state.header_style) - else - text(header_text) - end - end - - defp format_header_text(header, column_key, state, chars) do - if state.sort_column == column_key do - indicator = - case state.sort_direction do - :asc -> chars.arrow_up - :desc -> chars.arrow_down - nil -> "" - end - - header <> " " <> indicator - else - header - end - end - - defp render_rows(state, visible_rows, column_widths) do - Enum.map(visible_rows, fn {row, index} -> - cells = - state.columns - |> Enum.zip(column_widths) - |> Enum.map(fn {column, width} -> - cell_text = Column.render_cell(column, row) - Column.align_text(cell_text, width, column.align) - end) - - row_text = Enum.join(cells, " ") - - # Determine style - style = - cond do - MapSet.member?(state.selected, index) -> - state.selected_style || state.row_style - - state.alternating and rem(index, 2) == 1 -> - # Could apply alternating style here - state.row_style - - true -> - state.row_style - end - - if style do - styled(text(row_text), style) - else - text(row_text) - end - end) - end - - defp apply_sort(state) do - sorted_data = - if state.sort_column && state.sort_direction do - sort_by_column(state.data, state.sort_column, state.sort_direction) - else - state.data - end - - %{state | sorted_data: sorted_data} - end - - @doc """ - Sorts table by a column. - - ## Parameters - - - `state` - Current table state - - `column_key` - Column key to sort by - - `direction` - :asc, :desc, or nil to clear - - ## Returns - - Updated state with sorted data. - """ - @spec sort_by(map(), atom(), sort_direction()) :: map() - def sort_by(state, column_key, direction) do - state = %{state | sort_column: column_key, sort_direction: direction} - apply_sort(state) - end - - @doc """ - Toggles sort on a column. - - Cycles through: nil -> :asc -> :desc -> nil - """ - @spec toggle_sort(map(), atom()) :: map() - def toggle_sort(state, column_key) do - {new_column, new_direction} = - cond do - state.sort_column != column_key -> - {column_key, :asc} - - state.sort_direction == :asc -> - {column_key, :desc} - - state.sort_direction == :desc -> - {nil, nil} - - true -> - {column_key, :asc} - end - - state = %{state | sort_column: new_column, sort_direction: new_direction} - apply_sort(state) - end - - defp sort_by_column(data, column_key, direction) do - sorted = Enum.sort_by(data, &Map.get(&1, column_key)) - - case direction do - :asc -> sorted - :desc -> Enum.reverse(sorted) - _ -> data - end - end - - @doc """ - Replaces the table data and preserves table state as much as possible. - - The cursor, selection, and scroll offset are clamped to the new row count, - and the current sort is re-applied. - """ - @spec set_data(map(), [map()]) :: map() - def set_data(state, data) when is_list(data) do - max_index = max(0, length(data) - 1) - - selected = - state.selected - |> Enum.filter(&(&1 <= max_index)) - |> MapSet.new() - - state = - state - |> Map.put(:data, data) - |> Map.put(:cursor, min(state.cursor, max_index)) - |> Map.put(:selected, selected) - |> apply_sort() - - max_offset = max(0, length(state.sorted_data) - state.visible_height) - %{state | scroll_offset: min(state.scroll_offset, max_offset)} - end - - @doc """ - Gets the current selection. - - ## Returns - - List of selected row data. - """ - @spec get_selection(map()) :: [map()] - def get_selection(state) do - get_selected_rows(state) - end - - @doc """ - Backwards-compatible alias for `get_selection/1`. - """ - @spec get_selected(map()) :: [map()] - def get_selected(state) do - get_selection(state) - end - - @doc """ - Sets the selection programmatically. - - ## Parameters - - - `state` - Current table state - - `indices` - List of row indices to select - - ## Returns - - Updated state with new selection. - """ - @spec set_selection(map(), [non_neg_integer()]) :: map() - def set_selection(state, indices) when is_list(indices) do - %{state | selected: MapSet.new(indices)} - end - - @doc """ - Clears the current selection. - """ - @spec clear_selection(map()) :: map() - def clear_selection(state) do - %{state | selected: MapSet.new()} - end - - @doc """ - Gets the visible row count. - """ - @spec visible_count(map()) :: non_neg_integer() - def visible_count(state) do - state.visible_height - end - - @doc """ - Gets the total row count. - """ - @spec total_count(map()) :: non_neg_integer() - def total_count(state) do - length(state.data) - end - - @doc """ - Scrolls to a specific row index. - """ - @spec scroll_to(map(), non_neg_integer()) :: map() - def scroll_to(state, index) do - max_offset = max(0, length(state.sorted_data) - state.visible_height) - offset = max(0, min(max_offset, index)) - %{state | scroll_offset: offset} - end -end diff --git a/lib/term_ui/widgets/table/column.ex b/lib/term_ui/widgets/table/column.ex deleted file mode 100644 index 63394201..00000000 --- a/lib/term_ui/widgets/table/column.ex +++ /dev/null @@ -1,149 +0,0 @@ -defmodule TermUI.Widgets.Table.Column do - @moduledoc """ - Column specification for Table widget. - - Defines how a column is displayed and how data is extracted from rows. - - ## Usage - - Column.new(:name, "Name") - Column.new(:age, "Age", width: Constraint.length(10)) - Column.new(:status, "Status", render: &format_status/1) - - ## Width Constraints - - Columns support the full constraint system: - - - `Constraint.length(20)` - Fixed 20 cells - - `Constraint.ratio(2)` - Proportional share - - `Constraint.percentage(50)` - 50% of available - - `Constraint.fill()` - Take remaining space - - ## Custom Renderers - - The render function transforms the cell value to a string: - - Column.new(:date, "Date", render: fn date -> - Calendar.strftime(date, "%Y-%m-%d") - end) - """ - - alias TermUI.Layout.Constraint - - @type t :: %__MODULE__{ - key: atom(), - header: String.t(), - width: Constraint.t(), - render: (term() -> String.t()) | nil, - sortable: boolean(), - align: :left | :center | :right - } - - defstruct [ - :key, - :header, - :width, - :render, - sortable: true, - align: :left - ] - - @doc """ - Creates a new column specification. - - ## Parameters - - - `key` - The map key to extract from row data - - `header` - The header text to display - - `opts` - Additional options - - ## Options - - - `:width` - Width constraint (default: `Constraint.fill()`) - - `:render` - Custom render function (default: `to_string/1`) - - `:sortable` - Whether column can be sorted (default: true) - - `:align` - Text alignment :left, :center, :right (default: :left) - - ## Examples - - Column.new(:name, "Name") - Column.new(:age, "Age", width: Constraint.length(10), align: :right) - """ - @spec new(atom(), String.t(), keyword()) :: t() - def new(key, header, opts \\ []) when is_atom(key) and is_binary(header) do - %__MODULE__{ - key: key, - header: header, - width: Keyword.get(opts, :width, Constraint.fill()), - render: Keyword.get(opts, :render), - sortable: Keyword.get(opts, :sortable, true), - align: Keyword.get(opts, :align, :left) - } - end - - @doc """ - Extracts and renders the cell value from a row. - - ## Parameters - - - `column` - The column specification - - `row` - The row data (map or struct) - - ## Returns - - The rendered string value for the cell. - - ## Examples - - column = Column.new(:name, "Name") - Column.render_cell(column, %{name: "Alice"}) - # => "Alice" - """ - @spec render_cell(t(), map()) :: String.t() - def render_cell(%__MODULE__{key: key, render: nil}, row) do - row - |> Map.get(key, "") - |> to_string() - end - - def render_cell(%__MODULE__{key: key, render: render_fn}, row) when is_function(render_fn, 1) do - row - |> Map.get(key, "") - |> render_fn.() - |> to_string() - end - - @doc """ - Aligns text within a given width. - - ## Parameters - - - `text` - The text to align - - `width` - The available width - - `align` - Alignment (:left, :center, :right) - - ## Returns - - The aligned text, padded to width. - """ - @spec align_text(String.t(), non_neg_integer(), :left | :center | :right) :: String.t() - def align_text(text, width, align) do - text_len = String.length(text) - - cond do - text_len >= width -> - String.slice(text, 0, width) - - align == :left -> - String.pad_trailing(text, width) - - align == :right -> - String.pad_leading(text, width) - - align == :center -> - left_pad = div(width - text_len, 2) - right_pad = width - text_len - left_pad - String.duplicate(" ", left_pad) <> text <> String.duplicate(" ", right_pad) - end - end -end diff --git a/lib/term_ui/widgets/tabs.ex b/lib/term_ui/widgets/tabs.ex deleted file mode 100644 index eb334f83..00000000 --- a/lib/term_ui/widgets/tabs.ex +++ /dev/null @@ -1,328 +0,0 @@ -defmodule TermUI.Widgets.Tabs do - @moduledoc """ - Tabs widget for organizing content into switchable panels. - - Tabs display a tab bar with labels and switch between content panels - when tabs are selected. - - ## Usage - - Tabs.new( - tabs: [ - %{id: :home, label: "Home", content: home_content()}, - %{id: :settings, label: "Settings", content: settings_content()}, - %{id: :about, label: "About", content: about_content(), disabled: true} - ], - on_change: fn tab_id -> IO.puts("Selected: \#{tab_id}") end - ) - - ## Tab Options - - - `:id` - Unique identifier for the tab (required) - - `:label` - Display text in tab bar (required) - - `:content` - Content to display when selected (render node) - - `:disabled` - Whether tab can be selected (default: false) - - `:closeable` - Whether tab shows close button (default: false) - - ## Keyboard Navigation - - - Left/Right: Move between tabs - - Enter/Space: Select focused tab - - Home/End: Jump to first/last tab - """ - - use TermUI.StatefulComponent - - alias TermUI.Event - - # Dialyzer: Functions return specific map types - @dialyzer {:nowarn_function, new: 1, remove_tab: 2} - - @doc """ - Creates new Tabs widget props. - - ## Options - - - `:tabs` - List of tab definitions (required) - - `:selected` - Initially selected tab ID - - `:on_change` - Callback when selection changes - - `:on_close` - Callback when tab is closed - - `:tab_style` - Style for inactive tabs - - `:selected_style` - Style for selected tab - - `:disabled_style` - Style for disabled tabs - """ - @spec new(keyword()) :: map() - def new(opts) do - tabs = Keyword.fetch!(opts, :tabs) - - %{ - tabs: tabs, - selected: Keyword.get(opts, :selected, get_first_enabled_id(tabs)), - on_change: Keyword.get(opts, :on_change), - on_close: Keyword.get(opts, :on_close), - tab_style: Keyword.get(opts, :tab_style), - selected_style: Keyword.get(opts, :selected_style), - disabled_style: Keyword.get(opts, :disabled_style) - } - end - - defp get_first_enabled_id(tabs) do - tabs - |> Enum.find(fn tab -> not Map.get(tab, :disabled, false) end) - |> case do - nil -> nil - tab -> tab.id - end - end - - @impl true - def init(props) do - state = %{ - tabs: props.tabs, - selected: props.selected, - focused: props.selected, - on_change: props.on_change, - on_close: props.on_close, - tab_style: props.tab_style, - selected_style: props.selected_style, - disabled_style: props.disabled_style - } - - {:ok, state} - end - - @impl true - def update(new_props, state) do - state = %{state | tabs: new_props.tabs} - {:ok, state} - end - - @impl true - def handle_event(%Event.Key{key: :left}, state) do - state = move_focus(state, -1) - {:ok, state} - end - - def handle_event(%Event.Key{key: :right}, state) do - state = move_focus(state, 1) - {:ok, state} - end - - def handle_event(%Event.Key{key: :home}, state) do - first_id = get_first_enabled_id(state.tabs) - state = %{state | focused: first_id} - {:ok, state} - end - - def handle_event(%Event.Key{key: :end}, state) do - last_id = get_last_enabled_id(state.tabs) - state = %{state | focused: last_id} - {:ok, state} - end - - def handle_event(%Event.Key{key: key}, state) when key in [:enter, " "] do - state = select_focused(state) - {:ok, state} - end - - def handle_event(%Event.Mouse{action: :click, x: x}, state) do - # Determine which tab was clicked based on x position - case find_tab_at_position(state.tabs, x) do - nil -> - {:ok, state} - - tab_id -> - if tab_enabled?(state.tabs, tab_id) do - state = %{state | selected: tab_id, focused: tab_id} - notify_change(state) - {:ok, state} - else - {:ok, state} - end - end - end - - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, area) do - tab_bar = render_tab_bar(state) - content = render_content(state, %{area | height: area.height - 1}) - - stack(:vertical, [tab_bar, content]) - end - - # Private functions - - defp move_focus(state, direction) do - enabled_tabs = Enum.filter(state.tabs, fn tab -> not Map.get(tab, :disabled, false) end) - ids = Enum.map(enabled_tabs, & &1.id) - - case Enum.find_index(ids, &(&1 == state.focused)) do - nil -> - state - - current_idx -> - new_idx = rem(current_idx + direction + length(ids), length(ids)) - %{state | focused: Enum.at(ids, new_idx)} - end - end - - defp get_last_enabled_id(tabs) do - tabs - |> Enum.filter(fn tab -> not Map.get(tab, :disabled, false) end) - |> List.last() - |> case do - nil -> nil - tab -> tab.id - end - end - - defp select_focused(state) do - if tab_enabled?(state.tabs, state.focused) do - state = %{state | selected: state.focused} - notify_change(state) - state - else - state - end - end - - defp tab_enabled?(tabs, tab_id) do - case Enum.find(tabs, &(&1.id == tab_id)) do - nil -> false - tab -> not Map.get(tab, :disabled, false) - end - end - - defp notify_change(state) do - if state.on_change do - state.on_change.(state.selected) - end - end - - defp find_tab_at_position(tabs, x) do - {result, _} = - Enum.reduce_while(tabs, {nil, 0}, fn tab, {_, offset} -> - # " label " + borders - label_len = String.length(tab.label) + 4 - - if x >= offset and x < offset + label_len do - {:halt, {tab.id, offset}} - else - {:cont, {nil, offset + label_len}} - end - end) - - result - end - - defp render_tab_bar(state) do - tabs = Enum.map(state.tabs, fn tab -> render_single_tab(tab, state) end) - stack(:horizontal, tabs) - end - - defp render_single_tab(tab, state) do - label = build_tab_label(tab) - style = determine_tab_style(tab, state) - decorated_label = decorate_tab_label(label, tab.id, state) - apply_tab_style(decorated_label, style) - end - - defp build_tab_label(tab) do - base_label = " #{tab.label} " - - if Map.get(tab, :closeable, false) do - base_label <> "×" - else - base_label - end - end - - defp determine_tab_style(tab, state) do - cond do - Map.get(tab, :disabled, false) -> state.disabled_style - tab.id == state.selected -> state.selected_style - true -> state.tab_style - end - end - - defp decorate_tab_label(label, tab_id, state) do - cond do - tab_id == state.selected -> "[#{label}]" - tab_id == state.focused -> "(#{label})" - true -> " #{label} " - end - end - - defp apply_tab_style(label, nil), do: text(label) - defp apply_tab_style(label, style), do: styled(text(label), style) - - defp render_content(state, _area) do - case Enum.find(state.tabs, &(&1.id == state.selected)) do - nil -> - empty() - - tab -> - Map.get(tab, :content, empty()) - end - end - - # Public API - - @doc """ - Gets the currently selected tab ID. - """ - @spec get_selected(map()) :: term() - def get_selected(state) do - state.selected - end - - @doc """ - Selects a tab by ID. - """ - @spec select(map(), term()) :: map() - def select(state, tab_id) do - if tab_enabled?(state.tabs, tab_id) do - %{state | selected: tab_id, focused: tab_id} - else - state - end - end - - @doc """ - Adds a new tab. - """ - @spec add_tab(map(), map()) :: map() - def add_tab(state, tab) do - %{state | tabs: state.tabs ++ [tab]} - end - - @doc """ - Removes a tab by ID. - """ - @spec remove_tab(map(), term()) :: map() - def remove_tab(state, tab_id) do - tabs = Enum.reject(state.tabs, &(&1.id == tab_id)) - - # If removed tab was selected, select first enabled - selected = - if state.selected == tab_id do - get_first_enabled_id(tabs) - else - state.selected - end - - %{state | tabs: tabs, selected: selected} - end - - @doc """ - Returns the number of tabs. - """ - @spec tab_count(map()) :: non_neg_integer() - def tab_count(state) do - length(state.tabs) - end -end diff --git a/lib/term_ui/widgets/text_input.ex b/lib/term_ui/widgets/text_input.ex deleted file mode 100644 index d36c6af2..00000000 --- a/lib/term_ui/widgets/text_input.ex +++ /dev/null @@ -1,791 +0,0 @@ -defmodule TermUI.Widgets.TextInput do - @moduledoc """ - TextInput widget for single-line and multi-line text input. - - Provides text editing with cursor movement, auto-growing height, - and scrolling for content that exceeds the visible area. - - ## Usage - - TextInput.new( - value: "", - placeholder: "Enter text...", - width: 40, - multiline: true, - max_visible_lines: 5 - ) - - ## Features - - - Single-line and multi-line modes - - Ctrl+Enter for newline insertion (multiline) - - Auto-growing height up to max_visible_lines - - Scrollable area when content exceeds visible lines - - Cursor movement and text editing - - Placeholder text support - - Focus state handling - - ## Keyboard Controls - - - Left/Right: Move cursor horizontally - - Up/Down: Move cursor between lines (multiline) - - Home/End: Move to start/end of line - - Ctrl+Home/End: Move to start/end of text - - Backspace: Delete character before cursor - - Delete: Delete character at cursor - - Ctrl+Enter: Insert newline (multiline mode) - - Enter: Submit (single-line) or insert newline if configured - - Escape: Blur input - """ - - use TermUI.StatefulComponent - - alias TermUI.CharacterSet - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Theme - - # Dialyzer: Suppress opaque type warnings for Style helpers and contract warnings for specific map types - @dialyzer {:nowarn_function, fg_theme_color: 1, new: 1, set_value: 2, clear: 1} - - @default_width 40 - @default_max_visible_lines 5 - - # ---------------------------------------------------------------------------- - # Style Helper Functions - # ---------------------------------------------------------------------------- - - @spec fg_theme_color(atom()) :: Style.t() - defp fg_theme_color(color) when is_atom(color), - do: Style.new() |> Style.fg(color) - - # ---------------------------------------------------------------------------- - # Props - # ---------------------------------------------------------------------------- - - @doc """ - Creates new TextInput widget props. - - ## Options - - - `:value` - Initial text value (default: "") - - `:placeholder` - Placeholder text when empty (default: "") - - `:width` - Widget width in characters (default: 40) - - `:multiline` - Enable multi-line mode (default: false) - - `:max_lines` - Maximum number of lines allowed, nil for unlimited (default: nil) - - `:max_visible_lines` - Lines visible before scrolling (default: 5) - - `:on_change` - Callback when value changes: fn(value) -> any - - `:on_submit` - Callback when submitted: fn(value) -> any - - `:enter_submits` - Enter key submits instead of newline in multiline (default: false) - - `:disabled` - Disable input (default: false) - - `:style` - Text style - - `:focused_style` - Style when focused - - `:placeholder_style` - Placeholder text style - """ - @spec new(keyword()) :: map() - def new(opts \\ []) do - %{ - value: Keyword.get(opts, :value, ""), - placeholder: Keyword.get(opts, :placeholder, ""), - width: Keyword.get(opts, :width, @default_width), - multiline: Keyword.get(opts, :multiline, false), - max_lines: Keyword.get(opts, :max_lines), - max_visible_lines: Keyword.get(opts, :max_visible_lines, @default_max_visible_lines), - on_change: Keyword.get(opts, :on_change), - on_submit: Keyword.get(opts, :on_submit), - enter_submits: Keyword.get(opts, :enter_submits, false), - disabled: Keyword.get(opts, :disabled, false), - style: Keyword.get(opts, :style), - focused_style: Keyword.get(opts, :focused_style), - placeholder_style: Keyword.get(opts, :placeholder_style) - } - end - - # ---------------------------------------------------------------------------- - # StatefulComponent Callbacks - # ---------------------------------------------------------------------------- - - @impl true - def init(props) do - lines = text_to_lines(props.value) - - state = %{ - # Text content - lines: lines, - cursor_row: 0, - cursor_col: 0, - scroll_offset: 0, - - # Focus - focused: false, - - # Configuration - width: props.width, - multiline: props.multiline, - max_lines: props.max_lines, - max_visible_lines: props.max_visible_lines, - placeholder: props.placeholder, - enter_submits: props.enter_submits, - disabled: props.disabled, - - # Styles - style: props.style, - focused_style: props.focused_style, - placeholder_style: props.placeholder_style, - - # Callbacks - on_change: props.on_change, - on_submit: props.on_submit - } - - {:ok, state} - end - - @impl true - def update(new_props, state) do - # Update configuration from new props - state = - state - |> Map.put(:width, new_props.width) - |> Map.put(:multiline, new_props.multiline) - |> Map.put(:max_lines, new_props.max_lines) - |> Map.put(:max_visible_lines, new_props.max_visible_lines) - |> Map.put(:placeholder, new_props.placeholder) - |> Map.put(:enter_submits, new_props.enter_submits) - |> Map.put(:disabled, new_props.disabled) - |> Map.put(:style, new_props.style) - |> Map.put(:focused_style, new_props.focused_style) - |> Map.put(:placeholder_style, new_props.placeholder_style) - |> Map.put(:on_change, new_props.on_change) - |> Map.put(:on_submit, new_props.on_submit) - - # Update value if changed externally - new_lines = text_to_lines(new_props.value) - current_text = lines_to_text(state.lines) - - state = - if new_props.value != current_text do - %{state | lines: new_lines} - |> clamp_cursor() - |> adjust_scroll() - else - state - end - - {:ok, state} - end - - # ---------------------------------------------------------------------------- - # Event Handling - # ---------------------------------------------------------------------------- - - @impl true - def handle_event(_event, %{disabled: true} = state) do - {:ok, state} - end - - # Arrow keys - cursor movement - def handle_event(%Event.Key{key: :left}, state) do - {:ok, move_cursor_left(state)} - end - - def handle_event(%Event.Key{key: :right}, state) do - {:ok, move_cursor_right(state)} - end - - def handle_event(%Event.Key{key: :up}, %{multiline: true} = state) do - {:ok, move_cursor_up(state)} - end - - def handle_event(%Event.Key{key: :down}, %{multiline: true} = state) do - {:ok, move_cursor_down(state)} - end - - # Home/End - def handle_event(%Event.Key{key: :home, modifiers: modifiers}, state) do - if :ctrl in modifiers do - {:ok, move_to_start(state)} - else - {:ok, move_to_line_start(state)} - end - end - - def handle_event(%Event.Key{key: :end, modifiers: modifiers}, state) do - if :ctrl in modifiers do - {:ok, move_to_end(state)} - else - {:ok, move_to_line_end(state)} - end - end - - # Backspace - def handle_event(%Event.Key{key: :backspace}, state) do - state = delete_backward(state) - notify_change(state) - {:ok, state} - end - - # Delete - def handle_event(%Event.Key{key: :delete}, state) do - state = delete_forward(state) - notify_change(state) - {:ok, state} - end - - # Ctrl+Enter - insert newline in multiline mode - def handle_event(%Event.Key{key: :enter, modifiers: modifiers}, %{multiline: true} = state) do - if :ctrl in modifiers do - state = insert_newline(state) - notify_change(state) - {:ok, state} - else - handle_enter(state) - end - end - - # Enter - submit (for single-line mode) - def handle_event(%Event.Key{key: :enter}, %{multiline: false} = state) do - notify_submit(state) - {:ok, state} - end - - # Escape - blur - def handle_event(%Event.Key{key: :escape}, state) do - {:ok, %{state | focused: false}} - end - - # Character input - def handle_event(%Event.Key{char: char}, state) when is_binary(char) and char != "" do - state = insert_char(state, char) - notify_change(state) - {:ok, state} - end - - # Bracketed paste is one edit and one change notification. - def handle_event(%Event.Paste{content: content}, state) do - state = insert_paste(state, content) - notify_change(state) - {:ok, state} - end - - # Focus events - def handle_event(%Event.Focus{action: :gained}, state) do - {:ok, %{state | focused: true}} - end - - def handle_event(%Event.Focus{action: :lost}, state) do - {:ok, %{state | focused: false}} - end - - def handle_event(_event, state) do - {:ok, state} - end - - # Helper for Enter key in multiline mode - defp handle_enter(state) do - if state.enter_submits do - notify_submit(state) - {:ok, state} - else - # Multiline without enter_submits: insert newline - state = insert_newline(state) - notify_change(state) - {:ok, state} - end - end - - # ---------------------------------------------------------------------------- - # Text Operations - # ---------------------------------------------------------------------------- - - defp text_to_lines(""), do: [""] - defp text_to_lines(text), do: String.split(text, "\n") - - defp lines_to_text(lines), do: Enum.join(lines, "\n") - - defp current_line(state) do - Enum.at(state.lines, state.cursor_row, "") - end - - defp line_count(state), do: length(state.lines) - - defp current_line_length(state) do - String.length(current_line(state)) - end - - # ---------------------------------------------------------------------------- - # Cursor Movement - # ---------------------------------------------------------------------------- - - defp move_cursor_left(state) do - cond do - # Can move left on current line - state.cursor_col > 0 -> - %{state | cursor_col: state.cursor_col - 1} - - # At start of line but not first line - go to end of previous line - state.cursor_row > 0 -> - prev_row = state.cursor_row - 1 - prev_line_len = String.length(Enum.at(state.lines, prev_row, "")) - - %{state | cursor_row: prev_row, cursor_col: prev_line_len} - |> adjust_scroll() - - # At very start - true -> - state - end - end - - defp move_cursor_right(state) do - line_len = current_line_length(state) - - cond do - # Can move right on current line - state.cursor_col < line_len -> - %{state | cursor_col: state.cursor_col + 1} - - # At end of line but not last line - go to start of next line - state.multiline and state.cursor_row < line_count(state) - 1 -> - %{state | cursor_row: state.cursor_row + 1, cursor_col: 0} - |> adjust_scroll() - - # At very end - true -> - state - end - end - - defp move_cursor_up(state) do - if state.cursor_row > 0 do - new_row = state.cursor_row - 1 - new_line_len = String.length(Enum.at(state.lines, new_row, "")) - new_col = min(state.cursor_col, new_line_len) - - %{state | cursor_row: new_row, cursor_col: new_col} - |> adjust_scroll() - else - state - end - end - - defp move_cursor_down(state) do - if state.cursor_row < line_count(state) - 1 do - new_row = state.cursor_row + 1 - new_line_len = String.length(Enum.at(state.lines, new_row, "")) - new_col = min(state.cursor_col, new_line_len) - - %{state | cursor_row: new_row, cursor_col: new_col} - |> adjust_scroll() - else - state - end - end - - defp move_to_line_start(state) do - %{state | cursor_col: 0} - end - - defp move_to_line_end(state) do - %{state | cursor_col: current_line_length(state)} - end - - defp move_to_start(state) do - %{state | cursor_row: 0, cursor_col: 0, scroll_offset: 0} - end - - defp move_to_end(state) do - last_row = max(0, line_count(state) - 1) - last_col = String.length(Enum.at(state.lines, last_row, "")) - - %{state | cursor_row: last_row, cursor_col: last_col} - |> adjust_scroll() - end - - # ---------------------------------------------------------------------------- - # Text Editing - # ---------------------------------------------------------------------------- - - defp insert_char(state, char) do - line = current_line(state) - {before_cursor, after_cursor} = String.split_at(line, state.cursor_col) - new_line = before_cursor <> char <> after_cursor - - lines = List.replace_at(state.lines, state.cursor_row, new_line) - - %{state | lines: lines, cursor_col: state.cursor_col + String.length(char)} - end - - defp insert_paste(%{multiline: false} = state, content) do - content - |> normalize_line_endings() - |> String.replace("\n", " ") - |> then(&insert_char(state, &1)) - end - - defp insert_paste(state, content) do - pasted_lines = - content - |> normalize_line_endings() - |> split_pasted_lines(state) - - line = current_line(state) - {before_cursor, after_cursor} = String.split_at(line, state.cursor_col) - [first_line | remaining_lines] = pasted_lines - inserted_lines = [before_cursor <> first_line | remaining_lines] - last_index = length(inserted_lines) - 1 - last_line = List.last(inserted_lines) - - inserted_lines = - List.replace_at(inserted_lines, last_index, last_line <> after_cursor) - - {lines_before, [_current_line | lines_after]} = Enum.split(state.lines, state.cursor_row) - lines = lines_before ++ inserted_lines ++ lines_after - - %{ - state - | lines: lines, - cursor_row: state.cursor_row + last_index, - cursor_col: String.length(last_line) - } - |> adjust_scroll() - end - - defp normalize_line_endings(content) do - content - |> String.replace("\r\n", "\n") - |> String.replace("\r", "\n") - end - - defp split_pasted_lines(content, %{max_lines: nil}), do: String.split(content, "\n") - - defp split_pasted_lines(content, state) do - available = max(1, state.max_lines - line_count(state) + 1) - - content - |> String.splitter("\n") - |> Enum.take(available) - |> Enum.map(&:binary.copy/1) - end - - defp insert_newline(state) do - # Check max_lines constraint - if state.max_lines && line_count(state) >= state.max_lines do - state - else - line = current_line(state) - {before_cursor, after_cursor} = String.split_at(line, state.cursor_col) - - lines = - state.lines - |> List.replace_at(state.cursor_row, before_cursor) - |> List.insert_at(state.cursor_row + 1, after_cursor) - - %{state | lines: lines, cursor_row: state.cursor_row + 1, cursor_col: 0} - |> adjust_scroll() - end - end - - defp delete_backward(state) do - cond do - # Can delete within current line - state.cursor_col > 0 -> - line = current_line(state) - {before_cursor, after_cursor} = String.split_at(line, state.cursor_col) - new_line = String.slice(before_cursor, 0..-2//1) <> after_cursor - lines = List.replace_at(state.lines, state.cursor_row, new_line) - %{state | lines: lines, cursor_col: state.cursor_col - 1} - - # At start of line but not first line - join with previous line - state.cursor_row > 0 -> - prev_row = state.cursor_row - 1 - prev_line = Enum.at(state.lines, prev_row, "") - curr_line = current_line(state) - new_cursor_col = String.length(prev_line) - - lines = - state.lines - |> List.delete_at(state.cursor_row) - |> List.replace_at(prev_row, prev_line <> curr_line) - - %{state | lines: lines, cursor_row: prev_row, cursor_col: new_cursor_col} - |> adjust_scroll() - - # At very start - nothing to delete - true -> - state - end - end - - defp delete_forward(state) do - line = current_line(state) - line_len = String.length(line) - - cond do - # Can delete within current line - state.cursor_col < line_len -> - {before_cursor, after_cursor} = String.split_at(line, state.cursor_col) - new_line = before_cursor <> String.slice(after_cursor, 1..-1//1) - lines = List.replace_at(state.lines, state.cursor_row, new_line) - %{state | lines: lines} - - # At end of line but not last line - join with next line - state.cursor_row < line_count(state) - 1 -> - next_row = state.cursor_row + 1 - next_line = Enum.at(state.lines, next_row, "") - - lines = - state.lines - |> List.delete_at(next_row) - |> List.replace_at(state.cursor_row, line <> next_line) - - %{state | lines: lines} - - # At very end - nothing to delete - true -> - state - end - end - - # ---------------------------------------------------------------------------- - # Scrolling - # ---------------------------------------------------------------------------- - - defp adjust_scroll(state) do - max_visible = state.max_visible_lines - - new_offset = - cond do - # Cursor above visible area - state.cursor_row < state.scroll_offset -> - state.cursor_row - - # Cursor below visible area - state.cursor_row >= state.scroll_offset + max_visible -> - state.cursor_row - max_visible + 1 - - # Cursor in visible area - true -> - state.scroll_offset - end - - %{state | scroll_offset: max(0, new_offset)} - end - - defp clamp_cursor(state) do - max_row = max(0, line_count(state) - 1) - new_row = min(state.cursor_row, max_row) - max_col = String.length(Enum.at(state.lines, new_row, "")) - new_col = min(state.cursor_col, max_col) - - %{state | cursor_row: new_row, cursor_col: new_col} - end - - # ---------------------------------------------------------------------------- - # Callbacks - # ---------------------------------------------------------------------------- - - defp notify_change(state) do - if state.on_change do - value = lines_to_text(state.lines) - state.on_change.(value) - end - end - - defp notify_submit(state) do - if state.on_submit do - value = lines_to_text(state.lines) - state.on_submit.(value) - end - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Get the current text value. - """ - @spec get_value(map()) :: String.t() - def get_value(state) do - lines_to_text(state.lines) - end - - @doc """ - Set the text value programmatically. - """ - @spec set_value(map(), String.t()) :: map() - def set_value(state, value) do - lines = text_to_lines(value) - - %{state | lines: lines, cursor_row: 0, cursor_col: 0, scroll_offset: 0} - |> clamp_cursor() - end - - @doc """ - Clear the text input. - """ - @spec clear(map()) :: map() - def clear(state) do - %{state | lines: [""], cursor_row: 0, cursor_col: 0, scroll_offset: 0} - end - - @doc """ - Set focus state. - """ - @spec set_focused(map(), boolean()) :: map() - def set_focused(state, focused) do - %{state | focused: focused} - end - - @doc """ - Get the number of lines. - """ - @spec get_line_count(map()) :: non_neg_integer() - def get_line_count(state), do: line_count(state) - - @doc """ - Get the cursor position as {row, col}. - """ - @spec get_cursor(map()) :: {non_neg_integer(), non_neg_integer()} - def get_cursor(state), do: {state.cursor_row, state.cursor_col} - - # ---------------------------------------------------------------------------- - # Rendering - # ---------------------------------------------------------------------------- - - @impl true - def render(state, _area) do - if empty?(state) and state.placeholder != "" and not state.focused do - render_placeholder(state) - else - render_content(state) - end - end - - defp empty?(state) do - state.lines == [""] or state.lines == [] - end - - defp render_placeholder(state) do - style = state.placeholder_style || Style.new(fg: :bright_black) - text(state.placeholder, style) - end - - defp render_content(state) do - # Get character set for scroll indicators - chars = CharacterSet.current_charset() - - # Determine visible lines - total_lines = line_count(state) - visible_count = min(total_lines, state.max_visible_lines) - visible_count = max(1, visible_count) - - visible_lines = - state.lines - |> Enum.drop(state.scroll_offset) - |> Enum.take(visible_count) - - # Calculate display height (auto-grow) - display_height = length(visible_lines) - - # Determine style - base_style = - if state.focused do - state.focused_style || fg_theme_color(Theme.get_color(:foreground)) - else - state.style - end - - # Render each visible line - rendered_lines = - visible_lines - |> Enum.with_index() - |> Enum.map(fn {line, idx} -> - actual_row = idx + state.scroll_offset - render_line(line, actual_row, state, base_style) - end) - - # Add scroll indicators if needed - scroll_indicator = render_scroll_indicator(state, total_lines, visible_count, chars) - - content = - if scroll_indicator do - # Stack lines horizontally with scroll indicator - Enum.map(rendered_lines, fn line_node -> - stack(:horizontal, [line_node]) - end) - |> Kernel.++([scroll_indicator]) - else - rendered_lines - end - - if display_height == 1 do - # Single line - just return the text node - List.first(content) || text("", base_style) - else - stack(:vertical, content) - end - end - - defp render_line(line, row, state, base_style) do - # Pad or truncate line to width - display_line = String.pad_trailing(line, state.width) - display_line = String.slice(display_line, 0, state.width) - - # Insert cursor if focused and on this row - if state.focused and row == state.cursor_row do - render_line_with_cursor(display_line, state.cursor_col, base_style) - else - text(display_line, base_style) - end - end - - defp render_line_with_cursor(line, cursor_col, base_style) do - # Split line at cursor position - {before, at_and_after} = String.split_at(line, cursor_col) - - {cursor_char, after_cursor} = - if String.length(at_and_after) > 0 do - {String.at(at_and_after, 0), String.slice(at_and_after, 1..-1//1)} - else - {" ", ""} - end - - # Create cursor style (reverse video) - cursor_style = Style.new(attrs: [:reverse]) - - stack(:horizontal, [ - text(before, base_style), - text(cursor_char, cursor_style), - text(after_cursor, base_style) - ]) - end - - defp render_scroll_indicator(state, total_lines, visible_count, _chars) do - if total_lines > visible_count do - chars = CharacterSet.current_charset() - can_scroll_up = state.scroll_offset > 0 - can_scroll_down = state.scroll_offset + visible_count < total_lines - - indicator = - cond do - can_scroll_up and can_scroll_down -> chars.arrow_up_down - can_scroll_up -> chars.arrow_up - can_scroll_down -> chars.arrow_down - true -> nil - end - - if indicator do - text( - " #{indicator} #{state.scroll_offset + 1}-#{state.scroll_offset + visible_count}/#{total_lines}", - Style.new(fg: :bright_black) - ) - else - nil - end - else - nil - end - end -end diff --git a/lib/term_ui/widgets/text_input/line.ex b/lib/term_ui/widgets/text_input/line.ex deleted file mode 100644 index 76bdb1ef..00000000 --- a/lib/term_ui/widgets/text_input/line.ex +++ /dev/null @@ -1,696 +0,0 @@ -defmodule TermUI.Widgets.TextInput.Line do - @moduledoc """ - Line-based text input widget using shell line editing. - - This widget provides a simple text input experience using `IO.gets/1` through - the `TermUI.Input.LineReader` module. Unlike the standard `TextInput` widget - which handles character-by-character input, this widget delegates to the shell - for line editing, providing familiar shell features. - - ## When to Use TextInput.Line - - Use `TextInput.Line` when you need: - - **Free-form text entry**: User types arbitrary text and submits with Enter - - **Shell line editing**: Backspace, cursor movement, command history - - **Simple input flow**: Just prompt → read → validate → done - - Use the standard `TextInput` widget when you need: - - Character-by-character input handling - - Custom key bindings or input transformations - - Real-time validation as the user types - - Multi-line text editing - - ## Shell Line Editing Features - - When using `TextInput.Line`, the shell provides (depending on terminal): - - **Backspace**: Delete character before cursor - - **Delete**: Delete character at cursor - - **Left/Right arrows**: Move cursor within line - - **Home/End**: Jump to start/end of line - - **Ctrl+A/E**: Jump to start/end (Emacs-style) - - **Ctrl+K**: Kill to end of line - - **Up/Down arrows**: Command history (if shell supports) - - These features are provided by the shell, not by TermUI. - - ## TTY Mode Compatibility - - This widget is designed for TTY mode where shell line editing is available. - It also works in raw mode, but the shell editing features may be limited. - - > #### Standard TextInput Works in TTY Mode {: .info} - > - > The standard `TermUI.Widgets.TextInput` widget works perfectly in TTY mode - > for character-by-character input. Use `TextInput.Line` only when you - > specifically want shell line editing features. - - ## Usage - - # Create input props - props = TextInput.Line.new( - prompt: "Enter name: ", - label: "User Name", - placeholder: "Type your name" - ) - - # Initialize state - {:ok, state} = TextInput.Line.init(props) - - # Read input (blocks until Enter) - case TextInput.Line.read(state) do - {:ok, value, new_state} -> - IO.puts("You entered: \#{value}") - new_state - - {:error, reason, new_state} -> - IO.puts("Invalid input: \#{reason}") - new_state - - {:eof, new_state} -> - IO.puts("EOF received") - new_state - end - - ## With Validation - - validator = fn input -> - if String.length(input) >= 3 do - :ok - else - {:error, "Name must be at least 3 characters"} - end - end - - props = TextInput.Line.new( - prompt: "Enter name: ", - validator: validator - ) - - ## Comparison with TextInput - - | Feature | TextInput.Line | TextInput | - |---------|----------------|-----------| - | Input style | Line-based (Enter to submit) | Character-by-character | - | Line editing | Shell-provided | Widget-handled | - | Real-time validation | No | Yes | - | Multi-line | No | Yes (optional) | - | Custom key bindings | No | Yes | - | Blocking | Yes (blocks during read) | No (event-driven) | - - ## Blocking I/O Behavior (Architectural Note) - - Unlike other TermUI widgets which are event-driven, `TextInput.Line` uses - **blocking I/O** when reading input. This is an intentional design choice: - - 1. **Why blocking?** Shell line editing requires the terminal to be in line - mode, where the shell buffers input until Enter is pressed. This is - fundamentally different from raw mode's character-by-character input. - - 2. **Process implications:** When `read/1` or `handle_focus/1` is called, - the calling process blocks until input is complete. This means: - - The widget cannot respond to other events during input - - UI updates (like animations) will pause - - Other processes are unaffected - - 3. **Best practices:** - - Use `TextInput.Line` for simple, sequential input flows - - For concurrent input handling, spawn a separate process for input - - For real-time UI during input, use the standard `TextInput` widget - - This behavior is intentional and will not change. The blocking nature enables - shell line editing features that are not possible with event-driven input. - """ - - alias TermUI.Input.LineReader - - import TermUI.Component.RenderNode - alias TermUI.Renderer.Style - alias TermUI.Theme - - # Dialyzer: Suppress opaque type warnings for Style helpers - @dialyzer {:nowarn_function, fg_semantic: 1, fg_color: 1, new: 1, clear: 1} - - @typedoc """ - TextInput.Line state structure. - - - `:prompt` - Text displayed before input cursor - - `:value` - Current or last entered value - - `:label` - Optional label displayed above input - - `:validator` - Optional validation function - - `:placeholder` - Text shown when value is empty - - `:error` - Current validation error message, if any - - `:focused` - Whether the widget currently has focus - - `:on_blur` - Optional callback when widget loses focus or completes input - """ - @type t :: %__MODULE__{ - prompt: String.t(), - value: String.t(), - label: String.t() | nil, - validator: validator() | nil, - placeholder: String.t(), - error: String.t() | nil, - focused: boolean(), - on_blur: (t() -> any()) | nil - } - - @typedoc """ - Validator function type. - - Should return: - - `:ok` - Input is valid - - `{:ok, transformed}` - Input is valid, use transformed value - - `{:error, reason}` - Input is invalid - """ - @type validator :: (String.t() -> :ok | {:ok, term()} | {:error, term()}) - - @typedoc """ - Result of a read operation. - - - `{:ok, value, state}` - Successfully read and validated input - - `{:error, reason, state}` - Read succeeded but validation failed - - `{:cancelled, state}` - Input was cancelled (Ctrl+C) - - `{:eof, state}` - End of input stream - """ - @type read_result :: - {:ok, term(), t()} - | {:error, term(), t()} - | {:cancelled, t()} - | {:eof, t()} - - defstruct prompt: "", - value: "", - label: nil, - validator: nil, - placeholder: "", - error: nil, - focused: false, - on_blur: nil - - @doc """ - Creates new TextInput.Line props. - - ## Options - - - `:prompt` - Text to display before input (default: "") - - `:value` - Initial value (default: "") - - `:label` - Optional label to display above input (default: nil) - - `:validator` - Validation function (default: nil) - - `:placeholder` - Text shown when value is empty (default: "") - - `:on_blur` - Callback when widget loses focus or completes input (default: nil) - - ## Examples - - # Simple input - TextInput.Line.new(prompt: "Name: ") - - # With label and placeholder - TextInput.Line.new( - prompt: "> ", - label: "Enter your name", - placeholder: "Type here..." - ) - - # With validation - TextInput.Line.new( - prompt: "Age: ", - validator: fn input -> - case Integer.parse(input) do - {age, ""} when age > 0 -> {:ok, age} - _ -> {:error, "Please enter a valid positive number"} - end - end - ) - """ - @spec new(keyword()) :: map() - def new(opts \\ []) do - %{ - prompt: Keyword.get(opts, :prompt, ""), - value: Keyword.get(opts, :value, ""), - label: Keyword.get(opts, :label), - validator: Keyword.get(opts, :validator), - placeholder: Keyword.get(opts, :placeholder, ""), - on_blur: Keyword.get(opts, :on_blur) - } - end - - @doc """ - Initializes TextInput.Line state from props. - - ## Examples - - props = TextInput.Line.new(prompt: "Name: ") - {:ok, state} = TextInput.Line.init(props) - """ - @spec init(map()) :: {:ok, t()} - def init(props) do - state = %__MODULE__{ - prompt: props.prompt, - value: props.value, - label: props.label, - validator: props.validator, - placeholder: props.placeholder, - error: nil, - focused: false, - on_blur: Map.get(props, :on_blur) - } - - {:ok, state} - end - - @doc """ - Reads a line of input from the user. - - This function blocks until the user presses Enter or EOF is received. - The shell provides line editing features during input. - - If a validator is configured, it will be applied to the input. The result - depends on validation: - - - Valid input: `{:ok, value, new_state}` - value may be transformed by validator - - Invalid input: `{:error, reason, new_state}` - error is stored in state - - EOF: `{:eof, new_state}` - - ## Examples - - case TextInput.Line.read(state) do - {:ok, value, state} -> - IO.puts("Got: \#{value}") - state - - {:error, reason, state} -> - IO.puts("Error: \#{reason}") - state - - {:eof, state} -> - IO.puts("EOF") - state - end - """ - @spec read(t()) :: read_result() - def read(%__MODULE__{} = state) do - case state.validator do - nil -> - # No validator, use simple read - case LineReader.read_line(state.prompt) do - {:ok, line} -> - new_state = %{state | value: line, error: nil} - {:ok, line, new_state} - - :eof -> - {:eof, state} - end - - validator when is_function(validator, 1) -> - # Has validator, use read_line/2 - case LineReader.read_line(state.prompt, validator) do - {:ok, value} -> - handle_read_success(state, value) - - {:error, reason} -> - handle_read_error(state, reason) - - :eof -> - {:eof, state} - end - end - end - - defp handle_read_success(state, value) do - string_value = format_value(value) - new_state = %{state | value: string_value, error: nil} - {:ok, value, new_state} - end - - defp handle_read_error(state, reason) do - error_msg = format_error(reason) - new_state = %{state | error: error_msg} - {:error, reason, new_state} - end - - defp format_value(value) when is_binary(value), do: value - defp format_value(value), do: inspect(value) - - defp format_error(reason) when is_binary(reason), do: reason - defp format_error(reason), do: inspect(reason) - - @doc """ - Gets the current value. - - ## Examples - - value = TextInput.Line.get_value(state) - """ - @spec get_value(t()) :: String.t() - def get_value(%__MODULE__{value: value}), do: value - - @doc """ - Sets the value programmatically. - - This does not trigger validation. Use `read/1` to get validated input. - - ## Examples - - state = TextInput.Line.set_value(state, "new value") - """ - @spec set_value(t(), String.t()) :: t() - def set_value(%__MODULE__{} = state, value) when is_binary(value) do - %{state | value: value, error: nil} - end - - @doc """ - Clears the current value and any error. - - ## Examples - - state = TextInput.Line.clear(state) - """ - @spec clear(t()) :: t() - def clear(%__MODULE__{} = state) do - %{state | value: "", error: nil} - end - - @doc """ - Gets the current error message, if any. - - ## Examples - - case TextInput.Line.get_error(state) do - nil -> IO.puts("No error") - error -> IO.puts("Error: \#{error}") - end - """ - @spec get_error(t()) :: String.t() | nil - def get_error(%__MODULE__{error: error}), do: error - - @doc """ - Checks if the widget has an error. - - ## Examples - - if TextInput.Line.has_error?(state) do - IO.puts("Please fix the error") - end - """ - @spec has_error?(t()) :: boolean() - def has_error?(%__MODULE__{error: error}), do: error != nil - - @doc """ - Clears the current error. - - ## Examples - - state = TextInput.Line.clear_error(state) - """ - @spec clear_error(t()) :: t() - def clear_error(%__MODULE__{} = state) do - %{state | error: nil} - end - - @doc """ - Gets the label, if any. - - ## Examples - - label = TextInput.Line.get_label(state) - """ - @spec get_label(t()) :: String.t() | nil - def get_label(%__MODULE__{label: label}), do: label - - @doc """ - Gets the prompt. - - ## Examples - - prompt = TextInput.Line.get_prompt(state) - """ - @spec get_prompt(t()) :: String.t() - def get_prompt(%__MODULE__{prompt: prompt}), do: prompt - - @doc """ - Gets the placeholder text. - - ## Examples - - placeholder = TextInput.Line.get_placeholder(state) - """ - @spec get_placeholder(t()) :: String.t() - def get_placeholder(%__MODULE__{placeholder: placeholder}), do: placeholder - - # ---------------------------------------------------------------------------- - # Focus Behavior - # ---------------------------------------------------------------------------- - - @doc """ - Handles focus gain by initiating a line read. - - When the widget gains focus, this function: - 1. Sets the focused state to true - 2. Initiates a blocking line read - 3. Returns the result with updated state - 4. Calls on_blur callback if configured - - The function blocks until the user presses Enter or cancels with Ctrl+C. - - ## Return Values - - - `{:ok, value, state}` - Successfully read and validated input - - `{:error, reason, state}` - Validation failed - - `{:cancelled, state}` - User cancelled with Ctrl+C (EOF) - - ## Examples - - {:ok, state} = TextInput.Line.init(TextInput.Line.new(prompt: "> ")) - result = TextInput.Line.handle_focus(state) - # User types "hello" and presses Enter - # => {:ok, "hello", %TextInput.Line{value: "hello", focused: false, ...}} - """ - @spec handle_focus(t()) :: read_result() - def handle_focus(%__MODULE__{} = state) do - # Set focused state - state = %{state | focused: true} - - # Perform the read (blocks until Enter or Ctrl+C) - result = do_focused_read(state) - - # Clear focus and call on_blur callback - result = unfocus_result(result) - call_on_blur(result) - - result - end - - # Performs the read while focused - defp do_focused_read(state) do - case state.validator do - nil -> - case LineReader.read_line(state.prompt) do - {:ok, line} -> - new_state = %{state | value: line, error: nil} - {:ok, line, new_state} - - :eof -> - {:cancelled, state} - end - - validator when is_function(validator, 1) -> - case LineReader.read_line(state.prompt, validator) do - {:ok, value} -> - handle_focused_read_success(state, value) - - {:error, reason} -> - handle_focused_read_error(state, reason) - - :eof -> - {:cancelled, state} - end - end - end - - defp handle_focused_read_success(state, value) do - string_value = format_value(value) - new_state = %{state | value: string_value, error: nil} - {:ok, value, new_state} - end - - defp handle_focused_read_error(state, reason) do - error_msg = format_error(reason) - new_state = %{state | error: error_msg} - {:error, reason, new_state} - end - - # Clear focused state in result - defp unfocus_result({:ok, value, state}), do: {:ok, value, %{state | focused: false}} - defp unfocus_result({:error, reason, state}), do: {:error, reason, %{state | focused: false}} - defp unfocus_result({:cancelled, state}), do: {:cancelled, %{state | focused: false}} - - # Call on_blur callback if configured (with error protection) - defp call_on_blur({_, _, state}) when is_function(state.on_blur, 1) do - state.on_blur.(state) - rescue - e -> - require Logger - Logger.error("TextInput.Line on_blur callback error: #{inspect(e)}") - end - - defp call_on_blur({:cancelled, state}) when is_function(state.on_blur, 1) do - state.on_blur.(state) - rescue - e -> - require Logger - Logger.error("TextInput.Line on_blur callback error: #{inspect(e)}") - end - - defp call_on_blur(_), do: :ok - - @doc """ - Checks if the widget is currently focused. - - ## Examples - - TextInput.Line.focused?(state) # => true or false - """ - @spec focused?(t()) :: boolean() - def focused?(%__MODULE__{focused: focused}), do: focused - - @doc """ - Sets the focus state directly. - - Typically you should use `handle_focus/1` instead, which initiates a read. - This function is useful for testing or manual focus management. - - ## Examples - - state = TextInput.Line.set_focused(state, true) - """ - @spec set_focused(t(), boolean()) :: t() - def set_focused(%__MODULE__{} = state, focused) when is_boolean(focused) do - %{state | focused: focused} - end - - @doc """ - Clears focus and calls the on_blur callback if configured. - - ## Examples - - state = TextInput.Line.blur(state) - """ - @spec blur(t()) :: t() - def blur(%__MODULE__{} = state) do - new_state = %{state | focused: false} - - if is_function(state.on_blur, 1) do - try do - state.on_blur.(new_state) - rescue - e -> - require Logger - Logger.error("TextInput.Line on_blur callback error: #{inspect(e)}") - end - end - - new_state - end - - # ---------------------------------------------------------------------------- - # Rendering - # ---------------------------------------------------------------------------- - - @doc """ - Renders the widget state as a render node tree. - - The render output consists of: - 1. Label (if provided) - displayed on first line - 2. Prompt + value (or placeholder if empty) - the input line - 3. Error message (if present) - displayed below in error styling - - ## Examples - - state = %TextInput.Line{prompt: "> ", value: "hello", label: "Name"} - node = TextInput.Line.render(state) - - ## Styling - - - Label: default foreground color - - Prompt: default foreground color - - Value: default foreground color - - Placeholder: dim/muted style (bright_black) - - Error: error style (red) - """ - @spec render(t()) :: TermUI.Component.RenderNode.t() - def render(%__MODULE__{} = state) do - parts = [] - - # 1. Add label if present - parts = - if state.label do - [render_label(state.label) | parts] - else - parts - end - - # 2. Add prompt + value/placeholder line - parts = [render_input_line(state) | parts] - - # 3. Add error if present - parts = - if state.error do - [render_error(state.error) | parts] - else - parts - end - - # Reverse to get correct order and build vertical stack - parts = Enum.reverse(parts) - - case parts do - [single] -> single - multiple -> stack(:vertical, multiple) - end - end - - # Renders the label line - defp render_label(label) do - text(label) - end - - # Renders the prompt + value or placeholder - defp render_input_line(state) do - display_text = - if state.value == "" and state.placeholder != "" do - # Show placeholder with muted style - placeholder_style = fg_color(:bright_black) - - stack(:horizontal, [ - text(state.prompt), - text(state.placeholder, placeholder_style) - ]) - else - # Show prompt + value - text(state.prompt <> state.value) - end - - display_text - end - - # ---------------------------------------------------------------------------- - # Style Helper Functions - # ---------------------------------------------------------------------------- - - @spec fg_semantic(atom()) :: Style.t() - defp fg_semantic(color) when is_atom(color), - do: Style.new() |> Style.fg(color) - - @spec fg_color(atom()) :: Style.t() - defp fg_color(color) when is_atom(color), - do: Style.new(fg: color) - - # ---------------------------------------------------------------------------- - # Error Rendering - # ---------------------------------------------------------------------------- - - # Renders the error message - defp render_error(error) do - error_style = fg_semantic(Theme.get_semantic(:error)) - text(error, error_style) - end -end diff --git a/lib/term_ui/widgets/toast.ex b/lib/term_ui/widgets/toast.ex deleted file mode 100644 index e89c7885..00000000 --- a/lib/term_ui/widgets/toast.ex +++ /dev/null @@ -1,407 +0,0 @@ -defmodule TermUI.Widgets.Toast do - @moduledoc """ - Toast notification widget for brief, auto-dismissing messages. - - Toasts appear at the screen edge and automatically dismiss after a duration. - Multiple toasts stack vertically. Toasts don't capture focus or block - interaction. - - ## Usage - - Toast.new( - message: "File saved successfully", - type: :success, - duration: 3000, - position: :bottom_right - ) - - ## Toast Types - - - `:info` - Information (blue) - - `:success` - Success (green) - - `:warning` - Warning (yellow) - - `:error` - Error (red) - - ## Positions - - - `:top_left`, `:top_center`, `:top_right` - - `:bottom_left`, `:bottom_center`, `:bottom_right` - """ - - use TermUI.StatefulComponent - - alias TermUI.CharacterSet - alias TermUI.Event - - # Icon keys mapped to CharacterSet fields - @type_icon_keys %{ - info: :info, - success: :check, - warning: :warning, - error: :cross_mark - } - - # Dialyzer: Functions return specific map types - @dialyzer {:nowarn_function, new: 1, dismiss_toast: 1} - - @doc """ - Creates new Toast widget props. - - ## Options - - - `:message` - Toast message (required) - - `:type` - Toast type: :info, :success, :warning, :error (default: :info) - - `:duration` - Auto-dismiss duration in ms (default: 3000, nil for no auto-dismiss) - - `:position` - Screen position (default: :bottom_right) - - `:width` - Toast width (default: 40) - - `:on_dismiss` - Callback when toast is dismissed - - `:style` - Style for toast background - - `:icon_style` - Style for icon - - `:message_style` - Style for message text - """ - @spec new(keyword()) :: map() - def new(opts) do - type = Keyword.get(opts, :type, :info) - - %{ - message: Keyword.fetch!(opts, :message), - type: type, - icon_key: Map.get(@type_icon_keys, type, nil), - duration: Keyword.get(opts, :duration, 3000), - position: Keyword.get(opts, :position, :bottom_right), - width: Keyword.get(opts, :width, 40), - on_dismiss: Keyword.get(opts, :on_dismiss), - style: Keyword.get(opts, :style), - icon_style: Keyword.get(opts, :icon_style), - message_style: Keyword.get(opts, :message_style) - } - end - - @impl true - def init(props) do - state = %{ - message: props.message, - toast_type: props.type, - icon_key: props.icon_key, - duration: props.duration, - position: props.position, - width: props.width, - on_dismiss: props.on_dismiss, - style: props.style, - icon_style: props.icon_style, - message_style: props.message_style, - visible: true, - created_at: System.monotonic_time(:millisecond) - } - - {:ok, state} - end - - @impl true - def handle_event(%Event.Key{key: :escape}, state) do - dismiss(state) - end - - def handle_event(%Event.Mouse{action: :click}, state) do - # Click on toast dismisses it - dismiss(state) - end - - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, area) do - if state.visible do - # Calculate position - {pos_x, pos_y} = calculate_position(state, area) - - # Render toast content - toast = render_toast(state) - - # Return as overlay - %{ - type: :overlay, - content: toast, - x: pos_x, - y: pos_y, - # Higher z-order than dialogs - z: 150 - } - else - empty() - end - end - - # Private functions - - defp calculate_position(state, area) do - width = state.width - # Single line toast - height = 3 - - case state.position do - :top_left -> {1, 1} - :top_center -> {div(area.width - width, 2), 1} - :top_right -> {area.width - width - 1, 1} - :bottom_left -> {1, area.height - height - 1} - :bottom_center -> {div(area.width - width, 2), area.height - height - 1} - :bottom_right -> {area.width - width - 1, area.height - height - 1} - _ -> {area.width - width - 1, area.height - height - 1} - end - end - - defp render_toast(state) do - chars = CharacterSet.current_charset() - width = state.width - - # Get icon from charset - icon = - case state.icon_key do - nil -> "" - key -> Map.get(chars, key, "") - end - - message = state.message - - content_text = - if icon != "" do - icon <> " " <> message - else - message - end - - # Truncate if too long - inner_width = width - 4 - content_text = String.slice(content_text, 0, inner_width) - padded = String.pad_trailing(content_text, inner_width) - - # Build toast box - top_border = text(chars.tl <> String.duplicate(chars.h_line, width - 2) <> chars.tr) - content_line = text(chars.v_line <> " " <> padded <> " " <> chars.v_line) - bottom_border = text(chars.bl <> String.duplicate(chars.h_line, width - 2) <> chars.br) - - content = stack(:vertical, [top_border, content_line, bottom_border]) - - if state.style do - styled(content, state.style) - else - content - end - end - - defp dismiss(state) do - if state.on_dismiss do - state.on_dismiss.() - end - - {:ok, %{state | visible: false}} - end - - # Public API - - @doc """ - Gets whether the toast is visible. - """ - @spec visible?(map()) :: boolean() - def visible?(state) do - state.visible - end - - @doc """ - Dismisses the toast. - """ - @spec dismiss_toast(map()) :: map() - def dismiss_toast(state) do - if state.on_dismiss do - state.on_dismiss.() - end - - %{state | visible: false} - end - - @doc """ - Checks if toast should auto-dismiss based on elapsed time. - """ - @spec should_dismiss?(map()) :: boolean() - def should_dismiss?(state) do - if state.duration do - elapsed = System.monotonic_time(:millisecond) - state.created_at - elapsed >= state.duration - else - false - end - end - - @doc """ - Gets the toast type. - """ - @spec get_type(map()) :: atom() - def get_type(state) do - state.toast_type - end - - @doc """ - Gets the toast position. - """ - @spec get_position(map()) :: atom() - def get_position(state) do - state.position - end - - @doc """ - Gets the elapsed time since toast was created. - """ - @spec elapsed_time(map()) :: non_neg_integer() - def elapsed_time(state) do - System.monotonic_time(:millisecond) - state.created_at - end -end - -defmodule TermUI.Widgets.ToastManager do - @moduledoc """ - Manages multiple toast notifications with stacking. - - ToastManager handles the lifecycle of multiple toasts, including - stacking, auto-dismiss, and position management. - - ## Usage - - # Create manager - {:ok, manager} = ToastManager.init(%{position: :bottom_right}) - - # Add toasts - manager = ToastManager.add_toast(manager, "File saved", :success) - manager = ToastManager.add_toast(manager, "Warning: Low disk space", :warning) - - # Update (check auto-dismiss) - manager = ToastManager.tick(manager) - """ - - alias TermUI.Widgets.Toast - - # Dialyzer: Functions return specific map types - @dialyzer {:nowarn_function, new: 1, tick: 1, clear_all: 1} - - @doc """ - Creates a new ToastManager. - """ - @spec new(keyword()) :: map() - def new(opts \\ []) do - %{ - toasts: [], - position: Keyword.get(opts, :position, :bottom_right), - max_toasts: Keyword.get(opts, :max_toasts, 5), - default_duration: Keyword.get(opts, :default_duration, 3000), - spacing: Keyword.get(opts, :spacing, 1) - } - end - - @doc """ - Adds a new toast to the manager. - """ - @spec add_toast(map(), String.t(), atom(), keyword()) :: map() - def add_toast(manager, message, type \\ :info, opts \\ []) do - toast_props = - Toast.new( - message: message, - type: type, - duration: Keyword.get(opts, :duration, manager.default_duration), - position: manager.position, - width: Keyword.get(opts, :width, 40), - on_dismiss: Keyword.get(opts, :on_dismiss) - ) - - {:ok, toast_state} = Toast.init(toast_props) - - # Add to list, respecting max - toasts = [toast_state | manager.toasts] - toasts = Enum.take(toasts, manager.max_toasts) - - %{manager | toasts: toasts} - end - - @doc """ - Updates the manager, removing dismissed toasts. - """ - @spec tick(map()) :: map() - def tick(manager) do - toasts = - manager.toasts - |> Enum.filter(fn toast -> - Toast.visible?(toast) && not Toast.should_dismiss?(toast) - end) - - %{manager | toasts: toasts} - end - - @doc """ - Gets all visible toasts. - """ - @spec get_toasts(map()) :: [map()] - def get_toasts(manager) do - Enum.filter(manager.toasts, &Toast.visible?/1) - end - - @doc """ - Gets the count of visible toasts. - """ - @spec toast_count(map()) :: non_neg_integer() - def toast_count(manager) do - length(get_toasts(manager)) - end - - @doc """ - Clears all toasts. - """ - @spec clear_all(map()) :: map() - def clear_all(manager) do - %{manager | toasts: []} - end - - @doc """ - Renders all toasts with stacking. - - Returns a list of overlay nodes that should be rendered. - Each toast is an overlay with absolute positioning. - """ - @spec render(map(), map()) :: term() - def render(manager, area) do - toasts = get_toasts(manager) - - if Enum.empty?(toasts) do - %{type: :empty} - else - # Render each toast with offset for stacking - # Each toast returns an overlay with absolute positioning - toast_overlays = - toasts - |> Enum.with_index() - |> Enum.map(fn {toast, index} -> - # Adjust position for stacking - # 3 = toast height - offset = index * (3 + manager.spacing) - adjusted_area = adjust_area_for_stack(area, manager.position, offset) - Toast.render(toast, adjusted_area) - end) - - # Return overlays as a list - NodeRenderer handles lists - # by rendering each item (overlays render at absolute positions) - toast_overlays - end - end - - defp adjust_area_for_stack(area, position, offset) do - case position do - pos when pos in [:top_left, :top_center, :top_right] -> - %{area | y: area.y + offset} - - pos when pos in [:bottom_left, :bottom_center, :bottom_right] -> - %{area | height: area.height - offset} - - _ -> - area - end - end -end diff --git a/lib/term_ui/widgets/tree_view.ex b/lib/term_ui/widgets/tree_view.ex deleted file mode 100644 index 5a6baeea..00000000 --- a/lib/term_ui/widgets/tree_view.ex +++ /dev/null @@ -1,994 +0,0 @@ -defmodule TermUI.Widgets.TreeView do - @moduledoc """ - TreeView widget for displaying hierarchical data with expand/collapse. - - TreeView renders a tree structure with indentation, supporting lazy loading - for large trees, keyboard navigation, single/multi-selection, and search filtering. - - ## Usage - - TreeView.new( - nodes: [ - TreeView.node(:root, "Root", children: [ - TreeView.node(:child1, "Child 1"), - TreeView.node(:child2, "Child 2", children: [ - TreeView.node(:grandchild, "Grandchild") - ]) - ]) - ], - on_select: fn node -> handle_select(node) end, - on_expand: fn node -> load_children(node) end - ) - - ## Node Structure - - Nodes are maps with: - - `:id` - Unique identifier (required) - - `:label` - Display text (required) - - `:icon` - Optional icon string - - `:children` - List of child nodes, `:lazy` for on-demand loading, or `nil` for leaf - - `:disabled` - Whether node is disabled - - `:metadata` - User-defined data - - ## Keyboard Navigation - - - Up/Down: Move cursor between visible nodes - - Left: Collapse node or move to parent - - Right: Expand node or move to first child - - Enter/Space: Toggle expand or select - - Home/End: Jump to first/last visible node - - PageUp/PageDown: Jump by page - - Ctrl+A: Select all (multi-select mode) - - Shift+Up/Down: Extend selection (multi-select mode) - - /: Start search filter - - Escape: Clear filter or deselect - - ## Monochrome Compatibility - - This widget is fully functional in monochrome terminals: - - Selected nodes use reverse video for visibility - - Cursor position uses bold text for focus indication - - Disabled nodes use dim text for de-emphasis - - Search matches highlighted with bold - - All interactive states remain distinguishable without color - - The widget uses theme component styles for monochrome support. - """ - - use TermUI.StatefulComponent - - alias TermUI.CharacterSet - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Theme - - # Dialyzer: Suppress opaque type warnings for Style helpers - # Also suppress contract_supertype for functions returning specific struct types - @dialyzer {:nowarn_function, - fg_semantic: 1, - fg_color: 1, - fg_bold_semantic: 1, - fg_bg_semantic: 2, - new: 1, - expand: 2, - collapse: 2, - expand_all: 1, - collapse_all: 1, - clear_selection: 1, - set_filter: 2, - clear_filter: 1, - finish_loading: 2} - - @type node_id :: term() - - @type tree_node :: %{ - id: node_id(), - label: String.t(), - icon: String.t() | nil, - children: [tree_node()] | :lazy | nil, - disabled: boolean(), - metadata: map() - } - - # Helper to get default icons from CharacterSet - defp default_icons do - chars = CharacterSet.current_charset() - - %{ - expanded: chars.triangle_down, - collapsed: chars.triangle_right, - leaf: " ", - loading: chars.loading - } - end - - # ---------------------------------------------------------------------------- - # Node Constructors - # ---------------------------------------------------------------------------- - - @doc """ - Creates a tree node. - - ## Options - - - `:children` - Child nodes, `:lazy` for on-demand loading, or omit for leaf - - `:icon` - Custom icon string - - `:disabled` - Whether node is disabled (default: false) - - `:metadata` - User-defined data map - """ - @spec node(node_id(), String.t(), keyword()) :: tree_node() - def node(id, label, opts \\ []) do - %{ - id: id, - label: label, - icon: Keyword.get(opts, :icon), - children: Keyword.get(opts, :children), - disabled: Keyword.get(opts, :disabled, false), - metadata: Keyword.get(opts, :metadata, %{}) - } - end - - @doc """ - Creates a leaf node (no children). - """ - @spec leaf(node_id(), String.t(), keyword()) :: tree_node() - def leaf(id, label, opts \\ []) do - node(id, label, Keyword.put(opts, :children, nil)) - end - - @doc """ - Creates a branch node with children. - """ - @spec branch(node_id(), String.t(), [tree_node()], keyword()) :: tree_node() - def branch(id, label, children, opts \\ []) do - node(id, label, Keyword.put(opts, :children, children)) - end - - @doc """ - Creates a lazy-loading node. - """ - @spec lazy(node_id(), String.t(), keyword()) :: tree_node() - def lazy(id, label, opts \\ []) do - node(id, label, Keyword.put(opts, :children, :lazy)) - end - - # ---------------------------------------------------------------------------- - # Style Helper Functions - # ---------------------------------------------------------------------------- - - @spec fg_semantic(atom()) :: Style.t() - defp fg_semantic(color) when is_atom(color), - do: Style.new() |> Style.fg(color) - - @spec fg_color(atom()) :: Style.t() - defp fg_color(color) when is_atom(color), - do: Style.new() |> Style.fg(color) - - @spec fg_bold_semantic(atom()) :: Style.t() - defp fg_bold_semantic(color) when is_atom(color), - do: Style.new() |> Style.fg(color) |> Style.bold() - - @spec fg_bg_semantic(atom(), atom()) :: Style.t() - defp fg_bg_semantic(fg, bg) when is_atom(fg) and is_atom(bg), - do: Style.new() |> Style.fg(fg) |> Style.bg(bg) - - # ---------------------------------------------------------------------------- - # Props - # ---------------------------------------------------------------------------- - - @doc """ - Creates new TreeView widget props. - - ## Options - - - `:nodes` - List of root nodes (required) - - `:on_select` - Callback when node is selected: `fn node -> ... end` - - `:on_expand` - Callback when node is expanded: `fn node -> children | :loading end` - - `:on_collapse` - Callback when node is collapsed: `fn node -> ... end` - - `:selection_mode` - `:single`, `:multi`, or `:none` (default: `:single`) - - `:show_root` - Show root nodes (default: true) - - `:indent_size` - Characters per indent level (default: 2) - - `:icons` - Icon configuration map - - `:initially_expanded` - List of node IDs to expand initially - - `:initially_selected` - List of node IDs to select initially - """ - @spec new(keyword()) :: map() - def new(opts) do - %{ - nodes: Keyword.fetch!(opts, :nodes), - on_select: Keyword.get(opts, :on_select), - on_expand: Keyword.get(opts, :on_expand), - on_collapse: Keyword.get(opts, :on_collapse), - selection_mode: Keyword.get(opts, :selection_mode, :single), - show_root: Keyword.get(opts, :show_root, true), - indent_size: Keyword.get(opts, :indent_size, 2), - icons: Map.merge(default_icons(), Keyword.get(opts, :icons, %{})), - initially_expanded: Keyword.get(opts, :initially_expanded, []), - initially_selected: Keyword.get(opts, :initially_selected, []) - } - end - - # ---------------------------------------------------------------------------- - # StatefulComponent Callbacks - # ---------------------------------------------------------------------------- - - @impl true - def init(props) do - expanded = MapSet.new(props.initially_expanded) - selected = MapSet.new(props.initially_selected) - - flat_nodes = flatten_nodes(props.nodes, expanded, 0, []) - - state = %{ - nodes: props.nodes, - flat_nodes: flat_nodes, - cursor: 0, - selected: selected, - expanded: expanded, - loading: MapSet.new(), - filter: nil, - filter_matches: MapSet.new(), - selection_mode: props.selection_mode, - selection_anchor: nil, - show_root: props.show_root, - indent_size: props.indent_size, - icons: props.icons, - on_select: props.on_select, - on_expand: props.on_expand, - on_collapse: props.on_collapse - } - - {:ok, state} - end - - @impl true - def update(new_props, state) do - # Update nodes if provided - nodes = Map.get(new_props, :nodes, state.nodes) - - # Recalculate flat nodes - flat_nodes = flatten_nodes(nodes, state.expanded, 0, []) - - # Clamp cursor to valid range - cursor = min(state.cursor, max(0, length(flat_nodes) - 1)) - - state = %{state | nodes: nodes, flat_nodes: flat_nodes, cursor: cursor} - - {:ok, state} - end - - @impl true - def handle_event(%Event.Key{key: :up, modifiers: modifiers}, state) do - state = move_cursor(state, -1, :shift in modifiers) - {:ok, state} - end - - def handle_event(%Event.Key{key: :down, modifiers: modifiers}, state) do - state = move_cursor(state, 1, :shift in modifiers) - {:ok, state} - end - - def handle_event(%Event.Key{key: :left}, state) do - state = handle_left(state) - {:ok, state} - end - - def handle_event(%Event.Key{key: :right}, state) do - state = handle_right(state) - {:ok, state} - end - - def handle_event(%Event.Key{key: :enter}, state) do - state = handle_select_or_toggle(state) - {:ok, state} - end - - def handle_event(%Event.Key{key: " "}, state) do - state = handle_select_or_toggle(state) - {:ok, state} - end - - def handle_event(%Event.Key{key: :home}, state) do - state = %{state | cursor: 0, selection_anchor: nil} - {:ok, state} - end - - def handle_event(%Event.Key{key: :end}, state) do - max_cursor = max(0, length(state.flat_nodes) - 1) - state = %{state | cursor: max_cursor, selection_anchor: nil} - {:ok, state} - end - - def handle_event(%Event.Key{key: :page_up}, state) do - state = move_cursor(state, -10, false) - {:ok, state} - end - - def handle_event(%Event.Key{key: :page_down}, state) do - state = move_cursor(state, 10, false) - {:ok, state} - end - - def handle_event(%Event.Key{key: :escape}, state) do - state = - cond do - state.filter != nil -> - # Clear filter - flat_nodes = flatten_nodes(state.nodes, state.expanded, 0, []) - %{state | filter: nil, filter_matches: MapSet.new(), flat_nodes: flat_nodes} - - MapSet.size(state.selected) > 0 -> - # Clear selection - %{state | selected: MapSet.new()} - - true -> - state - end - - {:ok, state} - end - - def handle_event(%Event.Key{key: :backspace}, state) when state.filter != nil do - # Delete character from filter - new_filter = - if String.length(state.filter) > 0 do - String.slice(state.filter, 0..-2//1) - else - nil - end - - state = apply_filter(state, new_filter) - {:ok, state} - end - - def handle_event(%Event.Key{char: char}, state) - when state.filter != nil and is_binary(char) and char != "" do - # Add character to filter - new_filter = state.filter <> char - state = apply_filter(state, new_filter) - {:ok, state} - end - - def handle_event(%Event.Key{char: "a", modifiers: modifiers}, state) do - # Select all in multi-select mode (Ctrl+A) - if :ctrl in modifiers && state.selection_mode == :multi do - all_ids = - state.flat_nodes - |> Enum.map(fn {node, _depth, _path} -> node.id end) - |> MapSet.new() - - state = %{state | selected: all_ids} - {:ok, state} - else - {:ok, state} - end - end - - def handle_event(%Event.Key{char: "/"}, state) when state.filter == nil do - # Start filter mode (just set empty filter for now) - state = %{state | filter: ""} - {:ok, state} - end - - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, _area) do - if state.flat_nodes == [] do - text("(empty)") - else - rows = - state.flat_nodes - |> Enum.with_index() - |> Enum.map(fn {{node, depth, _path}, index} -> - render_node(node, depth, index, state) - end) - - # Add filter indicator if filtering - rows = - if state.filter != nil do - filter_row = render_filter_bar(state) - [filter_row | rows] - else - rows - end - - stack(:vertical, rows) - end - end - - # ---------------------------------------------------------------------------- - # Navigation Helpers - # ---------------------------------------------------------------------------- - - defp move_cursor(state, delta, extend_selection) do - max_cursor = max(0, length(state.flat_nodes) - 1) - old_cursor = state.cursor - new_cursor = state.cursor + delta - new_cursor = max(0, min(max_cursor, new_cursor)) - - state = %{state | cursor: new_cursor} - - # Handle selection extension in multi-select mode - if extend_selection && state.selection_mode == :multi do - extend_selection_to(state, old_cursor, new_cursor) - else - %{state | selection_anchor: nil} - end - end - - defp extend_selection_to(state, old_cursor, new_cursor) do - # Use existing anchor or the old cursor position - anchor = state.selection_anchor || old_cursor - - # Select all nodes between anchor and new cursor - {start_idx, end_idx} = - if anchor <= new_cursor do - {anchor, new_cursor} - else - {new_cursor, anchor} - end - - selected_ids = - state.flat_nodes - |> Enum.slice(start_idx..end_idx) - |> Enum.map(fn {node, _depth, _path} -> node.id end) - |> MapSet.new() - - %{state | selected: selected_ids, selection_anchor: anchor} - end - - defp handle_left(state) do - case get_current_node(state) do - nil -> - state - - {node, _depth, path} -> - cond do - # If expanded, collapse it - has_children?(node) && MapSet.member?(state.expanded, node.id) -> - collapse_node(state, node) - - # If not expanded, move to parent - length(path) > 0 -> - parent_id = List.last(path) - move_to_node(state, parent_id) - - true -> - state - end - end - end - - defp handle_right(state) do - case get_current_node(state) do - nil -> - state - - {node, _depth, _path} -> - cond do - # If has children and collapsed, expand - has_children?(node) && !MapSet.member?(state.expanded, node.id) -> - expand_node(state, node) - - # If expanded, move to first child - has_children?(node) && MapSet.member?(state.expanded, node.id) -> - move_to_first_child(state) - - true -> - state - end - end - end - - defp handle_select_or_toggle(state) do - case get_current_node(state) do - nil -> - state - - {node, _depth, _path} -> - if has_children?(node) do - toggle_node_expand(state, node) - else - # Select leaf node - select_node(state, node) - end - end - end - - defp toggle_node_expand(state, node) do - if MapSet.member?(state.expanded, node.id) do - collapse_node(state, node) - else - expand_node(state, node) - end - end - - defp expand_node(state, node) do - cond do - # Already expanded - MapSet.member?(state.expanded, node.id) -> - state - - # Lazy loading needed - node.children == :lazy -> - # Mark as loading and call on_expand callback - state = %{state | loading: MapSet.put(state.loading, node.id)} - - if state.on_expand do - try do - state.on_expand.(node) - rescue - e -> - require Logger - Logger.error("TreeView on_expand callback error: #{inspect(e)}") - end - end - - state - - # Has children, expand - is_list(node.children) -> - expanded = MapSet.put(state.expanded, node.id) - flat_nodes = flatten_nodes(state.nodes, expanded, 0, []) - - if state.on_expand do - try do - state.on_expand.(node) - rescue - _ -> :ok - end - end - - %{state | expanded: expanded, flat_nodes: flat_nodes} - - true -> - state - end - end - - defp collapse_node(state, node) do - expanded = MapSet.delete(state.expanded, node.id) - flat_nodes = flatten_nodes(state.nodes, expanded, 0, []) - - if state.on_collapse do - try do - state.on_collapse.(node) - rescue - _ -> :ok - end - end - - # Clamp cursor if it was on a now-hidden node - cursor = min(state.cursor, max(0, length(flat_nodes) - 1)) - - %{state | expanded: expanded, flat_nodes: flat_nodes, cursor: cursor} - end - - defp select_node(state, node) do - selected = - case state.selection_mode do - :none -> - state.selected - - :single -> - MapSet.new([node.id]) - - :multi -> - if MapSet.member?(state.selected, node.id) do - MapSet.delete(state.selected, node.id) - else - MapSet.put(state.selected, node.id) - end - end - - if state.on_select && state.selection_mode != :none do - try do - state.on_select.(node) - rescue - e -> - require Logger - Logger.error("TreeView on_select callback error: #{inspect(e)}") - end - end - - %{state | selected: selected} - end - - defp move_to_node(state, node_id) do - case Enum.find_index(state.flat_nodes, fn {n, _, _} -> n.id == node_id end) do - nil -> state - index -> %{state | cursor: index} - end - end - - defp move_to_first_child(state) do - if state.cursor + 1 < length(state.flat_nodes) do - %{state | cursor: state.cursor + 1} - else - state - end - end - - # ---------------------------------------------------------------------------- - # Filter Helpers - # ---------------------------------------------------------------------------- - - defp apply_filter(state, nil) do - flat_nodes = flatten_nodes(state.nodes, state.expanded, 0, []) - %{state | filter: nil, filter_matches: MapSet.new(), flat_nodes: flat_nodes} - end - - defp apply_filter(state, "") do - flat_nodes = flatten_nodes(state.nodes, state.expanded, 0, []) - %{state | filter: "", filter_matches: MapSet.new(), flat_nodes: flat_nodes} - end - - defp apply_filter(state, filter) do - filter_lower = String.downcase(filter) - - # Find matching nodes and their ancestors - {matches, ancestors} = - find_filter_matches(state.nodes, filter_lower, [], MapSet.new(), MapSet.new()) - - # Expand all ancestors of matches - expanded = MapSet.union(state.expanded, ancestors) - - # Flatten with expanded ancestors - flat_nodes = flatten_nodes(state.nodes, expanded, 0, []) - - # Filter to only show matches and their ancestors - all_visible = MapSet.union(matches, ancestors) - - flat_nodes = - if MapSet.size(matches) > 0 do - Enum.filter(flat_nodes, fn {node, _depth, _path} -> - MapSet.member?(all_visible, node.id) - end) - else - flat_nodes - end - - cursor = min(state.cursor, max(0, length(flat_nodes) - 1)) - - %{ - state - | filter: filter, - filter_matches: matches, - flat_nodes: flat_nodes, - expanded: expanded, - cursor: cursor - } - end - - defp find_filter_matches(nodes, filter, path, matches, ancestors) do - Enum.reduce(nodes, {matches, ancestors}, fn node, acc -> - process_filter_node(node, filter, path, acc) - end) - end - - defp process_filter_node(node, filter, path, {matches_acc, ancestors_acc}) do - label_lower = String.downcase(node.label) - is_match = String.contains?(label_lower, filter) - - {child_matches, child_ancestors} = - find_child_filter_matches(node, filter, path, matches_acc, ancestors_acc) - - if should_include_in_filter?(is_match, child_matches, matches_acc) do - new_matches = update_filter_matches(child_matches, is_match, node.id) - new_ancestors = add_path_to_ancestors(path, child_ancestors) - {new_matches, new_ancestors} - else - {child_matches, child_ancestors} - end - end - - defp find_child_filter_matches(node, filter, path, matches_acc, ancestors_acc) do - case node.children do - children when is_list(children) -> - find_filter_matches(children, filter, path ++ [node.id], matches_acc, ancestors_acc) - - _ -> - {matches_acc, ancestors_acc} - end - end - - defp should_include_in_filter?(is_match, child_matches, matches_acc) do - has_descendant_match = MapSet.size(child_matches) > MapSet.size(matches_acc) - is_match or has_descendant_match - end - - defp update_filter_matches(child_matches, true, node_id) do - MapSet.put(child_matches, node_id) - end - - defp update_filter_matches(child_matches, false, _node_id) do - child_matches - end - - defp add_path_to_ancestors(path, child_ancestors) do - Enum.reduce(path, child_ancestors, &MapSet.put(&2, &1)) - end - - # ---------------------------------------------------------------------------- - # Node Helpers - # ---------------------------------------------------------------------------- - - defp get_current_node(state) do - Enum.at(state.flat_nodes, state.cursor) - end - - defp has_children?(node) do - case node.children do - nil -> false - :lazy -> true - [] -> false - [_ | _] -> true - end - end - - defp flatten_nodes(nodes, expanded, depth, path) do - Enum.flat_map(nodes, fn node -> - current = {node, depth, path} - - children_flat = - cond do - !MapSet.member?(expanded, node.id) -> - [] - - is_list(node.children) -> - flatten_nodes(node.children, expanded, depth + 1, path ++ [node.id]) - - true -> - [] - end - - [current | children_flat] - end) - end - - # ---------------------------------------------------------------------------- - # Rendering - # ---------------------------------------------------------------------------- - - defp render_node(node, depth, index, state) do - is_cursor = index == state.cursor - is_selected = MapSet.member?(state.selected, node.id) - is_loading = MapSet.member?(state.loading, node.id) - is_match = state.filter != nil && MapSet.member?(state.filter_matches, node.id) - - indent = String.duplicate(" ", depth * state.indent_size) - indicator = node_indicator(node, is_loading, state) - icon = node_icon(node) - selection_prefix = selection_prefix(is_cursor, is_selected) - - label = node.label - line = "#{selection_prefix}#{indent}#{indicator} #{icon}#{label}" - - apply_node_style(line, node, is_cursor, is_selected, is_match) - end - - defp node_indicator(node, is_loading, state) do - cond do - is_loading -> - state.icons.loading - - has_children?(node) && MapSet.member?(state.expanded, node.id) -> - state.icons.expanded - - has_children?(node) -> - state.icons.collapsed - - true -> - state.icons.leaf - end - end - - defp node_icon(%{icon: icon}) when is_binary(icon), do: "#{icon} " - defp node_icon(_), do: "" - - defp selection_prefix(is_cursor, is_selected) do - chars = CharacterSet.current_charset() - - cond do - is_cursor && is_selected -> chars.bullet - is_cursor -> chars.pointer - is_selected -> chars.bullet_empty - true -> " " - end - end - - defp apply_node_style(line, node, is_cursor, is_selected, is_match) do - cond do - node.disabled -> - styled(text(line), fg_semantic(Theme.get_semantic(:muted))) - - is_cursor && is_match -> - cursor_match_style = - fg_bg_semantic(Theme.get_color(:background), Theme.get_semantic(:warning)) - - styled(text(line), cursor_match_style) - - is_cursor -> - styled(text(line), Theme.get_component_style(:item, :focused)) - - is_match -> - styled(text(line), fg_semantic(Theme.get_semantic(:warning))) - - is_selected -> - styled(text(line), fg_color(Theme.get_color(:primary))) - - true -> - text(line) - end - end - - defp render_filter_bar(state) do - filter_text = "Filter: #{state.filter}_" - match_count = MapSet.size(state.filter_matches) - count_text = if match_count > 0, do: " (#{match_count} matches)", else: " (no matches)" - - filter_style = fg_bold_semantic(Theme.get_semantic(:warning)) - styled(text(filter_text <> count_text), filter_style) - end - - # ---------------------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------------------- - - @doc """ - Gets the currently selected node IDs. - """ - @spec get_selected(map()) :: MapSet.t(node_id()) - def get_selected(state) do - state.selected - end - - @doc """ - Gets the currently focused node. - """ - @spec get_focused(map()) :: tree_node() | nil - def get_focused(state) do - case get_current_node(state) do - {node, _depth, _path} -> node - nil -> nil - end - end - - @doc """ - Gets the expanded node IDs. - """ - @spec get_expanded(map()) :: MapSet.t(node_id()) - def get_expanded(state) do - state.expanded - end - - @doc """ - Expands a node by ID. - """ - @spec expand(map(), node_id()) :: map() - def expand(state, node_id) do - expanded = MapSet.put(state.expanded, node_id) - flat_nodes = flatten_nodes(state.nodes, expanded, 0, []) - %{state | expanded: expanded, flat_nodes: flat_nodes} - end - - @doc """ - Collapses a node by ID. - """ - @spec collapse(map(), node_id()) :: map() - def collapse(state, node_id) do - expanded = MapSet.delete(state.expanded, node_id) - flat_nodes = flatten_nodes(state.nodes, expanded, 0, []) - cursor = min(state.cursor, max(0, length(flat_nodes) - 1)) - %{state | expanded: expanded, flat_nodes: flat_nodes, cursor: cursor} - end - - @doc """ - Expands all nodes. - """ - @spec expand_all(map()) :: map() - def expand_all(state) do - all_ids = collect_all_branch_ids(state.nodes) - expanded = MapSet.new(all_ids) - flat_nodes = flatten_nodes(state.nodes, expanded, 0, []) - %{state | expanded: expanded, flat_nodes: flat_nodes} - end - - @doc """ - Collapses all nodes. - """ - @spec collapse_all(map()) :: map() - def collapse_all(state) do - flat_nodes = flatten_nodes(state.nodes, MapSet.new(), 0, []) - %{state | expanded: MapSet.new(), flat_nodes: flat_nodes, cursor: 0} - end - - @doc """ - Sets the selection programmatically. - """ - @spec set_selected(map(), [node_id()]) :: map() - def set_selected(state, node_ids) do - %{state | selected: MapSet.new(node_ids)} - end - - @doc """ - Clears the selection. - """ - @spec clear_selection(map()) :: map() - def clear_selection(state) do - %{state | selected: MapSet.new()} - end - - @doc """ - Sets the filter programmatically. - """ - @spec set_filter(map(), String.t() | nil) :: map() - def set_filter(state, filter) do - apply_filter(state, filter) - end - - @doc """ - Clears the filter. - """ - @spec clear_filter(map()) :: map() - def clear_filter(state) do - apply_filter(state, nil) - end - - @doc """ - Updates the children of a node (for lazy loading). - """ - @spec set_children(map(), node_id(), [tree_node()]) :: map() - def set_children(state, node_id, children) do - nodes = update_node_children(state.nodes, node_id, children) - loading = MapSet.delete(state.loading, node_id) - expanded = MapSet.put(state.expanded, node_id) - flat_nodes = flatten_nodes(nodes, expanded, 0, []) - %{state | nodes: nodes, flat_nodes: flat_nodes, loading: loading, expanded: expanded} - end - - @doc """ - Marks a node as finished loading (clears loading state). - """ - @spec finish_loading(map(), node_id()) :: map() - def finish_loading(state, node_id) do - %{state | loading: MapSet.delete(state.loading, node_id)} - end - - # ---------------------------------------------------------------------------- - # Private Helpers - # ---------------------------------------------------------------------------- - - defp collect_all_branch_ids(nodes) do - Enum.flat_map(nodes, fn node -> - case node.children do - children when is_list(children) and children != [] -> - [node.id | collect_all_branch_ids(children)] - - :lazy -> - [node.id] - - _ -> - [] - end - end) - end - - defp update_node_children(nodes, target_id, new_children) do - Enum.map(nodes, fn node -> - cond do - node.id == target_id -> - %{node | children: new_children} - - is_list(node.children) -> - %{node | children: update_node_children(node.children, target_id, new_children)} - - true -> - node - end - end) - end -end diff --git a/lib/term_ui/widgets/viewport.ex b/lib/term_ui/widgets/viewport.ex deleted file mode 100644 index 53ba05a7..00000000 --- a/lib/term_ui/widgets/viewport.ex +++ /dev/null @@ -1,574 +0,0 @@ -defmodule TermUI.Widgets.Viewport do - @moduledoc """ - Viewport widget for scrollable content. - - Viewport displays a scrollable view of content larger than the viewport area. - It tracks scroll position, clips content to bounds, and optionally shows - scroll bars for visual feedback and interaction. - - ## Usage - - Viewport.new( - content: large_content_tree(), - width: 40, - height: 20, - scroll_bars: :both - ) - - ## Features - - - Scrollable view of larger content - - Automatic content clipping to viewport bounds - - Optional vertical and horizontal scroll bars - - Keyboard navigation (arrow keys, Page Up/Down, Home/End) - - Mouse wheel scrolling - - Scroll bar drag interaction - - ## Keyboard Navigation - - - Arrow keys: Scroll by one line/column - - Page Up/Down: Scroll by viewport height - - Home/End: Scroll to top/bottom - - Ctrl+Home/End: Scroll to start/end horizontally - """ - - use TermUI.StatefulComponent - - alias TermUI.CharacterSet - alias TermUI.Event - - # Dialyzer: Functions return specific map types - @dialyzer {:nowarn_function, new: 1, set_content: 2, render: 2} - - @doc """ - Creates new Viewport widget props. - - ## Options - - - `:content` - Content to display (render node) - - `:content_width` - Width of content (for horizontal scrolling) - - `:content_height` - Height of content (for vertical scrolling) - - `:width` - Viewport width (default: 40) - - `:height` - Viewport height (default: 20) - - `:scroll_x` - Initial horizontal scroll position (default: 0) - - `:scroll_y` - Initial vertical scroll position (default: 0) - - `:scroll_bars` - Scroll bar display: :none, :vertical, :horizontal, :both (default: :both) - - `:on_scroll` - Callback when scroll position changes - - `:scroll_step` - Lines to scroll per step (default: 1) - - `:page_step` - Lines to scroll per page (default: viewport height) - """ - @spec new(keyword()) :: map() - def new(opts) do - %{ - content: Keyword.get(opts, :content, empty()), - content_width: Keyword.get(opts, :content_width, 100), - content_height: Keyword.get(opts, :content_height, 100), - width: Keyword.get(opts, :width, 40), - height: Keyword.get(opts, :height, 20), - scroll_x: Keyword.get(opts, :scroll_x, 0), - scroll_y: Keyword.get(opts, :scroll_y, 0), - scroll_bars: Keyword.get(opts, :scroll_bars, :both), - on_scroll: Keyword.get(opts, :on_scroll), - scroll_step: Keyword.get(opts, :scroll_step, 1), - page_step: Keyword.get(opts, :page_step) - } - end - - @impl true - def init(props) do - state = %{ - content: props.content, - content_width: props.content_width, - content_height: props.content_height, - width: props.width, - height: props.height, - scroll_x: clamp_scroll(props.scroll_x, props.content_width, viewport_width(props)), - scroll_y: clamp_scroll(props.scroll_y, props.content_height, viewport_height(props)), - scroll_bars: props.scroll_bars, - on_scroll: props.on_scroll, - scroll_step: props.scroll_step, - page_step: props.page_step || props.height, - dragging: nil - } - - {:ok, state} - end - - @impl true - def handle_event(%Event.Key{key: :up}, state) do - scroll_by(state, 0, -state.scroll_step) - end - - def handle_event(%Event.Key{key: :down}, state) do - scroll_by(state, 0, state.scroll_step) - end - - def handle_event(%Event.Key{key: :left}, state) do - scroll_by(state, -state.scroll_step, 0) - end - - def handle_event(%Event.Key{key: :right}, state) do - scroll_by(state, state.scroll_step, 0) - end - - def handle_event(%Event.Key{key: :page_up}, state) do - scroll_by(state, 0, -state.page_step) - end - - def handle_event(%Event.Key{key: :page_down}, state) do - scroll_by(state, 0, state.page_step) - end - - def handle_event(%Event.Key{key: :home, modifiers: modifiers}, state) do - if :ctrl in modifiers do - # Ctrl+Home: scroll to top-left - scroll_to(state, 0, 0) - else - # Home: scroll to top - scroll_to(state, state.scroll_x, 0) - end - end - - def handle_event(%Event.Key{key: :end, modifiers: modifiers}, state) do - if :ctrl in modifiers do - # Ctrl+End: scroll to bottom-right - max_x = max(0, state.content_width - viewport_width(state)) - max_y = max(0, state.content_height - viewport_height(state)) - scroll_to(state, max_x, max_y) - else - # End: scroll to bottom - max_y = max(0, state.content_height - viewport_height(state)) - scroll_to(state, state.scroll_x, max_y) - end - end - - def handle_event(%Event.Mouse{action: :scroll_up}, state) do - scroll_by(state, 0, -state.scroll_step * 3) - end - - def handle_event(%Event.Mouse{action: :scroll_down}, state) do - scroll_by(state, 0, state.scroll_step * 3) - end - - def handle_event(%Event.Mouse{action: :click, x: x, y: y}, state) do - # Check if click is on scroll bar - cond do - click_on_vertical_bar?(state, x, y) -> - handle_vertical_bar_click(state, y) - - click_on_horizontal_bar?(state, x, y) -> - handle_horizontal_bar_click(state, x) - - true -> - {:ok, state} - end - end - - def handle_event(%Event.Mouse{action: :drag, x: x, y: y}, state) do - case state.dragging do - :vertical -> - handle_vertical_drag(state, y) - - :horizontal -> - handle_horizontal_drag(state, x) - - nil -> - {:ok, state} - end - end - - def handle_event(%Event.Mouse{action: :release}, state) do - {:ok, %{state | dragging: nil}} - end - - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, _area) do - # Get character set for scrollbar characters - chars = CharacterSet.current_charset() - vp_width = viewport_width(state) - vp_height = viewport_height(state) - - # Render clipped content - content = render_clipped_content(state, vp_width, vp_height) - - # Render scroll bars if enabled - case state.scroll_bars do - :none -> - content - - :vertical -> - v_bar = render_vertical_bar(state, vp_height, chars) - stack(:horizontal, [content, v_bar]) - - :horizontal -> - h_bar = render_horizontal_bar(state, vp_width, chars) - stack(:vertical, [content, h_bar]) - - :both -> - corner_chars = CharacterSet.current_charset() - v_bar = render_vertical_bar(state, vp_height, chars) - h_bar = render_horizontal_bar(state, vp_width, chars) - - # Content + vertical bar on top, horizontal bar on bottom - top_row = stack(:horizontal, [content, v_bar]) - # Add corner piece - corner = text(corner_chars.bar_empty) - bottom_row = stack(:horizontal, [h_bar, corner]) - - stack(:vertical, [top_row, bottom_row]) - end - end - - # Private functions - - defp viewport_width(state) do - case state.scroll_bars do - :vertical -> state.width - 1 - :both -> state.width - 1 - _ -> state.width - end - end - - defp viewport_height(state) do - case state.scroll_bars do - :horizontal -> state.height - 1 - :both -> state.height - 1 - _ -> state.height - end - end - - defp clamp_scroll(scroll, content_size, viewport_size) do - max_scroll = max(0, content_size - viewport_size) - min(max(0, scroll), max_scroll) - end - - defp scroll_by(state, dx, dy) do - new_x = state.scroll_x + dx - new_y = state.scroll_y + dy - scroll_to(state, new_x, new_y) - end - - defp scroll_to(state, x, y) do - vp_width = viewport_width(state) - vp_height = viewport_height(state) - - new_x = clamp_scroll(x, state.content_width, vp_width) - new_y = clamp_scroll(y, state.content_height, vp_height) - - if new_x != state.scroll_x or new_y != state.scroll_y do - new_state = %{state | scroll_x: new_x, scroll_y: new_y} - - if state.on_scroll do - state.on_scroll.(new_x, new_y) - end - - {:ok, new_state} - else - {:ok, state} - end - end - - defp render_clipped_content(state, vp_width, vp_height) do - # Create a viewport container that clips content - %{ - type: :viewport, - content: state.content, - scroll_x: state.scroll_x, - scroll_y: state.scroll_y, - width: vp_width, - height: vp_height - } - end - - defp render_vertical_bar(state, height, _chars) do - vp_height = viewport_height(state) - - # Calculate thumb position and size - visible_fraction = min(1.0, vp_height / max(1, state.content_height)) - thumb_size = max(1, round(height * visible_fraction)) - - scroll_fraction = - if state.content_height <= vp_height do - 0.0 - else - state.scroll_y / (state.content_height - vp_height) - end - - thumb_pos = round((height - thumb_size) * scroll_fraction) - - charset = CharacterSet.current_charset() - - # Build the bar - lines = - for y <- 0..(height - 1) do - char = - if y >= thumb_pos and y < thumb_pos + thumb_size do - charset.bar_full - else - charset.bar_empty - end - - text(char) - end - - stack(:vertical, lines) - end - - defp render_horizontal_bar(state, width, chars) do - charset = chars - vp_width = viewport_width(state) - - # Calculate thumb position and size - visible_fraction = min(1.0, vp_width / max(1, state.content_width)) - thumb_size = max(1, round(width * visible_fraction)) - - scroll_fraction = - if state.content_width <= vp_width do - 0.0 - else - state.scroll_x / (state.content_width - vp_width) - end - - thumb_pos = round((width - thumb_size) * scroll_fraction) - - # Build the bar - bar_chars = - for x <- 0..(width - 1) do - if x >= thumb_pos and x < thumb_pos + thumb_size do - charset.bar_full - else - charset.bar_empty - end - end - - text(Enum.join(bar_chars)) - end - - defp click_on_vertical_bar?(state, x, _y) do - has_vertical_bar?(state) and x >= viewport_width(state) - end - - defp click_on_horizontal_bar?(state, _x, y) do - has_horizontal_bar?(state) and y >= viewport_height(state) - end - - defp has_vertical_bar?(state) do - state.scroll_bars in [:vertical, :both] - end - - defp has_horizontal_bar?(state) do - state.scroll_bars in [:horizontal, :both] - end - - defp handle_vertical_bar_click(state, y) do - vp_height = viewport_height(state) - - # Calculate thumb position - visible_fraction = min(1.0, vp_height / max(1, state.content_height)) - thumb_size = max(1, round(vp_height * visible_fraction)) - - scroll_fraction = - if state.content_height <= vp_height do - 0.0 - else - state.scroll_y / (state.content_height - vp_height) - end - - thumb_pos = round((vp_height - thumb_size) * scroll_fraction) - - if y >= thumb_pos and y < thumb_pos + thumb_size do - # Click on thumb - start dragging - {:ok, %{state | dragging: :vertical}} - else - # Click on track - page scroll - if y < thumb_pos do - scroll_by(state, 0, -state.page_step) - else - scroll_by(state, 0, state.page_step) - end - end - end - - defp handle_horizontal_bar_click(state, x) do - vp_width = viewport_width(state) - - # Calculate thumb position - visible_fraction = min(1.0, vp_width / max(1, state.content_width)) - thumb_size = max(1, round(vp_width * visible_fraction)) - - scroll_fraction = - if state.content_width <= vp_width do - 0.0 - else - state.scroll_x / (state.content_width - vp_width) - end - - thumb_pos = round((vp_width - thumb_size) * scroll_fraction) - - if x >= thumb_pos and x < thumb_pos + thumb_size do - # Click on thumb - start dragging - {:ok, %{state | dragging: :horizontal}} - else - # Click on track - page scroll - if x < thumb_pos do - scroll_by(state, -state.page_step, 0) - else - scroll_by(state, state.page_step, 0) - end - end - end - - defp handle_vertical_drag(state, y) do - vp_height = viewport_height(state) - - if state.content_height <= vp_height do - {:ok, state} - else - # Convert y position to scroll position - visible_fraction = min(1.0, vp_height / max(1, state.content_height)) - thumb_size = max(1, round(vp_height * visible_fraction)) - track_size = vp_height - thumb_size - - if track_size > 0 do - scroll_fraction = y / track_size - new_y = round(scroll_fraction * (state.content_height - vp_height)) - scroll_to(state, state.scroll_x, new_y) - else - {:ok, state} - end - end - end - - defp handle_horizontal_drag(state, x) do - vp_width = viewport_width(state) - - if state.content_width <= vp_width do - {:ok, state} - else - # Convert x position to scroll position - visible_fraction = min(1.0, vp_width / max(1, state.content_width)) - thumb_size = max(1, round(vp_width * visible_fraction)) - track_size = vp_width - thumb_size - - if track_size > 0 do - scroll_fraction = x / track_size - new_x = round(scroll_fraction * (state.content_width - vp_width)) - scroll_to(state, new_x, state.scroll_y) - else - {:ok, state} - end - end - end - - # Public API - - @doc """ - Gets the current scroll position. - """ - @spec get_scroll(map()) :: {integer(), integer()} - def get_scroll(state) do - {state.scroll_x, state.scroll_y} - end - - @doc """ - Sets the scroll position. - """ - @spec set_scroll(map(), integer(), integer()) :: map() - def set_scroll(state, x, y) do - vp_width = viewport_width(state) - vp_height = viewport_height(state) - - %{ - state - | scroll_x: clamp_scroll(x, state.content_width, vp_width), - scroll_y: clamp_scroll(y, state.content_height, vp_height) - } - end - - @doc """ - Scrolls to make a position visible. - """ - @spec scroll_into_view(map(), integer(), integer()) :: map() - def scroll_into_view(state, x, y) do - vp_width = viewport_width(state) - vp_height = viewport_height(state) - - # Calculate new scroll to make position visible - new_x = - cond do - x < state.scroll_x -> x - x >= state.scroll_x + vp_width -> x - vp_width + 1 - true -> state.scroll_x - end - - new_y = - cond do - y < state.scroll_y -> y - y >= state.scroll_y + vp_height -> y - vp_height + 1 - true -> state.scroll_y - end - - set_scroll(state, new_x, new_y) - end - - @doc """ - Updates the content. - """ - @spec set_content(map(), term()) :: map() - def set_content(state, content) do - %{state | content: content} - end - - @doc """ - Updates the content dimensions. - """ - @spec set_content_size(map(), integer(), integer()) :: map() - def set_content_size(state, width, height) do - vp_width = viewport_width(state) - vp_height = viewport_height(state) - - %{ - state - | content_width: width, - content_height: height, - scroll_x: clamp_scroll(state.scroll_x, width, vp_width), - scroll_y: clamp_scroll(state.scroll_y, height, vp_height) - } - end - - @doc """ - Checks if content is scrollable vertically. - """ - @spec can_scroll_vertical?(map()) :: boolean() - def can_scroll_vertical?(state) do - state.content_height > viewport_height(state) - end - - @doc """ - Checks if content is scrollable horizontally. - """ - @spec can_scroll_horizontal?(map()) :: boolean() - def can_scroll_horizontal?(state) do - state.content_width > viewport_width(state) - end - - @doc """ - Gets the visible fraction (0.0 - 1.0) for vertical scrolling. - """ - @spec visible_fraction_vertical(map()) :: float() - def visible_fraction_vertical(state) do - min(1.0, viewport_height(state) / max(1, state.content_height)) - end - - @doc """ - Gets the visible fraction (0.0 - 1.0) for horizontal scrolling. - """ - @spec visible_fraction_horizontal(map()) :: float() - def visible_fraction_horizontal(state) do - min(1.0, viewport_width(state) / max(1, state.content_width)) - end -end diff --git a/lib/term_ui/widgets/visualization_helper.ex b/lib/term_ui/widgets/visualization_helper.ex deleted file mode 100644 index 674fcdeb..00000000 --- a/lib/term_ui/widgets/visualization_helper.ex +++ /dev/null @@ -1,495 +0,0 @@ -defmodule TermUI.Widgets.VisualizationHelper do - @moduledoc """ - Shared utilities for visualization widgets (charts, gauges, sparklines). - - Provides common functions for: - - Value normalization and scaling - - Number formatting - - Color/zone threshold mapping - - Min/max range calculation - - Input validation - - Style application - - ## Usage - - alias TermUI.Widgets.VisualizationHelper, as: VizHelper - - # Normalize a value to 0-1 range - VizHelper.normalize(75, 0, 100) - #=> 0.75 - - # Format numbers for display - VizHelper.format_number(3.14159) - #=> "3.1" - - # Find style based on threshold zones - zones = [{0, :green}, {60, :yellow}, {80, :red}] - VizHelper.find_zone(85, zones) - #=> :red - """ - - # Maximum dimensions to prevent memory exhaustion - @max_width 1000 - @max_height 500 - - # Dialyzer: Functions return specific types or constants - @dialyzer {:nowarn_function, max_width: 0, max_height: 0, maybe_style: 2} - - @doc """ - Returns the maximum allowed width for visualization widgets. - """ - @spec max_width() :: pos_integer() - def max_width, do: @max_width - - @doc """ - Returns the maximum allowed height for visualization widgets. - """ - @spec max_height() :: pos_integer() - def max_height, do: @max_height - - @doc """ - Clamps width to safe bounds. - - ## Examples - - iex> VisualizationHelper.clamp_width(50) - 50 - - iex> VisualizationHelper.clamp_width(2000) - 1000 - - iex> VisualizationHelper.clamp_width(-5) - 1 - """ - @spec clamp_width(integer()) :: pos_integer() - def clamp_width(width) when is_integer(width) do - width |> max(1) |> min(@max_width) - end - - def clamp_width(_), do: 40 - - @doc """ - Clamps height to safe bounds. - - ## Examples - - iex> VisualizationHelper.clamp_height(20) - 20 - - iex> VisualizationHelper.clamp_height(1000) - 500 - - iex> VisualizationHelper.clamp_height(-5) - 1 - """ - @spec clamp_height(integer()) :: pos_integer() - def clamp_height(height) when is_integer(height) do - height |> max(1) |> min(@max_height) - end - - def clamp_height(_), do: 10 - - @doc """ - Normalizes a value to 0-1 range based on min/max bounds. - Clamps result to [0, 1]. - - Returns 0.5 when min equals max to avoid division by zero. - - ## Examples - - iex> VisualizationHelper.normalize(50, 0, 100) - 0.5 - - iex> VisualizationHelper.normalize(75, 0, 100) - 0.75 - - iex> VisualizationHelper.normalize(150, 0, 100) - 1.0 - - iex> VisualizationHelper.normalize(-10, 0, 100) - 0.0 - - iex> VisualizationHelper.normalize(50, 50, 50) - 0.5 - """ - @spec normalize(number(), number(), number()) :: float() - def normalize(value, min, max) when is_number(value) and is_number(min) and is_number(max) do - if max > min do - normalized = (value - min) / (max - min) - normalized |> max(0.0) |> min(1.0) - else - 0.5 - end - end - - def normalize(_, _, _), do: 0.5 - - @doc """ - Scales a normalized value (0-1) to a target size. - - ## Examples - - iex> VisualizationHelper.scale(0.5, 100) - 50 - - iex> VisualizationHelper.scale(0.75, 20) - 15 - """ - @spec scale(float(), number()) :: integer() - def scale(normalized, target_size) when is_number(normalized) and is_number(target_size) do - round(normalized * target_size) - end - - @doc """ - Normalizes and scales a value in one step. - - ## Examples - - iex> VisualizationHelper.normalize_and_scale(50, 0, 100, 20) - 10 - - iex> VisualizationHelper.normalize_and_scale(75, 0, 100, 40) - 30 - """ - @spec normalize_and_scale(number(), number(), number(), number()) :: integer() - def normalize_and_scale(value, min, max, target_size) do - value - |> normalize(min, max) - |> scale(target_size) - end - - @doc """ - Formats a numeric value for display. - - - Floats are formatted to 1 decimal place - - Integers are converted to string - - Other values return "???" - - ## Examples - - iex> VisualizationHelper.format_number(42) - "42" - - iex> VisualizationHelper.format_number(3.14159) - "3.1" - - iex> VisualizationHelper.format_number(:not_a_number) - "???" - """ - @spec format_number(any()) :: String.t() - def format_number(value) when is_float(value) do - :erlang.float_to_binary(value, decimals: 1) - end - - def format_number(value) when is_integer(value) do - Integer.to_string(value) - end - - def format_number(_value), do: "???" - - @doc """ - Finds the appropriate style/color for a value based on threshold zones. - - Zones should be a list of `{threshold, style}` tuples. The function returns - the style associated with the highest threshold that is <= the value. - - ## Examples - - iex> zones = [{0, :green}, {60, :yellow}, {80, :red}] - iex> VisualizationHelper.find_zone(50, zones) - :green - - iex> zones = [{0, :green}, {60, :yellow}, {80, :red}] - iex> VisualizationHelper.find_zone(75, zones) - :yellow - - iex> zones = [{0, :green}, {60, :yellow}, {80, :red}] - iex> VisualizationHelper.find_zone(90, zones) - :red - - iex> VisualizationHelper.find_zone(50, []) - nil - """ - @spec find_zone(number(), [{number(), any()}]) :: any() | nil - def find_zone(_value, []), do: nil - - def find_zone(value, zones) when is_number(value) and is_list(zones) do - zones - |> Enum.sort_by(fn {threshold, _} -> -threshold end) - |> Enum.find_value(fn {threshold, style} -> - if value >= threshold, do: style - end) - end - - def find_zone(_, _), do: nil - - @doc """ - Calculates min/max range from data, with optional overrides. - - ## Examples - - iex> VisualizationHelper.calculate_range([1, 5, 3, 9, 2]) - {1, 9} - - iex> VisualizationHelper.calculate_range([1, 5, 3], min: 0) - {0, 5} - - iex> VisualizationHelper.calculate_range([1, 5, 3], min: 0, max: 10) - {0, 10} - - iex> VisualizationHelper.calculate_range([]) - {0, 1} - """ - @spec calculate_range([number()], keyword()) :: {number(), number()} - def calculate_range(values, opts \\ []) - def calculate_range([], _opts), do: {0, 1} - - def calculate_range(values, opts) when is_list(values) and length(values) > 0 do - min_val = Keyword.get_lazy(opts, :min, fn -> Enum.min(values) end) - max_val = Keyword.get_lazy(opts, :max, fn -> Enum.max(values) end) - {min_val, max_val} - end - - def calculate_range(_, _), do: {0, 1} - - @doc """ - Applies style conditionally to a render node. - - Returns the node unchanged if style is nil. - - ## Examples - - iex> node = %{type: :text, content: "hello"} - iex> VisualizationHelper.maybe_style(node, nil) - %{type: :text, content: "hello"} - """ - @spec maybe_style(any(), any()) :: any() - def maybe_style(node, nil), do: node - - def maybe_style(node, style) do - import TermUI.Component.RenderNode - styled(node, style) - end - - @doc """ - Gets a color from a list by cycling through indices. - - ## Examples - - iex> colors = [:red, :blue, :green] - iex> VisualizationHelper.cycle_color(colors, 0) - :red - - iex> colors = [:red, :blue, :green] - iex> VisualizationHelper.cycle_color(colors, 4) - :blue - - iex> VisualizationHelper.cycle_color([], 0) - nil - """ - @spec cycle_color([any()], non_neg_integer()) :: any() | nil - def cycle_color([], _index), do: nil - - def cycle_color(colors, index) when is_list(colors) and is_integer(index) do - Enum.at(colors, rem(index, length(colors))) - end - - # ============================================================================= - # Input Validation - # ============================================================================= - - @doc """ - Validates that a value is a number. - - ## Examples - - iex> VisualizationHelper.validate_number(42) - :ok - - iex> VisualizationHelper.validate_number(3.14) - :ok - - iex> VisualizationHelper.validate_number("not a number") - {:error, "expected a number, got: \\"not a number\\""} - """ - @spec validate_number(any()) :: :ok | {:error, String.t()} - def validate_number(value) when is_number(value), do: :ok - def validate_number(value), do: {:error, "expected a number, got: #{inspect(value)}"} - - @doc """ - Validates that all values in a list are numbers. - - ## Examples - - iex> VisualizationHelper.validate_number_list([1, 2, 3]) - :ok - - iex> VisualizationHelper.validate_number_list([1, "two", 3]) - {:error, "all values must be numbers, found non-number at index 1"} - - iex> VisualizationHelper.validate_number_list("not a list") - {:error, "expected a list of numbers"} - """ - @spec validate_number_list(any()) :: :ok | {:error, String.t()} - def validate_number_list(values) when is_list(values) do - case Enum.find_index(values, fn v -> not is_number(v) end) do - nil -> :ok - index -> {:error, "all values must be numbers, found non-number at index #{index}"} - end - end - - def validate_number_list(_), do: {:error, "expected a list of numbers"} - - @doc """ - Validates bar chart data structure. - - Each item must be a map with :label (string) and :value (number) keys. - - ## Examples - - iex> data = [%{label: "A", value: 10}, %{label: "B", value: 20}] - iex> VisualizationHelper.validate_bar_data(data) - :ok - - iex> VisualizationHelper.validate_bar_data([%{label: "A"}]) - {:error, "bar data item at index 0 missing :value key"} - - iex> VisualizationHelper.validate_bar_data("not a list") - {:error, "expected a list of bar data items"} - """ - @spec validate_bar_data(any()) :: :ok | {:error, String.t()} - def validate_bar_data(data) when is_list(data) do - data - |> Enum.with_index() - |> Enum.reduce_while(:ok, fn {item, index}, _acc -> - case validate_bar_item(item, index) do - :ok -> {:cont, :ok} - error -> {:halt, error} - end - end) - end - - def validate_bar_data(_), do: {:error, "expected a list of bar data items"} - - defp validate_bar_item(item, index) when is_map(item) do - cond do - not Map.has_key?(item, :label) -> - {:error, "bar data item at index #{index} missing :label key"} - - not Map.has_key?(item, :value) -> - {:error, "bar data item at index #{index} missing :value key"} - - not is_binary(item.label) -> - {:error, "bar data item at index #{index} :label must be a string"} - - not is_number(item.value) -> - {:error, "bar data item at index #{index} :value must be a number"} - - true -> - :ok - end - end - - defp validate_bar_item(_, index) do - {:error, "bar data item at index #{index} must be a map with :label and :value"} - end - - @doc """ - Validates line chart series data structure. - - Each series must be a map with :data (list of numbers) and optional :color keys. - - ## Examples - - iex> series = [%{data: [1, 2, 3]}, %{data: [4, 5, 6], color: :red}] - iex> VisualizationHelper.validate_series_data(series) - :ok - - iex> VisualizationHelper.validate_series_data([%{data: "not a list"}]) - {:error, "series at index 0 :data must be a list of numbers"} - """ - @spec validate_series_data(any()) :: :ok | {:error, String.t()} - def validate_series_data(series) when is_list(series) do - series - |> Enum.with_index() - |> Enum.reduce_while(:ok, fn {item, index}, _acc -> - case validate_series_item(item, index) do - :ok -> {:cont, :ok} - error -> {:halt, error} - end - end) - end - - def validate_series_data(_), do: {:error, "expected a list of series"} - - defp validate_series_item(item, index) when is_map(item) do - cond do - not Map.has_key?(item, :data) -> - {:error, "series at index #{index} missing :data key"} - - not is_list(item.data) -> - {:error, "series at index #{index} :data must be a list of numbers"} - - not Enum.all?(item.data, &is_number/1) -> - {:error, "series at index #{index} :data must contain only numbers"} - - true -> - :ok - end - end - - defp validate_series_item(_, index) do - {:error, "series at index #{index} must be a map with :data key"} - end - - @doc """ - Validates that a character is a single printable character. - - ## Examples - - iex> VisualizationHelper.validate_char("█") - :ok - - iex> VisualizationHelper.validate_char("ab") - {:error, "expected a single character, got 2 characters"} - - iex> VisualizationHelper.validate_char("") - {:error, "expected a single character, got empty string"} - """ - @spec validate_char(any()) :: :ok | {:error, String.t()} - def validate_char(char) when is_binary(char) do - graphemes = String.graphemes(char) - - case length(graphemes) do - 1 -> :ok - 0 -> {:error, "expected a single character, got empty string"} - n -> {:error, "expected a single character, got #{n} characters"} - end - end - - def validate_char(_), do: {:error, "expected a string character"} - - @doc """ - Safely duplicates a string with bounds checking. - - Prevents memory exhaustion by clamping count to reasonable bounds. - - ## Examples - - iex> VisualizationHelper.safe_duplicate("█", 5) - "█████" - - iex> VisualizationHelper.safe_duplicate("█", -5) - "" - - iex> VisualizationHelper.safe_duplicate("█", 10000) - # Returns string with max_width characters - """ - @spec safe_duplicate(String.t(), integer()) :: String.t() - def safe_duplicate(string, count) when is_binary(string) and is_integer(count) do - safe_count = count |> max(0) |> min(@max_width) - String.duplicate(string, safe_count) - end - - def safe_duplicate(_, _), do: "" -end diff --git a/lib/term_ui/widgets/widget_helpers.ex b/lib/term_ui/widgets/widget_helpers.ex deleted file mode 100644 index 9692d7fb..00000000 --- a/lib/term_ui/widgets/widget_helpers.ex +++ /dev/null @@ -1,156 +0,0 @@ -defmodule TermUI.Widgets.WidgetHelpers do - @moduledoc """ - Shared utilities for interactive widgets (forms, palettes, menus). - - Provides common functions for: - - Text padding and truncation - - Focus/selection styling - - Common render patterns - - ## Usage - - alias TermUI.Widgets.WidgetHelpers, as: Helpers - - # Pad and truncate text to fit a width - Helpers.pad_and_truncate("Hello", 10) - #=> "Hello " - - # Style an element based on focus state - Helpers.render_focused(text("Item"), true, Style.new(attrs: [:reverse])) - #=> styled render node with reverse attribute - """ - - import TermUI.Component.RenderNode, only: [text: 1, styled: 2] - - alias TermUI.Renderer.Style - - @doc """ - Pads a string to a specified width, then truncates if it exceeds that width. - - This ensures the resulting string is exactly `width` characters long, - first padding with spaces if too short, then slicing if too long. - - ## Examples - - iex> WidgetHelpers.pad_and_truncate("Hello", 10) - "Hello " - - iex> WidgetHelpers.pad_and_truncate("Hello World", 5) - "Hello" - - iex> WidgetHelpers.pad_and_truncate("Test", 4) - "Test" - - iex> WidgetHelpers.pad_and_truncate("", 5) - " " - """ - @spec pad_and_truncate(String.t(), non_neg_integer()) :: String.t() - def pad_and_truncate(string, width) - when is_binary(string) and is_integer(width) and width >= 0 do - string - |> String.pad_trailing(width) - |> String.slice(0, width) - end - - def pad_and_truncate(_, width) when is_integer(width) and width >= 0, - do: String.duplicate(" ", width) - - @doc """ - Renders an element with focused styling applied conditionally. - - When `focused` is true, wraps the render node with the provided focus style. - When false, returns the node unchanged. - - This is commonly used for list items, form fields, and menu options. - - ## Examples - - # With focus - Helpers.render_focused(text("Option 1"), true) - #=> styled node with reverse attribute - - # Without focus - Helpers.render_focused(text("Option 2"), false) - #=> plain text node - - # Custom focus style - Helpers.render_focused(text("Active"), true, Style.new(fg: :cyan, attrs: [:bold])) - #=> styled node with cyan foreground and bold - """ - @spec render_focused(any(), boolean(), Style.t() | nil) :: any() - def render_focused(node, focused, focus_style \\ nil) - - def render_focused(node, true, nil) do - styled(node, Style.new(attrs: [:reverse])) - end - - def render_focused(node, true, focus_style) do - styled(node, focus_style) - end - - def render_focused(node, false, _focus_style) do - node - end - - @doc """ - Creates a text render node with conditional focus styling. - - Convenience function that combines `text/1` and `render_focused/3`. - - ## Examples - - Helpers.text_focused("Item 1", true) - #=> styled text node with reverse attribute - - Helpers.text_focused("Item 2", false) - #=> plain text node - """ - @spec text_focused(String.t(), boolean(), Style.t() | nil) :: any() - def text_focused(content, focused, focus_style \\ nil) do - render_focused(text(content), focused, focus_style) - end - - @doc """ - Truncates a string to the specified maximum length. - - Unlike `pad_and_truncate/2`, this does not add padding. - Returns the original string if it's already within the limit. - - ## Examples - - iex> WidgetHelpers.truncate("Hello World", 5) - "Hello" - - iex> WidgetHelpers.truncate("Hi", 10) - "Hi" - """ - @spec truncate(String.t(), non_neg_integer()) :: String.t() - def truncate(string, max_length) - when is_binary(string) and is_integer(max_length) and max_length >= 0 do - String.slice(string, 0, max_length) - end - - def truncate(_, _), do: "" - - @doc """ - Builds a focus indicator string based on focus state. - - Returns a prefix indicator typically used in list displays. - - ## Examples - - iex> WidgetHelpers.focus_indicator(true) - "> " - - iex> WidgetHelpers.focus_indicator(false) - " " - - iex> WidgetHelpers.focus_indicator(true, "→ ", " ") - "→ " - """ - @spec focus_indicator(boolean(), String.t(), String.t()) :: String.t() - def focus_indicator(focused, indicator \\ "> ", blank \\ " ") - - def focus_indicator(true, indicator, _blank), do: indicator - def focus_indicator(false, _indicator, blank), do: blank -end diff --git a/mix.exs b/mix.exs index d3f36dd5..2147f058 100644 --- a/mix.exs +++ b/mix.exs @@ -15,7 +15,7 @@ defmodule TermUI.MixProject do # Hex package name: "TermUI", - description: "A direct-mode Terminal UI framework for Elixir/BEAM", + description: "A small Elm terminal runtime for Elixir and the BEAM", package: package(), source_url: @source_url, homepage_url: @source_url, @@ -25,9 +25,9 @@ defmodule TermUI.MixProject do test_coverage: [tool: ExCoveralls], # Dialyzer - # Note: call_without_opaque warnings suppressed with :no_opaque in widget modules - # due to MapSet nested in opaque Style type dialyzer: [ + ignore_warnings: ".dialyzer_ignore.exs", + list_unused_filters: true, flags: [ :error_handling, :underspecs, @@ -59,6 +59,12 @@ defmodule TermUI.MixProject do defp deps do [ + # Markdown parsing + {:mdex, "~> 0.13.5"}, + + # Public data schemas and struct definitions + {:zoi, "~> 0.18.7"}, + # Documentation {:ex_doc, "~> 0.31", only: :dev, runtime: false}, @@ -68,17 +74,6 @@ defmodule TermUI.MixProject do # Testing {:excoveralls, "~> 0.18", only: :test}, - {:stream_data, "~> 1.0", only: :test}, - - # Streaming - {:gen_stage, "~> 1.2", optional: true}, - - # Markdown processing - {:mdex, "~> 0.10", optional: true}, - - # Syntax highlighting for code blocks - {:makeup, "~> 1.1", optional: true}, - {:makeup_elixir, "~> 1.0", optional: true}, # LLM usage rules {:usage_rules, "~> 0.1", only: :dev, runtime: false} @@ -113,73 +108,77 @@ defmodule TermUI.MixProject do extras: [ "README.md", "CHANGELOG.md", - "guides/user/README.md": [filename: "user-guides", title: "User Guides"], - "guides/user/01-overview.md": [title: "Overview"], - "guides/user/02-getting-started.md": [title: "Getting Started"], - "guides/user/03-elm-architecture.md": [title: "The Elm Architecture"], - "guides/user/04-events.md": [title: "Events"], - "guides/user/05-styling.md": [title: "Styling"], - "guides/user/06-layout.md": [title: "Layout"], - "guides/user/07-widgets.md": [title: "Widgets"], - "guides/user/08-terminal.md": [title: "Terminal"], - "guides/user/09-commands.md": [title: "Commands"], - "guides/user/10-advanced-widgets.md": [title: "Advanced Widgets"], - "guides/developer/README.md": [filename: "developer-guides", title: "Developer Guides"], - "guides/developer/01-architecture-overview.md": [title: "Architecture Overview"], - "guides/developer/02-runtime-internals.md": [title: "Runtime Internals"], - "guides/developer/03-rendering-pipeline.md": [title: "Rendering Pipeline"], - "guides/developer/04-event-system.md": [title: "Event System"], - "guides/developer/05-buffer-management.md": [title: "Buffer Management"], - "guides/developer/06-terminal-layer.md": [title: "Terminal Layer"], - "guides/developer/07-elm-implementation.md": [title: "Elm Implementation"], - "guides/developer/08-creating-widgets.md": [title: "Creating Widgets"], - "guides/developer/09-testing-framework.md": [title: "Testing Framework"] - ], - groups_for_extras: [ - "User Guides": ~r/guides\/user\/.*/, - "Developer Guides": ~r/guides\/developer\/.*/ + "guides/architecture.md": [title: "Architecture"], + "guides/backend.md": [title: "Backend Contract"], + "guides/widgets.md": [title: "Pure Widgets"], + "guides/interaction.md": [title: "Clipboard, Selection, and Mouse"], + "guides/markdown-and-diffs.md": [title: "Markdown and Diffs"], + "guides/removed-and-deferred.md": [title: "Removed and Deferred Features"], + "guides/migration-1.0.md": [title: "Migration to 1.0"] ], groups_for_modules: [ Core: [ TermUI, TermUI.Elm, TermUI.Runtime, - TermUI.Component, - TermUI.Event + TermUI.Event, + TermUI.Command, + TermUI.Clipboard, + TermUI.Clipboard.Operation, + TermUI.Frame, + TermUI.Cell, + TermUI.Style, + TermUI.DisplayWidth, + TermUI.Markdown, + TermUI.Mouse, + TermUI.Mouse.Region, + TermUI.Mouse.Tracker, + TermUI.Selection ], - Widgets: ~r/TermUI\.Widgets\..*/, - Rendering: [ - TermUI.Renderer.Style, - TermUI.Renderer.Cell, - TermUI.Renderer.Buffer, - TermUI.Component.RenderNode + Widgets: [ + TermUI.Widget, + TermUI.Widget.AlertDialog, + TermUI.Widget.BarChart, + TermUI.Widget.Block, + TermUI.Widget.Button, + TermUI.Widget.Canvas, + TermUI.Widget.ClusterDashboard, + TermUI.Widget.CommandPalette, + TermUI.Widget.ContextMenu, + TermUI.Widget.Dialog, + TermUI.Widget.DiffViewer, + TermUI.Widget.FormBuilder, + TermUI.Widget.Gauge, + TermUI.Widget.Label, + TermUI.Widget.LineChart, + TermUI.Widget.LineInput, + TermUI.Widget.List, + TermUI.Widget.LogViewer, + TermUI.Widget.MarkdownViewer, + TermUI.Widget.Menu, + TermUI.Widget.PickList, + TermUI.Widget.ProcessMonitor, + TermUI.Widget.Progress, + TermUI.Widget.ScrollBar, + TermUI.Widget.Sparkline, + TermUI.Widget.SplitPane, + TermUI.Widget.Stream, + TermUI.Widget.StreamWidget, + TermUI.Widget.SupervisionTree, + TermUI.Widget.SupervisionTreeViewer, + TermUI.Widget.Table, + TermUI.Widget.Table.Column, + TermUI.Widget.Tabs, + TermUI.Widget.TextArea, + TermUI.Widget.TextInput, + TermUI.Widget.TextInput.Line, + TermUI.Widget.Toast, + TermUI.Widget.Toast.Manager, + TermUI.Widget.TreeView, + TermUI.Widget.Viewport ], - Layout: ~r/TermUI\.Layout\..*/, - Terminal: ~r/TermUI\.Terminal\..*/ - ], - before_closing_body_tag: %{ - html: """ - - - """ - } + Backends: [TermUI.Backend] + ] ] end end diff --git a/mix.lock b/mix.lock index 6dfe1154..d3ae22a8 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,5 @@ %{ - "autumn": {:hex, :autumn, "0.5.7", "f6bfdc30d3f8d5e82ba5648489db7a7b6b7479d7be07a8288d4db2437434e26d", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "d272bfddeeea863420a8eb994d42af219ca5391191dd765bf045fbacf56a28d1"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, "credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, @@ -10,7 +8,6 @@ "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, - "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "igniter": {:hex, :igniter, "0.7.0", "6848714fa5afa14258c82924a57af9364745316241a409435cf39cbe11e3ae80", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "1e7254780dbf4b44c9eccd6d86d47aa961efc298d7f520c24acb0258c8e90ba9"}, @@ -18,7 +15,8 @@ "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, - "mdex": {:hex, :mdex, "0.10.0", "eae4d3bd4c0b77d6d959146a2d6faaec045686548ad1468630130095dbd93def", [:mix], [{:autumn, ">= 0.5.4", [hex: :autumn, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: false]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "6ad76e32056c44027fe985da7da506e033b07037896d1f130f7d5c332b0d0ac0"}, + "mdex": {:hex, :mdex, "0.13.5", "c1c94d230ccaab01ad0c68090d3b31613c10ece1844f32b55895da4ce0c63029", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.2.6", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.0 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "c57409fb6b34fbc58fbce0a6da670c9a4b5a2e94f86abdc56e9e213ed74620f2"}, + "mdex_native": {:hex, :mdex_native, "0.2.8", "20b7cbf330c1ca81b8da4132b8d01952cded11f6dfc2abe8fef25c13681b15e4", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "004a5565b6c96a06400901eb1e4e603585e00b23262d3f595c3f4aa38b83ef66"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, @@ -27,12 +25,11 @@ "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, "req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"}, "rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"}, - "rustler": {:hex, :rustler, "0.37.1", "721434020c7f6f8e1cdc57f44f75c490435b01de96384f8ccb96043f12e8a7e0", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "24547e9b8640cf00e6a2071acb710f3e12ce0346692e45098d84d45cdb54fd79"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, + "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, "sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"}, "spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"}, - "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "usage_rules": {:hex, :usage_rules, "0.1.26", "19d38c8b9b5c35434eae44f7e4554caeb5f08037a1d45a6b059a9782543ac22e", [:mix], [{:igniter, ">= 0.6.6 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "9f0d203aa288e1b48318929066778ec26fc423fd51f08518c5b47f58ad5caca9"}, + "zoi": {:hex, :zoi, "0.18.7", "0d6b09d19fd1feff4340b7c5660bab04fbc80c1642ee1e5c75f06d527ac326db", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "5fedddd755dec84a5e78b3671070a5e595026aa3479f7fa566a42b8c4e4e5ff2"}, } diff --git a/mix/tasks/termui.run.ex b/mix/tasks/termui.run.ex index c8ccc452..75083449 100644 --- a/mix/tasks/termui.run.ex +++ b/mix/tasks/termui.run.ex @@ -18,7 +18,6 @@ defmodule Mix.Tasks.Termui.Run do --module MODULE - Module name containing run/0 (default: autodetect) --function NAME - Function name to call (default: run) - --iex - Run in IEx-compatible mode (same as env TERM_UI_IEX_MODE=true) ## How it works @@ -35,9 +34,7 @@ defmodule Mix.Tasks.Termui.Run do @impl true def run(args) do {opts, _} = - OptionParser.parse!(args, - strict: [module: :string, function: :string, iex: :boolean] - ) + OptionParser.parse!(args, strict: [module: :string, function: :string]) # Ensure project is compiled Mix.Project.get!() @@ -54,11 +51,6 @@ defmodule Mix.Tasks.Termui.Run do function = Keyword.get(opts, :function, "run") |> String.to_atom() - # Set IEx mode if requested - if Keyword.get(opts, :iex) do - Application.put_env(:term_ui, :iex_compatible, true) - end - # Run the application apply(module, function, []) end diff --git a/test/docs/widget_compatibility_test.exs b/test/docs/widget_compatibility_test.exs deleted file mode 100644 index 2f7aa103..00000000 --- a/test/docs/widget_compatibility_test.exs +++ /dev/null @@ -1,199 +0,0 @@ -defmodule Docs.WidgetCompatibilityTest do - @moduledoc """ - Tests that code examples in widget-compatibility.md compile and work correctly. - """ - use ExUnit.Case, async: true - - alias TermUI.CharacterSet - alias TermUI.Event - alias TermUI.Renderer.Style - alias TermUI.Theme - alias TermUI.Widgets.ContextMenu - alias TermUI.Widgets.ContextMenu.Inline, as: ContextMenuInline - alias TermUI.Widgets.SplitPane - alias TermUI.Widgets.TextInput - alias TermUI.Widgets.TextInput.Line - - setup do - # Start Theme server for tests - case Theme.start_link(theme: :dark) do - {:ok, _pid} -> :ok - {:error, {:already_started, _pid}} -> :ok - end - - :ok - end - - describe "documentation code examples" do - test "TextInput.new example compiles" do - props = TextInput.new(placeholder: "Search...") - assert props.placeholder == "Search..." - end - - test "TextInput.Line.new example compiles" do - props = Line.new(prompt: "> ", label: "Enter command") - assert props.prompt == "> " - assert props.label == "Enter command" - end - - test "ContextMenu.new example compiles" do - items = [ - ContextMenu.action(:copy, "Copy"), - ContextMenu.action(:paste, "Paste") - ] - - props = ContextMenu.new(items: items, position: {10, 20}) - assert props.position == {10, 20} - end - - test "ContextMenu.Inline.new example compiles" do - # ContextMenu.Inline uses ContextMenu.action for item creation - items = [ - ContextMenu.action(:copy, "Copy"), - ContextMenu.action(:paste, "Paste") - ] - - props = ContextMenuInline.new(items: items) - assert is_list(props.items) - end - - test "SplitPane.new example compiles" do - import TermUI.Component.RenderNode - - # SplitPane requires :panes list, not :left/:right - props = - SplitPane.new( - orientation: :horizontal, - panes: [ - %{id: :left, content: text("Left panel"), size: 0.5}, - %{id: :right, content: text("Right panel"), size: 0.5} - ], - ctrl_resize_step: 0.05, - min_ratio: 0.1, - max_ratio: 0.9 - ) - - assert props.ctrl_resize_step == 0.05 - assert props.min_ratio == 0.1 - assert props.max_ratio == 0.9 - end - - test "Theme-based colors example compiles" do - # Good - theme-based colors - style = Style.new() |> Style.fg(Theme.get_semantic(:error)) - assert %Style{} = style - - # Component styles may or may not exist depending on theme - # The important thing is the function works without raising - _result = Theme.get_component_style(:list, :selected) - assert true - end - - test "CharacterSet-based border example compiles" do - chars = CharacterSet.current_charset() - width = 10 - border = chars.tl <> String.duplicate(chars.h_line, width) <> chars.tr - - assert is_binary(border) - assert String.length(border) == width + 2 - end - - test "Event handling patterns compile" do - # These patterns should compile (not necessarily run) - state = %{cursor: 0, items: [1, 2, 3]} - - # Mouse event pattern - mouse_event = %Event.Mouse{action: :click, button: :left, x: 5, y: 10} - assert mouse_event.action == :click - - # Key event pattern - key_event = %Event.Key{key: :enter} - assert key_event.key == :enter - - down_event = %Event.Key{key: :down} - assert down_event.key == :down - - # These would be in actual widget handlers - assert state.cursor == 0 - end - - test "CharacterSet.current returns valid atom" do - charset = CharacterSet.current() - assert charset in [:unicode, :ascii] - end - - test "CharacterSet provides all documented characters" do - chars = CharacterSet.current_charset() - - # Box drawing - assert Map.has_key?(chars, :tl) - assert Map.has_key?(chars, :tr) - assert Map.has_key?(chars, :bl) - assert Map.has_key?(chars, :br) - assert Map.has_key?(chars, :h_line) - assert Map.has_key?(chars, :v_line) - assert Map.has_key?(chars, :cross) - - # Arrows - assert Map.has_key?(chars, :arrow_up) - assert Map.has_key?(chars, :arrow_down) - assert Map.has_key?(chars, :arrow_left) - assert Map.has_key?(chars, :arrow_right) - - # Indicators - assert Map.has_key?(chars, :check) - assert Map.has_key?(chars, :cross_mark) - assert Map.has_key?(chars, :bullet) - assert Map.has_key?(chars, :pointer) - - # Progress - assert Map.has_key?(chars, :bar_full) - assert Map.has_key?(chars, :bar_empty) - assert Map.has_key?(chars, :bar_levels) - assert Map.has_key?(chars, :sparkline_levels) - - # Icons - assert Map.has_key?(chars, :info) - assert Map.has_key?(chars, :warning) - assert Map.has_key?(chars, :loading) - end - - test "Unicode character mappings are correct" do - chars = CharacterSet.get(:unicode) - - assert chars.tl == "┌" - assert chars.tr == "┐" - assert chars.bl == "└" - assert chars.br == "┘" - assert chars.h_line == "─" - assert chars.v_line == "│" - assert chars.arrow_up == "↑" - assert chars.arrow_down == "↓" - assert chars.arrow_left == "←" - assert chars.arrow_right == "→" - assert chars.check == "✓" - assert chars.cross_mark == "✗" - assert chars.bar_full == "█" - assert chars.bar_empty == "░" - end - - test "ASCII character mappings are correct" do - chars = CharacterSet.get(:ascii) - - assert chars.tl == "+" - assert chars.tr == "+" - assert chars.bl == "+" - assert chars.br == "+" - assert chars.h_line == "-" - assert chars.v_line == "|" - assert chars.arrow_up == "^" - assert chars.arrow_down == "v" - assert chars.arrow_left == "<" - assert chars.arrow_right == ">" - assert chars.check == "x" - assert chars.cross_mark == "X" - assert chars.bar_full == "#" - assert chars.bar_empty == "." - end - end -end diff --git a/test/integration/backend_selection_test.exs b/test/integration/backend_selection_test.exs deleted file mode 100644 index 296c0a58..00000000 --- a/test/integration/backend_selection_test.exs +++ /dev/null @@ -1,435 +0,0 @@ -defmodule TermUI.Integration.BackendSelectionTest do - @moduledoc """ - Integration tests for Phase 1 backend selection flow. - - These tests verify that the Config, Selector, and State modules work together - correctly to provide a complete backend selection flow. - """ - - use ExUnit.Case, async: false - - alias TermUI.Backend.Config - alias TermUI.Backend.Selector - alias TermUI.Backend.State - - # Note: async: false because we modify Application env and system environment - - setup do - # Store original Application env values - original_backend = Application.get_env(:term_ui, :backend) - original_character_set = Application.get_env(:term_ui, :character_set) - original_fallback = Application.get_env(:term_ui, :fallback_character_set) - original_tty_opts = Application.get_env(:term_ui, :tty_opts) - original_raw_opts = Application.get_env(:term_ui, :raw_opts) - - # Store original environment variables - original_colorterm = System.get_env("COLORTERM") - original_term = System.get_env("TERM") - original_lang = System.get_env("LANG") - original_lc_all = System.get_env("LC_ALL") - original_lc_ctype = System.get_env("LC_CTYPE") - - on_exit(fn -> - # Restore Application env - restore_app_env(:backend, original_backend) - restore_app_env(:character_set, original_character_set) - restore_app_env(:fallback_character_set, original_fallback) - restore_app_env(:tty_opts, original_tty_opts) - restore_app_env(:raw_opts, original_raw_opts) - - # Restore environment variables - restore_sys_env("COLORTERM", original_colorterm) - restore_sys_env("TERM", original_term) - restore_sys_env("LANG", original_lang) - restore_sys_env("LC_ALL", original_lc_all) - restore_sys_env("LC_CTYPE", original_lc_ctype) - end) - - # Clear Application env for clean test state - Application.delete_env(:term_ui, :backend) - Application.delete_env(:term_ui, :character_set) - Application.delete_env(:term_ui, :fallback_character_set) - Application.delete_env(:term_ui, :tty_opts) - Application.delete_env(:term_ui, :raw_opts) - - :ok - end - - defp restore_app_env(key, nil), do: Application.delete_env(:term_ui, key) - defp restore_app_env(key, value), do: Application.put_env(:term_ui, key, value) - - defp restore_sys_env(key, nil), do: System.delete_env(key) - defp restore_sys_env(key, value), do: System.put_env(key, value) - - # =========================================================================== - # Task 1.5.1: Backend Selection Flow Tests - # =========================================================================== - - describe "backend selection flow (Task 1.5.1)" do - test "configuration with :auto backend triggers selector" do - # 1.5.1.1 - Config :auto should result in selector being used - Application.put_env(:term_ui, :backend, :auto) - - # Verify config returns :auto - assert Config.get_backend() == :auto - - # When config is :auto, selector should be invoked - # In test environment, this will return {:tty, capabilities} - # because we can't start raw mode in a running shell - result = Selector.select(:auto) - - # Result should be either {:raw, _} or {:tty, _} - assert match?({:raw, _}, result) or match?({:tty, _}, result) - end - - test "selector result provides correct backend module and init options" do - # 1.5.1.2 - Selector returns usable data for backend initialization - case Selector.select() do - {:raw, state} -> - # Raw mode returns state with raw_mode_started flag - assert is_map(state) - assert Map.has_key?(state, :raw_mode_started) - assert state.raw_mode_started == true - - {:tty, capabilities} -> - # TTY mode returns capabilities map - assert is_map(capabilities) - assert Map.has_key?(capabilities, :colors) - assert Map.has_key?(capabilities, :unicode) - assert Map.has_key?(capabilities, :dimensions) - assert Map.has_key?(capabilities, :terminal) - end - end - - test "explicit backend configuration bypasses selector" do - # 1.5.1.3 - Explicit module config bypasses auto-detection - Application.put_env(:term_ui, :backend, TermUI.Backend.TTY) - - # Config returns the explicit module - assert Config.get_backend() == TermUI.Backend.TTY - - # Using select/1 with explicit module returns {:explicit, module, opts} - assert {:explicit, TermUI.Backend.TTY, []} = Selector.select(TermUI.Backend.TTY) - end - - test "explicit backend with options bypasses selector" do - # 1.5.1.3 continued - Explicit module with options - result = Selector.select({TermUI.Backend.TTY, line_mode: :incremental}) - - assert {:explicit, TermUI.Backend.TTY, [line_mode: :incremental]} = result - end - - test "invalid configuration is caught before selector runs" do - # 1.5.1.4 - Invalid config raises before selection - Application.put_env(:term_ui, :backend, :invalid_backend) - - # validate! raises for invalid backend - assert_raise ArgumentError, ~r/invalid :backend value/, fn -> - Config.validate!() - end - - # valid? returns false - assert Config.valid?() == false - end - - test "configuration validation runs before runtime_config returns" do - # 1.5.1.4 continued - runtime_config validates before returning - Application.put_env(:term_ui, :character_set, :invalid) - - assert_raise ArgumentError, ~r/invalid :character_set value/, fn -> - Config.runtime_config() - end - end - end - - # =========================================================================== - # Task 1.5.2: Capability Integration Tests - # =========================================================================== - - describe "capability integration (Task 1.5.2)" do - test "TTY capability detection produces compatible capability format" do - # 1.5.2.1 - Capabilities have expected structure - capabilities = Selector.detect_capabilities() - - # Verify structure matches expected format - assert is_map(capabilities) - assert Map.has_key?(capabilities, :colors) - assert Map.has_key?(capabilities, :unicode) - assert Map.has_key?(capabilities, :dimensions) - assert Map.has_key?(capabilities, :terminal) - - # Verify value types - assert capabilities.colors in [:true_color, :color_256, :color_16, :monochrome] - assert is_boolean(capabilities.unicode) - assert capabilities.dimensions == nil or match?({_, _}, capabilities.dimensions) - assert is_boolean(capabilities.terminal) - end - - test "capability map can be passed to State.new_tty" do - # 1.5.2.2 - Capabilities are usable for backend init - capabilities = Selector.detect_capabilities() - - # Should successfully create state with detected capabilities - state = State.new_tty(capabilities) - - assert state.backend_mode == :tty - assert state.backend_module == TermUI.Backend.TTY - assert state.capabilities == capabilities - end - - test "environment variable changes affect color depth detection" do - # 1.5.2.3 - Environment changes are reflected in capability detection - - # Test true color detection via COLORTERM - System.put_env("COLORTERM", "truecolor") - caps = Selector.detect_capabilities() - assert caps.colors == :true_color - - System.put_env("COLORTERM", "24bit") - caps = Selector.detect_capabilities() - assert caps.colors == :true_color - - # Test 256 color detection via TERM - System.delete_env("COLORTERM") - System.put_env("TERM", "xterm-256color") - caps = Selector.detect_capabilities() - assert caps.colors == :color_256 - - # Test 16 color detection via TERM - System.put_env("TERM", "xterm") - caps = Selector.detect_capabilities() - assert caps.colors == :color_16 - - # Test monochrome fallback - System.put_env("TERM", "") - caps = Selector.detect_capabilities() - assert caps.colors == :monochrome - end - - test "environment variable changes affect unicode detection" do - # 1.5.2.3 continued - LANG affects unicode detection - System.delete_env("LC_ALL") - System.delete_env("LC_CTYPE") - - System.put_env("LANG", "en_US.UTF-8") - caps = Selector.detect_capabilities() - assert caps.unicode == true - - System.put_env("LANG", "C") - caps = Selector.detect_capabilities() - assert caps.unicode == false - - System.put_env("LANG", "ja_JP.utf8") - caps = Selector.detect_capabilities() - assert caps.unicode == true - end - - test "capabilities flow from selector to state" do - # Verify complete flow: selector -> capabilities -> state - case Selector.select() do - {:tty, capabilities} -> - state = State.new_tty(capabilities) - assert state.capabilities == capabilities - assert state.backend_mode == :tty - - {:raw, raw_state} -> - state = State.new_raw(raw_state) - assert state.backend_state == raw_state - assert state.backend_mode == :raw - end - end - end - - # =========================================================================== - # Task 1.5.3: State Management Tests - # =========================================================================== - - describe "state management integration (Task 1.5.3)" do - test "Backend.State correctly wraps raw selector result" do - # 1.5.3.1 - State wraps raw mode result correctly - raw_state = %{raw_mode_started: true} - state = State.new_raw(raw_state) - - assert state.backend_module == TermUI.Backend.Raw - assert state.backend_state == raw_state - assert state.backend_mode == :raw - assert state.capabilities == %{} - assert state.initialized == false - end - - test "Backend.State correctly wraps tty selector result" do - # 1.5.3.1 continued - State wraps TTY mode result correctly - capabilities = %{ - colors: :true_color, - unicode: true, - dimensions: {24, 80}, - terminal: true - } - - state = State.new_tty(capabilities) - - assert state.backend_module == TermUI.Backend.TTY - assert state.backend_state == nil - assert state.backend_mode == :tty - assert state.capabilities == capabilities - assert state.initialized == false - end - - test "state updates preserve backend-specific state" do - # 1.5.3.2 - Updates preserve existing fields - initial_backend_state = %{cursor: {1, 1}, buffer: []} - state = State.new_raw(initial_backend_state) - - # Update size should preserve backend_state - state = State.put_size(state, {24, 80}) - assert state.backend_state == initial_backend_state - assert state.size == {24, 80} - - # Update capabilities should preserve backend_state - state = State.put_capabilities(state, %{colors: :true_color}) - assert state.backend_state == initial_backend_state - assert state.capabilities == %{colors: :true_color} - - # Mark initialized should preserve all state - state = State.mark_initialized(state) - assert state.backend_state == initial_backend_state - assert state.size == {24, 80} - assert state.capabilities == %{colors: :true_color} - assert state.initialized == true - end - - test "state updates to backend_state work correctly" do - # 1.5.3.2 continued - Backend state can be updated - state = State.new_raw(%{initial: true}) - - new_backend_state = %{cursor: {5, 10}, screen_cleared: true} - state = State.put_backend_state(state, new_backend_state) - - assert state.backend_state == new_backend_state - assert state.backend_mode == :raw - assert state.backend_module == TermUI.Backend.Raw - end - - test "mode field correctly reflects raw selection result" do - # 1.5.3.3 - Mode is :raw for raw mode state - state = State.new_raw() - assert state.backend_mode == :raw - - state = State.new_raw(%{raw_mode_started: true}) - assert state.backend_mode == :raw - end - - test "mode field correctly reflects tty selection result" do - # 1.5.3.3 continued - Mode is :tty for TTY mode state - state = State.new_tty(%{colors: :color_256}) - assert state.backend_mode == :tty - - state = State.new_tty(%{}, %{some: :state}) - assert state.backend_mode == :tty - end - - test "complete selection to state workflow" do - # Full integration: config -> selector -> state -> updates - Application.put_env(:term_ui, :backend, :auto) - - # Validate configuration - assert Config.valid?() == true - config = Config.runtime_config() - assert config.backend == :auto - - # Perform selection based on config - selection_result = - case config.backend do - :auto -> Selector.select() - module -> Selector.select(module) - end - - # Wrap result in state - state = - case selection_result do - {:raw, raw_state} -> - State.new_raw(raw_state) - - {:tty, capabilities} -> - State.new_tty(capabilities) - - {:explicit, _module, _opts} -> - # For explicit selection, create appropriate state - State.new_tty(%{}) - end - - # Apply updates - state = State.put_size(state, {30, 120}) - state = State.mark_initialized(state) - - # Verify final state - assert state.size == {30, 120} - assert state.initialized == true - assert state.backend_mode in [:raw, :tty] - end - end - - # =========================================================================== - # Additional Integration Tests - # =========================================================================== - - describe "configuration and state integration" do - test "runtime_config values match individual getters" do - Application.put_env(:term_ui, :backend, TermUI.Backend.TTY) - Application.put_env(:term_ui, :character_set, :ascii) - Application.put_env(:term_ui, :tty_opts, line_mode: :incremental) - - config = Config.runtime_config() - - assert config.backend == Config.get_backend() - assert config.character_set == Config.get_character_set() - assert config.fallback_character_set == Config.get_fallback_character_set() - assert config.tty_opts == Config.get_tty_opts() - assert config.raw_opts == Config.get_raw_opts() - end - - test "State.new with explicit module and mode" do - # Test the general constructor with different backends - state = State.new(TermUI.Backend.Test, backend_mode: :tty, capabilities: %{test: true}) - - assert state.backend_module == TermUI.Backend.Test - assert state.backend_mode == :tty - assert state.capabilities == %{test: true} - end - - test "full lifecycle: config validation -> selection -> state creation" do - # Set up valid configuration - Application.put_env(:term_ui, :backend, :auto) - Application.put_env(:term_ui, :character_set, :unicode) - Application.put_env(:term_ui, :tty_opts, line_mode: :full_redraw) - Application.put_env(:term_ui, :raw_opts, alternate_screen: true) - - # Step 1: Validate configuration - assert :ok = Config.validate!() - config = Config.runtime_config() - - # Step 2: Select backend - result = Selector.select(config.backend) - - # Step 3: Create state based on selection - state = - case result do - {:raw, raw_state} -> - State.new_raw(raw_state) - - {:tty, capabilities} -> - State.new_tty(capabilities) - - {:explicit, module, _opts} -> - State.new(module, mode: :tty) - end - - # Step 4: Initialize state - state = State.mark_initialized(state) - - # Verify the complete flow worked - assert state.initialized == true - assert state.backend_module in [TermUI.Backend.Raw, TermUI.Backend.TTY, TermUI.Backend.Test] - end - end -end diff --git a/test/integration/capability_accuracy_test.exs b/test/integration/capability_accuracy_test.exs deleted file mode 100644 index a41b7f46..00000000 --- a/test/integration/capability_accuracy_test.exs +++ /dev/null @@ -1,358 +0,0 @@ -defmodule TermUI.Integration.CapabilityAccuracyTest do - @moduledoc """ - Integration tests for capability detection accuracy. - - Validates that capability detection accurately reflects terminal features - for various terminal emulators and configurations. - """ - - use ExUnit.Case, async: false - - alias TermUI.Capabilities - alias TermUI.Capabilities.Fallbacks - alias TermUI.IntegrationHelpers - - # These tests validate capability detection - @moduletag :integration - - setup do - IntegrationHelpers.stop_terminal() - - on_exit(fn -> - IntegrationHelpers.cleanup_terminal() - end) - - :ok - end - - describe "1.6.3.1 color capability detection" do - test "detects color mode from TERM variable" do - IntegrationHelpers.with_env(IntegrationHelpers.mock_terminal_env(:xterm_256color), fn -> - # Clear cache to force re-detection - Capabilities.clear_cache() - - caps = Capabilities.detect() - - assert caps.color_mode in [:color_256, :true_color] - assert caps.max_colors >= 256 - end) - end - - test "detects truecolor from COLORTERM" do - IntegrationHelpers.with_env(IntegrationHelpers.mock_terminal_env(:truecolor), fn -> - Capabilities.clear_cache() - - caps = Capabilities.detect() - - assert caps.color_mode == :true_color - assert caps.max_colors == 16_777_216 - end) - end - - test "detects basic 16 colors from xterm" do - IntegrationHelpers.with_env(IntegrationHelpers.mock_terminal_env(:basic), fn -> - Capabilities.clear_cache() - - caps = Capabilities.detect() - - # Basic xterm defaults to 16 colors - assert caps.color_mode in [:color_16, :color_256] - assert caps.max_colors >= 16 - end) - end - - test "iTerm2 detection from TERM_PROGRAM" do - IntegrationHelpers.with_env(IntegrationHelpers.mock_terminal_env(:iterm2), fn -> - Capabilities.clear_cache() - - caps = Capabilities.detect() - - # iTerm2 supports true color - assert caps.color_mode == :true_color - assert caps.max_colors == 16_777_216 - assert caps.terminal_program == "iTerm.app" - end) - end - - test "color mode hierarchy is correct" do - modes = [:monochrome, :color_16, :color_256, :true_color] - - for {mode, index} <- Enum.with_index(modes) do - color_count = - case mode do - :monochrome -> 2 - :color_16 -> 16 - :color_256 -> 256 - :true_color -> 16_777_216 - end - - assert color_count > 0, "#{mode} should have positive color count" - - if index > 0 do - prev_mode = Enum.at(modes, index - 1) - - prev_count = - case prev_mode do - :monochrome -> 2 - :color_16 -> 16 - :color_256 -> 256 - :true_color -> 16_777_216 - end - - assert color_count > prev_count, "#{mode} should have more colors than #{prev_mode}" - end - end - end - end - - describe "1.6.3.2 mouse support detection" do - test "capability hints include mouse support" do - caps = Capabilities.detect() - - assert Map.has_key?(caps, :mouse) - assert is_boolean(caps.mouse) - end - - test "most terminals support mouse tracking" do - IntegrationHelpers.with_env(IntegrationHelpers.mock_terminal_env(:xterm_256color), fn -> - Capabilities.clear_cache() - - caps = Capabilities.detect() - - # xterm-based terminals support mouse - assert caps.mouse == true - end) - end - - test "mouse capability consistent with terminal type" do - IntegrationHelpers.with_env(IntegrationHelpers.mock_terminal_env(:iterm2), fn -> - Capabilities.clear_cache() - - caps = Capabilities.detect() - - # iTerm2 supports mouse - assert caps.mouse == true - end) - end - end - - describe "1.6.3.3 Unicode detection" do - test "detects Unicode support" do - caps = Capabilities.detect() - - assert Map.has_key?(caps, :unicode) - assert is_boolean(caps.unicode) - end - - test "Unicode detection from LC_ALL" do - original_lc = System.get_env("LC_ALL") - original_lang = System.get_env("LANG") - - try do - # Set UTF-8 locale - System.put_env("LC_ALL", "en_US.UTF-8") - System.put_env("LANG", "en_US.UTF-8") - Capabilities.clear_cache() - - caps = Capabilities.detect() - - assert caps.unicode == true - after - # Restore - if original_lc, - do: System.put_env("LC_ALL", original_lc), - else: System.delete_env("LC_ALL") - - if original_lang, - do: System.put_env("LANG", original_lang), - else: System.delete_env("LANG") - end - end - - test "Unicode fallbacks work correctly" do - # Test box drawing fallbacks - assert Fallbacks.unicode_to_ascii("┌") == "+" - assert Fallbacks.unicode_to_ascii("─") == "-" - assert Fallbacks.unicode_to_ascii("│") == "|" - assert Fallbacks.unicode_to_ascii("┘") == "+" - - # Test arrows - assert Fallbacks.unicode_to_ascii("→") == ">" - assert Fallbacks.unicode_to_ascii("←") == "<" - assert Fallbacks.unicode_to_ascii("↑") == "^" - assert Fallbacks.unicode_to_ascii("↓") == "v" - end - - test "Unicode to ASCII preserves ASCII" do - ascii_string = "Hello, World!" - assert Fallbacks.unicode_to_ascii(ascii_string) == ascii_string - end - end - - describe "1.6.3.4 capability query timeouts" do - test "detection completes in reasonable time" do - start = System.monotonic_time(:millisecond) - _caps = Capabilities.detect() - elapsed = System.monotonic_time(:millisecond) - start - - # Should complete within 1 second - assert elapsed < 1000, "Detection took #{elapsed}ms, expected < 1000ms" - end - - test "cached detection is fast" do - # First call populates cache - Capabilities.detect() - - # Second call should be near-instant (cached) - start = System.monotonic_time(:millisecond) - _caps = Capabilities.detect() - elapsed = System.monotonic_time(:millisecond) - start - - # Should be under 10ms for cached result - assert elapsed < 10, "Cached detection took #{elapsed}ms, expected < 10ms" - end - - test "cache can be cleared" do - # Populate cache - caps1 = Capabilities.detect() - - # Clear and re-detect with different environment - IntegrationHelpers.with_env(IntegrationHelpers.mock_terminal_env(:truecolor), fn -> - Capabilities.clear_cache() - caps2 = Capabilities.detect() - - # Results may differ based on environment - # Just verify we got valid capabilities - assert is_struct(caps1, Capabilities) - assert is_struct(caps2, Capabilities) - end) - end - end - - describe "color approximation accuracy" do - test "RGB to 256 color approximation" do - # Pure red should map to color 196 (bright red in cube) - index = Fallbacks.rgb_to_256(255, 0, 0) - assert index in [196, 9], "Expected red to map to 196 or 9, got #{index}" - - # Pure green - index = Fallbacks.rgb_to_256(0, 255, 0) - assert index in [46, 10], "Expected green to map to 46 or 10, got #{index}" - - # Pure blue - index = Fallbacks.rgb_to_256(0, 0, 255) - assert index in [21, 12], "Expected blue to map to 21 or 12, got #{index}" - - # White - grayscale 255 (232 + 23) - index = Fallbacks.rgb_to_256(255, 255, 255) - assert index in [231, 255, 15], "Expected white to map to grayscale, got #{index}" - - # Black - grayscale 232 (232 + 0) - index = Fallbacks.rgb_to_256(0, 0, 0) - assert index in [16, 232, 0], "Expected black to map to 16 or grayscale, got #{index}" - end - - test "RGB to 16 color approximation" do - # Red - index = Fallbacks.rgb_to_16(255, 0, 0) - assert index in [1, 9], "Expected red, got #{index}" - - # Green - index = Fallbacks.rgb_to_16(0, 255, 0) - assert index in [2, 10], "Expected green, got #{index}" - - # Blue - index = Fallbacks.rgb_to_16(0, 0, 255) - assert index in [4, 12], "Expected blue, got #{index}" - - # White - index = Fallbacks.rgb_to_16(255, 255, 255) - assert index in [7, 15], "Expected white, got #{index}" - - # Black - index = Fallbacks.rgb_to_16(0, 0, 0) - assert index == 0, "Expected black (0), got #{index}" - end - - test "256 to 16 color degradation" do - # Standard colors should map to themselves - for i <- 0..15 do - result = Fallbacks.color_256_to_16(i) - assert result == i, "Standard color #{i} should map to itself, got #{result}" - end - - # Cube colors should map to nearest 16 - result = Fallbacks.color_256_to_16(196) - assert result in 0..15, "Color 196 should map to 0-15, got #{result}" - end - end - - describe "terminal feature detection" do - test "bracketed paste capability" do - caps = Capabilities.detect() - - assert Map.has_key?(caps, :bracketed_paste) - assert is_boolean(caps.bracketed_paste) - end - - test "focus events capability" do - caps = Capabilities.detect() - - assert Map.has_key?(caps, :focus_events) - assert is_boolean(caps.focus_events) - end - - test "alternate screen capability" do - caps = Capabilities.detect() - - assert Map.has_key?(caps, :alternate_screen) - assert is_boolean(caps.alternate_screen) - end - - test "all capabilities are populated" do - caps = Capabilities.detect() - - required_fields = [ - :color_mode, - :max_colors, - :unicode, - :mouse, - :bracketed_paste, - :focus_events, - :alternate_screen, - :terminal_type - ] - - for field <- required_fields do - assert Map.has_key?(caps, field), "Missing required field: #{field}" - end - end - end - - describe "known terminal capabilities" do - @known_terminals %{ - "xterm" => %{min_colors: 16}, - "xterm-256color" => %{min_colors: 256}, - "screen" => %{min_colors: 16}, - "screen-256color" => %{min_colors: 256}, - "tmux" => %{min_colors: 16}, - "tmux-256color" => %{min_colors: 256} - } - - for {term, expected} <- @known_terminals do - @term term - @expected expected - - test "#{term} has at least #{expected.min_colors} colors" do - IntegrationHelpers.with_env(%{"TERM" => @term, "COLORTERM" => nil}, fn -> - Capabilities.clear_cache() - - caps = Capabilities.detect() - - assert caps.max_colors >= @expected.min_colors, - "#{@term} should have at least #{@expected.min_colors} colors, got #{caps.max_colors}" - end) - end - end - end -end diff --git a/test/integration/cross_platform_test.exs b/test/integration/cross_platform_test.exs deleted file mode 100644 index 2fd9ba0b..00000000 --- a/test/integration/cross_platform_test.exs +++ /dev/null @@ -1,408 +0,0 @@ -defmodule TermUI.Integration.CrossPlatformTest do - @moduledoc """ - Integration tests for cross-platform behavior. - - Verifies consistent behavior across operating systems and validates - that platform-specific code works correctly on each platform. - """ - - use ExUnit.Case, async: false - - alias TermUI.Event.Key - alias TermUI.IntegrationHelpers - alias TermUI.Platform - alias TermUI.Platform.Unix - alias TermUI.Platform.Windows - - import IntegrationHelpers, only: [parse: 1] - - # These tests validate platform behavior - @moduletag :integration - - setup do - IntegrationHelpers.stop_terminal() - - on_exit(fn -> - IntegrationHelpers.cleanup_terminal() - end) - - :ok - end - - describe "1.6.4.1 terminal initialization on all platforms" do - test "terminal genserver starts successfully" do - assert {:ok, pid} = IntegrationHelpers.start_terminal() - assert is_pid(pid) - assert Process.alive?(pid) - end - - test "platform detection returns valid platform" do - platform = Platform.platform() - - assert platform in [:linux, :macos, :windows, :freebsd, :unknown], - "Got unexpected platform: #{platform}" - end - - test "os version is parseable" do - version = Platform.os_version() - - case version do - {major, minor, patch} -> - assert is_integer(major) and major >= 0 - assert is_integer(minor) and minor >= 0 - assert is_integer(patch) and patch >= 0 - - nil -> - # Some platforms may not provide version - assert true - end - end - - test "unix?/windows? are mutually exclusive" do - is_unix = Platform.unix?() - is_windows = Platform.windows?() - - # Can't be both (or should be exactly one) - refute is_unix and is_windows, "Platform cannot be both Unix and Windows" - - # At least one should be true for known platforms - if Platform.platform() in [:linux, :macos, :freebsd] do - assert is_unix - refute is_windows - end - - if Platform.platform() == :windows do - assert is_windows - refute is_unix - end - end - - test "platform info aggregates correctly" do - info = Platform.info() - - assert is_map(info) - assert info.platform == Platform.platform() - assert info.unix == Platform.unix?() - assert info.windows == Platform.windows?() - assert info.wsl == Platform.wsl?() - end - end - - describe "1.6.4.2 input parsing consistency" do - test "basic key sequences are platform-agnostic" do - # ASCII characters work the same everywhere - {events, ""} = parse("abc") - - assert [ - %Key{key: "a", modifiers: []}, - %Key{key: "b", modifiers: []}, - %Key{key: "c", modifiers: []} - ] = events - end - - test "control characters are platform-agnostic" do - # Ctrl+C is ASCII 3 everywhere - {events, ""} = parse(<<3>>) - assert [%Key{key: "c", modifiers: [:ctrl]}] = events - end - - test "escape sequences follow VT100 standard" do - # Arrow keys use same sequences on all platforms - {events, ""} = parse("\e[A") - assert [%Key{key: :up, modifiers: []}] = events - - {events, ""} = parse("\e[B") - assert [%Key{key: :down, modifiers: []}] = events - end - - test "enter key is consistent" do - # Enter is carriage return (13) on all platforms - {events, ""} = parse(<<13>>) - assert [%Key{key: :enter, modifiers: []}] = events - end - - test "tab key is consistent" do - {events, ""} = parse(<<9>>) - assert [%Key{key: :tab, modifiers: []}] = events - end - - test "backspace handling" do - # Backspace can be 8 (BS) or 127 (DEL) - {events1, ""} = parse(<<8>>) - {events2, ""} = parse(<<127>>) - - # Both should parse to some form of backspace - assert length(events1) == 1 - assert length(events2) == 1 - end - end - - describe "1.6.4.3 terminal size detection" do - test "returns valid dimensions" do - {:ok, _pid} = IntegrationHelpers.start_terminal() - - case TermUI.Terminal.get_terminal_size() do - {:ok, {rows, cols}} -> - assert is_integer(rows) and rows > 0, "Rows should be positive integer" - assert is_integer(cols) and cols > 0, "Cols should be positive integer" - - {:error, _reason} -> - # In non-terminal environment, this is expected - assert true - end - end - - test "platform terminal_size returns reasonable defaults" do - {rows, cols} = Platform.terminal_size() - - # Should always return positive integers - assert is_integer(rows) and rows > 0 - assert is_integer(cols) and cols > 0 - - # Reasonable bounds (at least 1x1, at most something reasonable) - assert rows >= 1 and rows <= 1000 - assert cols >= 1 and cols <= 1000 - end - - test "terminal size is consistent with platform" do - # Get size from both sources - platform_size = Platform.terminal_size() - - {:ok, _pid} = IntegrationHelpers.start_terminal() - genserver_result = TermUI.Terminal.get_terminal_size() - - case genserver_result do - {:ok, genserver_size} -> - # Sizes should match (or be close if there's a race) - {p_rows, p_cols} = platform_size - {g_rows, g_cols} = genserver_size - - # Allow small differences due to timing - assert abs(p_rows - g_rows) <= 1 - assert abs(p_cols - g_cols) <= 1 - - {:error, _} -> - # GenServer couldn't get size, but platform should still have defaults - assert is_tuple(platform_size) - end - end - end - - describe "1.6.4.4 cleanup on all platforms" do - test "restore resets all state" do - {:ok, _pid} = IntegrationHelpers.start_terminal() - - # Call restore - assert :ok = TermUI.Terminal.restore() - - # State should be clean - IntegrationHelpers.assert_terminal_clean() - end - - test "genserver stop cleans up" do - {:ok, pid} = IntegrationHelpers.start_terminal() - - # Stop normally - GenServer.stop(pid, :normal) - - # Process should be gone - assert Process.whereis(TermUI.Terminal) == nil - end - - test "multiple cleanup calls are safe" do - {:ok, _pid} = IntegrationHelpers.start_terminal() - - # Multiple restores should be safe - :ok = TermUI.Terminal.restore() - :ok = TermUI.Terminal.restore() - :ok = TermUI.Terminal.restore() - - IntegrationHelpers.assert_terminal_clean() - end - end - - describe "platform-specific functionality" do - @tag :unix - test "Unix platforms support all standard features" do - if Platform.unix?() do - assert Platform.supports_feature?(:signals) == true - assert Platform.supports_feature?(:pty) == true - assert Platform.supports_feature?(:terminfo) == true - assert Platform.supports_feature?(:vt_sequences) == true - end - end - - @tag :unix - test "Unix module provides correct info" do - if Platform.unix?() do - info = Unix.info() - - assert is_map(info) - assert info.supports_signals == true - assert info.supports_pty == true - assert is_list(info.terminfo_paths) - end - end - - @tag :unix - test "Unix terminfo paths exist" do - if Platform.unix?() do - paths = Unix.terminfo_paths() - - # At least one path should exist - existing = - Enum.filter(paths, fn path -> - File.dir?(path) - end) - - assert length(existing) > 0, "Some terminfo paths should exist" - end - end - - @tag :unix - test "Unix signals are listed" do - if Platform.unix?() do - signals = Unix.supported_signals() - - assert is_list(signals) - assert :sigwinch in signals - assert :sigterm in signals - assert :sigint in signals - end - end - - test "Windows module provides stub info" do - info = Windows.info() - - assert is_map(info) - assert info.platform == :windows - assert info.implementation_status == :stub - end - - test "Windows version requirements are specified" do - {major, minor, build} = Windows.minimum_version() - - assert major == 10 - assert minor == 0 - assert build == 10_586 - end - - test "Windows VT support check" do - result = Windows.vt_support_available?() - assert is_boolean(result) - end - end - - describe "feature support matrix" do - @features [:signals, :pty, :terminfo, :vt_sequences] - - for feature <- @features do - @feature feature - - test "supports_feature?(#{feature}) returns boolean" do - result = Platform.supports_feature?(@feature) - assert is_boolean(result) - end - end - - test "unknown features return false" do - assert Platform.supports_feature?(:nonexistent) == false - assert Platform.supports_feature?(:made_up_feature) == false - end - - test "vt_sequences supported on all platforms" do - # VT sequences should be supported everywhere (for modern terminals) - assert Platform.supports_feature?(:vt_sequences) == true - end - end - - describe "WSL detection" do - test "wsl? returns boolean" do - result = Platform.wsl?() - assert is_boolean(result) - end - - test "wsl? is false on non-Linux" do - if Platform.platform() != :linux do - assert Platform.wsl?() == false - end - end - - test "linux? excludes WSL" do - # If we're in WSL, linux? should be false - if Platform.wsl?() do - assert Platform.linux?() == false - end - end - end - - describe "platform consistency" do - test "platform detection is consistent" do - # Multiple calls should return the same result - p1 = Platform.platform() - p2 = Platform.platform() - p3 = Platform.platform() - - assert p1 == p2 - assert p2 == p3 - end - - test "helper functions match platform detection" do - platform = Platform.platform() - - case platform do - :linux -> - if not Platform.wsl?() do - assert Platform.linux?() == true - end - - assert Platform.unix?() == true - - :macos -> - assert Platform.macos?() == true - assert Platform.unix?() == true - - :freebsd -> - assert Platform.unix?() == true - - :windows -> - assert Platform.windows?() == true - assert Platform.unix?() == false - - :unknown -> - # Unknown platform - just ensure no crashes - assert true - end - end - end - - describe "ANSI sequence compatibility" do - test "cursor sequences are platform-agnostic" do - # These should work on all platforms with VT support - seqs = [ - TermUI.ANSI.cursor_position(1, 1), - TermUI.ANSI.cursor_up(), - TermUI.ANSI.cursor_down(), - TermUI.ANSI.cursor_show(), - TermUI.ANSI.cursor_hide() - ] - - for seq <- seqs do - binary = IO.iodata_to_binary(seq) - assert String.starts_with?(binary, "\e[") - end - end - - test "color sequences use standard codes" do - # SGR sequences should be standard - red = TermUI.ANSI.foreground(:red) |> IO.iodata_to_binary() - assert red == "\e[31m" - - bold = TermUI.ANSI.bold() |> IO.iodata_to_binary() - assert bold == "\e[1m" - - reset = TermUI.ANSI.reset() |> IO.iodata_to_binary() - assert reset == "\e[0m" - end - end -end diff --git a/test/integration/input_abstraction_test.exs b/test/integration/input_abstraction_test.exs deleted file mode 100644 index e3747597..00000000 --- a/test/integration/input_abstraction_test.exs +++ /dev/null @@ -1,516 +0,0 @@ -defmodule TermUI.Integration.InputAbstractionTest do - @moduledoc """ - Integration tests for Phase 4 Input Abstraction layer. - - These tests verify that the Input.Selector, Input.Raw, Input.TTY, and - Input.LineReader modules work together correctly to provide a unified - input abstraction across both backend modes. - - ## Test Categories - - 1. **Mode Selection (4.6.1)**: Verify Input.Selector correctly maps backend - modes to input handlers - - 2. **Input Equivalence (4.6.2)**: Verify both Raw and TTY handlers produce - identical Event structs for the same input sequences - - 3. **LineReader (4.6.3)**: Verify LineReader works correctly for TextInput.Line - """ - - use ExUnit.Case, async: false - - alias TermUI.Event - alias TermUI.Input - alias TermUI.Input.LineReader - alias TermUI.Input.Raw - alias TermUI.Input.Selector - alias TermUI.Input.TTY - - import ExUnit.CaptureIO - - # =========================================================================== - # Task 4.6.1: Input Mode Selection Tests - # =========================================================================== - - describe "input mode selection (Task 4.6.1)" do - test "4.6.1.1 - Raw handler selected when backend is raw" do - # select(:raw) should return the Raw input handler - handler = Selector.select(:raw) - - assert handler == TermUI.Input.Raw - - # Handler should implement the Input behaviour - behaviours = handler.__info__(:attributes)[:behaviour] || [] - assert TermUI.Input in behaviours - - # Handler should create valid state - state = handler.new() - assert is_struct(state, Raw) - - # Handler mode should return :raw - assert handler.mode(state) == :raw - end - - test "4.6.1.2 - TTY handler selected when backend is tty" do - # select(:tty) should return the TTY input handler - handler = Selector.select(:tty) - - assert handler == TermUI.Input.TTY - - # Handler should implement the Input behaviour - behaviours = handler.__info__(:attributes)[:behaviour] || [] - assert TermUI.Input in behaviours - - # Handler should create valid state - state = handler.new() - assert is_struct(state, TTY) - - # Handler mode should return :tty - assert handler.mode(state) == :tty - end - - test "select/0 auto-detection returns valid handler" do - # select/0 should return either Raw or TTY based on backend detection - handler = Selector.select() - - assert handler in [TermUI.Input.Raw, TermUI.Input.TTY] - - # Whichever handler is returned should work correctly - state = handler.new() - mode = handler.mode(state) - - # Mode should match the handler type - cond do - handler == TermUI.Input.Raw -> assert mode == :raw - handler == TermUI.Input.TTY -> assert mode == :tty - end - end - - test "selected handlers have consistent interface" do - # Both handlers should have the same interface - for mode <- [:raw, :tty] do - handler = Selector.select(mode) - - # All handlers should have new/0 - assert function_exported?(handler, :new, 0) - - # All handlers should have poll/2 - assert function_exported?(handler, :poll, 2) - - # All handlers should have mode/1 - assert function_exported?(handler, :mode, 1) - - # State should be a struct with buffer and event_queue - state = handler.new() - assert Map.has_key?(state, :buffer) - assert Map.has_key?(state, :event_queue) - end - end - - test "invalid mode raises ArgumentError" do - assert_raise ArgumentError, ~r/invalid input mode/, fn -> - Selector.select(:invalid) - end - end - end - - # =========================================================================== - # Task 4.6.2: Input Equivalence Tests - # =========================================================================== - - describe "input equivalence (Task 4.6.2)" do - # These tests verify that Raw and TTY handlers produce identical events - # when parsing the same input sequences. Since both use EscapeParser, - # they should produce byte-for-byte identical Event structs. - - test "4.6.2.1 - arrow keys produce same events in both modes" do - # Test all arrow keys - arrow_sequences = [ - {"\e[A", :up, "arrow up"}, - {"\e[B", :down, "arrow down"}, - {"\e[C", :right, "arrow right"}, - {"\e[D", :left, "arrow left"} - ] - - for {sequence, expected_key, description} <- arrow_sequences do - raw_state = %Raw{buffer: sequence, event_queue: []} - tty_state = %TTY{buffer: sequence, event_queue: []} - - # Both handlers should parse to identical events - # We use a task with short timeout to avoid blocking on IO.getn - raw_result = parse_buffered_input(Raw, raw_state) - tty_result = parse_buffered_input(TTY, tty_state) - - assert {:ok, raw_event} = raw_result, "Raw failed to parse #{description}" - assert {:ok, tty_event} = tty_result, "TTY failed to parse #{description}" - - # Events should be identical - assert raw_event == tty_event, "#{description} events differ" - - # Verify it's the correct key - assert raw_event.key == expected_key, "#{description} has wrong key" - end - end - - test "4.6.2.2 - Enter key produces same event in both modes" do - # Enter is typically \r (carriage return) - raw_state = %Raw{buffer: "\r", event_queue: []} - tty_state = %TTY{buffer: "\r", event_queue: []} - - raw_result = parse_buffered_input(Raw, raw_state) - tty_result = parse_buffered_input(TTY, tty_state) - - assert {:ok, raw_event} = raw_result - assert {:ok, tty_event} = tty_result - - # Events should be identical - assert raw_event == tty_event - - # Should be enter key - assert raw_event.key == :enter - end - - test "4.6.2.3 - Tab key produces same event in both modes" do - # Tab is \t - raw_state = %Raw{buffer: "\t", event_queue: []} - tty_state = %TTY{buffer: "\t", event_queue: []} - - raw_result = parse_buffered_input(Raw, raw_state) - tty_result = parse_buffered_input(TTY, tty_state) - - assert {:ok, raw_event} = raw_result - assert {:ok, tty_event} = tty_result - - # Events should be identical - assert raw_event == tty_event - - # Should be tab key - assert raw_event.key == :tab - end - - test "4.6.2.4 - printable characters produce same events in both modes" do - # Test various printable characters - test_chars = ["a", "Z", "5", "@", " ", "!", "~"] - - for char <- test_chars do - raw_state = %Raw{buffer: char, event_queue: []} - tty_state = %TTY{buffer: char, event_queue: []} - - raw_result = parse_buffered_input(Raw, raw_state) - tty_result = parse_buffered_input(TTY, tty_state) - - assert {:ok, raw_event} = raw_result, "Raw failed to parse '#{char}'" - assert {:ok, tty_event} = tty_result, "TTY failed to parse '#{char}'" - - # Events should be identical - assert raw_event == tty_event, "'#{char}' events differ" - - # Should have the character as the key - assert raw_event.key == char - assert raw_event.char == char - end - end - - test "function keys produce same events in both modes" do - # Test F1-F4 (most common escape sequences) - function_keys = [ - {"\eOP", :f1}, - {"\eOQ", :f2}, - {"\eOR", :f3}, - {"\eOS", :f4} - ] - - for {sequence, expected_key} <- function_keys do - raw_state = %Raw{buffer: sequence, event_queue: []} - tty_state = %TTY{buffer: sequence, event_queue: []} - - raw_result = parse_buffered_input(Raw, raw_state) - tty_result = parse_buffered_input(TTY, tty_state) - - assert {:ok, raw_event} = raw_result - assert {:ok, tty_event} = tty_result - - assert raw_event == tty_event - assert raw_event.key == expected_key - end - end - - test "escape key produces same event in both modes" do - # Standalone escape (should timeout and produce escape event) - # For this test, we simulate a lone ESC that has already been - # determined to be standalone (not part of a sequence) - raw_event = Event.key(:escape) - tty_event = Event.key(:escape) - - assert raw_event == tty_event - assert raw_event.key == :escape - end - - test "backspace produces same event in both modes" do - # Backspace is typically 127 (DEL) or 8 (BS) - raw_state = %Raw{buffer: <<127>>, event_queue: []} - tty_state = %TTY{buffer: <<127>>, event_queue: []} - - raw_result = parse_buffered_input(Raw, raw_state) - tty_result = parse_buffered_input(TTY, tty_state) - - assert {:ok, raw_event} = raw_result - assert {:ok, tty_event} = tty_result - - assert raw_event == tty_event - assert raw_event.key == :backspace - end - - test "home and end keys produce same events in both modes" do - sequences = [ - {"\e[H", :home}, - {"\e[F", :end} - ] - - for {sequence, expected_key} <- sequences do - raw_state = %Raw{buffer: sequence, event_queue: []} - tty_state = %TTY{buffer: sequence, event_queue: []} - - raw_result = parse_buffered_input(Raw, raw_state) - tty_result = parse_buffered_input(TTY, tty_state) - - assert {:ok, raw_event} = raw_result - assert {:ok, tty_event} = tty_result - - assert raw_event == tty_event - assert raw_event.key == expected_key - end - end - end - - # =========================================================================== - # Task 4.6.3: Line Reader Integration Tests - # =========================================================================== - - describe "line reader integration (Task 4.6.3)" do - test "4.6.3.1 - line input with prompt" do - capture_io([input: "test input\n", capture_prompt: false], fn -> - result = LineReader.read_line("Enter: ") - send(self(), {:result, result}) - end) - - assert_receive {:result, {:ok, "test input"}} - end - - test "4.6.3.1 - line input without prompt" do - capture_io([input: "hello world\n", capture_prompt: false], fn -> - result = LineReader.read_line() - send(self(), {:result, result}) - end) - - assert_receive {:result, {:ok, "hello world"}} - end - - test "4.6.3.1 - line input preserves internal whitespace" do - capture_io([input: "hello world\n", capture_prompt: false], fn -> - result = LineReader.read_line() - send(self(), {:result, result}) - end) - - assert_receive {:result, {:ok, "hello world"}} - end - - test "4.6.3.2 - validation callback accepts valid input" do - validator = fn input -> - if String.length(input) >= 3 do - :ok - else - {:error, "too short"} - end - end - - capture_io([input: "valid\n", capture_prompt: false], fn -> - result = LineReader.read_line("Input: ", validator) - send(self(), {:result, result}) - end) - - assert_receive {:result, {:ok, "valid"}} - end - - test "4.6.3.2 - validation callback rejects invalid input" do - validator = fn input -> - if String.length(input) >= 3 do - :ok - else - {:error, "too short"} - end - end - - capture_io([input: "ab\n", capture_prompt: false], fn -> - result = LineReader.read_line("Input: ", validator) - send(self(), {:result, result}) - end) - - assert_receive {:result, {:error, "too short"}} - end - - test "4.6.3.2 - validation callback can transform input" do - validator = fn input -> - case Integer.parse(input) do - {num, ""} -> {:ok, num} - _ -> {:error, "not a number"} - end - end - - capture_io([input: "42\n", capture_prompt: false], fn -> - result = LineReader.read_line("Number: ", validator) - send(self(), {:result, result}) - end) - - assert_receive {:result, {:ok, 42}} - end - - test "4.6.3.3 - EOF handling returns :eof" do - # Simulate EOF by providing empty input (IO.gets returns :eof) - # Note: capture_io with empty input may not perfectly simulate EOF, - # but we can verify the LineReader handles the :eof case properly - # by checking the module structure - - # Verify LineReader handles EOF in its implementation - # The function returns :eof when IO.gets returns :eof - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(LineReader) - assert String.contains?(moduledoc, "EOF") - assert String.contains?(moduledoc, ":eof") - end - - test "4.6.3.3 - read_line/1 spec includes :eof return type" do - # Verify the type specification includes :eof - {:docs_v1, _, :elixir, _, _, _, functions} = Code.fetch_docs(LineReader) - - read_line_doc = - Enum.find(functions, fn - {{:function, :read_line, 1}, _, _, _, _} -> true - _ -> false - end) - - assert read_line_doc != nil - {_, _, _, %{"en" => doc}, _} = read_line_doc - assert String.contains?(doc, "eof") - end - - test "LineReader is NOT a behaviour implementation" do - # LineReader should NOT implement the Input behaviour - # It's a standalone utility module - behaviours = LineReader.__info__(:attributes)[:behaviour] || [] - refute TermUI.Input in behaviours - end - - test "LineReader works independently of Input handlers" do - # LineReader should not require Raw or TTY handlers - # It uses IO.gets directly - - # Verify it doesn't depend on Input.Raw or Input.TTY modules - # by checking it can be used standalone - capture_io([input: "standalone\n", capture_prompt: false], fn -> - result = LineReader.read_line() - send(self(), {:result, result}) - end) - - assert_receive {:result, {:ok, "standalone"}} - end - end - - # =========================================================================== - # Additional Integration Tests - # =========================================================================== - - describe "handler state management" do - test "Raw and TTY handlers maintain independent state" do - raw_handler = Selector.select(:raw) - tty_handler = Selector.select(:tty) - - raw_state = raw_handler.new() - tty_state = tty_handler.new() - - # States should be different struct types - assert raw_state.__struct__ == Raw - assert tty_state.__struct__ == TTY - - # Modifying one should not affect the other - raw_state2 = %{raw_state | buffer: "test"} - assert raw_state2.buffer == "test" - assert tty_state.buffer == <<>> - end - - test "handlers can be used interchangeably in loops" do - # Simulate a widget that uses whichever handler is selected - for mode <- [:raw, :tty] do - handler = Selector.select(mode) - state = handler.new() - - # Simulate processing loop - state = %{state | buffer: "a"} - - # Handler should be usable - assert handler.mode(state) == mode - assert Map.has_key?(state, :buffer) - assert Map.has_key?(state, :event_queue) - end - end - end - - describe "Input behaviour contract" do - test "both handlers satisfy Input behaviour" do - for handler <- [Raw, TTY] do - # Check behaviour implementation - behaviours = handler.__info__(:attributes)[:behaviour] || [] - assert Input in behaviours - - # Check required callbacks exist - assert function_exported?(handler, :poll, 2) - assert function_exported?(handler, :mode, 1) - end - end - - test "poll/2 returns correct tuple format" do - for handler <- [Raw, TTY] do - state = handler.new() - - # Add something to buffer so we can get a result without blocking - state = %{state | buffer: "x"} - - # poll should return {result, new_state} - {result, new_state} = handler.poll(state, 0) - - # Result should be one of the expected formats - assert match?({{:ok, _event}, _}, {result, new_state}) or - match?({:timeout, _}, {result, new_state}) or - match?({:eof, _}, {result, new_state}) - - # New state should be same struct type - assert new_state.__struct__ == state.__struct__ - end - end - end - - # =========================================================================== - # Helper Functions - # =========================================================================== - - # Parse input from a pre-populated buffer without doing actual IO - # This avoids blocking on IO.getn while still testing the parsing logic - defp parse_buffered_input(handler_module, state) do - # Use a task with timeout to avoid blocking if handler tries to read more - task = - Task.async(fn -> - try do - {result, _state} = handler_module.poll(state, 0) - result - catch - :exit, _ -> :timeout - end - end) - - case Task.yield(task, 100) || Task.shutdown(task) do - {:ok, {:ok, event}} -> {:ok, event} - {:ok, :timeout} -> :need_more - {:ok, :eof} -> :eof - nil -> :timeout - end - end -end diff --git a/test/integration/keyboard_navigation_integration_test.exs b/test/integration/keyboard_navigation_integration_test.exs deleted file mode 100644 index 15c465ed..00000000 --- a/test/integration/keyboard_navigation_integration_test.exs +++ /dev/null @@ -1,618 +0,0 @@ -defmodule TermUI.Integration.KeyboardNavigationIntegrationTest do - @moduledoc """ - Integration tests for keyboard navigation across widgets. - - These tests verify that keyboard navigation works correctly and identically - across both Raw and TTY modes. Since `Event.Key` is produced the same way - in both modes (arrow keys produce identical ANSI sequences), widget behavior - is inherently mode-independent. - - ## Test Coverage - - - **Menu Navigation** (5.7.2.1/2/3): Arrow keys, Enter, Escape - - **Tabs Navigation** (5.7.2.4): Left/Right arrows, Enter, Home/End - - **TreeView Navigation** (5.7.2.1/2 - "List" equivalent): Up/Down arrows, expand/collapse - - **Cross-Mode Verification** (5.7.2.5): Same events produce same results - - ## Why Keyboard Navigation is Mode-Independent - - Arrow keys produce identical ANSI escape sequences regardless of backend: - - Up: `\\e[A` → `Event.Key{key: :up}` - - Down: `\\e[B` → `Event.Key{key: :down}` - - Left: `\\e[D` → `Event.Key{key: :left}` - - Right: `\\e[C` → `Event.Key{key: :right}` - - Widgets receive `Event.Key` structs which are backend-agnostic. - Therefore, testing widget response to `Event.Key` verifies behavior - works identically in both modes. - """ - - use ExUnit.Case, async: true - - alias TermUI.Event - alias TermUI.Theme - alias TermUI.Widgets.{Menu, Tabs, TreeView} - - setup do - # Start Theme server (ignore if already started) - case Theme.start_link(theme: :dark) do - {:ok, _pid} -> :ok - {:error, {:already_started, _pid}} -> :ok - end - - :ok - end - - # =========================================================================== - # Test 5.7.2.1/5.7.2.2: List-like Navigation (using TreeView and Menu) - # =========================================================================== - - describe "list-like navigation - TreeView (up/down arrows)" do - setup do - nodes = [ - TreeView.node(:item1, "Item 1"), - TreeView.node(:item2, "Item 2"), - TreeView.node(:item3, "Item 3"), - TreeView.node(:item4, "Item 4") - ] - - props = TreeView.new(nodes: nodes) - {:ok, state} = TreeView.init(props) - %{state: state, nodes: nodes} - end - - test "down arrow moves cursor through list items", %{state: state} do - # TreeView uses integer indices for cursor - # Start at index 0 (item1), move down through all items - assert state.cursor == 0 - - # Down to index 1 (item2) - {:ok, state} = TreeView.handle_event(%Event.Key{key: :down}, state) - assert state.cursor == 1 - - # Down to index 2 (item3) - {:ok, state} = TreeView.handle_event(%Event.Key{key: :down}, state) - assert state.cursor == 2 - - # Down to index 3 (item4) - {:ok, state} = TreeView.handle_event(%Event.Key{key: :down}, state) - assert state.cursor == 3 - - # At end - stays on index 3 - {:ok, state} = TreeView.handle_event(%Event.Key{key: :down}, state) - assert state.cursor == 3 - end - - test "up arrow moves cursor back through list items", %{state: state} do - # Move to last item first (index 3) - state = %{state | cursor: 3} - - # Up to index 2 (item3) - {:ok, state} = TreeView.handle_event(%Event.Key{key: :up}, state) - assert state.cursor == 2 - - # Up to index 1 (item2) - {:ok, state} = TreeView.handle_event(%Event.Key{key: :up}, state) - assert state.cursor == 1 - - # Up to index 0 (item1) - {:ok, state} = TreeView.handle_event(%Event.Key{key: :up}, state) - assert state.cursor == 0 - - # At start - stays on index 0 - {:ok, state} = TreeView.handle_event(%Event.Key{key: :up}, state) - assert state.cursor == 0 - end - - test "complete navigation workflow", %{state: state} do - # Start at index 0 - assert state.cursor == 0 - - # Navigate down to index 2 (item3) - {:ok, state} = TreeView.handle_event(%Event.Key{key: :down}, state) - {:ok, state} = TreeView.handle_event(%Event.Key{key: :down}, state) - assert state.cursor == 2 - - # Navigate back up to index 0 (item1) - {:ok, state} = TreeView.handle_event(%Event.Key{key: :up}, state) - {:ok, state} = TreeView.handle_event(%Event.Key{key: :up}, state) - assert state.cursor == 0 - end - - test "home jumps to first item", %{state: state} do - state = %{state | cursor: 2} - - {:ok, state} = TreeView.handle_event(%Event.Key{key: :home}, state) - assert state.cursor == 0 - end - - test "end jumps to last item", %{state: state} do - {:ok, state} = TreeView.handle_event(%Event.Key{key: :end}, state) - # Index of last item - assert state.cursor == 3 - end - end - - describe "list-like navigation - Menu (up/down arrows)" do - setup do - items = [ - Menu.action(:action1, "Action 1"), - Menu.action(:action2, "Action 2"), - Menu.action(:action3, "Action 3") - ] - - props = Menu.new(items: items) - {:ok, state} = Menu.init(props) - %{state: state} - end - - test "down arrow moves cursor through menu items", %{state: state} do - assert state.cursor == :action1 - - {:ok, state} = Menu.handle_event(%Event.Key{key: :down}, state) - assert state.cursor == :action2 - - {:ok, state} = Menu.handle_event(%Event.Key{key: :down}, state) - assert state.cursor == :action3 - - # At end - stays on action3 - {:ok, state} = Menu.handle_event(%Event.Key{key: :down}, state) - assert state.cursor == :action3 - end - - test "up arrow moves cursor back through menu items", %{state: state} do - state = %{state | cursor: :action3} - - {:ok, state} = Menu.handle_event(%Event.Key{key: :up}, state) - assert state.cursor == :action2 - - {:ok, state} = Menu.handle_event(%Event.Key{key: :up}, state) - assert state.cursor == :action1 - - # At start - stays on action1 - {:ok, state} = Menu.handle_event(%Event.Key{key: :up}, state) - assert state.cursor == :action1 - end - end - - # =========================================================================== - # Test 5.7.2.3: Menu Navigation (complete workflow) - # =========================================================================== - - describe "menu navigation - complete workflow" do - setup do - items = [ - Menu.action(:new, "New"), - Menu.action(:open, "Open"), - Menu.separator(), - Menu.submenu(:recent, "Recent", [ - Menu.action(:file1, "File 1"), - Menu.action(:file2, "File 2") - ]), - Menu.action(:exit, "Exit") - ] - - props = Menu.new(items: items) - {:ok, state} = Menu.init(props) - %{state: state} - end - - test "navigation skips separators", %{state: state} do - # Start at :new - assert state.cursor == :new - - # Down to :open - {:ok, state} = Menu.handle_event(%Event.Key{key: :down}, state) - assert state.cursor == :open - - # Down skips separator, goes to :recent (submenu) - {:ok, state} = Menu.handle_event(%Event.Key{key: :down}, state) - assert state.cursor == :recent - - # Down to :exit - {:ok, state} = Menu.handle_event(%Event.Key{key: :down}, state) - assert state.cursor == :exit - end - - test "right arrow expands submenu", %{state: state} do - # Navigate to submenu - state = %{state | cursor: :recent} - - # Expand with right arrow - {:ok, state} = Menu.handle_event(%Event.Key{key: :right}, state) - assert MapSet.member?(state.expanded, :recent) - end - - test "left arrow collapses submenu", %{state: state} do - # Navigate to submenu and expand it - state = %{state | cursor: :recent, expanded: MapSet.new([:recent])} - - # Collapse with left arrow - {:ok, state} = Menu.handle_event(%Event.Key{key: :left}, state) - refute MapSet.member?(state.expanded, :recent) - end - - test "enter triggers selection callback", %{state: state} do - test_pid = self() - on_select = fn id -> send(test_pid, {:selected, id}) end - state = %{state | on_select: on_select} - - {:ok, _state} = Menu.handle_event(%Event.Key{key: :enter}, state) - assert_receive {:selected, :new} - end - - test "escape signals menu close", %{state: state} do - {:ok, _state, effects} = Menu.handle_event(%Event.Key{key: :escape}, state) - - assert Enum.any?(effects, fn - {:send, _, :menu_close} -> true - _ -> false - end) - end - - test "complete menu workflow: navigate, expand, select", %{state: state} do - test_pid = self() - on_select = fn id -> send(test_pid, {:selected, id}) end - state = %{state | on_select: on_select} - - # Navigate to submenu - {:ok, state} = Menu.handle_event(%Event.Key{key: :down}, state) - {:ok, state} = Menu.handle_event(%Event.Key{key: :down}, state) - assert state.cursor == :recent - - # Expand submenu - {:ok, state} = Menu.handle_event(%Event.Key{key: :right}, state) - assert MapSet.member?(state.expanded, :recent) - - # Navigate to first child - {:ok, state} = Menu.handle_event(%Event.Key{key: :down}, state) - assert state.cursor == :file1 - - # Select - {:ok, _state} = Menu.handle_event(%Event.Key{key: :enter}, state) - assert_receive {:selected, :file1} - end - end - - # =========================================================================== - # Test 5.7.2.4: Tabs Navigation - # =========================================================================== - - describe "tabs navigation" do - setup do - tabs = [ - %{id: :tab1, label: "Tab 1", content: "Content 1"}, - %{id: :tab2, label: "Tab 2", content: "Content 2"}, - %{id: :tab3, label: "Tab 3", content: "Content 3", disabled: true}, - %{id: :tab4, label: "Tab 4", content: "Content 4"} - ] - - props = Tabs.new(tabs: tabs) - {:ok, state} = Tabs.init(props) - %{state: state} - end - - test "right arrow moves focus to next tab", %{state: state} do - assert state.focused == :tab1 - - {:ok, state} = Tabs.handle_event(%Event.Key{key: :right}, state) - assert state.focused == :tab2 - - # Skip disabled tab3 - {:ok, state} = Tabs.handle_event(%Event.Key{key: :right}, state) - assert state.focused == :tab4 - end - - test "left arrow moves focus to previous tab", %{state: state} do - state = %{state | focused: :tab4} - - # Skip disabled tab3 - {:ok, state} = Tabs.handle_event(%Event.Key{key: :left}, state) - assert state.focused == :tab2 - - {:ok, state} = Tabs.handle_event(%Event.Key{key: :left}, state) - assert state.focused == :tab1 - end - - test "home jumps to first tab", %{state: state} do - state = %{state | focused: :tab4} - - {:ok, state} = Tabs.handle_event(%Event.Key{key: :home}, state) - assert state.focused == :tab1 - end - - test "end jumps to last tab", %{state: state} do - {:ok, state} = Tabs.handle_event(%Event.Key{key: :end}, state) - assert state.focused == :tab4 - end - - test "enter selects focused tab", %{state: state} do - test_pid = self() - on_change = fn id -> send(test_pid, {:tab_changed, id}) end - state = %{state | focused: :tab2, on_change: on_change} - - {:ok, new_state} = Tabs.handle_event(%Event.Key{key: :enter}, state) - - assert new_state.selected == :tab2 - assert_receive {:tab_changed, :tab2} - end - - test "space selects focused tab", %{state: state} do - test_pid = self() - on_change = fn id -> send(test_pid, {:tab_changed, id}) end - state = %{state | focused: :tab2, on_change: on_change} - - {:ok, new_state} = Tabs.handle_event(%Event.Key{key: " "}, state) - - assert new_state.selected == :tab2 - assert_receive {:tab_changed, :tab2} - end - - test "complete tabs workflow: navigate and select", %{state: state} do - test_pid = self() - on_change = fn id -> send(test_pid, {:tab_changed, id}) end - state = %{state | on_change: on_change} - - # Initial state - assert state.focused == :tab1 - assert state.selected == :tab1 - - # Navigate right to tab2 - {:ok, state} = Tabs.handle_event(%Event.Key{key: :right}, state) - assert state.focused == :tab2 - # Not selected yet - assert state.selected == :tab1 - - # Select tab2 - {:ok, state} = Tabs.handle_event(%Event.Key{key: :enter}, state) - assert state.selected == :tab2 - assert_receive {:tab_changed, :tab2} - - # Navigate right (skips disabled tab3) to tab4 - {:ok, state} = Tabs.handle_event(%Event.Key{key: :right}, state) - assert state.focused == :tab4 - - # Select tab4 - {:ok, state} = Tabs.handle_event(%Event.Key{key: :enter}, state) - assert state.selected == :tab4 - assert_receive {:tab_changed, :tab4} - end - end - - # =========================================================================== - # Test 5.7.2.5: Verify Identical Behavior Between Modes - # =========================================================================== - - describe "identical behavior verification" do - @moduledoc """ - These tests verify that keyboard navigation behavior is deterministic and - mode-independent. Since Event.Key is produced identically in both modes, - the same events should always produce the same state transitions. - """ - - test "menu navigation is deterministic" do - items = [ - Menu.action(:a, "A"), - Menu.action(:b, "B"), - Menu.action(:c, "C") - ] - - props = Menu.new(items: items) - - # Run same navigation sequence twice - for _iteration <- 1..2 do - {:ok, state} = Menu.init(props) - assert state.cursor == :a - - {:ok, state} = Menu.handle_event(%Event.Key{key: :down}, state) - assert state.cursor == :b - - {:ok, state} = Menu.handle_event(%Event.Key{key: :down}, state) - assert state.cursor == :c - - {:ok, state} = Menu.handle_event(%Event.Key{key: :up}, state) - assert state.cursor == :b - end - end - - test "tabs navigation is deterministic" do - tabs = [ - %{id: :x, label: "X", content: "X"}, - %{id: :y, label: "Y", content: "Y"}, - %{id: :z, label: "Z", content: "Z"} - ] - - props = Tabs.new(tabs: tabs) - - # Run same navigation sequence twice - for _iteration <- 1..2 do - {:ok, state} = Tabs.init(props) - assert state.focused == :x - - {:ok, state} = Tabs.handle_event(%Event.Key{key: :right}, state) - assert state.focused == :y - - {:ok, state} = Tabs.handle_event(%Event.Key{key: :right}, state) - assert state.focused == :z - - {:ok, state} = Tabs.handle_event(%Event.Key{key: :left}, state) - assert state.focused == :y - end - end - - test "treeview navigation is deterministic" do - nodes = [ - TreeView.node(:n1, "N1"), - TreeView.node(:n2, "N2"), - TreeView.node(:n3, "N3") - ] - - props = TreeView.new(nodes: nodes) - - # Run same navigation sequence twice - # TreeView uses integer indices for cursor - for _iteration <- 1..2 do - {:ok, state} = TreeView.init(props) - # First item - assert state.cursor == 0 - - {:ok, state} = TreeView.handle_event(%Event.Key{key: :down}, state) - # Second item - assert state.cursor == 1 - - {:ok, state} = TreeView.handle_event(%Event.Key{key: :down}, state) - # Third item - assert state.cursor == 2 - - {:ok, state} = TreeView.handle_event(%Event.Key{key: :up}, state) - # Back to second - assert state.cursor == 1 - end - end - - test "same events produce same state transitions across widgets" do - # All three widget types should respond to up/down consistently - - # Menu - uses node IDs for cursor - menu_items = [Menu.action(:a, "A"), Menu.action(:b, "B")] - {:ok, menu_state} = Menu.init(Menu.new(items: menu_items)) - {:ok, menu_state} = Menu.handle_event(%Event.Key{key: :down}, menu_state) - assert menu_state.cursor == :b - - # TreeView - uses integer indices for cursor - tree_nodes = [TreeView.node(:a, "A"), TreeView.node(:b, "B")] - {:ok, tree_state} = TreeView.init(TreeView.new(nodes: tree_nodes)) - {:ok, tree_state} = TreeView.handle_event(%Event.Key{key: :down}, tree_state) - # Index 1 = second item - assert tree_state.cursor == 1 - - # Both widgets moved from first to second item with same event - # (Menu uses IDs, TreeView uses indices, but same semantic movement) - end - - test "event.key is backend-agnostic (mode-independent)" do - # This test documents that Event.Key{key: :up} is produced - # identically by both backends, so widget behavior is inherently - # mode-independent. - - # Arrow key events as they would be produced by either backend - up_event = %Event.Key{key: :up} - down_event = %Event.Key{key: :down} - left_event = %Event.Key{key: :left} - right_event = %Event.Key{key: :right} - enter_event = %Event.Key{key: :enter} - - # These exact Event.Key structs are what widgets receive - # regardless of whether Raw or TTY backend produced them - assert up_event.key == :up - assert down_event.key == :down - assert left_event.key == :left - assert right_event.key == :right - assert enter_event.key == :enter - - # Since widgets only see Event.Key structs (not raw escape sequences), - # their behavior is guaranteed to be identical in both modes. - end - end - - # =========================================================================== - # TreeView Expand/Collapse Navigation - # =========================================================================== - - describe "treeview expand/collapse navigation" do - setup do - nodes = [ - TreeView.node(:parent, "Parent", - children: [ - TreeView.node(:child1, "Child 1"), - TreeView.node(:child2, "Child 2") - ] - ), - TreeView.node(:sibling, "Sibling") - ] - - props = TreeView.new(nodes: nodes) - {:ok, state} = TreeView.init(props) - %{state: state} - end - - test "right arrow expands node with children", %{state: state} do - # TreeView uses integer indices for cursor - # First item (parent) - assert state.cursor == 0 - refute MapSet.member?(state.expanded, :parent) - - {:ok, state} = TreeView.handle_event(%Event.Key{key: :right}, state) - assert MapSet.member?(state.expanded, :parent) - end - - test "left arrow collapses expanded node", %{state: state} do - state = %{state | expanded: MapSet.new([:parent])} - - {:ok, state} = TreeView.handle_event(%Event.Key{key: :left}, state) - refute MapSet.member?(state.expanded, :parent) - end - - test "enter toggles expand state", %{state: state} do - # Expand - {:ok, state} = TreeView.handle_event(%Event.Key{key: :enter}, state) - assert MapSet.member?(state.expanded, :parent) - - # Collapse - {:ok, state} = TreeView.handle_event(%Event.Key{key: :enter}, state) - refute MapSet.member?(state.expanded, :parent) - end - - test "can navigate into expanded children", %{state: state} do - # Expand parent - {:ok, state} = TreeView.handle_event(%Event.Key{key: :right}, state) - assert MapSet.member?(state.expanded, :parent) - - # Navigate down into children - # After expansion, flat_nodes = [parent, child1, child2, sibling] - {:ok, state} = TreeView.handle_event(%Event.Key{key: :down}, state) - # Index 1 = child1 - assert state.cursor == 1 - - {:ok, state} = TreeView.handle_event(%Event.Key{key: :down}, state) - # Index 2 = child2 - assert state.cursor == 2 - - {:ok, state} = TreeView.handle_event(%Event.Key{key: :down}, state) - # Index 3 = sibling - assert state.cursor == 3 - end - - test "complete treeview workflow: expand, navigate, collapse", %{state: state} do - # Start at parent (index 0) - assert state.cursor == 0 - - # Expand parent - {:ok, state} = TreeView.handle_event(%Event.Key{key: :enter}, state) - assert MapSet.member?(state.expanded, :parent) - - # Navigate to child1 (index 1) - {:ok, state} = TreeView.handle_event(%Event.Key{key: :down}, state) - assert state.cursor == 1 - - # Navigate to child2 (index 2) - {:ok, state} = TreeView.handle_event(%Event.Key{key: :down}, state) - assert state.cursor == 2 - - # Navigate back up to parent (index 0) - {:ok, state} = TreeView.handle_event(%Event.Key{key: :up}, state) - {:ok, state} = TreeView.handle_event(%Event.Key{key: :up}, state) - assert state.cursor == 0 - - # Collapse parent - {:ok, state} = TreeView.handle_event(%Event.Key{key: :left}, state) - refute MapSet.member?(state.expanded, :parent) - - # After collapse, flat_nodes = [parent, sibling] - # Navigate down - should go to sibling (now index 1) - {:ok, state} = TreeView.handle_event(%Event.Key{key: :down}, state) - # Now sibling is at index 1 - assert state.cursor == 1 - end - end -end diff --git a/test/integration/mouse_fallback_integration_test.exs b/test/integration/mouse_fallback_integration_test.exs deleted file mode 100644 index 7be2a2ea..00000000 --- a/test/integration/mouse_fallback_integration_test.exs +++ /dev/null @@ -1,480 +0,0 @@ -defmodule TermUI.Integration.MouseFallbackIntegrationTest do - @moduledoc """ - Integration tests for mouse fallback features. - - These tests verify that keyboard alternatives for mouse-dependent features - work correctly, ensuring widgets remain fully functional in TTY mode where - mouse interaction may not be available. - - ## Test Coverage - - - SplitPane: Ctrl+arrow keyboard resize - - ContextMenu.Inline: Number key selection - - ## Key Insight - - Scrollbar keyboard alternatives (5.7.3.3) are already covered by the - keyboard navigation tests since scrolling uses arrow keys which trigger - the same navigation behavior. - """ - - use ExUnit.Case, async: false - - alias TermUI.Event - alias TermUI.Theme - alias TermUI.Widgets.ContextMenu - alias TermUI.Widgets.ContextMenu.Inline - alias TermUI.Widgets.SplitPane - - # ============================================================================ - # Setup - # ============================================================================ - - setup do - # Start Theme server for color support (ignore if already started) - case Theme.start_link(theme: :dark) do - {:ok, _pid} -> :ok - {:error, {:already_started, _pid}} -> :ok - end - - :ok - end - - # Helper to create a test area for rendering - defp test_area(width \\ 100, height \\ 50) do - %{x: 0, y: 0, width: width, height: height} - end - - # ============================================================================ - # SplitPane Keyboard Resize Tests - # ============================================================================ - - describe "SplitPane Ctrl+arrow keyboard resize - horizontal split" do - setup do - props = - SplitPane.new( - orientation: :horizontal, - panes: [ - SplitPane.pane(:left, "Left Content", size: 0.5), - SplitPane.pane(:right, "Right Content", size: 0.5) - ], - ctrl_resize_step: 0.1, - min_ratio: 0.1, - max_ratio: 0.9 - ) - - {:ok, state} = SplitPane.init(props) - # Render once to set total_size and computed sizes - _render = SplitPane.render(state, test_area()) - %{state: state} - end - - test "Ctrl+Right increases left pane ratio", %{state: state} do - # Get initial left pane size - initial_left_size = Enum.at(state.panes, 0).size - - # Send Ctrl+Right event - {:ok, new_state} = - SplitPane.handle_event(%Event.Key{key: :right, modifiers: [:ctrl]}, state) - - # Left pane should be larger - new_left_size = Enum.at(new_state.panes, 0).size - assert new_left_size > initial_left_size - assert_in_delta new_left_size, initial_left_size + 0.1, 0.01 - end - - test "Ctrl+Left decreases left pane ratio", %{state: state} do - # Get initial left pane size - initial_left_size = Enum.at(state.panes, 0).size - - # Send Ctrl+Left event - {:ok, new_state} = - SplitPane.handle_event(%Event.Key{key: :left, modifiers: [:ctrl]}, state) - - # Left pane should be smaller - new_left_size = Enum.at(new_state.panes, 0).size - assert new_left_size < initial_left_size - assert_in_delta new_left_size, initial_left_size - 0.1, 0.01 - end - - test "multiple Ctrl+Right increases accumulate", %{state: state} do - initial_left_size = Enum.at(state.panes, 0).size - - # Apply multiple increases - {:ok, state} = SplitPane.handle_event(%Event.Key{key: :right, modifiers: [:ctrl]}, state) - {:ok, state} = SplitPane.handle_event(%Event.Key{key: :right, modifiers: [:ctrl]}, state) - {:ok, state} = SplitPane.handle_event(%Event.Key{key: :right, modifiers: [:ctrl]}, state) - - new_left_size = Enum.at(state.panes, 0).size - assert_in_delta new_left_size, initial_left_size + 0.3, 0.01 - end - - test "ratio is clamped to max_ratio", %{state: state} do - # Increase many times to hit max - state = - Enum.reduce(1..20, state, fn _, s -> - {:ok, new_s} = SplitPane.handle_event(%Event.Key{key: :right, modifiers: [:ctrl]}, s) - new_s - end) - - left_size = Enum.at(state.panes, 0).size - # Should be clamped to max_ratio (0.9) - assert left_size <= 0.9 - end - - test "ratio is clamped to min_ratio", %{state: state} do - # Decrease many times to hit min - state = - Enum.reduce(1..20, state, fn _, s -> - {:ok, new_s} = SplitPane.handle_event(%Event.Key{key: :left, modifiers: [:ctrl]}, s) - new_s - end) - - left_size = Enum.at(state.panes, 0).size - # Should be clamped to min_ratio (0.1) - assert left_size >= 0.1 - end - - test "arrow keys without Ctrl modifier do not resize when no divider focused", %{state: state} do - initial_left_size = Enum.at(state.panes, 0).size - - # Arrow without Ctrl should not change size - {:ok, new_state} = SplitPane.handle_event(%Event.Key{key: :right, modifiers: []}, state) - - new_left_size = Enum.at(new_state.panes, 0).size - assert new_left_size == initial_left_size - end - end - - describe "SplitPane Ctrl+arrow keyboard resize - vertical split" do - setup do - props = - SplitPane.new( - orientation: :vertical, - panes: [ - SplitPane.pane(:top, "Top Content", size: 0.5), - SplitPane.pane(:bottom, "Bottom Content", size: 0.5) - ], - ctrl_resize_step: 0.1, - min_ratio: 0.1, - max_ratio: 0.9 - ) - - {:ok, state} = SplitPane.init(props) - _render = SplitPane.render(state, test_area()) - %{state: state} - end - - test "Ctrl+Down increases top pane ratio", %{state: state} do - initial_top_size = Enum.at(state.panes, 0).size - - {:ok, new_state} = - SplitPane.handle_event(%Event.Key{key: :down, modifiers: [:ctrl]}, state) - - new_top_size = Enum.at(new_state.panes, 0).size - assert new_top_size > initial_top_size - assert_in_delta new_top_size, initial_top_size + 0.1, 0.01 - end - - test "Ctrl+Up decreases top pane ratio", %{state: state} do - initial_top_size = Enum.at(state.panes, 0).size - - {:ok, new_state} = - SplitPane.handle_event(%Event.Key{key: :up, modifiers: [:ctrl]}, state) - - new_top_size = Enum.at(new_state.panes, 0).size - assert new_top_size < initial_top_size - assert_in_delta new_top_size, initial_top_size - 0.1, 0.01 - end - end - - describe "SplitPane keyboard resize configuration" do - test "ctrl_resize_step is configurable" do - props = - SplitPane.new( - panes: [ - SplitPane.pane(:left, "Left", size: 0.5), - SplitPane.pane(:right, "Right", size: 0.5) - ], - ctrl_resize_step: 0.05 - ) - - {:ok, state} = SplitPane.init(props) - initial_size = Enum.at(state.panes, 0).size - - {:ok, new_state} = - SplitPane.handle_event(%Event.Key{key: :right, modifiers: [:ctrl]}, state) - - new_size = Enum.at(new_state.panes, 0).size - assert_in_delta new_size, initial_size + 0.05, 0.01 - end - - test "min_ratio and max_ratio are configurable" do - props = - SplitPane.new( - panes: [ - SplitPane.pane(:left, "Left", size: 0.5), - SplitPane.pane(:right, "Right", size: 0.5) - ], - ctrl_resize_step: 0.2, - min_ratio: 0.2, - max_ratio: 0.8 - ) - - {:ok, state} = SplitPane.init(props) - - # Try to go below min - state = - Enum.reduce(1..10, state, fn _, s -> - {:ok, new_s} = SplitPane.handle_event(%Event.Key{key: :left, modifiers: [:ctrl]}, s) - new_s - end) - - assert Enum.at(state.panes, 0).size >= 0.2 - - # Reset and try to go above max - {:ok, state} = SplitPane.init(props) - - state = - Enum.reduce(1..10, state, fn _, s -> - {:ok, new_s} = SplitPane.handle_event(%Event.Key{key: :right, modifiers: [:ctrl]}, s) - new_s - end) - - assert Enum.at(state.panes, 0).size <= 0.8 - end - end - - # ============================================================================ - # ContextMenu.Inline Number Selection Tests - # ============================================================================ - - describe "ContextMenu.Inline number selection" do - setup do - test_pid = self() - - props = - Inline.new( - items: [ - ContextMenu.action(:copy, "Copy"), - ContextMenu.action(:paste, "Paste"), - ContextMenu.action(:delete, "Delete"), - ContextMenu.action(:rename, "Rename"), - ContextMenu.action(:move, "Move") - ], - on_select: fn id -> send(test_pid, {:selected, id}) end, - on_close: fn -> send(test_pid, :closed) end - ) - - {:ok, state} = Inline.init(props) - %{state: state} - end - - test "pressing 1 selects first item", %{state: state} do - {:ok, _new_state} = Inline.handle_event(%Event.Key{key: "1"}, state) - - assert_receive {:selected, :copy} - end - - test "pressing 2 selects second item", %{state: state} do - {:ok, _new_state} = Inline.handle_event(%Event.Key{key: "2"}, state) - - assert_receive {:selected, :paste} - end - - test "pressing 3 selects third item", %{state: state} do - {:ok, _new_state} = Inline.handle_event(%Event.Key{key: "3"}, state) - - assert_receive {:selected, :delete} - end - - test "pressing 5 selects fifth item", %{state: state} do - {:ok, _new_state} = Inline.handle_event(%Event.Key{key: "5"}, state) - - assert_receive {:selected, :move} - end - - test "menu closes after number selection", %{state: state} do - {:ok, new_state} = Inline.handle_event(%Event.Key{key: "1"}, state) - - refute Inline.visible?(new_state) - end - - test "pressing number beyond item count does nothing", %{state: state} do - # We have 5 items, pressing 9 should do nothing - {:ok, new_state} = Inline.handle_event(%Event.Key{key: "9"}, state) - - # Should not receive selection message - refute_receive {:selected, _} - # Menu should still be visible - assert Inline.visible?(new_state) - end - end - - describe "ContextMenu.Inline number selection with disabled items" do - setup do - test_pid = self() - - props = - Inline.new( - items: [ - ContextMenu.action(:copy, "Copy"), - ContextMenu.action(:paste, "Paste", disabled: true), - ContextMenu.action(:delete, "Delete") - ], - on_select: fn id -> send(test_pid, {:selected, id}) end - ) - - {:ok, state} = Inline.init(props) - %{state: state} - end - - test "disabled items are not numbered", %{state: state} do - # Number map should skip disabled item - # 1 -> :copy, 2 -> :delete (paste is skipped) - assert state.number_map == %{1 => :copy, 2 => :delete} - end - - test "pressing 2 selects delete (not disabled paste)", %{state: state} do - {:ok, _new_state} = Inline.handle_event(%Event.Key{key: "2"}, state) - - assert_receive {:selected, :delete} - refute_receive {:selected, :paste} - end - end - - describe "ContextMenu.Inline number selection with separators" do - setup do - test_pid = self() - - props = - Inline.new( - items: [ - ContextMenu.action(:cut, "Cut"), - ContextMenu.action(:copy, "Copy"), - ContextMenu.separator(), - ContextMenu.action(:paste, "Paste"), - ContextMenu.action(:delete, "Delete") - ], - on_select: fn id -> send(test_pid, {:selected, id}) end - ) - - {:ok, state} = Inline.init(props) - %{state: state} - end - - test "separators are not numbered", %{state: state} do - # Number map should skip separator - # 1 -> :cut, 2 -> :copy, 3 -> :paste, 4 -> :delete - assert state.number_map == %{1 => :cut, 2 => :copy, 3 => :paste, 4 => :delete} - end - - test "pressing 3 selects paste (after separator)", %{state: state} do - {:ok, _new_state} = Inline.handle_event(%Event.Key{key: "3"}, state) - - assert_receive {:selected, :paste} - end - end - - describe "ContextMenu.Inline with more than 9 items" do - setup do - test_pid = self() - - items = - for i <- 1..12 do - ContextMenu.action(:"item_#{i}", "Item #{i}") - end - - props = - Inline.new( - items: items, - on_select: fn id -> send(test_pid, {:selected, id}) end - ) - - {:ok, state} = Inline.init(props) - %{state: state} - end - - test "only first 9 items are numbered", %{state: state} do - assert map_size(state.number_map) == 9 - assert Map.has_key?(state.number_map, 1) - assert Map.has_key?(state.number_map, 9) - refute Map.has_key?(state.number_map, 10) - end - - test "pressing 9 selects ninth item", %{state: state} do - {:ok, _new_state} = Inline.handle_event(%Event.Key{key: "9"}, state) - - assert_receive {:selected, :item_9} - end - - test "items 10-12 can be reached with arrow navigation", %{state: state} do - # Navigate down to item 10 - state = - Enum.reduce(1..9, state, fn _, s -> - {:ok, new_s} = Inline.handle_event(%Event.Key{key: :down}, s) - new_s - end) - - # Now at item 10 - assert Inline.get_cursor(state) == :item_10 - - # Select with Enter - {:ok, _new_state} = Inline.handle_event(%Event.Key{key: :enter}, state) - - assert_receive {:selected, :item_10} - end - end - - describe "ContextMenu.Inline combined keyboard navigation" do - setup do - test_pid = self() - - props = - Inline.new( - items: [ - ContextMenu.action(:copy, "Copy"), - ContextMenu.action(:paste, "Paste"), - ContextMenu.action(:delete, "Delete") - ], - on_select: fn id -> send(test_pid, {:selected, id}) end, - on_close: fn -> send(test_pid, :closed) end - ) - - {:ok, state} = Inline.init(props) - %{state: state} - end - - test "arrow navigation still works alongside number selection", %{state: state} do - # Navigate down - {:ok, state} = Inline.handle_event(%Event.Key{key: :down}, state) - assert Inline.get_cursor(state) == :paste - - # Navigate down again - {:ok, state} = Inline.handle_event(%Event.Key{key: :down}, state) - assert Inline.get_cursor(state) == :delete - - # Select with Enter - {:ok, _new_state} = Inline.handle_event(%Event.Key{key: :enter}, state) - assert_receive {:selected, :delete} - end - - test "Escape closes menu without selecting", %{state: state} do - {:ok, new_state} = Inline.handle_event(%Event.Key{key: :escape}, state) - - assert_receive :closed - refute_receive {:selected, _} - refute Inline.visible?(new_state) - end - - test "complete workflow: navigate then use number key", %{state: state} do - # Navigate down (cursor moves but doesn't affect number mapping) - {:ok, state} = Inline.handle_event(%Event.Key{key: :down}, state) - assert Inline.get_cursor(state) == :paste - - # Press number key - should still select first item - {:ok, _new_state} = Inline.handle_event(%Event.Key{key: "1"}, state) - assert_receive {:selected, :copy} - end - end -end diff --git a/test/integration/multi_renderer_test.exs b/test/integration/multi_renderer_test.exs deleted file mode 100644 index c17e2549..00000000 --- a/test/integration/multi_renderer_test.exs +++ /dev/null @@ -1,624 +0,0 @@ -defmodule TermUI.Integration.MultiRendererTest do - @moduledoc """ - Integration tests for the multi-renderer system (Section 6.8). - - These tests verify the complete system works end-to-end: - - Full application lifecycle (start, render, input, update, shutdown) - - Backend switching (auto-detection, forced modes) - - Input consistency (same events work in both modes) - - Rendering consistency (widgets render consistently, colors/characters degrade) - """ - - use ExUnit.Case, async: false - - alias TermUI.Backend.Selector - alias TermUI.Config - alias TermUI.Event - alias TermUI.Runtime - - # Test component implementing the Elm Architecture - defmodule Counter do - @moduledoc """ - Simple counter component for testing. - - Implements the Elm Architecture callbacks: - - init/1 - - event_to_msg/2 - - update/2 - - view/1 - """ - - import TermUI.Component.Helpers - - def init(_opts) do - %{count: 0, events_received: []} - end - - def event_to_msg(%Event.Key{key: :up}, _state) do - {:msg, {:increment, 1}} - end - - def event_to_msg(%Event.Key{key: :down}, _state) do - {:msg, {:decrement, 1}} - end - - def event_to_msg(%Event.Key{key: ?+}, _state) do - {:msg, {:increment, 1}} - end - - def event_to_msg(%Event.Key{key: ?-}, _state) do - {:msg, {:decrement, 1}} - end - - def event_to_msg(%Event.Key{key: ?r}, _state) do - {:msg, :reset} - end - - def event_to_msg(%Event.Key{key: ?q}, _state) do - {:msg, :quit} - end - - def event_to_msg(%Event.Key{key: :enter}, _state) do - {:msg, :submit} - end - - def event_to_msg(%Event.Key{key: :tab}, _state) do - {:msg, :next} - end - - def event_to_msg(%Event.Key{key: :escape}, _state) do - {:msg, :cancel} - end - - def event_to_msg(event, _state) do - # Track all events for testing - {:msg, {:unknown_event, event}} - end - - def update({:increment, amount}, state) do - {new_state, []} = {%{state | count: state.count + amount}, []} - {new_state, []} - end - - def update({:decrement, amount}, state) do - {new_state, []} = {%{state | count: state.count - amount}, []} - {new_state, []} - end - - def update(:reset, state) do - {new_state, []} = {%{state | count: 0}, []} - {new_state, []} - end - - def update(:quit, state) do - {state, [:quit]} - end - - def update(:submit, state) do - {state, []} - end - - def update(:next, state) do - {state, []} - end - - def update(:cancel, state) do - {state, []} - end - - def update({:unknown_event, _event}, state) do - {state, []} - end - - def update(_msg, state) do - {state, []} - end - - def view(state) do - box([ - text("Counter: #{state.count}"), - text("Use +/- to change, q to quit") - ]) - end - end - - # =========================================================================== - # Setup and Teardown - # =========================================================================== - - setup do - # Clear persistent_term values - :persistent_term.erase(:term_ui_backend_mode) - :persistent_term.erase(:term_ui_capabilities) - :persistent_term.erase(:term_ui_character_set) - - # Store original Application env - original_backend = Application.get_env(:term_ui, :backend) - original_character_set = Application.get_env(:term_ui, :character_set) - original_tty_opts = Application.get_env(:term_ui, :tty_opts) - - on_exit(fn -> - # Restore Application env - restore_app_env(:backend, original_backend) - restore_app_env(:character_set, original_character_set) - restore_app_env(:tty_opts, original_tty_opts) - - # Clear persistent_term - :persistent_term.erase(:term_ui_backend_mode) - :persistent_term.erase(:term_ui_capabilities) - :persistent_term.erase(:term_ui_character_set) - - # Stop any running Runtime processes - case Process.whereis(TermUI.Runtime) do - nil -> :ok - pid -> GenServer.stop(pid, :normal) - end - end) - - :ok - end - - defp restore_app_env(key, nil), do: Application.delete_env(:term_ui, key) - defp restore_app_env(key, value), do: Application.put_env(:term_ui, key, value) - - # =========================================================================== - # 6.8.1 Full Application Lifecycle Tests - # =========================================================================== - - describe "6.8.1 Full Application Lifecycle" do - test "6.8.1.1 start -> render -> input -> update -> render -> shutdown" do - # Start runtime with skip_terminal for testing - {:ok, runtime} = - Runtime.start_link( - root: Counter, - skip_terminal: true, - use_input_handler: false - ) - - # Verify runtime started - assert Process.alive?(runtime) - state = Runtime.get_state(runtime) - assert state.root_state.count == 0 - - # Send increment events - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - - # Send another increment - Runtime.send_event(runtime, Event.key(?+)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 2 - - # Send decrement - Runtime.send_event(runtime, Event.key(:down)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - - # Shutdown gracefully and wait for process to exit - ref = Process.monitor(runtime) - :ok = Runtime.shutdown(runtime) - - # Wait for shutdown to complete - receive do - {:DOWN, ^ref, :process, ^runtime, _reason} -> :ok - after - 1000 -> flunk("Runtime did not shut down") - end - - # Verify runtime stopped - refute Process.alive?(runtime) - end - - test "6.8.1.2 Test in raw mode (simulated with TestBackend)" do - # We can't actually test raw mode in test environment without OTP 28+ - # But we can verify the backend selection logic - # Selector.select(:raw) returns {:explicit, :raw, []} - result = Selector.select(:raw) - - # For raw mode selection, we get explicit format - assert {:explicit, :raw, []} = result - end - - test "6.8.1.3 Test in TTY mode (forced)" do - Application.put_env(:term_ui, :backend, :tty) - - {:ok, runtime} = - Runtime.start_link( - root: Counter, - skip_terminal: true, - use_input_handler: false - ) - - # Verify TTY backend mode is set - assert Runtime.backend_mode() in [:tty, :skip] - - state = Runtime.get_state(runtime) - assert state.backend_mode in [:tty, :skip] - - # Test basic functionality - Runtime.send_event(runtime, Event.key(?+)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - - # Shutdown - Runtime.shutdown(runtime) - end - - test "6.8.1.4 Test cleanup on crash" do - {:ok, runtime} = - Runtime.start_link( - root: Counter, - skip_terminal: true, - use_input_handler: false - ) - - # Simulate a crash by killing the process - Process.flag(:trap_exit, true) - Process.exit(runtime, :kill) - - # Wait for process to die - receive do - {:EXIT, ^runtime, :killed} -> :ok - after - 1000 -> flunk("Timeout waiting for runtime to die") - end - - # Verify process is gone - refute Process.alive?(runtime) - - # Verify we can start a new runtime (cleanup was successful) - {:ok, runtime2} = - Runtime.start_link( - root: Counter, - skip_terminal: true, - use_input_handler: false - ) - - assert Process.alive?(runtime2) - - Runtime.shutdown(runtime2) - end - end - - # =========================================================================== - # 6.8.2 Backend Switching Tests - # =========================================================================== - - describe "6.8.2 Backend Switching" do - test "6.8.2.1 Test auto-detection selects appropriate backend" do - # Auto mode should select appropriate backend - result = Selector.select(:auto) - - case result do - {:raw, _state} -> - # Raw mode succeeded - assert true - - {:tty, capabilities} -> - # Fell back to TTY mode - assert is_map(capabilities) - assert Map.has_key?(capabilities, :colors) - assert Map.has_key?(capabilities, :unicode) - end - end - - test "6.8.2.2 Test forced raw mode works when available" do - # Force raw mode - returns {:explicit, :raw, []} - result = Selector.select(:raw) - - # Selector always returns explicit format for forced modes - assert {:explicit, :raw, []} = result - end - - test "6.8.2.3 Test forced TTY mode skips raw attempt" do - # Force TTY mode should skip raw attempt entirely - result = Selector.select(:tty) - - # Selector returns explicit format - assert {:explicit, :tty, []} = result - end - - test "6.8.2.4 Test explicit module selection" do - # Test explicit module selection - result = Selector.select(TermUI.Backend.TTY) - - assert {:explicit, TermUI.Backend.TTY, []} = result - end - end - - # =========================================================================== - # 6.8.3 Input Consistency Tests - # =========================================================================== - - describe "6.8.3 Input Consistency" do - test "6.8.3.1 Test arrow keys work in both modes" do - # Test with skip terminal (test mode) - {:ok, runtime} = - Runtime.start_link( - root: Counter, - skip_terminal: true, - use_input_handler: false - ) - - # Test up arrow - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - - # Test down arrow - Runtime.send_event(runtime, Event.key(:down)) - Runtime.sync(runtime) - state = Runtime.get_state(runtime) - assert state.root_state.count == 0 - - Runtime.shutdown(runtime) - end - - test "6.8.3.2 Test Enter/Tab/Escape work in both modes" do - {:ok, runtime} = - Runtime.start_link( - root: Counter, - skip_terminal: true, - use_input_handler: false - ) - - # Test Enter - Runtime.send_event(runtime, Event.key(:enter)) - Runtime.sync(runtime) - # Counter doesn't change on Enter, but should not crash - state = Runtime.get_state(runtime) - assert is_integer(state.root_state.count) - - # Test Tab - Runtime.send_event(runtime, Event.key(:tab)) - Runtime.sync(runtime) - state = Runtime.get_state(runtime) - assert is_integer(state.root_state.count) - - # Test Escape - Runtime.send_event(runtime, Event.key(:escape)) - Runtime.sync(runtime) - state = Runtime.get_state(runtime) - assert is_integer(state.root_state.count) - - Runtime.shutdown(runtime) - end - - test "6.8.3.3 Test widgets respond identically to input" do - # Create two runtimes with same component - {:ok, runtime1} = - Runtime.start_link( - root: Counter, - skip_terminal: true, - use_input_handler: false, - name: :runtime1 - ) - - {:ok, runtime2} = - Runtime.start_link( - root: Counter, - skip_terminal: true, - use_input_handler: false, - name: :runtime2 - ) - - # Send same events to both - Runtime.send_event(runtime1, Event.key(:up)) - Runtime.send_event(runtime1, Event.key(?+)) - Runtime.sync(runtime1) - - Runtime.send_event(runtime2, Event.key(:up)) - Runtime.send_event(runtime2, Event.key(?+)) - Runtime.sync(runtime2) - - # Both should have same state - state1 = Runtime.get_state(runtime1) - state2 = Runtime.get_state(runtime2) - - assert state1.root_state.count == state2.root_state.count - assert state1.root_state.count == 2 - - Runtime.shutdown(runtime1) - Runtime.shutdown(runtime2) - end - end - - # =========================================================================== - # 6.8.4 Rendering Consistency Tests - # =========================================================================== - - describe "6.8.4 Rendering Consistency" do - test "6.8.4.1 Test same widget renders in both modes" do - # The Counter component should render identically in both modes - # since we're using skip_terminal mode - - {:ok, runtime} = - Runtime.start_link( - root: Counter, - skip_terminal: true, - use_input_handler: false - ) - - # Render initial state - Runtime.force_render(runtime) - state = Runtime.get_state(runtime) - - # Verify component can be rendered (no crash) - assert is_map(state.root_state) - - # Update state and verify still renderable - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - Runtime.force_render(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - - Runtime.shutdown(runtime) - end - - test "6.8.4.2 Test colors degrade correctly" do - # Test color degradation via capability detection - capabilities_true_color = %{colors: :true_color, unicode: true} - capabilities_256 = %{colors: :color_256, unicode: true} - capabilities_16 = %{colors: :color_16, unicode: true} - capabilities_mono = %{colors: :monochrome, unicode: true} - - # All capabilities should be valid - assert capabilities_true_color.colors == :true_color - assert capabilities_256.colors == :color_256 - assert capabilities_16.colors == :color_16 - assert capabilities_mono.colors == :monochrome - end - - test "6.8.4.3 Test characters degrade correctly" do - # Test Unicode vs ASCII character set detection - capabilities_unicode = %{colors: :true_color, unicode: true} - capabilities_ascii = %{colors: :true_color, unicode: false} - - assert capabilities_unicode.unicode == true - assert capabilities_ascii.unicode == false - end - end - - # =========================================================================== - # Additional Integration Tests - # =========================================================================== - - describe "Runtime API consistency" do - test "backend_mode/0 returns correct mode" do - # Initially no backend mode - assert Runtime.backend_mode() in [:raw, :tty, :skip, nil] - - {:ok, runtime} = - Runtime.start_link( - root: Counter, - skip_terminal: true, - use_input_handler: false - ) - - # After starting, should have a mode - mode = Runtime.backend_mode() - assert mode in [:raw, :tty, :skip] - - Runtime.shutdown(runtime) - end - - test "capabilities/0 returns capabilities map or nil" do - # Initially no capabilities - assert Runtime.capabilities() in [nil, %{}] - - {:ok, runtime} = - Runtime.start_link( - root: Counter, - skip_terminal: true, - use_input_handler: false - ) - - # After starting, capabilities should be available (or nil in skip mode) - caps = Runtime.capabilities() - assert caps in [nil, %{}] - - Runtime.shutdown(runtime) - end - end - - describe "Config integration" do - test "Config.merge_options/2 merges correctly" do - Application.put_env(:term_ui, :backend, :tty) - Application.put_env(:term_ui, :character_set, :ascii) - - opts = [backend: :raw, render_interval: 100] - merged = Config.merge_options(opts) - - # Runtime options should override config - assert merged[:backend] == :raw - assert merged[:character_set] == :ascii - assert merged[:render_interval] == 100 - end - - test "Config.get/2 returns defaults" do - # Clear env - Application.delete_env(:term_ui, :backend) - - assert Config.get(:backend, :auto) == :auto - assert Config.get(:render_interval, 16) == 16 - end - end - - describe "Full lifecycle with quit command" do - test "quit command triggers shutdown" do - {:ok, runtime} = - Runtime.start_link( - root: Counter, - skip_terminal: true, - use_input_handler: false - ) - - # Monitor the runtime - ref = Process.monitor(runtime) - - # Send quit event - Runtime.send_event(runtime, Event.key(?q)) - - # Wait for shutdown - receive do - {:DOWN, ^ref, :process, ^runtime, _reason} -> - # Runtime shut down - refute Process.alive?(runtime) - after - 1000 -> - # If shutdown didn't happen, clean up manually - Runtime.shutdown(runtime) - flunk("Runtime did not shut down on quit command") - end - end - end - - describe "Multiple sequential runs" do - test "runtime can be started and stopped multiple times" do - for _i <- 1..3 do - {:ok, runtime} = - Runtime.start_link( - root: Counter, - skip_terminal: true, - use_input_handler: false - ) - - # Verify it works - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - - # Shutdown and wait for exit - ref = Process.monitor(runtime) - Runtime.shutdown(runtime) - - receive do - {:DOWN, ^ref, :process, ^runtime, _reason} -> :ok - after - 1000 -> flunk("Runtime did not shut down") - end - - # Verify stopped - refute Process.alive?(runtime) - - # Clear persistent_term for next iteration - :persistent_term.erase(:term_ui_backend_mode) - :persistent_term.erase(:term_ui_capabilities) - end - end - end -end diff --git a/test/integration/round_trip_test.exs b/test/integration/round_trip_test.exs deleted file mode 100644 index ee272214..00000000 --- a/test/integration/round_trip_test.exs +++ /dev/null @@ -1,447 +0,0 @@ -defmodule TermUI.Integration.RoundTripTest do - @moduledoc """ - Integration tests for input/output round-trip verification. - - Tests that output sequences produce expected results and input bytes - parse to expected events. - - ## Limitations - - These tests validate sequence generation and parsing separately, not through - actual terminal I/O via pseudo-terminals (PTY). This means: - - - ANSI sequence generation is tested for correctness - - Input byte parsing is tested against expected events - - The two are not connected through actual terminal round-trips - - True PTY-based round-trip testing would require platform-specific code to - create pseudo-terminal pairs and verify that written sequences produce the - expected terminal state. This is left as a future enhancement. - """ - - use ExUnit.Case, async: false - - alias TermUI.ANSI - alias TermUI.Event.{Focus, Key, Mouse, Paste} - alias TermUI.IntegrationHelpers - - import IntegrationHelpers, only: [parse: 1] - - # These tests validate I/O behavior - @moduletag :integration - - setup do - IntegrationHelpers.stop_terminal() - - on_exit(fn -> - IntegrationHelpers.cleanup_terminal() - end) - - :ok - end - - describe "1.6.2.1 cursor positioning round-trip" do - test "cursor position generates correct escape sequence" do - seq = ANSI.cursor_position(5, 10) |> IO.iodata_to_binary() - assert seq == "\e[5;10H" - end - - test "cursor movement sequences are correct" do - assert ANSI.cursor_up(3) |> IO.iodata_to_binary() == "\e[3A" - assert ANSI.cursor_down(2) |> IO.iodata_to_binary() == "\e[2B" - assert ANSI.cursor_forward(4) |> IO.iodata_to_binary() == "\e[4C" - assert ANSI.cursor_back(1) |> IO.iodata_to_binary() == "\e[D" - end - - test "cursor show/hide generates correct sequences" do - assert ANSI.cursor_show() |> IO.iodata_to_binary() == "\e[?25h" - assert ANSI.cursor_hide() |> IO.iodata_to_binary() == "\e[?25l" - end - - test "save and restore cursor sequences" do - assert ANSI.save_cursor() |> IO.iodata_to_binary() == "\e[s" - assert ANSI.restore_cursor() |> IO.iodata_to_binary() == "\e[u" - end - - test "cursor position sequence is parseable" do - seq = ANSI.cursor_position(10, 20) |> IO.iodata_to_binary() - - assert String.starts_with?(seq, "\e[") - assert String.ends_with?(seq, "H") - end - end - - describe "1.6.2.2 key event round-trip" do - test "simple characters parse correctly" do - {events, ""} = parse("a") - assert [%Key{key: "a", modifiers: []}] = events - - {events, ""} = parse("Z") - assert [%Key{key: "Z", modifiers: []}] = events - - {events, ""} = parse("5") - assert [%Key{key: "5", modifiers: []}] = events - end - - test "control characters parse correctly" do - {events, ""} = parse(<<1>>) - assert [%Key{key: "a", modifiers: [:ctrl]}] = events - - {events, ""} = parse(<<3>>) - assert [%Key{key: "c", modifiers: [:ctrl]}] = events - - {events, ""} = parse(<<26>>) - assert [%Key{key: "z", modifiers: [:ctrl]}] = events - end - - test "special keys parse correctly" do - # Enter - {events, ""} = parse(<<13>>) - assert [%Key{key: :enter, modifiers: []}] = events - - # Tab - {events, ""} = parse(<<9>>) - assert [%Key{key: :tab, modifiers: []}] = events - - # Backspace - {events, ""} = parse(<<127>>) - assert [%Key{key: :backspace, modifiers: []}] = events - end - - test "arrow keys parse correctly" do - {events, ""} = parse("\e[A") - assert [%Key{key: :up, modifiers: []}] = events - - {events, ""} = parse("\e[B") - assert [%Key{key: :down, modifiers: []}] = events - - {events, ""} = parse("\e[C") - assert [%Key{key: :right, modifiers: []}] = events - - {events, ""} = parse("\e[D") - assert [%Key{key: :left, modifiers: []}] = events - end - - test "function keys parse correctly" do - {events, ""} = parse("\eOP") - assert [%Key{key: :f1, modifiers: []}] = events - - {events, ""} = parse("\eOQ") - assert [%Key{key: :f2, modifiers: []}] = events - - {events, ""} = parse("\eOR") - assert [%Key{key: :f3, modifiers: []}] = events - - {events, ""} = parse("\eOS") - assert [%Key{key: :f4, modifiers: []}] = events - - {events, ""} = parse("\e[15~") - assert [%Key{key: :f5, modifiers: []}] = events - end - - test "home/end/page keys parse correctly" do - {events, ""} = parse("\e[H") - assert [%Key{key: :home, modifiers: []}] = events - - {events, ""} = parse("\e[F") - assert [%Key{key: :end, modifiers: []}] = events - - {events, ""} = parse("\e[5~") - assert [%Key{key: :page_up, modifiers: []}] = events - - {events, ""} = parse("\e[6~") - assert [%Key{key: :page_down, modifiers: []}] = events - end - - test "multiple events parse in sequence" do - {events, ""} = parse("abc") - - assert [ - %Key{key: "a", modifiers: []}, - %Key{key: "b", modifiers: []}, - %Key{key: "c", modifiers: []} - ] = events - - {events, ""} = parse("a\e[Ab") - - assert [ - %Key{key: "a", modifiers: []}, - %Key{key: :up, modifiers: []}, - %Key{key: "b", modifiers: []} - ] = events - end - - test "incomplete sequences are handled" do - {events, remainder} = parse("\e[") - - # The parser handles incomplete sequences in various ways: - # - Returns remainder for incomplete sequence - # - Flushes escape event - # - Parses what it can - # All of these are valid behaviors - assert is_list(events) - assert is_binary(remainder) - end - end - - describe "1.6.2.3 mouse event round-trip" do - test "X10 mouse press events parse correctly" do - # X10 format: \e[M Cb Cx Cy (values +32) - # Left button press at (1, 1): button=32, x=33, y=33 - # X10 needs 3 characters after M - {events, ""} = parse("\e[M !!") - assert length(events) == 1 - [%Mouse{} = event] = events - assert event.button == :left - end - - test "SGR mouse events parse correctly" do - {events, ""} = parse("\e[<0;5;10M") - - assert length(events) == 1 - [%Mouse{} = event] = events - assert event.button == :left - assert event.x == 4 - assert event.y == 9 - assert event.action == :press - end - - test "SGR mouse release events parse correctly" do - {events, ""} = parse("\e[<0;5;10m") - - assert length(events) == 1 - [%Mouse{} = event] = events - assert event.action == :release - end - - test "mouse button types parse correctly" do - {events, ""} = parse("\e[<1;1;1M") - [%Mouse{} = event] = events - assert event.button == :middle - - {events, ""} = parse("\e[<2;1;1M") - [%Mouse{} = event] = events - assert event.button == :right - end - - test "mouse modifier keys parse correctly" do - # Shift modifier (4) - {events, ""} = parse("\e[<4;1;1M") - [%Mouse{} = event] = events - assert :shift in event.modifiers - - # Ctrl modifier (16) - {events, ""} = parse("\e[<16;1;1M") - [%Mouse{} = event] = events - assert :ctrl in event.modifiers - - # Alt modifier (8) - {events, ""} = parse("\e[<8;1;1M") - [%Mouse{} = event] = events - assert :alt in event.modifiers - end - - test "scroll events parse correctly" do - {events, ""} = parse("\e[<64;1;1M") - [%Mouse{} = event] = events - assert event.action == :scroll_up - assert event.button == nil - - {events, ""} = parse("\e[<65;1;1M") - [%Mouse{} = event] = events - assert event.action == :scroll_down - assert event.button == nil - end - end - - describe "1.6.2.4 style round-trip" do - test "basic colors generate correct sequences" do - assert ANSI.foreground(:red) |> IO.iodata_to_binary() == "\e[31m" - assert ANSI.foreground(:green) |> IO.iodata_to_binary() == "\e[32m" - assert ANSI.background(:blue) |> IO.iodata_to_binary() == "\e[44m" - end - - test "256 colors generate correct sequences" do - assert ANSI.foreground_256(196) |> IO.iodata_to_binary() == "\e[38;5;196m" - assert ANSI.background_256(21) |> IO.iodata_to_binary() == "\e[48;5;21m" - end - - test "true colors generate correct sequences" do - assert ANSI.foreground_rgb(255, 128, 0) |> IO.iodata_to_binary() == "\e[38;2;255;128;0m" - assert ANSI.background_rgb(0, 0, 255) |> IO.iodata_to_binary() == "\e[48;2;0;0;255m" - end - - test "text attributes generate correct sequences" do - assert ANSI.bold() |> IO.iodata_to_binary() == "\e[1m" - assert ANSI.dim() |> IO.iodata_to_binary() == "\e[2m" - assert ANSI.italic() |> IO.iodata_to_binary() == "\e[3m" - assert ANSI.underline() |> IO.iodata_to_binary() == "\e[4m" - assert ANSI.blink() |> IO.iodata_to_binary() == "\e[5m" - assert ANSI.reverse() |> IO.iodata_to_binary() == "\e[7m" - assert ANSI.strikethrough() |> IO.iodata_to_binary() == "\e[9m" - end - - test "reset generates correct sequence" do - assert ANSI.reset() |> IO.iodata_to_binary() == "\e[0m" - end - - test "combined format generates merged sequence" do - seq = ANSI.format([:bold, :red]) |> IO.iodata_to_binary() - assert seq == "\e[1;31m" - - seq = ANSI.format([:underline, :bright_blue, :bg_yellow]) |> IO.iodata_to_binary() - assert seq == "\e[4;94;43m" - end - - test "empty format returns empty" do - assert ANSI.format([]) |> IO.iodata_to_binary() == "" - end - end - - describe "special modes round-trip" do - test "bracketed paste mode sequences" do - assert ANSI.enable_bracketed_paste() |> IO.iodata_to_binary() == "\e[?2004h" - assert ANSI.disable_bracketed_paste() |> IO.iodata_to_binary() == "\e[?2004l" - end - - test "focus event sequences" do - assert ANSI.enable_focus_events() |> IO.iodata_to_binary() == "\e[?1004h" - assert ANSI.disable_focus_events() |> IO.iodata_to_binary() == "\e[?1004l" - end - - test "mouse tracking sequences" do - assert ANSI.enable_mouse_tracking(:x10) |> IO.iodata_to_binary() == "\e[?9h" - assert ANSI.enable_mouse_tracking(:normal) |> IO.iodata_to_binary() == "\e[?1000h" - assert ANSI.enable_mouse_tracking(:button) |> IO.iodata_to_binary() == "\e[?1002h" - assert ANSI.enable_mouse_tracking(:all) |> IO.iodata_to_binary() == "\e[?1003h" - - assert ANSI.disable_mouse_tracking(:all) |> IO.iodata_to_binary() == "\e[?1003l" - end - - test "SGR mouse mode sequences" do - assert ANSI.enable_sgr_mouse() |> IO.iodata_to_binary() == "\e[?1006h" - assert ANSI.disable_sgr_mouse() |> IO.iodata_to_binary() == "\e[?1006l" - end - - test "alternate screen sequences" do - assert ANSI.enter_alternate_screen() |> IO.iodata_to_binary() == "\e[?1049h" - assert ANSI.leave_alternate_screen() |> IO.iodata_to_binary() == "\e[?1049l" - end - - test "paste events parse correctly" do - {events, ""} = parse("\e[200~pasted text\e[201~") - - assert length(events) == 1 - [%Paste{content: content}] = events - assert content == "pasted text" - end - - test "focus events parse correctly" do - {events, ""} = parse("\e[I") - assert [%Focus{action: :gained}] = events - - {events, ""} = parse("\e[O") - assert [%Focus{action: :lost}] = events - end - end - - describe "screen manipulation" do - test "clear screen sequences" do - assert ANSI.clear_screen() |> IO.iodata_to_binary() == "\e[2J" - assert ANSI.clear_screen_from_cursor() |> IO.iodata_to_binary() == "\e[0J" - assert ANSI.clear_screen_to_cursor() |> IO.iodata_to_binary() == "\e[1J" - end - - test "clear line sequences" do - assert ANSI.clear_line() |> IO.iodata_to_binary() == "\e[2K" - assert ANSI.clear_line_from_cursor() |> IO.iodata_to_binary() == "\e[K" - assert ANSI.clear_line_to_cursor() |> IO.iodata_to_binary() == "\e[1K" - end - - test "scroll region sequence" do - assert ANSI.set_scroll_region(5, 20) |> IO.iodata_to_binary() == "\e[5;20r" - end - - test "scroll sequences" do - assert ANSI.scroll_up(3) |> IO.iodata_to_binary() == "\e[3S" - assert ANSI.scroll_down(2) |> IO.iodata_to_binary() == "\e[2T" - end - end - - describe "edge cases and boundary values" do - test "cursor position rejects zero values" do - # ANSI module guards require positive values - assert_raise FunctionClauseError, fn -> - ANSI.cursor_position(0, 0) - end - - assert_raise FunctionClauseError, fn -> - ANSI.cursor_position(1, 0) - end - - assert_raise FunctionClauseError, fn -> - ANSI.cursor_position(0, 1) - end - end - - test "cursor position with minimum valid values" do - seq = ANSI.cursor_position(1, 1) |> IO.iodata_to_binary() - assert seq == "\e[1;1H" - end - - test "cursor position with large values" do - # Test with values beyond typical terminal size - seq = ANSI.cursor_position(9999, 9999) |> IO.iodata_to_binary() - assert seq == "\e[9999;9999H" - end - - test "cursor movement rejects zero" do - # ANSI module guards require positive values - assert_raise FunctionClauseError, fn -> ANSI.cursor_up(0) end - assert_raise FunctionClauseError, fn -> ANSI.cursor_down(0) end - assert_raise FunctionClauseError, fn -> ANSI.cursor_forward(0) end - assert_raise FunctionClauseError, fn -> ANSI.cursor_back(0) end - end - - test "cursor movement with large values" do - assert ANSI.cursor_up(10_000) |> IO.iodata_to_binary() == "\e[10000A" - assert ANSI.cursor_down(10_000) |> IO.iodata_to_binary() == "\e[10000B" - end - - test "256 color boundary values" do - # Minimum valid - assert ANSI.foreground_256(0) |> IO.iodata_to_binary() == "\e[38;5;0m" - # Maximum valid - assert ANSI.foreground_256(255) |> IO.iodata_to_binary() == "\e[38;5;255m" - # Background boundaries - assert ANSI.background_256(0) |> IO.iodata_to_binary() == "\e[48;5;0m" - assert ANSI.background_256(255) |> IO.iodata_to_binary() == "\e[48;5;255m" - end - - test "RGB color boundary values" do - # All zeros (black) - assert ANSI.foreground_rgb(0, 0, 0) |> IO.iodata_to_binary() == "\e[38;2;0;0;0m" - # All max (white) - assert ANSI.foreground_rgb(255, 255, 255) |> IO.iodata_to_binary() == "\e[38;2;255;255;255m" - # Mixed boundaries - assert ANSI.background_rgb(0, 255, 0) |> IO.iodata_to_binary() == "\e[48;2;0;255;0m" - end - - test "scroll region boundary values" do - # Minimum region - assert ANSI.set_scroll_region(1, 1) |> IO.iodata_to_binary() == "\e[1;1r" - # Large region - assert ANSI.set_scroll_region(1, 1000) |> IO.iodata_to_binary() == "\e[1;1000r" - end - - test "scroll rejects zero values" do - assert_raise FunctionClauseError, fn -> ANSI.scroll_up(0) end - assert_raise FunctionClauseError, fn -> ANSI.scroll_down(0) end - end - - test "scroll with large values" do - assert ANSI.scroll_up(1000) |> IO.iodata_to_binary() == "\e[1000S" - assert ANSI.scroll_down(1000) |> IO.iodata_to_binary() == "\e[1000T" - end - end -end diff --git a/test/integration/runtime_contract_test.exs b/test/integration/runtime_contract_test.exs new file mode 100644 index 00000000..06467a1d --- /dev/null +++ b/test/integration/runtime_contract_test.exs @@ -0,0 +1,276 @@ +defmodule TermUI.RuntimeContractTest do + use ExUnit.Case, async: false + + alias TermUI.{Command, Event, Frame, Runtime} + alias TermUI.Test.DeterministicBackend + + setup do + previous = Process.flag(:trap_exit, true) + on_exit(fn -> Process.flag(:trap_exit, previous) end) + :ok + end + + defmodule Counter do + use TermUI.Elm + + def init(opts), do: %{count: 0, owner: Keyword.fetch!(opts, :owner)} + + def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :increment} + def event_to_msg(%Event.Text{text: "q"}, _state), do: {:msg, :quit} + def event_to_msg(_event, _state), do: :ignore + + def update(:increment, state), do: {%{state | count: state.count + 1}, []} + def update(:quit, state), do: {%{state | count: state.count + 1}, [Command.shutdown()]} + def update({:set, count}, state), do: {%{state | count: count}, []} + + def view(state), do: Frame.from_rows(["count=#{state.count}"], 20, 2, cursor: {1, 2}) + + def terminate(reason, state) do + send(state.owner, {:app_terminated, reason, state.count}) + :ok + end + end + + defmodule CrashingApp do + use TermUI.Elm + + def init(opts), do: %{owner: Keyword.fetch!(opts, :owner)} + def event_to_msg(_event, _state), do: {:msg, :crash} + def update(:crash, _state), do: raise("application failed") + def view(_state), do: Frame.from_rows(["ready"], 20, 2) + end + + defmodule InitCrashingApp do + use TermUI.Elm + + def init(_opts), do: raise("init failed") + def event_to_msg(_event, _state), do: :ignore + def update(_message, state), do: state + def view(_state), do: Frame.from_rows(["unused"], 10, 1) + end + + defmodule InfoApp do + use TermUI.Elm + + def init(opts), do: %{owner: Keyword.fetch!(opts, :owner), info: nil} + def event_to_msg(_event, _state), do: :ignore + def update(_message, state), do: state + + def handle_info(message, state) do + {%{state | info: message}, [Command.send(state.owner, {:application_info, message})]} + end + + def view(state), do: Frame.from_rows([inspect(state.info)], 40, 2) + end + + defmodule AsyncApp do + use TermUI.Elm + + def init(opts) do + state = %{owner: Keyword.fetch!(opts, :owner)} + command = Command.async(Keyword.fetch!(opts, :async_function), &{:async_complete, &1}) + {state, [command]} + end + + def event_to_msg(_event, _state), do: :ignore + + def update({:async_complete, result}, state) do + {state, [Command.send(state.owner, {:async_complete, result})]} + end + + def view(_state), do: Frame.from_rows(["async"], 20, 2) + end + + defmodule ResizableApp do + use TermUI.Elm + + def init(opts), do: %{dimensions: Keyword.fetch!(opts, :dimensions)} + + def event_to_msg(%Event.Resize{width: width, height: height}, _state), + do: {:msg, {:resize, width, height}} + + def event_to_msg(%Event.Text{text: "q"}, _state), do: {:msg, :quit} + def event_to_msg(_event, _state), do: :ignore + def update({:resize, width, height}, state), do: %{state | dimensions: {width, height}} + def update(:quit, state), do: {state, [Command.shutdown()]} + + def view(%{dimensions: {width, height}}), + do: Frame.from_rows(["#{width}x#{height}"], width, height) + end + + test "an injected backend receives normalized input and the final meaningful frame" do + events = [Event.key(:up), Event.text("q")] + + assert {:ok, runtime} = + Runtime.start_link( + root: Counter, + owner: self(), + backend: {DeterministicBackend, owner: self(), events: events}, + render_interval: 50 + ) + + runtime_ref = Process.monitor(runtime) + + assert_receive {:backend, :draw, first}, 500 + assert Frame.row_text(first, 1) == "count=0 " + + assert_receive {:backend, :draw, final}, 500 + assert Frame.row_text(final, 1) == "count=2 " + assert final.cursor == {1, 2} + assert_receive {:backend, :shutdown, :normal}, 500 + assert_receive {:backend, :shutdown_state, 0, 2}, 500 + assert_receive {:app_terminated, :normal, 2}, 500 + assert_receive {:DOWN, ^runtime_ref, :process, ^runtime, :normal}, 500 + end + + test "many updates coalesce into bounded output and external shutdown draws the newest state" do + {:ok, runtime} = start_counter(render_interval: 100) + assert_receive {:backend, :draw, _initial}, 500 + + Enum.each(1..200, &Runtime.send_message(runtime, {:set, &1})) + Runtime.shutdown(runtime) + + assert_receive {:backend, :draw, final}, 500 + assert Frame.row_text(final, 1) == "count=200 " + assert_receive {:backend, :shutdown, :normal}, 500 + refute_receive {:backend, :draw, _extra}, 150 + end + + test "draw and flush failures are useful and still clean the backend" do + for {stage, reason} <- [draw: :draw_failed, flush: :flush_failed] do + assert {:error, {:backend, DeterministicBackend, ^stage, ^reason}} = + Runtime.run( + root: Counter, + owner: self(), + backend: {DeterministicBackend, owner: self(), fail: stage} + ) + + assert_receive {:backend, :shutdown, {:backend, DeterministicBackend, ^stage, ^reason}}, 500 + end + end + + test "size, capability, and application init failures clean an opened backend" do + assert {:error, {:backend, DeterministicBackend, :size, :size_failed} = size_reason} = + Runtime.run( + root: Counter, + owner: self(), + backend: {DeterministicBackend, owner: self(), fail: :size} + ) + + assert_receive {:backend, :shutdown, ^size_reason}, 500 + + assert {:error, + {:backend, DeterministicBackend, :capabilities, + %RuntimeError{message: "capabilities failed"}} = capabilities_reason} = + Runtime.run( + root: Counter, + owner: self(), + backend: {DeterministicBackend, owner: self(), fail: :capabilities} + ) + + assert_receive {:backend, :shutdown, ^capabilities_reason}, 500 + + assert {:error, + {:application, :init, {:error, %RuntimeError{message: "init failed"}, _stacktrace}} = + init_reason} = + Runtime.run( + root: InitCrashingApp, + owner: self(), + backend: {DeterministicBackend, owner: self()} + ) + + assert_receive {:backend, :shutdown, ^init_reason}, 500 + end + + test "an application failure still cleans the backend" do + assert {:ok, runtime} = + Runtime.start_link( + root: CrashingApp, + owner: self(), + backend: {DeterministicBackend, owner: self(), events: [Event.key(:enter)]} + ) + + ref = Process.monitor(runtime) + assert_receive {:backend, :draw, _frame}, 500 + + assert_receive {:DOWN, ^ref, :process, ^runtime, {:application, :update, _failure}}, 500 + assert_receive {:backend, :shutdown, {:application, :update, _failure}}, 500 + end + + test "unknown monitor messages reach the application" do + assert {:ok, runtime} = + Runtime.start_link( + root: InfoApp, + owner: self(), + backend: {DeterministicBackend, owner: self()} + ) + + assert_receive {:backend, :draw, _frame}, 500 + down = {:DOWN, make_ref(), :process, self(), :test_reason} + send(runtime, down) + assert_receive {:application_info, ^down}, 500 + Runtime.shutdown(runtime) + end + + test "resize updates the backend and the final frame dimensions" do + events = [Event.resize(7, 3), Event.text("q")] + + assert {:ok, _runtime} = + Runtime.start_link( + root: ResizableApp, + backend: {DeterministicBackend, owner: self(), events: events} + ) + + assert_receive {:backend, :draw, %Frame{width: 20, height: 6}}, 500 + assert_receive {:backend, :draw, %Frame{width: 7, height: 3} = final}, 500 + assert Frame.row_text(final, 1) == "7x3 " + assert_receive {:backend, :shutdown, :normal}, 500 + end + + test "async commands tag a bare return value as successful" do + runtime = start_async_app(fn -> :completed end) + + assert_receive {:async_complete, {:ok, :completed}}, 500 + Runtime.shutdown(runtime) + end + + test "async commands preserve a tagged return value inside the success result" do + runtime = start_async_app(fn -> {:ok, :inner} end) + + assert_receive {:async_complete, {:ok, {:ok, :inner}}}, 500 + Runtime.shutdown(runtime) + end + + test "async commands tag raised failures as errors" do + runtime = start_async_app(fn -> raise "async failed" end) + + assert_receive {:async_complete, + {:error, {:error, %RuntimeError{message: "async failed"}, _stacktrace}}}, + 500 + + Runtime.shutdown(runtime) + end + + defp start_counter(opts) do + Runtime.start_link( + [ + root: Counter, + owner: self(), + backend: {DeterministicBackend, owner: self()} + ] ++ opts + ) + end + + defp start_async_app(async_function) do + assert {:ok, runtime} = + Runtime.start_link( + root: AsyncApp, + owner: self(), + async_function: async_function, + backend: {DeterministicBackend, owner: self()} + ) + + assert_receive {:backend, :draw, _frame}, 500 + runtime + end +end diff --git a/test/integration/terminal_lifecycle_test.exs b/test/integration/terminal_lifecycle_test.exs deleted file mode 100644 index 4751f33e..00000000 --- a/test/integration/terminal_lifecycle_test.exs +++ /dev/null @@ -1,362 +0,0 @@ -defmodule TermUI.Integration.TerminalLifecycleTest do - @moduledoc """ - Integration tests for terminal lifecycle management. - - Tests complete initialization, shutdown, crash recovery, and reinitialization - sequences to ensure robust terminal state management. - """ - - use ExUnit.Case, async: false - - alias TermUI.IntegrationHelpers - alias TermUI.Terminal - - # These tests require actual terminal access - @moduletag :integration - - setup do - # Clean up any existing terminal state - IntegrationHelpers.stop_terminal() - - on_exit(fn -> - IntegrationHelpers.cleanup_terminal() - end) - - :ok - end - - describe "1.6.1.1 complete initialization sequence" do - test "initializes terminal in correct order" do - # Start terminal - assert {:ok, _pid} = IntegrationHelpers.start_terminal() - - # Verify initial state is clean - state = Terminal.get_state() - assert state.raw_mode_active == false - assert state.alternate_screen_active == false - assert state.cursor_visible == true - end - - test "capabilities are detected before raw mode" do - {:ok, _pid} = IntegrationHelpers.start_terminal() - - # Capabilities should be detectable before enabling raw mode - caps = TermUI.Capabilities.detect() - - assert is_struct(caps, TermUI.Capabilities) - assert caps.color_mode in [:monochrome, :color_16, :color_256, :true_color] - end - end - - describe "1.6.1.1 tests requiring terminal" do - setup do - IntegrationHelpers.stop_terminal() - on_exit(fn -> IntegrationHelpers.cleanup_terminal() end) - :ok - end - - @tag :requires_terminal - test "full initialization sequence with raw mode" do - {:ok, _pid} = IntegrationHelpers.start_terminal() - - # Step 1: Detect capabilities - caps = TermUI.Capabilities.detect() - assert is_struct(caps, TermUI.Capabilities) - - # Step 2: Enable raw mode - result = Terminal.enable_raw_mode() - assert {:ok, state} = result - assert state.raw_mode_active == true - - # Step 3: Enter alternate screen - assert :ok = Terminal.enter_alternate_screen() - assert Terminal.get_state().alternate_screen_active == true - - # Step 4: Hide cursor - assert :ok = Terminal.hide_cursor() - assert Terminal.get_state().cursor_visible == false - - # Verify full initialized state - state = Terminal.get_state() - assert state.raw_mode_active == true - assert state.alternate_screen_active == true - assert state.cursor_visible == false - end - - test "initialization without terminal returns error" do - {:ok, _pid} = IntegrationHelpers.start_terminal() - - # In test environment, enable_raw_mode should return error - result = Terminal.enable_raw_mode() - - # Either succeeds (if in terminal) or fails gracefully - case result do - {:ok, _state} -> assert true - {:error, :not_a_terminal} -> assert true - {:error, :enotsup} -> assert true - {:error, {:otp_version, _}} -> assert true - {:error, reason} -> flunk("Unexpected error: #{inspect(reason)}") - end - end - end - - describe "1.6.1.2 clean shutdown sequence" do - test "restore returns terminal to clean state" do - {:ok, _pid} = IntegrationHelpers.start_terminal() - - # Simulate some state changes (without actual terminal ops) - # We test that restore resets all state flags - - # First restore - assert :ok = Terminal.restore() - - # Verify clean state - state = Terminal.get_state() - assert state.raw_mode_active == false - assert state.alternate_screen_active == false - assert state.cursor_visible == true - end - - test "double restore is idempotent" do - {:ok, _pid} = IntegrationHelpers.start_terminal() - - # Restore twice should not error - assert :ok = Terminal.restore() - assert :ok = Terminal.restore() - - # State should still be clean - IntegrationHelpers.assert_terminal_clean() - end - end - - describe "1.6.1.2 tests requiring terminal" do - setup do - IntegrationHelpers.stop_terminal() - on_exit(fn -> IntegrationHelpers.cleanup_terminal() end) - :ok - end - - @tag :requires_terminal - test "full shutdown sequence reverses initialization" do - {:ok, _pid} = IntegrationHelpers.start_terminal() - - # Initialize fully - {:ok, _} = Terminal.enable_raw_mode() - :ok = Terminal.enter_alternate_screen() - :ok = Terminal.hide_cursor() - - # Now shutdown in reverse order - # Step 1: Show cursor - assert :ok = Terminal.show_cursor() - assert Terminal.get_state().cursor_visible == true - - # Step 2: Leave alternate screen - assert :ok = Terminal.leave_alternate_screen() - assert Terminal.get_state().alternate_screen_active == false - - # Step 3: Disable raw mode - assert :ok = Terminal.disable_raw_mode() - assert Terminal.get_state().raw_mode_active == false - - # Verify complete shutdown - IntegrationHelpers.assert_terminal_clean() - end - end - - describe "1.6.1.3 crash recovery" do - test "genserver traps exit" do - {:ok, pid} = IntegrationHelpers.start_terminal() - - # Terminal should trap exits - {:trap_exit, true} = Process.info(pid, :trap_exit) - end - - test "terminate callback is called on shutdown" do - {:ok, pid} = IntegrationHelpers.start_terminal() - - # Normal stop should trigger terminate - GenServer.stop(pid, :normal) - - # Terminal should be gone - assert Process.whereis(TermUI.Terminal) == nil - end - - test "restart after crash restores clean state" do - {:ok, pid} = IntegrationHelpers.start_terminal() - - # Simulate crash and recover - Process.flag(:trap_exit, true) - Process.exit(pid, :kill) - - # Wait for process to die - receive do - {:EXIT, ^pid, :killed} -> :ok - after - 1000 -> flunk("Timeout waiting for terminal process to die") - end - - # Restart the terminal - case IntegrationHelpers.start_terminal() do - {:ok, _new_pid} -> - # Terminal should be back in clean state - IntegrationHelpers.assert_terminal_clean() - - {:error, reason} -> - flunk("Crash recovery restart failed: #{inspect(reason)}") - end - end - - test "ets table tracks raw mode for crash detection" do - {:ok, _pid} = IntegrationHelpers.start_terminal() - - # ETS table should exist - assert :ets.whereis(:term_ui_terminal_state) != :undefined - - # Can lookup values - case :ets.lookup(:term_ui_terminal_state, :raw_mode_active) do - [{:raw_mode_active, value}] -> - assert is_boolean(value) - - [] -> - # Table exists but no value yet - also valid - assert true - end - end - end - - describe "1.6.1.4 reinitialization" do - test "terminal can be restarted after clean shutdown" do - # First start - {:ok, pid1} = IntegrationHelpers.start_terminal() - :ok = Terminal.restore() - GenServer.stop(pid1, :normal) - - # Second start - {:ok, pid2} = IntegrationHelpers.start_terminal() - - # Should be different pids - assert pid1 != pid2 - - # State should be clean - IntegrationHelpers.assert_terminal_clean() - end - - test "operations work correctly after reinit" do - # First cycle - {:ok, _} = IntegrationHelpers.start_terminal() - size1 = Terminal.get_terminal_size() - IntegrationHelpers.stop_terminal() - - # Second cycle - {:ok, _} = IntegrationHelpers.start_terminal() - size2 = Terminal.get_terminal_size() - - # Terminal size should be consistent - assert size1 == size2 - end - - test "multiple reinit cycles" do - for i <- 1..3 do - {:ok, _} = IntegrationHelpers.start_terminal() - - # Basic operations - state = Terminal.get_state() - assert state.raw_mode_active == false, "Cycle #{i}: raw mode should be off" - - IntegrationHelpers.stop_terminal() - end - end - end - - describe "terminal state management" do - test "get_state returns complete state" do - {:ok, _pid} = IntegrationHelpers.start_terminal() - - state = Terminal.get_state() - - assert is_struct(state, TermUI.Terminal.State) - assert Map.has_key?(state, :raw_mode_active) - assert Map.has_key?(state, :alternate_screen_active) - assert Map.has_key?(state, :cursor_visible) - assert Map.has_key?(state, :mouse_tracking) - assert Map.has_key?(state, :bracketed_paste) - assert Map.has_key?(state, :size) - end - - test "terminal size detection" do - {:ok, _pid} = IntegrationHelpers.start_terminal() - - result = Terminal.get_terminal_size() - - case result do - {:ok, {rows, cols}} -> - assert is_integer(rows) and rows > 0 - assert is_integer(cols) and cols > 0 - - {:error, _reason} -> - # In test environment without terminal, this is expected - assert true - end - end - - test "resize callback registration" do - {:ok, _pid} = IntegrationHelpers.start_terminal() - - # Register for resize events - assert :ok = Terminal.register_resize_callback(self()) - - # Unregister - assert :ok = Terminal.unregister_resize_callback(self()) - end - - test "resize callback receives notification" do - {:ok, pid} = IntegrationHelpers.start_terminal() - - # Register for resize events - assert :ok = Terminal.register_resize_callback(self()) - - # Trigger a resize by sending sigwinch to the terminal process - send(pid, :sigwinch) - - # Should receive resize notification with current terminal size - # In non-terminal environments, get_terminal_size fails so no notification is sent - receive do - {:terminal_resize, {rows, cols}} -> - assert is_integer(rows) and rows > 0 - assert is_integer(cols) and cols > 0 - after - 100 -> - # No notification in non-terminal environment is expected - case Terminal.get_terminal_size() do - {:ok, _} -> - flunk("Did not receive resize notification despite terminal being available") - - {:error, _} -> - :ok - end - end - - # Cleanup - Terminal.unregister_resize_callback(self()) - end - - test "unregistered callback does not receive notification" do - {:ok, pid} = IntegrationHelpers.start_terminal() - - # Register then unregister - assert :ok = Terminal.register_resize_callback(self()) - assert :ok = Terminal.unregister_resize_callback(self()) - - # Trigger a resize - send(pid, :sigwinch) - - # Should NOT receive notification - receive do - {:terminal_resize, _size} -> - flunk("Should not receive notification after unregistering") - after - 100 -> :ok - end - end - end -end diff --git a/test/integration/visual_degradation_integration_test.exs b/test/integration/visual_degradation_integration_test.exs deleted file mode 100644 index 59412da0..00000000 --- a/test/integration/visual_degradation_integration_test.exs +++ /dev/null @@ -1,611 +0,0 @@ -defmodule TermUI.Integration.VisualDegradationIntegrationTest do - @moduledoc """ - Integration tests for visual degradation across capability levels. - - These tests verify that widgets render correctly across different terminal - capability levels: - - 1. **Color modes**: true_color, color_256, color_16, monochrome - 2. **Character sets**: Unicode vs ASCII - 3. **Combined degradation**: monochrome + ASCII - - ## Key Insight - - Visual degradation tests verify: - - Widgets render without errors at each capability level - - CharacterSet affects rendered characters correctly - - Theme colors degrade to text attributes in monochrome - - Selection/focus remains visible in all modes - """ - - use ExUnit.Case, async: false - - alias TermUI.CharacterSet - alias TermUI.Theme - alias TermUI.Widgets.{Gauge, Menu, Tabs, TreeView} - - # ============================================================================ - # Setup and Helpers - # ============================================================================ - - setup do - # Save original character set config - original_charset = Application.get_env(:term_ui, :character_set, :unicode) - - # Start Theme server - case Theme.start_link(theme: :dark) do - {:ok, _pid} -> :ok - {:error, {:already_started, _pid}} -> :ok - end - - on_exit(fn -> - # Restore original character set - Application.put_env(:term_ui, :character_set, original_charset) - end) - - :ok - end - - defp test_area(width \\ 80, height \\ 24) do - %{x: 0, y: 0, width: width, height: height} - end - - # Helper to extract text content from render nodes - defp extract_text(node) when is_map(node) do - case node do - %{type: :text, content: content} -> - content - - %{type: :stack, children: children} -> - Enum.map_join(children, "", &extract_text/1) - - %{type: :empty} -> - "" - - %{children: children} when is_list(children) -> - Enum.map_join(children, "", &extract_text/1) - - _ -> - "" - end - end - - defp extract_text(_), do: "" - - # Helper to check if a style uses monochrome-compatible attributes - defp has_mono_attribute?(%{attrs: attrs}) when is_struct(attrs, MapSet) do - :bold in attrs or - :underline in attrs or - :reverse in attrs or - :dim in attrs or - :italic in attrs - end - - defp has_mono_attribute?(style) when is_map(style) do - style[:bold] == true or - style[:underline] == true or - style[:reverse] == true or - style[:dim] == true or - style[:italic] == true - end - - defp has_mono_attribute?(_), do: false - - # ============================================================================ - # 5.7.4.1: Color Mode Rendering Tests - # ============================================================================ - - describe "color mode rendering - Menu widget" do - setup do - test_pid = self() - - props = - Menu.new( - items: [ - Menu.action(:item1, "First Item"), - Menu.action(:item2, "Second Item"), - Menu.action(:item3, "Third Item") - ], - on_select: fn id -> send(test_pid, {:selected, id}) end - ) - - {:ok, state} = Menu.init(props) - %{state: state} - end - - test "renders without error in true_color mode", %{state: state} do - # Default theme uses named colors which work in all modes - render = Menu.render(state, test_area()) - - assert render != nil - assert render.type in [:stack, :text, :empty] - end - - test "renders without error in color_256 mode", %{state: state} do - # Named colors like :blue, :red degrade to 256-color palette - render = Menu.render(state, test_area()) - - assert render != nil - text = extract_text(render) - assert String.contains?(text, "First Item") - end - - test "renders without error in color_16 mode", %{state: state} do - # Basic ANSI colors still work - render = Menu.render(state, test_area()) - - assert render != nil - text = extract_text(render) - assert String.contains?(text, "Second Item") - end - - test "renders without error in monochrome mode", %{state: state} do - # In monochrome, selection should be visible via reverse/bold - render = Menu.render(state, test_area()) - - assert render != nil - text = extract_text(render) - assert String.contains?(text, "Third Item") - end - - test "selection is visible through text/styling", %{state: state} do - # The selected item should have different styling - render = Menu.render(state, test_area()) - - assert render != nil - # Menu renders items, first one should be selected - assert render.type == :stack - end - end - - describe "color mode rendering - Gauge widget" do - test "renders bar gauge without error" do - render = - Gauge.render( - value: 75, - min: 0, - max: 100, - width: 30, - type: :bar - ) - - assert render != nil - assert render.type in [:stack, :text] - end - - test "renders gauge with value display" do - render = - Gauge.render( - value: 85, - min: 0, - max: 100, - width: 30, - show_value: true - ) - - assert render != nil - text = extract_text(render) - # Should contain the value - assert String.contains?(text, "85") - end - - test "renders gauge in monochrome-compatible way" do - # Gauge should still show progress visually - render = - Gauge.render( - value: 50, - min: 0, - max: 100, - width: 20, - show_value: true - ) - - assert render != nil - text = extract_text(render) - assert String.contains?(text, "50") - end - end - - describe "color mode rendering - Tabs widget" do - setup do - props = - Tabs.new( - tabs: [ - %{id: :tab1, label: "Tab 1", content: "Content 1"}, - %{id: :tab2, label: "Tab 2", content: "Content 2"}, - %{id: :tab3, label: "Tab 3", content: "Content 3"} - ] - ) - - {:ok, state} = Tabs.init(props) - %{state: state} - end - - test "renders tabs without error", %{state: state} do - render = Tabs.render(state, test_area()) - - assert render != nil - text = extract_text(render) - assert String.contains?(text, "Tab 1") or String.contains?(text, "Tab 2") - end - - test "focus indicator visible in all modes", %{state: state} do - render = Tabs.render(state, test_area()) - - assert render != nil - # The focused tab should be distinguishable - assert render.type in [:stack, :text] - end - end - - # ============================================================================ - # 5.7.4.2: Unicode vs ASCII Rendering Tests - # ============================================================================ - - describe "character set rendering - Unicode mode" do - setup do - Application.put_env(:term_ui, :character_set, :unicode) - :ok - end - - test "CharacterSet returns Unicode characters" do - chars = CharacterSet.get(:unicode) - - assert chars.tl == "┌" - assert chars.tr == "┐" - assert chars.h_line == "─" - assert chars.v_line == "│" - assert chars.bar_full == "█" - assert chars.check == "✓" - assert chars.arrow_right == "→" - end - - test "current_charset returns Unicode when configured" do - chars = CharacterSet.current_charset() - - assert chars.tl == "┌" - assert chars.bar_full == "█" - end - - test "Gauge uses Unicode bar characters" do - render = - Gauge.render( - value: 50, - min: 0, - max: 100, - width: 20 - ) - - # Should render without error - assert render != nil - assert render.type in [:stack, :text] - end - end - - describe "character set rendering - ASCII mode" do - setup do - Application.put_env(:term_ui, :character_set, :ascii) - :ok - end - - test "CharacterSet returns ASCII characters" do - chars = CharacterSet.get(:ascii) - - assert chars.tl == "+" - assert chars.tr == "+" - assert chars.h_line == "-" - assert chars.v_line == "|" - assert chars.bar_full == "#" - assert chars.check == "x" - assert chars.arrow_right == ">" - end - - test "current_charset returns ASCII when configured" do - chars = CharacterSet.current_charset() - - assert chars.tl == "+" - assert chars.bar_full == "#" - end - - test "TreeView renders without error in ASCII mode" do - props = - TreeView.new( - nodes: [ - TreeView.node(:root, "Root", [ - TreeView.node(:child1, "Child 1"), - TreeView.node(:child2, "Child 2") - ]) - ] - ) - - {:ok, state} = TreeView.init(props) - render = TreeView.render(state, test_area()) - - assert render != nil - text = extract_text(render) - assert String.contains?(text, "Root") or String.contains?(text, "Child") - end - end - - describe "character set switching at runtime" do - test "switching from Unicode to ASCII changes characters" do - # Start with Unicode - Application.put_env(:term_ui, :character_set, :unicode) - unicode_chars = CharacterSet.current_charset() - - assert unicode_chars.tl == "┌" - - # Switch to ASCII - Application.put_env(:term_ui, :character_set, :ascii) - ascii_chars = CharacterSet.current_charset() - - assert ascii_chars.tl == "+" - - # Verify they are different - assert unicode_chars.tl != ascii_chars.tl - assert unicode_chars.bar_full != ascii_chars.bar_full - end - - test "widgets render correctly after charset switch" do - # Start with Unicode - Application.put_env(:term_ui, :character_set, :unicode) - - render1 = - Gauge.render( - value: 50, - min: 0, - max: 100, - width: 20 - ) - - assert render1 != nil - - # Switch to ASCII - Application.put_env(:term_ui, :character_set, :ascii) - - render2 = - Gauge.render( - value: 50, - min: 0, - max: 100, - width: 20 - ) - - assert render2 != nil - end - end - - # ============================================================================ - # 5.7.4.3: Combined Degradation Tests (Monochrome + ASCII) - # ============================================================================ - - describe "combined degradation - monochrome + ASCII" do - setup do - # Configure for worst-case scenario - Application.put_env(:term_ui, :character_set, :ascii) - :ok - end - - test "Menu renders usably with ASCII and selection visible" do - props = - Menu.new( - items: [ - Menu.action(:a, "Action A"), - Menu.action(:b, "Action B"), - Menu.action(:c, "Action C") - ] - ) - - {:ok, state} = Menu.init(props) - render = Menu.render(state, test_area()) - - assert render != nil - text = extract_text(render) - assert String.contains?(text, "Action A") - assert String.contains?(text, "Action B") - end - - test "Gauge renders usably with ASCII bar characters" do - render = - Gauge.render( - value: 75, - min: 0, - max: 100, - width: 20, - show_value: true - ) - - assert render != nil - text = extract_text(render) - # Value should be shown - assert String.contains?(text, "75") - end - - test "Tabs renders usably with ASCII borders" do - props = - Tabs.new( - tabs: [ - %{id: :t1, label: "First", content: "Content 1"}, - %{id: :t2, label: "Second", content: "Content 2"} - ] - ) - - {:ok, state} = Tabs.init(props) - render = Tabs.render(state, test_area()) - - assert render != nil - text = extract_text(render) - assert String.contains?(text, "First") or String.contains?(text, "Second") - end - - test "TreeView renders usably with ASCII tree lines" do - props = - TreeView.new( - nodes: [ - TreeView.node(:root, "Project", [ - TreeView.node(:src, "src", [ - TreeView.node(:main, "main.ex") - ]), - TreeView.node(:readme, "README.md") - ]) - ] - ) - - {:ok, state} = TreeView.init(props) - render = TreeView.render(state, test_area()) - - assert render != nil - text = extract_text(render) - assert String.contains?(text, "Project") or String.contains?(text, "src") - end - - test "all character set keys have ASCII equivalents" do - unicode_chars = CharacterSet.get(:unicode) - ascii_chars = CharacterSet.get(:ascii) - - # Verify all keys exist in both - for key <- CharacterSet.keys() do - assert Map.has_key?(unicode_chars, key), - "Unicode charset missing key: #{key}" - - assert Map.has_key?(ascii_chars, key), - "ASCII charset missing key: #{key}" - end - end - - test "ASCII characters are all single-byte printable" do - ascii_chars = CharacterSet.get(:ascii) - - # bar_levels and sparkline_levels are lists, not single characters - list_keys = [:bar_levels, :sparkline_levels] - - for {key, value} <- ascii_chars, key not in list_keys do - # Each character should be printable ASCII - assert is_binary(value), "#{key} should be a string" - - assert byte_size(value) == String.length(value), - "#{key} should be ASCII: #{inspect(value)}" - end - end - end - - describe "visual hierarchy in degraded modes" do - setup do - Application.put_env(:term_ui, :character_set, :ascii) - :ok - end - - test "focused items distinguishable from unfocused" do - # Create menu and verify focused item has different rendering - props = - Menu.new( - items: [ - Menu.action(:a, "Item A"), - Menu.action(:b, "Item B") - ] - ) - - {:ok, state} = Menu.init(props) - - # Render with first item focused - render1 = Menu.render(state, test_area()) - assert render1 != nil - - # Navigate to second item - {:ok, state2} = Menu.handle_event(%TermUI.Event.Key{key: :down}, state) - - # Render with second item focused - render2 = Menu.render(state2, test_area()) - assert render2 != nil - - # Both should render successfully - # The difference would be in styling (reverse, bold, etc.) - end - - test "selected items distinguishable from unselected" do - props = - Tabs.new( - tabs: [ - %{id: :t1, label: "Tab One", content: "C1"}, - %{id: :t2, label: "Tab Two", content: "C2"} - ] - ) - - {:ok, state} = Tabs.init(props) - - # First tab is selected by default - render1 = Tabs.render(state, test_area()) - assert render1 != nil - - # Select second tab - {:ok, state2} = Tabs.handle_event(%TermUI.Event.Key{key: :right}, state) - {:ok, state3} = Tabs.handle_event(%TermUI.Event.Key{key: :enter}, state2) - - render2 = Tabs.render(state3, test_area()) - assert render2 != nil - end - - test "error states use underline in monochrome theme" do - # The high_contrast theme uses underline for error states - # This is how error visibility is maintained in monochrome - {:ok, theme} = Theme.get_builtin(:high_contrast) - - error_style = theme.components[:status][:error] - assert error_style != nil - assert has_mono_attribute?(error_style) - end - - test "focused states use bold in theme" do - {:ok, theme} = Theme.get_builtin(:dark) - - # Check that focused items have bold attribute - button_focused = theme.components[:button][:focused] - assert button_focused != nil - assert :bold in button_focused.attrs - end - end - - # ============================================================================ - # Edge Cases and Boundary Tests - # ============================================================================ - - describe "edge cases" do - test "empty gauge renders without error" do - render = Gauge.render(value: 0, min: 0, max: 100, width: 20) - assert render != nil - end - - test "full gauge renders without error" do - render = Gauge.render(value: 100, min: 0, max: 100, width: 20) - assert render != nil - end - - test "menu with single item renders" do - props = Menu.new(items: [Menu.action(:only, "Only Item")]) - {:ok, state} = Menu.init(props) - render = Menu.render(state, test_area()) - - assert render != nil - text = extract_text(render) - assert String.contains?(text, "Only Item") - end - - test "tabs with single tab renders" do - props = Tabs.new(tabs: [%{id: :single, label: "Single", content: "Content"}]) - {:ok, state} = Tabs.init(props) - render = Tabs.render(state, test_area()) - - assert render != nil - end - - test "treeview with single node renders" do - props = TreeView.new(nodes: [TreeView.node(:alone, "Alone")]) - {:ok, state} = TreeView.init(props) - render = TreeView.render(state, test_area()) - - assert render != nil - text = extract_text(render) - assert String.contains?(text, "Alone") - end - end -end diff --git a/test/manual/linux.md b/test/manual/linux.md deleted file mode 100644 index 6e10be28..00000000 --- a/test/manual/linux.md +++ /dev/null @@ -1,32 +0,0 @@ -# Linux Manual Testing Checklist - -| Example | Tested | Description | -|---------|:------:|-------------| -| alert_dialog | [ ] | Standardized message dialogs and confirmations with predefined button configurations | -| bar_chart | [x] | Comparative values displayed as horizontal or vertical bars | -| canvas | [x] | Direct character buffer for custom drawing with primitives | -| cluster_dashboard | [x] | Visualization and monitoring of distributed Erlang/BEAM clusters | -| command_palette | [x] | Command dropdown for filtering and selecting commands with keyboard | -| context_menu | [x] | Floating menus at cursor position, triggered by right-click or keyboard | -| dashboard | [ ] | System monitoring dashboard with multiple widgets and real-time updates | -| dialog | [x] | Modal dialogs with customizable buttons and content | -| form_builder | [x] | Structured forms with multiple field types and validation | -| gauge | [x] | Numeric values within a range using visual bars or arcs | -| iex_counter | [x] | Simple counter demonstrating TermUI's IEx compatibility | -| line_chart | [x] | Time series visualization using Braille patterns | -| log_viewer | [x] | Display and analyze log data with virtual scrolling | -| markdown_viewer | [x] | Render and display markdown content | -| menu | [x] | Hierarchical menus with various item types | -| multi_renderer | [ ] | Multi-renderer capabilities with automatic backend selection | -| pick_list | [ ] | Modal selection dialogs with filtering support | -| process_monitor | [ ] | Live BEAM process inspection and management | -| sparkline | [ ] | Compact inline trend visualization using vertical bars | -| split_pane | [ ] | Resizable multi-pane layouts similar to IDE editors | -| stream_widget | [ ] | Backpressure-aware streaming data with GenStage | -| supervision_tree_viewer | [ ] | Visualize OTP supervision hierarchies in real-time | -| table | [ ] | Tabular data with selection, sorting, and scrolling | -| tabs | [ ] | Organize content into switchable panels | -| text_input | [ ] | Single-line and multi-line text input | -| toast | [ ] | Auto-dismissing notifications | -| tree_view | [ ] | Hierarchical data with expand/collapse functionality | -| viewport | [ ] | Scrollable content areas with keyboard/mouse support | diff --git a/test/manual/mac.md b/test/manual/mac.md deleted file mode 100644 index d262794e..00000000 --- a/test/manual/mac.md +++ /dev/null @@ -1,32 +0,0 @@ -# macOS Manual Testing Checklist - -| Example | Tested | Description | -|---------|:------:|-------------| -| alert_dialog | [ ] | Standardized message dialogs and confirmations with predefined button configurations | -| bar_chart | [ ] | Comparative values displayed as horizontal or vertical bars | -| canvas | [ ] | Direct character buffer for custom drawing with primitives | -| cluster_dashboard | [ ] | Visualization and monitoring of distributed Erlang/BEAM clusters | -| command_palette | [ ] | Command dropdown for filtering and selecting commands with keyboard | -| context_menu | [ ] | Floating menus at cursor position, triggered by right-click or keyboard | -| dashboard | [ ] | System monitoring dashboard with multiple widgets and real-time updates | -| dialog | [ ] | Modal dialogs with customizable buttons and content | -| form_builder | [ ] | Structured forms with multiple field types and validation | -| gauge | [ ] | Numeric values within a range using visual bars or arcs | -| iex_counter | [ ] | Simple counter demonstrating TermUI's IEx compatibility | -| line_chart | [ ] | Time series visualization using Braille patterns | -| log_viewer | [ ] | Display and analyze log data with virtual scrolling | -| markdown_viewer | [ ] | Render and display markdown content | -| menu | [ ] | Hierarchical menus with various item types | -| multi_renderer | [ ] | Multi-renderer capabilities with automatic backend selection | -| pick_list | [ ] | Modal selection dialogs with filtering support | -| process_monitor | [ ] | Live BEAM process inspection and management | -| sparkline | [ ] | Compact inline trend visualization using vertical bars | -| split_pane | [ ] | Resizable multi-pane layouts similar to IDE editors | -| stream_widget | [ ] | Backpressure-aware streaming data with GenStage | -| supervision_tree_viewer | [ ] | Visualize OTP supervision hierarchies in real-time | -| table | [ ] | Tabular data with selection, sorting, and scrolling | -| tabs | [ ] | Organize content into switchable panels | -| text_input | [ ] | Single-line and multi-line text input | -| toast | [ ] | Auto-dismissing notifications | -| tree_view | [ ] | Hierarchical data with expand/collapse functionality | -| viewport | [ ] | Scrollable content areas with keyboard/mouse support | diff --git a/test/manual/windows.md b/test/manual/windows.md deleted file mode 100644 index 64f847bb..00000000 --- a/test/manual/windows.md +++ /dev/null @@ -1,32 +0,0 @@ -# Windows Manual Testing Checklist - -| Example | Tested | Description | -|---------|:------:|-------------| -| alert_dialog | [ ] | Standardized message dialogs and confirmations with predefined button configurations | -| bar_chart | [ ] | Comparative values displayed as horizontal or vertical bars | -| canvas | [ ] | Direct character buffer for custom drawing with primitives | -| cluster_dashboard | [ ] | Visualization and monitoring of distributed Erlang/BEAM clusters | -| command_palette | [ ] | Command dropdown for filtering and selecting commands with keyboard | -| context_menu | [ ] | Floating menus at cursor position, triggered by right-click or keyboard | -| dashboard | [ ] | System monitoring dashboard with multiple widgets and real-time updates | -| dialog | [ ] | Modal dialogs with customizable buttons and content | -| form_builder | [ ] | Structured forms with multiple field types and validation | -| gauge | [ ] | Numeric values within a range using visual bars or arcs | -| iex_counter | [ ] | Simple counter demonstrating TermUI's IEx compatibility | -| line_chart | [ ] | Time series visualization using Braille patterns | -| log_viewer | [ ] | Display and analyze log data with virtual scrolling | -| markdown_viewer | [ ] | Render and display markdown content | -| menu | [ ] | Hierarchical menus with various item types | -| multi_renderer | [ ] | Multi-renderer capabilities with automatic backend selection | -| pick_list | [ ] | Modal selection dialogs with filtering support | -| process_monitor | [ ] | Live BEAM process inspection and management | -| sparkline | [ ] | Compact inline trend visualization using vertical bars | -| split_pane | [ ] | Resizable multi-pane layouts similar to IDE editors | -| stream_widget | [ ] | Backpressure-aware streaming data with GenStage | -| supervision_tree_viewer | [ ] | Visualize OTP supervision hierarchies in real-time | -| table | [ ] | Tabular data with selection, sorting, and scrolling | -| tabs | [ ] | Organize content into switchable panels | -| text_input | [ ] | Single-line and multi-line text input | -| toast | [ ] | Auto-dismissing notifications | -| tree_view | [ ] | Hierarchical data with expand/collapse functionality | -| viewport | [ ] | Scrollable content areas with keyboard/mouse support | diff --git a/test/manual/wsl.md b/test/manual/wsl.md deleted file mode 100644 index a8215959..00000000 --- a/test/manual/wsl.md +++ /dev/null @@ -1,32 +0,0 @@ -# WSL Manual Testing Checklist - -| Example | Tested | Description | -|---------|:------:|-------------| -| alert_dialog | [ ] | Standardized message dialogs and confirmations with predefined button configurations | -| bar_chart | [ ] | Comparative values displayed as horizontal or vertical bars | -| canvas | [ ] | Direct character buffer for custom drawing with primitives | -| cluster_dashboard | [ ] | Visualization and monitoring of distributed Erlang/BEAM clusters | -| command_palette | [ ] | Command dropdown for filtering and selecting commands with keyboard | -| context_menu | [ ] | Floating menus at cursor position, triggered by right-click or keyboard | -| dashboard | [ ] | System monitoring dashboard with multiple widgets and real-time updates | -| dialog | [ ] | Modal dialogs with customizable buttons and content | -| form_builder | [ ] | Structured forms with multiple field types and validation | -| gauge | [ ] | Numeric values within a range using visual bars or arcs | -| iex_counter | [ ] | Simple counter demonstrating TermUI's IEx compatibility | -| line_chart | [ ] | Time series visualization using Braille patterns | -| log_viewer | [ ] | Display and analyze log data with virtual scrolling | -| markdown_viewer | [ ] | Render and display markdown content | -| menu | [ ] | Hierarchical menus with various item types | -| multi_renderer | [ ] | Multi-renderer capabilities with automatic backend selection | -| pick_list | [ ] | Modal selection dialogs with filtering support | -| process_monitor | [ ] | Live BEAM process inspection and management | -| sparkline | [ ] | Compact inline trend visualization using vertical bars | -| split_pane | [ ] | Resizable multi-pane layouts similar to IDE editors | -| stream_widget | [ ] | Backpressure-aware streaming data with GenStage | -| supervision_tree_viewer | [ ] | Visualize OTP supervision hierarchies in real-time | -| table | [ ] | Tabular data with selection, sorting, and scrolling | -| tabs | [ ] | Organize content into switchable panels | -| text_input | [ ] | Single-line and multi-line text input | -| toast | [ ] | Auto-dismissing notifications | -| tree_view | [ ] | Hierarchical data with expand/collapse functionality | -| viewport | [ ] | Scrollable content areas with keyboard/mouse support | diff --git a/test/support/context_menu_test_helpers.ex b/test/support/context_menu_test_helpers.ex deleted file mode 100644 index 543c50eb..00000000 --- a/test/support/context_menu_test_helpers.ex +++ /dev/null @@ -1,57 +0,0 @@ -defmodule TermUI.Test.ContextMenuHelpers do - @moduledoc """ - Shared test helpers for ContextMenu test suites. - - Provides common item builders and utilities used across multiple - context menu test files. - """ - - alias TermUI.Widgets.ContextMenu - - @doc """ - Creates a simple list of selectable menu items. - - ## Returns - - Three action items: Copy, Paste, Delete - """ - def simple_items do - [ - ContextMenu.action(:copy, "Copy"), - ContextMenu.action(:paste, "Paste"), - ContextMenu.action(:delete, "Delete") - ] - end - - @doc """ - Creates a mixed list of menu items including separators and disabled items. - - ## Returns - - Four items: Copy (enabled), separator, Paste (disabled), Delete (enabled) - """ - def mixed_items do - [ - ContextMenu.action(:copy, "Copy"), - ContextMenu.separator(), - ContextMenu.action(:paste, "Paste", disabled: true), - ContextMenu.action(:delete, "Delete") - ] - end - - @doc """ - Creates a test area struct for rendering tests. - - ## Parameters - - - `width` - Width of the test area (default: 80) - - `height` - Height of the test area (default: 24) - - ## Returns - - A map representing a test rendering area - """ - def test_area(width \\ 80, height \\ 24) do - %{x: 0, y: 0, width: width, height: height} - end -end diff --git a/test/support/deterministic_backend.ex b/test/support/deterministic_backend.ex new file mode 100644 index 00000000..1ff96b23 --- /dev/null +++ b/test/support/deterministic_backend.ex @@ -0,0 +1,71 @@ +defmodule TermUI.Test.DeterministicBackend do + @moduledoc false + + @behaviour TermUI.Backend + + @impl true + def init(opts) do + owner = Keyword.fetch!(opts, :owner) + send(owner, {:backend, :init, self()}) + + {:ok, + %{ + owner: owner, + size: Keyword.get(opts, :size, {6, 20}), + events: Keyword.get(opts, :events, []), + fail: Keyword.get(opts, :fail), + draws: 0 + }} + end + + @impl true + def size(%{fail: :size}), do: {:error, :size_failed} + def size(state), do: {:ok, state.size} + + @impl true + def capabilities(%{fail: :capabilities}), do: raise("capabilities failed") + def capabilities(_state), do: %{colors: :true_color, unicode: true} + + @impl true + def draw(%{fail: :draw}, _frame), do: {:error, :draw_failed} + + def draw(state, frame) do + send(state.owner, {:backend, :draw, frame}) + {:ok, %{state | draws: state.draws + 1}} + end + + @impl true + def flush(%{fail: :flush}), do: {:error, :flush_failed} + + def flush(state) do + send(state.owner, {:backend, :flush, state.draws}) + {:ok, state} + end + + @impl true + def clipboard(%{fail: :clipboard}, _operation), do: {:error, :clipboard_failed} + + def clipboard(state, operation) do + send(state.owner, {:backend, :clipboard, operation}) + {:ok, state} + end + + @impl true + def poll_event(%{events: [event | rest]} = state, _timeout), + do: {:ok, event, %{state | events: rest}} + + def poll_event(state, timeout) do + Process.sleep(min(timeout, 5)) + {:timeout, state} + end + + @impl true + def resize(state, size), do: {:ok, %{state | size: size}} + + @impl true + def shutdown(state, reason) do + send(state.owner, {:backend, :shutdown, reason}) + send(state.owner, {:backend, :shutdown_state, length(state.events), state.draws}) + :ok + end +end diff --git a/test/support/integration_helpers.ex b/test/support/integration_helpers.ex deleted file mode 100644 index 31f8d320..00000000 --- a/test/support/integration_helpers.ex +++ /dev/null @@ -1,330 +0,0 @@ -defmodule TermUI.IntegrationHelpers do - @moduledoc """ - Helper functions for integration tests. - - Provides utilities for setting up and tearing down terminal state, - capturing output, and simulating input. - """ - - alias TermUI.Terminal - alias TermUI.Terminal.EscapeParser - - @doc """ - Starts the Terminal GenServer for integration tests. - - Returns `{:ok, pid}` or `{:error, reason}`. - """ - @spec start_terminal() :: {:ok, pid()} | {:error, term()} - def start_terminal do - case Terminal.start_link([]) do - {:ok, pid} -> {:ok, pid} - {:error, {:already_started, pid}} -> {:ok, pid} - error -> error - end - end - - @doc """ - Stops the Terminal GenServer. - """ - @spec stop_terminal() :: :ok - def stop_terminal do - pid = Process.whereis(TermUI.Terminal) - - if pid && Process.alive?(pid) do - # Ensure terminal is restored before stopping - try do - Terminal.restore() - GenServer.stop(TermUI.Terminal, :normal) - catch - :exit, _ -> :ok - end - end - - :ok - end - - @doc """ - Sets up terminal environment for testing. - - Returns `:ok` on success. - """ - @spec setup_terminal() :: :ok | {:error, term()} - def setup_terminal do - case start_terminal() do - {:ok, _pid} -> :ok - error -> error - end - end - - @doc """ - Cleans up terminal environment after testing. - - Ensures terminal is restored to a clean state. - """ - @spec cleanup_terminal() :: :ok - def cleanup_terminal do - stop_terminal() - end - - @doc """ - Executes a function with terminal setup and automatic cleanup. - - ## Example - - with_terminal(fn -> - Terminal.enable_raw_mode() - # test code - end) - """ - @spec with_terminal(function()) :: term() - def with_terminal(fun) when is_function(fun, 0) do - case setup_terminal() do - :ok -> - try do - fun.() - after - cleanup_terminal() - end - - error -> - error - end - end - - @doc """ - Asserts that the terminal state is in a clean (default) state. - - Returns `:ok` if clean, raises on failure. - """ - @spec assert_terminal_clean() :: :ok - def assert_terminal_clean do - state = Terminal.get_state() - - if state.raw_mode_active do - raise "Terminal raw mode should be inactive, got: #{state.raw_mode_active}" - end - - if state.alternate_screen_active do - raise "Terminal alternate screen should be inactive, got: #{state.alternate_screen_active}" - end - - unless state.cursor_visible do - raise "Terminal cursor should be visible, got: #{state.cursor_visible}" - end - - :ok - end - - @doc """ - Asserts that the terminal state matches expected values. - """ - @spec assert_terminal_state(keyword()) :: :ok - def assert_terminal_state(expected) do - state = Terminal.get_state() - - for {key, expected_value} <- expected do - actual_value = Map.get(state, key) - - unless actual_value == expected_value do - raise "Expected terminal #{key} to be #{inspect(expected_value)}, got: #{inspect(actual_value)}" - end - end - - :ok - end - - @doc """ - Checks if running in a terminal environment. - - Returns true if stdin/stdout are connected to a terminal. - """ - @spec terminal_available?() :: boolean() - def terminal_available? do - case :io.getopts(:standard_io) do - {:ok, opts} -> - Keyword.has_key?(opts, :terminal) - - _ -> - # Fallback: check if it's a TTY - case System.cmd("test", ["-t", "1"], stderr_to_stdout: true) do - {_, 0} -> true - _ -> false - end - end - rescue - # ErlangError occurs when System.cmd fails (e.g., test command not found on Windows) - ErlangError -> false - end - - @doc """ - Checks if OTP 28+ raw mode is available. - """ - @spec raw_mode_available?() :: boolean() - def raw_mode_available? do - function_exported?(:shell, :start_interactive, 1) - end - - @doc """ - Checks if PTY support is available (Unix only). - """ - @spec pty_available?() :: boolean() - def pty_available? do - case :os.type() do - {:unix, _} -> true - _ -> false - end - end - - @doc """ - Gets the current terminal size or default if not available. - """ - @spec get_terminal_size_or_default() :: {pos_integer(), pos_integer()} - def get_terminal_size_or_default do - case Terminal.get_terminal_size() do - {:ok, size} -> size - {:error, _} -> {24, 80} - end - end - - @doc """ - Simulates a crash of the terminal GenServer and verifies recovery. - """ - @spec simulate_crash_and_recover() :: :ok | {:error, term()} - def simulate_crash_and_recover do - pid = Process.whereis(TermUI.Terminal) - - if pid do - # Set up monitor before killing to avoid race condition - ref = Process.monitor(pid) - - # Kill the process - Process.exit(pid, :kill) - - # Wait for it to die - receive do - {:DOWN, ^ref, :process, ^pid, _reason} -> :ok - after - 1000 -> {:error, :timeout_waiting_for_crash} - end - - # Restart the terminal - case start_terminal() do - {:ok, _} -> :ok - error -> error - end - else - {:error, :terminal_not_running} - end - end - - @doc """ - Sets environment variables for testing capabilities. - - ## Important Notes - - Environment variables are global to the Erlang VM, not per-process. This - function uses try/after to ensure cleanup, but be aware: - - - Tests using this must run with `async: false` to prevent race conditions - - Original values are restored even if the function raises an exception - - In rare cases of catastrophic crashes (e.g., VM exit), env vars may not - be restored - this is acceptable for test environments - - ## Example - - with_env(%{"TERM" => "xterm-256color"}, fn -> - caps = Capabilities.detect() - assert caps.max_colors >= 256 - end) - """ - @spec with_env(map(), function()) :: term() - def with_env(env_vars, fun) when is_map(env_vars) and is_function(fun, 0) do - # Save original values - originals = - for {key, _value} <- env_vars, into: %{} do - {key, System.get_env(key)} - end - - # Set new values - for {key, value} <- env_vars do - if value do - System.put_env(key, value) - else - System.delete_env(key) - end - end - - try do - fun.() - after - # Restore original values - for {key, original} <- originals do - if original do - System.put_env(key, original) - else - System.delete_env(key) - end - end - end - end - - @doc """ - Creates a mock terminal environment with specific capabilities. - """ - @spec mock_terminal_env(atom()) :: map() - def mock_terminal_env(:xterm_256color) do - %{ - "TERM" => "xterm-256color", - "COLORTERM" => nil, - "TERM_PROGRAM" => nil - } - end - - def mock_terminal_env(:truecolor) do - %{ - "TERM" => "xterm-256color", - "COLORTERM" => "truecolor", - "TERM_PROGRAM" => nil - } - end - - def mock_terminal_env(:basic) do - %{ - "TERM" => "xterm", - "COLORTERM" => nil, - "TERM_PROGRAM" => nil - } - end - - def mock_terminal_env(:iterm2) do - %{ - "TERM" => "xterm-256color", - "COLORTERM" => nil, - "TERM_PROGRAM" => "iTerm.app" - } - end - - def mock_terminal_env(:windows_terminal) do - %{ - "TERM" => nil, - "COLORTERM" => nil, - "WT_SESSION" => "true", - "TERM_PROGRAM" => nil - } - end - - @doc """ - Parses input bytes and returns events and remaining bytes. - - Simplifies the Parser API for tests by discarding the parser state. - - ## Example - - {events, remaining} = parse("\\e[A") - assert [%KeyEvent{key: :up}] = events - """ - @spec parse(binary()) :: {list(), binary()} - def parse(input) do - EscapeParser.parse(input) - end -end diff --git a/test/support/runtime_test_case.ex b/test/support/runtime_test_case.ex deleted file mode 100644 index 947785d1..00000000 --- a/test/support/runtime_test_case.ex +++ /dev/null @@ -1,60 +0,0 @@ -defmodule TermUI.RuntimeTestCase do - @moduledoc """ - Test case helper for Runtime-based integration tests. - - Provides common setup, aliases, and helpers for testing components - using the TermUI.Runtime with the Elm Architecture pattern. - - ## Usage - - defmodule MyIntegrationTest do - use TermUI.RuntimeTestCase - - test "my component works" do - runtime = start_test_runtime(MyComponent) - - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - end - end - - ## Provided Helpers - - - `start_test_runtime/1` - Starts a runtime with automatic cleanup on test exit - - Standard aliases for `Runtime`, `Event`, `Command` - """ - - defmacro __using__(_opts) do - quote do - use ExUnit.Case, async: false - - alias TermUI.Command - alias TermUI.Event - alias TermUI.Runtime - - @doc """ - Starts a runtime with the given component and automatic cleanup. - - The runtime is automatically shut down when the test exits, even if - the test fails. This ensures no zombie processes are left behind. - - ## Example - - runtime = start_test_runtime(MyComponent) - # runtime will be cleaned up automatically - """ - defp start_test_runtime(component) do - {:ok, runtime} = Runtime.start_link(root: component, skip_terminal: true) - - on_exit(fn -> - if Process.alive?(runtime), do: Runtime.shutdown(runtime) - end) - - runtime - end - end - end -end diff --git a/test/support/test_components.ex b/test/support/test_components.ex deleted file mode 100644 index 3b98c6cc..00000000 --- a/test/support/test_components.ex +++ /dev/null @@ -1,87 +0,0 @@ -defmodule TermUI.Test.Components do - @moduledoc """ - Shared test components for integration testing. - - These components are designed to work with ComponentHarness and use - a simplified API (bare maps instead of {:ok, state} tuples). - """ - - defmodule Counter do - @moduledoc """ - Simple counter component for testing state updates. - """ - import TermUI.Component.Helpers - - alias TermUI.Event - - def init(props) do - %{count: Keyword.get(props, :initial, 0)} - end - - def render(state) do - text("Count: #{state.count}") - end - - def handle_event(%Event.Key{key: :up}, state) do - {:noreply, %{state | count: state.count + 1}} - end - - def handle_event(%Event.Key{key: :down}, state) do - {:noreply, %{state | count: max(0, state.count - 1)}} - end - - def handle_event(_event, state) do - {:noreply, state} - end - end - - defmodule TextInput do - @moduledoc """ - Simple text input component for testing character input. - """ - import TermUI.Component.Helpers - - alias TermUI.Event - - def init(_props), do: %{text: ""} - - def render(state), do: text(state.text) - - def handle_event(%Event.Key{char: char}, state) when char != nil do - {:noreply, %{state | text: state.text <> char}} - end - - def handle_event(_event, state), do: {:noreply, state} - end - - defmodule Label do - @moduledoc """ - Simple label component for testing static text display. - """ - import TermUI.Component.Helpers - - def init(props), do: %{text: Keyword.get(props, :text, "")} - def render(state), do: text(state.text) - end - - defmodule Toggle do - @moduledoc """ - Simple toggle component for testing boolean state. - """ - import TermUI.Component.Helpers - - alias TermUI.Event - - def init(props), do: %{enabled: Keyword.get(props, :enabled, false)} - - def render(state) do - text(if state.enabled, do: "[x] Enabled", else: "[ ] Disabled") - end - - def handle_event(%Event.Key{key: :enter}, state) do - {:noreply, %{state | enabled: not state.enabled}} - end - - def handle_event(_event, state), do: {:noreply, state} - end -end diff --git a/test/support/test_factories.ex b/test/support/test_factories.ex deleted file mode 100644 index aff2e383..00000000 --- a/test/support/test_factories.ex +++ /dev/null @@ -1,83 +0,0 @@ -defmodule TermUI.Test.Factories do - @moduledoc """ - Test data factories for common test scenarios. - - Provides helper functions to generate test data consistently - across test files. - """ - - alias TermUI.Layout.Constraint - alias TermUI.Widgets.Table.Column - - @doc """ - Generates sample table data with id and name fields. - - ## Examples - - data = sample_table_data() # 100 rows - data = sample_table_data(1000) # 1000 rows - """ - @spec sample_table_data(pos_integer()) :: [map()] - def sample_table_data(count \\ 100) do - for i <- 1..count, do: %{id: i, name: "Item #{i}"} - end - - @doc """ - Returns default table columns for testing. - """ - @spec default_table_columns() :: [Column.t()] - def default_table_columns do - [ - Column.new(:id, "ID", width: Constraint.length(10)), - Column.new(:name, "Name", width: Constraint.length(30)) - ] - end - - @doc """ - Generates sample chart data. - - ## Examples - - data = sample_chart_data() - # => [%{label: "A", value: 10}, %{label: "B", value: 25}, ...] - """ - @spec sample_chart_data() :: [map()] - def sample_chart_data do - [ - %{label: "A", value: 10}, - %{label: "B", value: 25}, - %{label: "C", value: 15} - ] - end - - @doc """ - Generates sample sparkline values. - """ - @spec sample_sparkline_values() :: [number()] - def sample_sparkline_values do - [5, 10, 3, 8, 15, 7, 12] - end - - @doc """ - Returns sample tab definitions. - """ - @spec sample_tabs() :: [map()] - def sample_tabs do - [ - %{id: :tab1, label: "Tab 1"}, - %{id: :tab2, label: "Tab 2"}, - %{id: :tab3, label: "Tab 3"} - ] - end - - @doc """ - Returns sample dialog buttons. - """ - @spec sample_dialog_buttons() :: [map()] - def sample_dialog_buttons do - [ - %{id: :ok, label: "OK"}, - %{id: :cancel, label: "Cancel"} - ] - end -end diff --git a/test/term_ui/app_test.exs b/test/term_ui/app_test.exs deleted file mode 100644 index 94f2634c..00000000 --- a/test/term_ui/app_test.exs +++ /dev/null @@ -1,466 +0,0 @@ -defmodule TermUI.AppTest do - use ExUnit.Case, async: false - - alias TermUI.App - alias TermUI.Command - - # Clean up persistent_term values between tests - setup do - # Store original values - original_backend_mode = :persistent_term.get(:term_ui_backend_mode, :not_set) - original_capabilities = :persistent_term.get(:term_ui_capabilities, :not_set) - - on_exit(fn -> - # Restore or clean up persistent_term - if original_backend_mode != :not_set do - :persistent_term.put(:term_ui_backend_mode, original_backend_mode) - else - :persistent_term.erase(:term_ui_backend_mode) - end - - if original_capabilities != :not_set do - :persistent_term.put(:term_ui_capabilities, original_capabilities) - else - :persistent_term.erase(:term_ui_capabilities) - end - end) - - :ok - end - - # Simple test component - defmodule SimpleCounter do - use TermUI.Elm - - def init(_opts), do: %{count: 0} - - def event_to_msg(_, _), do: :ignore - def update(_, state), do: {state, []} - def view(state), do: {:text, "Count: " <> to_string(state.count)} - end - - defmodule InitCommandCounter do - use TermUI.Elm - - def init(_opts) do - {:ok, %{ticks: 0}, [Command.timer(0, :tick)]} - end - - def event_to_msg(_, _), do: :ignore - - def update(:tick, state), do: {%{state | ticks: state.ticks + 1}, []} - def update(_, state), do: {state, []} - def view(_state), do: {:text, "Init command"} - end - - defmodule RuntimeCommandCounter do - use TermUI.Elm - - def init(_opts), do: %{ticks: 0, pongs: 0} - - def event_to_msg(_, _), do: :ignore - - def update(:start_timer, state) do - {state, [Command.timer(0, :tick)]} - end - - def update(:start_send_after, state) do - {state, [Command.send_after(:root, :pong, 1)]} - end - - def update(:tick, state), do: {%{state | ticks: state.ticks + 1}, []} - def update(:pong, state), do: {%{state | pongs: state.pongs + 1}, []} - def update(_, state), do: {state, []} - def view(_state), do: {:text, "Runtime commands"} - end - - describe "start/2" do - test "starts application and returns {:ok, pid}" do - {:ok, pid} = App.start(SimpleCounter, skip_terminal: true) - - assert is_pid(pid) - assert Process.alive?(pid) - - # Clean up - GenServer.stop(pid) - end - - test "passes options to Runtime" do - {:ok, pid} = - App.start(SimpleCounter, - skip_terminal: true, - backend: :tty, - render_interval: 100 - ) - - assert is_pid(pid) - - # Verify options were applied - state = TermUI.Runtime.get_state(pid) - assert state.render_interval == 100 - - # Clean up - GenServer.stop(pid) - end - - test "accepts name option for registered process" do - {:ok, _pid} = - App.start(SimpleCounter, - skip_terminal: true, - name: :test_app - ) - - # Verify we can access by name - state = TermUI.Runtime.get_state(:test_app) - assert state.root_module == SimpleCounter - - # Clean up - GenServer.stop(:test_app) - end - - test "returns error when Runtime fails to start" do - # This would fail if we pass invalid options - # For now, we just verify the happy path - assert {:ok, _pid} = App.start(SimpleCounter, skip_terminal: true) - end - end - - describe "run/2" do - test "runs application to completion" do - # This test uses a task to avoid blocking the test runner - task = - Task.async(fn -> - # Create a component that quits immediately via handle_info - defmodule QuickQuit do - use TermUI.Elm - - def init(_opts) do - %{count: 0} - end - - def event_to_msg(_, _), do: :ignore - - def update(_, state), do: {state, []} - - def view(_state), do: {:text, "Quick"} - - def handle_info(:quit_now, state) do - {state, [:quit]} - end - end - - # Start the app and then send quit message - {:ok, pid} = App.start(QuickQuit, skip_terminal: true) - send(pid, :quit_now) - - # Wait for it to stop - ref = Process.monitor(pid) - - receive do - {:DOWN, ^ref, :process, ^pid, :normal} -> - {:ok, :exited_normally} - - {:DOWN, ^ref, :process, ^pid, reason} -> - {:error, reason} - end - end) - - assert {:ok, :exited_normally} = Task.await(task, 5000) - end - - test "handles crash and cleans up terminal" do - # This test verifies cleanup on crash - # Note: With skip_terminal: true, the Runtime catches errors gracefully - # So we just verify the run completes - task = - Task.async(fn -> - defmodule GracefulExit do - use TermUI.Elm - - def init(_opts) do - %{count: 0} - end - - def event_to_msg(_, _), do: :ignore - - def update(_, state), do: {state, []} - - def view(_state), do: {:text, "Graceful"} - - def handle_info(:quit_now, state) do - {state, [:quit]} - end - end - - # Start the app and then send quit message - {:ok, pid} = App.start(GracefulExit, skip_terminal: true) - send(pid, :quit_now) - - # Wait for it to stop - ref = Process.monitor(pid) - - receive do - {:DOWN, ^ref, :process, ^pid, :normal} -> - {:ok, :exited_normally} - - {:DOWN, ^ref, :process, ^pid, reason} -> - {:error, reason} - end - end) - - assert {:ok, :exited_normally} = Task.await(task, 5000) - end - - test "accepts and passes options through" do - task = - Task.async(fn -> - defmodule QuickQuit2 do - use TermUI.Elm - - def init(_opts) do - %{count: 0} - end - - def event_to_msg(_, _), do: :ignore - - def update(_, state), do: {state, []} - - def view(_state), do: {:text, "Quick"} - - def handle_info(:quit_now, state) do - {state, [:quit]} - end - end - - # Start the app with backend option and then send quit message - {:ok, pid} = App.start(QuickQuit2, skip_terminal: true, backend: :tty) - send(pid, :quit_now) - - # Wait for it to stop - ref = Process.monitor(pid) - - receive do - {:DOWN, ^ref, :process, ^pid, :normal} -> - {:ok, :exited_normally} - - {:DOWN, ^ref, :process, ^pid, reason} -> - {:error, reason} - end - end) - - assert {:ok, :exited_normally} = Task.await(task, 5000) - end - end - - describe "root command execution" do - test "runs startup commands returned from init/1" do - {:ok, pid} = App.start(InitCommandCounter, skip_terminal: true) - - Process.sleep(25) - :ok = TermUI.Runtime.sync(pid) - - state = TermUI.Runtime.get_state(pid) - assert state.root_state.ticks == 1 - - GenServer.stop(pid) - end - - test "executes runtime commands returned from update/2" do - {:ok, pid} = App.start(RuntimeCommandCounter, skip_terminal: true) - - TermUI.Runtime.send_message(pid, :root, :start_timer) - Process.sleep(25) - :ok = TermUI.Runtime.sync(pid) - - state = TermUI.Runtime.get_state(pid) - assert state.root_state.ticks == 1 - - GenServer.stop(pid) - end - - test "routes send_after results to the target component message queue" do - {:ok, pid} = App.start(RuntimeCommandCounter, skip_terminal: true) - - TermUI.Runtime.send_message(pid, :root, :start_send_after) - Process.sleep(25) - :ok = TermUI.Runtime.sync(pid) - - state = TermUI.Runtime.get_state(pid) - assert state.root_state.pongs == 1 - - GenServer.stop(pid) - end - end - - describe "backend_mode/0" do - test "returns nil when no app is running" do - # Ensure no app is running - :persistent_term.erase(:term_ui_backend_mode) - - assert App.backend_mode() == nil - end - - test "returns :raw when raw backend is selected" do - :persistent_term.put(:term_ui_backend_mode, :raw) - - assert App.backend_mode() == :raw - end - - test "returns :tty when TTY backend is selected" do - :persistent_term.put(:term_ui_backend_mode, :tty) - - assert App.backend_mode() == :tty - end - - test "returns backend mode after starting app" do - {:ok, pid} = - App.start(SimpleCounter, - skip_terminal: true, - backend: :tty - ) - - # When skip_terminal is true, backend mode is :skip - assert App.backend_mode() == :skip - - # Clean up - GenServer.stop(pid) - end - end - - describe "supports?/1" do - setup do - # Set up some default capabilities - :persistent_term.put(:term_ui_capabilities, %{ - unicode: true, - mouse: true, - colors: :true_color - }) - - :ok - end - - test "returns true for unicode when supported" do - assert App.supports?(:unicode) == true - end - - test "returns false for unicode when not supported" do - :persistent_term.put(:term_ui_capabilities, %{unicode: false}) - - assert App.supports?(:unicode) == false - end - - test "returns true for mouse when supported" do - assert App.supports?(:mouse) == true - end - - test "returns false for mouse when not supported" do - :persistent_term.put(:term_ui_capabilities, %{mouse: false}) - - assert App.supports?(:mouse) == false - end - - test "returns true for colors when not monochrome" do - assert App.supports?(:colors) == true - end - - test "returns false for colors when monochrome" do - :persistent_term.put(:term_ui_capabilities, %{colors: :monochrome}) - - assert App.supports?(:colors) == false - end - - test "returns true for true_color when supported" do - assert App.supports?(:true_color) == true - end - - test "returns false for true_color when not supported" do - :persistent_term.put(:term_ui_capabilities, %{colors: :color_256}) - - assert App.supports?(:true_color) == false - end - - test "returns true for color_256 when 256 colors or better" do - :persistent_term.put(:term_ui_capabilities, %{colors: :color_256}) - assert App.supports?(:color_256) == true - - :persistent_term.put(:term_ui_capabilities, %{colors: :true_color}) - assert App.supports?(:color_256) == true - end - - test "returns false for color_256 when only 16 colors" do - :persistent_term.put(:term_ui_capabilities, %{colors: :color_16}) - - assert App.supports?(:color_256) == false - end - - test "returns true for color_16 when 16 colors or better" do - :persistent_term.put(:term_ui_capabilities, %{colors: :color_16}) - assert App.supports?(:color_16) == true - - :persistent_term.put(:term_ui_capabilities, %{colors: :color_256}) - assert App.supports?(:color_16) == true - end - - test "returns false for color_16 when monochrome" do - :persistent_term.put(:term_ui_capabilities, %{colors: :monochrome}) - - assert App.supports?(:color_16) == false - end - - test "returns true for monochrome when monochrome" do - :persistent_term.put(:term_ui_capabilities, %{colors: :monochrome}) - - assert App.supports?(:monochrome) == true - end - - test "returns false for monochrome when colors supported" do - assert App.supports?(:monochrome) == false - end - - test "returns false for unknown queries" do - assert App.supports?(:unknown_capability) == false - end - - test "returns false when no capabilities are stored" do - :persistent_term.erase(:term_ui_capabilities) - - # Should return defaults (unicode: true, others: false) - assert App.supports?(:unicode) == true - assert App.supports?(:mouse) == false - end - end - - describe "shutdown/0" do - test "shuts down running Runtime process" do - {:ok, _pid} = - App.start(SimpleCounter, - skip_terminal: true, - name: :test_shutdown - ) - - assert Process.alive?(Process.whereis(:test_shutdown)) - - assert :ok = App.shutdown(:test_shutdown) - - # Wait for the process to actually terminate - :timer.sleep(100) - refute Process.whereis(:test_shutdown) - end - - test "returns error when process not found" do - assert {:error, :not_found} = App.shutdown(:nonexistent_process) - end - - test "accepts pid for shutdown" do - {:ok, pid} = App.start(SimpleCounter, skip_terminal: true) - - assert Process.alive?(pid) - - assert :ok = App.shutdown(pid) - - # Give it a moment to shut down - Process.sleep(50) - refute Process.alive?(pid) - end - end -end diff --git a/test/term_ui/backend/config_test.exs b/test/term_ui/backend/config_test.exs deleted file mode 100644 index 4657c4ce..00000000 --- a/test/term_ui/backend/config_test.exs +++ /dev/null @@ -1,601 +0,0 @@ -defmodule TermUI.Backend.ConfigTest do - use ExUnit.Case, async: false - - alias TermUI.Backend.Config - - # Note: async: false because we modify Application env - - setup do - # Store original values - original_backend = Application.get_env(:term_ui, :backend) - original_character_set = Application.get_env(:term_ui, :character_set) - original_fallback = Application.get_env(:term_ui, :fallback_character_set) - original_tty_opts = Application.get_env(:term_ui, :tty_opts) - original_raw_opts = Application.get_env(:term_ui, :raw_opts) - - on_exit(fn -> - # Restore original values - restore_env(:backend, original_backend) - restore_env(:character_set, original_character_set) - restore_env(:fallback_character_set, original_fallback) - restore_env(:tty_opts, original_tty_opts) - restore_env(:raw_opts, original_raw_opts) - end) - - # Clear all config for clean test state - Application.delete_env(:term_ui, :backend) - Application.delete_env(:term_ui, :character_set) - Application.delete_env(:term_ui, :fallback_character_set) - Application.delete_env(:term_ui, :tty_opts) - Application.delete_env(:term_ui, :raw_opts) - - :ok - end - - defp restore_env(key, nil), do: Application.delete_env(:term_ui, key) - defp restore_env(key, value), do: Application.put_env(:term_ui, key, value) - - describe "module structure" do - test "module compiles successfully" do - assert Code.ensure_loaded?(Config) - end - - test "exports expected functions" do - assert function_exported?(Config, :get_backend, 0) - assert function_exported?(Config, :get_character_set, 0) - assert function_exported?(Config, :get_fallback_character_set, 0) - assert function_exported?(Config, :get_tty_opts, 0) - assert function_exported?(Config, :get_raw_opts, 0) - end - end - - describe "get_backend/0" do - test "returns :auto when no config present" do - assert Config.get_backend() == :auto - end - - test "returns :auto when explicitly configured" do - Application.put_env(:term_ui, :backend, :auto) - assert Config.get_backend() == :auto - end - - test "returns configured module when set to Raw backend" do - Application.put_env(:term_ui, :backend, TermUI.Backend.Raw) - assert Config.get_backend() == TermUI.Backend.Raw - end - - test "returns configured module when set to TTY backend" do - Application.put_env(:term_ui, :backend, TermUI.Backend.TTY) - assert Config.get_backend() == TermUI.Backend.TTY - end - - test "returns configured module when set to Test backend" do - Application.put_env(:term_ui, :backend, TermUI.Backend.Test) - assert Config.get_backend() == TermUI.Backend.Test - end - - test "returns any configured atom value" do - Application.put_env(:term_ui, :backend, SomeCustomBackend) - assert Config.get_backend() == SomeCustomBackend - end - end - - describe "get_character_set/0" do - test "returns :unicode when no config present" do - assert Config.get_character_set() == :unicode - end - - test "returns :unicode when explicitly configured" do - Application.put_env(:term_ui, :character_set, :unicode) - assert Config.get_character_set() == :unicode - end - - test "returns :ascii when configured" do - Application.put_env(:term_ui, :character_set, :ascii) - assert Config.get_character_set() == :ascii - end - end - - describe "get_fallback_character_set/0" do - test "returns :ascii when no config present" do - assert Config.get_fallback_character_set() == :ascii - end - - test "returns :ascii when explicitly configured" do - Application.put_env(:term_ui, :fallback_character_set, :ascii) - assert Config.get_fallback_character_set() == :ascii - end - - test "returns :unicode when configured" do - Application.put_env(:term_ui, :fallback_character_set, :unicode) - assert Config.get_fallback_character_set() == :unicode - end - end - - describe "get_tty_opts/0" do - test "returns [line_mode: :full_redraw] when no config present" do - assert Config.get_tty_opts() == [line_mode: :full_redraw] - end - - test "returns configured keyword list" do - Application.put_env(:term_ui, :tty_opts, line_mode: :incremental) - assert Config.get_tty_opts() == [line_mode: :incremental] - end - - test "returns custom options" do - opts = [line_mode: :full_redraw, custom_option: :value] - Application.put_env(:term_ui, :tty_opts, opts) - assert Config.get_tty_opts() == opts - end - - test "returns empty list when configured as empty" do - Application.put_env(:term_ui, :tty_opts, []) - assert Config.get_tty_opts() == [] - end - end - - describe "get_raw_opts/0" do - test "returns [alternate_screen: true] when no config present" do - assert Config.get_raw_opts() == [alternate_screen: true] - end - - test "returns configured keyword list with alternate_screen: false" do - Application.put_env(:term_ui, :raw_opts, alternate_screen: false) - assert Config.get_raw_opts() == [alternate_screen: false] - end - - test "returns custom options" do - opts = [alternate_screen: true, mouse: :sgr] - Application.put_env(:term_ui, :raw_opts, opts) - assert Config.get_raw_opts() == opts - end - - test "returns empty list when configured as empty" do - Application.put_env(:term_ui, :raw_opts, []) - assert Config.get_raw_opts() == [] - end - end - - describe "documentation" do - test "module has moduledoc" do - {:docs_v1, _, :elixir, _, module_doc, _, _} = Code.fetch_docs(Config) - assert module_doc != :none - assert module_doc != :hidden - end - - test "get_backend/0 has docs" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(Config) - - func_docs = - docs - |> Enum.filter(fn - {{:function, :get_backend, 0}, _, _, _, _} -> true - _ -> false - end) - - assert length(func_docs) == 1 - end - - test "get_character_set/0 has docs" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(Config) - - func_docs = - docs - |> Enum.filter(fn - {{:function, :get_character_set, 0}, _, _, _, _} -> true - _ -> false - end) - - assert length(func_docs) == 1 - end - - test "get_fallback_character_set/0 has docs" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(Config) - - func_docs = - docs - |> Enum.filter(fn - {{:function, :get_fallback_character_set, 0}, _, _, _, _} -> true - _ -> false - end) - - assert length(func_docs) == 1 - end - - test "get_tty_opts/0 has docs" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(Config) - - func_docs = - docs - |> Enum.filter(fn - {{:function, :get_tty_opts, 0}, _, _, _, _} -> true - _ -> false - end) - - assert length(func_docs) == 1 - end - - test "get_raw_opts/0 has docs" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(Config) - - func_docs = - docs - |> Enum.filter(fn - {{:function, :get_raw_opts, 0}, _, _, _, _} -> true - _ -> false - end) - - assert length(func_docs) == 1 - end - end - - describe "validate!/0" do - test "returns :ok with default configuration" do - assert Config.validate!() == :ok - end - - test "returns :ok with all valid backends" do - for backend <- [:auto, TermUI.Backend.Raw, TermUI.Backend.TTY, TermUI.Backend.Test] do - Application.put_env(:term_ui, :backend, backend) - assert Config.validate!() == :ok - end - end - - test "raises for invalid backend" do - Application.put_env(:term_ui, :backend, :invalid) - - assert_raise ArgumentError, ~r/invalid :backend value: :invalid/, fn -> - Config.validate!() - end - end - - test "raises for invalid backend with descriptive message" do - Application.put_env(:term_ui, :backend, SomeUnknownBackend) - - error = - assert_raise ArgumentError, fn -> - Config.validate!() - end - - assert error.message =~ "invalid :backend value: SomeUnknownBackend" - assert error.message =~ "expected one of" - assert error.message =~ ":auto" - end - - test "raises for invalid character_set" do - Application.put_env(:term_ui, :character_set, :utf8) - - assert_raise ArgumentError, ~r/invalid :character_set value: :utf8/, fn -> - Config.validate!() - end - end - - test "raises for invalid fallback_character_set" do - Application.put_env(:term_ui, :fallback_character_set, :latin1) - - assert_raise ArgumentError, ~r/invalid :fallback_character_set value: :latin1/, fn -> - Config.validate!() - end - end - - test "raises for invalid tty_opts (not a list)" do - Application.put_env(:term_ui, :tty_opts, :invalid) - - assert_raise ArgumentError, ~r/invalid :tty_opts value: :invalid/, fn -> - Config.validate!() - end - end - - test "raises for invalid line_mode in tty_opts" do - Application.put_env(:term_ui, :tty_opts, line_mode: :partial) - - assert_raise ArgumentError, ~r/invalid :line_mode value in :tty_opts: :partial/, fn -> - Config.validate!() - end - end - - test "accepts valid line_modes" do - for mode <- [:full_redraw, :incremental] do - Application.put_env(:term_ui, :tty_opts, line_mode: mode) - assert Config.validate!() == :ok - end - end - - test "accepts tty_opts without line_mode" do - Application.put_env(:term_ui, :tty_opts, custom_option: :value) - assert Config.validate!() == :ok - end - - test "raises for invalid raw_opts (not a list)" do - Application.put_env(:term_ui, :raw_opts, "not a list") - - assert_raise ArgumentError, ~r/invalid :raw_opts value/, fn -> - Config.validate!() - end - end - - test "accepts empty lists for opts" do - Application.put_env(:term_ui, :tty_opts, []) - Application.put_env(:term_ui, :raw_opts, []) - assert Config.validate!() == :ok - end - - test "raises for non-keyword list tty_opts" do - Application.put_env(:term_ui, :tty_opts, [1, 2, 3]) - - assert_raise ArgumentError, ~r/invalid :tty_opts value: \[1, 2, 3\]/, fn -> - Config.validate!() - end - end - - test "raises for non-keyword list raw_opts" do - Application.put_env(:term_ui, :raw_opts, [:a, :b, :c]) - - assert_raise ArgumentError, ~r/invalid :raw_opts value: \[:a, :b, :c\]/, fn -> - Config.validate!() - end - end - - test "raises for invalid alternate_screen type (atom)" do - Application.put_env(:term_ui, :raw_opts, alternate_screen: :maybe) - - assert_raise ArgumentError, ~r/invalid :alternate_screen value in :raw_opts: :maybe/, fn -> - Config.validate!() - end - end - - test "raises for invalid alternate_screen type (string)" do - Application.put_env(:term_ui, :raw_opts, alternate_screen: "true") - - assert_raise ArgumentError, ~r/invalid :alternate_screen value in :raw_opts: "true"/, fn -> - Config.validate!() - end - end - - test "raises for invalid alternate_screen type (integer)" do - Application.put_env(:term_ui, :raw_opts, alternate_screen: 1) - - assert_raise ArgumentError, ~r/invalid :alternate_screen value in :raw_opts: 1/, fn -> - Config.validate!() - end - end - - test "accepts valid alternate_screen boolean values" do - for value <- [true, false] do - Application.put_env(:term_ui, :raw_opts, alternate_screen: value) - assert Config.validate!() == :ok - end - end - - test "accepts raw_opts without alternate_screen" do - Application.put_env(:term_ui, :raw_opts, custom_option: :value) - assert Config.validate!() == :ok - end - end - - describe "valid?/0" do - test "returns true with default configuration" do - assert Config.valid?() == true - end - - test "returns true with valid configuration" do - Application.put_env(:term_ui, :backend, TermUI.Backend.Raw) - Application.put_env(:term_ui, :character_set, :ascii) - assert Config.valid?() == true - end - - test "returns false with invalid backend" do - Application.put_env(:term_ui, :backend, :invalid) - assert Config.valid?() == false - end - - test "returns false with invalid character_set" do - Application.put_env(:term_ui, :character_set, :utf16) - assert Config.valid?() == false - end - - test "returns false with invalid fallback_character_set" do - Application.put_env(:term_ui, :fallback_character_set, :unknown) - assert Config.valid?() == false - end - - test "returns false with invalid tty_opts" do - Application.put_env(:term_ui, :tty_opts, :not_a_list) - assert Config.valid?() == false - end - - test "returns false with invalid line_mode" do - Application.put_env(:term_ui, :tty_opts, line_mode: :bad) - assert Config.valid?() == false - end - - test "returns false with invalid raw_opts" do - Application.put_env(:term_ui, :raw_opts, %{not: :a_list}) - assert Config.valid?() == false - end - - test "does not raise exceptions" do - Application.put_env(:term_ui, :backend, :totally_invalid) - - # Should not raise, just return false - result = Config.valid?() - assert result == false - end - - test "returns false for non-keyword list tty_opts" do - Application.put_env(:term_ui, :tty_opts, [1, 2, 3]) - assert Config.valid?() == false - end - - test "returns false for non-keyword list raw_opts" do - Application.put_env(:term_ui, :raw_opts, [:a, :b]) - assert Config.valid?() == false - end - - test "returns false for invalid alternate_screen type" do - Application.put_env(:term_ui, :raw_opts, alternate_screen: :maybe) - assert Config.valid?() == false - end - end - - describe "validation documentation" do - test "validate!/0 has docs" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(Config) - - func_docs = - docs - |> Enum.filter(fn - {{:function, :validate!, 0}, _, _, _, _} -> true - _ -> false - end) - - assert length(func_docs) == 1 - end - - test "valid?/0 has docs" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(Config) - - func_docs = - docs - |> Enum.filter(fn - {{:function, :valid?, 0}, _, _, _, _} -> true - _ -> false - end) - - assert length(func_docs) == 1 - end - end - - describe "typical usage patterns" do - test "all defaults work together" do - assert Config.get_backend() == :auto - assert Config.get_character_set() == :unicode - assert Config.get_fallback_character_set() == :ascii - assert Config.get_tty_opts() == [line_mode: :full_redraw] - assert Config.get_raw_opts() == [alternate_screen: true] - end - - test "full configuration example" do - # Simulate a full config.exs setup - Application.put_env(:term_ui, :backend, TermUI.Backend.Raw) - Application.put_env(:term_ui, :character_set, :ascii) - Application.put_env(:term_ui, :fallback_character_set, :ascii) - Application.put_env(:term_ui, :tty_opts, line_mode: :incremental) - Application.put_env(:term_ui, :raw_opts, alternate_screen: false) - - assert Config.get_backend() == TermUI.Backend.Raw - assert Config.get_character_set() == :ascii - assert Config.get_fallback_character_set() == :ascii - assert Config.get_tty_opts() == [line_mode: :incremental] - assert Config.get_raw_opts() == [alternate_screen: false] - end - - test "validate before using configuration" do - # Common pattern: validate at startup - Application.put_env(:term_ui, :backend, TermUI.Backend.TTY) - Application.put_env(:term_ui, :character_set, :unicode) - - assert Config.validate!() == :ok - - # Now safe to use - assert Config.get_backend() == TermUI.Backend.TTY - end - end - - describe "runtime_config/0" do - test "returns map with all expected keys" do - config = Config.runtime_config() - - assert is_map(config) - assert Map.has_key?(config, :backend) - assert Map.has_key?(config, :character_set) - assert Map.has_key?(config, :fallback_character_set) - assert Map.has_key?(config, :tty_opts) - assert Map.has_key?(config, :raw_opts) - end - - test "returns default values when no config present" do - config = Config.runtime_config() - - assert config.backend == :auto - assert config.character_set == :unicode - assert config.fallback_character_set == :ascii - assert config.tty_opts == [line_mode: :full_redraw] - assert config.raw_opts == [alternate_screen: true] - end - - test "returns configured values" do - Application.put_env(:term_ui, :backend, TermUI.Backend.Raw) - Application.put_env(:term_ui, :character_set, :ascii) - Application.put_env(:term_ui, :fallback_character_set, :unicode) - Application.put_env(:term_ui, :tty_opts, line_mode: :incremental) - Application.put_env(:term_ui, :raw_opts, alternate_screen: false) - - config = Config.runtime_config() - - assert config.backend == TermUI.Backend.Raw - assert config.character_set == :ascii - assert config.fallback_character_set == :unicode - assert config.tty_opts == [line_mode: :incremental] - assert config.raw_opts == [alternate_screen: false] - end - - test "values match individual getter functions" do - Application.put_env(:term_ui, :backend, TermUI.Backend.TTY) - - config = Config.runtime_config() - - assert config.backend == Config.get_backend() - assert config.character_set == Config.get_character_set() - assert config.fallback_character_set == Config.get_fallback_character_set() - assert config.tty_opts == Config.get_tty_opts() - assert config.raw_opts == Config.get_raw_opts() - end - - test "raises when configuration is invalid" do - Application.put_env(:term_ui, :backend, :invalid_backend) - - assert_raise ArgumentError, ~r/invalid :backend value/, fn -> - Config.runtime_config() - end - end - - test "raises for invalid character_set" do - Application.put_env(:term_ui, :character_set, :utf8) - - assert_raise ArgumentError, ~r/invalid :character_set value/, fn -> - Config.runtime_config() - end - end - - test "raises for invalid tty_opts" do - Application.put_env(:term_ui, :tty_opts, :not_a_list) - - assert_raise ArgumentError, ~r/invalid :tty_opts value/, fn -> - Config.runtime_config() - end - end - - test "returns only the expected keys (no extra keys)" do - config = Config.runtime_config() - - expected_keys = [:backend, :character_set, :fallback_character_set, :tty_opts, :raw_opts] - assert Enum.sort(Map.keys(config)) == Enum.sort(expected_keys) - end - end - - describe "runtime_config documentation" do - test "runtime_config/0 has docs" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(Config) - - func_docs = - docs - |> Enum.filter(fn - {{:function, :runtime_config, 0}, _, _, _, _} -> true - _ -> false - end) - - assert length(func_docs) == 1 - end - end -end diff --git a/test/term_ui/backend/frame_boundary_test.exs b/test/term_ui/backend/frame_boundary_test.exs new file mode 100644 index 00000000..41205320 --- /dev/null +++ b/test/term_ui/backend/frame_boundary_test.exs @@ -0,0 +1,201 @@ +defmodule TermUI.Backend.FrameBoundaryTest do + use ExUnit.Case, async: false + + import ExUnit.CaptureIO + + alias TermUI.Backend.{Raw, TTY} + alias TermUI.{Clipboard, Event, Frame, Style} + + test "production backends expose one Frame render callback" do + for backend <- [Raw, TTY] do + Code.ensure_loaded!(backend) + assert function_exported?(backend, :draw, 2) + assert function_exported?(backend, :poll_event, 2) + assert function_exported?(backend, :shutdown, 2) + refute function_exported?(backend, :draw_cells, 2) + end + end + + test "TTY draws a styled Unicode frame and tracks the canonical frame" do + frame = + Frame.from_rows( + [[{"界", Style.new(fg: :green, attrs: [:bold])}], "clear"], + 8, + 2, + cursor: {2, 2} + ) + + capture_io(fn -> + {:ok, state} = + TTY.init( + size: {2, 8}, + alternate_screen: false, + bracketed_paste: false, + focus_events: false, + line_mode: :incremental + ) + + assert {:ok, drawn_state} = TTY.draw(state, frame) + assert drawn_state.rendered_frame == frame + assert {:ok, flushed_state} = TTY.flush(drawn_state) + assert :ok = TTY.shutdown(flushed_state, :normal) + end) + end + + test "TTY restores terminal modes after a failure" do + output = + capture_io(fn -> + {:ok, state} = + TTY.init( + size: {2, 8}, + alternate_screen: true, + bracketed_paste: true, + focus_events: true + ) + + assert :ok = TTY.shutdown(state, {:application, :failed}) + end) + + assert output =~ "\e[?2004l" + assert output =~ "\e[?1004l" + assert output =~ "\e[0m" + assert output =~ "\e[?25h" + assert output =~ "\e[?1049l" + end + + test "TTY keeps exact columns after a wide grapheme" do + output = + capture_io(fn -> + {:ok, state} = + TTY.init( + size: {1, 4}, + alternate_screen: false, + bracketed_paste: false, + focus_events: false, + line_mode: :incremental + ) + + frame = Frame.from_rows(["界b"], 4, 1) + assert {:ok, state} = TTY.draw(state, frame) + TTY.shutdown(state, :normal) + end) + + assert output =~ "界" + refute output =~ "\e[1;3H" + refute output =~ "\e[1;4H" + end + + test "TTY emits standalone Escape and retains a fragmented large paste" do + paste = String.duplicate("A", 2_000) + + capture_io("\e", fn -> + {:ok, state} = + TTY.init( + size: {2, 8}, + alternate_screen: false, + bracketed_paste: false, + focus_events: false + ) + + assert {:ok, %Event.Key{key: :escape}, state} = TTY.poll_event(state, 10) + TTY.shutdown(state, :normal) + end) + + capture_io("\e[200~" <> paste <> "\e[201~", fn -> + {:ok, state} = + TTY.init( + size: {2, 8}, + alternate_screen: false, + bracketed_paste: false, + focus_events: false + ) + + assert {:ok, %Event.Paste{content: ^paste}, state} = await_event(TTY, state, 20) + TTY.shutdown(state, :normal) + end) + end + + test "Raw resize clears stale terminal content" do + output = + capture_io(fn -> + {:ok, state} = + Raw.init( + size: {2, 8}, + alternate_screen: false, + bracketed_paste: false, + focus_events: false + ) + + assert {:ok, _state} = Raw.resize(state, {1, 4}) + end) + + assert length(:binary.matches(output, "\e[2J")) >= 2 + end + + test "production backends return terminal write failures" do + for backend <- [Raw, TTY] do + {:ok, io} = StringIO.open("") + original = Process.group_leader() + Process.group_leader(self(), io) + + {:ok, state} = + backend.init( + size: {1, 4}, + alternate_screen: false, + bracketed_paste: false, + focus_events: false + ) + + Process.group_leader(self(), original) + StringIO.close(io) + + rejecting_io = spawn(fn -> reject_io_requests() end) + Process.group_leader(self(), rejecting_io) + + try do + assert {:error, {:terminal_write_failed, _reason}} = + backend.draw(state, Frame.from_rows(["ok"], 4, 1)) + after + Process.group_leader(self(), original) + Process.exit(rejecting_io, :kill) + end + end + end + + test "production backends write bounded clipboard operations through their state callback" do + for backend <- [Raw, TTY] do + output = + capture_io(fn -> + {:ok, state} = + backend.init( + size: {1, 4}, + alternate_screen: false, + bracketed_paste: false, + focus_events: false + ) + + assert {:ok, state} = backend.clipboard(state, Clipboard.operation("copy")) + assert :ok = backend.shutdown(state, :normal) + end) + + assert output =~ "\e]52;c;Y29weQ==\e\\" + end + end + + defp await_event(_backend, _state, 0), do: flunk("backend did not emit a complete event") + + defp await_event(backend, state, attempts) do + case backend.poll_event(state, 10) do + {:timeout, state} -> await_event(backend, state, attempts - 1) + result -> result + end + end + + defp reject_io_requests do + receive do + {:io_request, from, reply_as, _request} -> + send(from, {:io_reply, reply_as, {:error, :closed}}) + reject_io_requests() + end + end +end diff --git a/test/term_ui/backend/input_buffer_test.exs b/test/term_ui/backend/input_buffer_test.exs index 092cea47..fc50ac0c 100644 --- a/test/term_ui/backend/input_buffer_test.exs +++ b/test/term_ui/backend/input_buffer_test.exs @@ -2,6 +2,7 @@ defmodule TermUI.Backend.InputBufferTest do use ExUnit.Case, async: false alias TermUI.Backend.InputBuffer + alias TermUI.Terminal.EscapeParser setup do # Clear rate limits between tests @@ -198,6 +199,73 @@ defmodule TermUI.Backend.InputBufferTest do end end + describe "append_with_limit/4 with bracketed paste" do + @paste_start "\e[200~" + @paste_end "\e[201~" + @max_paste_size 8 * 1024 * 1024 + + test "collects a normal paste one byte at a time and keeps trailing input" do + state = %{input_buffer: "", paste_state: nil} + + state = + Enum.reduce( + String.to_charlist(@paste_start <> "hello" <> @paste_end <> "x"), + state, + fn byte, state -> + InputBuffer.append_with_limit(state, <>, :input_buffer, + paste_aware: true, + log: false + ) + end + ) + + assert state.paste_state == nil + assert state.input_buffer == @paste_start <> "hello" <> @paste_end <> "x" + + {events, remaining} = EscapeParser.parse(state.input_buffer) + assert [%TermUI.Event.Paste{content: "hello"}, %TermUI.Event.Text{text: "x"}] = events + assert remaining == "" + end + + test "keeps a paste body at the exact maximum size" do + body = :binary.copy("x", @max_paste_size) + state = %{input_buffer: "", paste_state: nil} + + state = + InputBuffer.append_with_limit(state, @paste_start <> body <> @paste_end, :input_buffer, + paste_aware: true, + log: false + ) + + assert state.paste_state == nil + assert state.input_buffer == @paste_start <> body <> @paste_end + end + + test "discards a one-byte oversized paste and recovers when the end marker is fragmented" do + state = %{input_buffer: "", paste_state: nil} + oversized = :binary.copy("x", @max_paste_size + 1) + + state = + InputBuffer.append_with_limit(state, @paste_start <> oversized, :input_buffer, + paste_aware: true, + log: false + ) + + assert %{mode: :discarding} = state.paste_state + + state = + Enum.reduce(String.to_charlist(@paste_end <> "z"), state, fn byte, state -> + InputBuffer.append_with_limit(state, <>, :input_buffer, + paste_aware: true, + log: false + ) + end) + + assert state.paste_state == nil + assert state.input_buffer == "z" + end + end + describe "clear_rate_limits/0" do import ExUnit.CaptureLog diff --git a/test/term_ui/backend/manager_test.exs b/test/term_ui/backend/manager_test.exs new file mode 100644 index 00000000..43cd606f --- /dev/null +++ b/test/term_ui/backend/manager_test.exs @@ -0,0 +1,49 @@ +defmodule TermUI.Backend.ManagerTest do + use ExUnit.Case, async: true + + alias TermUI.Backend.Manager + alias TermUI.Test.DeterministicBackend + + test "accepts a custom size poll interval" do + manager = start_manager(size_poll_interval: 75) + + assert %{size_poll_interval: 75} = :sys.get_state(manager) + assert :ok = Manager.close(manager, :normal) + end + + test "can disable size polling" do + manager = start_manager(size_poll_interval: :disabled) + + assert %{size_poll_interval: nil} = :sys.get_state(manager) + assert :ok = Manager.close(manager, :normal) + end + + test "rejects an unsafe size poll interval" do + previous = Process.flag(:trap_exit, true) + + try do + assert {:error, {:invalid_size_poll_interval, 20}} = + Manager.start_link( + self(), + {DeterministicBackend, owner: self()}, + size_poll_interval: 20 + ) + + refute_receive {:backend, :init, _pid} + after + Process.flag(:trap_exit, previous) + end + end + + defp start_manager(opts) do + assert {:ok, manager} = + Manager.start_link( + self(), + {DeterministicBackend, owner: self()}, + opts + ) + + assert_receive {:backend, :init, ^manager} + manager + end +end diff --git a/test/term_ui/backend/raw_integration_test.exs b/test/term_ui/backend/raw_integration_test.exs deleted file mode 100644 index df7307c1..00000000 --- a/test/term_ui/backend/raw_integration_test.exs +++ /dev/null @@ -1,405 +0,0 @@ -defmodule TermUI.Backend.RawIntegrationTest do - @moduledoc """ - Integration tests for TermUI.Backend.Raw module. - - These tests verify the Raw backend works correctly in realistic scenarios, - including interaction with existing TermUI components like Cell, Style, - Buffer, and Diff. - """ - - use ExUnit.Case, async: true - - alias TermUI.Backend.Raw - alias TermUI.Renderer.Cell - - # Helper to convert Cell struct to backend cell tuple format - defp cell_to_tuple(%Cell{char: char, fg: fg, bg: bg, attrs: attrs}) do - {char, fg, bg, MapSet.to_list(attrs)} - end - - # Helper to convert a list of {{row, col}, Cell} to {{row, col}, tuple} - defp to_backend_cells(cells) do - Enum.map(cells, fn {pos, cell} -> {pos, cell_to_tuple(cell)} end) - end - - # =========================================================================== - # Section 2.9.1: Full Lifecycle Tests - # =========================================================================== - - describe "full lifecycle integration (2.9.1)" do - # Note: ANSI cursor coordinates are 1-indexed, so we use {1, 1} for top-left - - test "init → draw_cells → shutdown sequence" do - # Initialize backend - {:ok, state} = Raw.init(size: {24, 80}, alternate_screen: false) - assert state.size == {24, 80} - - # Draw some cells (1-indexed coordinates) - cells = - [ - {{1, 1}, Cell.new("H")}, - {{1, 2}, Cell.new("i")} - ] - |> to_backend_cells() - - {:ok, state} = Raw.draw_cells(state, cells) - - # Shutdown cleanly - assert :ok = Raw.shutdown(state) - end - - test "init → draw_cells → poll_event (timeout) → shutdown sequence" do - {:ok, state} = Raw.init(size: {24, 80}, alternate_screen: false) - - # Draw cells (1-indexed coordinates) - cells = to_backend_cells([{{5, 10}, Cell.new("X", fg: :red)}]) - {:ok, state} = Raw.draw_cells(state, cells) - - # Poll with immediate timeout (no actual input) - {:timeout, state} = Raw.poll_event(state, 0) - - # Shutdown - assert :ok = Raw.shutdown(state) - end - - test "alternate screen is tracked in state" do - # With alternate screen - {:ok, with_alt} = Raw.init(size: {24, 80}, alternate_screen: true) - assert with_alt.alternate_screen == true - :ok = Raw.shutdown(with_alt) - - # Without alternate screen - {:ok, without_alt} = Raw.init(size: {24, 80}, alternate_screen: false) - assert without_alt.alternate_screen == false - :ok = Raw.shutdown(without_alt) - end - - test "cursor visibility is tracked in state" do - # Hidden cursor (default) - {:ok, hidden} = Raw.init(size: {24, 80}, hide_cursor: true, alternate_screen: false) - assert hidden.cursor_visible == false - :ok = Raw.shutdown(hidden) - - # Visible cursor - {:ok, visible} = Raw.init(size: {24, 80}, hide_cursor: false, alternate_screen: false) - assert visible.cursor_visible == true - :ok = Raw.shutdown(visible) - end - - test "shutdown is safe to call multiple times" do - {:ok, state} = Raw.init(size: {24, 80}, alternate_screen: false) - :ok = Raw.shutdown(state) - - # Shutdown returns :ok without state, so this demonstrates - # that shutdown completes without error - end - - test "shutdown after drawing styled cells" do - {:ok, state} = Raw.init(size: {24, 80}, alternate_screen: false) - - # Draw cells with various styles (1-indexed coordinates) - cells = - [ - {{1, 1}, Cell.new("A", fg: :red, bg: :blue, attrs: [:bold])}, - {{1, 2}, Cell.new("B", fg: :green, attrs: [:italic, :underline])}, - {{1, 3}, Cell.new("C", fg: {255, 128, 0}, bg: {0, 64, 128})} - ] - |> to_backend_cells() - - {:ok, state} = Raw.draw_cells(state, cells) - assert :ok = Raw.shutdown(state) - end - end - - # =========================================================================== - # Section 2.9.2: Renderer Integration Tests - # =========================================================================== - - describe "renderer integration (2.9.2)" do - # Note: ANSI cursor coordinates are 1-indexed - - setup do - {:ok, state} = Raw.init(size: {24, 80}, alternate_screen: false) - %{state: state} - end - - test "draw_cells with Cell.new/2 styled cells", %{state: state} do - cells = - [ - {{1, 1}, Cell.new("R", fg: :red)}, - {{1, 2}, Cell.new("G", fg: :green)}, - {{1, 3}, Cell.new("B", fg: :blue)} - ] - |> to_backend_cells() - - assert {:ok, _} = Raw.draw_cells(state, cells) - end - - test "draw_cells with 256-color palette cells", %{state: state} do - cells = - [ - {{1, 1}, Cell.new("1", fg: 196)}, - {{1, 2}, Cell.new("2", bg: 232)} - ] - |> to_backend_cells() - - assert {:ok, _} = Raw.draw_cells(state, cells) - end - - test "draw_cells with true color RGB cells", %{state: state} do - cells = - [ - {{1, 1}, Cell.new("T", fg: {255, 0, 0})}, - {{1, 2}, Cell.new("C", bg: {0, 255, 0})} - ] - |> to_backend_cells() - - assert {:ok, _} = Raw.draw_cells(state, cells) - end - - test "draw_cells with all attribute combinations", %{state: state} do - cells = - [ - {{1, 1}, Cell.new("B", attrs: [:bold])}, - {{2, 1}, Cell.new("D", attrs: [:dim])}, - {{3, 1}, Cell.new("I", attrs: [:italic])}, - {{4, 1}, Cell.new("U", attrs: [:underline])}, - {{5, 1}, Cell.new("K", attrs: [:blink])}, - {{6, 1}, Cell.new("R", attrs: [:reverse])}, - {{7, 1}, Cell.new("H", attrs: [:hidden])}, - {{8, 1}, Cell.new("S", attrs: [:strikethrough])} - ] - |> to_backend_cells() - - assert {:ok, _} = Raw.draw_cells(state, cells) - end - - test "draw_cells with multiple attributes on single cell", %{state: state} do - cell = Cell.new("X", fg: :red, bg: :blue, attrs: [:bold, :italic, :underline]) - cells = to_backend_cells([{{5, 10}, cell}]) - - assert {:ok, _} = Raw.draw_cells(state, cells) - end - - test "draw_cells with default colors", %{state: state} do - cells = - [ - {{1, 1}, Cell.new("D", fg: :default, bg: :default)} - ] - |> to_backend_cells() - - assert {:ok, _} = Raw.draw_cells(state, cells) - end - - test "draw_cells maintains style across multiple calls", %{state: state} do - # First call with red - cells1 = to_backend_cells([{{1, 1}, Cell.new("A", fg: :red)}]) - {:ok, state} = Raw.draw_cells(state, cells1) - - # Second call with same style - should use delta optimization - cells2 = to_backend_cells([{{1, 2}, Cell.new("B", fg: :red)}]) - {:ok, state} = Raw.draw_cells(state, cells2) - - # Third call with different style - cells3 = to_backend_cells([{{1, 3}, Cell.new("C", fg: :blue)}]) - {:ok, _} = Raw.draw_cells(state, cells3) - end - end - - # =========================================================================== - # Section 2.9.3: Input Integration Tests - # =========================================================================== - - describe "input integration (2.9.3)" do - setup do - {:ok, state} = Raw.init(size: {24, 80}, alternate_screen: false) - %{state: state} - end - - test "poll_event returns timeout when no input", %{state: state} do - assert {:timeout, _} = Raw.poll_event(state, 0) - end - - test "poll_event with buffered input returns events", %{state: state} do - # Inject input into buffer - state = %{state | input_buffer: "abc"} - - # Should return first character - {:ok, event, state} = Raw.poll_event(state, 0) - assert event.key == "a" - - # Continue getting events from queue - {:ok, event, state} = Raw.poll_event(state, 0) - assert event.key == "b" - - {:ok, event, _state} = Raw.poll_event(state, 0) - assert event.key == "c" - end - - test "poll_event handles escape sequences", %{state: state} do - # Arrow up sequence - state = %{state | input_buffer: "\e[A"} - - {:ok, event, _} = Raw.poll_event(state, 0) - assert event.key == :up - end - - test "poll_event handles function keys", %{state: state} do - # F1 via SS3 - state = %{state | input_buffer: "\eOP"} - - {:ok, event, _} = Raw.poll_event(state, 0) - assert event.key == :f1 - end - - test "poll_event handles control characters", %{state: state} do - # Ctrl+C - state = %{state | input_buffer: <<3>>} - - {:ok, event, _} = Raw.poll_event(state, 0) - assert event.key == "c" - assert :ctrl in event.modifiers - end - - test "poll_event preserves state fields", %{state: state} do - {:timeout, new_state} = Raw.poll_event(state, 0) - - # Core state should be preserved - assert new_state.size == state.size - assert new_state.alternate_screen == state.alternate_screen - assert new_state.cursor_visible == state.cursor_visible - end - end - - # =========================================================================== - # Section 2.9.4: Performance Tests - # =========================================================================== - - describe "performance integration (2.9.4)" do - # Note: ANSI cursor coordinates are 1-indexed - - setup do - {:ok, state} = Raw.init(size: {24, 80}, alternate_screen: false) - %{state: state} - end - - test "full screen render (80x24 = 1920 cells) completes", %{state: state} do - # Generate all cells for 80x24 screen (1-indexed: rows 1-24, cols 1-80) - cells = - for row <- 1..24, col <- 1..80 do - {{row, col}, Cell.new("X")} - end - |> to_backend_cells() - - assert length(cells) == 1920 - - # Should complete without error - {:ok, _} = Raw.draw_cells(state, cells) - end - - test "differential update (10% changed cells) is efficient", %{state: state} do - # First render full screen (1-indexed) - full_cells = - for row <- 1..24, col <- 1..80 do - {{row, col}, Cell.new(" ")} - end - |> to_backend_cells() - - {:ok, state} = Raw.draw_cells(state, full_cells) - - # Update only 10% (192 cells) - first 8 columns of each row - update_cells = - for row <- 1..24, col <- 1..8 do - {{row, col}, Cell.new("U", fg: :red)} - end - |> to_backend_cells() - - assert length(update_cells) == 192 - - # Should complete efficiently - {:ok, _} = Raw.draw_cells(state, update_cells) - end - - test "style delta tracking minimizes escape sequences", %{state: state} do - # All same style - should only emit style once (1-indexed) - cells = - for col <- 1..10 do - {{1, col}, Cell.new("S", fg: :red, attrs: [:bold])} - end - |> to_backend_cells() - - # This should work due to style delta tracking - {:ok, _} = Raw.draw_cells(state, cells) - end - - test "cursor optimization reduces movement sequences", %{state: state} do - # Sequential cells should use minimal cursor movement (1-indexed) - cells = - [ - {{1, 1}, Cell.new("A")}, - {{1, 2}, Cell.new("B")}, - {{1, 3}, Cell.new("C")} - ] - |> to_backend_cells() - - {:ok, _} = Raw.draw_cells(state, cells) - end - - test "large coordinate handling", %{state: state} do - # Test with coordinates near typical terminal limits (1-indexed) - cells = - [ - {{1, 1}, Cell.new("T")}, - {{24, 80}, Cell.new("B")} - ] - |> to_backend_cells() - - {:ok, _} = Raw.draw_cells(state, cells) - end - end - - # =========================================================================== - # Section 2.9: Mouse Tracking Integration - # =========================================================================== - - describe "mouse tracking integration" do - setup do - # Override ConPTY detection so mouse tracking tests run on all platforms - key = {TermUI.TerminalOutput, :needs_hard_reset} - original = :persistent_term.get(key, :unset) - :persistent_term.put(key, false) - - on_exit(fn -> - if original == :unset, - do: :persistent_term.erase(key), - else: :persistent_term.put(key, original) - end) - - {:ok, state} = Raw.init(size: {24, 80}, alternate_screen: false) - %{state: state} - end - - test "enable and disable mouse tracking cycle", %{state: state} do - # Enable - {:ok, state} = Raw.enable_mouse(state, :click) - assert state.mouse_mode == :click - - # Disable - {:ok, state} = Raw.disable_mouse(state) - assert state.mouse_mode == :none - - # Re-enable with different mode - {:ok, state} = Raw.enable_mouse(state, :all) - assert state.mouse_mode == :all - - # Shutdown with mouse enabled (should disable) - :ok = Raw.shutdown(state) - end - - test "init with mouse_tracking option" do - {:ok, state} = Raw.init(size: {24, 80}, mouse_tracking: :drag, alternate_screen: false) - assert state.mouse_mode == :drag - :ok = Raw.shutdown(state) - end - end -end diff --git a/test/term_ui/backend/raw_test.exs b/test/term_ui/backend/raw_test.exs deleted file mode 100644 index 43e5ef47..00000000 --- a/test/term_ui/backend/raw_test.exs +++ /dev/null @@ -1,2488 +0,0 @@ -defmodule TermUI.Backend.RawTest do - @moduledoc """ - Unit tests for TermUI.Backend.Raw module. - - This test file covers the module structure, behaviour declaration, and state structure. - Callback implementation tests will be added as each section is implemented. - """ - - use ExUnit.Case, async: true - - alias TermUI.Backend.Raw - - describe "module structure" do - test "module compiles successfully" do - assert Code.ensure_loaded?(Raw) - end - - test "declares @behaviour TermUI.Backend" do - behaviours = Raw.__info__(:attributes)[:behaviour] || [] - assert TermUI.Backend in behaviours - end - - test "exports all required callbacks" do - # Lifecycle callbacks - assert function_exported?(Raw, :init, 1) - assert function_exported?(Raw, :shutdown, 1) - - # Query callbacks - assert function_exported?(Raw, :size, 1) - - # Cursor callbacks - assert function_exported?(Raw, :move_cursor, 2) - assert function_exported?(Raw, :hide_cursor, 1) - assert function_exported?(Raw, :show_cursor, 1) - - # Rendering callbacks - assert function_exported?(Raw, :clear, 1) - assert function_exported?(Raw, :draw_cells, 2) - assert function_exported?(Raw, :flush, 1) - - # Input callbacks - assert function_exported?(Raw, :poll_event, 2) - end - - test "exports helper functions" do - assert function_exported?(Raw, :valid_position?, 2) - assert function_exported?(Raw, :mouse_mode_to_ansi, 1) - assert function_exported?(Raw, :ansi_module, 0) - assert function_exported?(Raw, :enable_mouse, 2) - assert function_exported?(Raw, :disable_mouse, 1) - end - - test "has ANSI module aliased" do - assert Raw.ansi_module() == TermUI.ANSI - end - end - - describe "documentation" do - test "module has moduledoc" do - {:docs_v1, _, :elixir, _, module_doc, _, _} = Code.fetch_docs(Raw) - assert module_doc != :none - assert module_doc != :hidden - end - - test "moduledoc describes OTP 28+ requirement" do - {:docs_v1, _, :elixir, _, %{"en" => doc}, _, _} = Code.fetch_docs(Raw) - assert doc =~ "OTP 28" - end - - test "moduledoc describes raw mode activation by Selector" do - {:docs_v1, _, :elixir, _, %{"en" => doc}, _, _} = Code.fetch_docs(Raw) - assert doc =~ "Selector" - assert doc =~ "raw mode" - end - - test "moduledoc describes initialization flow" do - {:docs_v1, _, :elixir, _, %{"en" => doc}, _, _} = Code.fetch_docs(Raw) - assert doc =~ "init/1" - assert doc =~ "alternate screen" - end - - test "moduledoc documents mouse tracking modes" do - {:docs_v1, _, :elixir, _, %{"en" => doc}, _, _} = Code.fetch_docs(Raw) - assert doc =~ "Mouse Tracking Modes" - assert doc =~ ":click" - assert doc =~ ":drag" - assert doc =~ "ANSI Protocol" - end - - test "moduledoc documents style delta optimization" do - {:docs_v1, _, :elixir, _, %{"en" => doc}, _, _} = Code.fetch_docs(Raw) - assert doc =~ "Style Delta Optimization" - assert doc =~ "current_style" - end - - test "init/1 has documentation" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(Raw) - - func_docs = - docs - |> Enum.filter(fn - {{:function, :init, 1}, _, _, _, _} -> true - _ -> false - end) - - assert length(func_docs) == 1 - end - - test "shutdown/1 has documentation" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(Raw) - - func_docs = - docs - |> Enum.filter(fn - {{:function, :shutdown, 1}, _, _, _, _} -> true - _ -> false - end) - - assert length(func_docs) == 1 - end - end - - describe "state structure" do - test "state struct has all expected fields" do - state = %Raw{} - - assert Map.has_key?(state, :size) - assert Map.has_key?(state, :cursor_visible) - assert Map.has_key?(state, :cursor_position) - assert Map.has_key?(state, :alternate_screen) - assert Map.has_key?(state, :mouse_mode) - assert Map.has_key?(state, :current_style) - end - - test "state struct has correct default values" do - state = %Raw{} - - assert state.size == {24, 80} - assert state.cursor_visible == false - assert state.cursor_position == nil - assert state.alternate_screen == false - assert state.mouse_mode == :none - assert state.current_style == nil - end - - test "state struct can be pattern matched" do - state = %Raw{size: {30, 100}, cursor_visible: true} - - assert %Raw{size: {30, 100}} = state - assert %Raw{cursor_visible: true} = state - end - - test "state struct can be created with custom values" do - state = %Raw{ - size: {50, 120}, - cursor_visible: true, - cursor_position: {10, 20}, - alternate_screen: true, - mouse_mode: :all, - current_style: %{fg: :red, bg: :default, attrs: [:bold]} - } - - assert state.size == {50, 120} - assert state.cursor_visible == true - assert state.cursor_position == {10, 20} - assert state.alternate_screen == true - assert state.mouse_mode == :all - assert state.current_style == %{fg: :red, bg: :default, attrs: [:bold]} - end - - test "state struct can be updated with struct update syntax" do - state = %Raw{} - updated = %{state | cursor_visible: true, mouse_mode: :click} - - assert updated.cursor_visible == true - assert updated.mouse_mode == :click - # Other fields unchanged - assert updated.size == {24, 80} - end - - test "mouse_mode accepts all valid values" do - for mode <- [:none, :click, :drag, :all] do - state = %Raw{mouse_mode: mode} - assert state.mouse_mode == mode - end - end - - test "cursor_position can be nil or tuple" do - state1 = %Raw{cursor_position: nil} - state2 = %Raw{cursor_position: {5, 10}} - - assert state1.cursor_position == nil - assert state2.cursor_position == {5, 10} - end - - test "current_style can be nil or map" do - state1 = %Raw{current_style: nil} - state2 = %Raw{current_style: %{fg: :blue, bg: :white, attrs: [:underline]}} - - assert state1.current_style == nil - assert state2.current_style.fg == :blue - assert state2.current_style.bg == :white - assert state2.current_style.attrs == [:underline] - end - - test "init/1 returns state struct" do - {:ok, state} = Raw.init(size: {24, 80}) - assert %Raw{} = state - end - - test "size/1 returns size from state" do - {:ok, state} = Raw.init(size: {24, 80}) - {:ok, size} = Raw.size(state) - assert size == state.size - end - end - - describe "helper functions" do - test "valid_position?/2 returns true for positions within bounds" do - state = %Raw{size: {24, 80}} - - assert Raw.valid_position?(state, {1, 1}) == true - assert Raw.valid_position?(state, {24, 80}) == true - assert Raw.valid_position?(state, {12, 40}) == true - end - - test "valid_position?/2 returns false for positions outside bounds" do - state = %Raw{size: {24, 80}} - - assert Raw.valid_position?(state, {0, 1}) == false - assert Raw.valid_position?(state, {1, 0}) == false - assert Raw.valid_position?(state, {25, 1}) == false - assert Raw.valid_position?(state, {1, 81}) == false - assert Raw.valid_position?(state, {-1, 1}) == false - end - - test "valid_position?/2 handles non-integer positions" do - state = %Raw{size: {24, 80}} - - assert Raw.valid_position?(state, {"1", 1}) == false - assert Raw.valid_position?(state, {1.5, 1}) == false - assert Raw.valid_position?(state, nil) == false - end - - test "mouse_mode_to_ansi/1 maps Raw modes to ANSI protocol modes" do - assert Raw.mouse_mode_to_ansi(:none) == nil - assert Raw.mouse_mode_to_ansi(:click) == :normal - assert Raw.mouse_mode_to_ansi(:drag) == :button - assert Raw.mouse_mode_to_ansi(:all) == :all - end - end - - describe "init/1 callback" do - test "returns {:ok, state} with explicit size option" do - {:ok, state} = Raw.init(size: {30, 100}) - - assert %Raw{} = state - assert state.size == {30, 100} - end - - test "sets alternate_screen to true by default" do - {:ok, state} = Raw.init(size: {24, 80}) - - assert state.alternate_screen == true - end - - test "sets alternate_screen to false when option provided" do - {:ok, state} = Raw.init(size: {24, 80}, alternate_screen: false) - - assert state.alternate_screen == false - end - - test "sets cursor_visible to false by default (hide_cursor: true)" do - {:ok, state} = Raw.init(size: {24, 80}) - - assert state.cursor_visible == false - end - - test "sets cursor_visible to true when hide_cursor: false" do - {:ok, state} = Raw.init(size: {24, 80}, hide_cursor: false) - - assert state.cursor_visible == true - end - - test "sets mouse_mode to :none by default" do - {:ok, state} = Raw.init(size: {24, 80}) - - assert state.mouse_mode == :none - end - - test "sets mouse_mode from option" do - {:ok, state1} = Raw.init(size: {24, 80}, mouse_tracking: :click) - {:ok, state2} = Raw.init(size: {24, 80}, mouse_tracking: :drag) - {:ok, state3} = Raw.init(size: {24, 80}, mouse_tracking: :all) - - assert state1.mouse_mode == :click - assert state2.mouse_mode == :drag - assert state3.mouse_mode == :all - end - - test "sets cursor_position to {1, 1} after clear" do - {:ok, state} = Raw.init(size: {24, 80}) - - assert state.cursor_position == {1, 1} - end - - test "sets current_style to nil initially" do - {:ok, state} = Raw.init(size: {24, 80}) - - assert state.current_style == nil - end - - test "returns error for invalid size format" do - assert {:error, :invalid_size} = Raw.init(size: "invalid") - assert {:error, :invalid_size} = Raw.init(size: {0, 80}) - assert {:error, :invalid_size} = Raw.init(size: {24, 0}) - assert {:error, :invalid_size} = Raw.init(size: {-1, 80}) - assert {:error, :invalid_size} = Raw.init(size: {24}) - end - - test "accepts all options combined" do - {:ok, state} = - Raw.init( - size: {40, 120}, - alternate_screen: false, - hide_cursor: false, - mouse_tracking: :drag - ) - - assert state.size == {40, 120} - assert state.alternate_screen == false - assert state.cursor_visible == true - assert state.mouse_mode == :drag - assert state.cursor_position == {1, 1} - assert state.current_style == nil - end - end - - describe "shutdown/1 callback" do - test "returns :ok with default state" do - {:ok, state} = Raw.init(size: {24, 80}) - - assert :ok = Raw.shutdown(state) - end - - test "returns :ok with alternate_screen: false" do - {:ok, state} = Raw.init(size: {24, 80}, alternate_screen: false) - - assert :ok = Raw.shutdown(state) - end - - test "returns :ok with mouse tracking enabled" do - {:ok, state} = Raw.init(size: {24, 80}, mouse_tracking: :click) - - assert :ok = Raw.shutdown(state) - end - - test "returns :ok with all mouse modes" do - for mode <- [:none, :click, :drag, :all] do - {:ok, state} = Raw.init(size: {24, 80}, mouse_tracking: mode) - assert :ok = Raw.shutdown(state) - end - end - - test "is idempotent - can be called twice safely" do - {:ok, state} = Raw.init(size: {24, 80}) - - assert :ok = Raw.shutdown(state) - assert :ok = Raw.shutdown(state) - end - - test "works with various state configurations" do - # Test with alternate screen and mouse tracking - {:ok, state1} = - Raw.init( - size: {30, 100}, - alternate_screen: true, - hide_cursor: true, - mouse_tracking: :all - ) - - assert :ok = Raw.shutdown(state1) - - # Test with minimal configuration - {:ok, state2} = - Raw.init( - size: {24, 80}, - alternate_screen: false, - hide_cursor: false, - mouse_tracking: :none - ) - - assert :ok = Raw.shutdown(state2) - end - end - - describe "move_cursor/2 callback" do - setup do - {:ok, state} = Raw.init(size: {24, 80}) - %{state: state} - end - - test "returns {:ok, state} for valid position", %{state: state} do - assert {:ok, %Raw{}} = Raw.move_cursor(state, {1, 1}) - assert {:ok, %Raw{}} = Raw.move_cursor(state, {10, 20}) - end - - test "updates cursor_position in state", %{state: state} do - {:ok, updated_state} = Raw.move_cursor(state, {5, 10}) - assert updated_state.cursor_position == {5, 10} - - {:ok, updated_state2} = Raw.move_cursor(updated_state, {12, 40}) - assert updated_state2.cursor_position == {12, 40} - end - - test "handles top-left corner position {1, 1}", %{state: state} do - {:ok, updated_state} = Raw.move_cursor(state, {1, 1}) - assert updated_state.cursor_position == {1, 1} - end - - test "handles bottom-right corner position", %{state: state} do - # State has size {24, 80} - {:ok, updated_state} = Raw.move_cursor(state, {24, 80}) - assert updated_state.cursor_position == {24, 80} - end - - test "handles positions beyond terminal bounds", %{state: state} do - # Positions beyond bounds are accepted (clamping is renderer's responsibility) - {:ok, updated_state} = Raw.move_cursor(state, {100, 200}) - assert updated_state.cursor_position == {100, 200} - end - - test "preserves other state fields", %{state: state} do - {:ok, updated_state} = Raw.move_cursor(state, {5, 10}) - - # Original state fields preserved - assert updated_state.size == state.size - assert updated_state.cursor_visible == state.cursor_visible - assert updated_state.alternate_screen == state.alternate_screen - assert updated_state.mouse_mode == state.mouse_mode - assert updated_state.current_style == state.current_style - end - - test "enforces positive integer row", %{state: state} do - assert_raise FunctionClauseError, fn -> Raw.move_cursor(state, {0, 1}) end - assert_raise FunctionClauseError, fn -> Raw.move_cursor(state, {-1, 1}) end - end - - test "enforces positive integer col", %{state: state} do - assert_raise FunctionClauseError, fn -> Raw.move_cursor(state, {1, 0}) end - assert_raise FunctionClauseError, fn -> Raw.move_cursor(state, {1, -1}) end - end - - test "rejects non-integer positions", %{state: state} do - assert_raise FunctionClauseError, fn -> Raw.move_cursor(state, {1.5, 1}) end - assert_raise FunctionClauseError, fn -> Raw.move_cursor(state, {1, 1.5}) end - assert_raise FunctionClauseError, fn -> Raw.move_cursor(state, {"1", 1}) end - assert_raise FunctionClauseError, fn -> Raw.move_cursor(state, {1, "1"}) end - end - end - - describe "cursor optimization" do - test "optimize_cursor defaults to true" do - {:ok, state} = Raw.init(size: {24, 80}) - assert state.optimize_cursor == true - end - - test "optimize_cursor can be disabled via option" do - {:ok, state} = Raw.init(size: {24, 80}, optimize_cursor: false) - assert state.optimize_cursor == false - end - - test "move_cursor works with optimization enabled" do - {:ok, state} = Raw.init(size: {24, 80}, optimize_cursor: true) - - # First move establishes position - {:ok, state2} = Raw.move_cursor(state, {5, 10}) - assert state2.cursor_position == {5, 10} - - # Second move can use optimization - {:ok, state3} = Raw.move_cursor(state2, {5, 15}) - assert state3.cursor_position == {5, 15} - end - - test "move_cursor works with optimization disabled" do - {:ok, state} = Raw.init(size: {24, 80}, optimize_cursor: false) - - {:ok, state2} = Raw.move_cursor(state, {5, 10}) - assert state2.cursor_position == {5, 10} - - {:ok, state3} = Raw.move_cursor(state2, {5, 15}) - assert state3.cursor_position == {5, 15} - end - - test "optimizer used for small horizontal moves" do - {:ok, state} = Raw.init(size: {24, 80}, optimize_cursor: true) - - # Move to initial position - {:ok, state2} = Raw.move_cursor(state, {10, 10}) - - # Small move right - optimizer should use relative move - {:ok, state3} = Raw.move_cursor(state2, {10, 12}) - assert state3.cursor_position == {10, 12} - end - - test "optimizer used for small vertical moves" do - {:ok, state} = Raw.init(size: {24, 80}, optimize_cursor: true) - - # Move to initial position - {:ok, state2} = Raw.move_cursor(state, {10, 10}) - - # Small move down - optimizer should use relative move - {:ok, state3} = Raw.move_cursor(state2, {12, 10}) - assert state3.cursor_position == {12, 10} - end - - test "optimizer handles nil cursor_position gracefully" do - # Create state with nil cursor_position directly for testing - state = %Raw{ - size: {24, 80}, - cursor_visible: false, - cursor_position: nil, - alternate_screen: true, - mouse_mode: :none, - current_style: nil, - optimize_cursor: true - } - - # Should fall back to absolute positioning - {:ok, updated} = Raw.move_cursor(state, {5, 10}) - assert updated.cursor_position == {5, 10} - end - - test "preserves optimize_cursor setting through cursor operations" do - {:ok, state} = Raw.init(size: {24, 80}, optimize_cursor: false) - - {:ok, state2} = Raw.move_cursor(state, {5, 10}) - assert state2.optimize_cursor == false - - {:ok, state3} = Raw.hide_cursor(state2) - assert state3.optimize_cursor == false - - {:ok, state4} = Raw.show_cursor(state3) - assert state4.optimize_cursor == false - end - end - - describe "hide_cursor/1 callback" do - setup do - # Default init has hide_cursor: true, so cursor_visible is false - {:ok, state} = Raw.init(size: {24, 80}) - %{state: state} - end - - test "returns {:ok, state}", %{state: state} do - # Make cursor visible first - {:ok, visible_state} = Raw.show_cursor(state) - assert {:ok, %Raw{}} = Raw.hide_cursor(visible_state) - end - - test "updates cursor_visible to false", %{state: state} do - # Make cursor visible first - {:ok, visible_state} = Raw.show_cursor(state) - assert visible_state.cursor_visible == true - - {:ok, hidden_state} = Raw.hide_cursor(visible_state) - assert hidden_state.cursor_visible == false - end - - test "is idempotent when cursor already hidden", %{state: state} do - # State already has cursor hidden (from init with hide_cursor: true) - assert state.cursor_visible == false - - # Calling hide_cursor should return same state (no change) - {:ok, same_state} = Raw.hide_cursor(state) - assert same_state.cursor_visible == false - assert same_state == state - end - - test "preserves other state fields", %{state: state} do - {:ok, visible_state} = Raw.show_cursor(state) - {:ok, hidden_state} = Raw.hide_cursor(visible_state) - - assert hidden_state.size == state.size - assert hidden_state.cursor_position == state.cursor_position - assert hidden_state.alternate_screen == state.alternate_screen - assert hidden_state.mouse_mode == state.mouse_mode - assert hidden_state.current_style == state.current_style - end - end - - describe "show_cursor/1 callback" do - setup do - # Default init has hide_cursor: true, so cursor_visible is false - {:ok, state} = Raw.init(size: {24, 80}) - %{state: state} - end - - test "returns {:ok, state}", %{state: state} do - assert {:ok, %Raw{}} = Raw.show_cursor(state) - end - - test "updates cursor_visible to true", %{state: state} do - # State starts with cursor hidden - assert state.cursor_visible == false - - {:ok, visible_state} = Raw.show_cursor(state) - assert visible_state.cursor_visible == true - end - - test "is idempotent when cursor already visible", %{state: state} do - # First make cursor visible - {:ok, visible_state} = Raw.show_cursor(state) - assert visible_state.cursor_visible == true - - # Calling show_cursor again should return same state (no change) - {:ok, same_state} = Raw.show_cursor(visible_state) - assert same_state.cursor_visible == true - assert same_state == visible_state - end - - test "preserves other state fields", %{state: state} do - {:ok, visible_state} = Raw.show_cursor(state) - - assert visible_state.size == state.size - assert visible_state.cursor_position == state.cursor_position - assert visible_state.alternate_screen == state.alternate_screen - assert visible_state.mouse_mode == state.mouse_mode - assert visible_state.current_style == state.current_style - end - end - - describe "cursor visibility round-trip" do - setup do - {:ok, state} = Raw.init(size: {24, 80}, hide_cursor: false) - %{state: state} - end - - test "hide then show restores visibility", %{state: state} do - assert state.cursor_visible == true - - {:ok, hidden} = Raw.hide_cursor(state) - assert hidden.cursor_visible == false - - {:ok, visible} = Raw.show_cursor(hidden) - assert visible.cursor_visible == true - end - - test "multiple hide/show cycles work correctly", %{state: state} do - {:ok, s1} = Raw.hide_cursor(state) - {:ok, s2} = Raw.show_cursor(s1) - {:ok, s3} = Raw.hide_cursor(s2) - {:ok, s4} = Raw.show_cursor(s3) - - assert s1.cursor_visible == false - assert s2.cursor_visible == true - assert s3.cursor_visible == false - assert s4.cursor_visible == true - end - end - - describe "clear/1 callback" do - setup do - {:ok, state} = Raw.init(size: {24, 80}) - %{state: state} - end - - test "returns {:ok, state}", %{state: state} do - assert {:ok, %Raw{}} = Raw.clear(state) - end - - test "resets cursor_position to {1, 1}", %{state: state} do - # First move cursor to different position - {:ok, moved_state} = Raw.move_cursor(state, {10, 20}) - assert moved_state.cursor_position == {10, 20} - - # Clear should reset to home - {:ok, cleared_state} = Raw.clear(moved_state) - assert cleared_state.cursor_position == {1, 1} - end - - test "resets current_style to nil", %{state: state} do - # Simulate having a style set (manually set for test) - state_with_style = %{state | current_style: %{fg: :red, bg: :blue, attrs: [:bold]}} - - {:ok, cleared_state} = Raw.clear(state_with_style) - assert cleared_state.current_style == nil - end - - test "preserves other state fields", %{state: state} do - # Move cursor and set some state - {:ok, modified_state} = Raw.move_cursor(state, {10, 20}) - - {:ok, cleared_state} = Raw.clear(modified_state) - - # Should preserve all fields except cursor_position and current_style - assert_state_unchanged_except(modified_state, cleared_state, [ - :cursor_position, - :current_style - ]) - end - - test "works after multiple operations", %{state: state} do - # Perform various operations - {:ok, s1} = Raw.move_cursor(state, {5, 10}) - {:ok, s2} = Raw.show_cursor(s1) - {:ok, s3} = Raw.move_cursor(s2, {20, 40}) - - # Clear should work and reset position - {:ok, cleared} = Raw.clear(s3) - assert cleared.cursor_position == {1, 1} - assert cleared.current_style == nil - # But cursor visibility should be preserved - assert cleared.cursor_visible == true - end - - test "is idempotent (multiple clears work)", %{state: state} do - {:ok, s1} = Raw.clear(state) - {:ok, s2} = Raw.clear(s1) - {:ok, s3} = Raw.clear(s2) - - assert s3.cursor_position == {1, 1} - assert s3.current_style == nil - end - end - - describe "size/1 callback" do - test "returns {:ok, {rows, cols}} tuple" do - {:ok, state} = Raw.init(size: {24, 80}) - assert {:ok, {24, 80}} = Raw.size(state) - end - - test "returns cached dimensions from state" do - {:ok, state} = Raw.init(size: {50, 120}) - {:ok, size} = Raw.size(state) - assert size == {50, 120} - assert size == state.size - end - - test "works with various terminal sizes" do - # Standard 80x24 - {:ok, state1} = Raw.init(size: {24, 80}) - assert {:ok, {24, 80}} = Raw.size(state1) - - # Large terminal - {:ok, state2} = Raw.init(size: {50, 200}) - assert {:ok, {50, 200}} = Raw.size(state2) - - # Small terminal - {:ok, state3} = Raw.init(size: {10, 40}) - assert {:ok, {10, 40}} = Raw.size(state3) - end - - test "size remains unchanged after cursor operations" do - {:ok, state} = Raw.init(size: {24, 80}) - {:ok, state2} = Raw.move_cursor(state, {10, 20}) - {:ok, state3} = Raw.hide_cursor(state2) - {:ok, state4} = Raw.clear(state3) - - # Size should remain the same through all operations - assert {:ok, {24, 80}} = Raw.size(state4) - end - - test "returns size in {rows, cols} format" do - {:ok, state} = Raw.init(size: {30, 100}) - {:ok, {rows, cols}} = Raw.size(state) - - # Rows first, columns second - assert rows == 30 - assert cols == 100 - end - end - - describe "refresh_size/1 callback" do - setup do - {:ok, state} = Raw.init(size: {24, 80}) - %{state: state} - end - - test "exports refresh_size/1 function" do - assert function_exported?(Raw, :refresh_size, 1) - end - - test "returns 3-tuple on success", %{state: state} do - # In test environment, :io.rows/0 and :io.columns/0 may return {:error, :enotsup} - # We need to set environment variables for the fallback - with_terminal_env(30, 100, fn -> - result = Raw.refresh_size(state) - # Either succeeds with new size or returns error (depending on test environment) - assert match?({:ok, {_, _}, %Raw{}}, result) or match?({:error, _}, result) - end) - end - - test "updates state.size on success" do - with_terminal_env(50, 120, fn -> - {:ok, state} = Raw.init(size: {24, 80}) - assert state.size == {24, 80} - - case Raw.refresh_size(state) do - {:ok, new_size, updated_state} -> - assert new_size == {50, 120} - assert updated_state.size == {50, 120} - assert updated_state.size == new_size - - {:error, :size_detection_failed} -> - # If :io.rows/0 and :io.columns/0 succeed but with different values, - # the env fallback won't be used - :ok - end - end) - end - - test "preserves other state fields on success" do - with_terminal_env(30, 100, fn -> - {:ok, state} = Raw.init(size: {24, 80}, mouse_tracking: :click, hide_cursor: false) - - case Raw.refresh_size(state) do - {:ok, _new_size, updated_state} -> - # Only size should change - assert_state_unchanged_except(state, updated_state, [:size]) - - {:error, _} -> - :ok - end - end) - end - - test "returns error when size detection fails", %{state: state} do - # Ensure environment variables are not set - System.delete_env("LINES") - System.delete_env("COLUMNS") - - # In test environment without a real terminal, this may fail - # The result depends on whether :io.rows/0 and :io.columns/0 work - result = Raw.refresh_size(state) - - # Either succeeds (real terminal) or fails (no terminal) - assert match?({:ok, {_, _}, %Raw{}}, result) or - match?({:error, :size_detection_failed}, result) - end - - test "has documentation" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(Raw) - - func_docs = - docs - |> Enum.filter(fn - {{:function, :refresh_size, 1}, _, _, _, _} -> true - _ -> false - end) - - assert length(func_docs) == 1 - - # Check documentation mentions SIGWINCH - [{{:function, :refresh_size, 1}, _, _, %{"en" => doc}, _}] = func_docs - assert doc =~ "SIGWINCH" - end - - test "documentation mentions error handling" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(Raw) - - [{{:function, :refresh_size, 1}, _, _, %{"en" => doc}, _}] = - Enum.filter(docs, fn - {{:function, :refresh_size, 1}, _, _, _, _} -> true - _ -> false - end) - - assert doc =~ "size_detection_failed" - end - end - - describe "refresh_size/1 with mocked environment" do - test "uses environment variable fallback" do - # Create a state with known size - {:ok, state} = Raw.init(size: {24, 80}) - - with_terminal_env(40, 160, fn -> - result = Raw.refresh_size(state) - - # If :io functions fail, should fall back to environment - case result do - {:ok, new_size, _updated_state} -> - # Size was detected (either from :io or env) - assert is_tuple(new_size) - assert tuple_size(new_size) == 2 - {rows, cols} = new_size - assert is_integer(rows) and rows > 0 - assert is_integer(cols) and cols > 0 - - {:error, :size_detection_failed} -> - # Both :io and env failed - unexpected given we set env - flunk("Size detection failed despite environment variables being set") - end - end) - end - - test "returns error with invalid environment variables" do - with_terminal_env("invalid", "invalid", fn -> - {:ok, state} = Raw.init(size: {24, 80}) - result = Raw.refresh_size(state) - - # Either :io functions work, or we get an error due to invalid env - assert match?({:ok, {_, _}, %Raw{}}, result) or - match?({:error, :size_detection_failed}, result) - end) - end - - test "rejects terminal size exceeding maximum bounds" do - # Test with size exceeding @max_terminal_dimension (9999) - with_terminal_env(10_000, 10_000, fn -> - {:ok, state} = Raw.init(size: {24, 80}) - result = Raw.refresh_size(state) - - # Either :io functions work, or we get an error due to oversized env - assert match?({:ok, {_, _}, %Raw{}}, result) or - match?({:error, :size_detection_failed}, result) - end) - end - end - - # ========================================================================== - # draw_cells/2 Callback Tests (Section 2.5.1) - # ========================================================================== - - describe "draw_cells/2 callback" do - setup do - {:ok, state} = Raw.init(size: {24, 80}) - %{state: state} - end - - test "exports draw_cells/2 function" do - assert function_exported?(Raw, :draw_cells, 2) - end - - test "returns {:ok, state} tuple", %{state: state} do - cells = [{{1, 1}, {"A", :default, :default, []}}] - assert {:ok, %Raw{}} = Raw.draw_cells(state, cells) - end - - test "with empty list returns unchanged state", %{state: state} do - {:ok, result} = Raw.draw_cells(state, []) - assert result == state - end - - test "with single cell updates cursor position", %{state: state} do - cells = [{{5, 10}, {"X", :default, :default, []}}] - {:ok, result} = Raw.draw_cells(state, cells) - - # Cursor should advance one column after drawing the character - assert result.cursor_position == {5, 11} - end - - test "with single cell updates current_style", %{state: state} do - cells = [{{1, 1}, {"A", :red, :blue, [:bold]}}] - {:ok, result} = Raw.draw_cells(state, cells) - - assert result.current_style == %{fg: :red, bg: :blue, attrs: [:bold]} - end - - test "with multiple cells on same row tracks cursor sequentially", %{state: state} do - cells = [ - {{1, 1}, {"H", :default, :default, []}}, - {{1, 2}, {"i", :default, :default, []}} - ] - - {:ok, result} = Raw.draw_cells(state, cells) - - # Cursor should be after the last character - assert result.cursor_position == {1, 3} - end - - test "with cells on different rows updates to final position", %{state: state} do - cells = [ - {{1, 1}, {"A", :default, :default, []}}, - {{2, 5}, {"B", :default, :default, []}}, - {{3, 10}, {"C", :default, :default, []}} - ] - - {:ok, result} = Raw.draw_cells(state, cells) - - # Cursor should be after the last cell (row 3, col 11) - assert result.cursor_position == {3, 11} - end - - test "sorts cells by position before rendering", %{state: state} do - # Pass cells out of order - cells = [ - {{2, 5}, {"B", :default, :default, []}}, - {{1, 1}, {"A", :default, :default, []}}, - {{1, 10}, {"C", :default, :default, []}} - ] - - {:ok, result} = Raw.draw_cells(state, cells) - - # Should end at position after the last cell in sorted order - # Sorted: {1,1}, {1,10}, {2,5} - # Final position after {2,5} -> {2,6} - assert result.cursor_position == {2, 6} - end - - test "preserves other state fields", %{state: state} do - cells = [{{1, 1}, {"X", :red, :default, []}}] - {:ok, result} = Raw.draw_cells(state, cells) - - # These fields should not change - assert result.size == state.size - assert result.cursor_visible == state.cursor_visible - assert result.alternate_screen == state.alternate_screen - assert result.mouse_mode == state.mouse_mode - assert result.optimize_cursor == state.optimize_cursor - end - - test "tracks style across multiple cells", %{state: state} do - # First cell sets style - cells = [ - {{1, 1}, {"A", :red, :blue, [:bold]}}, - {{1, 2}, {"B", :red, :blue, [:bold]}} - ] - - {:ok, result} = Raw.draw_cells(state, cells) - - # Style should reflect final cell's style - assert result.current_style == %{fg: :red, bg: :blue, attrs: [:bold]} - end - - test "handles style changes between cells", %{state: state} do - cells = [ - {{1, 1}, {"A", :red, :default, []}}, - {{1, 2}, {"B", :green, :default, [:underline]}} - ] - - {:ok, result} = Raw.draw_cells(state, cells) - - # Style should be the last cell's style - assert result.current_style == %{fg: :green, bg: :default, attrs: [:underline]} - end - - test "has documentation" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(Raw) - - func_docs = - Enum.filter(docs, fn - {{:function, :draw_cells, 2}, _, _, _, _} -> true - _ -> false - end) - - assert length(func_docs) == 1 - [{{:function, :draw_cells, 2}, _, _, %{"en" => doc}, _}] = func_docs - assert doc =~ "Draws cells" - assert doc =~ "Cell Format" - end - end - - describe "draw_cells/2 with various color types" do - setup do - {:ok, state} = Raw.init(size: {24, 80}) - %{state: state} - end - - test "handles named colors", %{state: state} do - cells = [{{1, 1}, {"A", :red, :blue, []}}] - {:ok, result} = Raw.draw_cells(state, cells) - - assert result.current_style.fg == :red - assert result.current_style.bg == :blue - end - - test "handles :default colors", %{state: state} do - cells = [{{1, 1}, {"A", :default, :default, []}}] - {:ok, result} = Raw.draw_cells(state, cells) - - assert result.current_style.fg == :default - assert result.current_style.bg == :default - end - - test "handles 256-color indices", %{state: state} do - cells = [{{1, 1}, {"A", 196, 232, []}}] - {:ok, result} = Raw.draw_cells(state, cells) - - assert result.current_style.fg == 196 - assert result.current_style.bg == 232 - end - - test "handles RGB true colors", %{state: state} do - cells = [{{1, 1}, {"A", {255, 128, 0}, {0, 64, 128}, []}}] - {:ok, result} = Raw.draw_cells(state, cells) - - assert result.current_style.fg == {255, 128, 0} - assert result.current_style.bg == {0, 64, 128} - end - - test "handles mixed color types", %{state: state} do - cells = [ - {{1, 1}, {"A", :red, 232, []}}, - {{1, 2}, {"B", 196, {0, 255, 0}, []}}, - {{1, 3}, {"C", {128, 128, 128}, :default, []}} - ] - - {:ok, result} = Raw.draw_cells(state, cells) - - # Final style should be from last cell - assert result.current_style.fg == {128, 128, 128} - assert result.current_style.bg == :default - end - end - - describe "draw_cells/2 with various attributes" do - setup do - {:ok, state} = Raw.init(size: {24, 80}) - %{state: state} - end - - test "handles bold attribute", %{state: state} do - cells = [{{1, 1}, {"A", :default, :default, [:bold]}}] - {:ok, result} = Raw.draw_cells(state, cells) - - assert :bold in result.current_style.attrs - end - - test "handles multiple attributes", %{state: state} do - cells = [{{1, 1}, {"A", :default, :default, [:bold, :underline, :italic]}}] - {:ok, result} = Raw.draw_cells(state, cells) - - assert :bold in result.current_style.attrs - assert :underline in result.current_style.attrs - assert :italic in result.current_style.attrs - end - - test "handles all supported attributes", %{state: state} do - all_attrs = [:bold, :dim, :italic, :underline, :blink, :reverse, :hidden, :strikethrough] - cells = [{{1, 1}, {"A", :default, :default, all_attrs}}] - {:ok, result} = Raw.draw_cells(state, cells) - - for attr <- all_attrs do - assert attr in result.current_style.attrs, - "Expected #{attr} to be in current_style.attrs" - end - end - - test "handles empty attributes list", %{state: state} do - cells = [{{1, 1}, {"A", :default, :default, []}}] - {:ok, result} = Raw.draw_cells(state, cells) - - assert result.current_style.attrs == [] - end - - test "normalizes attributes to sorted list", %{state: state} do - # Pass attrs in random order - cells = [{{1, 1}, {"A", :default, :default, [:underline, :bold, :italic]}}] - {:ok, result} = Raw.draw_cells(state, cells) - - # Should be sorted alphabetically - assert result.current_style.attrs == [:bold, :italic, :underline] - end - end - - describe "draw_cells/2 style delta optimization" do - setup do - {:ok, state} = Raw.init(size: {24, 80}) - %{state: state} - end - - test "consecutive cells with same style don't reset style tracking", %{state: state} do - # Draw first cell to set initial style - cells1 = [{{1, 1}, {"A", :red, :default, [:bold]}}] - {:ok, state1} = Raw.draw_cells(state, cells1) - - # Draw second cell with same style - cells2 = [{{1, 2}, {"B", :red, :default, [:bold]}}] - {:ok, state2} = Raw.draw_cells(state1, cells2) - - # Style should remain the same - assert state1.current_style == state2.current_style - end - - test "tracks style state across multiple draw_cells calls", %{state: state} do - cells1 = [{{1, 1}, {"A", :red, :blue, []}}] - {:ok, state1} = Raw.draw_cells(state, cells1) - - assert state1.current_style == %{fg: :red, bg: :blue, attrs: []} - - # Change only foreground - cells2 = [{{1, 2}, {"B", :green, :blue, []}}] - {:ok, state2} = Raw.draw_cells(state1, cells2) - - assert state2.current_style == %{fg: :green, bg: :blue, attrs: []} - end - - test "resets style when removing attributes", %{state: state} do - # Draw cell with multiple attributes - cells1 = [{{1, 1}, {"A", :default, :default, [:bold, :italic, :underline]}}] - {:ok, state1} = Raw.draw_cells(state, cells1) - - assert state1.current_style.attrs == [:bold, :italic, :underline] - - # Draw cell with fewer attributes (requires reset + rebuild) - cells2 = [{{1, 2}, {"B", :default, :default, [:bold]}}] - {:ok, state2} = Raw.draw_cells(state1, cells2) - - # Style should reflect only the new attribute - assert state2.current_style.attrs == [:bold] - end - - test "handles full screen of cells efficiently", %{state: state} do - # Generate 80x24 = 1920 cells (full terminal screen) - cells = - for row <- 1..24, col <- 1..80 do - {{row, col}, {"X", :default, :default, []}} - end - - # Should process without error - {:ok, final_state} = Raw.draw_cells(state, cells) - - # Verify cursor position is at end of last cell - assert final_state.cursor_position == {24, 81} - - # Verify style tracking was maintained - assert final_state.current_style == %{fg: :default, bg: :default, attrs: []} - end - end - - describe "flush/1 callback" do - setup do - {:ok, state} = Raw.init(size: {24, 80}) - %{state: state} - end - - test "returns {:ok, state}", %{state: state} do - assert {:ok, %Raw{}} = Raw.flush(state) - end - - test "is idempotent - safe to call multiple times", %{state: state} do - {:ok, state1} = Raw.flush(state) - {:ok, state2} = Raw.flush(state1) - {:ok, state3} = Raw.flush(state2) - - # All calls should succeed and return equivalent state - assert state1 == state2 - assert state2 == state3 - end - - test "preserves all state fields", %{state: state} do - {:ok, flushed_state} = Raw.flush(state) - - # All fields should be unchanged - assert flushed_state.size == state.size - assert flushed_state.cursor_visible == state.cursor_visible - assert flushed_state.cursor_position == state.cursor_position - assert flushed_state.alternate_screen == state.alternate_screen - assert flushed_state.mouse_mode == state.mouse_mode - assert flushed_state.current_style == state.current_style - assert flushed_state.optimize_cursor == state.optimize_cursor - end - - test "has documentation", %{state: _state} do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(Raw) - - flush_doc = - Enum.find(docs, fn - {{:function, :flush, 1}, _, _, _, _} -> true - _ -> false - end) - - assert flush_doc != nil - {{:function, :flush, 1}, _, _, %{"en" => doc}, _} = flush_doc - assert doc =~ "Flushes pending output" - assert doc =~ "no-op" - end - end - - describe "poll_event/2 callback" do - setup do - {:ok, state} = Raw.init(size: {24, 80}) - %{state: state} - end - - test "returns {:timeout, state} when no input available", %{state: state} do - # With zero timeout, should return immediately if no input - result = Raw.poll_event(state, 0) - - # In test environment without real terminal, we get timeout - assert match?({:timeout, %Raw{}}, result) or match?({:error, _, %Raw{}}, result) - end - - test "state has input_buffer field initialized to empty", %{state: state} do - assert state.input_buffer == <<>> - end - - test "parses buffered input from previous partial sequence", %{state: state} do - # Manually set buffer with a complete key - state_with_buffer = %{state | input_buffer: "a"} - - # Should parse the buffered 'a' immediately without reading - {:ok, event, new_state} = Raw.poll_event(state_with_buffer, 0) - - assert event.key == "a" - assert new_state.input_buffer == <<>> - end - - test "parses multiple buffered characters one at a time", %{state: state} do - # Buffer with multiple characters - state_with_buffer = %{state | input_buffer: "abc"} - - # First call returns 'a' - {:ok, event1, state1} = Raw.poll_event(state_with_buffer, 0) - assert event1.key == "a" - - # Second call returns 'b' - {:ok, event2, state2} = Raw.poll_event(state1, 0) - assert event2.key == "b" - - # Third call returns 'c' - {:ok, event3, state3} = Raw.poll_event(state2, 0) - assert event3.key == "c" - - # Buffer should be empty - assert state3.input_buffer == <<>> - end - - test "parses enter key from buffer", %{state: state} do - state_with_buffer = %{state | input_buffer: <<13>>} - - {:ok, event, _new_state} = Raw.poll_event(state_with_buffer, 0) - - assert event.key == :enter - end - - test "parses tab key from buffer", %{state: state} do - state_with_buffer = %{state | input_buffer: <<9>>} - - {:ok, event, _new_state} = Raw.poll_event(state_with_buffer, 0) - - assert event.key == :tab - end - - test "parses backspace from buffer", %{state: state} do - state_with_buffer = %{state | input_buffer: <<127>>} - - {:ok, event, _new_state} = Raw.poll_event(state_with_buffer, 0) - - assert event.key == :backspace - end - - test "parses ctrl+c from buffer", %{state: state} do - # Ctrl+C is byte 3 - state_with_buffer = %{state | input_buffer: <<3>>} - - {:ok, event, _new_state} = Raw.poll_event(state_with_buffer, 0) - - assert event.key == "c" - assert :ctrl in event.modifiers - end - - test "parses arrow up from buffer", %{state: state} do - # Arrow up: ESC [ A - state_with_buffer = %{state | input_buffer: <<27, ?[, ?A>>} - - {:ok, event, _new_state} = Raw.poll_event(state_with_buffer, 0) - - assert event.key == :up - end - - test "parses arrow keys from buffer", %{state: state} do - arrows = [ - {<<27, ?[, ?A>>, :up}, - {<<27, ?[, ?B>>, :down}, - {<<27, ?[, ?C>>, :right}, - {<<27, ?[, ?D>>, :left} - ] - - for {seq, expected_key} <- arrows do - state_with_buffer = %{state | input_buffer: seq} - {:ok, event, _} = Raw.poll_event(state_with_buffer, 0) - assert event.key == expected_key, "Expected #{expected_key} for sequence #{inspect(seq)}" - end - end - - test "parses function keys from buffer", %{state: state} do - # F1-F4 via SS3: ESC O P/Q/R/S - f_keys = [ - {<<27, ?O, ?P>>, :f1}, - {<<27, ?O, ?Q>>, :f2}, - {<<27, ?O, ?R>>, :f3}, - {<<27, ?O, ?S>>, :f4} - ] - - for {seq, expected_key} <- f_keys do - state_with_buffer = %{state | input_buffer: seq} - {:ok, event, _} = Raw.poll_event(state_with_buffer, 0) - assert event.key == expected_key - end - end - - test "has documentation", %{state: _state} do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(Raw) - - poll_doc = - Enum.find(docs, fn - {{:function, :poll_event, 2}, _, _, _, _} -> true - _ -> false - end) - - assert poll_doc != nil - {{:function, :poll_event, 2}, _, _, %{"en" => doc}, _} = poll_doc - assert doc =~ "Polls for input events" - assert doc =~ "timeout" - end - end - - describe "poll_event/2 escape sequence timeout" do - setup do - {:ok, state} = Raw.init(size: {24, 80}) - %{state: state} - end - - test "lone ESC in buffer emits escape key event", %{state: state} do - # Just ESC byte - partial sequence - state_with_buffer = %{state | input_buffer: <<27>>} - - # With zero timeout, partial escape should emit escape key - result = Raw.poll_event(state_with_buffer, 0) - - case result do - {:ok, event, new_state} -> - assert event.key == :escape - assert new_state.input_buffer == <<>> - - {:timeout, _} -> - # Also acceptable if implementation waits for more input - :ok - end - end - end - - describe "stub callbacks" do - # Use setup to avoid repeating Raw.init([]) in every test - setup do - {:ok, state} = Raw.init(size: {24, 80}) - %{state: state} - end - - test "shutdown/1 returns :ok", %{state: state} do - assert :ok = Raw.shutdown(state) - end - end - - # ========================================================================== - # Test Helpers - # ========================================================================== - - # Asserts that all state fields except the specified ones are unchanged. - # Example: assert_state_unchanged_except(original, updated, [:cursor_position]) - defp assert_state_unchanged_except(original, updated, changed_fields) do - all_fields = [ - :size, - :cursor_visible, - :cursor_position, - :alternate_screen, - :mouse_mode, - :current_style, - :optimize_cursor, - :input_buffer, - :event_queue - ] - - for field <- all_fields, field not in changed_fields do - assert Map.get(updated, field) == Map.get(original, field), - "Expected #{field} to be unchanged, got #{inspect(Map.get(updated, field))} instead of #{inspect(Map.get(original, field))}" - end - end - - # Executes a test function with LINES and COLUMNS environment variables set, - # ensuring cleanup even if the test fails. - defp with_terminal_env(lines, cols, fun) do - System.put_env("LINES", to_string(lines)) - System.put_env("COLUMNS", to_string(cols)) - - try do - fun.() - after - System.delete_env("LINES") - System.delete_env("COLUMNS") - end - end - - # ========================================================================== - # Additional Cursor Optimization Tests - # ========================================================================== - - describe "cursor optimization - large distance behavior" do - setup do - {:ok, state} = Raw.init(size: {24, 80}, optimize_cursor: true) - %{state: state} - end - - test "optimizer uses absolute positioning for large horizontal moves", %{state: state} do - # Move to initial position - {:ok, state2} = Raw.move_cursor(state, {10, 10}) - - # Large move right (60 columns) - optimizer should prefer absolute - {:ok, state3} = Raw.move_cursor(state2, {10, 70}) - assert state3.cursor_position == {10, 70} - - # Verify state preservation - assert_state_unchanged_except(state2, state3, [:cursor_position]) - end - - test "optimizer uses absolute positioning for large vertical moves", %{state: state} do - # Move to initial position - {:ok, state2} = Raw.move_cursor(state, {5, 40}) - - # Large move down (15 rows) - optimizer should prefer absolute - {:ok, state3} = Raw.move_cursor(state2, {20, 40}) - assert state3.cursor_position == {20, 40} - - # Verify state preservation - assert_state_unchanged_except(state2, state3, [:cursor_position]) - end - - test "optimizer handles diagonal moves", %{state: state} do - # Move to initial position - {:ok, state2} = Raw.move_cursor(state, {5, 5}) - - # Diagonal move - optimizer should calculate best path - {:ok, state3} = Raw.move_cursor(state2, {15, 50}) - assert state3.cursor_position == {15, 50} - end - - test "optimizer handles home position special case", %{state: state} do - # Move to arbitrary position - {:ok, state2} = Raw.move_cursor(state, {20, 40}) - - # Move back to home - optimizer should recognize ESC[H is cheaper - {:ok, state3} = Raw.move_cursor(state2, {1, 1}) - assert state3.cursor_position == {1, 1} - end - end - - describe "cursor state preservation with helper" do - setup do - {:ok, state} = Raw.init(size: {24, 80}) - %{state: state} - end - - test "move_cursor preserves all other fields", %{state: state} do - {:ok, updated} = Raw.move_cursor(state, {10, 20}) - assert_state_unchanged_except(state, updated, [:cursor_position]) - end - - test "hide_cursor preserves all other fields", %{state: state} do - {:ok, visible} = Raw.show_cursor(state) - {:ok, hidden} = Raw.hide_cursor(visible) - assert_state_unchanged_except(visible, hidden, [:cursor_visible]) - end - - test "show_cursor preserves all other fields", %{state: state} do - {:ok, visible} = Raw.show_cursor(state) - assert_state_unchanged_except(state, visible, [:cursor_visible]) - end - end - - # =========================================================================== - # Section 2.8: Mouse Tracking - # =========================================================================== - - describe "enable_mouse/2 callback" do - setup do - # Override ConPTY detection so mouse tracking tests run on all platforms - key = {TermUI.TerminalOutput, :needs_hard_reset} - original = :persistent_term.get(key, :unset) - :persistent_term.put(key, false) - - on_exit(fn -> - if original == :unset, - do: :persistent_term.erase(key), - else: :persistent_term.put(key, original) - end) - - {:ok, state} = Raw.init(size: {24, 80}, alternate_screen: false) - %{state: state} - end - - test "enables click tracking mode", %{state: state} do - assert state.mouse_mode == :none - {:ok, updated} = Raw.enable_mouse(state, :click) - assert updated.mouse_mode == :click - end - - test "enables drag tracking mode", %{state: state} do - {:ok, updated} = Raw.enable_mouse(state, :drag) - assert updated.mouse_mode == :drag - end - - test "enables all movement tracking mode", %{state: state} do - {:ok, updated} = Raw.enable_mouse(state, :all) - assert updated.mouse_mode == :all - end - - test "is idempotent - same mode returns unchanged state", %{state: state} do - {:ok, with_click} = Raw.enable_mouse(state, :click) - {:ok, same} = Raw.enable_mouse(with_click, :click) - assert same == with_click - end - - test "can switch between modes", %{state: state} do - {:ok, click} = Raw.enable_mouse(state, :click) - assert click.mouse_mode == :click - - {:ok, drag} = Raw.enable_mouse(click, :drag) - assert drag.mouse_mode == :drag - - {:ok, all} = Raw.enable_mouse(drag, :all) - assert all.mouse_mode == :all - end - - test "preserves all other state fields", %{state: state} do - {:ok, updated} = Raw.enable_mouse(state, :click) - assert_state_unchanged_except(state, updated, [:mouse_mode]) - end - - test "has documentation" do - {:docs_v1, _, _, _, _, _, docs} = Code.fetch_docs(Raw) - - enable_mouse_docs = - Enum.find(docs, fn - {{:function, :enable_mouse, 2}, _, _, _, _} -> true - _ -> false - end) - - assert enable_mouse_docs != nil - {{:function, :enable_mouse, 2}, _, _, doc, _} = enable_mouse_docs - assert doc != :hidden - assert doc != :none - end - end - - describe "enable_mouse/2 escape sequences" do - # These tests verify the correct escape sequences are emitted - # by checking that init with mouse_tracking option produces expected state - - setup do - # Override ConPTY detection so mouse tracking tests run on all platforms - key = {TermUI.TerminalOutput, :needs_hard_reset} - original = :persistent_term.get(key, :unset) - :persistent_term.put(key, false) - - on_exit(fn -> - if original == :unset, - do: :persistent_term.erase(key), - else: :persistent_term.put(key, original) - end) - - :ok - end - - test "click mode maps to ANSI normal mode (1000)" do - {:ok, state} = Raw.init(size: {24, 80}, mouse_tracking: :click, alternate_screen: false) - assert state.mouse_mode == :click - # The ANSI mapping is verified through mouse_mode_to_ansi - assert Raw.mouse_mode_to_ansi(:click) == :normal - end - - test "drag mode maps to ANSI button mode (1002)" do - {:ok, state} = Raw.init(size: {24, 80}, mouse_tracking: :drag, alternate_screen: false) - assert state.mouse_mode == :drag - assert Raw.mouse_mode_to_ansi(:drag) == :button - end - - test "all mode maps to ANSI all mode (1003)" do - {:ok, state} = Raw.init(size: {24, 80}, mouse_tracking: :all, alternate_screen: false) - assert state.mouse_mode == :all - assert Raw.mouse_mode_to_ansi(:all) == :all - end - end - - describe "disable_mouse/1 callback" do - setup do - # Override ConPTY detection so mouse tracking tests run on all platforms - key = {TermUI.TerminalOutput, :needs_hard_reset} - original = :persistent_term.get(key, :unset) - :persistent_term.put(key, false) - - on_exit(fn -> - if original == :unset, - do: :persistent_term.erase(key), - else: :persistent_term.put(key, original) - end) - - {:ok, state} = Raw.init(size: {24, 80}, alternate_screen: false) - %{state: state} - end - - test "disables mouse tracking from click mode", %{state: state} do - {:ok, with_click} = Raw.enable_mouse(state, :click) - assert with_click.mouse_mode == :click - - {:ok, disabled} = Raw.disable_mouse(with_click) - assert disabled.mouse_mode == :none - end - - test "disables mouse tracking from drag mode", %{state: state} do - {:ok, with_drag} = Raw.enable_mouse(state, :drag) - {:ok, disabled} = Raw.disable_mouse(with_drag) - assert disabled.mouse_mode == :none - end - - test "disables mouse tracking from all mode", %{state: state} do - {:ok, with_all} = Raw.enable_mouse(state, :all) - {:ok, disabled} = Raw.disable_mouse(with_all) - assert disabled.mouse_mode == :none - end - - test "is idempotent - already disabled returns unchanged state", %{state: state} do - assert state.mouse_mode == :none - {:ok, same} = Raw.disable_mouse(state) - assert same == state - end - - test "preserves all other state fields", %{state: state} do - {:ok, with_click} = Raw.enable_mouse(state, :click) - {:ok, disabled} = Raw.disable_mouse(with_click) - assert_state_unchanged_except(with_click, disabled, [:mouse_mode]) - end - - test "has documentation" do - {:docs_v1, _, _, _, _, _, docs} = Code.fetch_docs(Raw) - - disable_mouse_docs = - Enum.find(docs, fn - {{:function, :disable_mouse, 1}, _, _, _, _} -> true - _ -> false - end) - - assert disable_mouse_docs != nil - {{:function, :disable_mouse, 1}, _, _, doc, _} = disable_mouse_docs - assert doc != :hidden - assert doc != :none - end - end - - describe "enable_mouse/2 and disable_mouse/1 integration" do - setup do - # Override ConPTY detection so mouse tracking tests run on all platforms - key = {TermUI.TerminalOutput, :needs_hard_reset} - original = :persistent_term.get(key, :unset) - :persistent_term.put(key, false) - - on_exit(fn -> - if original == :unset, - do: :persistent_term.erase(key), - else: :persistent_term.put(key, original) - end) - - {:ok, state} = Raw.init(size: {24, 80}, alternate_screen: false) - %{state: state} - end - - test "can enable, disable, and re-enable mouse tracking", %{state: state} do - # Enable click - {:ok, click} = Raw.enable_mouse(state, :click) - assert click.mouse_mode == :click - - # Disable - {:ok, disabled} = Raw.disable_mouse(click) - assert disabled.mouse_mode == :none - - # Re-enable with different mode - {:ok, all} = Raw.enable_mouse(disabled, :all) - assert all.mouse_mode == :all - end - - test "enable after disable works correctly", %{state: state} do - {:ok, click} = Raw.enable_mouse(state, :click) - {:ok, disabled} = Raw.disable_mouse(click) - {:ok, drag} = Raw.enable_mouse(disabled, :drag) - assert drag.mouse_mode == :drag - end - end - - describe "mouse event parsing via EscapeParser" do - # These tests verify that EscapeParser correctly parses SGR mouse sequences - # which are used by poll_event/2 when mouse tracking is enabled - - alias TermUI.Event - alias TermUI.Terminal.EscapeParser - - test "parses left button press" do - # ESC [ < 0 ; 10 ; 20 M (left button press at col 10, row 20) - input = "\e[<0;10;20M" - {events, remaining} = EscapeParser.parse(input) - - assert remaining == "" - assert length(events) == 1 - [event] = events - - assert %Event.Mouse{} = event - assert event.action == :press - assert event.button == :left - # 0-indexed - assert event.x == 9 - # 0-indexed - assert event.y == 19 - assert event.modifiers == [] - end - - test "parses middle button press" do - input = "\e[<1;15;25M" - {[event], ""} = EscapeParser.parse(input) - - assert event.action == :press - assert event.button == :middle - assert event.x == 14 - assert event.y == 24 - end - - test "parses right button press" do - input = "\e[<2;5;5M" - {[event], ""} = EscapeParser.parse(input) - - assert event.action == :press - assert event.button == :right - assert event.x == 4 - assert event.y == 4 - end - - test "parses button release" do - # Lowercase 'm' indicates release - input = "\e[<0;10;20m" - {[event], ""} = EscapeParser.parse(input) - - assert event.action == :release - # Note: On release, we default to :left since button info is often lost - assert event.button == :left - assert event.x == 9 - assert event.y == 19 - end - - test "parses scroll up" do - # Bit 6 (64) + button 0 = scroll up - input = "\e[<64;10;20M" - {[event], ""} = EscapeParser.parse(input) - - assert event.action == :scroll_up - assert event.button == nil - assert event.x == 9 - assert event.y == 19 - end - - test "parses scroll down" do - # Bit 6 (64) + button 1 = scroll down - input = "\e[<65;10;20M" - {[event], ""} = EscapeParser.parse(input) - - assert event.action == :scroll_down - assert event.button == nil - end - - test "parses drag event" do - # Bit 5 (32) indicates motion, combined with button press - input = "\e[<32;15;25M" - {[event], ""} = EscapeParser.parse(input) - - assert event.action == :drag - assert event.button == :left - end - - test "parses shift modifier" do - # Bit 2 (4) = shift - input = "\e[<4;10;20M" - {[event], ""} = EscapeParser.parse(input) - - assert :shift in event.modifiers - end - - test "parses alt modifier" do - # Bit 3 (8) = alt - input = "\e[<8;10;20M" - {[event], ""} = EscapeParser.parse(input) - - assert :alt in event.modifiers - end - - test "parses ctrl modifier" do - # Bit 4 (16) = ctrl - input = "\e[<16;10;20M" - {[event], ""} = EscapeParser.parse(input) - - assert :ctrl in event.modifiers - end - - test "parses multiple modifiers" do - # Shift (4) + Alt (8) + Ctrl (16) = 28 - input = "\e[<28;10;20M" - {[event], ""} = EscapeParser.parse(input) - - assert :shift in event.modifiers - assert :alt in event.modifiers - assert :ctrl in event.modifiers - end - - test "handles incomplete mouse sequence" do - # Incomplete - no terminator - input = "\e[<0;10;20" - {events, remaining} = EscapeParser.parse(input) - - assert events == [] - assert remaining == "\e[<0;10;20" - end - - test "converts 1-indexed coords to 0-indexed" do - # Terminal sends 1-indexed coordinates - # Top-left corner - input = "\e[<0;1;1M" - {[event], ""} = EscapeParser.parse(input) - - assert event.x == 0 - assert event.y == 0 - end - - test "rejects out-of-bounds coordinates" do - # Huge coordinates should be rejected - input = "\e[<0;99999999;99999999M" - {events, _remaining} = EscapeParser.parse(input) - - # Should not produce a valid mouse event - assert events == [] or - Enum.all?(events, fn e -> not match?(%{__struct__: TermUI.Event.Mouse}, e) end) - end - - test "rejects negative coordinates" do - # Negative coordinates (invalid) - input = "\e[<0;-1;-1M" - {events, _remaining} = EscapeParser.parse(input) - - # Should not produce a valid mouse event with negative coords - assert Enum.empty?(events) or - not Enum.any?(events, fn e -> - match?(%{__struct__: TermUI.Event.Mouse, x: x, y: y} when x < 0 or y < 0, e) - end) - end - end - - # =========================================================================== - # Section: ANSI Output Verification Tests - # =========================================================================== - - describe "ANSI output verification" do - import ExUnit.CaptureIO - - setup do - # Override ConPTY detection so mouse tracking tests run on all platforms - key = {TermUI.TerminalOutput, :needs_hard_reset} - original = :persistent_term.get(key, :unset) - :persistent_term.put(key, false) - - on_exit(fn -> - if original == :unset, - do: :persistent_term.erase(key), - else: :persistent_term.put(key, original) - end) - - {:ok, state} = Raw.init(size: {24, 80}, alternate_screen: false) - %{state: state} - end - - test "move_cursor emits correct ANSI sequence", %{state: state} do - output = - capture_io(fn -> - {:ok, _state} = Raw.move_cursor(state, {10, 20}) - end) - - # Should contain cursor position sequence ESC[row;colH - assert output =~ "\e[10;20H" - end - - test "hide_cursor emits correct ANSI sequence", %{state: state} do - # First show cursor so we can hide it - {:ok, state} = Raw.show_cursor(state) - - output = - capture_io(fn -> - {:ok, _state} = Raw.hide_cursor(state) - end) - - # Should contain cursor hide sequence ESC[?25l - assert output =~ "\e[?25l" - end - - test "show_cursor emits correct ANSI sequence", %{state: state} do - # state already has cursor hidden - output = - capture_io(fn -> - {:ok, _state} = Raw.show_cursor(state) - end) - - # Should contain cursor show sequence ESC[?25h - assert output =~ "\e[?25h" - end - - test "draw_cells emits cursor position for single cell", %{state: state} do - cells = [{{5, 10}, {"X", :default, :default, []}}] - - output = - capture_io(fn -> - {:ok, _state} = Raw.draw_cells(state, cells) - end) - - # Should position cursor and output character - assert output =~ "\e[5;10H" - assert output =~ "X" - end - - test "draw_cells emits foreground color sequence", %{state: state} do - cells = [{{1, 1}, {"R", :red, :default, []}}] - - output = - capture_io(fn -> - {:ok, _state} = Raw.draw_cells(state, cells) - end) - - # Should contain red foreground (SGR 31) - assert output =~ "\e[31m" or output =~ "31" - end - - test "draw_cells emits 256-color sequence", %{state: state} do - # Color index 196 (bright red in 256-color) - cells = [{{1, 1}, {"C", 196, :default, []}}] - - output = - capture_io(fn -> - {:ok, _state} = Raw.draw_cells(state, cells) - end) - - # Should contain 256-color sequence ESC[38;5;196m - assert output =~ "38;5;196" - end - - test "draw_cells emits RGB color sequence", %{state: state} do - cells = [{{1, 1}, {"T", {255, 128, 64}, :default, []}}] - - output = - capture_io(fn -> - {:ok, _state} = Raw.draw_cells(state, cells) - end) - - # Should contain true color sequence ESC[38;2;R;G;Bm - assert output =~ "38;2;255;128;64" - end - - test "draw_cells emits bold attribute", %{state: state} do - cells = [{{1, 1}, {"B", :default, :default, [:bold]}}] - - output = - capture_io(fn -> - {:ok, _state} = Raw.draw_cells(state, cells) - end) - - # Should contain bold sequence (SGR 1) - assert output =~ "\e[1m" or output =~ "[1m" - end - - test "enable_mouse emits tracking sequence", %{state: state} do - output = - capture_io(fn -> - {:ok, _state} = Raw.enable_mouse(state, :click) - end) - - # Should contain mouse tracking enable (mode 1000) - assert output =~ "1000h" - # Should contain SGR mouse enable (mode 1006) - assert output =~ "1006h" - end - - test "disable_mouse emits tracking disable sequence", %{state: state} do - {:ok, state} = Raw.enable_mouse(state, :click) - - output = - capture_io(fn -> - {:ok, _state} = Raw.disable_mouse(state) - end) - - # Should contain mouse tracking disable - assert output =~ "1006l" - assert output =~ "1000l" - end - end - - # =========================================================================== - # Section: Run Coalescing Tests - # =========================================================================== - - describe "draw_cells/2 run coalescing" do - import ExUnit.CaptureIO - - setup do - key = {TermUI.TerminalOutput, :needs_hard_reset} - original = :persistent_term.get(key, :unset) - :persistent_term.put(key, false) - - on_exit(fn -> - if original == :unset, - do: :persistent_term.erase(key), - else: :persistent_term.put(key, original) - end) - - # Init inside capture_io to discard setup sequences (cursor hide, clear, etc.) - ExUnit.CaptureIO.capture_io(fn -> - {:ok, s} = Raw.init(size: {24, 80}, alternate_screen: false) - send(self(), {:state, s}) - end) - - state = - receive do - {:state, s} -> s - end - - %{state: state} - end - - test "adjacent cells on same row use single cursor position", %{state: state} do - # 3 adjacent cells: col 5, 6, 7 - cells = [ - {{1, 5}, {"A", :default, :default, []}}, - {{1, 6}, {"B", :default, :default, []}}, - {{1, 7}, {"C", :default, :default, []}} - ] - - output = - capture_io(fn -> - {:ok, _state} = Raw.draw_cells(state, cells) - end) - - # Should have exactly ONE cursor position sequence for this run - cursor_positions = Regex.scan(~r/\e\[\d+;\d+H/, output) - assert length(cursor_positions) == 1 - assert output =~ "\e[1;5H" - - # Characters should appear in sequence without cursor moves between them - assert output =~ "ABC" - end - - test "non-adjacent cells on same row get separate cursor positions", %{state: state} do - # Gap between col 3 and col 10 - cells = [ - {{1, 3}, {"X", :default, :default, []}}, - {{1, 10}, {"Y", :default, :default, []}} - ] - - output = - capture_io(fn -> - {:ok, _state} = Raw.draw_cells(state, cells) - end) - - # Should have TWO cursor position sequences (one per run) - cursor_positions = Regex.scan(~r/\e\[\d+;\d+H/, output) - assert length(cursor_positions) == 2 - assert output =~ "\e[1;3H" - assert output =~ "\e[1;10H" - end - - test "style change within a run emits inline SGR without cursor reposition", %{state: state} do - # Adjacent cells at col 5,6,7 (not at cursor start) with different styles - cells = [ - {{1, 5}, {"R", :red, :default, []}}, - {{1, 6}, {"G", :green, :default, []}}, - {{1, 7}, {"B", :blue, :default, []}} - ] - - output = - capture_io(fn -> - {:ok, _state} = Raw.draw_cells(state, cells) - end) - - # Should have exactly ONE cursor position (start of run) - cursor_positions = Regex.scan(~r/\e\[\d+;\d+H/, output) - assert length(cursor_positions) == 1 - - # All three characters should be present - assert output =~ "R" - assert output =~ "G" - assert output =~ "B" - end - - test "multiple runs on same row coalesce independently", %{state: state} do - # Two runs: cols 5-7 and cols 15-17 (neither at cursor start {1,1}) - cells = [ - {{1, 5}, {"A", :default, :default, []}}, - {{1, 6}, {"B", :default, :default, []}}, - {{1, 7}, {"C", :default, :default, []}}, - {{1, 15}, {"X", :default, :default, []}}, - {{1, 16}, {"Y", :default, :default, []}}, - {{1, 17}, {"Z", :default, :default, []}} - ] - - output = - capture_io(fn -> - {:ok, _state} = Raw.draw_cells(state, cells) - end) - - # Exactly 2 cursor positions (one per run) - cursor_positions = Regex.scan(~r/\e\[\d+;\d+H/, output) - assert length(cursor_positions) == 2 - - # Characters should be streamed contiguously within each run - assert output =~ "ABC" - assert output =~ "XYZ" - end - - test "cells across rows each get cursor position for their run", %{state: state} do - cells = [ - {{2, 1}, {"A", :default, :default, []}}, - {{2, 2}, {"B", :default, :default, []}}, - {{3, 1}, {"C", :default, :default, []}}, - {{3, 2}, {"D", :default, :default, []}} - ] - - output = - capture_io(fn -> - {:ok, _state} = Raw.draw_cells(state, cells) - end) - - # 2 cursor positions: one for row 2 run, one for row 3 run - cursor_positions = Regex.scan(~r/\e\[\d+;\d+H/, output) - assert length(cursor_positions) == 2 - assert output =~ "\e[2;1H" - assert output =~ "\e[3;1H" - assert output =~ "AB" - assert output =~ "CD" - end - - test "single cell still works correctly", %{state: state} do - cells = [{{5, 10}, {"Z", :red, :default, [:bold]}}] - - output = - capture_io(fn -> - {:ok, result} = Raw.draw_cells(state, cells) - assert result.cursor_position == {5, 11} - assert result.current_style.fg == :red - end) - - assert output =~ "\e[5;10H" - assert output =~ "Z" - end - - test "full row of same style produces minimal output", %{state: state} do - # 40 adjacent cells on row 2 (not at cursor start), same style - cells = - for col <- 1..40 do - {{2, col}, {"X", :green, :default, []}} - end - - output = - capture_io(fn -> - {:ok, _state} = Raw.draw_cells(state, cells) - end) - - # Exactly 1 cursor position for the entire run - cursor_positions = Regex.scan(~r/\e\[\d+;\d+H/, output) - assert length(cursor_positions) == 1 - - # All 40 X characters should be contiguous - assert output =~ String.duplicate("X", 40) - end - - test "run coalescing reduces byte count vs per-cell positioning", %{state: state} do - # 20 adjacent cells on row 5 (not at cursor start), same style - measure output size - cells = - for col <- 1..20 do - {{5, col}, {"A", :default, :default, []}} - end - - output = - capture_io(fn -> - {:ok, _state} = Raw.draw_cells(state, cells) - end) - - output_bytes = byte_size(output) - - # Per-cell positioning would need ~10 bytes per cursor move * 20 cells = 200+ bytes - # Coalesced: 1 cursor position (~6 bytes) + 20 chars = ~26 bytes + style overhead - # Should be well under 100 bytes for 20 same-style chars - assert output_bytes < 100, - "Expected < 100 bytes for 20 coalesced cells, got #{output_bytes}" - end - - test "style continuity across runs avoids redundant SGR", %{state: state} do - # Two separate runs with same style on row 3 (not at cursor start) - cells = [ - {{3, 1}, {"A", :red, :default, [:bold]}}, - {{3, 2}, {"B", :red, :default, [:bold]}}, - # gap - {{3, 10}, {"C", :red, :default, [:bold]}}, - {{3, 11}, {"D", :red, :default, [:bold]}} - ] - - output = - capture_io(fn -> - {:ok, _state} = Raw.draw_cells(state, cells) - end) - - # Style is set once at the beginning; second run should NOT re-emit the same style - # Count red foreground sequences (SGR 31) - red_seqs = Regex.scan(~r/\e\[31m/, output) - assert length(red_seqs) == 1, "Expected 1 red fg sequence, got #{length(red_seqs)}" - end - - test "no per-row style reset when style continues", %{state: state} do - # Same style across two rows - should not emit reset between them - cells = [ - {{3, 1}, {"A", :red, :default, []}}, - {{4, 1}, {"B", :red, :default, []}} - ] - - output = - capture_io(fn -> - {:ok, _state} = Raw.draw_cells(state, cells) - end) - - # Should NOT contain any ESC[0m reset sequences - reset_count = length(Regex.scan(~r/\e\[0m/, output)) - - assert reset_count == 0, - "Expected 0 resets for same-style cross-row cells, got #{reset_count}" - end - - test "reset emitted only when attributes are removed", %{state: state} do - cells = [ - {{3, 1}, {"A", :default, :default, [:bold, :italic]}}, - {{3, 2}, {"B", :default, :default, [:bold]}} - ] - - output = - capture_io(fn -> - {:ok, _state} = Raw.draw_cells(state, cells) - end) - - # Should emit exactly one reset (when italic is removed) - reset_count = length(Regex.scan(~r/\e\[0m/, output)) - assert reset_count == 1 - end - - test "unsorted cells are sorted before coalescing", %{state: state} do - # Pass cells in reverse order on row 3 (not at cursor start) - cells = [ - {{3, 3}, {"C", :default, :default, []}}, - {{3, 1}, {"A", :default, :default, []}}, - {{3, 2}, {"B", :default, :default, []}} - ] - - output = - capture_io(fn -> - {:ok, _state} = Raw.draw_cells(state, cells) - end) - - # Should coalesce into single run after sorting - cursor_positions = Regex.scan(~r/\e\[\d+;\d+H/, output) - assert length(cursor_positions) == 1 - assert output =~ "ABC" - end - - test "mixed rows with runs produce correct output structure", %{state: state} do - cells = [ - # Row 2: two runs (not at cursor start {1,1}) - {{2, 1}, {"A", :red, :default, []}}, - {{2, 2}, {"B", :red, :default, []}}, - {{2, 10}, {"C", :blue, :default, []}}, - # Row 4: one run (skip row 3) - {{4, 5}, {"D", :green, :default, []}}, - {{4, 6}, {"E", :green, :default, []}}, - {{4, 7}, {"F", :green, :default, []}} - ] - - output = - capture_io(fn -> - {:ok, result} = Raw.draw_cells(state, cells) - # Final cursor at end of last cell - assert result.cursor_position == {4, 8} - end) - - # 3 runs = 3 cursor positions - cursor_positions = Regex.scan(~r/\e\[\d+;\d+H/, output) - assert length(cursor_positions) == 3 - - # Verify character groupings - assert output =~ "AB" - assert output =~ "DEF" - end - - test "full screen render is efficient", %{state: state} do - # 24 rows x 80 cols, all same style - cells = - for row <- 1..24, col <- 1..80 do - {{row, col}, {"X", :default, :default, []}} - end - - output = - capture_io(fn -> - {:ok, result} = Raw.draw_cells(state, cells) - assert result.cursor_position == {24, 81} - end) - - # Row 1 starts at cursor {1,1} so no move needed: 23 explicit positions - cursor_positions = Regex.scan(~r/\e\[\d+;\d+H/, output) - assert length(cursor_positions) == 23 - - # Output should contain 24 runs of 80 X's - x_runs = Regex.scan(~r/X{80}/, output) - assert length(x_runs) == 24 - - # Total output should be much less than per-cell approach - # Per-cell: ~10 bytes cursor * 1920 + 1920 chars = ~21,000 bytes - # Coalesced: ~8 bytes cursor * 23 + 1920 chars + style = ~2,200 bytes - assert byte_size(output) < 5000, - "Full screen output #{byte_size(output)} bytes, expected < 5000" - end - end - - # =========================================================================== - # Section: Security Limits Tests - # =========================================================================== - - describe "security limits" do - test "input buffer has size limit constant defined" do - # Verify the constant exists and is reasonable - # We check this indirectly by ensuring module compiles with the constant - assert is_integer(1024) - end - - test "event queue has size limit constant defined" do - # Verify the constant exists and is reasonable - assert is_integer(100) - end - end - - # =========================================================================== - # Section: CursorOptimizer Error Handling - # =========================================================================== - - describe "cursor optimization error handling" do - import ExUnit.CaptureIO - - setup do - {:ok, state} = Raw.init(size: {80, 24}) - %{state: state} - end - - test "cursor optimization uses fallback when optimizer is disabled", %{state: state} do - # Disable cursor optimization - state = %{state | optimize_cursor: false, cursor_position: {5, 10}} - - # Move cursor and verify absolute positioning is used - output = - capture_io(fn -> - {:ok, _state} = Raw.move_cursor(state, {10, 20}) - end) - - # Should use absolute positioning when optimization is disabled - assert output =~ "10;20H" - end - - test "cursor optimization works when enabled", %{state: state} do - # Enable cursor optimization - state = %{state | optimize_cursor: true, cursor_position: {5, 10}} - - output = - capture_io(fn -> - {:ok, _state} = Raw.move_cursor(state, {5, 15}) - end) - - # Should produce cursor movement sequence (relative movement is more efficient - # for short moves on same row) - assert byte_size(output) > 0 - end - - test "cursor movement works from nil position", %{state: state} do - # Ensure no previous position - state = %{state | cursor_position: nil, optimize_cursor: true} - - output = - capture_io(fn -> - {:ok, _state} = Raw.move_cursor(state, {10, 20}) - end) - - # Should use absolute positioning when no previous position - assert output =~ "10;20H" - end - end -end diff --git a/test/term_ui/backend/renderer_test.exs b/test/term_ui/backend/renderer_test.exs new file mode 100644 index 00000000..2e8f8c99 --- /dev/null +++ b/test/term_ui/backend/renderer_test.exs @@ -0,0 +1,71 @@ +defmodule TermUI.Backend.RendererTest do + use ExUnit.Case, async: true + + alias TermUI.Backend.Renderer + + test "renders a dense row as one cursor run with bounded output" do + output = + [ + {{1, 1}, {"a", :default, :default, []}}, + {{1, 2}, {"b", :default, :default, []}}, + {{1, 3}, {"c", :default, :default, []}}, + {{1, 4}, {"d", :default, :default, []}} + ] + |> Renderer.render(:true_color, :unicode) + |> IO.iodata_to_binary() + + assert output == "\e[1;1H\e[0mabcd\e[0m" + assert byte_size(output) == 18 + end + + test "changes terminal style only at a style boundary" do + output = + [ + {{1, 1}, {"x", :red, :default, [:bold]}}, + {{1, 2}, {"y", :red, :default, [:bold]}}, + {{1, 3}, {"z", :green, :default, [:bold]}} + ] + |> Renderer.render(:true_color, :unicode) + |> IO.iodata_to_binary() + + assert output == "\e[1;1H\e[0m\e[31m\e[1mxy\e[0m\e[32m\e[1mz\e[0m" + end + + test "starts a new run for sparse row and column gaps while rendering erased cells" do + output = + [ + {{1, 1}, {"a", :default, :default, []}}, + {{1, 3}, {" ", :default, :default, []}}, + {{2, 1}, {"b", :default, :default, []}} + ] + |> Renderer.render(:true_color, :unicode) + |> IO.iodata_to_binary() + + assert output == "\e[1;1H\e[0ma\e[1;3H \e[2;1Hb\e[0m" + end + + test "keeps a wide grapheme and its following narrow cell in one run" do + output = + [ + {{1, 1}, {"界", :default, :default, []}}, + {{1, 3}, {"b", :default, :default, []}} + ] + |> Renderer.render(:true_color, :unicode) + |> IO.iodata_to_binary() + + assert output == "\e[1;1H\e[0m界b\e[0m" + refute output =~ "\e[1;3H" + end + + test "maps adjacent Unicode box drawing cells to ASCII" do + output = + [ + {{1, 1}, {"┌", :default, :default, []}}, + {{1, 2}, {"─", :default, :default, []}} + ] + |> Renderer.render(:true_color, :ascii) + |> IO.iodata_to_binary() + + assert output == "\e[1;1H\e[0m+-\e[0m" + end +end diff --git a/test/term_ui/backend/selector_test.exs b/test/term_ui/backend/selector_test.exs index 45c2594b..fea4ba29 100644 --- a/test/term_ui/backend/selector_test.exs +++ b/test/term_ui/backend/selector_test.exs @@ -5,6 +5,11 @@ defmodule TermUI.Backend.SelectorTest do alias TermUI.Backend.Selector import TermUI.Backend.SelectorTestHelpers + setup_all do + Code.ensure_loaded!(Selector) + :ok + end + describe "module structure" do test "module compiles successfully" do assert Code.ensure_loaded?(Selector) @@ -20,34 +25,9 @@ defmodule TermUI.Backend.SelectorTest do end describe "documentation" do - test "module has comprehensive moduledoc" do + test "module stays out of the public documentation" do {:docs_v1, _, :elixir, _, module_doc, _, _} = Code.fetch_docs(Selector) - assert module_doc != :none - assert module_doc != :hidden - - %{"en" => doc} = module_doc - - # Check key documentation topics are covered - assert String.contains?(doc, "try raw mode first"), - "Should document the selection strategy" - - assert String.contains?(doc, "heuristics") or String.contains?(doc, "Heuristics"), - "Should explain why heuristics are insufficient" - - assert String.contains?(doc, "Nerves"), - "Should mention Nerves as an example" - - assert String.contains?(doc, "SSH"), - "Should mention SSH sessions as an example" - - assert String.contains?(doc, "IEx") or String.contains?(doc, "remsh"), - "Should mention remote IEx as an example" - - assert String.contains?(doc, "{:raw, state}"), - "Should document raw return value" - - assert String.contains?(doc, "{:tty, capabilities}"), - "Should document tty return value" + assert module_doc == :hidden end test "select/0 has documentation" do @@ -295,6 +275,7 @@ defmodule TermUI.Backend.SelectorTest do test "function exports attempt_raw_mode for testability" do # attempt_raw_mode is exported (doc false) to allow testing the core logic + Code.ensure_loaded!(Selector) assert function_exported?(Selector, :attempt_raw_mode, 0) end end diff --git a/test/term_ui/backend/ssh_test.exs b/test/term_ui/backend/ssh_test.exs deleted file mode 100644 index 589880e6..00000000 --- a/test/term_ui/backend/ssh_test.exs +++ /dev/null @@ -1,528 +0,0 @@ -defmodule TermUI.Backend.SSHTest do - use ExUnit.Case, async: true - - alias TermUI.Backend.SSH - - # Helper: init SSH backend with a StringIO device to capture output - defp init_ssh(opts \\ []) do - {:ok, device} = StringIO.open("") - opts = Keyword.put_new(opts, :device, device) - {:ok, state} = SSH.init(opts) - {state, device} - end - - # Helper: read all output written to the StringIO device - defp device_output(device) do - {_input, output} = StringIO.contents(device) - output - end - - # =========================================================================== - # Module Structure - # =========================================================================== - - describe "behaviour declaration" do - test "module declares @behaviour TermUI.Backend" do - behaviours = SSH.__info__(:attributes)[:behaviour] || [] - assert TermUI.Backend in behaviours - end - - test "module compiles without warnings" do - assert Code.ensure_loaded?(SSH) - end - end - - # =========================================================================== - # State Struct - # =========================================================================== - - describe "state struct defaults" do - test "has device field with default nil" do - state = %SSH{} - assert state.device == nil - end - - test "has size field with default {24, 80}" do - state = %SSH{} - assert state.size == {24, 80} - end - - test "has cursor_visible field with default false" do - state = %SSH{} - assert state.cursor_visible == false - end - - test "has cursor_position field with default nil" do - state = %SSH{} - assert state.cursor_position == nil - end - - test "has alternate_screen field with default false" do - state = %SSH{} - assert state.alternate_screen == false - end - - test "has mouse_mode field with default :none" do - state = %SSH{} - assert state.mouse_mode == :none - end - - test "has current_style field with default nil" do - state = %SSH{} - assert state.current_style == nil - end - end - - # =========================================================================== - # init/1 - # =========================================================================== - - describe "init/1" do - test "returns {:ok, state} with device" do - {state, _device} = init_ssh() - assert %SSH{} = state - end - - test "stores device in state" do - {:ok, device} = StringIO.open("") - {:ok, state} = SSH.init(device: device) - assert state.device == device - end - - test "stores custom size" do - {state, _device} = init_ssh(size: {50, 120}) - assert state.size == {50, 120} - end - - test "defaults size to {24, 80}" do - {state, _device} = init_ssh() - assert state.size == {24, 80} - end - - test "raises on missing device" do - assert_raise KeyError, fn -> - SSH.init([]) - end - end - - test "enters alternate screen by default" do - {state, device} = init_ssh() - assert state.alternate_screen == true - assert device_output(device) =~ "\e[?1049h" - end - - test "skips alternate screen when disabled" do - {state, device} = init_ssh(alternate_screen: false) - assert state.alternate_screen == false - refute device_output(device) =~ "\e[?1049h" - end - - test "hides cursor by default" do - {_state, device} = init_ssh() - assert device_output(device) =~ "\e[?25l" - end - - test "skips hiding cursor when disabled" do - {state, device} = init_ssh(hide_cursor: false) - assert state.cursor_visible == true - refute device_output(device) =~ "\e[?25l" - end - - test "clears screen on init" do - {_state, device} = init_ssh() - output = device_output(device) - assert output =~ "\e[2J" - assert output =~ "\e[H" - end - - test "enables mouse tracking when requested" do - {state, device} = init_ssh(mouse_tracking: :click) - assert state.mouse_mode == :click - assert device_output(device) =~ "\e[?1000h" - end - - test "no mouse tracking by default" do - {state, _device} = init_ssh() - assert state.mouse_mode == :none - end - end - - # =========================================================================== - # shutdown/1 - # =========================================================================== - - describe "shutdown/1" do - test "returns :ok" do - {state, _device} = init_ssh() - assert :ok = SSH.shutdown(state) - end - - test "shows cursor on shutdown" do - {state, device} = init_ssh() - # Clear init output - StringIO.contents(device) - SSH.shutdown(state) - {_input, output} = StringIO.contents(device) - assert output =~ "\e[?25h" - end - - test "leaves alternate screen on shutdown" do - {state, device} = init_ssh() - SSH.shutdown(state) - {_input, output} = StringIO.contents(device) - assert output =~ "\e[?1049l" - end - - test "resets attributes on shutdown" do - {state, device} = init_ssh() - SSH.shutdown(state) - {_input, output} = StringIO.contents(device) - assert output =~ "\e[0m" - end - - test "disables mouse tracking on shutdown" do - {state, device} = init_ssh(mouse_tracking: :all) - SSH.shutdown(state) - {_input, output} = StringIO.contents(device) - assert output =~ "\e[?1000l" - end - - test "handles closed device gracefully" do - {state, device} = init_ssh() - StringIO.close(device) - # Should not raise - assert :ok = SSH.shutdown(state) - end - end - - # =========================================================================== - # size/1 - # =========================================================================== - - describe "size/1" do - test "returns cached size" do - {state, _device} = init_ssh(size: {40, 160}) - assert {:ok, {40, 160}} = SSH.size(state) - end - - test "returns default size" do - {state, _device} = init_ssh() - assert {:ok, {24, 80}} = SSH.size(state) - end - end - - # =========================================================================== - # update_size/3 - # =========================================================================== - - describe "update_size/3" do - test "updates size in state" do - {state, _device} = init_ssh() - {:ok, new_state} = SSH.update_size(state, 50, 120) - assert {:ok, {50, 120}} = SSH.size(new_state) - end - - test "rejects zero rows" do - {state, _device} = init_ssh() - - assert_raise FunctionClauseError, fn -> - SSH.update_size(state, 0, 80) - end - end - - test "rejects zero cols" do - {state, _device} = init_ssh() - - assert_raise FunctionClauseError, fn -> - SSH.update_size(state, 24, 0) - end - end - - test "rejects negative dimensions" do - {state, _device} = init_ssh() - - assert_raise FunctionClauseError, fn -> - SSH.update_size(state, -1, 80) - end - end - end - - # =========================================================================== - # Cursor operations - # =========================================================================== - - describe "move_cursor/2" do - test "writes cursor position sequence" do - {state, device} = init_ssh() - {:ok, _state} = SSH.move_cursor(state, {5, 10}) - assert device_output(device) =~ "\e[5;10H" - end - - test "updates cursor_position in state" do - {state, _device} = init_ssh() - {:ok, state} = SSH.move_cursor(state, {5, 10}) - assert state.cursor_position == {5, 10} - end - - test "clamps row to terminal bounds" do - {state, _device} = init_ssh(size: {24, 80}) - {:ok, state} = SSH.move_cursor(state, {100, 10}) - assert state.cursor_position == {24, 10} - end - - test "clamps col to terminal bounds" do - {state, _device} = init_ssh(size: {24, 80}) - {:ok, state} = SSH.move_cursor(state, {5, 200}) - assert state.cursor_position == {5, 80} - end - - test "clamps minimum to 1" do - {state, _device} = init_ssh() - {:ok, state} = SSH.move_cursor(state, {0, 0}) - assert state.cursor_position == {1, 1} - end - end - - describe "hide_cursor/1" do - test "writes hide sequence" do - {state, _device} = init_ssh(hide_cursor: false) - {:ok, device} = StringIO.open("") - state = %{state | device: device} - {:ok, state} = SSH.hide_cursor(state) - assert device_output(device) =~ "\e[?25l" - assert state.cursor_visible == false - end - - test "no-op when already hidden" do - {state, _device} = init_ssh() - {:ok, device} = StringIO.open("") - state = %{state | device: device, cursor_visible: false} - {:ok, _state} = SSH.hide_cursor(state) - assert device_output(device) == "" - end - end - - describe "show_cursor/1" do - test "writes show sequence" do - {state, _device} = init_ssh() - {:ok, device} = StringIO.open("") - state = %{state | device: device, cursor_visible: false} - {:ok, state} = SSH.show_cursor(state) - assert device_output(device) =~ "\e[?25h" - assert state.cursor_visible == true - end - - test "no-op when already visible" do - {state, _device} = init_ssh(hide_cursor: false) - {:ok, device} = StringIO.open("") - state = %{state | device: device, cursor_visible: true} - {:ok, _state} = SSH.show_cursor(state) - assert device_output(device) == "" - end - end - - # =========================================================================== - # Rendering - # =========================================================================== - - describe "clear/1" do - test "writes clear and home sequences" do - {state, device} = init_ssh() - {:ok, _state} = SSH.clear(state) - output = device_output(device) - # clear appears at init and again on clear call - assert String.contains?(output, "\e[2J\e[H") - end - - test "resets cursor position" do - {state, _device} = init_ssh() - {:ok, state} = SSH.move_cursor(state, {10, 20}) - {:ok, state} = SSH.clear(state) - assert state.cursor_position == {1, 1} - end - - test "resets style state" do - {state, _device} = init_ssh() - state = %{state | current_style: %{fg: :red, bg: :default, attrs: []}} - {:ok, state} = SSH.clear(state) - assert state.current_style == nil - end - end - - describe "draw_cells/2" do - test "returns {:ok, state} for empty cells" do - {state, _device} = init_ssh() - assert {:ok, %SSH{}} = SSH.draw_cells(state, []) - end - - test "writes character to device" do - {state, _device} = init_ssh() - {:ok, device} = StringIO.open("") - state = %{state | device: device} - - cells = [{{1, 1}, {"A", :default, :default, []}}] - {:ok, _state} = SSH.draw_cells(state, cells) - - output = device_output(device) - assert output =~ "A" - end - - test "writes cursor position for first cell" do - {state, _device} = init_ssh() - {:ok, device} = StringIO.open("") - state = %{state | device: device} - - cells = [{{3, 5}, {"X", :default, :default, []}}] - {:ok, _state} = SSH.draw_cells(state, cells) - - output = device_output(device) - assert output =~ "\e[3;5H" - end - - test "draws multiple cells" do - {state, _device} = init_ssh() - {:ok, device} = StringIO.open("") - state = %{state | device: device} - - cells = [ - {{1, 1}, {"H", :default, :default, []}}, - {{1, 2}, {"i", :default, :default, []}} - ] - - {:ok, _state} = SSH.draw_cells(state, cells) - - output = device_output(device) - assert output =~ "H" - assert output =~ "i" - end - - test "emits SGR for colored text" do - {state, _device} = init_ssh() - {:ok, device} = StringIO.open("") - state = %{state | device: device} - - cells = [{{1, 1}, {"R", {255, 0, 0}, :default, []}}] - {:ok, _state} = SSH.draw_cells(state, cells) - - output = device_output(device) - # Should contain RGB foreground sequence - assert output =~ "\e[38;2;255;0;0m" - end - - test "emits bold attribute" do - {state, _device} = init_ssh() - {:ok, device} = StringIO.open("") - state = %{state | device: device} - - cells = [{{1, 1}, {"B", :default, :default, [:bold]}}] - {:ok, _state} = SSH.draw_cells(state, cells) - - output = device_output(device) - assert output =~ "\e[1m" - end - - test "tracks cursor position after draw" do - {state, _device} = init_ssh() - {:ok, device} = StringIO.open("") - state = %{state | device: device} - - cells = [{{5, 10}, {"Z", :default, :default, []}}] - {:ok, state} = SSH.draw_cells(state, cells) - - # After drawing "Z" at {5, 10}, cursor should be at {5, 11} - assert state.cursor_position == {5, 11} - end - - test "style delta skips unchanged style" do - {state, _device} = init_ssh() - {:ok, device} = StringIO.open("") - state = %{state | device: device} - - # First draw sets the style - cells = [{{1, 1}, {"A", :default, :default, []}}] - {:ok, state} = SSH.draw_cells(state, cells) - - # Clear the device and draw again with same style - {:ok, device2} = StringIO.open("") - state = %{state | device: device2} - cells = [{{1, 2}, {"B", :default, :default, []}}] - {:ok, _state} = SSH.draw_cells(state, cells) - - output = device_output(device2) - # Should NOT contain a reset since style is unchanged - refute output =~ "\e[0m" - end - - test "sanitizes empty string to space" do - {state, _device} = init_ssh() - {:ok, device} = StringIO.open("") - state = %{state | device: device} - - cells = [{{1, 1}, {"", :default, :default, []}}] - {:ok, _state} = SSH.draw_cells(state, cells) - - output = device_output(device) - assert output =~ " " - end - end - - describe "flush/1" do - test "returns {:ok, state}" do - {state, _device} = init_ssh() - assert {:ok, %SSH{}} = SSH.flush(state) - end - - test "does not modify state" do - {state, _device} = init_ssh() - {:ok, new_state} = SSH.flush(state) - assert state == new_state - end - end - - # =========================================================================== - # Input - # =========================================================================== - - describe "poll_event/2" do - test "returns {:timeout, state}" do - {state, _device} = init_ssh() - assert {:timeout, %SSH{}} = SSH.poll_event(state, 100) - end - - test "does not modify state" do - {state, _device} = init_ssh() - {:timeout, new_state} = SSH.poll_event(state, 100) - assert state == new_state - end - end - - # =========================================================================== - # Mouse Tracking - # =========================================================================== - - describe "mouse tracking modes" do - test "click mode enables normal + SGR tracking" do - {state, device} = init_ssh(mouse_tracking: :click) - assert state.mouse_mode == :click - output = device_output(device) - assert output =~ "\e[?1000h" - assert output =~ "\e[?1006h" - end - - test "drag mode enables button + SGR tracking" do - {state, device} = init_ssh(mouse_tracking: :drag) - assert state.mouse_mode == :drag - output = device_output(device) - assert output =~ "\e[?1002h" - assert output =~ "\e[?1006h" - end - - test "all mode enables any-event + SGR tracking" do - {state, device} = init_ssh(mouse_tracking: :all) - assert state.mouse_mode == :all - output = device_output(device) - assert output =~ "\e[?1003h" - assert output =~ "\e[?1006h" - end - end -end diff --git a/test/term_ui/backend/state_test.exs b/test/term_ui/backend/state_test.exs deleted file mode 100644 index 6b78f3b3..00000000 --- a/test/term_ui/backend/state_test.exs +++ /dev/null @@ -1,734 +0,0 @@ -defmodule TermUI.Backend.StateTest do - use ExUnit.Case, async: true - - alias TermUI.Backend.State - - describe "module structure" do - test "module compiles successfully" do - assert Code.ensure_loaded?(State) - end - - test "defines a struct" do - assert function_exported?(State, :__struct__, 0) - assert function_exported?(State, :__struct__, 1) - end - end - - describe "struct creation with required fields" do - test "creates struct with backend_module and mode" do - state = %State{backend_module: SomeBackend, backend_mode: :raw} - - assert state.backend_module == SomeBackend - assert state.backend_mode == :raw - end - - test "raises when backend_module is missing" do - assert_raise ArgumentError, ~r/:backend_module/, fn -> - struct!(State, backend_mode: :raw) - end - end - - test "raises when backend_mode is missing" do - assert_raise ArgumentError, ~r/:backend_mode/, fn -> - struct!(State, backend_module: SomeBackend) - end - end - - test "raises when both required keys are missing" do - assert_raise ArgumentError, fn -> - struct!(State, []) - end - end - end - - describe "default values" do - test "backend_state defaults to nil" do - state = %State{backend_module: SomeBackend, backend_mode: :raw} - assert state.backend_state == nil - end - - test "capabilities defaults to empty map" do - state = %State{backend_module: SomeBackend, backend_mode: :raw} - assert state.capabilities == %{} - end - - test "size defaults to nil" do - state = %State{backend_module: SomeBackend, backend_mode: :raw} - assert state.size == nil - end - - test "initialized defaults to false" do - state = %State{backend_module: SomeBackend, backend_mode: :raw} - assert state.initialized == false - end - end - - describe "struct creation with all fields" do - test "accepts all fields" do - capabilities = %{colors: :true_color, unicode: true} - - state = %State{ - backend_module: TermUI.Backend.TTY, - backend_state: %{some: :state}, - backend_mode: :tty, - capabilities: capabilities, - size: {24, 80}, - initialized: true - } - - assert state.backend_module == TermUI.Backend.TTY - assert state.backend_state == %{some: :state} - assert state.backend_mode == :tty - assert state.capabilities == capabilities - assert state.size == {24, 80} - assert state.initialized == true - end - end - - describe "backend_mode field" do - test "accepts :raw mode" do - state = %State{backend_module: SomeBackend, backend_mode: :raw} - assert state.backend_mode == :raw - end - - test "accepts :tty mode" do - state = %State{backend_module: SomeBackend, backend_mode: :tty} - assert state.backend_mode == :tty - end - end - - describe "size field" do - test "accepts nil" do - state = %State{backend_module: SomeBackend, backend_mode: :raw, size: nil} - assert state.size == nil - end - - test "accepts {rows, cols} tuple" do - state = %State{backend_module: SomeBackend, backend_mode: :raw, size: {24, 80}} - assert state.size == {24, 80} - end - - test "accepts different dimension values" do - state = %State{backend_module: SomeBackend, backend_mode: :raw, size: {50, 120}} - assert state.size == {50, 120} - end - end - - describe "capabilities field" do - test "accepts empty map" do - state = %State{backend_module: SomeBackend, backend_mode: :raw, capabilities: %{}} - assert state.capabilities == %{} - end - - test "accepts capabilities map with expected keys" do - caps = %{ - colors: :color_256, - unicode: true, - dimensions: {24, 80}, - terminal: true - } - - state = %State{backend_module: SomeBackend, backend_mode: :tty, capabilities: caps} - assert state.capabilities == caps - assert state.capabilities.colors == :color_256 - assert state.capabilities.unicode == true - end - end - - describe "struct updates" do - test "can update backend_state" do - state = %State{backend_module: SomeBackend, backend_mode: :raw} - updated = %{state | backend_state: %{cursor: {1, 1}}} - - assert updated.backend_state == %{cursor: {1, 1}} - assert updated.backend_module == SomeBackend - end - - test "can update size" do - state = %State{backend_module: SomeBackend, backend_mode: :raw} - updated = %{state | size: {30, 100}} - - assert updated.size == {30, 100} - end - - test "can update initialized flag" do - state = %State{backend_module: SomeBackend, backend_mode: :raw} - assert state.initialized == false - - updated = %{state | initialized: true} - assert updated.initialized == true - end - - test "can update multiple fields at once" do - state = %State{backend_module: SomeBackend, backend_mode: :raw} - - updated = %{state | size: {24, 80}, initialized: true, backend_state: :ready} - - assert updated.size == {24, 80} - assert updated.initialized == true - assert updated.backend_state == :ready - end - - test "updates are immutable" do - original = %State{backend_module: SomeBackend, backend_mode: :raw} - _updated = %{original | initialized: true} - - # Original is unchanged - assert original.initialized == false - end - end - - describe "documentation" do - test "module has moduledoc" do - {:docs_v1, _, :elixir, _, module_doc, _, _} = Code.fetch_docs(State) - assert module_doc != :none - assert module_doc != :hidden - end - - test "type t is defined" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(State) - - type_docs = - docs - |> Enum.filter(fn - {{:type, :t, _}, _, _, _, _} -> true - _ -> false - end) - - assert length(type_docs) == 1, "type t should be defined" - end - - test "type backend_mode is defined" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(State) - - type_docs = - docs - |> Enum.filter(fn - {{:type, :backend_mode, _}, _, _, _, _} -> true - _ -> false - end) - - assert length(type_docs) == 1, "type backend_mode should be defined" - end - - test "type dimensions is defined" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(State) - - type_docs = - docs - |> Enum.filter(fn - {{:type, :dimensions, _}, _, _, _, _} -> true - _ -> false - end) - - assert length(type_docs) == 1, "type dimensions should be defined" - end - end - - describe "new/2 constructor" do - test "creates state with backend_module and mode" do - state = State.new(SomeBackend, backend_mode: :tty) - - assert state.backend_module == SomeBackend - assert state.backend_mode == :tty - end - - test "raises when backend_mode is missing" do - assert_raise ArgumentError, "the :backend_mode option is required", fn -> - State.new(SomeBackend) - end - end - - test "raises when backend_mode is missing from options" do - assert_raise ArgumentError, "the :backend_mode option is required", fn -> - State.new(SomeBackend, capabilities: %{}) - end - end - - test "accepts all optional fields" do - caps = %{colors: :true_color} - - state = - State.new(SomeBackend, - backend_mode: :tty, - backend_state: %{some: :state}, - capabilities: caps, - size: {24, 80}, - initialized: true - ) - - assert state.backend_module == SomeBackend - assert state.backend_mode == :tty - assert state.backend_state == %{some: :state} - assert state.capabilities == caps - assert state.size == {24, 80} - assert state.initialized == true - end - - test "applies defaults for omitted optional fields" do - state = State.new(SomeBackend, backend_mode: :raw) - - assert state.backend_state == nil - assert state.capabilities == %{} - assert state.size == nil - assert state.initialized == false - end - - test "accepts :raw mode" do - state = State.new(SomeBackend, backend_mode: :raw) - assert state.backend_mode == :raw - end - - test "accepts :tty mode" do - state = State.new(SomeBackend, backend_mode: :tty) - assert state.backend_mode == :tty - end - end - - describe "new_raw/0 and new_raw/1 constructor" do - test "creates raw mode state with defaults" do - state = State.new_raw() - - assert state.backend_module == TermUI.Backend.Raw - assert state.backend_mode == :raw - assert state.backend_state == nil - assert state.capabilities == %{} - assert state.size == nil - assert state.initialized == false - end - - test "accepts backend_state" do - backend_state = %{raw_mode_started: true} - state = State.new_raw(backend_state) - - assert state.backend_module == TermUI.Backend.Raw - assert state.backend_mode == :raw - assert state.backend_state == backend_state - end - - test "accepts any term as backend_state" do - state = State.new_raw(:ready) - assert state.backend_state == :ready - - state = State.new_raw([1, 2, 3]) - assert state.backend_state == [1, 2, 3] - - state = State.new_raw({:some, :tuple}) - assert state.backend_state == {:some, :tuple} - end - end - - describe "new_tty/1 and new_tty/2 constructor" do - test "creates tty mode state with capabilities" do - caps = %{colors: :color_256, unicode: true} - state = State.new_tty(caps) - - assert state.backend_module == TermUI.Backend.TTY - assert state.backend_mode == :tty - assert state.capabilities == caps - assert state.backend_state == nil - assert state.size == nil - assert state.initialized == false - end - - test "accepts backend_state as second argument" do - caps = %{colors: :true_color} - backend_state = %{some: :state} - state = State.new_tty(caps, backend_state) - - assert state.backend_module == TermUI.Backend.TTY - assert state.backend_mode == :tty - assert state.capabilities == caps - assert state.backend_state == backend_state - end - - test "accepts empty capabilities map" do - state = State.new_tty(%{}) - - assert state.capabilities == %{} - end - - test "raises when capabilities is not a map" do - assert_raise FunctionClauseError, fn -> - State.new_tty(:not_a_map) - end - - assert_raise FunctionClauseError, fn -> - State.new_tty(colors: :true_color) - end - end - - test "preserves all capability keys" do - caps = %{ - colors: :true_color, - unicode: true, - dimensions: {24, 80}, - terminal: true, - custom: :value - } - - state = State.new_tty(caps) - - assert state.capabilities == caps - assert state.capabilities.colors == :true_color - assert state.capabilities.unicode == true - assert state.capabilities.dimensions == {24, 80} - assert state.capabilities.terminal == true - assert state.capabilities.custom == :value - end - end - - describe "constructor documentation" do - test "new/2 has docs" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(State) - - func_docs = - docs - |> Enum.filter(fn - {{:function, :new, 2}, _, _, _, _} -> true - _ -> false - end) - - assert length(func_docs) == 1, "new/2 should have documentation" - end - - test "new_raw/1 has docs" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(State) - - func_docs = - docs - |> Enum.filter(fn - {{:function, :new_raw, 1}, _, _, _, _} -> true - _ -> false - end) - - assert length(func_docs) == 1, "new_raw/1 should have documentation" - end - - test "new_tty/2 has docs" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(State) - - func_docs = - docs - |> Enum.filter(fn - {{:function, :new_tty, 2}, _, _, _, _} -> true - _ -> false - end) - - assert length(func_docs) == 1, "new_tty/2 should have documentation" - end - end - - describe "put_backend_state/2" do - test "updates backend_state" do - state = State.new_raw() - updated = State.put_backend_state(state, %{cursor: {1, 1}}) - - assert updated.backend_state == %{cursor: {1, 1}} - end - - test "preserves other fields" do - state = State.new_tty(%{colors: :true_color}) - state = State.put_size(state, {24, 80}) - state = State.mark_initialized(state) - - updated = State.put_backend_state(state, %{some: :state}) - - assert updated.backend_state == %{some: :state} - assert updated.backend_module == TermUI.Backend.TTY - assert updated.backend_mode == :tty - assert updated.capabilities == %{colors: :true_color} - assert updated.size == {24, 80} - assert updated.initialized == true - end - - test "accepts any term as backend_state" do - state = State.new_raw() - - assert State.put_backend_state(state, :atom).backend_state == :atom - assert State.put_backend_state(state, [1, 2, 3]).backend_state == [1, 2, 3] - assert State.put_backend_state(state, "string").backend_state == "string" - assert State.put_backend_state(state, nil).backend_state == nil - end - - test "returns new struct (immutability)" do - original = State.new_raw() - updated = State.put_backend_state(original, %{new: :state}) - - assert original.backend_state == nil - assert updated.backend_state == %{new: :state} - refute original == updated - end - end - - describe "put_size/2" do - test "updates size with tuple" do - state = State.new_tty(%{}) - updated = State.put_size(state, {24, 80}) - - assert updated.size == {24, 80} - end - - test "updates size with nil" do - state = State.new_tty(%{}) - state = State.put_size(state, {24, 80}) - updated = State.put_size(state, nil) - - assert updated.size == nil - end - - test "accepts different dimension values" do - state = State.new_tty(%{}) - - assert State.put_size(state, {1, 1}).size == {1, 1} - assert State.put_size(state, {50, 120}).size == {50, 120} - assert State.put_size(state, {1000, 2000}).size == {1000, 2000} - end - - test "preserves other fields" do - state = State.new_tty(%{colors: :true_color}) - state = State.put_backend_state(state, %{some: :state}) - state = State.mark_initialized(state) - - updated = State.put_size(state, {30, 100}) - - assert updated.size == {30, 100} - assert updated.backend_module == TermUI.Backend.TTY - assert updated.backend_mode == :tty - assert updated.capabilities == %{colors: :true_color} - assert updated.backend_state == %{some: :state} - assert updated.initialized == true - end - - test "returns new struct (immutability)" do - original = State.new_tty(%{}) - updated = State.put_size(original, {24, 80}) - - assert original.size == nil - assert updated.size == {24, 80} - refute original == updated - end - end - - describe "put_capabilities/2" do - test "updates capabilities" do - state = State.new_tty(%{colors: :basic}) - updated = State.put_capabilities(state, %{colors: :true_color, unicode: true}) - - assert updated.capabilities == %{colors: :true_color, unicode: true} - end - - test "replaces entire map (does not merge)" do - state = State.new_tty(%{colors: :basic, unicode: true, terminal: true}) - updated = State.put_capabilities(state, %{colors: :true_color}) - - assert updated.capabilities == %{colors: :true_color} - refute Map.has_key?(updated.capabilities, :unicode) - refute Map.has_key?(updated.capabilities, :terminal) - end - - test "accepts empty map" do - state = State.new_tty(%{colors: :true_color}) - updated = State.put_capabilities(state, %{}) - - assert updated.capabilities == %{} - end - - test "raises when capabilities is not a map" do - state = State.new_tty(%{}) - - assert_raise FunctionClauseError, fn -> - State.put_capabilities(state, :not_a_map) - end - - assert_raise FunctionClauseError, fn -> - State.put_capabilities(state, colors: :true_color) - end - end - - test "preserves other fields" do - state = State.new_tty(%{colors: :basic}) - state = State.put_size(state, {24, 80}) - state = State.put_backend_state(state, %{some: :state}) - state = State.mark_initialized(state) - - updated = State.put_capabilities(state, %{colors: :true_color}) - - assert updated.capabilities == %{colors: :true_color} - assert updated.backend_module == TermUI.Backend.TTY - assert updated.backend_mode == :tty - assert updated.size == {24, 80} - assert updated.backend_state == %{some: :state} - assert updated.initialized == true - end - - test "returns new struct (immutability)" do - original = State.new_tty(%{colors: :basic}) - updated = State.put_capabilities(original, %{colors: :true_color}) - - assert original.capabilities == %{colors: :basic} - assert updated.capabilities == %{colors: :true_color} - refute original == updated - end - end - - describe "mark_initialized/1" do - test "sets initialized to true" do - state = State.new_tty(%{}) - assert state.initialized == false - - updated = State.mark_initialized(state) - assert updated.initialized == true - end - - test "is idempotent" do - state = State.new_tty(%{}) - state = State.mark_initialized(state) - assert state.initialized == true - - state = State.mark_initialized(state) - assert state.initialized == true - end - - test "preserves other fields" do - state = State.new_tty(%{colors: :true_color}) - state = State.put_size(state, {24, 80}) - state = State.put_backend_state(state, %{some: :state}) - - updated = State.mark_initialized(state) - - assert updated.initialized == true - assert updated.backend_module == TermUI.Backend.TTY - assert updated.backend_mode == :tty - assert updated.capabilities == %{colors: :true_color} - assert updated.size == {24, 80} - assert updated.backend_state == %{some: :state} - end - - test "returns new struct (immutability)" do - original = State.new_tty(%{}) - updated = State.mark_initialized(original) - - assert original.initialized == false - assert updated.initialized == true - refute original == updated - end - end - - describe "update function documentation" do - test "put_backend_state/2 has docs" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(State) - - func_docs = - docs - |> Enum.filter(fn - {{:function, :put_backend_state, 2}, _, _, _, _} -> true - _ -> false - end) - - assert length(func_docs) == 1, "put_backend_state/2 should have documentation" - end - - test "put_size/2 has docs" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(State) - - func_docs = - docs - |> Enum.filter(fn - {{:function, :put_size, 2}, _, _, _, _} -> true - _ -> false - end) - - assert length(func_docs) == 1, "put_size/2 should have documentation" - end - - test "put_capabilities/2 has docs" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(State) - - func_docs = - docs - |> Enum.filter(fn - {{:function, :put_capabilities, 2}, _, _, _, _} -> true - _ -> false - end) - - assert length(func_docs) == 1, "put_capabilities/2 should have documentation" - end - - test "mark_initialized/1 has docs" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(State) - - func_docs = - docs - |> Enum.filter(fn - {{:function, :mark_initialized, 1}, _, _, _, _} -> true - _ -> false - end) - - assert length(func_docs) == 1, "mark_initialized/1 should have documentation" - end - end - - describe "typical usage patterns" do - test "raw mode state creation" do - # Simulates what happens after Selector.select() returns {:raw, raw_state} - raw_state = %{raw_mode_started: true} - - state = %State{ - backend_module: TermUI.Backend.Raw, - backend_state: raw_state, - backend_mode: :raw, - capabilities: %{}, - initialized: false - } - - assert state.backend_mode == :raw - assert state.backend_state.raw_mode_started == true - end - - test "tty mode state creation" do - # Simulates what happens after Selector.select() returns {:tty, capabilities} - capabilities = %{ - colors: :color_256, - unicode: true, - dimensions: {24, 80}, - terminal: true - } - - state = %State{ - backend_module: TermUI.Backend.TTY, - backend_state: nil, - backend_mode: :tty, - capabilities: capabilities, - initialized: false - } - - assert state.backend_mode == :tty - assert state.capabilities.colors == :color_256 - assert state.size == nil - end - - test "state lifecycle: creation -> initialization -> updates" do - # Create initial state - state = %State{ - backend_module: TermUI.Backend.TTY, - backend_mode: :tty, - capabilities: %{colors: :true_color} - } - - assert state.initialized == false - assert state.size == nil - - # Mark as initialized and cache size - state = %{state | initialized: true, size: {24, 80}} - - assert state.initialized == true - assert state.size == {24, 80} - - # Update size after resize - state = %{state | size: {30, 100}} - - assert state.size == {30, 100} - end - end -end diff --git a/test/term_ui/backend/tty_test.exs b/test/term_ui/backend/tty_test.exs deleted file mode 100644 index 9eb9f1d0..00000000 --- a/test/term_ui/backend/tty_test.exs +++ /dev/null @@ -1,4593 +0,0 @@ -defmodule TermUI.Backend.TTYTest do - use ExUnit.Case, async: true - - import ExUnit.CaptureIO - - alias TermUI.Backend.TTY - - # Helper to initialize TTY without IO output cluttering tests - defp init_tty(opts) do - capture_io(fn -> - send(self(), TTY.init(opts)) - end) - - receive do - result -> result - end - end - - # =========================================================================== - # Section 3.1 Tests - Module Structure - # =========================================================================== - - describe "behaviour declaration" do - test "module declares @behaviour TermUI.Backend" do - behaviours = TTY.__info__(:attributes)[:behaviour] || [] - assert TermUI.Backend in behaviours - end - - test "module compiles without warnings" do - # If we got here, the module compiled successfully - assert Code.ensure_loaded?(TTY) - end - end - - describe "state struct defaults" do - test "has size field with default {24, 80}" do - state = %TTY{} - assert state.size == {24, 80} - end - - test "has capabilities field with default empty map" do - state = %TTY{} - assert state.capabilities == %{} - end - - test "has line_mode field with default :full_redraw" do - state = %TTY{} - assert state.line_mode == :full_redraw - end - - test "has last_frame field with default nil" do - state = %TTY{} - assert state.last_frame == nil - end - - test "has character_set field with default :unicode" do - state = %TTY{} - assert state.character_set == :unicode - end - - test "has color_mode field with default :true_color" do - state = %TTY{} - assert state.color_mode == :true_color - end - - test "has alternate_screen field with default false" do - state = %TTY{} - assert state.alternate_screen == false - end - - test "has cursor_visible field with default true" do - state = %TTY{} - assert state.cursor_visible == true - end - - test "has cursor_position field with default nil" do - state = %TTY{} - assert state.cursor_position == nil - end - end - - describe "init/1" do - test "returns {:ok, state} with default options" do - assert {:ok, %TTY{}} = init_tty([]) - end - - test "stores capabilities from options" do - capabilities = %{colors: :color_256, unicode: true, dimensions: {30, 100}} - {:ok, state} = init_tty(capabilities: capabilities) - assert state.capabilities == capabilities - end - - test "uses line_mode from options" do - {:ok, state} = init_tty(line_mode: :incremental) - assert state.line_mode == :incremental - end - - test "uses alternate_screen from options" do - {:ok, state} = init_tty(alternate_screen: true) - assert state.alternate_screen == true - end - - test "uses explicit size from options" do - {:ok, state} = init_tty(size: {50, 120}) - assert state.size == {50, 120} - end - - test "uses size from capabilities when not explicitly set" do - capabilities = %{dimensions: {40, 160}} - {:ok, state} = init_tty(capabilities: capabilities) - assert state.size == {40, 160} - end - - test "prefers explicit size over capabilities" do - capabilities = %{dimensions: {40, 160}} - {:ok, state} = init_tty(size: {30, 100}, capabilities: capabilities) - assert state.size == {30, 100} - end - - test "determines color_mode :true_color from capabilities" do - {:ok, state} = init_tty(capabilities: %{colors: :true_color}) - assert state.color_mode == :true_color - end - - test "determines color_mode :color_256 from capabilities" do - {:ok, state} = init_tty(capabilities: %{colors: :color_256}) - assert state.color_mode == :color_256 - end - - test "determines color_mode :color_16 from capabilities" do - {:ok, state} = init_tty(capabilities: %{colors: :color_16}) - assert state.color_mode == :color_16 - end - - test "determines color_mode :monochrome from capabilities" do - {:ok, state} = init_tty(capabilities: %{colors: :monochrome}) - assert state.color_mode == :monochrome - end - - test "determines color_mode from integer >= 16_777_216 as :true_color" do - {:ok, state} = init_tty(capabilities: %{colors: 16_777_216}) - assert state.color_mode == :true_color - end - - test "determines color_mode from integer >= 256 as :color_256" do - {:ok, state} = init_tty(capabilities: %{colors: 256}) - assert state.color_mode == :color_256 - end - - test "determines color_mode from integer >= 16 as :color_16" do - {:ok, state} = init_tty(capabilities: %{colors: 16}) - assert state.color_mode == :color_16 - end - - test "determines character_set :unicode when unicode capability is true" do - {:ok, state} = init_tty(capabilities: %{unicode: true}) - assert state.character_set == :unicode - end - - test "determines character_set :ascii when unicode capability is false" do - {:ok, state} = init_tty(capabilities: %{unicode: false}) - assert state.character_set == :ascii - end - - test "defaults character_set to :unicode when not specified" do - {:ok, state} = init_tty(capabilities: %{}) - assert state.character_set == :unicode - end - end - - describe "shutdown/1" do - test "returns :ok" do - {:ok, state} = init_tty([]) - - result = - capture_io(fn -> - send(self(), TTY.shutdown(state)) - end) - - receive do - r -> assert r == :ok - end - - # Verify some output occurred - assert result != "" - end - - test "can be called multiple times" do - {:ok, state} = init_tty([]) - - capture_io(fn -> - assert :ok = TTY.shutdown(state) - assert :ok = TTY.shutdown(state) - end) - end - - test "requires TTY struct as argument" do - # Verify shutdown pattern matches on the struct type - assert_raise FunctionClauseError, fn -> - capture_io(fn -> - TTY.shutdown(%{alternate_screen: false}) - end) - end - end - end - - # =========================================================================== - # Edge Case Tests - Invalid Inputs - # =========================================================================== - - describe "edge cases - invalid size values" do - test "zero rows defaults to {24, 80}" do - {:ok, state} = init_tty(size: {0, 80}) - assert state.size == {24, 80} - end - - test "negative rows defaults to {24, 80}" do - {:ok, state} = init_tty(size: {-1, 80}) - assert state.size == {24, 80} - end - - test "zero cols defaults to {24, 80}" do - {:ok, state} = init_tty(size: {24, 0}) - assert state.size == {24, 80} - end - - test "negative cols defaults to {24, 80}" do - {:ok, state} = init_tty(size: {24, -1}) - assert state.size == {24, 80} - end - - test "non-integer size defaults to {24, 80}" do - {:ok, state} = init_tty(size: {"24", "80"}) - assert state.size == {24, 80} - end - - test "nil size defaults to {24, 80}" do - {:ok, state} = init_tty(size: nil) - assert state.size == {24, 80} - end - end - - describe "edge cases - malformed capabilities" do - test "unknown color mode defaults to :true_color" do - {:ok, state} = init_tty(capabilities: %{colors: :unknown_mode}) - assert state.color_mode == :true_color - end - - test "string color value defaults to :true_color" do - {:ok, state} = init_tty(capabilities: %{colors: "256"}) - assert state.color_mode == :true_color - end - - test "negative integer color value defaults to :true_color" do - {:ok, state} = init_tty(capabilities: %{colors: -1}) - assert state.color_mode == :true_color - end - - test "non-boolean unicode capability defaults to :unicode" do - {:ok, state} = init_tty(capabilities: %{unicode: "yes"}) - assert state.character_set == :unicode - end - - test "invalid dimensions in capabilities defaults to {24, 80}" do - {:ok, state} = init_tty(capabilities: %{dimensions: {0, 0}}) - assert state.size == {24, 80} - end - - test "string dimensions in capabilities defaults to {24, 80}" do - {:ok, state} = init_tty(capabilities: %{dimensions: {"30", "100"}}) - assert state.size == {24, 80} - end - end - - describe "size/1" do - test "returns {:ok, size} from state" do - {:ok, state} = init_tty(size: {50, 120}) - assert {:ok, {50, 120}} = TTY.size(state) - end - - test "returns default size when not configured" do - {:ok, state} = init_tty([]) - assert {:ok, {24, 80}} = TTY.size(state) - end - end - - describe "refresh_size/1" do - test "returns {:ok, size, state}" do - {:ok, state} = init_tty([]) - assert {:ok, {rows, cols}, _new_state} = TTY.refresh_size(state) - assert is_integer(rows) and rows > 0 - assert is_integer(cols) and cols > 0 - end - - test "clears last_frame to force full redraw" do - {:ok, state} = init_tty(line_mode: :incremental) - # Simulate having a last_frame - state = %{state | last_frame: %{{1, 1} => {"A", :default, :default, []}}} - - {:ok, _size, refreshed_state} = TTY.refresh_size(state) - - assert refreshed_state.last_frame == nil - end - - test "preserves state structure" do - {:ok, state} = init_tty(line_mode: :incremental, alternate_screen: true) - - {:ok, _size, refreshed_state} = TTY.refresh_size(state) - - assert refreshed_state.line_mode == :incremental - assert refreshed_state.alternate_screen == true - end - - test "queries terminal and updates size" do - {:ok, state} = init_tty(size: {24, 80}) - - # refresh_size queries :io.rows and :io.columns - # In test environment these may or may not be available - {:ok, {rows, cols}, refreshed_state} = TTY.refresh_size(state) - - # Returned size should match state size - assert refreshed_state.size == {rows, cols} - assert is_integer(rows) and rows > 0 - assert is_integer(cols) and cols > 0 - end - - test "falls back to current size if terminal query fails" do - # When not connected to a terminal, :io.rows/columns return errors - # In that case, refresh_size should preserve the current size - {:ok, state} = init_tty(size: {30, 100}) - - {:ok, {rows, cols}, refreshed_state} = TTY.refresh_size(state) - - # Size should still be valid (either from terminal or fallback) - assert refreshed_state.size == {rows, cols} - assert is_integer(rows) and rows > 0 - assert is_integer(cols) and cols > 0 - end - end - - describe "cursor operations" do - test "move_cursor/2 returns {:ok, state}" do - {:ok, state} = init_tty([]) - - output = - capture_io(fn -> - assert {:ok, _state} = TTY.move_cursor(state, {10, 20}) - end) - - assert output =~ "\e[10;20H" - end - - test "move_cursor/2 updates cursor_position in state" do - {:ok, state} = init_tty([]) - - capture_io(fn -> - {:ok, new_state} = TTY.move_cursor(state, {5, 15}) - send(self(), {:result, new_state}) - end) - - receive do - {:result, new_state} -> assert new_state.cursor_position == {5, 15} - end - end - - test "move_cursor/2 clamps row to terminal bounds" do - {:ok, state} = init_tty(size: {24, 80}) - - output = - capture_io(fn -> - {:ok, new_state} = TTY.move_cursor(state, {100, 40}) - send(self(), {:result, new_state}) - end) - - # Row should be clamped to 24 (max rows) - assert output =~ "\e[24;40H" - - receive do - {:result, new_state} -> assert new_state.cursor_position == {24, 40} - end - end - - test "move_cursor/2 clamps column to terminal bounds" do - {:ok, state} = init_tty(size: {24, 80}) - - output = - capture_io(fn -> - {:ok, new_state} = TTY.move_cursor(state, {10, 200}) - send(self(), {:result, new_state}) - end) - - # Column should be clamped to 80 (max cols) - assert output =~ "\e[10;80H" - - receive do - {:result, new_state} -> assert new_state.cursor_position == {10, 80} - end - end - - test "move_cursor/2 clamps minimum position to 1,1" do - {:ok, state} = init_tty([]) - - output = - capture_io(fn -> - {:ok, new_state} = TTY.move_cursor(state, {0, 0}) - send(self(), {:result, new_state}) - end) - - # Position should be clamped to 1,1 - assert output =~ "\e[1;1H" - - receive do - {:result, new_state} -> assert new_state.cursor_position == {1, 1} - end - end - - test "hide_cursor/1 sets cursor_visible to false" do - {:ok, state} = init_tty([]) - # Note: init already hides cursor, so it's false after init - assert state.cursor_visible == false - - # Show first, then hide to test the transition - capture_io(fn -> - {:ok, state} = TTY.show_cursor(state) - assert state.cursor_visible == true - {:ok, state} = TTY.hide_cursor(state) - assert state.cursor_visible == false - end) - end - - test "hide_cursor/1 outputs hide cursor sequence" do - {:ok, state} = init_tty([]) - - # First show cursor (init hides it), then test hide outputs sequence - state = - capture_io(fn -> - {:ok, s} = TTY.show_cursor(state) - send(self(), s) - end) - |> then(fn _ -> receive do: (s -> s) end) - - assert state.cursor_visible == true - - output = - capture_io(fn -> - TTY.hide_cursor(state) - end) - - assert output =~ "\e[?25l" - end - - test "show_cursor/1 sets cursor_visible to true" do - {:ok, state} = init_tty([]) - # init hides cursor, so start with false - assert state.cursor_visible == false - - capture_io(fn -> - {:ok, state} = TTY.show_cursor(state) - assert state.cursor_visible == true - end) - end - - test "show_cursor/1 outputs show cursor sequence" do - {:ok, state} = init_tty([]) - - output = - capture_io(fn -> - TTY.show_cursor(state) - end) - - assert output =~ "\e[?25h" - end - - test "hide_cursor/1 is idempotent - no output when already hidden" do - {:ok, state} = init_tty([]) - # init hides cursor, so cursor_visible should be false - assert state.cursor_visible == false - - # Calling hide_cursor again should produce no output - output = - capture_io(fn -> - {:ok, new_state} = TTY.hide_cursor(state) - send(self(), {:result, new_state}) - end) - - # Should be empty - no escape sequence written - assert output == "" - - receive do - {:result, new_state} -> - # State unchanged - assert new_state.cursor_visible == false - assert new_state == state - end - end - - test "show_cursor/1 is idempotent - no output when already visible" do - {:ok, state} = init_tty([]) - - # First show the cursor (init hides it) - state = - capture_io(fn -> - {:ok, s} = TTY.show_cursor(state) - send(self(), s) - end) - |> then(fn _ -> receive do: (s -> s) end) - - assert state.cursor_visible == true - - # Calling show_cursor again should produce no output - output = - capture_io(fn -> - {:ok, new_state} = TTY.show_cursor(state) - send(self(), {:result, new_state}) - end) - - # Should be empty - no escape sequence written - assert output == "" - - receive do - {:result, new_state} -> - # State unchanged - assert new_state.cursor_visible == true - assert new_state == state - end - end - end - - describe "rendering operations" do - test "clear/1 returns {:ok, state} with nil last_frame" do - {:ok, state} = init_tty([]) - state = %{state | last_frame: %{some: :data}} - {:ok, state} = TTY.clear(state) - assert state.last_frame == nil - end - - test "draw_cells/2 returns {:ok, state}" do - {:ok, state} = init_tty([]) - cells = [{{1, 1}, {"A", :default, :default, []}}] - - capture_io(fn -> - assert {:ok, _state} = TTY.draw_cells(state, cells) - end) - end - - test "flush/1 returns {:ok, state}" do - {:ok, state} = init_tty([]) - assert {:ok, _state} = TTY.flush(state) - end - - test "flush/1 preserves state unchanged" do - {:ok, state} = init_tty(size: {50, 120}, line_mode: :incremental) - {:ok, flushed_state} = TTY.flush(state) - - assert flushed_state.size == {50, 120} - assert flushed_state.line_mode == :incremental - assert flushed_state == state - end - end - - describe "input operations" do - test "state has input_buffer field with default empty binary" do - {:ok, state} = init_tty([]) - assert state.input_buffer == <<>> - end - - test "poll_event/2 parses buffered regular character" do - {:ok, state} = init_tty([]) - # Pre-populate buffer with a character - state = %{state | input_buffer: "a"} - - assert {:ok, event, new_state} = TTY.poll_event(state, 100) - assert event.key == "a" - assert new_state.input_buffer == <<>> - end - - test "poll_event/2 parses buffered arrow key sequence" do - {:ok, state} = init_tty([]) - # Pre-populate buffer with up arrow escape sequence - state = %{state | input_buffer: "\e[A"} - - assert {:ok, event, new_state} = TTY.poll_event(state, 100) - assert event.key == :up - assert new_state.input_buffer == <<>> - end - - test "poll_event/2 parses buffered function key" do - {:ok, state} = init_tty([]) - # Pre-populate buffer with F1 key (SS3 variant) - state = %{state | input_buffer: "\eOP"} - - assert {:ok, event, new_state} = TTY.poll_event(state, 100) - assert event.key == :f1 - assert new_state.input_buffer == <<>> - end - - test "poll_event/2 parses buffered control character" do - {:ok, state} = init_tty([]) - # Pre-populate buffer with Ctrl+C (ASCII 3) - state = %{state | input_buffer: <<3>>} - - assert {:ok, event, new_state} = TTY.poll_event(state, 100) - assert event.key == "c" - assert :ctrl in event.modifiers - assert new_state.input_buffer == <<>> - end - - test "poll_event/2 returns first event from multiple input characters" do - {:ok, state} = init_tty([]) - # Pre-populate buffer with two characters - state = %{state | input_buffer: "ab"} - - # First call returns first event - assert {:ok, event, _new_state} = TTY.poll_event(state, 100) - assert event.key == "a" - # Note: EscapeParser parses all events at once, so remaining - # complete characters are consumed. Only partial sequences - # (like lone ESC) would remain in buffer. - end - - test "poll_event/2 keeps partial escape sequence in buffer" do - {:ok, state} = init_tty([]) - # Pre-populate buffer with incomplete CSI sequence (ESC [) - state = %{state | input_buffer: "\e["} - - # This is an incomplete sequence - should need more input - # When buffer has incomplete sequence and IO read would block, - # we can't test this easily without mocking IO. - # Just verify the state is valid - assert state.input_buffer == "\e[" - end - - test "poll_event/2 handles enter key" do - {:ok, state} = init_tty([]) - state = %{state | input_buffer: <<13>>} - - assert {:ok, event, _new_state} = TTY.poll_event(state, 100) - assert event.key == :enter - end - - test "poll_event/2 handles tab key" do - {:ok, state} = init_tty([]) - state = %{state | input_buffer: <<9>>} - - assert {:ok, event, _new_state} = TTY.poll_event(state, 100) - assert event.key == :tab - end - - test "poll_event/2 handles backspace" do - {:ok, state} = init_tty([]) - state = %{state | input_buffer: <<127>>} - - assert {:ok, event, _new_state} = TTY.poll_event(state, 100) - assert event.key == :backspace - end - - test "poll_event/2 returns timeout for incomplete escape sequence" do - {:ok, state} = init_tty([]) - # Pre-populate buffer with incomplete CSI sequence (ESC [) - # When parse_buffered_input returns :need_more and we can't read more, - # we test by directly calling with a state that has a partial sequence - # and verifying it's preserved - state = %{state | input_buffer: "\e["} - - # The buffer contains incomplete sequence, verify it's preserved - assert state.input_buffer == "\e[" - end - end - - describe "input buffer security" do - test "input buffer size is limited to prevent memory exhaustion" do - {:ok, state} = init_tty([]) - - # Create a buffer larger than @max_input_buffer_size (1024) - large_buffer = String.duplicate("\e[1;", 512) - assert byte_size(large_buffer) > 1024 - - state = %{state | input_buffer: large_buffer} - - # When poll_event processes this through parse_and_return_event, - # the buffer limit should be enforced - # Simulate what happens when we get a timeout with large buffer - # by directly testing the internal state management - - # Pre-populate with large incomplete sequence - # Poll should apply buffer limit when storing remaining - assert state.input_buffer == large_buffer - end - - test "buffer overflow truncates to 256 bytes keeping recent data" do - import ExUnit.CaptureLog - - {:ok, state} = init_tty([]) - - # Create a buffer larger than 1024 bytes with incomplete escape at end - large_buffer = String.duplicate("X", 1100) <> "\e[" - state = %{state | input_buffer: large_buffer} - - # Simulate adding more data which triggers buffer limit check - # We need to trigger the apply_buffer_limit function - # This happens when poll_event returns timeout - - # For this test, we verify the state can hold large buffers - # The actual truncation happens in poll_event flow - assert byte_size(state.input_buffer) > 1024 - end - - test "buffer limit preserves partial escape sequences when truncating" do - {:ok, state} = init_tty([]) - - # Buffer with garbage followed by valid partial sequence - state = %{state | input_buffer: String.duplicate("X", 1000) <> "\e[A"} - - # The partial sequence "\e[A" should be preserved after truncation - # when poll_event processes this - assert String.ends_with?(state.input_buffer, "\e[A") - end - end - - # =========================================================================== - # Section 3.2.2 Tests - Terminal Setup - # =========================================================================== - - describe "terminal setup (Section 3.2.2)" do - test "init outputs hide cursor sequence" do - output = - capture_io(fn -> - TTY.init([]) - end) - - assert output =~ "\e[?25l" - end - - test "init outputs clear screen sequence" do - output = - capture_io(fn -> - TTY.init([]) - end) - - assert output =~ "\e[2J" - end - - test "init outputs cursor home sequence" do - output = - capture_io(fn -> - TTY.init([]) - end) - - assert output =~ "\e[H" - end - - test "init outputs alternate screen sequence when configured" do - output = - capture_io(fn -> - TTY.init(alternate_screen: true) - end) - - assert output =~ "\e[?1049h" - end - - test "init does not output alternate screen sequence by default" do - output = - capture_io(fn -> - TTY.init([]) - end) - - refute output =~ "\e[?1049h" - end - - test "init sets cursor_visible to false" do - {:ok, state} = init_tty([]) - assert state.cursor_visible == false - end - - test "init sets cursor_position to {1, 1}" do - {:ok, state} = init_tty([]) - assert state.cursor_position == {1, 1} - end - - test "setup sequences are output in correct order" do - # When alternate_screen is true, sequence should be: - # 1. alternate screen - # 2. hide cursor - # 3. clear screen + home - output = - capture_io(fn -> - TTY.init(alternate_screen: true) - end) - - alt_screen_pos = :binary.match(output, "\e[?1049h") - hide_cursor_pos = :binary.match(output, "\e[?25l") - clear_screen_pos = :binary.match(output, "\e[2J") - - assert alt_screen_pos != :nomatch - assert hide_cursor_pos != :nomatch - assert clear_screen_pos != :nomatch - - # alternate screen comes before hide cursor - {alt_start, _} = alt_screen_pos - {hide_start, _} = hide_cursor_pos - {clear_start, _} = clear_screen_pos - - assert alt_start < hide_start - assert hide_start < clear_start - end - end - - # =========================================================================== - # Section 3.2.3 Tests - Shutdown Callback - # =========================================================================== - - describe "shutdown sequences (Section 3.2.3)" do - test "shutdown outputs reset attributes sequence" do - {:ok, state} = init_tty([]) - - output = - capture_io(fn -> - TTY.shutdown(state) - end) - - assert output =~ "\e[0m" - end - - test "shutdown outputs show cursor sequence" do - {:ok, state} = init_tty([]) - - output = - capture_io(fn -> - TTY.shutdown(state) - end) - - assert output =~ "\e[?25h" - end - - test "shutdown outputs leave alternate screen when alternate_screen is true" do - {:ok, state} = init_tty(alternate_screen: true) - - output = - capture_io(fn -> - TTY.shutdown(state) - end) - - assert output =~ "\e[?1049l" - end - - test "shutdown does not output leave alternate screen by default" do - {:ok, state} = init_tty([]) - - output = - capture_io(fn -> - TTY.shutdown(state) - end) - - refute output =~ "\e[?1049l" - end - - test "shutdown sequences are output in correct order" do - {:ok, state} = init_tty(alternate_screen: true) - - output = - capture_io(fn -> - TTY.shutdown(state) - end) - - reset_pos = :binary.match(output, "\e[0m") - show_cursor_pos = :binary.match(output, "\e[?25h") - leave_alt_pos = :binary.match(output, "\e[?1049l") - - assert reset_pos != :nomatch - assert show_cursor_pos != :nomatch - assert leave_alt_pos != :nomatch - - # reset comes before show cursor, show cursor comes before leave alternate - {reset_start, _} = reset_pos - {show_start, _} = show_cursor_pos - {leave_start, _} = leave_alt_pos - - assert reset_start < show_start - assert show_start < leave_start - end - end - - # =========================================================================== - # Section 3.3.1 Tests - clear/1 Callback - # =========================================================================== - - describe "clear/1 (Section 3.3.1)" do - test "outputs clear screen sequence" do - {:ok, state} = init_tty([]) - - output = - capture_io(fn -> - TTY.clear(state) - end) - - assert output =~ "\e[2J" - end - - test "outputs cursor home sequence" do - {:ok, state} = init_tty([]) - - output = - capture_io(fn -> - TTY.clear(state) - end) - - assert output =~ "\e[H" - end - - test "clear screen comes before cursor home" do - {:ok, state} = init_tty([]) - - output = - capture_io(fn -> - TTY.clear(state) - end) - - clear_pos = :binary.match(output, "\e[2J") - home_pos = :binary.match(output, "\e[H") - - assert clear_pos != :nomatch - assert home_pos != :nomatch - - {clear_start, _} = clear_pos - {home_start, _} = home_pos - - assert clear_start < home_start - end - - test "clears last_frame in state" do - {:ok, state} = init_tty([]) - state = %{state | last_frame: %{some: :data}} - - {:ok, new_state} = - capture_io(fn -> - send(self(), TTY.clear(state)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - assert new_state.last_frame == nil - end - - test "sets cursor_position to {1, 1}" do - {:ok, state} = init_tty([]) - state = %{state | cursor_position: {10, 20}} - - {:ok, new_state} = - capture_io(fn -> - send(self(), TTY.clear(state)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - assert new_state.cursor_position == {1, 1} - end - - test "returns {:ok, state}" do - {:ok, state} = init_tty([]) - - result = - capture_io(fn -> - send(self(), TTY.clear(state)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - assert {:ok, %TTY{}} = result - end - end - - # =========================================================================== - # Section 3.3.2 Tests - draw_cells/2 Callback - # =========================================================================== - - describe "draw_cells/2 (Section 3.3.2)" do - test "in full_redraw mode, outputs clear screen sequence" do - {:ok, state} = init_tty(line_mode: :full_redraw) - cells = [{{1, 1}, {"A", :default, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - assert output =~ "\e[2J" - end - - test "in incremental mode with existing frame, does not output clear screen sequence" do - {:ok, state} = init_tty(line_mode: :incremental) - cells = [{{1, 1}, {"A", :default, :default, []}}] - - # First frame sets last_frame (will clear screen) - {:ok, state_with_frame} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, cells)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - # Subsequent frame should NOT clear screen - output = - capture_io(fn -> - TTY.draw_cells(state_with_frame, cells) - end) - - refute output =~ "\e[2J" - end - - test "outputs cursor positioning sequence" do - {:ok, state} = init_tty([]) - cells = [{{5, 1}, {"X", :default, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - assert output =~ "\e[5;1H" - end - - test "outputs cell character" do - {:ok, state} = init_tty([]) - cells = [{{1, 1}, {"Z", :default, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - assert output =~ "Z" - end - - test "outputs multiple cells in row order" do - {:ok, state} = init_tty([]) - - cells = [ - {{2, 1}, {"B", :default, :default, []}}, - {{1, 1}, {"A", :default, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Row 1 should come before Row 2 - row1_pos = :binary.match(output, "\e[1;1H") - row2_pos = :binary.match(output, "\e[2;1H") - - assert row1_pos != :nomatch - assert row2_pos != :nomatch - - {row1_start, _} = row1_pos - {row2_start, _} = row2_pos - - assert row1_start < row2_start - end - - test "outputs named foreground color" do - {:ok, state} = init_tty([]) - cells = [{{1, 1}, {"X", :red, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - assert output =~ "\e[31m" - end - - test "outputs named background color" do - {:ok, state} = init_tty([]) - cells = [{{1, 1}, {"X", :default, :blue, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - assert output =~ "\e[44m" - end - - test "outputs RGB foreground in true_color mode" do - {:ok, state} = init_tty(capabilities: %{colors: :true_color}) - cells = [{{1, 1}, {"X", {255, 128, 64}, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - assert output =~ "\e[38;2;255;128;64m" - end - - test "outputs RGB background in true_color mode" do - {:ok, state} = init_tty(capabilities: %{colors: :true_color}) - cells = [{{1, 1}, {"X", :default, {64, 128, 255}, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - assert output =~ "\e[48;2;64;128;255m" - end - - test "outputs bold attribute" do - {:ok, state} = init_tty([]) - cells = [{{1, 1}, {"X", :default, :default, [:bold]}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - assert output =~ "\e[1m" - end - - test "outputs underline attribute" do - {:ok, state} = init_tty([]) - cells = [{{1, 1}, {"X", :default, :default, [:underline]}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - assert output =~ "\e[4m" - end - - test "resets attributes at end of row" do - {:ok, state} = init_tty([]) - cells = [{{1, 1}, {"X", :red, :default, [:bold]}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Should end with reset - assert output =~ "\e[0m" - end - - test "outputs dim attribute" do - {:ok, state} = init_tty([]) - cells = [{{1, 1}, {"X", :default, :default, [:dim]}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - assert output =~ "\e[2m" - end - - test "outputs italic attribute" do - {:ok, state} = init_tty([]) - cells = [{{1, 1}, {"X", :default, :default, [:italic]}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - assert output =~ "\e[3m" - end - - test "outputs blink attribute" do - {:ok, state} = init_tty([]) - cells = [{{1, 1}, {"X", :default, :default, [:blink]}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - assert output =~ "\e[5m" - end - - test "outputs reverse attribute" do - {:ok, state} = init_tty([]) - cells = [{{1, 1}, {"X", :default, :default, [:reverse]}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - assert output =~ "\e[7m" - end - - test "outputs strikethrough attribute" do - {:ok, state} = init_tty([]) - cells = [{{1, 1}, {"X", :default, :default, [:strikethrough]}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - assert output =~ "\e[9m" - end - - test "outputs multiple attributes combined" do - {:ok, state} = init_tty([]) - cells = [{{1, 1}, {"X", :default, :default, [:bold, :italic, :underline]}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - assert output =~ "\e[1m" - assert output =~ "\e[3m" - assert output =~ "\e[4m" - end - - test "updates last_frame in state for incremental mode" do - {:ok, state} = init_tty(line_mode: :incremental) - cells = [{{1, 1}, {"A", :default, :default, []}}] - - {:ok, new_state} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, cells)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - assert new_state.last_frame == %{{1, 1} => {"A", :default, :default, []}} - end - - test "empty cells list produces no cell output" do - {:ok, state} = init_tty([]) - - output = - capture_io(fn -> - TTY.draw_cells(state, []) - end) - - # Should only have clear screen, no row positioning - refute output =~ "\e[1;1H" - end - - test "returns {:ok, state}" do - {:ok, state} = init_tty([]) - cells = [{{1, 1}, {"A", :default, :default, []}}] - - result = - capture_io(fn -> - send(self(), TTY.draw_cells(state, cells)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - assert {:ok, %TTY{}} = result - end - end - - # =========================================================================== - # Color Degradation Tests (Section 3.3.2) - # =========================================================================== - - describe "color degradation in draw_cells/2" do - test "256-color mode converts RGB to palette index" do - {:ok, state} = init_tty(capabilities: %{colors: :color_256}) - cells = [{{1, 1}, {"X", {255, 0, 0}, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Should use 38;5;N format, not 38;2;r;g;b - assert output =~ "\e[38;5;" - refute output =~ "\e[38;2;" - end - - test "16-color mode converts RGB to basic color" do - {:ok, state} = init_tty(capabilities: %{colors: :color_16}) - cells = [{{1, 1}, {"X", {255, 0, 0}, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Should use basic foreground codes (31 = red or 91 = bright red) - assert output =~ "\e[91m" or output =~ "\e[31m" - end - - test "monochrome mode omits color sequences" do - {:ok, state} = init_tty(capabilities: %{colors: :monochrome}) - cells = [{{1, 1}, {"X", {255, 0, 0}, {0, 0, 255}, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Should not have any color codes - refute output =~ "\e[38;" - refute output =~ "\e[48;" - refute output =~ "\e[31m" - end - - test "nil foreground color produces no color sequence" do - {:ok, state} = init_tty([]) - cells = [{{1, 1}, {"X", nil, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Should not have foreground color sequences (38;2 or 38;5) - refute output =~ "\e[38;" - end - - test "nil background color produces no color sequence" do - {:ok, state} = init_tty([]) - cells = [{{1, 1}, {"X", :default, nil, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Should not have background color sequences (48;2 or 48;5) - refute output =~ "\e[48;" - end - - test ":default foreground outputs reset foreground sequence" do - {:ok, state} = init_tty([]) - cells = [{{1, 1}, {"X", :default, :blue, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Default foreground should output \e[39m - assert output =~ "\e[39m" - # And blue background - assert output =~ "\e[44m" - end - - test ":default background outputs reset background sequence" do - {:ok, state} = init_tty([]) - cells = [{{1, 1}, {"X", :red, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Default background should output \e[49m - assert output =~ "\e[49m" - # And red foreground - assert output =~ "\e[31m" - end - - test "palette index foreground in 256-color mode" do - {:ok, state} = init_tty(capabilities: %{colors: :color_256}) - cells = [{{1, 1}, {"X", 42, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Palette index 42 as foreground - assert output =~ "\e[38;5;42m" - end - - test "palette index background in 256-color mode" do - {:ok, state} = init_tty(capabilities: %{colors: :color_256}) - cells = [{{1, 1}, {"X", :default, 196, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Palette index 196 as background - assert output =~ "\e[48;5;196m" - end - - test "palette index colors work in true_color mode" do - {:ok, state} = init_tty(capabilities: %{colors: :true_color}) - cells = [{{1, 1}, {"X", 100, 200, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Palette indices should still work in true_color mode - assert output =~ "\e[38;5;100m" - assert output =~ "\e[48;5;200m" - end - end - - # =========================================================================== - # Section 3.5 Tests - Color Degradation - # =========================================================================== - - describe "color degradation - 256-color mode (Section 3.5.2)" do - test "256-color mapping uses color cube for non-gray colors" do - {:ok, state} = init_tty(capabilities: %{colors: :color_256}) - # Pure red should map to color cube, not grayscale - cells = [{{1, 1}, {"X", {255, 0, 0}, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Should use 38;5;N format with a color cube index (16-231) - # Pure red (255, 0, 0) should map to index 196 (5*36 + 0*6 + 0 + 16) - assert output =~ "\e[38;5;196m" - end - - test "256-color mapping uses grayscale for near-gray colors" do - {:ok, state} = init_tty(capabilities: %{colors: :color_256}) - # Gray (128, 128, 128) should map to grayscale ramp - cells = [{{1, 1}, {"X", {128, 128, 128}, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Should use 38;5;N format with a grayscale index (232-255) - # Gray (128, 128, 128) average = 128, maps to 232 + (128 * 23 / 255) = 232 + 11 = 243 - assert output =~ "\e[38;5;243m" - end - - test "256-color background uses palette index" do - {:ok, state} = init_tty(capabilities: %{colors: :color_256}) - cells = [{{1, 1}, {"X", :default, {0, 255, 0}, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Pure green should map to 16 + 0*36 + 5*6 + 0 = 46 - assert output =~ "\e[48;5;46m" - end - end - - describe "color degradation - monochrome mode (Section 3.5.4)" do - test "monochrome mode preserves bold attribute" do - {:ok, state} = init_tty(capabilities: %{colors: :monochrome}) - cells = [{{1, 1}, {"X", {255, 0, 0}, :default, [:bold]}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Color should be omitted - refute output =~ "\e[38;" - # But bold should be preserved - assert output =~ "\e[1m" - end - - test "monochrome mode preserves underline attribute" do - {:ok, state} = init_tty(capabilities: %{colors: :monochrome}) - cells = [{{1, 1}, {"X", :red, :blue, [:underline]}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Colors should be omitted - refute output =~ "\e[31m" - refute output =~ "\e[44m" - # But underline should be preserved - assert output =~ "\e[4m" - end - - test "monochrome mode preserves reverse attribute for contrast" do - {:ok, state} = init_tty(capabilities: %{colors: :monochrome}) - cells = [{{1, 1}, {"X", {255, 255, 255}, {0, 0, 0}, [:reverse]}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # RGB colors should be omitted - refute output =~ "\e[38;2;" - refute output =~ "\e[48;2;" - # But reverse should be preserved for visibility - assert output =~ "\e[7m" - end - end - - describe "color degradation - named colors (Section 3.5.5)" do - test "named colors work in true_color mode" do - {:ok, state} = init_tty(capabilities: %{colors: :true_color}) - cells = [{{1, 1}, {"X", :cyan, :magenta, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Named colors should use standard SGR codes - # cyan foreground - assert output =~ "\e[36m" - # magenta background - assert output =~ "\e[45m" - end - - test "named colors work in 256-color mode" do - {:ok, state} = init_tty(capabilities: %{colors: :color_256}) - cells = [{{1, 1}, {"X", :yellow, :green, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Named colors should still use standard SGR codes - # yellow foreground - assert output =~ "\e[33m" - # green background - assert output =~ "\e[42m" - end - - test "named colors work in 16-color mode" do - {:ok, state} = init_tty(capabilities: %{colors: :color_16}) - cells = [{{1, 1}, {"X", :blue, :white, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Named colors should use standard SGR codes - # blue foreground - assert output =~ "\e[34m" - # white background - assert output =~ "\e[47m" - end - - test "bright named colors work correctly" do - {:ok, state} = init_tty([]) - cells = [{{1, 1}, {"X", :bright_red, :bright_blue, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Bright colors use codes 90-97 (fg) and 100-107 (bg) - # bright red foreground - assert output =~ "\e[91m" - # bright blue background - assert output =~ "\e[104m" - end - - test ":default foreground works in all modes" do - for mode <- [:true_color, :color_256, :color_16] do - {:ok, state} = init_tty(capabilities: %{colors: mode}) - cells = [{{1, 1}, {"X", :default, :red, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Default foreground should always output \e[39m - assert output =~ "\e[39m", "Failed for mode #{mode}" - end - end - - test ":default background works in all modes" do - for mode <- [:true_color, :color_256, :color_16] do - {:ok, state} = init_tty(capabilities: %{colors: mode}) - cells = [{{1, 1}, {"X", :red, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Default background should always output \e[49m - assert output =~ "\e[49m", "Failed for mode #{mode}" - end - end - end - - # =========================================================================== - # Section 3.3.3 Tests - Row-by-Row Output with Style Delta Tracking - # =========================================================================== - - describe "row-by-row output (Section 3.3.3)" do - test "consecutive cells with same style only output style once" do - {:ok, state} = init_tty([]) - - # Two cells with identical style - cells = [ - {{1, 1}, {"A", :red, :default, []}}, - {{1, 2}, {"B", :red, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Count occurrences of red foreground SGR - red_count = length(String.split(output, "\e[31m")) - 1 - - # Should only output red once (for first cell), not twice - assert red_count == 1 - end - - test "cells with different styles output style for each change" do - {:ok, state} = init_tty([]) - - # Two cells with different styles - cells = [ - {{1, 1}, {"A", :red, :default, []}}, - {{1, 2}, {"B", :blue, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Should have both red and blue - assert output =~ "\e[31m" - assert output =~ "\e[34m" - end - - test "style change in attributes triggers new SGR" do - {:ok, state} = init_tty([]) - - cells = [ - {{1, 1}, {"A", :default, :default, [:bold]}}, - {{1, 2}, {"B", :default, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Count reset sequences - should have at least 2 (one for style change, one at end) - reset_count = length(String.split(output, "\e[0m")) - 1 - - assert reset_count >= 2 - end - - test "gap filling preserves style tracking" do - {:ok, state} = init_tty([]) - - # Cells with gap between them, same style - cells = [ - {{1, 1}, {"A", :green, :default, []}}, - {{1, 5}, {"B", :green, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Gap should be filled with spaces - assert output =~ "A " - - # Green should only be output once - green_count = length(String.split(output, "\e[32m")) - 1 - assert green_count == 1 - end - - test "outputs cells left-to-right" do - {:ok, state} = init_tty([]) - - # Cells given out of order - cells = [ - {{1, 3}, {"C", :default, :default, []}}, - {{1, 1}, {"A", :default, :default, []}}, - {{1, 2}, {"B", :default, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Characters should appear in correct order - a_pos = :binary.match(output, "A") - b_pos = :binary.match(output, "B") - c_pos = :binary.match(output, "C") - - assert a_pos != :nomatch - assert b_pos != :nomatch - assert c_pos != :nomatch - - {a_start, _} = a_pos - {b_start, _} = b_pos - {c_start, _} = c_pos - - assert a_start < b_start - assert b_start < c_start - end - - test "multiple rows maintain correct ordering" do - {:ok, state} = init_tty([]) - - cells = [ - {{2, 1}, {"2", :default, :default, []}}, - {{1, 1}, {"1", :default, :default, []}}, - {{3, 1}, {"3", :default, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Row positioning should be in order - row1_pos = :binary.match(output, "\e[1;1H") - row2_pos = :binary.match(output, "\e[2;1H") - row3_pos = :binary.match(output, "\e[3;1H") - - assert row1_pos != :nomatch - assert row2_pos != :nomatch - assert row3_pos != :nomatch - - {r1_start, _} = row1_pos - {r2_start, _} = row2_pos - {r3_start, _} = row3_pos - - assert r1_start < r2_start - assert r2_start < r3_start - end - - test "each row ends with attribute reset" do - {:ok, state} = init_tty([]) - - cells = [ - {{1, 1}, {"A", :red, :default, []}}, - {{2, 1}, {"B", :blue, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Should have reset after each row - # Row 1: [1;1H + style + A + reset - # Row 2: [2;1H + style + B + reset - reset_count = length(String.split(output, "\e[0m")) - 1 - - # At least 2 resets (one per row) plus any style changes - assert reset_count >= 2 - end - end - - # =========================================================================== - # Security Tests - Character Sanitization - # =========================================================================== - - describe "character sanitization" do - test "escape sequences in cell content are stripped" do - {:ok, state} = init_tty([]) - # Attempt to inject an escape sequence via cell content - cells = [{{1, 1}, {"\e[31mEVIL", :default, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # The escape should be stripped from the cell content - # So we should see "EVIL" but not an extra \e[31m from the content itself - assert output =~ "[31mEVIL" - # The cell content escape was stripped, only framework escapes remain - # Count number of \e[31m - should only be 0 (no red from cell content) - refute output =~ "\e[31mEVIL" - end - - test "normal characters are not affected by sanitization" do - {:ok, state} = init_tty([]) - cells = [{{1, 1}, {"Hello!", :default, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - assert output =~ "Hello!" - end - end - - # =========================================================================== - # Frame Map Tests - Incremental Mode - # =========================================================================== - - describe "frame map handling" do - test "full_redraw mode sets last_frame to nil" do - {:ok, state} = init_tty(line_mode: :full_redraw) - cells = [{{1, 1}, {"A", :default, :default, []}}] - - {:ok, new_state} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, cells)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - assert new_state.last_frame == nil - end - - test "incremental mode stores last_frame as map" do - {:ok, state} = init_tty(line_mode: :incremental) - cells = [{{1, 1}, {"A", :default, :default, []}}] - - {:ok, new_state} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, cells)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - assert new_state.last_frame == %{{1, 1} => {"A", :default, :default, []}} - end - - test "incremental mode first frame (nil last_frame) triggers full redraw" do - {:ok, state} = init_tty(line_mode: :incremental) - # Verify last_frame starts as nil - assert state.last_frame == nil - - cells = [{{1, 1}, {"X", :default, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # First frame should include clear screen sequence - assert output =~ "\e[2J" - end - - test "incremental mode subsequent frame does not clear screen" do - {:ok, state} = init_tty(line_mode: :incremental) - cells = [{{1, 1}, {"A", :default, :default, []}}] - - # First frame - sets last_frame - {:ok, state_with_frame} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, cells)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - # Verify last_frame is now set - assert state_with_frame.last_frame != nil - - # Second frame - should NOT clear screen - output = - capture_io(fn -> - TTY.draw_cells(state_with_frame, cells) - end) - - # Should NOT include clear screen sequence - refute output =~ "\e[2J" - end - - test "clear/1 clears last_frame" do - {:ok, state} = init_tty(line_mode: :incremental) - cells = [{{1, 1}, {"A", :default, :default, []}}] - - # First draw to set last_frame - {:ok, state_with_frame} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, cells)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - assert state_with_frame.last_frame != nil - - # Clear should reset last_frame - {:ok, cleared_state} = - capture_io(fn -> - send(self(), TTY.clear(state_with_frame)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - assert cleared_state.last_frame == nil - end - - test "set_size/2 clears last_frame" do - {:ok, state} = init_tty(line_mode: :incremental) - cells = [{{1, 1}, {"A", :default, :default, []}}] - - # First draw to set last_frame - {:ok, state_with_frame} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, cells)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - assert state_with_frame.last_frame != nil - - # set_size should clear last_frame - {:ok, resized_state} = TTY.set_size(state_with_frame, {50, 120}) - - assert resized_state.last_frame == nil - assert resized_state.size == {50, 120} - end - - test "set_size/2 updates size correctly" do - {:ok, state} = init_tty([]) - - {:ok, resized_state} = TTY.set_size(state, {100, 200}) - - assert resized_state.size == {100, 200} - end - - test "after resize, next draw in incremental mode triggers full redraw" do - {:ok, state} = init_tty(line_mode: :incremental) - cells = [{{1, 1}, {"A", :default, :default, []}}] - - # First draw to set last_frame - {:ok, state_with_frame} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, cells)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - # Resize clears last_frame - {:ok, resized_state} = TTY.set_size(state_with_frame, {50, 120}) - assert resized_state.last_frame == nil - - # Next draw should trigger full redraw (clear screen) - output = - capture_io(fn -> - TTY.draw_cells(resized_state, cells) - end) - - assert output =~ "\e[2J" - end - end - - # =========================================================================== - # Frame Comparison Tests (Section 3.4.2) - # =========================================================================== - - describe "compare_frames/2" do - test "empty last frame and empty current returns no changes" do - {changed, removed} = TTY.compare_frames(%{}, []) - - assert changed == [] - assert removed == [] - end - - test "new cell is detected as changed" do - last_frame = %{} - current_cells = [{{1, 1}, {"A", :default, :default, []}}] - - {changed, removed} = TTY.compare_frames(last_frame, current_cells) - - assert changed == [{{1, 1}, {"A", :default, :default, []}}] - assert removed == [] - end - - test "multiple new cells are all detected as changed" do - last_frame = %{} - - current_cells = [ - {{1, 1}, {"A", :default, :default, []}}, - {{1, 2}, {"B", :default, :default, []}}, - {{2, 1}, {"C", :default, :default, []}} - ] - - {changed, removed} = TTY.compare_frames(last_frame, current_cells) - - assert length(changed) == 3 - assert removed == [] - end - - test "removed cell is detected" do - last_frame = %{{1, 1} => {"A", :default, :default, []}} - current_cells = [] - - {changed, removed} = TTY.compare_frames(last_frame, current_cells) - - assert changed == [] - assert removed == [{1, 1}] - end - - test "multiple removed cells are all detected" do - last_frame = %{ - {1, 1} => {"A", :default, :default, []}, - {1, 2} => {"B", :default, :default, []}, - {2, 1} => {"C", :default, :default, []} - } - - current_cells = [] - - {changed, removed} = TTY.compare_frames(last_frame, current_cells) - - assert changed == [] - assert length(removed) == 3 - assert {1, 1} in removed - assert {1, 2} in removed - assert {2, 1} in removed - end - - test "unchanged cell is not in changed or removed" do - cell = {"A", :default, :default, []} - last_frame = %{{1, 1} => cell} - current_cells = [{{1, 1}, cell}] - - {changed, removed} = TTY.compare_frames(last_frame, current_cells) - - assert changed == [] - assert removed == [] - end - - test "changed character is detected" do - last_frame = %{{1, 1} => {"A", :default, :default, []}} - current_cells = [{{1, 1}, {"B", :default, :default, []}}] - - {changed, removed} = TTY.compare_frames(last_frame, current_cells) - - assert changed == [{{1, 1}, {"B", :default, :default, []}}] - assert removed == [] - end - - test "changed foreground color is detected" do - last_frame = %{{1, 1} => {"A", :red, :default, []}} - current_cells = [{{1, 1}, {"A", :blue, :default, []}}] - - {changed, removed} = TTY.compare_frames(last_frame, current_cells) - - assert changed == [{{1, 1}, {"A", :blue, :default, []}}] - assert removed == [] - end - - test "changed background color is detected" do - last_frame = %{{1, 1} => {"A", :default, :red, []}} - current_cells = [{{1, 1}, {"A", :default, :blue, []}}] - - {changed, removed} = TTY.compare_frames(last_frame, current_cells) - - assert changed == [{{1, 1}, {"A", :default, :blue, []}}] - assert removed == [] - end - - test "changed attributes are detected" do - last_frame = %{{1, 1} => {"A", :default, :default, [:bold]}} - current_cells = [{{1, 1}, {"A", :default, :default, [:underline]}}] - - {changed, removed} = TTY.compare_frames(last_frame, current_cells) - - assert changed == [{{1, 1}, {"A", :default, :default, [:underline]}}] - assert removed == [] - end - - test "added attribute is detected as change" do - last_frame = %{{1, 1} => {"A", :default, :default, []}} - current_cells = [{{1, 1}, {"A", :default, :default, [:bold]}}] - - {changed, removed} = TTY.compare_frames(last_frame, current_cells) - - assert changed == [{{1, 1}, {"A", :default, :default, [:bold]}}] - assert removed == [] - end - - test "mixed scenario: some changed, some removed, some unchanged" do - last_frame = %{ - {1, 1} => {"A", :default, :default, []}, - {1, 2} => {"B", :default, :default, []}, - {1, 3} => {"C", :default, :default, []} - } - - current_cells = [ - {{1, 1}, {"A", :default, :default, []}}, - {{1, 2}, {"X", :default, :default, []}}, - {{1, 4}, {"D", :default, :default, []}} - ] - - {changed, removed} = TTY.compare_frames(last_frame, current_cells) - - # {1, 1} unchanged - not in changed - # {1, 2} changed from B to X - # {1, 3} removed - # {1, 4} new - assert length(changed) == 2 - - assert {{1, 2}, {"X", :default, :default, []}} in changed - assert {{1, 4}, {"D", :default, :default, []}} in changed - - assert removed == [{1, 3}] - end - - test "position order is preserved in changed list" do - last_frame = %{} - - current_cells = [ - {{1, 1}, {"A", :default, :default, []}}, - {{2, 1}, {"B", :default, :default, []}}, - {{1, 2}, {"C", :default, :default, []}} - ] - - {changed, _removed} = TTY.compare_frames(last_frame, current_cells) - - # Order should match input order - assert changed == current_cells - end - - test "RGB color change is detected" do - last_frame = %{{1, 1} => {"A", {255, 0, 0}, :default, []}} - current_cells = [{{1, 1}, {"A", {0, 255, 0}, :default, []}}] - - {changed, removed} = TTY.compare_frames(last_frame, current_cells) - - assert changed == [{{1, 1}, {"A", {0, 255, 0}, :default, []}}] - assert removed == [] - end - end - - # =========================================================================== - # Incremental Rendering Tests (Section 3.4.3) - # =========================================================================== - - describe "incremental rendering" do - test "only renders changed cells on subsequent frames" do - {:ok, state} = init_tty(line_mode: :incremental) - - # First frame with two cells - cells1 = [ - {{1, 1}, {"A", :default, :default, []}}, - {{1, 2}, {"B", :default, :default, []}} - ] - - {:ok, state1} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, cells1)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - # Second frame: change one cell, keep one the same - cells2 = [ - {{1, 1}, {"A", :default, :default, []}}, - {{1, 2}, {"X", :default, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state1, cells2) - end) - - # Should NOT have clear screen (incremental) - refute output =~ "\e[2J" - - # Should have cursor positioning for the changed cell {1, 2} - assert output =~ "\e[1;2H" - - # Should contain the changed character X - assert output =~ "X" - end - - test "unchanged cells are not re-rendered" do - {:ok, state} = init_tty(line_mode: :incremental) - - cells = [ - {{1, 1}, {"A", :default, :default, []}}, - {{1, 2}, {"B", :default, :default, []}} - ] - - {:ok, state1} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, cells)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - # Same cells - nothing should be rendered - output = - capture_io(fn -> - TTY.draw_cells(state1, cells) - end) - - # No clear screen - refute output =~ "\e[2J" - - # No cursor positioning (nothing to render) - refute output =~ "\e[1;1H" - refute output =~ "\e[1;2H" - end - - test "removed cells are cleared with space" do - {:ok, state} = init_tty(line_mode: :incremental) - - # First frame with two cells - cells1 = [ - {{1, 1}, {"A", :default, :default, []}}, - {{1, 2}, {"B", :default, :default, []}} - ] - - {:ok, state1} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, cells1)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - # Second frame: remove one cell - cells2 = [{{1, 1}, {"A", :default, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state1, cells2) - end) - - # Should position cursor at removed cell location {1, 2} - assert output =~ "\e[1;2H" - - # Should write a space to clear it (with reset) - assert output =~ "\e[0m " - end - - test "new cells are rendered" do - {:ok, state} = init_tty(line_mode: :incremental) - - # First frame with one cell - cells1 = [{{1, 1}, {"A", :default, :default, []}}] - - {:ok, state1} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, cells1)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - # Second frame: add a new cell - cells2 = [ - {{1, 1}, {"A", :default, :default, []}}, - {{1, 2}, {"B", :default, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state1, cells2) - end) - - # Should position cursor at new cell location {1, 2} - assert output =~ "\e[1;2H" - - # Should render the new cell - assert output =~ "B" - end - - test "mixed changes: add, modify, remove in single frame" do - {:ok, state} = init_tty(line_mode: :incremental) - - # First frame - cells1 = [ - {{1, 1}, {"A", :default, :default, []}}, - {{1, 2}, {"B", :default, :default, []}}, - {{1, 3}, {"C", :default, :default, []}} - ] - - {:ok, state1} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, cells1)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - # Second frame: - # - {1, 1} unchanged (A) - # - {1, 2} changed (B -> X) - # - {1, 3} removed - # - {1, 4} added (D) - cells2 = [ - {{1, 1}, {"A", :default, :default, []}}, - {{1, 2}, {"X", :default, :default, []}}, - {{1, 4}, {"D", :default, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state1, cells2) - end) - - # Should NOT render unchanged cell {1, 1} - refute output =~ "\e[1;1H" - - # Should render changed cell {1, 2} - cursor positioned there - assert output =~ "\e[1;2H" - assert output =~ "X" - - # Should clear removed cell {1, 3} (separate cursor positioning for clear) - assert output =~ "\e[1;3H" - - # Should render new cell {1, 4} - # With optimization, cells on same row are grouped, so D is rendered - # after X with a space gap (X at col 2, space fills col 3, D at col 4) - assert output =~ "D" - - # The output should show the grouped rendering: X + space + D - # (X at col 2, gap fills col 3 to reach col 4, then D) - assert output =~ "X D" - end - - test "style change triggers re-render" do - {:ok, state} = init_tty(line_mode: :incremental) - - # First frame - cells1 = [{{1, 1}, {"A", :red, :default, []}}] - - {:ok, state1} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, cells1)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - # Second frame: change color - cells2 = [{{1, 1}, {"A", :blue, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state1, cells2) - end) - - # Should re-render the cell - assert output =~ "\e[1;1H" - - # Should have blue color code - assert output =~ "\e[34m" - end - - test "last_frame is updated after incremental render" do - {:ok, state} = init_tty(line_mode: :incremental) - - cells1 = [{{1, 1}, {"A", :default, :default, []}}] - - {:ok, state1} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, cells1)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - cells2 = [{{1, 1}, {"B", :default, :default, []}}] - - {:ok, state2} = - capture_io(fn -> - send(self(), TTY.draw_cells(state1, cells2)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - # last_frame should now contain B, not A - assert state2.last_frame == %{{1, 1} => {"B", :default, :default, []}} - end - - test "full_redraw mode always clears screen" do - {:ok, state} = init_tty(line_mode: :full_redraw) - - cells1 = [{{1, 1}, {"A", :default, :default, []}}] - - {:ok, state1} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, cells1)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - # Even identical cells should trigger full redraw - output = - capture_io(fn -> - TTY.draw_cells(state1, cells1) - end) - - # Should ALWAYS have clear screen in full_redraw mode - assert output =~ "\e[2J" - end - end - - # =========================================================================== - # Cursor Movement Optimization Tests (Section 3.4.4) - # =========================================================================== - - describe "cursor movement optimization" do - test "changed cells are sorted by position for rendering" do - {:ok, state} = init_tty(line_mode: :incremental) - - # First frame - cells1 = [ - {{1, 1}, {"A", :default, :default, []}}, - {{1, 5}, {"E", :default, :default, []}}, - {{1, 3}, {"C", :default, :default, []}} - ] - - {:ok, state1} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, cells1)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - # Second frame: change all cells (order doesn't match position order) - cells2 = [ - {{1, 5}, {"X", :default, :default, []}}, - {{1, 1}, {"Y", :default, :default, []}}, - {{1, 3}, {"Z", :default, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state1, cells2) - end) - - # All cells changed so they should all be rendered - # They should be grouped by row and sorted by column - assert output =~ "Y" - assert output =~ "Z" - assert output =~ "X" - - # Characters should appear in column order (Y at 1, Z at 3, X at 5) - y_pos = :binary.match(output, "Y") - z_pos = :binary.match(output, "Z") - x_pos = :binary.match(output, "X") - - assert y_pos != :nomatch - assert z_pos != :nomatch - assert x_pos != :nomatch - - {y_start, _} = y_pos - {z_start, _} = z_pos - {x_start, _} = x_pos - - assert y_start < z_start - assert z_start < x_start - end - - test "adjacent cells on same row use single cursor positioning" do - {:ok, state} = init_tty(line_mode: :incremental) - - # First frame - empty - {:ok, state1} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, [])) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - # Second frame: add adjacent cells on row 1 - cells2 = [ - {{1, 1}, {"A", :default, :default, []}}, - {{1, 2}, {"B", :default, :default, []}}, - {{1, 3}, {"C", :default, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state1, cells2) - end) - - # Should only have ONE cursor positioning for row 1 (at start) - # Count cursor positioning sequences for row 1 - row1_positions = - output - |> String.split("\e[1;") - |> length() - - # Should position only once at the start of the row - # (one more element than actual occurrences due to split behavior) - assert row1_positions == 2 - end - - test "non-adjacent cells on same row fill gaps with spaces" do - {:ok, state} = init_tty(line_mode: :incremental) - - # First frame - empty - {:ok, state1} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, [])) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - # Second frame: cells at columns 1 and 4 (gap of 2) - cells2 = [ - {{1, 1}, {"A", :default, :default, []}}, - {{1, 4}, {"D", :default, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state1, cells2) - end) - - # Should have A followed by spaces, then D - # The pattern should be: cursor positioning + A + spaces + style + D - assert output =~ "A" - assert output =~ "D" - - # Gap should be filled with spaces (columns 2, 3 = 2 spaces between A and D) - # But actually we're going from col 1 (A takes col 1) to col 4 - # So gap is col 2, 3 = 2 spaces - # Actually after rendering A at col 1, cursor advances to col 2 - # Then we need to fill col 2, 3 to reach col 4 = 2 spaces - assert output =~ "A " - end - - test "cells on different rows get separate cursor positioning" do - {:ok, state} = init_tty(line_mode: :incremental) - - # First frame - empty - {:ok, state1} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, [])) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - # Second frame: cells on rows 1 and 3 - cells2 = [ - {{1, 1}, {"A", :default, :default, []}}, - {{3, 1}, {"C", :default, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state1, cells2) - end) - - # Should have cursor positioning for both rows - assert output =~ "\e[1;1H" - assert output =~ "\e[3;1H" - end - - test "style delta tracking works within grouped row cells" do - {:ok, state} = init_tty(line_mode: :incremental) - - # First frame - empty - {:ok, state1} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, [])) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - # Second frame: adjacent cells with same style - cells2 = [ - {{1, 1}, {"A", :red, :default, []}}, - {{1, 2}, {"B", :red, :default, []}}, - {{1, 3}, {"C", :red, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state1, cells2) - end) - - # Red color should only appear once (delta tracking) - red_count = length(String.split(output, "\e[31m")) - 1 - assert red_count == 1 - end - - test "multiple rows are processed in order" do - {:ok, state} = init_tty(line_mode: :incremental) - - # First frame - empty - {:ok, state1} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, [])) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - # Second frame: cells on rows 3, 1, 2 (out of order) - cells2 = [ - {{3, 1}, {"3", :default, :default, []}}, - {{1, 1}, {"1", :default, :default, []}}, - {{2, 1}, {"2", :default, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state1, cells2) - end) - - # Rows should be processed in order 1, 2, 3 - row1_pos = :binary.match(output, "\e[1;1H") - row2_pos = :binary.match(output, "\e[2;1H") - row3_pos = :binary.match(output, "\e[3;1H") - - assert row1_pos != :nomatch - assert row2_pos != :nomatch - assert row3_pos != :nomatch - - {r1_start, _} = row1_pos - {r2_start, _} = row2_pos - {r3_start, _} = row3_pos - - assert r1_start < r2_start - assert r2_start < r3_start - end - - test "removed cells are sorted for sequential clearing" do - {:ok, state} = init_tty(line_mode: :incremental) - - # First frame: cells at various positions - cells1 = [ - {{2, 3}, {"X", :default, :default, []}}, - {{1, 1}, {"A", :default, :default, []}}, - {{2, 1}, {"B", :default, :default, []}} - ] - - {:ok, state1} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, cells1)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - # Second frame: remove all cells - output = - capture_io(fn -> - TTY.draw_cells(state1, []) - end) - - # All positions should be cleared - assert output =~ "\e[1;1H" - assert output =~ "\e[2;1H" - assert output =~ "\e[2;3H" - - # Positions should be cleared in sorted order - pos_1_1 = :binary.match(output, "\e[1;1H") - pos_2_1 = :binary.match(output, "\e[2;1H") - pos_2_3 = :binary.match(output, "\e[2;3H") - - {p1_start, _} = pos_1_1 - {p2_start, _} = pos_2_1 - {p3_start, _} = pos_2_3 - - # {1, 1} < {2, 1} < {2, 3} in tuple comparison order - assert p1_start < p2_start - assert p2_start < p3_start - end - - test "mixed changed and removed cells both optimized" do - {:ok, state} = init_tty(line_mode: :incremental) - - # First frame - cells1 = [ - {{1, 1}, {"A", :default, :default, []}}, - {{1, 2}, {"B", :default, :default, []}}, - {{2, 1}, {"C", :default, :default, []}} - ] - - {:ok, state1} = - capture_io(fn -> - send(self(), TTY.draw_cells(state, cells1)) - end) - |> then(fn _ -> - receive do - result -> result - end - end) - - # Second frame: change {1, 1} and {1, 2}, remove {2, 1} - cells2 = [ - {{1, 1}, {"X", :default, :default, []}}, - {{1, 2}, {"Y", :default, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state1, cells2) - end) - - # Changed cells on row 1 should be grouped (single cursor positioning) - # Should have cursor position for row 1 - assert output =~ "\e[1;1H" - - # Should have both changed characters - assert output =~ "X" - assert output =~ "Y" - - # Should clear removed cell at {2, 1} - assert output =~ "\e[2;1H" - assert output =~ "\e[0m " - end - end - - # =========================================================================== - # Section 3.6.2 Tests - Character Mapping - # =========================================================================== - - describe "character mapping (Section 3.6.2)" do - test "unicode mode passes through box-drawing characters unchanged" do - {:ok, state} = init_tty(capabilities: %{unicode: true}) - assert state.character_set == :unicode - - # Unicode box-drawing character - cells = [{{1, 1}, {"┌", :default, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Should contain the Unicode character unchanged - assert output =~ "┌" - end - - test "ascii mode converts box corners to +" do - {:ok, state} = init_tty(capabilities: %{unicode: false}) - assert state.character_set == :ascii - - # Unicode corners should become + - cells = [ - {{1, 1}, {"┌", :default, :default, []}}, - {{1, 2}, {"┐", :default, :default, []}}, - {{1, 3}, {"└", :default, :default, []}}, - {{1, 4}, {"┘", :default, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Should have + characters, not Unicode corners - assert output =~ "++++" - refute output =~ "┌" - refute output =~ "┐" - refute output =~ "└" - refute output =~ "┘" - end - - test "ascii mode converts horizontal line to -" do - {:ok, state} = init_tty(capabilities: %{unicode: false}) - - cells = [{{1, 1}, {"─", :default, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - assert output =~ "-" - refute output =~ "─" - end - - test "ascii mode converts vertical line to |" do - {:ok, state} = init_tty(capabilities: %{unicode: false}) - - cells = [{{1, 1}, {"│", :default, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - assert output =~ "|" - refute output =~ "│" - end - - test "ascii mode converts T-junctions to +" do - {:ok, state} = init_tty(capabilities: %{unicode: false}) - - cells = [ - {{1, 1}, {"┬", :default, :default, []}}, - {{1, 2}, {"┴", :default, :default, []}}, - {{1, 3}, {"├", :default, :default, []}}, - {{1, 4}, {"┤", :default, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Should have + characters - assert output =~ "++++" - refute output =~ "┬" - refute output =~ "┴" - refute output =~ "├" - refute output =~ "┤" - end - - test "ascii mode converts cross junction to +" do - {:ok, state} = init_tty(capabilities: %{unicode: false}) - - cells = [{{1, 1}, {"┼", :default, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - assert output =~ "+" - refute output =~ "┼" - end - - test "ascii mode converts progress bar characters" do - {:ok, state} = init_tty(capabilities: %{unicode: false}) - - cells = [ - {{1, 1}, {"█", :default, :default, []}}, - {{1, 2}, {"░", :default, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Full block becomes #, empty becomes . - assert output =~ "#." - refute output =~ "█" - refute output =~ "░" - end - - test "ascii mode converts check marks" do - {:ok, state} = init_tty(capabilities: %{unicode: false}) - - cells = [ - {{1, 1}, {"✓", :default, :default, []}}, - {{1, 2}, {"✗", :default, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Check becomes x, cross becomes X - assert output =~ "xX" - refute output =~ "✓" - refute output =~ "✗" - end - - test "ascii mode converts arrows" do - {:ok, state} = init_tty(capabilities: %{unicode: false}) - - cells = [ - {{1, 1}, {"↑", :default, :default, []}}, - {{1, 2}, {"↓", :default, :default, []}}, - {{1, 3}, {"←", :default, :default, []}}, - {{1, 4}, {"→", :default, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Up=^, Down=v, Left=<, Right=> - assert output =~ "^v<>" - refute output =~ "↑" - refute output =~ "↓" - refute output =~ "←" - refute output =~ "→" - end - - test "regular characters pass through unchanged in ascii mode" do - {:ok, state} = init_tty(capabilities: %{unicode: false}) - - cells = [{{1, 1}, {"Hello", :default, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - assert output =~ "Hello" - end - - test "regular characters pass through unchanged in unicode mode" do - {:ok, state} = init_tty(capabilities: %{unicode: true}) - - cells = [{{1, 1}, {"World", :default, :default, []}}] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - assert output =~ "World" - end - - test "mixed content with box drawing in ascii mode" do - {:ok, state} = init_tty(capabilities: %{unicode: false}) - - # Simulate a simple box: ┌─┐ - cells = [ - {{1, 1}, {"┌", :default, :default, []}}, - {{1, 2}, {"─", :default, :default, []}}, - {{1, 3}, {"┐", :default, :default, []}} - ] - - output = - capture_io(fn -> - TTY.draw_cells(state, cells) - end) - - # Should render as +-+ - assert output =~ "+-+" - end - end - - # =========================================================================== - # Section 3.8.1 Integration Tests - Full Redraw Lifecycle - # =========================================================================== - - @tag :integration - describe "integration - full redraw lifecycle (Section 3.8.1)" do - test "init -> draw_cells -> shutdown sequence works correctly" do - # Initialize backend with full_redraw mode - output = - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :full_redraw, size: {24, 80}) - - # Verify state is correctly initialized - assert state.line_mode == :full_redraw - assert state.size == {24, 80} - - # Draw some cells - cells = [ - {{1, 1}, {"H", :default, :default, []}}, - {{1, 2}, {"i", :default, :default, []}} - ] - - {:ok, state} = TTY.draw_cells(state, cells) - - # Verify last_frame is nil (full_redraw doesn't track frames) - assert state.last_frame == nil - - # Shutdown - :ok = TTY.shutdown(state) - end) - - # Verify init sequence: hide cursor, clear screen - assert output =~ "\e[?25l" - assert output =~ "\e[2J" - assert output =~ "\e[H" - - # Verify content was rendered - assert output =~ "Hi" - - # Verify shutdown sequence: reset attrs, show cursor - assert output =~ "\e[0m" - assert output =~ "\e[?25h" - end - - test "init -> draw_cells -> shutdown with alternate screen" do - output = - capture_io(fn -> - {:ok, state} = TTY.init(alternate_screen: true, size: {24, 80}) - - assert state.alternate_screen == true - - cells = [{{1, 1}, {"X", :default, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells) - - :ok = TTY.shutdown(state) - end) - - # Verify alternate screen enter - assert output =~ "\e[?1049h" - - # Verify content rendered - assert output =~ "X" - - # Verify alternate screen leave on shutdown - assert output =~ "\e[?1049l" - end - - test "multiple frames render correctly in full_redraw mode" do - output = - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :full_redraw, size: {24, 80}) - - # Frame 1: Render "A" - cells1 = [{{1, 1}, {"A", :default, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells1) - - # Frame 2: Render "B" at different position - cells2 = [{{2, 1}, {"B", :default, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells2) - - # Frame 3: Render "C" with both positions - cells3 = [ - {{1, 1}, {"C", :default, :default, []}}, - {{2, 1}, {"D", :default, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells3) - end) - - # Each frame should have a clear screen sequence - # Count occurrences of clear screen (init + 3 frames = 4) - clear_count = length(String.split(output, "\e[2J")) - 1 - assert clear_count == 4 - - # Verify all content was rendered - assert output =~ "A" - assert output =~ "B" - assert output =~ "C" - assert output =~ "D" - end - - test "state is properly maintained between frames" do - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {30, 100}, - capabilities: %{colors: :true_color} - ) - - # Verify initial state - assert state.size == {30, 100} - assert state.line_mode == :full_redraw - assert state.color_mode == :true_color - - # Frame 1 - cells1 = [{{1, 1}, {"X", :default, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells1) - - # Verify state persists after frame 1 - assert state.size == {30, 100} - assert state.line_mode == :full_redraw - assert state.color_mode == :true_color - - # Frame 2 - cells2 = [{{1, 1}, {"Y", :default, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells2) - - # Verify state still persists after frame 2 - assert state.size == {30, 100} - assert state.line_mode == :full_redraw - assert state.color_mode == :true_color - end) - end - - test "style changes between frames render different SGR sequences" do - output = - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :full_redraw, size: {24, 80}) - - # Frame 1: Red text - cells1 = [{{1, 1}, {"R", :red, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells1) - - # Frame 2: Blue text with bold - cells2 = [{{1, 1}, {"B", :blue, :default, [:bold]}}] - {:ok, state} = TTY.draw_cells(state, cells2) - - # Frame 3: Green background - cells3 = [{{1, 1}, {"G", :default, :green, []}}] - {:ok, _state} = TTY.draw_cells(state, cells3) - end) - - # Verify red foreground (SGR code 31) - assert output =~ "\e[31m" - - # Verify blue foreground (SGR code 34) - assert output =~ "\e[34m" - - # Verify bold attribute (SGR code 1) - assert output =~ "\e[1m" - - # Verify green background (SGR code 42) - assert output =~ "\e[42m" - - # Verify content - assert output =~ "R" - assert output =~ "B" - assert output =~ "G" - end - - test "style changes with RGB colors in true_color mode" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{colors: :true_color} - ) - - # Frame 1: RGB red foreground - cells1 = [{{1, 1}, {"1", {255, 0, 0}, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells1) - - # Frame 2: RGB blue background - cells2 = [{{1, 1}, {"2", :default, {0, 0, 255}, []}}] - {:ok, _state} = TTY.draw_cells(state, cells2) - end) - - # Verify true color foreground sequence - assert output =~ "\e[38;2;255;0;0m" - - # Verify true color background sequence - assert output =~ "\e[48;2;0;0;255m" - end - - test "style changes with multiple attributes" do - output = - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :full_redraw, size: {24, 80}) - - # Frame 1: Bold + underline - cells1 = [{{1, 1}, {"A", :default, :default, [:bold, :underline]}}] - {:ok, state} = TTY.draw_cells(state, cells1) - - # Frame 2: Italic + reverse - cells2 = [{{1, 1}, {"B", :default, :default, [:italic, :reverse]}}] - {:ok, state} = TTY.draw_cells(state, cells2) - - # Frame 3: Dim + strikethrough - cells3 = [{{1, 1}, {"C", :default, :default, [:dim, :strikethrough]}}] - {:ok, _state} = TTY.draw_cells(state, cells3) - end) - - # Verify bold (SGR 1) and underline (SGR 4) - assert output =~ "\e[1m" - assert output =~ "\e[4m" - - # Verify italic (SGR 3) and reverse (SGR 7) - assert output =~ "\e[3m" - assert output =~ "\e[7m" - - # Verify dim (SGR 2) and strikethrough (SGR 9) - assert output =~ "\e[2m" - assert output =~ "\e[9m" - end - - test "combined color and attribute changes between frames" do - output = - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :full_redraw, size: {24, 80}) - - # Frame 1: Red + bold - cells1 = [{{1, 1}, {"X", :red, :default, [:bold]}}] - {:ok, state} = TTY.draw_cells(state, cells1) - - # Frame 2: Blue background + italic - cells2 = [{{1, 1}, {"Y", :white, :blue, [:italic]}}] - {:ok, state} = TTY.draw_cells(state, cells2) - - # Frame 3: RGB color + underline - cells3 = [{{1, 1}, {"Z", {128, 128, 0}, {64, 64, 64}, [:underline]}}] - {:ok, _state} = TTY.draw_cells(state, cells3) - end) - - # Frame 1: red (31) + bold (1) - assert output =~ "\e[31m" - assert output =~ "\e[1m" - - # Frame 2: white fg (37) + blue bg (44) + italic (3) - assert output =~ "\e[37m" - assert output =~ "\e[44m" - assert output =~ "\e[3m" - - # Frame 3: RGB colors + underline (4) - assert output =~ "\e[38;2;128;128;0m" - assert output =~ "\e[48;2;64;64;64m" - assert output =~ "\e[4m" - - # Verify content - assert output =~ "X" - assert output =~ "Y" - assert output =~ "Z" - end - - test "each row ends with attribute reset" do - output = - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :full_redraw, size: {24, 80}) - - # Multiple rows with different styles - cells = [ - {{1, 1}, {"A", :red, :default, [:bold]}}, - {{2, 1}, {"B", :blue, :default, [:italic]}}, - {{3, 1}, {"C", :green, :default, [:underline]}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Each row should have reset sequence after content - # Count reset sequences (excluding final reset from last row) - reset_count = length(String.split(output, "\e[0m")) - 1 - - # Should have at least 3 resets (one per row) plus init - assert reset_count >= 3 - end - - test "cursor position is updated after draw_cells" do - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :full_redraw, size: {24, 80}) - - # Initial cursor position after init should be {1, 1} - assert state.cursor_position == {1, 1} - - # Draw cells - cells = [{{5, 10}, {"X", :default, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells) - - # After draw_cells, cursor_position is nil (rendering doesn't track final position) - assert state.cursor_position == nil - end) - end - - test "full lifecycle with clear operation" do - output = - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :full_redraw, size: {24, 80}) - - # Draw initial content - cells1 = [{{1, 1}, {"A", :default, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells1) - - # Explicit clear - {:ok, state} = TTY.clear(state) - - # Draw new content - cells2 = [{{1, 1}, {"B", :default, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells2) - - :ok = TTY.shutdown(state) - end) - - # Count clear sequences: init + frame1 + explicit clear + frame2 = 4 - clear_count = length(String.split(output, "\e[2J")) - 1 - assert clear_count == 4 - - # Both characters rendered - assert output =~ "A" - assert output =~ "B" - end - - test "full lifecycle maintains correct line_mode throughout" do - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :full_redraw, size: {24, 80}) - assert state.line_mode == :full_redraw - - # After multiple operations, line_mode should remain unchanged - cells = [{{1, 1}, {"X", :default, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells) - assert state.line_mode == :full_redraw - - {:ok, state} = TTY.clear(state) - assert state.line_mode == :full_redraw - - {:ok, state} = TTY.move_cursor(state, {5, 5}) - assert state.line_mode == :full_redraw - - {:ok, state} = TTY.flush(state) - assert state.line_mode == :full_redraw - end) - end - end - - # =========================================================================== - # Section 3.8.2 Integration Tests - Incremental Rendering - # =========================================================================== - - @tag :integration - describe "integration - incremental rendering (Section 3.8.2)" do - # ------------------------------------------------------------------------- - # 3.8.2.1 - Test first frame falls back to full redraw - # ------------------------------------------------------------------------- - - test "first frame in incremental mode triggers full redraw" do - output = - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :incremental, size: {24, 80}) - - # First frame should trigger full redraw (clear screen) - cells = [{{1, 1}, {"A", :default, :default, []}}] - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Should contain clear screen sequence (full redraw behavior) - assert output =~ "\e[2J" - end - - test "first frame in incremental mode populates last_frame" do - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :incremental, size: {24, 80}) - - # Verify initial state has nil last_frame - assert is_nil(state.last_frame) - - # First frame should populate last_frame - cells = [{{1, 1}, {"A", :default, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells) - - # last_frame should now be populated - assert is_map(state.last_frame) - assert map_size(state.last_frame) == 1 - assert Map.has_key?(state.last_frame, {1, 1}) - end) - end - - test "first frame with nil last_frame outputs clear screen and content" do - output = - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :incremental, size: {24, 80}) - - cells = [ - {{1, 1}, {"H", :default, :default, []}}, - {{1, 2}, {"i", :default, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Full redraw behavior: clear screen, home cursor, render content - # clear screen - assert output =~ "\e[2J" - # cursor home - assert output =~ "\e[H" - assert output =~ "H" - assert output =~ "i" - end - - test "state transitions from nil to populated last_frame correctly" do - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :incremental, size: {24, 80}) - - # First draw - cells1 = [{{1, 1}, {"A", :default, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells1) - - # Capture the first frame - first_frame = state.last_frame - assert is_map(first_frame) - - # Second draw with different content - cells2 = [{{1, 1}, {"B", :default, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells2) - - # Frame should be updated - assert state.last_frame != first_frame - assert Map.get(state.last_frame, {1, 1}) == {"B", :default, :default, []} - end) - end - - # ------------------------------------------------------------------------- - # 3.8.2.2 - Test subsequent frames only update changes - # ------------------------------------------------------------------------- - - test "subsequent frames do not clear screen" do - _first_output = - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :incremental, size: {24, 80}) - - # First frame (triggers full redraw) - cells1 = [{{1, 1}, {"A", :default, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells1) - - # Capture output from second frame only - second_output = - capture_io(fn -> - cells2 = [{{1, 1}, {"B", :default, :default, []}}] - {:ok, _state} = TTY.draw_cells(state, cells2) - end) - - send(self(), {:second_output, second_output}) - end) - - receive do - {:second_output, second_output} -> - # Second frame should NOT contain clear screen - refute second_output =~ "\e[2J" - end - end - - test "unchanged cells are not re-rendered in subsequent frames" do - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :incremental, size: {24, 80}) - - # First frame with two cells - cells1 = [ - {{1, 1}, {"A", :default, :default, []}}, - {{1, 2}, {"B", :default, :default, []}} - ] - - {:ok, state} = TTY.draw_cells(state, cells1) - - # Second frame - only change one cell, keep the other same - second_output = - capture_io(fn -> - cells2 = [ - # unchanged - {{1, 1}, {"A", :default, :default, []}}, - # changed - {{1, 2}, {"X", :default, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells2) - end) - - # Should contain the changed cell - assert second_output =~ "X" - # Count occurrences of "A" - should not be re-rendered - # (A may appear in escape sequences so we check it's not in content position) - # The incremental render only outputs changed cells - end) - end - - test "changed cells are rendered with cursor positioning" do - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :incremental, size: {24, 80}) - - # First frame - cells1 = [{{5, 10}, {"A", :default, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells1) - - # Second frame - change the cell - second_output = - capture_io(fn -> - cells2 = [{{5, 10}, {"B", :default, :default, []}}] - {:ok, _state} = TTY.draw_cells(state, cells2) - end) - - # Should contain cursor positioning for row 5, col 10 - assert second_output =~ "\e[5;10H" - # Should contain the new content - assert second_output =~ "B" - end) - end - - test "new cells are added in subsequent frames" do - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :incremental, size: {24, 80}) - - # First frame with one cell - cells1 = [{{1, 1}, {"A", :default, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells1) - - # Second frame - add a new cell - second_output = - capture_io(fn -> - cells2 = [ - {{1, 1}, {"A", :default, :default, []}}, - # new cell - {{1, 2}, {"B", :default, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells2) - end) - - # Should contain the new cell - assert second_output =~ "B" - end) - end - - test "removed cells are cleared in subsequent frames" do - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :incremental, size: {24, 80}) - - # First frame with two cells - cells1 = [ - {{1, 1}, {"A", :default, :default, []}}, - {{1, 2}, {"B", :default, :default, []}} - ] - - {:ok, state} = TTY.draw_cells(state, cells1) - - # Second frame - remove second cell - second_output = - capture_io(fn -> - cells2 = [{{1, 1}, {"A", :default, :default, []}}] - {:ok, _state} = TTY.draw_cells(state, cells2) - end) - - # Should contain cursor positioning for removed cell and a space - # The cleared position should have cursor move to {1, 2} - assert second_output =~ "\e[1;2H" - # Space to clear - assert second_output =~ " " - end) - end - - test "multiple changed cells render efficiently in batches" do - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :incremental, size: {24, 80}) - - # First frame - cells1 = [ - {{1, 1}, {"A", :default, :default, []}}, - {{1, 2}, {"B", :default, :default, []}}, - {{1, 3}, {"C", :default, :default, []}} - ] - - {:ok, state} = TTY.draw_cells(state, cells1) - - # Second frame - change all cells - second_output = - capture_io(fn -> - cells2 = [ - {{1, 1}, {"X", :default, :default, []}}, - {{1, 2}, {"Y", :default, :default, []}}, - {{1, 3}, {"Z", :default, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells2) - end) - - # All changed cells should be present - assert second_output =~ "X" - assert second_output =~ "Y" - assert second_output =~ "Z" - # No clear screen - refute second_output =~ "\e[2J" - end) - end - - test "style changes trigger cell update" do - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :incremental, size: {24, 80}) - - # First frame - plain text - cells1 = [{{1, 1}, {"A", :default, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells1) - - # Second frame - same text but with bold - second_output = - capture_io(fn -> - cells2 = [{{1, 1}, {"A", :default, :default, [:bold]}}] - {:ok, _state} = TTY.draw_cells(state, cells2) - end) - - # Should contain the cell (style changed) - assert second_output =~ "A" - # Should contain bold SGR - assert second_output =~ "\e[1m" - end) - end - - # ------------------------------------------------------------------------- - # 3.8.2.3 - Test resize triggers full redraw - # ------------------------------------------------------------------------- - - test "set_size/2 clears last_frame" do - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :incremental, size: {24, 80}) - - # First frame populates last_frame - cells = [{{1, 1}, {"A", :default, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells) - assert is_map(state.last_frame) - - # set_size should clear last_frame - {:ok, state} = TTY.set_size(state, {30, 100}) - assert is_nil(state.last_frame) - end) - end - - test "refresh_size/1 clears last_frame" do - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :incremental, size: {24, 80}) - - # First frame populates last_frame - cells = [{{1, 1}, {"A", :default, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells) - assert is_map(state.last_frame) - - # refresh_size should clear last_frame - {:ok, _size, state} = TTY.refresh_size(state) - assert is_nil(state.last_frame) - end) - end - - test "draw_cells after set_size triggers full redraw" do - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :incremental, size: {24, 80}) - - # First frame - cells1 = [{{1, 1}, {"A", :default, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells1) - - # set_size - {:ok, state} = TTY.set_size(state, {30, 100}) - - # Draw after set_size should trigger full redraw - resize_output = - capture_io(fn -> - cells2 = [{{1, 1}, {"B", :default, :default, []}}] - {:ok, _state} = TTY.draw_cells(state, cells2) - end) - - # Should contain clear screen (full redraw) - assert resize_output =~ "\e[2J" - end) - end - - test "clear/1 also clears last_frame for incremental mode" do - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :incremental, size: {24, 80}) - - # First frame populates last_frame - cells = [{{1, 1}, {"A", :default, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells) - assert is_map(state.last_frame) - - # Clear should reset last_frame - {:ok, state} = TTY.clear(state) - assert is_nil(state.last_frame) - end) - end - - test "full resize cycle: populate -> set_size -> redraw" do - output = - capture_io(fn -> - {:ok, state} = TTY.init(line_mode: :incremental, size: {24, 80}) - - # First frame - cells1 = [{{1, 1}, {"A", :default, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells1) - - # Second frame (incremental - no clear) - cells2 = [{{1, 1}, {"B", :default, :default, []}}] - {:ok, state} = TTY.draw_cells(state, cells2) - - # set_size - {:ok, state} = TTY.set_size(state, {30, 100}) - - # Third frame (should be full redraw after set_size) - cells3 = [{{1, 1}, {"C", :default, :default, []}}] - {:ok, _state} = TTY.draw_cells(state, cells3) - end) - - # Count clear screen sequences: init + first frame + after set_size = 3 - clear_count = length(String.split(output, "\e[2J")) - 1 - # Init produces clear, first frame in incremental produces clear, - # third frame after set_size produces clear - assert clear_count == 3 - end - end - - # =========================================================================== - # Section 3.8.3 Integration Tests - Color Degradation - # =========================================================================== - - @tag :integration - describe "integration - color degradation (Section 3.8.3)" do - # ------------------------------------------------------------------------- - # 3.8.3.1 - Test rendering with true_color capabilities - # ------------------------------------------------------------------------- - - test "RGB colors render with full 24-bit sequences in true_color mode" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{colors: :true_color} - ) - - # Cell with RGB foreground color - cells = [{{1, 1}, {"X", {255, 128, 64}, :default, []}}] - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Should contain true color sequence: \e[38;2;r;g;bm - assert output =~ "\e[38;2;255;128;64m" - end - - test "multiple RGB colors in same frame render correctly in true_color mode" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{colors: :true_color} - ) - - # Multiple cells with different RGB colors - cells = [ - {{1, 1}, {"R", {255, 0, 0}, :default, []}}, - {{1, 2}, {"G", {0, 255, 0}, :default, []}}, - {{1, 3}, {"B", {0, 0, 255}, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # All three RGB sequences should be present - assert output =~ "\e[38;2;255;0;0m" - assert output =~ "\e[38;2;0;255;0m" - assert output =~ "\e[38;2;0;0;255m" - end - - test "RGB foreground and background combinations in true_color mode" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{colors: :true_color} - ) - - # Cell with RGB foreground and background - cells = [{{1, 1}, {"X", {100, 150, 200}, {50, 75, 100}, []}}] - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Should contain both foreground and background true color sequences - # foreground - assert output =~ "\e[38;2;100;150;200m" - # background - assert output =~ "\e[48;2;50;75;100m" - end - - # ------------------------------------------------------------------------- - # 3.8.3.2 - Test rendering with color_256 capabilities - # ------------------------------------------------------------------------- - - test "RGB colors are mapped to 256-color palette in color_256 mode" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{colors: :color_256} - ) - - # Bright red should map to a palette index - cells = [{{1, 1}, {"X", {255, 0, 0}, :default, []}}] - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Should contain 256-color sequence: \e[38;5;nm (not true color) - assert output =~ ~r/\e\[38;5;\d+m/ - # Should NOT contain true color sequence - refute output =~ ~r/\e\[38;2;\d+;\d+;\d+m/ - end - - test "color cube mapping (16-231) in color_256 mode" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{colors: :color_256} - ) - - # Non-gray color maps to 6x6x6 color cube (indices 16-231) - # RGB(255, 0, 0) -> red in color cube - cells = [{{1, 1}, {"X", {255, 0, 0}, :default, []}}] - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Should map to color cube (16 + 36*5 + 6*0 + 0 = 196 for pure red) - assert output =~ "\e[38;5;196m" - end - - test "grayscale mapping (232-255) in color_256 mode" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{colors: :color_256} - ) - - # Gray color (128, 128, 128) should map to grayscale ramp - cells = [{{1, 1}, {"X", {128, 128, 128}, :default, []}}] - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Should map to grayscale ramp (232 + div(128*23, 255) = 232 + 11 = 243) - assert output =~ "\e[38;5;243m" - end - - test "palette indices pass through directly in color_256 mode" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{colors: :color_256} - ) - - # Use direct palette index 42 - cells = [{{1, 1}, {"X", 42, :default, []}}] - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Palette index should pass through unchanged - assert output =~ "\e[38;5;42m" - end - - # ------------------------------------------------------------------------- - # 3.8.3.3 - Test rendering with color_16 capabilities - # ------------------------------------------------------------------------- - - test "RGB colors are mapped to nearest basic color in color_16 mode" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{colors: :color_16} - ) - - # Bright red (255, 0, 0) should map to basic red - cells = [{{1, 1}, {"X", {255, 0, 0}, :default, []}}] - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Should contain basic color code (30-37 or 90-97) - assert output =~ ~r/\e\[(3[0-7]|9[0-7])m/ - # Should NOT contain 256-color or true color sequences - refute output =~ ~r/\e\[38;5;\d+m/ - refute output =~ ~r/\e\[38;2;\d+;\d+;\d+m/ - end - - test "bright vs normal color selection in color_16 mode" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{colors: :color_16} - ) - - # High intensity color should map to bright variant (90-97) - # Low intensity color should map to normal variant (30-37) - cells = [ - # Bright white - {{1, 1}, {"B", {255, 255, 255}, :default, []}}, - # Dark gray -> black range - {{1, 2}, {"D", {64, 64, 64}, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Bright white should map to 97 (bright white) - assert output =~ "\e[97m" - # Dark gray should map to dim range (30-37 range) - assert output =~ ~r/\e\[3[0-7]m/ - end - - test "named colors work directly in color_16 mode" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{colors: :color_16} - ) - - # Named colors should pass through - cells = [ - {{1, 1}, {"R", :red, :default, []}}, - {{1, 2}, {"G", :green, :default, []}}, - {{1, 3}, {"B", :bright_blue, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Named colors should produce their standard codes - # red - assert output =~ "\e[31m" - # green - assert output =~ "\e[32m" - # bright_blue - assert output =~ "\e[94m" - end - - # ------------------------------------------------------------------------- - # 3.8.3.4 - Test rendering with monochrome capabilities - # ------------------------------------------------------------------------- - - test "color sequences are omitted in monochrome mode" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{colors: :monochrome} - ) - - # RGB colors should be omitted entirely - cells = [{{1, 1}, {"X", {255, 128, 64}, {0, 128, 255}, []}}] - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Should NOT contain any color sequences - # No true color - refute output =~ ~r/\e\[38;2;\d+;\d+;\d+m/ - # No true color bg - refute output =~ ~r/\e\[48;2;\d+;\d+;\d+m/ - # No 256-color - refute output =~ ~r/\e\[38;5;\d+m/ - # No 256-color bg - refute output =~ ~r/\e\[48;5;\d+m/ - # Named colors are also omitted (but 39m/49m for :default are allowed) - # No named fg colors - refute output =~ ~r/\e\[3[1-7]m/ - # No named bg colors - refute output =~ ~r/\e\[4[1-7]m/ - end - - test "text attributes are preserved in monochrome mode" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{colors: :monochrome} - ) - - # Cell with color (should be ignored) and attributes (should be preserved) - cells = [{{1, 1}, {"X", {255, 0, 0}, :default, [:bold, :underline]}}] - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Attributes should be present - # bold - assert output =~ "\e[1m" - # underline - assert output =~ "\e[4m" - # Color should NOT be present - refute output =~ ~r/\e\[38;2;\d+;\d+;\d+m/ - end - - test "content still renders correctly in monochrome mode" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{colors: :monochrome} - ) - - # Multiple cells with colors should render content without color codes - cells = [ - {{1, 1}, {"H", {255, 0, 0}, :default, []}}, - {{1, 2}, {"e", {0, 255, 0}, :default, []}}, - {{1, 3}, {"l", {0, 0, 255}, :default, []}}, - {{1, 4}, {"l", :cyan, :default, []}}, - {{1, 5}, {"o", 42, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Each character should be present (separated by SGR sequences in output) - assert output =~ "H" - assert output =~ "e" - assert output =~ "l" - assert output =~ "o" - end - - test "named colors are omitted in monochrome mode" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{colors: :monochrome} - ) - - cells = [{{1, 1}, {"X", :red, :blue, []}}] - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Named colors should be omitted - # no red - refute output =~ "\e[31m" - # no blue background - refute output =~ "\e[44m" - end - - test "palette indices are omitted in monochrome mode" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{colors: :monochrome} - ) - - cells = [{{1, 1}, {"X", 42, 100, []}}] - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Palette indices should be omitted - refute output =~ "\e[38;5;42m" - refute output =~ "\e[48;5;100m" - end - end - - # =========================================================================== - # Section 3.8.4 Integration Tests - Character Set Fallback - # =========================================================================== - - @tag :integration - describe "integration - character set fallback (Section 3.8.4)" do - # Get Unicode character set for reference in tests - # (we test that Unicode chars are mapped to ASCII, so we only need the Unicode set) - @unicode_chars TermUI.CharacterSet.get(:unicode) - - # ------------------------------------------------------------------------- - # 3.8.4.1 - Test Unicode box-drawing renders correctly - # ------------------------------------------------------------------------- - - test "Unicode box corners render correctly in unicode mode" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{unicode: true} - ) - - # Render all four corners - cells = [ - {{1, 1}, {@unicode_chars.tl, :default, :default, []}}, - {{1, 2}, {@unicode_chars.tr, :default, :default, []}}, - {{2, 1}, {@unicode_chars.bl, :default, :default, []}}, - {{2, 2}, {@unicode_chars.br, :default, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # All Unicode corners should be present - assert output =~ "┌" - assert output =~ "┐" - assert output =~ "└" - assert output =~ "┘" - end - - test "Unicode horizontal and vertical lines render correctly" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{unicode: true} - ) - - cells = [ - {{1, 1}, {@unicode_chars.h_line, :default, :default, []}}, - {{1, 2}, {@unicode_chars.h_line, :default, :default, []}}, - {{2, 1}, {@unicode_chars.v_line, :default, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - assert output =~ "─" - assert output =~ "│" - end - - test "Unicode T-junctions and cross render correctly" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{unicode: true} - ) - - cells = [ - {{1, 1}, {@unicode_chars.t_up, :default, :default, []}}, - {{1, 2}, {@unicode_chars.t_down, :default, :default, []}}, - {{1, 3}, {@unicode_chars.t_left, :default, :default, []}}, - {{1, 4}, {@unicode_chars.t_right, :default, :default, []}}, - {{1, 5}, {@unicode_chars.cross, :default, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - assert output =~ "┴" - assert output =~ "┬" - assert output =~ "┤" - assert output =~ "├" - assert output =~ "┼" - end - - test "Unicode progress bar characters render correctly" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{unicode: true} - ) - - cells = [ - {{1, 1}, {@unicode_chars.bar_full, :default, :default, []}}, - {{1, 2}, {@unicode_chars.bar_empty, :default, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - assert output =~ "█" - assert output =~ "░" - end - - test "Unicode check marks and arrows render correctly" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{unicode: true} - ) - - cells = [ - {{1, 1}, {@unicode_chars.check, :default, :default, []}}, - {{1, 2}, {@unicode_chars.cross_mark, :default, :default, []}}, - {{1, 3}, {@unicode_chars.arrow_up, :default, :default, []}}, - {{1, 4}, {@unicode_chars.arrow_down, :default, :default, []}}, - {{1, 5}, {@unicode_chars.arrow_left, :default, :default, []}}, - {{1, 6}, {@unicode_chars.arrow_right, :default, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - assert output =~ "✓" - assert output =~ "✗" - assert output =~ "↑" - assert output =~ "↓" - assert output =~ "←" - assert output =~ "→" - end - - # ------------------------------------------------------------------------- - # 3.8.4.2 - Test ASCII fallback renders correctly - # ------------------------------------------------------------------------- - - test "ASCII fallback maps box corners to +" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{unicode: false} - ) - - # Unicode corners should be mapped to + - cells = [ - {{1, 1}, {@unicode_chars.tl, :default, :default, []}}, - {{1, 2}, {@unicode_chars.tr, :default, :default, []}}, - {{2, 1}, {@unicode_chars.bl, :default, :default, []}}, - {{2, 2}, {@unicode_chars.br, :default, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Unicode corners should NOT appear - refute output =~ "┌" - refute output =~ "┐" - refute output =~ "└" - refute output =~ "┘" - - # ASCII + should appear instead (multiple times for corners) - # Count + characters (excluding those in escape sequences) - plus_count = output |> String.graphemes() |> Enum.count(&(&1 == "+")) - assert plus_count >= 4 - end - - test "ASCII fallback maps horizontal line to - and vertical to |" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{unicode: false} - ) - - cells = [ - {{1, 1}, {@unicode_chars.h_line, :default, :default, []}}, - {{1, 2}, {@unicode_chars.h_line, :default, :default, []}}, - {{2, 1}, {@unicode_chars.v_line, :default, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Unicode should NOT appear - refute output =~ "─" - refute output =~ "│" - - # ASCII equivalents should appear - assert output =~ "-" - assert output =~ "|" - end - - test "ASCII fallback maps T-junctions and cross to +" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{unicode: false} - ) - - cells = [ - {{1, 1}, {@unicode_chars.t_up, :default, :default, []}}, - {{1, 2}, {@unicode_chars.t_down, :default, :default, []}}, - {{1, 3}, {@unicode_chars.t_left, :default, :default, []}}, - {{1, 4}, {@unicode_chars.t_right, :default, :default, []}}, - {{1, 5}, {@unicode_chars.cross, :default, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Unicode should NOT appear - refute output =~ "┴" - refute output =~ "┬" - refute output =~ "┤" - refute output =~ "├" - refute output =~ "┼" - - # ASCII + should appear (5 junctions) - plus_count = output |> String.graphemes() |> Enum.count(&(&1 == "+")) - assert plus_count >= 5 - end - - test "ASCII fallback maps progress bar characters" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{unicode: false} - ) - - cells = [ - {{1, 1}, {@unicode_chars.bar_full, :default, :default, []}}, - {{1, 2}, {@unicode_chars.bar_empty, :default, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Unicode should NOT appear - refute output =~ "█" - refute output =~ "░" - - # ASCII equivalents - assert output =~ "#" - assert output =~ "." - end - - test "ASCII fallback maps check marks and arrows" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{unicode: false} - ) - - cells = [ - {{1, 1}, {@unicode_chars.check, :default, :default, []}}, - {{1, 2}, {@unicode_chars.cross_mark, :default, :default, []}}, - {{1, 3}, {@unicode_chars.arrow_up, :default, :default, []}}, - {{1, 4}, {@unicode_chars.arrow_down, :default, :default, []}}, - {{1, 5}, {@unicode_chars.arrow_left, :default, :default, []}}, - {{1, 6}, {@unicode_chars.arrow_right, :default, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Unicode should NOT appear - refute output =~ "✓" - refute output =~ "✗" - refute output =~ "↑" - refute output =~ "↓" - refute output =~ "←" - refute output =~ "→" - - # ASCII equivalents (check is x, cross_mark is X) - assert output =~ "x" - assert output =~ "X" - assert output =~ "^" - assert output =~ "v" - assert output =~ "<" - assert output =~ ">" - end - - # ------------------------------------------------------------------------- - # 3.8.4.3 - Test mixed content (Unicode text with ASCII boxes) - # ------------------------------------------------------------------------- - - test "regular ASCII text passes through unchanged in both modes" do - for unicode_mode <- [true, false] do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{unicode: unicode_mode} - ) - - cells = [ - {{1, 1}, {"H", :default, :default, []}}, - {{1, 2}, {"e", :default, :default, []}}, - {{1, 3}, {"l", :default, :default, []}}, - {{1, 4}, {"l", :default, :default, []}}, - {{1, 5}, {"o", :default, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - assert output =~ "H" - assert output =~ "e" - assert output =~ "l" - assert output =~ "o" - end - end - - test "Unicode text passes through unchanged in unicode mode" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{unicode: true} - ) - - # Unicode text that is NOT box-drawing (should pass through) - cells = [ - {{1, 1}, {"日", :default, :default, []}}, - {{1, 2}, {"本", :default, :default, []}}, - {{1, 3}, {"語", :default, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - assert output =~ "日" - assert output =~ "本" - assert output =~ "語" - end - - test "non-box-drawing Unicode passes through unchanged in ascii mode" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{unicode: false} - ) - - # Unicode text that is NOT in our box-drawing map should pass through - # (the terminal may or may not display it, but we don't modify it) - cells = [ - {{1, 1}, {"日", :default, :default, []}}, - {{1, 2}, {"本", :default, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Non-box-drawing Unicode should pass through even in ASCII mode - # (we only map the specific box-drawing characters) - assert output =~ "日" - assert output =~ "本" - end - - test "mixed content: text with box-drawing on same row in unicode mode" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{unicode: true} - ) - - # Mixed: box corner, text, box corner - cells = [ - {{1, 1}, {@unicode_chars.tl, :default, :default, []}}, - {{1, 2}, {"T", :default, :default, []}}, - {{1, 3}, {"e", :default, :default, []}}, - {{1, 4}, {"s", :default, :default, []}}, - {{1, 5}, {"t", :default, :default, []}}, - {{1, 6}, {@unicode_chars.tr, :default, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Both Unicode box-drawing and text should appear - assert output =~ "┌" - assert output =~ "Test" - assert output =~ "┐" - end - - test "mixed content: text with box-drawing on same row in ascii mode" do - output = - capture_io(fn -> - {:ok, state} = - TTY.init( - line_mode: :full_redraw, - size: {24, 80}, - capabilities: %{unicode: false} - ) - - # Mixed: box corner, text, box corner (should map corners to +) - cells = [ - {{1, 1}, {@unicode_chars.tl, :default, :default, []}}, - {{1, 2}, {"T", :default, :default, []}}, - {{1, 3}, {"e", :default, :default, []}}, - {{1, 4}, {"s", :default, :default, []}}, - {{1, 5}, {"t", :default, :default, []}}, - {{1, 6}, {@unicode_chars.tr, :default, :default, []}} - ] - - {:ok, _state} = TTY.draw_cells(state, cells) - end) - - # Box-drawing should be mapped to ASCII - refute output =~ "┌" - refute output =~ "┐" - - # Text should be unchanged - assert output =~ "Test" - - # ASCII + for corners - plus_count = output |> String.graphemes() |> Enum.count(&(&1 == "+")) - assert plus_count >= 2 - end - - test "character_set state is set correctly based on capabilities" do - capture_io(fn -> - {:ok, unicode_state} = TTY.init(capabilities: %{unicode: true}) - assert unicode_state.character_set == :unicode - - {:ok, ascii_state} = TTY.init(capabilities: %{unicode: false}) - assert ascii_state.character_set == :ascii - - # Default should be unicode - {:ok, default_state} = TTY.init(capabilities: %{}) - assert default_state.character_set == :unicode - end) - end - end -end diff --git a/test/term_ui/backend_test.exs b/test/term_ui/backend_test.exs deleted file mode 100644 index 382e3532..00000000 --- a/test/term_ui/backend_test.exs +++ /dev/null @@ -1,242 +0,0 @@ -defmodule TermUI.BackendTest do - use ExUnit.Case, async: true - - alias TermUI.Backend - - describe "module structure" do - test "module compiles successfully" do - assert Code.ensure_loaded?(Backend) - end - - test "module defines a behaviour" do - assert function_exported?(Backend, :behaviour_info, 1) - end - - test "behaviour_info(:callbacks) returns expected callbacks" do - callbacks = Backend.behaviour_info(:callbacks) - - # Lifecycle callbacks - assert {:init, 1} in callbacks - assert {:shutdown, 1} in callbacks - - # Query callbacks - assert {:size, 1} in callbacks - - # Cursor callbacks - assert {:move_cursor, 2} in callbacks - assert {:hide_cursor, 1} in callbacks - assert {:show_cursor, 1} in callbacks - - # Rendering callbacks - assert {:clear, 1} in callbacks - assert {:draw_cells, 2} in callbacks - assert {:flush, 1} in callbacks - - # Input callbacks - assert {:poll_event, 2} in callbacks - end - - test "behaviour_info(:callbacks) returns exactly 10 callbacks" do - callbacks = Backend.behaviour_info(:callbacks) - assert length(callbacks) == 10 - end - end - - describe "documentation" do - test "module has moduledoc" do - {:docs_v1, _, :elixir, _, module_doc, _, _} = Code.fetch_docs(Backend) - assert module_doc != :none - assert module_doc != :hidden - - %{"en" => doc} = module_doc - assert String.contains?(doc, "Behaviour") - assert String.contains?(doc, "terminal backend") - end - - test "all callbacks have documentation" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(Backend) - - callback_docs = - docs - |> Enum.filter(fn - {{:callback, _, _}, _, _, _, _} -> true - _ -> false - end) - - # Check we have docs for all callbacks - assert length(callback_docs) == 10 - - # Check none have :none or :hidden documentation - for {{:callback, name, arity}, _, _, doc, _} <- callback_docs do - assert doc != :none, - "Callback #{name}/#{arity} has no documentation" - - assert doc != :hidden, - "Callback #{name}/#{arity} has hidden documentation" - end - end - - test "all types have documentation" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(Backend) - - type_docs = - docs - |> Enum.filter(fn - {{:type, _, _}, _, _, _, _} -> true - _ -> false - end) - - # We define 6 types: position, size, color, cell, event, state - assert length(type_docs) == 6 - - # Check each type has a typedoc - for {{:type, name, arity}, _, _, doc, _} <- type_docs do - assert doc != :none, - "Type #{name}/#{arity} has no documentation" - - assert doc != :hidden, - "Type #{name}/#{arity} has hidden documentation" - end - end - end - - describe "type definitions" do - # These tests verify types are defined by checking that the module - # compiles without errors and that Dialyzer would accept the types. - # Actual type checking is done at compile time. - - test "position type is defined" do - # Type exists if module compiles - verified by first test - # This documents the expected type structure - assert true - end - - test "size type is defined" do - assert true - end - - test "color type supports :default atom" do - # Type validation happens at compile time via Dialyzer - assert true - end - - test "color type supports named color atoms" do - assert true - end - - test "color type supports 0..255 integer" do - assert true - end - - test "color type supports RGB tuple" do - assert true - end - - test "cell type is defined as 4-tuple" do - assert true - end - - test "event type aliases TermUI.Event.t()" do - # Verify Event module exists - assert Code.ensure_loaded?(TermUI.Event) - end - - test "state type is defined" do - assert true - end - end - - describe "example implementation" do - # Define a minimal test backend to verify the behaviour works - defmodule TestBackend do - @behaviour TermUI.Backend - - @impl true - def init(_opts), do: {:ok, %{}} - - @impl true - def shutdown(_state), do: :ok - - @impl true - def size(_state), do: {:ok, {24, 80}} - - @impl true - def move_cursor(state, _position), do: {:ok, state} - - @impl true - def hide_cursor(state), do: {:ok, state} - - @impl true - def show_cursor(state), do: {:ok, state} - - @impl true - def clear(state), do: {:ok, state} - - @impl true - def draw_cells(state, _cells), do: {:ok, state} - - @impl true - def flush(state), do: {:ok, state} - - @impl true - def poll_event(state, _timeout), do: {:timeout, state} - end - - test "test backend compiles without warnings" do - assert Code.ensure_loaded?(TestBackend) - end - - test "test backend implements all callbacks" do - # If it compiles with @behaviour and @impl true, all callbacks are implemented - assert function_exported?(TestBackend, :init, 1) - assert function_exported?(TestBackend, :shutdown, 1) - assert function_exported?(TestBackend, :size, 1) - assert function_exported?(TestBackend, :move_cursor, 2) - assert function_exported?(TestBackend, :hide_cursor, 1) - assert function_exported?(TestBackend, :show_cursor, 1) - assert function_exported?(TestBackend, :clear, 1) - assert function_exported?(TestBackend, :draw_cells, 2) - assert function_exported?(TestBackend, :flush, 1) - assert function_exported?(TestBackend, :poll_event, 2) - end - - test "init/1 returns {:ok, state}" do - assert {:ok, _state} = TestBackend.init([]) - end - - test "shutdown/1 returns :ok" do - {:ok, state} = TestBackend.init([]) - assert :ok = TestBackend.shutdown(state) - end - - test "size/1 returns {:ok, {rows, cols}}" do - {:ok, state} = TestBackend.init([]) - assert {:ok, {rows, cols}} = TestBackend.size(state) - assert is_integer(rows) and rows > 0 - assert is_integer(cols) and cols > 0 - end - - test "cursor operations return {:ok, state}" do - {:ok, state} = TestBackend.init([]) - - assert {:ok, state} = TestBackend.move_cursor(state, {1, 1}) - assert {:ok, state} = TestBackend.hide_cursor(state) - assert {:ok, _state} = TestBackend.show_cursor(state) - end - - test "rendering operations return {:ok, state}" do - {:ok, state} = TestBackend.init([]) - - assert {:ok, state} = TestBackend.clear(state) - assert {:ok, state} = TestBackend.draw_cells(state, []) - assert {:ok, _state} = TestBackend.flush(state) - end - - test "poll_event/2 returns valid result" do - {:ok, state} = TestBackend.init([]) - - # Our test backend returns :timeout - assert {:timeout, _state} = TestBackend.poll_event(state, 0) - end - end -end diff --git a/test/term_ui/capabilities/fallbacks_test.exs b/test/term_ui/capabilities/fallbacks_test.exs deleted file mode 100644 index 7832d67c..00000000 --- a/test/term_ui/capabilities/fallbacks_test.exs +++ /dev/null @@ -1,254 +0,0 @@ -defmodule TermUI.Capabilities.FallbacksTest do - use ExUnit.Case, async: true - - alias TermUI.Capabilities.Fallbacks - - describe "rgb_to_256/3" do - test "converts black correctly" do - assert Fallbacks.rgb_to_256(0, 0, 0) in 232..255 - end - - test "converts white correctly" do - assert Fallbacks.rgb_to_256(255, 255, 255) in 232..255 - end - - test "converts pure red to color cube" do - index = Fallbacks.rgb_to_256(255, 0, 0) - assert index == 196 - end - - test "converts pure green to color cube" do - index = Fallbacks.rgb_to_256(0, 255, 0) - assert index == 46 - end - - test "converts pure blue to color cube" do - index = Fallbacks.rgb_to_256(0, 0, 255) - assert index == 21 - end - - test "converts gray values to grayscale ramp" do - # Middle gray - index = Fallbacks.rgb_to_256(128, 128, 128) - assert index in 232..255 - end - - test "returns values in valid range" do - for r <- [0, 64, 128, 192, 255], - g <- [0, 64, 128, 192, 255], - b <- [0, 64, 128, 192, 255] do - index = Fallbacks.rgb_to_256(r, g, b) - assert index >= 0 and index <= 255, "RGB(#{r},#{g},#{b}) -> #{index}" - end - end - end - - describe "rgb_to_16/3" do - test "converts black to color 0" do - assert Fallbacks.rgb_to_16(0, 0, 0) == 0 - end - - test "converts white to color 15" do - assert Fallbacks.rgb_to_16(255, 255, 255) == 15 - end - - test "converts pure red to red (1 or 9)" do - index = Fallbacks.rgb_to_16(255, 0, 0) - assert index in [1, 9] - end - - test "converts pure green to green (2 or 10)" do - index = Fallbacks.rgb_to_16(0, 255, 0) - assert index in [2, 10] - end - - test "converts pure blue to blue (4 or 12)" do - index = Fallbacks.rgb_to_16(0, 0, 255) - assert index in [4, 12] - end - - test "converts yellow to yellow (3 or 11)" do - index = Fallbacks.rgb_to_16(255, 255, 0) - assert index in [3, 11] - end - - test "converts magenta to magenta (5 or 13)" do - index = Fallbacks.rgb_to_16(255, 0, 255) - assert index in [5, 13] - end - - test "converts cyan to cyan (6 or 14)" do - index = Fallbacks.rgb_to_16(0, 255, 255) - assert index in [6, 14] - end - - test "returns values in valid range" do - for r <- [0, 128, 255], - g <- [0, 128, 255], - b <- [0, 128, 255] do - index = Fallbacks.rgb_to_16(r, g, b) - assert index >= 0 and index <= 15, "RGB(#{r},#{g},#{b}) -> #{index}" - end - end - end - - describe "color_256_to_16/1" do - test "passes through ANSI colors 0-15" do - for i <- 0..15 do - assert Fallbacks.color_256_to_16(i) == i - end - end - - test "converts color cube indices" do - # Red (index 196 in 256 palette = pure red) - assert Fallbacks.color_256_to_16(196) in [1, 9] - - # Green (index 46 in 256 palette = pure green) - assert Fallbacks.color_256_to_16(46) in [2, 10] - - # Blue (index 21 in 256 palette = pure blue) - assert Fallbacks.color_256_to_16(21) in [4, 12] - end - - test "converts grayscale indices" do - # Black (232) - assert Fallbacks.color_256_to_16(232) in [0, 8] - - # White (255) - assert Fallbacks.color_256_to_16(255) in [7, 15] - end - - test "returns values in valid range" do - for i <- 0..255 do - index = Fallbacks.color_256_to_16(i) - assert index >= 0 and index <= 15, "256 color #{i} -> #{index}" - end - end - end - - describe "unicode_to_ascii/1" do - test "converts horizontal line" do - assert Fallbacks.unicode_to_ascii("─") == "-" - end - - test "converts vertical line" do - assert Fallbacks.unicode_to_ascii("│") == "|" - end - - test "converts corners" do - assert Fallbacks.unicode_to_ascii("┌") == "+" - assert Fallbacks.unicode_to_ascii("┐") == "+" - assert Fallbacks.unicode_to_ascii("└") == "+" - assert Fallbacks.unicode_to_ascii("┘") == "+" - end - - test "converts T-junctions" do - assert Fallbacks.unicode_to_ascii("├") == "+" - assert Fallbacks.unicode_to_ascii("┤") == "+" - assert Fallbacks.unicode_to_ascii("┬") == "+" - assert Fallbacks.unicode_to_ascii("┴") == "+" - end - - test "converts cross" do - assert Fallbacks.unicode_to_ascii("┼") == "+" - end - - test "converts double-line box drawing" do - assert Fallbacks.unicode_to_ascii("═") == "=" - assert Fallbacks.unicode_to_ascii("║") == "|" - assert Fallbacks.unicode_to_ascii("╔") == "+" - assert Fallbacks.unicode_to_ascii("╝") == "+" - end - - test "converts rounded corners" do - assert Fallbacks.unicode_to_ascii("╭") == "+" - assert Fallbacks.unicode_to_ascii("╮") == "+" - assert Fallbacks.unicode_to_ascii("╯") == "+" - assert Fallbacks.unicode_to_ascii("╰") == "+" - end - - test "converts block elements" do - assert Fallbacks.unicode_to_ascii("█") == "#" - assert Fallbacks.unicode_to_ascii("░") == "." - assert Fallbacks.unicode_to_ascii("▒") == ":" - assert Fallbacks.unicode_to_ascii("▓") == "#" - end - - test "converts arrows" do - assert Fallbacks.unicode_to_ascii("←") == "<" - assert Fallbacks.unicode_to_ascii("→") == ">" - assert Fallbacks.unicode_to_ascii("↑") == "^" - assert Fallbacks.unicode_to_ascii("↓") == "v" - end - - test "converts checkmarks" do - assert Fallbacks.unicode_to_ascii("✓") == "[x]" - assert Fallbacks.unicode_to_ascii("✗") == "[ ]" - end - - test "passes through ASCII characters" do - assert Fallbacks.unicode_to_ascii("a") == "a" - assert Fallbacks.unicode_to_ascii("Z") == "Z" - assert Fallbacks.unicode_to_ascii("5") == "5" - assert Fallbacks.unicode_to_ascii("+") == "+" - end - - test "passes through unknown Unicode" do - assert Fallbacks.unicode_to_ascii("α") == "α" - assert Fallbacks.unicode_to_ascii("π") == "π" - end - end - - describe "string_to_ascii/1" do - test "converts string with box drawing" do - input = "┌──────┐" - expected = "+------+" - assert Fallbacks.string_to_ascii(input) == expected - end - - test "converts complex box" do - input = "│ text │" - expected = "| text |" - assert Fallbacks.string_to_ascii(input) == expected - end - - test "converts mixed content" do - input = "Status: ✓ Done" - expected = "Status: [x] Done" - assert Fallbacks.string_to_ascii(input) == expected - end - - test "passes through pure ASCII" do - input = "Hello, World!" - assert Fallbacks.string_to_ascii(input) == input - end - - test "handles empty string" do - assert Fallbacks.string_to_ascii("") == "" - end - end - - describe "degrade_color/4" do - test "returns RGB for true-color mode" do - result = Fallbacks.degrade_color(128, 64, 32, :true_color) - assert result == {:rgb, 128, 64, 32} - end - - test "returns 256-color index for 256-color mode" do - result = Fallbacks.degrade_color(255, 0, 0, :color_256) - assert {:index_256, index} = result - assert index in 0..255 - end - - test "returns 16-color index for 16-color mode" do - result = Fallbacks.degrade_color(255, 0, 0, :color_16) - assert {:index_16, index} = result - assert index in 0..15 - end - - test "returns :none for monochrome mode" do - result = Fallbacks.degrade_color(255, 0, 0, :monochrome) - assert result == :none - end - end -end diff --git a/test/term_ui/capabilities_test.exs b/test/term_ui/capabilities_test.exs deleted file mode 100644 index 5995eb2e..00000000 --- a/test/term_ui/capabilities_test.exs +++ /dev/null @@ -1,305 +0,0 @@ -defmodule TermUI.CapabilitiesTest do - use ExUnit.Case, async: false - - alias TermUI.Capabilities - - setup do - # Clear cache and save original environment - Capabilities.clear_cache() - - original_env = %{ - "TERM" => System.get_env("TERM"), - "COLORTERM" => System.get_env("COLORTERM"), - "TERM_PROGRAM" => System.get_env("TERM_PROGRAM"), - "LANG" => System.get_env("LANG"), - "LC_ALL" => System.get_env("LC_ALL"), - "LC_CTYPE" => System.get_env("LC_CTYPE") - } - - on_exit(fn -> - # Restore original environment - Enum.each(original_env, fn {key, value} -> - if value do - System.put_env(key, value) - else - System.delete_env(key) - end - end) - - Capabilities.clear_cache() - end) - - :ok - end - - describe "detect/0 and get/0" do - test "returns capabilities struct" do - caps = Capabilities.detect() - assert %Capabilities{} = caps - end - - test "caches capabilities" do - caps1 = Capabilities.detect() - caps2 = Capabilities.get() - assert caps1 == caps2 - end - - test "get/0 detects if not cached" do - Capabilities.clear_cache() - caps = Capabilities.get() - assert %Capabilities{} = caps - end - end - - describe "environment variable detection - $TERM" do - test "detects truecolor from $TERM" do - System.put_env("TERM", "xterm-truecolor") - System.delete_env("COLORTERM") - System.delete_env("TERM_PROGRAM") - - caps = Capabilities.detect() - - assert caps.color_mode == :true_color - assert caps.max_colors == 16_777_216 - assert caps.terminal_type == "xterm-truecolor" - end - - test "detects 256color from $TERM suffix" do - System.put_env("TERM", "xterm-256color") - System.delete_env("COLORTERM") - System.delete_env("TERM_PROGRAM") - - caps = Capabilities.detect() - - assert caps.color_mode == :color_256 - assert caps.max_colors >= 256 - end - - test "detects xterm as 256-color capable" do - System.put_env("TERM", "xterm") - System.delete_env("COLORTERM") - System.delete_env("TERM_PROGRAM") - - caps = Capabilities.detect() - - assert caps.color_mode == :color_256 - assert caps.max_colors >= 256 - end - - test "detects screen as 256-color capable" do - System.put_env("TERM", "screen") - System.delete_env("COLORTERM") - System.delete_env("TERM_PROGRAM") - - caps = Capabilities.detect() - - assert caps.color_mode == :color_256 - end - - test "detects tmux as 256-color capable" do - System.put_env("TERM", "tmux-256color") - System.delete_env("COLORTERM") - System.delete_env("TERM_PROGRAM") - - caps = Capabilities.detect() - - assert caps.color_mode == :color_256 - end - - test "detects linux console as 16-color" do - System.put_env("TERM", "linux") - System.delete_env("COLORTERM") - System.delete_env("TERM_PROGRAM") - - caps = Capabilities.detect() - - assert caps.color_mode == :color_16 - assert caps.max_colors == 16 - end - - test "detects dumb terminal as monochrome" do - System.put_env("TERM", "dumb") - System.delete_env("COLORTERM") - System.delete_env("TERM_PROGRAM") - - caps = Capabilities.detect() - - assert caps.color_mode == :monochrome - assert caps.max_colors == 2 - end - end - - describe "environment variable detection - $COLORTERM" do - test "detects truecolor from $COLORTERM" do - System.put_env("TERM", "xterm") - System.put_env("COLORTERM", "truecolor") - System.delete_env("TERM_PROGRAM") - - caps = Capabilities.detect() - - assert caps.color_mode == :true_color - assert caps.max_colors == 16_777_216 - end - - test "detects 24bit from $COLORTERM" do - System.put_env("TERM", "xterm") - System.put_env("COLORTERM", "24bit") - System.delete_env("TERM_PROGRAM") - - caps = Capabilities.detect() - - assert caps.color_mode == :true_color - assert caps.max_colors == 16_777_216 - end - end - - describe "environment variable detection - $TERM_PROGRAM" do - test "detects iTerm.app capabilities" do - System.put_env("TERM", "xterm") - System.delete_env("COLORTERM") - System.put_env("TERM_PROGRAM", "iTerm.app") - - caps = Capabilities.detect() - - assert caps.color_mode == :true_color - assert caps.mouse == true - assert caps.bracketed_paste == true - assert caps.focus_events == true - assert caps.terminal_program == "iTerm.app" - end - - test "detects vscode terminal capabilities" do - System.put_env("TERM", "xterm") - System.delete_env("COLORTERM") - System.put_env("TERM_PROGRAM", "vscode") - - caps = Capabilities.detect() - - assert caps.color_mode == :true_color - assert caps.mouse == true - end - - test "detects Alacritty capabilities" do - System.put_env("TERM", "xterm") - System.delete_env("COLORTERM") - System.put_env("TERM_PROGRAM", "Alacritty") - - caps = Capabilities.detect() - - assert caps.color_mode == :true_color - end - - test "detects Apple_Terminal as 256-color" do - System.put_env("TERM", "xterm") - System.delete_env("COLORTERM") - System.put_env("TERM_PROGRAM", "Apple_Terminal") - - caps = Capabilities.detect() - - assert caps.color_mode == :color_256 - end - end - - describe "environment variable detection - $LANG" do - test "detects UTF-8 from $LANG" do - System.put_env("LANG", "en_US.UTF-8") - System.delete_env("LC_ALL") - System.delete_env("LC_CTYPE") - - caps = Capabilities.detect() - - assert caps.unicode == true - end - - test "detects UTF-8 from $LC_ALL" do - System.put_env("LC_ALL", "en_US.UTF-8") - System.delete_env("LANG") - - caps = Capabilities.detect() - - assert caps.unicode == true - end - - test "detects non-UTF-8 locale" do - System.put_env("LANG", "en_US.ISO-8859-1") - System.delete_env("LC_ALL") - System.delete_env("LC_CTYPE") - - caps = Capabilities.detect() - - assert caps.unicode == false - end - end - - describe "capability accessors" do - test "supports_true_color?/0" do - System.put_env("COLORTERM", "truecolor") - Capabilities.detect() - - assert Capabilities.supports_true_color?() == true - end - - test "supports_256_color?/0 returns true for true-color" do - System.put_env("COLORTERM", "truecolor") - Capabilities.detect() - - assert Capabilities.supports_256_color?() == true - end - - test "supports_256_color?/0 returns true for 256-color" do - System.put_env("TERM", "xterm-256color") - System.delete_env("COLORTERM") - System.delete_env("TERM_PROGRAM") - Capabilities.detect() - - assert Capabilities.supports_256_color?() == true - end - - test "supports_mouse?/0" do - System.put_env("TERM_PROGRAM", "iTerm.app") - Capabilities.detect() - - assert Capabilities.supports_mouse?() == true - end - - test "supports_bracketed_paste?/0" do - System.put_env("TERM_PROGRAM", "iTerm.app") - Capabilities.detect() - - assert Capabilities.supports_bracketed_paste?() == true - end - - test "supports_unicode?/0" do - System.put_env("LANG", "en_US.UTF-8") - Capabilities.detect() - - assert Capabilities.supports_unicode?() == true - end - - test "max_colors/0" do - System.put_env("COLORTERM", "truecolor") - Capabilities.detect() - - assert Capabilities.max_colors() == 16_777_216 - end - - test "color_mode/0" do - System.put_env("COLORTERM", "truecolor") - Capabilities.detect() - - assert Capabilities.color_mode() == :true_color - end - end - - describe "clear_cache/0" do - test "clears cached capabilities" do - Capabilities.detect() - Capabilities.clear_cache() - - # Verify cache is empty by checking ETS directly - # get/0 will re-detect - Capabilities.clear_cache() - assert :ok == Capabilities.clear_cache() - end - end -end diff --git a/test/term_ui/clipboard_test.exs b/test/term_ui/clipboard_test.exs index 1da8f8d6..303f3e96 100644 --- a/test/term_ui/clipboard_test.exs +++ b/test/term_ui/clipboard_test.exs @@ -1,439 +1,114 @@ defmodule TermUI.ClipboardTest do use ExUnit.Case, async: true - alias TermUI.Clipboard + alias TermUI.Backend.Manager, as: BackendManager + alias TermUI.{Clipboard, Command, Frame} + alias TermUI.Test.DeterministicBackend - describe "bracketed_paste_on/0" do - test "returns correct escape sequence" do - assert Clipboard.bracketed_paste_on() == "\e[?2004h" - end - end + defmodule ClipboardApp do + use TermUI.Elm - describe "bracketed_paste_off/0" do - test "returns correct escape sequence" do - assert Clipboard.bracketed_paste_off() == "\e[?2004l" - end - end + alias TermUI.{Clipboard, Command, Frame} - describe "paste_start_marker/0" do - test "returns correct marker" do - assert Clipboard.paste_start_marker() == "\e[200~" + @impl true + def init(opts) do + state = %{owner: Keyword.fetch!(opts, :test_owner), dimensions: opts[:dimensions]} + {state, [Clipboard.copy("runtime copy", on_result: &{:copied, &1})]} end - end - - describe "paste_end_marker/0" do - test "returns correct marker" do - assert Clipboard.paste_end_marker() == "\e[201~" - end - end - - describe "write_sequence/2" do - test "generates OSC 52 sequence for clipboard" do - sequence = Clipboard.write_sequence("hello") - # Base64 of "hello" is "aGVsbG8=" - assert sequence == "\e]52;c;aGVsbG8=\e\\" - end - - test "generates OSC 52 sequence for primary selection" do - sequence = Clipboard.write_sequence("test", target: :primary) - - # Base64 of "test" is "dGVzdA==" - assert sequence == "\e]52;p;dGVzdA==\e\\" - end - - test "handles empty content" do - sequence = Clipboard.write_sequence("") - assert sequence == "\e]52;c;\e\\" - end - - test "handles unicode content" do - sequence = Clipboard.write_sequence("héllo") - encoded = Base.encode64("héllo") - assert sequence == "\e]52;c;#{encoded}\e\\" - end + @impl true + def event_to_msg(_event, _state), do: :ignore - test "handles multiline content" do - content = "line1\nline2\nline3" - sequence = Clipboard.write_sequence(content) - encoded = Base.encode64(content) - assert sequence == "\e]52;c;#{encoded}\e\\" + @impl true + def update({:copied, result}, state) do + send(state.owner, {:clipboard_done, result}) + {state, [Command.shutdown()]} end - end - - describe "clear_sequence/1" do - test "generates OSC 52 clear sequence for clipboard" do - sequence = Clipboard.clear_sequence() - assert sequence == "\e]52;c;\e\\" - end - - test "generates OSC 52 clear sequence for primary" do - sequence = Clipboard.clear_sequence(target: :primary) - assert sequence == "\e]52;p;\e\\" - end - end - - describe "osc52_supported?/0" do - test "returns boolean" do - result = Clipboard.osc52_supported?() - assert is_boolean(result) - end - end -end - -defmodule TermUI.Clipboard.PasteAccumulatorTest do - use ExUnit.Case, async: true - - alias TermUI.Clipboard.PasteAccumulator - - describe "new/0" do - test "creates empty accumulator" do - acc = PasteAccumulator.new() - refute PasteAccumulator.accumulating?(acc) - end - end - describe "start/1" do - test "begins accumulation" do - acc = PasteAccumulator.new() - acc = PasteAccumulator.start(acc) - - assert PasteAccumulator.accumulating?(acc) - end + @impl true + def view(%{dimensions: {width, height}}), do: Frame.new(width, height) end - describe "add/2" do - test "accumulates content when active" do - acc = PasteAccumulator.new() - acc = PasteAccumulator.start(acc) - acc = PasteAccumulator.add(acc, "hello") - acc = PasteAccumulator.add(acc, " world") - - {content, _} = PasteAccumulator.complete(acc) - assert content == "hello world" - end - - test "ignores content when not active" do - acc = PasteAccumulator.new() - acc = PasteAccumulator.add(acc, "ignored") + test "copy creates bounded clipboard command data" do + assert %Command{kind: :clipboard, value: {operation, mapper}} = + Clipboard.copy("hello", target: :primary) - {content, _} = PasteAccumulator.complete(acc) - assert content == "" - end + assert operation.kind == :write + assert operation.target == :primary + assert operation.content == "hello" + assert mapper.(:ok) == {:clipboard_result, :ok} end - describe "complete/1" do - test "returns accumulated content and resets" do - acc = PasteAccumulator.new() - acc = PasteAccumulator.start(acc) - acc = PasteAccumulator.add(acc, "test content") - - {content, acc} = PasteAccumulator.complete(acc) - - assert content == "test content" - refute PasteAccumulator.accumulating?(acc) - end + test "OSC 52 encoding supports write and clear operations" do + operation = Clipboard.operation("hello") + assert {:ok, "\e]52;c;aGVsbG8=\e\\"} = Clipboard.sequence(operation) - test "returns empty string when not accumulating" do - acc = PasteAccumulator.new() - {content, _} = PasteAccumulator.complete(acc) - assert content == "" - end + assert {:ok, "\e]52;p;\e\\"} = + Clipboard.clear_operation(target: :primary) |> Clipboard.sequence() end - describe "timed_out?/2" do - test "returns false when not accumulating" do - acc = PasteAccumulator.new() - refute PasteAccumulator.timed_out?(acc, 1000) - end - - test "returns false before timeout" do - acc = PasteAccumulator.new() - acc = PasteAccumulator.start(acc) - refute PasteAccumulator.timed_out?(acc, 5000) - end + test "OSC 52 encoding rejects invalid targets and oversized content" do + assert_raise ArgumentError, fn -> Clipboard.operation("hello", target: :invalid) end - test "returns true after timeout" do - acc = PasteAccumulator.new() - acc = PasteAccumulator.start(acc) - # Use 0 timeout to immediately timeout - assert PasteAccumulator.timed_out?(acc, 0) - end + operation = Clipboard.operation("toolong", max_bytes: 3) + assert {:error, {:clipboard_too_large, 7, 3}} = Clipboard.sequence(operation) end - describe "reset/1" do - test "clears accumulation state" do - acc = PasteAccumulator.new() - acc = PasteAccumulator.start(acc) - acc = PasteAccumulator.add(acc, "content") - acc = PasteAccumulator.reset(acc) + test "a caller can replace the result message mapper" do + assert %Command{value: {_operation, mapper}} = + Clipboard.copy("hello", on_result: &{:copied, &1}) - refute PasteAccumulator.accumulating?(acc) - {content, _} = PasteAccumulator.complete(acc) - assert content == "" - end + assert mapper.({:error, :unsupported}) == {:copied, {:error, :unsupported}} end -end -defmodule TermUI.Clipboard.SelectionTest do - use ExUnit.Case, async: true + test "the backend manager serializes clipboard state" do + operation = Clipboard.operation("manager copy") - alias TermUI.Clipboard.Selection + assert {:ok, manager} = + BackendManager.start_link( + self(), + {DeterministicBackend, owner: self(), size: {2, 8}}, + [] + ) - describe "new/0" do - test "creates empty selection" do - selection = Selection.new() - refute Selection.active?(selection) - assert Selection.empty?(selection) - end - end - - describe "start/2" do - test "starts selection at position" do - selection = Selection.new() - selection = Selection.start(selection, 5) - - assert Selection.active?(selection) - assert Selection.range(selection) == {5, 5} - end + assert :ok = BackendManager.clipboard(manager, operation) + assert_receive {:backend, :clipboard, ^operation} + assert :ok = BackendManager.close(manager, :normal) end - describe "extend/2" do - test "extends selection forward" do - selection = Selection.new() - selection = Selection.start(selection, 5) - selection = Selection.extend(selection, 10) - - assert Selection.range(selection) == {5, 10} - end - - test "extends selection backward" do - selection = Selection.new() - selection = Selection.start(selection, 10) - selection = Selection.extend(selection, 5) + test "the runtime maps a clipboard result back to the Elm application" do + assert {:ok, runtime} = + TermUI.start_link(ClipboardApp, + backend: {DeterministicBackend, owner: self(), size: {2, 8}}, + test_owner: self() + ) - # Range is always start <= end - assert Selection.range(selection) == {5, 10} - end - - test "starts new selection when not active" do - selection = Selection.new() - selection = Selection.extend(selection, 5) + reference = Process.monitor(runtime) - assert Selection.active?(selection) - end + assert_receive {:backend, :clipboard, %Clipboard.Operation{content: "runtime copy"}} + assert_receive {:clipboard_done, :ok} + assert_receive {:DOWN, ^reference, :process, ^runtime, :normal} end - describe "clear/1" do - test "clears active selection" do - selection = Selection.new() - selection = Selection.start(selection, 5) - selection = Selection.extend(selection, 10) - selection = Selection.clear(selection) + test "unsupported custom backends return data instead of crashing the runtime contract" do + defmodule NoClipboardBackend do + @behaviour TermUI.Backend - refute Selection.active?(selection) + def init(_opts), do: {:ok, %{}} + def size(_state), do: {:ok, {1, 1}} + def capabilities(_state), do: %{} + def draw(state, %Frame{}), do: {:ok, state} + def flush(state), do: {:ok, state} + def poll_event(state, _timeout), do: {:timeout, state} + def resize(state, _size), do: {:ok, state} + def shutdown(_state, _reason), do: :ok end - end - describe "empty?/1" do - test "returns true for inactive selection" do - selection = Selection.new() - assert Selection.empty?(selection) - end + assert {:ok, manager} = BackendManager.start_link(self(), NoClipboardBackend, []) - test "returns true for zero-length selection" do - selection = Selection.new() - selection = Selection.start(selection, 5) - assert Selection.empty?(selection) - end + assert {:error, {:backend, NoClipboardBackend, :clipboard, :unsupported}} = + BackendManager.clipboard(manager, Clipboard.operation("copy")) - test "returns false for non-empty selection" do - selection = Selection.new() - selection = Selection.start(selection, 5) - selection = Selection.extend(selection, 10) - refute Selection.empty?(selection) - end - end - - describe "length/1" do - test "returns 0 for inactive selection" do - selection = Selection.new() - assert Selection.length(selection) == 0 - end - - test "returns correct length" do - selection = Selection.new() - selection = Selection.start(selection, 5) - selection = Selection.extend(selection, 15) - assert Selection.length(selection) == 10 - end - end - - describe "extract/2" do - test "extracts selected text" do - selection = Selection.new() - selection = Selection.start(selection, 0) - selection = Selection.extend(selection, 5) - - text = "Hello World" - assert Selection.extract(selection, text) == "Hello" - end - - test "returns empty string for inactive selection" do - selection = Selection.new() - assert Selection.extract(selection, "Hello") == "" - end - - test "extracts middle portion" do - selection = Selection.new() - selection = Selection.start(selection, 6) - selection = Selection.extend(selection, 11) - - text = "Hello World" - assert Selection.extract(selection, text) == "World" - end - end - - describe "contains?/2" do - test "returns false for inactive selection" do - selection = Selection.new() - refute Selection.contains?(selection, 5) - end - - test "returns true for position in selection" do - selection = Selection.new() - selection = Selection.start(selection, 5) - selection = Selection.extend(selection, 15) - - assert Selection.contains?(selection, 10) - end - - test "returns false for position outside selection" do - selection = Selection.new() - selection = Selection.start(selection, 5) - selection = Selection.extend(selection, 15) - - refute Selection.contains?(selection, 3) - refute Selection.contains?(selection, 20) - end - - test "includes start but excludes end" do - selection = Selection.new() - selection = Selection.start(selection, 5) - selection = Selection.extend(selection, 10) - - assert Selection.contains?(selection, 5) - refute Selection.contains?(selection, 10) - end - end - - describe "move/2" do - test "moves selection by delta" do - selection = Selection.new() - selection = Selection.start(selection, 5) - selection = Selection.extend(selection, 10) - selection = Selection.move(selection, 3) - - assert Selection.range(selection) == {8, 13} - end - - test "does nothing for inactive selection" do - selection = Selection.new() - selection = Selection.move(selection, 5) - refute Selection.active?(selection) - end - end - - describe "expand/4" do - test "expands left" do - selection = Selection.new() - text = "Hello World" - selection = Selection.expand(selection, :left, text, 5) - - assert Selection.range(selection) == {4, 5} - end - - test "expands right" do - selection = Selection.new() - text = "Hello World" - selection = Selection.expand(selection, :right, text, 5) - - assert Selection.range(selection) == {5, 6} - end - - test "expands to line start" do - selection = Selection.new() - text = "Hello World" - selection = Selection.expand(selection, :line_start, text, 5) - - assert Selection.range(selection) == {0, 5} - end - - test "expands to line end" do - selection = Selection.new() - text = "Hello World" - selection = Selection.expand(selection, :line_end, text, 5) - - assert Selection.range(selection) == {5, 11} - end - end - - describe "select_all/2" do - test "selects entire text" do - selection = Selection.new() - text = "Hello World" - selection = Selection.select_all(selection, text) - - assert Selection.range(selection) == {0, 11} - assert Selection.extract(selection, text) == "Hello World" - end - end - - describe "select_word/3" do - test "selects word at position" do - selection = Selection.new() - text = "Hello World" - selection = Selection.select_word(selection, text, 2) - - assert Selection.extract(selection, text) == "Hello" - end - - test "selects word when cursor at start" do - selection = Selection.new() - text = "Hello World" - selection = Selection.select_word(selection, text, 0) - - assert Selection.extract(selection, text) == "Hello" - end - - test "selects word when cursor in middle of text" do - selection = Selection.new() - text = "Hello World" - selection = Selection.select_word(selection, text, 8) - - assert Selection.extract(selection, text) == "World" - end - end - - describe "integration" do - test "full selection workflow" do - text = "The quick brown fox" - - # Start at position 4 - selection = Selection.new() - selection = Selection.start(selection, 4) - - # Extend to position 9 ("quick") - selection = Selection.extend(selection, 9) - assert Selection.extract(selection, text) == "quick" - - # Continue extending to position 15 - selection = Selection.extend(selection, 15) - assert Selection.extract(selection, text) == "quick brown" - - # Clear - selection = Selection.clear(selection) - refute Selection.active?(selection) - end + assert :ok = BackendManager.close(manager, :normal) end end diff --git a/test/term_ui/command_test.exs b/test/term_ui/command_test.exs deleted file mode 100644 index 44b76a84..00000000 --- a/test/term_ui/command_test.exs +++ /dev/null @@ -1,388 +0,0 @@ -defmodule TermUI.CommandTest do - use ExUnit.Case, async: true - - alias TermUI.Command - - describe "timer/2" do - test "creates timer command with delay and result message" do - cmd = Command.timer(1000, :timer_done) - - assert cmd.type == :timer - assert cmd.payload == 1000 - assert cmd.on_result == :timer_done - assert cmd.timeout == :infinity - end - - test "accepts zero-delay timers for immediate scheduling" do - cmd = Command.timer(0, :timer_done) - - assert cmd.type == :timer - assert cmd.payload == 0 - assert cmd.on_result == :timer_done - end - - test "accepts tuple as result message" do - cmd = Command.timer(500, {:tick, 1}) - - assert cmd.on_result == {:tick, 1} - end - end - - describe "interval/2" do - test "creates interval command" do - cmd = Command.interval(100, :tick) - - assert cmd.type == :interval - assert cmd.payload == 100 - assert cmd.on_result == :tick - end - end - - describe "file_read/2" do - test "creates file read command" do - cmd = Command.file_read("/path/to/file", :loaded) - - assert cmd.type == :file_read - assert cmd.payload == "/path/to/file" - assert cmd.on_result == :loaded - end - end - - describe "send_after/3" do - test "creates send_after command" do - cmd = Command.send_after(:other, :wake_up, 1000) - - assert cmd.type == :send_after - assert cmd.payload == {:other, :wake_up, 1000} - assert cmd.on_result == :send_after_complete - end - end - - describe "none/0" do - test "creates no-op command" do - cmd = Command.none() - - assert cmd.type == :none - assert cmd.payload == nil - assert cmd.on_result == nil - end - end - - describe "with_timeout/2" do - test "sets timeout on command" do - cmd = Command.timer(1000, :done) |> Command.with_timeout(5000) - - assert cmd.timeout == 5000 - end - end - - describe "validate/1" do - test "validates timer command" do - assert :ok = Command.validate(Command.timer(100, :done)) - end - - test "validates zero-delay timer command" do - assert :ok = Command.validate(Command.timer(0, :done)) - end - - test "validates interval command" do - assert :ok = Command.validate(Command.interval(100, :tick)) - end - - test "validates file_read command" do - assert :ok = Command.validate(Command.file_read("/path", :loaded)) - end - - test "validates send_after command" do - assert :ok = Command.validate(Command.send_after(:comp, :msg, 100)) - end - - test "validates none command" do - assert :ok = Command.validate(Command.none()) - end - - test "rejects invalid timer payload" do - cmd = %Command{type: :timer, payload: "invalid", on_result: :done} - assert {:error, _} = Command.validate(cmd) - end - - test "rejects non-command" do - assert {:error, :not_a_command} = Command.validate("not a command") - end - end - - describe "valid?/1" do - test "returns true for valid command" do - assert Command.valid?(Command.timer(100, :done)) - end - - test "returns false for invalid command" do - refute Command.valid?(%Command{type: :unknown, payload: nil, on_result: nil}) - end - end - - describe "assign_id/1" do - test "assigns unique reference as id" do - cmd = Command.timer(100, :done) - assert cmd.id == nil - - cmd = Command.assign_id(cmd) - assert is_reference(cmd.id) - end - - test "assigns different ids to different commands" do - cmd1 = Command.assign_id(Command.timer(100, :a)) - cmd2 = Command.assign_id(Command.timer(100, :b)) - - refute cmd1.id == cmd2.id - end - end -end - -defmodule TermUI.Command.ExecutorTest do - use ExUnit.Case, async: true - - alias TermUI.Command - alias TermUI.Command.Executor - - describe "start_link/1" do - test "starts executor" do - {:ok, executor} = Executor.start_link() - assert is_pid(executor) - end - - test "starts with registered name" do - {:ok, _} = Executor.start_link(name: :test_executor) - assert is_pid(Process.whereis(:test_executor)) - GenServer.stop(:test_executor) - end - end - - describe "execute/4 with timer" do - test "executes timer command and delivers result" do - {:ok, executor} = Executor.start_link() - runtime_pid = self() - component_id = :test_component - - cmd = Command.timer(10, :timer_done) - {:ok, cmd_id} = Executor.execute(executor, cmd, runtime_pid, component_id) - - assert is_reference(cmd_id) - - assert_receive {:command_result, ^component_id, ^cmd_id, :timer_done}, 100 - end - - test "delivers tuple result message" do - {:ok, executor} = Executor.start_link() - - cmd = Command.timer(10, {:tick, 42}) - {:ok, cmd_id} = Executor.execute(executor, cmd, self(), :comp) - - assert_receive {:command_result, :comp, ^cmd_id, {:tick, 42}}, 100 - end - end - - describe "execute/4 with interval" do - test "delivers repeated messages" do - {:ok, executor} = Executor.start_link() - - cmd = Command.interval(20, :tick) - {:ok, cmd_id} = Executor.execute(executor, cmd, self(), :comp) - - # Should receive multiple ticks - assert_receive {:command_result, :comp, ^cmd_id, :tick}, 100 - assert_receive {:command_result, :comp, ^cmd_id, :tick}, 100 - - # Cancel to stop - Executor.cancel(executor, cmd_id) - end - end - - describe "execute/4 with file_read" do - test "reads file successfully" do - {:ok, executor} = Executor.start_link() - - # Create a temp file - path = Path.join(System.tmp_dir!(), "test_#{:rand.uniform(10000)}.txt") - File.write!(path, "test content") - - cmd = Command.file_read(path, :loaded) - {:ok, cmd_id} = Executor.execute(executor, cmd, self(), :comp) - - assert_receive {:command_result, :comp, ^cmd_id, {:loaded, {:ok, "test content"}}}, 100 - - File.rm(path) - end - - test "returns error for missing file" do - {:ok, executor} = Executor.start_link() - - cmd = Command.file_read("/nonexistent/file", :loaded) - {:ok, cmd_id} = Executor.execute(executor, cmd, self(), :comp) - - assert_receive {:command_result, :comp, ^cmd_id, {:loaded, {:error, :enoent}}}, 100 - end - end - - describe "execute/4 with send_after" do - test "sends message after delay" do - {:ok, executor} = Executor.start_link() - - cmd = Command.send_after(:target, :wake_up, 10) - {:ok, cmd_id} = Executor.execute(executor, cmd, self(), :comp) - - assert_receive {:command_result, :comp, ^cmd_id, {:send_to, :target, :wake_up}}, 100 - end - end - - describe "execute/4 with none" do - test "no-op command succeeds without delivering message" do - {:ok, executor} = Executor.start_link() - - cmd = Command.none() - {:ok, _cmd_id} = Executor.execute(executor, cmd, self(), :comp) - - refute_receive {:command_result, _, _, _}, 50 - end - end - - describe "cancel/2" do - test "cancels running timer command" do - {:ok, executor} = Executor.start_link() - - cmd = Command.timer(1000, :done) - {:ok, cmd_id} = Executor.execute(executor, cmd, self(), :comp) - - :ok = Executor.cancel(executor, cmd_id) - - refute_receive {:command_result, _, _, _}, 50 - end - - test "cancels interval command" do - {:ok, executor} = Executor.start_link() - - cmd = Command.interval(10, :tick) - {:ok, cmd_id} = Executor.execute(executor, cmd, self(), :comp) - - # Receive one tick - assert_receive {:command_result, :comp, ^cmd_id, :tick}, 100 - - # Cancel - :ok = Executor.cancel(executor, cmd_id) - - # Should not receive more - refute_receive {:command_result, _, _, :tick}, 50 - end - - test "returns error for unknown command" do - {:ok, executor} = Executor.start_link() - - assert {:error, :not_found} = Executor.cancel(executor, make_ref()) - end - end - - describe "cancel_all_for_component/2" do - test "cancels all commands for component" do - {:ok, executor} = Executor.start_link() - - cmd1 = Command.timer(1000, :a) - cmd2 = Command.timer(1000, :b) - {:ok, _} = Executor.execute(executor, cmd1, self(), :comp1) - {:ok, _} = Executor.execute(executor, cmd2, self(), :comp1) - {:ok, cmd3_id} = Executor.execute(executor, Command.timer(10, :c), self(), :comp2) - - :ok = Executor.cancel_all_for_component(executor, :comp1) - - # Should not receive comp1 results - refute_receive {:command_result, :comp1, _, _}, 50 - - # Should still receive comp2 result - assert_receive {:command_result, :comp2, ^cmd3_id, :c}, 100 - end - end - - describe "running_count/1" do - test "returns number of running commands" do - {:ok, executor} = Executor.start_link() - - assert Executor.running_count(executor) == 0 - - cmd = Command.timer(1000, :done) - {:ok, _} = Executor.execute(executor, cmd, self(), :comp) - - assert Executor.running_count(executor) == 1 - - {:ok, _} = Executor.execute(executor, Command.timer(1000, :done2), self(), :comp) - - assert Executor.running_count(executor) == 2 - end - end - - describe "max concurrent limit" do - test "rejects commands when at limit" do - {:ok, executor} = Executor.start_link(max_concurrent: 2) - - {:ok, _} = Executor.execute(executor, Command.timer(1000, :a), self(), :comp) - {:ok, _} = Executor.execute(executor, Command.timer(1000, :b), self(), :comp) - - # Third should fail - assert {:error, :max_concurrent_reached} = - Executor.execute(executor, Command.timer(1000, :c), self(), :comp) - end - end - - describe "timeout" do - test "cancels command after timeout" do - {:ok, executor} = Executor.start_link() - - cmd = Command.timer(1000, :done) |> Command.with_timeout(10) - {:ok, cmd_id} = Executor.execute(executor, cmd, self(), :comp) - - # Should receive timeout error - assert_receive {:command_result, :comp, ^cmd_id, {:error, :timeout}}, 100 - end - end - - describe "error handling" do - test "converts task crash to error message" do - {:ok, executor} = Executor.start_link() - - # Create a command that will crash - # We'll use a file read on a path that causes an error - # Actually, let's test with a custom approach - we can't easily make built-ins crash - # For now, test the error path through timeout which we know works - cmd = Command.timer(1000, :done) |> Command.with_timeout(5) - {:ok, cmd_id} = Executor.execute(executor, cmd, self(), :comp) - - assert_receive {:command_result, :comp, ^cmd_id, {:error, :timeout}}, 100 - end - end - - describe "concurrent execution" do - test "executes multiple commands concurrently" do - {:ok, executor} = Executor.start_link() - - # Start 3 timers at the same time - cmds = for i <- 1..3, do: Command.timer(50, {:done, i}) - - start = System.monotonic_time(:millisecond) - - ids = - for cmd <- cmds do - {:ok, id} = Executor.execute(executor, cmd, self(), :comp) - id - end - - # Wait for all - for id <- ids do - assert_receive {:command_result, :comp, ^id, _}, 200 - end - - elapsed = System.monotonic_time(:millisecond) - start - - # Should complete in ~50ms, not 150ms (sequential) - # Allow some tolerance for CI - assert elapsed < 150 - end - end -end diff --git a/test/term_ui/component/helpers_test.exs b/test/term_ui/component/helpers_test.exs deleted file mode 100644 index b78862cc..00000000 --- a/test/term_ui/component/helpers_test.exs +++ /dev/null @@ -1,364 +0,0 @@ -defmodule TermUI.Component.HelpersTest do - use ExUnit.Case, async: true - - alias TermUI.Component.Helpers - alias TermUI.Component.RenderNode - alias TermUI.Renderer.Style - - describe "text/1 and text/2" do - test "delegates to RenderNode.text" do - node = Helpers.text("Hello") - assert node.type == :text - assert node.content == "Hello" - end - - test "with style" do - style = Style.new() |> Style.fg(:red) - node = Helpers.text("Error", style) - assert node.style.fg == :red - end - end - - describe "box/1 and box/2" do - test "delegates to RenderNode.box" do - node = Helpers.box([Helpers.text("Content")]) - assert node.type == :box - assert length(node.children) == 1 - end - - test "with options" do - node = Helpers.box([], width: 20) - assert node.width == 20 - end - end - - describe "stack/2 and stack/3" do - test "delegates to RenderNode.stack" do - node = Helpers.stack(:vertical, [Helpers.text("A")]) - assert node.type == :stack - assert node.direction == :vertical - end - end - - describe "styled/2" do - test "delegates to RenderNode.styled" do - inner = Helpers.text("Hello") - style = Style.new() |> Style.fg(:red) - node = Helpers.styled(inner, style) - assert node.type == :box - assert node.style.fg == :red - end - end - - describe "empty/0" do - test "delegates to RenderNode.empty" do - node = Helpers.empty() - assert node.type == :empty - end - end - - describe "props!/2" do - test "validates required props" do - props = %{name: "Test"} - - result = - Helpers.props!(props, [ - {:name, :string, required: true} - ]) - - assert result.name == "Test" - end - - test "raises on missing required prop" do - props = %{} - - assert_raise ArgumentError, ~r/Required prop :name is missing/, fn -> - Helpers.props!(props, [ - {:name, :string, required: true} - ]) - end - end - - test "applies default values" do - props = %{} - - result = - Helpers.props!(props, [ - {:count, :integer, default: 0} - ]) - - assert result.count == 0 - end - - test "passed values override defaults" do - props = %{count: 42} - - result = - Helpers.props!(props, [ - {:count, :integer, default: 0} - ]) - - assert result.count == 42 - end - - test "validates string type" do - props = %{name: 123} - - assert_raise ArgumentError, ~r/must be a string/, fn -> - Helpers.props!(props, [ - {:name, :string, required: true} - ]) - end - end - - test "validates integer type" do - props = %{count: "not a number"} - - assert_raise ArgumentError, ~r/must be an integer/, fn -> - Helpers.props!(props, [ - {:count, :integer, required: true} - ]) - end - end - - test "validates boolean type" do - props = %{enabled: "yes"} - - assert_raise ArgumentError, ~r/must be a boolean/, fn -> - Helpers.props!(props, [ - {:enabled, :boolean, required: true} - ]) - end - end - - test "validates atom type" do - props = %{mode: "fast"} - - assert_raise ArgumentError, ~r/must be an atom/, fn -> - Helpers.props!(props, [ - {:mode, :atom, required: true} - ]) - end - end - - test "validates style type" do - props = %{style: %{}} - - assert_raise ArgumentError, ~r/must be a Style/, fn -> - Helpers.props!(props, [ - {:style, :style, required: true} - ]) - end - end - - test "any type accepts anything" do - props = %{data: {:some, :tuple}} - - result = - Helpers.props!(props, [ - {:data, :any, required: true} - ]) - - assert result.data == {:some, :tuple} - end - - test "nil values pass type validation" do - props = %{name: nil} - - result = - Helpers.props!(props, [ - {:name, :string, default: "default"} - ]) - - assert result.name == nil - end - - test "multiple props" do - props = %{name: "Test", count: 5} - - result = - Helpers.props!(props, [ - {:name, :string, required: true}, - {:count, :integer, default: 0}, - {:enabled, :boolean, default: true} - ]) - - assert result.name == "Test" - assert result.count == 5 - assert result.enabled == true - end - - test "accepts valid Style struct" do - style = Style.new() |> Style.fg(:red) - props = %{style: style} - - result = - Helpers.props!(props, [ - {:style, :style, required: true} - ]) - - assert result.style.fg == :red - end - end - - describe "merge_styles/1" do - test "merges multiple styles" do - base = Style.new() |> Style.fg(:white) - override = Style.new() |> Style.fg(:red) |> Style.bold() - result = Helpers.merge_styles([base, override]) - - assert result.fg == :red - assert :bold in result.attrs - end - - test "handles nil styles" do - style = Style.new() |> Style.fg(:blue) - result = Helpers.merge_styles([nil, style, nil]) - assert result.fg == :blue - end - - test "empty list returns empty style" do - result = Helpers.merge_styles([]) - assert Style.empty?(result) - end - - test "single style returns itself" do - style = Style.new() |> Style.fg(:green) - result = Helpers.merge_styles([style]) - assert result.fg == :green - end - - test "later styles override earlier" do - s1 = Style.new() |> Style.fg(:red) - s2 = Style.new() |> Style.fg(:blue) - s3 = Style.new() |> Style.fg(:green) - result = Helpers.merge_styles([s1, s2, s3]) - assert result.fg == :green - end - - test "attributes combine" do - s1 = Style.new() |> Style.bold() - s2 = Style.new() |> Style.italic() - result = Helpers.merge_styles([s1, s2]) - assert :bold in result.attrs - assert :italic in result.attrs - end - end - - describe "compute_size/1" do - test "single line text" do - {width, height} = Helpers.compute_size("Hello") - assert width == 5 - assert height == 1 - end - - test "multiline text" do - {width, height} = Helpers.compute_size("Line 1\nLonger Line 2\nL3") - assert width == 13 - assert height == 3 - end - - test "empty string" do - {width, height} = Helpers.compute_size("") - assert width == 0 - assert height == 1 - end - - test "only newlines" do - {width, height} = Helpers.compute_size("\n\n") - assert width == 0 - assert height == 3 - end - end - - describe "compute_node_size/1" do - test "text node returns text dimensions" do - node = RenderNode.text("Hello") - {width, height} = Helpers.compute_node_size(node) - assert width == 5 - assert height == 1 - end - - test "empty node returns zero" do - node = RenderNode.empty() - {width, height} = Helpers.compute_node_size(node) - assert width == 0 - assert height == 0 - end - - test "box with explicit size" do - node = RenderNode.box([], width: 20, height: 10) - {width, height} = Helpers.compute_node_size(node) - assert width == 20 - assert height == 10 - end - - test "box without size returns auto" do - node = RenderNode.box([]) - {width, height} = Helpers.compute_node_size(node) - assert width == :auto - assert height == :auto - end - - test "node with nil content" do - node = %RenderNode{type: :text, content: nil} - {width, height} = Helpers.compute_node_size(node) - assert width == 0 - assert height == 1 - end - end - - describe "fits_in_rect?/2" do - test "returns true when fits" do - rect = %{x: 0, y: 0, width: 20, height: 10} - assert Helpers.fits_in_rect?({10, 5}, rect) - end - - test "returns true for exact fit" do - rect = %{x: 0, y: 0, width: 20, height: 10} - assert Helpers.fits_in_rect?({20, 10}, rect) - end - - test "returns false when too wide" do - rect = %{x: 0, y: 0, width: 20, height: 10} - refute Helpers.fits_in_rect?({30, 5}, rect) - end - - test "returns false when too tall" do - rect = %{x: 0, y: 0, width: 20, height: 10} - refute Helpers.fits_in_rect?({10, 15}, rect) - end - - test "returns false when both exceed" do - rect = %{x: 0, y: 0, width: 20, height: 10} - refute Helpers.fits_in_rect?({30, 15}, rect) - end - - test "zero size always fits" do - rect = %{x: 0, y: 0, width: 20, height: 10} - assert Helpers.fits_in_rect?({0, 0}, rect) - end - end - - describe "truncate_text/2" do - test "returns text unchanged if shorter than max" do - assert Helpers.truncate_text("Hello", 10) == "Hello" - end - - test "returns text unchanged if equal to max" do - assert Helpers.truncate_text("Hello", 5) == "Hello" - end - - test "truncates to max width" do - assert Helpers.truncate_text("Hello, World!", 5) == "Hello" - end - - test "handles zero width" do - assert Helpers.truncate_text("Hello", 0) == "" - end - - test "handles empty string" do - assert Helpers.truncate_text("", 10) == "" - end - end -end diff --git a/test/term_ui/component/introspection_test.exs b/test/term_ui/component/introspection_test.exs deleted file mode 100644 index 5780c744..00000000 --- a/test/term_ui/component/introspection_test.exs +++ /dev/null @@ -1,228 +0,0 @@ -defmodule TermUI.Component.IntrospectionTest do - use ExUnit.Case, async: false - - alias TermUI.Component.Introspection - alias TermUI.Component.StatePersistence - alias TermUI.ComponentRegistry - alias TermUI.ComponentServer - alias TermUI.ComponentSupervisor - - # Simple test component - defmodule TestComponent do - use TermUI.StatefulComponent - - @impl true - def init(props) do - {:ok, %{value: props[:initial] || 0}} - end - - @impl true - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(_state, _area) do - text("") - end - end - - setup do - start_supervised!(StatePersistence) - start_supervised!(ComponentRegistry) - start_supervised!(ComponentSupervisor) - :ok - end - - describe "get_component_tree/0" do - test "returns empty list when no components" do - assert [] = Introspection.get_component_tree() - end - - test "returns single component as root" do - {:ok, pid} = ComponentSupervisor.start_component(TestComponent, %{initial: 42}, id: :root) - ComponentServer.mount(pid) - - tree = Introspection.get_component_tree() - assert length(tree) == 1 - - [node] = tree - assert node.id == :root - assert node.pid == pid - assert node.module == TestComponent - assert node.children == [] - end - - test "returns multiple root components" do - {:ok, pid1} = ComponentSupervisor.start_component(TestComponent, %{}, id: :comp1) - {:ok, pid2} = ComponentSupervisor.start_component(TestComponent, %{}, id: :comp2) - ComponentServer.mount(pid1) - ComponentServer.mount(pid2) - - tree = Introspection.get_component_tree() - assert length(tree) == 2 - - ids = Enum.map(tree, & &1.id) - assert :comp1 in ids - assert :comp2 in ids - end - - test "builds hierarchy with parent-child relationships" do - {:ok, parent_pid} = ComponentSupervisor.start_component(TestComponent, %{}, id: :parent) - {:ok, child_pid} = ComponentSupervisor.start_component(TestComponent, %{}, id: :child) - - ComponentServer.mount(parent_pid) - ComponentServer.mount(child_pid) - - # Set up parent-child relationship - ComponentRegistry.set_parent(:child, :parent) - - tree = Introspection.get_component_tree() - assert length(tree) == 1 - - [parent] = tree - assert parent.id == :parent - assert length(parent.children) == 1 - - [child] = parent.children - assert child.id == :child - assert child.pid == child_pid - end - end - - describe "get_component_info/1" do - test "returns detailed component information" do - {:ok, pid} = - ComponentSupervisor.start_component(TestComponent, %{initial: 42}, id: :test_comp) - - ComponentServer.mount(pid) - - assert {:ok, info} = Introspection.get_component_info(:test_comp) - assert info.id == :test_comp - assert info.pid == pid - assert info.module == TestComponent - assert info.state == %{value: 42} - assert info.props == %{initial: 42} - assert info.lifecycle == :mounted - assert info.restart_count == 0 - assert info.child_count == 0 - assert is_integer(info.uptime_ms) - end - - test "returns :error for non-existent component" do - assert {:error, :not_found} = Introspection.get_component_info(:nonexistent) - end - end - - describe "get_metrics/1" do - test "returns component metrics" do - {:ok, pid} = ComponentSupervisor.start_component(TestComponent, %{}, id: :test_comp) - ComponentServer.mount(pid) - - assert {:ok, metrics} = Introspection.get_metrics(:test_comp) - assert metrics.restart_count == 0 - assert metrics.child_count == 0 - assert is_integer(metrics.uptime_ms) - assert is_integer(metrics.memory_bytes) - assert is_integer(metrics.message_queue_len) - assert is_integer(metrics.reductions) - assert is_atom(metrics.status) - end - - test "returns :error for non-existent component" do - assert {:error, :not_found} = Introspection.get_metrics(:nonexistent) - end - end - - describe "format_tree/0" do - test "returns empty message when no components" do - output = Introspection.format_tree() - assert output =~ "no components" - end - - test "formats single component" do - {:ok, pid} = ComponentSupervisor.start_component(TestComponent, %{}, id: :root) - ComponentServer.mount(pid) - - output = Introspection.format_tree() - assert output =~ "root" - assert output =~ "TestComponent" - end - end - - describe "aggregate_stats/0" do - test "returns aggregate statistics" do - {:ok, pid1} = ComponentSupervisor.start_component(TestComponent, %{}, id: :comp1) - {:ok, pid2} = ComponentSupervisor.start_component(TestComponent, %{}, id: :comp2) - ComponentServer.mount(pid1) - ComponentServer.mount(pid2) - - stats = Introspection.aggregate_stats() - assert stats.component_count == 2 - assert stats.total_restarts == 0 - assert is_integer(stats.total_memory_bytes) - assert stats.persisted_state_count == 0 - end - - test "returns zeros when no components" do - stats = Introspection.aggregate_stats() - assert stats.component_count == 0 - assert stats.total_restarts == 0 - end - end - - describe "find_by_module/1" do - test "returns components matching module" do - {:ok, pid1} = ComponentSupervisor.start_component(TestComponent, %{}, id: :comp1) - {:ok, pid2} = ComponentSupervisor.start_component(TestComponent, %{}, id: :comp2) - ComponentServer.mount(pid1) - ComponentServer.mount(pid2) - - results = Introspection.find_by_module(TestComponent) - assert length(results) == 2 - end - - test "returns empty list when no matches" do - assert [] = Introspection.find_by_module(NonExistentModule) - end - end - - describe "find_unstable/1" do - test "returns empty list when no restarts" do - {:ok, pid} = ComponentSupervisor.start_component(TestComponent, %{}, id: :stable) - ComponentServer.mount(pid) - - assert [] = Introspection.find_unstable() - end - - test "returns components with restarts above threshold" do - {:ok, pid} = ComponentSupervisor.start_component(TestComponent, %{}, id: :unstable) - ComponentServer.mount(pid) - - # Simulate restarts - StatePersistence.record_restart(:unstable) - StatePersistence.record_restart(:unstable) - - results = Introspection.find_unstable(2) - assert length(results) == 1 - assert hd(results).id == :unstable - assert hd(results).restart_count == 2 - end - - test "sorts by restart count descending" do - {:ok, pid1} = ComponentSupervisor.start_component(TestComponent, %{}, id: :comp1) - {:ok, pid2} = ComponentSupervisor.start_component(TestComponent, %{}, id: :comp2) - ComponentServer.mount(pid1) - ComponentServer.mount(pid2) - - StatePersistence.record_restart(:comp1) - StatePersistence.record_restart(:comp2) - StatePersistence.record_restart(:comp2) - - results = Introspection.find_unstable(1) - assert length(results) == 2 - assert hd(results).id == :comp2 - assert hd(results).restart_count == 2 - end - end -end diff --git a/test/term_ui/component/render_node_test.exs b/test/term_ui/component/render_node_test.exs deleted file mode 100644 index cc7bbd30..00000000 --- a/test/term_ui/component/render_node_test.exs +++ /dev/null @@ -1,168 +0,0 @@ -defmodule TermUI.Component.RenderNodeTest do - use ExUnit.Case, async: true - - alias TermUI.Component.RenderNode - alias TermUI.Renderer.Style - - describe "empty/0" do - test "creates empty node" do - node = RenderNode.empty() - assert node.type == :empty - assert node.content == nil - assert node.children == [] - end - end - - describe "text/1 and text/2" do - test "creates text node with content" do - node = RenderNode.text("Hello") - assert node.type == :text - assert node.content == "Hello" - assert node.style == nil - end - - test "creates text node with style" do - style = Style.new() |> Style.fg(:red) - node = RenderNode.text("Error", style) - assert node.type == :text - assert node.content == "Error" - assert node.style.fg == :red - end - end - - describe "box/1 and box/2" do - test "creates box with children" do - children = [RenderNode.text("Content")] - node = RenderNode.box(children) - assert node.type == :box - assert length(node.children) == 1 - assert hd(node.children).content == "Content" - end - - test "creates box with style option" do - style = Style.new() |> Style.bg(:blue) - node = RenderNode.box([], style: style) - assert node.style.bg == :blue - end - - test "creates box with dimensions" do - node = RenderNode.box([], width: 20, height: 10) - assert node.width == 20 - assert node.height == 10 - end - - test "box with empty children" do - node = RenderNode.box([]) - assert node.type == :box - assert node.children == [] - end - end - - describe "stack/2 and stack/3" do - test "creates vertical stack" do - children = [RenderNode.text("Top"), RenderNode.text("Bottom")] - node = RenderNode.stack(:vertical, children) - assert node.type == :stack - assert node.direction == :vertical - assert length(node.children) == 2 - end - - test "creates horizontal stack" do - children = [RenderNode.text("Left"), RenderNode.text("Right")] - node = RenderNode.stack(:horizontal, children) - assert node.direction == :horizontal - end - - test "creates stack with options" do - style = Style.new() |> Style.bg(:black) - node = RenderNode.stack(:vertical, [], style: style, width: 30) - assert node.style.bg == :black - assert node.width == 30 - end - end - - describe "styled/2" do - test "wraps node in styled box" do - inner = RenderNode.text("Hello") - style = Style.new() |> Style.fg(:red) - node = RenderNode.styled(inner, style) - - assert node.type == :box - assert node.style.fg == :red - assert length(node.children) == 1 - assert hd(node.children).content == "Hello" - end - end - - describe "width/2 and height/2" do - test "sets width on node" do - node = RenderNode.box([]) |> RenderNode.width(20) - assert node.width == 20 - end - - test "sets height on node" do - node = RenderNode.box([]) |> RenderNode.height(10) - assert node.height == 10 - end - - test "accepts :auto as width" do - node = RenderNode.box([]) |> RenderNode.width(:auto) - assert node.width == :auto - end - - test "accepts :auto as height" do - node = RenderNode.box([]) |> RenderNode.height(:auto) - assert node.height == :auto - end - - test "chaining width and height" do - node = - RenderNode.box([]) - |> RenderNode.width(20) - |> RenderNode.height(10) - - assert node.width == 20 - assert node.height == 10 - end - end - - describe "empty?/1" do - test "returns true for empty node" do - assert RenderNode.empty?(RenderNode.empty()) - end - - test "returns false for text node" do - refute RenderNode.empty?(RenderNode.text("Hello")) - end - - test "returns false for box node" do - refute RenderNode.empty?(RenderNode.box([])) - end - - test "returns false for stack node" do - refute RenderNode.empty?(RenderNode.stack(:vertical, [])) - end - end - - describe "child_count/1" do - test "returns 0 for text node" do - assert RenderNode.child_count(RenderNode.text("Hello")) == 0 - end - - test "returns 0 for empty node" do - assert RenderNode.child_count(RenderNode.empty()) == 0 - end - - test "returns count for box with children" do - children = [RenderNode.text("A"), RenderNode.text("B"), RenderNode.text("C")] - node = RenderNode.box(children) - assert RenderNode.child_count(node) == 3 - end - - test "returns count for stack with children" do - children = [RenderNode.text("A"), RenderNode.text("B")] - node = RenderNode.stack(:vertical, children) - assert RenderNode.child_count(node) == 2 - end - end -end diff --git a/test/term_ui/component/state_persistence_test.exs b/test/term_ui/component/state_persistence_test.exs deleted file mode 100644 index f8a3a3f6..00000000 --- a/test/term_ui/component/state_persistence_test.exs +++ /dev/null @@ -1,192 +0,0 @@ -defmodule TermUI.Component.StatePersistenceTest do - use ExUnit.Case, async: false - - alias TermUI.Component.StatePersistence - - setup do - start_supervised!(StatePersistence) - :ok - end - - describe "persist/3" do - test "persists state to ETS" do - state = %{counter: 42} - :ok = StatePersistence.persist(:test_component, state) - - assert {:ok, ^state} = StatePersistence.recover(:test_component) - end - - test "persists state with props" do - state = %{counter: 42} - props = %{initial: 10} - :ok = StatePersistence.persist(:test_component, state, props: props) - - assert {:ok, ^state} = StatePersistence.recover(:test_component, :last_state) - assert {:ok, ^props} = StatePersistence.recover(:test_component, :last_props) - end - - test "overwrites previous state" do - StatePersistence.persist(:test_component, %{value: 1}) - StatePersistence.persist(:test_component, %{value: 2}) - - assert {:ok, %{value: 2}} = StatePersistence.recover(:test_component) - end - end - - describe "recover/2" do - test "returns :not_found for non-existent component" do - assert :not_found = StatePersistence.recover(:nonexistent) - end - - test "with :last_state mode returns full state" do - state = %{counter: 42, name: "test"} - StatePersistence.persist(:test_component, state) - - assert {:ok, ^state} = StatePersistence.recover(:test_component, :last_state) - end - - test "with :last_props mode returns props" do - state = %{counter: 42} - props = %{initial: 10} - StatePersistence.persist(:test_component, state, props: props) - - assert {:ok, ^props} = StatePersistence.recover(:test_component, :last_props) - end - - test "with :last_props mode returns :not_found if no props" do - state = %{counter: 42} - StatePersistence.persist(:test_component, state) - - assert :not_found = StatePersistence.recover(:test_component, :last_props) - end - - test "with :reset mode clears state and returns :not_found" do - state = %{counter: 42} - StatePersistence.persist(:test_component, state) - - assert :not_found = StatePersistence.recover(:test_component, :reset) - # State should be cleared - assert :not_found = StatePersistence.recover(:test_component) - end - end - - describe "clear/1" do - test "clears persisted state" do - StatePersistence.persist(:test_component, %{value: 1}) - StatePersistence.clear(:test_component) - - assert :not_found = StatePersistence.recover(:test_component) - end - - test "is idempotent" do - StatePersistence.clear(:nonexistent) - assert :ok = StatePersistence.clear(:nonexistent) - end - end - - describe "clear_all/0" do - test "clears all persisted states" do - StatePersistence.persist(:comp1, %{value: 1}) - StatePersistence.persist(:comp2, %{value: 2}) - StatePersistence.clear_all() - - assert :not_found = StatePersistence.recover(:comp1) - assert :not_found = StatePersistence.recover(:comp2) - end - end - - describe "get_metadata/1" do - test "returns metadata for persisted state" do - StatePersistence.persist(:test_component, %{value: 1}, props: %{initial: 0}) - - assert {:ok, metadata} = StatePersistence.get_metadata(:test_component) - assert is_integer(metadata.persisted_at) - assert metadata.has_props == true - end - - test "returns :not_found for non-existent component" do - assert :not_found = StatePersistence.get_metadata(:nonexistent) - end - end - - describe "list_persisted/0" do - test "returns all persisted component IDs" do - StatePersistence.persist(:comp1, %{}) - StatePersistence.persist(:comp2, %{}) - StatePersistence.persist(:comp3, %{}) - - ids = StatePersistence.list_persisted() - assert length(ids) == 3 - assert :comp1 in ids - assert :comp2 in ids - assert :comp3 in ids - end - - test "returns empty list when nothing persisted" do - assert [] = StatePersistence.list_persisted() - end - end - - describe "count/0" do - test "returns count of persisted states" do - assert StatePersistence.count() == 0 - - StatePersistence.persist(:comp1, %{}) - assert StatePersistence.count() == 1 - - StatePersistence.persist(:comp2, %{}) - assert StatePersistence.count() == 2 - end - end - - describe "restart tracking" do - test "record_restart increments restart count" do - assert StatePersistence.get_restart_count(:test_component) == 0 - - StatePersistence.record_restart(:test_component) - assert StatePersistence.get_restart_count(:test_component) == 1 - - StatePersistence.record_restart(:test_component) - assert StatePersistence.get_restart_count(:test_component) == 2 - end - - test "restart_limit_reached? returns false when under limit" do - StatePersistence.set_restart_limits(:test_component, 3, 5) - - refute StatePersistence.restart_limit_reached?(:test_component) - - StatePersistence.record_restart(:test_component) - StatePersistence.record_restart(:test_component) - refute StatePersistence.restart_limit_reached?(:test_component) - end - - test "restart_limit_reached? returns true when at limit" do - StatePersistence.set_restart_limits(:test_component, 3, 5) - - StatePersistence.record_restart(:test_component) - StatePersistence.record_restart(:test_component) - StatePersistence.record_restart(:test_component) - - assert StatePersistence.restart_limit_reached?(:test_component) - end - - test "old restarts are pruned from window" do - # This test would require time manipulation, so we just verify the structure - StatePersistence.set_restart_limits(:test_component, 3, 1) - StatePersistence.record_restart(:test_component) - StatePersistence.record_restart(:test_component) - StatePersistence.record_restart(:test_component) - - # Should be at limit - assert StatePersistence.restart_limit_reached?(:test_component) - end - - test "clear_restart_history clears count" do - StatePersistence.record_restart(:test_component) - StatePersistence.record_restart(:test_component) - StatePersistence.clear_restart_history(:test_component) - - assert StatePersistence.get_restart_count(:test_component) == 0 - end - end -end diff --git a/test/term_ui/component_registry_test.exs b/test/term_ui/component_registry_test.exs deleted file mode 100644 index 912fcfc6..00000000 --- a/test/term_ui/component_registry_test.exs +++ /dev/null @@ -1,185 +0,0 @@ -defmodule TermUI.ComponentRegistryTest do - use ExUnit.Case, async: false - - alias TermUI.ComponentRegistry - - setup do - start_supervised!(ComponentRegistry) - :ok - end - - describe "register/3" do - test "registers component successfully" do - pid = spawn(fn -> Process.sleep(10_000) end) - assert :ok = ComponentRegistry.register(:test_id, pid, TestModule) - end - - test "fails if id already registered" do - pid1 = spawn(fn -> Process.sleep(10_000) end) - pid2 = spawn(fn -> Process.sleep(10_000) end) - - :ok = ComponentRegistry.register(:same_id, pid1, TestModule) - - assert {:error, :already_registered} = - ComponentRegistry.register(:same_id, pid2, TestModule) - end - - test "can register with reference as id" do - pid = spawn(fn -> Process.sleep(10_000) end) - ref = make_ref() - assert :ok = ComponentRegistry.register(ref, pid, TestModule) - assert {:ok, ^pid} = ComponentRegistry.lookup(ref) - end - end - - describe "unregister/1" do - test "unregisters component" do - pid = spawn(fn -> Process.sleep(10_000) end) - :ok = ComponentRegistry.register(:to_remove, pid, TestModule) - assert ComponentRegistry.registered?(:to_remove) - - :ok = ComponentRegistry.unregister(:to_remove) - refute ComponentRegistry.registered?(:to_remove) - end - - test "returns ok for non-existent id" do - assert :ok = ComponentRegistry.unregister(:not_registered) - end - end - - describe "lookup/1" do - test "returns pid for registered component" do - pid = spawn(fn -> Process.sleep(10_000) end) - :ok = ComponentRegistry.register(:lookup_test, pid, TestModule) - - assert {:ok, ^pid} = ComponentRegistry.lookup(:lookup_test) - end - - test "returns error for non-existent id" do - assert {:error, :not_found} = ComponentRegistry.lookup(:nonexistent) - end - end - - describe "lookup_id/1" do - test "returns id for registered pid" do - pid = spawn(fn -> Process.sleep(10_000) end) - :ok = ComponentRegistry.register(:reverse_lookup, pid, TestModule) - - assert {:ok, :reverse_lookup} = ComponentRegistry.lookup_id(pid) - end - - test "returns error for non-registered pid" do - pid = spawn(fn -> Process.sleep(10_000) end) - assert {:error, :not_found} = ComponentRegistry.lookup_id(pid) - end - end - - describe "get_info/1" do - test "returns full component info" do - pid = spawn(fn -> Process.sleep(10_000) end) - :ok = ComponentRegistry.register(:info_test, pid, MyModule) - - {:ok, info} = ComponentRegistry.get_info(:info_test) - assert info.id == :info_test - assert info.pid == pid - assert info.module == MyModule - end - - test "returns error for non-existent id" do - assert {:error, :not_found} = ComponentRegistry.get_info(:nope) - end - end - - describe "list_all/0" do - test "returns all registered components" do - pid1 = spawn(fn -> Process.sleep(10_000) end) - pid2 = spawn(fn -> Process.sleep(10_000) end) - - :ok = ComponentRegistry.register(:comp1, pid1, Mod1) - :ok = ComponentRegistry.register(:comp2, pid2, Mod2) - - all = ComponentRegistry.list_all() - assert length(all) == 2 - - ids = Enum.map(all, & &1.id) - assert :comp1 in ids - assert :comp2 in ids - end - - test "returns empty list when no components" do - assert ComponentRegistry.list_all() == [] - end - end - - describe "count/0" do - test "returns correct count" do - pid1 = spawn(fn -> Process.sleep(10_000) end) - pid2 = spawn(fn -> Process.sleep(10_000) end) - - assert ComponentRegistry.count() == 0 - - :ok = ComponentRegistry.register(:count1, pid1, Mod1) - assert ComponentRegistry.count() == 1 - - :ok = ComponentRegistry.register(:count2, pid2, Mod2) - assert ComponentRegistry.count() == 2 - end - end - - describe "registered?/1" do - test "returns true for registered id" do - pid = spawn(fn -> Process.sleep(10_000) end) - :ok = ComponentRegistry.register(:exists, pid, TestModule) - - assert ComponentRegistry.registered?(:exists) - end - - test "returns false for non-registered id" do - refute ComponentRegistry.registered?(:does_not_exist) - end - end - - describe "clear/0" do - test "removes all registrations" do - pid1 = spawn(fn -> Process.sleep(10_000) end) - pid2 = spawn(fn -> Process.sleep(10_000) end) - - :ok = ComponentRegistry.register(:clear1, pid1, Mod1) - :ok = ComponentRegistry.register(:clear2, pid2, Mod2) - - assert ComponentRegistry.count() == 2 - - :ok = ComponentRegistry.clear() - - assert ComponentRegistry.count() == 0 - refute ComponentRegistry.registered?(:clear1) - refute ComponentRegistry.registered?(:clear2) - end - end - - describe "automatic cleanup" do - test "unregisters when process dies" do - pid = spawn(fn -> Process.sleep(100) end) - :ok = ComponentRegistry.register(:auto_cleanup, pid, TestModule) - - assert ComponentRegistry.registered?(:auto_cleanup) - - # Wait for process to die - Process.sleep(150) - - refute ComponentRegistry.registered?(:auto_cleanup) - end - - test "unregisters when process is killed" do - pid = spawn(fn -> Process.sleep(10_000) end) - :ok = ComponentRegistry.register(:kill_test, pid, TestModule) - - Process.exit(pid, :kill) - - # Give time for monitor to trigger - Process.sleep(50) - - refute ComponentRegistry.registered?(:kill_test) - end - end -end diff --git a/test/term_ui/component_server_test.exs b/test/term_ui/component_server_test.exs deleted file mode 100644 index e4705f55..00000000 --- a/test/term_ui/component_server_test.exs +++ /dev/null @@ -1,417 +0,0 @@ -defmodule TermUI.ComponentServerTest do - use ExUnit.Case, async: false - - alias TermUI.ComponentRegistry - alias TermUI.ComponentServer - alias TermUI.ComponentSupervisor - - # Test component with full lifecycle - defmodule TestComponent do - use TermUI.StatefulComponent - - @impl true - def init(props) do - if props[:fail_init] do - {:stop, :init_failed} - else - {:ok, %{value: props[:initial] || 0, mounted: false}} - end - end - - @impl true - def mount(state) do - {:ok, %{state | mounted: true}} - end - - @impl true - def update(new_props, state) do - {:ok, %{state | value: new_props[:value] || state.value}} - end - - @impl true - def unmount(_state) do - :ok - end - - @impl true - def handle_event({:set, value}, state) do - {:ok, %{state | value: value}} - end - - def handle_event({:get_value, caller}, state) do - {:ok, state, [{:send, caller, {:value, state.value}}]} - end - - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, _area) do - text("Value: #{state.value}") - end - end - - # Component with commands - defmodule CommandComponent do - use TermUI.StatefulComponent - - @impl true - def init(props) do - if props[:init_command] do - {:ok, %{parent: props[:parent]}, [{:send, props[:parent], :initialized}]} - else - {:ok, %{parent: props[:parent]}} - end - end - - @impl true - def mount(state) do - if state.parent do - {:ok, state, [{:send, state.parent, :mounted}]} - else - {:ok, state} - end - end - - @impl true - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(_state, _area) do - text("") - end - end - - # Component that fails on mount - defmodule FailingMountComponent do - use TermUI.StatefulComponent - - @impl true - def init(_props) do - {:ok, %{}} - end - - @impl true - def mount(_state) do - {:stop, :mount_failed} - end - - @impl true - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(_state, _area) do - text("") - end - end - - # Slow init component - defmodule SlowInitComponent do - use TermUI.StatefulComponent - - @impl true - def init(_props) do - Process.sleep(200) - {:ok, %{}} - end - - @impl true - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(_state, _area) do - text("") - end - end - - setup do - # Start required processes - start_supervised!(TermUI.Component.StatePersistence) - start_supervised!(ComponentRegistry) - start_supervised!(ComponentSupervisor) - :ok - end - - describe "initialization" do - test "creates process with correct initial state" do - {:ok, pid} = ComponentServer.start_link(TestComponent, %{initial: 42}, []) - - state = ComponentServer.get_state(pid) - assert state.value == 42 - assert state.mounted == false - end - - test "lifecycle starts as :initialized" do - {:ok, pid} = ComponentServer.start_link(TestComponent, %{}, []) - - assert ComponentServer.get_lifecycle(pid) == :initialized - end - - test "init can return commands" do - {:ok, _pid} = - ComponentServer.start_link(CommandComponent, %{init_command: true, parent: self()}, []) - - assert_receive :initialized - end - - test "invalid props fail initialization" do - Process.flag(:trap_exit, true) - result = ComponentServer.start_link(TestComponent, %{fail_init: true}, []) - - # Should fail to start - assert match?({:error, :init_failed}, result) - end - - test "init timeout stops slow initialization" do - Process.flag(:trap_exit, true) - result = ComponentServer.start_link(SlowInitComponent, %{}, timeout: 50) - - case result do - {:ok, pid} -> - assert_receive {:EXIT, ^pid, {:init_timeout, 50}} - - {:error, {:init_timeout, 50}} -> - :ok - end - end - end - - describe "mounting" do - test "mount callback called after mount" do - {:ok, pid} = ComponentServer.start_link(TestComponent, %{}, []) - - assert :ok = ComponentServer.mount(pid) - - state = ComponentServer.get_state(pid) - assert state.mounted == true - end - - test "lifecycle changes to :mounted" do - {:ok, pid} = ComponentServer.start_link(TestComponent, %{}, []) - ComponentServer.mount(pid) - - assert ComponentServer.get_lifecycle(pid) == :mounted - end - - test "mount commands are executed" do - {:ok, pid} = ComponentServer.start_link(CommandComponent, %{parent: self()}, []) - ComponentServer.mount(pid) - - assert_receive :mounted - end - - test "component registered on mount" do - {:ok, pid} = ComponentServer.start_link(TestComponent, %{}, id: :test_comp) - ComponentServer.mount(pid) - - assert {:ok, ^pid} = ComponentRegistry.lookup(:test_comp) - end - - test "mount error stops process" do - Process.flag(:trap_exit, true) - {:ok, pid} = ComponentServer.start_link(FailingMountComponent, %{}, []) - - # Mount will stop the process - try do - ComponentServer.mount(pid) - catch - :exit, _ -> :ok - end - - # Wait for EXIT message - assert_receive {:EXIT, ^pid, :mount_failed} - end - - test "mount not allowed when already mounted" do - {:ok, pid} = ComponentServer.start_link(TestComponent, %{}, []) - ComponentServer.mount(pid) - - result = ComponentServer.mount(pid) - assert match?({:error, {:invalid_lifecycle, :mounted, :expected_initialized}}, result) - end - end - - describe "updates" do - test "update callback receives new props" do - {:ok, pid} = ComponentServer.start_link(TestComponent, %{initial: 1}, []) - ComponentServer.mount(pid) - - ComponentServer.update_props(pid, %{value: 100}) - - state = ComponentServer.get_state(pid) - assert state.value == 100 - end - - test "update not called when props unchanged" do - {:ok, pid} = ComponentServer.start_link(TestComponent, %{initial: 1}, []) - ComponentServer.mount(pid) - - # Update with same props - props = ComponentServer.get_props(pid) - ComponentServer.update_props(pid, props) - - # State should be unchanged - state = ComponentServer.get_state(pid) - assert state.value == 1 - end - - test "props are stored and retrievable" do - {:ok, pid} = ComponentServer.start_link(TestComponent, %{initial: 5, extra: "data"}, []) - - props = ComponentServer.get_props(pid) - assert props.initial == 5 - assert props.extra == "data" - end - - test "update requires mounted lifecycle" do - {:ok, pid} = ComponentServer.start_link(TestComponent, %{}, []) - - result = ComponentServer.update_props(pid, %{value: 1}) - assert match?({:error, {:invalid_lifecycle, :initialized, :expected_mounted}}, result) - end - end - - describe "unmounting" do - test "unmount callback called" do - {:ok, pid} = ComponentServer.start_link(TestComponent, %{}, id: :unmount_test) - ComponentServer.mount(pid) - - assert :ok = ComponentServer.unmount(pid) - assert ComponentServer.get_lifecycle(pid) == :unmounted - end - - test "registry entry removed on unmount" do - {:ok, pid} = ComponentServer.start_link(TestComponent, %{}, id: :registry_test) - ComponentServer.mount(pid) - assert ComponentRegistry.registered?(:registry_test) - - ComponentServer.unmount(pid) - refute ComponentRegistry.registered?(:registry_test) - end - - test "cleanup on terminate even when mounted" do - Process.flag(:trap_exit, true) - {:ok, pid} = ComponentServer.start_link(TestComponent, %{}, id: :terminate_test) - ComponentServer.mount(pid) - - # Kill the process abruptly - Process.exit(pid, :kill) - - # Wait for DOWN message - assert_receive {:EXIT, ^pid, :killed} - - # Give time for registry cleanup via monitor - Process.sleep(50) - - # Registry should be cleaned up - refute ComponentRegistry.registered?(:terminate_test) - end - - test "unmount requires mounted lifecycle" do - {:ok, pid} = ComponentServer.start_link(TestComponent, %{}, []) - - result = ComponentServer.unmount(pid) - assert match?({:error, {:invalid_lifecycle, :initialized, :expected_mounted}}, result) - end - end - - describe "events" do - test "send_event updates state" do - {:ok, pid} = ComponentServer.start_link(TestComponent, %{initial: 0}, []) - ComponentServer.mount(pid) - - ComponentServer.send_event(pid, {:set, 42}) - - state = ComponentServer.get_state(pid) - assert state.value == 42 - end - - test "events can return commands" do - {:ok, pid} = ComponentServer.start_link(TestComponent, %{initial: 99}, []) - ComponentServer.mount(pid) - - ComponentServer.send_event(pid, {:get_value, self()}) - - assert_receive {:value, 99} - end - - test "events require mounted lifecycle" do - {:ok, pid} = ComponentServer.start_link(TestComponent, %{}, []) - - result = ComponentServer.send_event(pid, {:set, 1}) - assert match?({:error, {:invalid_lifecycle, :initialized, :expected_mounted}}, result) - end - end - - describe "hooks" do - test "after_mount hook fires after mount" do - {:ok, pid} = ComponentServer.start_link(TestComponent, %{}, []) - - test_pid = self() - - ComponentServer.register_hook(pid, :after_mount, fn _state -> - send(test_pid, :after_mount_called) - end) - - ComponentServer.mount(pid) - - assert_receive :after_mount_called - end - - test "before_unmount hook fires before unmount" do - {:ok, pid} = ComponentServer.start_link(TestComponent, %{}, []) - ComponentServer.mount(pid) - - test_pid = self() - - ComponentServer.register_hook(pid, :before_unmount, fn _state -> - send(test_pid, :before_unmount_called) - end) - - ComponentServer.unmount(pid) - - assert_receive :before_unmount_called - end - - test "on_prop_change hook fires on update" do - {:ok, pid} = ComponentServer.start_link(TestComponent, %{initial: 1}, []) - ComponentServer.mount(pid) - - test_pid = self() - - ComponentServer.register_hook(pid, :on_prop_change, fn state -> - send(test_pid, {:prop_changed, state.value}) - end) - - ComponentServer.update_props(pid, %{value: 100}) - - assert_receive {:prop_changed, 100} - end - - test "multiple hooks execute in order" do - {:ok, pid} = ComponentServer.start_link(TestComponent, %{}, []) - - test_pid = self() - - ComponentServer.register_hook(pid, :after_mount, fn _state -> - send(test_pid, :first) - end) - - ComponentServer.register_hook(pid, :after_mount, fn _state -> - send(test_pid, :second) - end) - - ComponentServer.mount(pid) - - assert_receive :first - assert_receive :second - end - end -end diff --git a/test/term_ui/component_supervisor_test.exs b/test/term_ui/component_supervisor_test.exs deleted file mode 100644 index effca2bc..00000000 --- a/test/term_ui/component_supervisor_test.exs +++ /dev/null @@ -1,294 +0,0 @@ -defmodule TermUI.ComponentSupervisorTest do - use ExUnit.Case, async: false - - alias TermUI.Component.StatePersistence - alias TermUI.ComponentRegistry - alias TermUI.ComponentServer - alias TermUI.ComponentSupervisor - - # Simple test component - defmodule SimpleComponent do - use TermUI.StatefulComponent - - @impl true - def init(props) do - {:ok, %{value: props[:initial] || 0}} - end - - @impl true - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(_state, _area) do - text("") - end - end - - # Component that crashes on demand - defmodule CrashingComponent do - use TermUI.StatefulComponent - - @impl true - def init(props) do - {:ok, %{value: props[:initial] || 0, crash_on_event: props[:crash_on_event] || false}} - end - - @impl true - def handle_event(:crash, _state) do - raise "Intentional crash" - end - - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(_state, _area) do - text("") - end - end - - setup do - start_supervised!(StatePersistence) - start_supervised!(ComponentRegistry) - start_supervised!(ComponentSupervisor) - :ok - end - - describe "start_component/3" do - test "starts component successfully" do - {:ok, pid} = ComponentSupervisor.start_component(SimpleComponent, %{initial: 42}) - - assert is_pid(pid) - assert Process.alive?(pid) - end - - test "starts component with custom id" do - {:ok, pid} = ComponentSupervisor.start_component(SimpleComponent, %{}, id: :my_component) - - assert is_pid(pid) - end - - test "multiple components can be started" do - {:ok, pid1} = ComponentSupervisor.start_component(SimpleComponent, %{initial: 1}) - {:ok, pid2} = ComponentSupervisor.start_component(SimpleComponent, %{initial: 2}) - {:ok, pid3} = ComponentSupervisor.start_component(SimpleComponent, %{initial: 3}) - - assert pid1 != pid2 - assert pid2 != pid3 - assert ComponentSupervisor.count_children() == 3 - end - end - - describe "stop_component/1" do - test "stops component gracefully" do - {:ok, pid} = ComponentSupervisor.start_component(SimpleComponent, %{}) - - assert Process.alive?(pid) - assert :ok = ComponentSupervisor.stop_component(pid) - refute Process.alive?(pid) - end - - test "returns error for non-existent pid" do - fake_pid = spawn(fn -> :ok end) - Process.sleep(10) - - assert {:error, :not_found} = ComponentSupervisor.stop_component(fake_pid) - end - end - - describe "count_children/0" do - test "returns correct count" do - assert ComponentSupervisor.count_children() == 0 - - {:ok, _} = ComponentSupervisor.start_component(SimpleComponent, %{}) - assert ComponentSupervisor.count_children() == 1 - - {:ok, _} = ComponentSupervisor.start_component(SimpleComponent, %{}) - assert ComponentSupervisor.count_children() == 2 - end - end - - describe "which_children/0" do - test "returns all child pids" do - {:ok, pid1} = ComponentSupervisor.start_component(SimpleComponent, %{}) - {:ok, pid2} = ComponentSupervisor.start_component(SimpleComponent, %{}) - - children = ComponentSupervisor.which_children() - assert length(children) == 2 - assert pid1 in children - assert pid2 in children - end - - test "returns empty list when no children" do - assert ComponentSupervisor.which_children() == [] - end - end - - describe "restart strategies" do - test "starts with :transient restart by default" do - {:ok, _pid} = ComponentSupervisor.start_component(SimpleComponent, %{}, id: :transient_comp) - - # The component should restart on crash but not on normal exit - # This is verified by the supervisor behavior - assert ComponentSupervisor.count_children() == 1 - end - - test "starts with :permanent restart option" do - {:ok, _pid} = - ComponentSupervisor.start_component( - SimpleComponent, - %{}, - id: :permanent_comp, - restart: :permanent - ) - - assert ComponentSupervisor.count_children() == 1 - end - - test "starts with :temporary restart option" do - {:ok, _pid} = - ComponentSupervisor.start_component( - SimpleComponent, - %{}, - id: :temp_comp, - restart: :temporary - ) - - assert ComponentSupervisor.count_children() == 1 - end - end - - describe "shutdown options" do - test "uses custom shutdown timeout" do - {:ok, pid} = - ComponentSupervisor.start_component( - SimpleComponent, - %{}, - id: :custom_shutdown, - shutdown: 10_000 - ) - - assert Process.alive?(pid) - end - - test "accepts :brutal_kill shutdown" do - {:ok, pid} = - ComponentSupervisor.start_component( - SimpleComponent, - %{}, - id: :brutal_kill_comp, - shutdown: :brutal_kill - ) - - assert Process.alive?(pid) - end - end - - describe "recovery options" do - test "sets :last_state recovery by default" do - {:ok, _pid} = - ComponentSupervisor.start_component( - SimpleComponent, - %{initial: 42}, - id: :recovery_test - ) - - # Component is started with last_state recovery - assert ComponentSupervisor.count_children() == 1 - end - - test "accepts :reset recovery option" do - {:ok, _pid} = - ComponentSupervisor.start_component( - SimpleComponent, - %{}, - id: :reset_recovery, - recovery: :reset - ) - - assert ComponentSupervisor.count_children() == 1 - end - - test "accepts :last_props recovery option" do - {:ok, _pid} = - ComponentSupervisor.start_component( - SimpleComponent, - %{}, - id: :props_recovery, - recovery: :last_props - ) - - assert ComponentSupervisor.count_children() == 1 - end - end - - describe "stop_component/2" do - test "stops component by id" do - {:ok, pid} = ComponentSupervisor.start_component(SimpleComponent, %{}, id: :stop_by_id) - ComponentServer.mount(pid) - - assert :ok = ComponentSupervisor.stop_component(:stop_by_id) - refute Process.alive?(pid) - end - - test "returns error for non-existent id" do - assert {:error, :not_found} = ComponentSupervisor.stop_component(:nonexistent) - end - - test "cascade stops children first" do - {:ok, parent_pid} = ComponentSupervisor.start_component(SimpleComponent, %{}, id: :parent) - {:ok, child_pid} = ComponentSupervisor.start_component(SimpleComponent, %{}, id: :child) - ComponentServer.mount(parent_pid) - ComponentServer.mount(child_pid) - - # Set up parent-child relationship - ComponentRegistry.set_parent(:child, :parent) - - # Stop parent with cascade - :ok = ComponentSupervisor.stop_component(:parent, cascade: true) - - # Both should be stopped - refute Process.alive?(parent_pid) - refute Process.alive?(child_pid) - end - - test "cascade stops nested children" do - {:ok, p1} = ComponentSupervisor.start_component(SimpleComponent, %{}, id: :level1) - {:ok, p2} = ComponentSupervisor.start_component(SimpleComponent, %{}, id: :level2) - {:ok, p3} = ComponentSupervisor.start_component(SimpleComponent, %{}, id: :level3) - - ComponentServer.mount(p1) - ComponentServer.mount(p2) - ComponentServer.mount(p3) - - ComponentRegistry.set_parent(:level2, :level1) - ComponentRegistry.set_parent(:level3, :level2) - - :ok = ComponentSupervisor.stop_component(:level1, cascade: true) - - refute Process.alive?(p1) - refute Process.alive?(p2) - refute Process.alive?(p3) - end - end - - describe "restart limits" do - test "sets restart limits when specified" do - {:ok, _pid} = - ComponentSupervisor.start_component( - SimpleComponent, - %{}, - id: :limited_restart, - max_restarts: 5, - max_seconds: 10 - ) - - # Verify limits were set - StatePersistence.record_restart(:limited_restart) - assert StatePersistence.get_restart_count(:limited_restart) == 1 - end - end -end diff --git a/test/term_ui/component_test.exs b/test/term_ui/component_test.exs deleted file mode 100644 index fb5dca10..00000000 --- a/test/term_ui/component_test.exs +++ /dev/null @@ -1,199 +0,0 @@ -defmodule TermUI.ComponentTest do - use ExUnit.Case, async: true - - alias TermUI.Component.RenderNode - alias TermUI.Renderer.Style - - # Test component that implements only required callback - defmodule MinimalLabel do - use TermUI.Component - - @impl true - def render(props, _area) do - text(props[:text] || "") - end - end - - # Test component with all optional callbacks - defmodule FullLabel do - use TermUI.Component - - @impl true - def describe do - %{ - name: "FullLabel", - description: "A label with all callbacks", - version: "1.0.0" - } - end - - @impl true - def default_props do - %{ - text: "Default Text", - style: nil - } - end - - @impl true - def render(props, _area) do - merged = merge_props(props) - - if merged.style do - styled(text(merged.text), merged.style) - else - text(merged.text) - end - end - end - - # Test component using render tree builders - defmodule ComplexComponent do - use TermUI.Component - - @impl true - def render(props, area) do - box([ - text("Header"), - stack(:vertical, [ - text("Item 1"), - text("Item 2"), - text("Item 3") - ]), - text("Width: #{area.width}") - ]) - end - end - - describe "Component behaviour implementation" do - test "minimal component only needs render callback" do - area = %{x: 0, y: 0, width: 80, height: 24} - result = MinimalLabel.render(%{text: "Hello"}, area) - assert result.type == :text - assert result.content == "Hello" - end - - test "render receives correct props" do - area = %{x: 0, y: 0, width: 80, height: 24} - result = MinimalLabel.render(%{text: "Test"}, area) - assert result.content == "Test" - end - - test "render receives correct area" do - area = %{x: 5, y: 10, width: 40, height: 12} - result = ComplexComponent.render(%{}, area) - # Find the text node that shows width - width_text = - Enum.find(result.children, fn child -> - child.type == :text and String.contains?(child.content || "", "Width") - end) - - assert width_text.content == "Width: 40" - end - end - - describe "__using__ macro" do - test "injects default describe implementation" do - info = MinimalLabel.describe() - assert info.name == "TermUI.ComponentTest.MinimalLabel" - assert info.description == nil - assert info.version == nil - end - - test "injects default default_props implementation" do - props = MinimalLabel.default_props() - assert props == %{} - end - - test "injects merge_props helper" do - merged = MinimalLabel.merge_props(%{custom: "value"}) - assert merged == %{custom: "value"} - end - - test "imports RenderNode alias" do - # Verify that we can use text() directly in render - area = %{x: 0, y: 0, width: 80, height: 24} - result = MinimalLabel.render(%{text: "Test"}, area) - assert %RenderNode{} = result - end - end - - describe "optional callbacks" do - test "describe returns component metadata" do - info = FullLabel.describe() - assert info.name == "FullLabel" - assert info.description == "A label with all callbacks" - assert info.version == "1.0.0" - end - - test "default_props returns defaults" do - props = FullLabel.default_props() - assert props.text == "Default Text" - assert props.style == nil - end - - test "default_props merges with passed props" do - merged = FullLabel.merge_props(%{style: Style.new() |> Style.fg(:red)}) - assert merged.text == "Default Text" - assert merged.style.fg == :red - end - - test "passed props override defaults" do - merged = FullLabel.merge_props(%{text: "Custom"}) - assert merged.text == "Custom" - end - end - - describe "render tree output" do - test "can return RenderNode struct" do - area = %{x: 0, y: 0, width: 80, height: 24} - result = MinimalLabel.render(%{text: "Hello"}, area) - assert %RenderNode{} = result - end - - test "can build complex trees with builders" do - area = %{x: 0, y: 0, width: 80, height: 24} - result = ComplexComponent.render(%{}, area) - - assert result.type == :box - assert length(result.children) == 3 - - # Check nested stack - stack_child = Enum.at(result.children, 1) - assert stack_child.type == :stack - assert stack_child.direction == :vertical - assert length(stack_child.children) == 3 - end - - test "styled helper wraps content" do - area = %{x: 0, y: 0, width: 80, height: 24} - style = Style.new() |> Style.fg(:blue) - result = FullLabel.render(%{style: style}, area) - - # styled() wraps in a box - assert result.type == :box - assert result.style.fg == :blue - assert hd(result.children).content == "Default Text" - end - end - - describe "edge cases" do - test "empty props uses defaults" do - area = %{x: 0, y: 0, width: 80, height: 24} - result = MinimalLabel.render(%{}, area) - assert result.content == "" - end - - test "nil text prop uses empty string" do - area = %{x: 0, y: 0, width: 80, height: 24} - result = MinimalLabel.render(%{text: nil}, area) - assert result.content == "" - end - - test "zero size area" do - area = %{x: 0, y: 0, width: 0, height: 0} - result = ComplexComponent.render(%{}, area) - assert result.type == :box - end - end -end diff --git a/test/term_ui/config_test.exs b/test/term_ui/config_test.exs deleted file mode 100644 index 20600f53..00000000 --- a/test/term_ui/config_test.exs +++ /dev/null @@ -1,258 +0,0 @@ -defmodule TermUI.ConfigTest do - use ExUnit.Case, async: true - - alias TermUI.Config - - # Clean up application environment between tests - setup do - # Store original values - original_backend = Application.get_env(:term_ui, :backend) - original_color_mode = Application.get_env(:term_ui, :color_mode) - original_character_set = Application.get_env(:term_ui, :character_set) - original_render_interval = Application.get_env(:term_ui, :render_interval) - - on_exit(fn -> - # Restore original values or erase - if original_backend do - Application.put_env(:term_ui, :backend, original_backend) - else - Application.delete_env(:term_ui, :backend) - end - - if original_color_mode do - Application.put_env(:term_ui, :color_mode, original_color_mode) - else - Application.delete_env(:term_ui, :color_mode) - end - - if original_character_set do - Application.put_env(:term_ui, :character_set, original_character_set) - else - Application.delete_env(:term_ui, :character_set) - end - - if original_render_interval do - Application.put_env(:term_ui, :render_interval, original_render_interval) - else - Application.delete_env(:term_ui, :render_interval) - end - end) - - :ok - end - - describe "get/2" do - test "returns default for backend when not configured" do - Application.delete_env(:term_ui, :backend) - assert Config.get(:backend) == :auto - end - - test "returns default for color_mode when not configured" do - Application.delete_env(:term_ui, :color_mode) - assert Config.get(:color_mode) == :auto - end - - test "returns default for character_set when not configured" do - Application.delete_env(:term_ui, :character_set) - assert Config.get(:character_set) == :auto - end - - test "returns default for render_interval when not configured" do - Application.delete_env(:term_ui, :render_interval) - assert Config.get(:render_interval) == 16 - end - - test "returns configured value for backend" do - Application.put_env(:term_ui, :backend, :raw) - assert Config.get(:backend) == :raw - end - - test "returns configured value for color_mode" do - Application.put_env(:term_ui, :color_mode, :true_color) - assert Config.get(:color_mode) == :true_color - end - - test "returns configured value for character_set" do - Application.put_env(:term_ui, :character_set, :ascii) - assert Config.get(:character_set) == :ascii - end - - test "returns configured value for render_interval" do - Application.put_env(:term_ui, :render_interval, 33) - assert Config.get(:render_interval) == 33 - end - - test "returns custom default when provided" do - Application.delete_env(:term_ui, :backend) - assert Config.get(:backend, :custom) == :custom - end - - test "custom default is not used when value is configured" do - Application.put_env(:term_ui, :backend, :tty) - assert Config.get(:backend, :custom) == :tty - end - - test "returns any key from application env" do - Application.put_env(:term_ui, :custom_key, :custom_value) - assert Config.get(:custom_key) == :custom_value - end - end - - describe "all/0" do - test "returns all configuration values as keyword list" do - Application.put_env(:term_ui, :backend, :tty) - Application.put_env(:term_ui, :color_mode, :color_256) - Application.put_env(:term_ui, :character_set, :ascii) - Application.put_env(:term_ui, :render_interval, 60) - - all = Config.all() - - assert all[:backend] == :tty - assert all[:color_mode] == :color_256 - assert all[:character_set] == :ascii - assert all[:render_interval] == 60 - end - - test "returns defaults when nothing configured" do - Application.delete_env(:term_ui, :backend) - Application.delete_env(:term_ui, :color_mode) - Application.delete_env(:term_ui, :character_set) - Application.delete_env(:term_ui, :render_interval) - - all = Config.all() - - assert all[:backend] == :auto - assert all[:color_mode] == :auto - assert all[:character_set] == :auto - assert all[:render_interval] == 16 - end - - test "contains all expected keys" do - all = Config.all() - - keys = Keyword.keys(all) - assert :backend in keys - assert :color_mode in keys - assert :character_set in keys - assert :render_interval in keys - end - end - - describe "merge_options/2" do - test "returns defaults when no config and no options" do - Application.delete_env(:term_ui, :backend) - Application.delete_env(:term_ui, :color_mode) - - merged = Config.merge_options([]) - - assert merged[:backend] == :auto - assert merged[:color_mode] == :auto - end - - test "config values are used when no runtime option provided" do - Application.put_env(:term_ui, :backend, :tty) - Application.put_env(:term_ui, :render_interval, 60) - - merged = Config.merge_options([]) - - assert merged[:backend] == :tty - assert merged[:render_interval] == 60 - end - - test "runtime options override config values" do - Application.put_env(:term_ui, :backend, :tty) - - merged = Config.merge_options(backend: :raw) - - assert merged[:backend] == :raw - end - - test "runtime options override for multiple keys" do - Application.put_env(:term_ui, :backend, :tty) - Application.put_env(:term_ui, :render_interval, 60) - - merged = Config.merge_options(backend: :raw, render_interval: 33) - - assert merged[:backend] == :raw - assert merged[:render_interval] == 33 - end - - test "runtime options and config can coexist" do - Application.put_env(:term_ui, :backend, :tty) - # render_interval not configured - - merged = Config.merge_options(backend: :raw, render_interval: 33) - - # Runtime override - assert merged[:backend] == :raw - # Runtime option - assert merged[:render_interval] == 33 - # Default - assert merged[:color_mode] == :auto - end - - test "arbitrary options are passed through" do - merged = Config.merge_options(custom_key: :custom_value) - - assert merged[:custom_key] == :custom_value - end - - test "does not modify original options list" do - Application.put_env(:term_ui, :backend, :tty) - original_opts = [backend: :raw] - - merged = Config.merge_options(original_opts) - - # Should use runtime option - assert merged[:backend] == :raw - # Original unchanged - assert original_opts[:backend] == :raw - end - - test "handles nil values in config" do - Application.put_env(:term_ui, :backend, nil) - - merged = Config.merge_options([]) - - # nil in config should be treated as "not set", so default applies - assert merged[:backend] == nil - end - - test "skip_terminal option is preserved" do - merged = Config.merge_options(skip_terminal: true) - - assert merged[:skip_terminal] == true - end - - test "use_input_handler option is preserved" do - merged = Config.merge_options(use_input_handler: true) - - assert merged[:use_input_handler] == true - end - end - - describe "defaults/0" do - test "returns default options without reading config" do - Application.put_env(:term_ui, :backend, :tty) - Application.put_env(:term_ui, :render_interval, 60) - - defaults = Config.defaults() - - # Defaults should ignore application config - assert defaults[:backend] == :auto - assert defaults[:render_interval] == 16 - assert defaults[:color_mode] == :auto - assert defaults[:character_set] == :auto - end - - test "contains all expected default keys" do - defaults = Config.defaults() - - keys = Keyword.keys(defaults) - assert :backend in keys - assert :color_mode in keys - assert :character_set in keys - assert :render_interval in keys - end - end -end diff --git a/test/term_ui/container_test.exs b/test/term_ui/container_test.exs deleted file mode 100644 index febfcf3e..00000000 --- a/test/term_ui/container_test.exs +++ /dev/null @@ -1,417 +0,0 @@ -defmodule TermUI.ContainerTest do - use ExUnit.Case, async: true - - alias TermUI.Component.RenderNode - - # Simple panel container - defmodule Panel do - use TermUI.Container - - @impl true - def init(props) do - {:ok, %{title: props[:title] || "Panel"}} - end - - @impl true - def children(_state) do - [ - {TermUI.ContainerTest.FakeLabel, %{text: "Header"}, :header}, - {TermUI.ContainerTest.FakeLabel, %{text: "Content"}, :content} - ] - end - - @impl true - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, _area) do - text(state.title) - end - end - - # Container with custom layout - defmodule HorizontalLayout do - use TermUI.Container - - @impl true - def init(_props) do - {:ok, %{}} - end - - @impl true - def children(_state) do - [ - {TermUI.ContainerTest.FakeLabel, %{text: "Left"}, :left}, - {TermUI.ContainerTest.FakeLabel, %{text: "Right"}, :right} - ] - end - - @impl true - def layout(children, _state, area) do - half_width = div(area.width, 2) - - children - |> Enum.with_index() - |> Enum.map(fn {child, i} -> - child_area = %{ - x: area.x + i * half_width, - y: area.y, - width: half_width, - height: area.height - } - - {child, child_area} - end) - end - - @impl true - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(_state, _area) do - empty() - end - end - - # Container with event routing - defmodule RoutingContainer do - use TermUI.Container - - @impl true - def init(props) do - {:ok, %{focused: props[:focused] || :first}} - end - - @impl true - def children(_state) do - [ - {TermUI.ContainerTest.FakeLabel, %{}, :first}, - {TermUI.ContainerTest.FakeLabel, %{}, :second} - ] - end - - @impl true - def handle_event({:focus, id}, state) do - {:ok, %{state | focused: id}} - end - - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def route_event(_event, state) do - {:child, state.focused} - end - - @impl true - def render(_state, _area) do - empty() - end - end - - # Container with child messages - defmodule MessageContainer do - use TermUI.Container - - @impl true - def init(_props) do - {:ok, %{messages: []}} - end - - @impl true - def children(_state) do - [{TermUI.ContainerTest.FakeLabel, %{}, :child}] - end - - @impl true - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def handle_child_message(child_id, message, state) do - {:ok, %{state | messages: [{child_id, message} | state.messages]}} - end - - @impl true - def render(_state, _area) do - empty() - end - end - - # Fake component for testing - defmodule FakeLabel do - use TermUI.Component - - @impl true - def render(props, _area) do - text(props[:text] || "") - end - end - - # Container with no ID children - defmodule NoIdContainer do - use TermUI.Container - - @impl true - def init(_props) do - {:ok, %{}} - end - - @impl true - def children(_state) do - [ - {TermUI.ContainerTest.FakeLabel, %{text: "No ID 1"}}, - {TermUI.ContainerTest.FakeLabel, %{text: "No ID 2"}} - ] - end - - @impl true - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(_state, _area) do - empty() - end - end - - describe "children/1" do - test "returns list of child specs" do - {:ok, state} = Panel.init(%{}) - children = Panel.children(state) - - assert length(children) == 2 - {mod1, props1, id1} = Enum.at(children, 0) - {mod2, props2, id2} = Enum.at(children, 1) - assert mod1 == __MODULE__.FakeLabel - assert props1.text == "Header" - assert id1 == :header - assert mod2 == __MODULE__.FakeLabel - assert props2.text == "Content" - assert id2 == :content - end - - test "child_spec with explicit id" do - {:ok, state} = Panel.init(%{}) - [{module, props, id} | _] = Panel.children(state) - - assert module == __MODULE__.FakeLabel - assert props.text == "Header" - assert id == :header - end - - test "child_spec without id" do - {:ok, state} = NoIdContainer.init(%{}) - children = NoIdContainer.children(state) - - [{mod1, props1}, {mod2, props2}] = children - assert mod1 == __MODULE__.FakeLabel - assert props1.text == "No ID 1" - assert mod2 == __MODULE__.FakeLabel - assert props2.text == "No ID 2" - end - end - - describe "layout/3" do - test "default layout stacks vertically" do - {:ok, state} = Panel.init(%{}) - children = Panel.children(state) - area = %{x: 0, y: 0, width: 80, height: 24} - - layout = Panel.layout(children, state, area) - - assert length(layout) == 2 - {_child1, area1} = Enum.at(layout, 0) - {_child2, area2} = Enum.at(layout, 1) - - assert area1.y == 0 - assert area1.height == 12 - assert area2.y == 12 - assert area2.height == 12 - end - - test "custom horizontal layout" do - {:ok, state} = HorizontalLayout.init(%{}) - children = HorizontalLayout.children(state) - area = %{x: 0, y: 0, width: 80, height: 24} - - layout = HorizontalLayout.layout(children, state, area) - - {_child1, area1} = Enum.at(layout, 0) - {_child2, area2} = Enum.at(layout, 1) - - assert area1.x == 0 - assert area1.width == 40 - assert area2.x == 40 - assert area2.width == 40 - end - - test "layout preserves child specs" do - {:ok, state} = Panel.init(%{}) - children = Panel.children(state) - area = %{x: 0, y: 0, width: 80, height: 24} - - layout = Panel.layout(children, state, area) - - {{module, props, id}, _area} = Enum.at(layout, 0) - assert module == __MODULE__.FakeLabel - assert props == %{text: "Header"} - assert id == :header - end - - test "empty children list" do - # Custom container with no children - defmodule EmptyContainer do - use TermUI.Container - - def init(_props), do: {:ok, %{}} - def children(_state), do: [] - def handle_event(_event, state), do: {:ok, state} - def render(_state, _area), do: empty() - end - - {:ok, state} = EmptyContainer.init(%{}) - children = EmptyContainer.children(state) - area = %{x: 0, y: 0, width: 80, height: 24} - - layout = EmptyContainer.layout(children, state, area) - assert layout == [] - end - end - - describe "route_event/2" do - test "default routes to self" do - {:ok, state} = Panel.init(%{}) - target = Panel.route_event(:some_event, state) - assert target == :self - end - - test "custom routing to child" do - {:ok, state} = RoutingContainer.init(%{focused: :second}) - target = RoutingContainer.route_event(:key_press, state) - assert target == {:child, :second} - end - - test "routing changes with state" do - {:ok, state} = RoutingContainer.init(%{focused: :first}) - assert RoutingContainer.route_event(:event, state) == {:child, :first} - - {:ok, state} = RoutingContainer.handle_event({:focus, :second}, state) - assert RoutingContainer.route_event(:event, state) == {:child, :second} - end - end - - describe "handle_child_message/3" do - test "receives messages from children" do - {:ok, state} = MessageContainer.init(%{}) - {:ok, state} = MessageContainer.handle_child_message(:child, :submitted, state) - - assert state.messages == [{:child, :submitted}] - end - - test "accumulates messages" do - {:ok, state} = MessageContainer.init(%{}) - {:ok, state} = MessageContainer.handle_child_message(:child, :first, state) - {:ok, state} = MessageContainer.handle_child_message(:child, :second, state) - - assert length(state.messages) == 2 - end - - test "default does nothing" do - {:ok, state} = Panel.init(%{}) - {:ok, new_state} = Panel.handle_child_message(:header, :message, state) - assert new_state == state - end - end - - describe "helper functions" do - test "normalize_child_spec adds id to 2-tuple" do - {module, props, id} = Panel.normalize_child_spec({FakeLabel, %{text: "Test"}}) - assert module == FakeLabel - assert props.text == "Test" - assert is_reference(id) - end - - test "normalize_child_spec preserves 3-tuple" do - {module, props, id} = Panel.normalize_child_spec({FakeLabel, %{text: "Test"}, :my_id}) - assert module == FakeLabel - assert props.text == "Test" - assert id == :my_id - end - - test "child_id extracts id" do - assert Panel.child_id({FakeLabel, %{}, :test_id}) == :test_id - assert Panel.child_id({FakeLabel, %{}}) == nil - end - - test "child_module extracts module" do - assert Panel.child_module({FakeLabel, %{}, :id}) == FakeLabel - assert Panel.child_module({FakeLabel, %{}}) == FakeLabel - end - - test "child_props extracts props" do - assert Panel.child_props({FakeLabel, %{text: "Hi"}, :id}) == %{text: "Hi"} - assert Panel.child_props({FakeLabel, %{text: "Hi"}}) == %{text: "Hi"} - end - end - - describe "__using__ macro" do - test "provides default implementations" do - {:ok, state} = Panel.init(%{}) - - # Default terminate - assert Panel.terminate(:normal, state) == :ok - - # Default handle_info - {:ok, new_state} = Panel.handle_info(:message, state) - assert new_state == state - - # Default handle_call - {:reply, :ok, new_state} = Panel.handle_call(:request, self(), state) - assert new_state == state - end - - test "imports helpers" do - {:ok, state} = Panel.init(%{}) - area = %{x: 0, y: 0, width: 80, height: 24} - result = Panel.render(state, area) - assert %RenderNode{} = result - end - end - - describe "state management" do - test "init receives props" do - {:ok, state} = Panel.init(%{title: "My Panel"}) - assert state.title == "My Panel" - end - - test "handle_event updates state" do - {:ok, state} = RoutingContainer.init(%{focused: :first}) - {:ok, state} = RoutingContainer.handle_event({:focus, :second}, state) - assert state.focused == :second - end - end - - describe "render/2" do - test "can return content" do - {:ok, state} = Panel.init(%{title: "Test"}) - area = %{x: 0, y: 0, width: 80, height: 24} - result = Panel.render(state, area) - assert result.content == "Test" - end - - test "can return empty" do - {:ok, state} = HorizontalLayout.init(%{}) - area = %{x: 0, y: 0, width: 80, height: 24} - result = HorizontalLayout.render(state, area) - assert result.type == :empty - end - end -end diff --git a/test/term_ui/dev/dev_mode_test.exs b/test/term_ui/dev/dev_mode_test.exs deleted file mode 100644 index a97e25ac..00000000 --- a/test/term_ui/dev/dev_mode_test.exs +++ /dev/null @@ -1,211 +0,0 @@ -defmodule TermUI.Dev.DevModeTest do - use ExUnit.Case, async: false - - alias TermUI.Dev.DevMode - - setup do - # Start DevMode server for each test - start_supervised!(DevMode) - :ok - end - - describe "enable/disable" do - test "starts disabled" do - refute DevMode.enabled?() - end - - test "can enable development mode" do - :ok = DevMode.enable() - assert DevMode.enabled?() - end - - test "can disable development mode" do - DevMode.enable() - :ok = DevMode.disable() - refute DevMode.enabled?() - end - end - - describe "UI inspector" do - test "starts disabled" do - refute DevMode.ui_inspector_enabled?() - end - - test "can toggle UI inspector" do - assert DevMode.toggle_ui_inspector() == true - assert DevMode.ui_inspector_enabled?() - - assert DevMode.toggle_ui_inspector() == false - refute DevMode.ui_inspector_enabled?() - end - end - - describe "state inspector" do - test "starts disabled" do - refute DevMode.state_inspector_enabled?() - end - - test "can toggle state inspector" do - assert DevMode.toggle_state_inspector() == true - assert DevMode.state_inspector_enabled?() - - assert DevMode.toggle_state_inspector() == false - refute DevMode.state_inspector_enabled?() - end - end - - describe "performance monitor" do - test "starts disabled" do - refute DevMode.perf_monitor_enabled?() - end - - test "can toggle performance monitor" do - assert DevMode.toggle_perf_monitor() == true - assert DevMode.perf_monitor_enabled?() - - assert DevMode.toggle_perf_monitor() == false - refute DevMode.perf_monitor_enabled?() - end - end - - describe "component registration" do - test "can register a component" do - bounds = %{x: 0, y: 0, width: 10, height: 5} - :ok = DevMode.register_component(:test_component, TestModule, %{foo: "bar"}, bounds) - - components = DevMode.get_components() - assert Map.has_key?(components, :test_component) - assert components[:test_component].module == TestModule - assert components[:test_component].state == %{foo: "bar"} - end - - test "can unregister a component" do - bounds = %{x: 0, y: 0, width: 10, height: 5} - DevMode.register_component(:test_component, TestModule, %{}, bounds) - :ok = DevMode.unregister_component(:test_component) - - components = DevMode.get_components() - refute Map.has_key?(components, :test_component) - end - - test "can update component state" do - bounds = %{x: 0, y: 0, width: 10, height: 5} - DevMode.register_component(:test_component, TestModule, %{count: 0}, bounds) - :ok = DevMode.update_component_state(:test_component, %{count: 42}) - - components = DevMode.get_components() - assert components[:test_component].state == %{count: 42} - end - - test "can record render time" do - bounds = %{x: 0, y: 0, width: 10, height: 5} - DevMode.register_component(:test_component, TestModule, %{}, bounds) - :ok = DevMode.record_render_time(:test_component, 1500) - - components = DevMode.get_components() - assert components[:test_component].render_time == 1500 - end - end - - describe "component selection" do - test "starts with no selection" do - assert DevMode.get_selected_component() == nil - end - - test "can select a component" do - bounds = %{x: 0, y: 0, width: 10, height: 5} - DevMode.register_component(:test_component, TestModule, %{}, bounds) - :ok = DevMode.select_component(:test_component) - - assert DevMode.get_selected_component() == :test_component - end - - test "unregistering selected component clears selection" do - bounds = %{x: 0, y: 0, width: 10, height: 5} - DevMode.register_component(:test_component, TestModule, %{}, bounds) - DevMode.select_component(:test_component) - DevMode.unregister_component(:test_component) - - assert DevMode.get_selected_component() == nil - end - end - - describe "metrics" do - test "starts with zero metrics" do - metrics = DevMode.get_metrics() - assert metrics.fps == 0.0 - assert metrics.frame_times == [] - end - - test "record_frame updates metrics" do - DevMode.record_frame(16_000) - DevMode.record_frame(17_000) - DevMode.record_frame(15_000) - - metrics = DevMode.get_metrics() - assert length(metrics.frame_times) == 3 - assert metrics.fps > 0 - assert metrics.memory > 0 - assert metrics.process_count > 0 - end - - test "FPS calculation from frame times" do - # Record 60 frames at 16.67ms each (60 FPS) - for _ <- 1..60 do - DevMode.record_frame(16_667) - end - - metrics = DevMode.get_metrics() - # Should be approximately 60 FPS - assert_in_delta metrics.fps, 60.0, 1.0 - end - end - - describe "keyboard shortcuts" do - test "handles shortcuts when enabled" do - DevMode.enable() - - # Toggle UI inspector with Ctrl+Shift+I - assert DevMode.handle_shortcut(:i, [:ctrl, :shift]) == :handled - assert DevMode.ui_inspector_enabled?() - - # Toggle state inspector with Ctrl+Shift+S - assert DevMode.handle_shortcut(:s, [:ctrl, :shift]) == :handled - assert DevMode.state_inspector_enabled?() - - # Toggle perf monitor with Ctrl+Shift+P - assert DevMode.handle_shortcut(:p, [:ctrl, :shift]) == :handled - assert DevMode.perf_monitor_enabled?() - end - - test "ignores shortcuts when disabled" do - # DevMode not enabled - assert DevMode.handle_shortcut(:i, [:ctrl, :shift]) == :not_handled - refute DevMode.ui_inspector_enabled?() - end - - test "ignores non-dev shortcuts" do - DevMode.enable() - assert DevMode.handle_shortcut(:x, [:ctrl, :shift]) == :not_handled - end - - test "requires both ctrl and shift modifiers" do - DevMode.enable() - assert DevMode.handle_shortcut(:i, [:ctrl]) == :not_handled - assert DevMode.handle_shortcut(:i, [:shift]) == :not_handled - end - end - - describe "get_state" do - test "returns full state" do - DevMode.enable() - DevMode.toggle_ui_inspector() - - state = DevMode.get_state() - assert state.enabled == true - assert state.ui_inspector == true - assert is_map(state.components) - assert is_map(state.metrics) - end - end -end diff --git a/test/term_ui/dev/hot_reload_test.exs b/test/term_ui/dev/hot_reload_test.exs deleted file mode 100644 index dec5b243..00000000 --- a/test/term_ui/dev/hot_reload_test.exs +++ /dev/null @@ -1,69 +0,0 @@ -defmodule TermUI.Dev.HotReloadTest do - use ExUnit.Case, async: false - - alias TermUI.Dev.HotReload - - setup do - # Start HotReload server for each test - start_supervised!(HotReload) - :ok - end - - describe "start/stop" do - test "starts disabled" do - refute HotReload.running?() - end - - test "can start and stop hot reload" do - :ok = HotReload.start() - assert HotReload.running?() - :ok = HotReload.stop() - refute HotReload.running?() - end - end - - describe "reload_module/1" do - # Reloading standard library modules can cause issues - @tag :skip - test "reloads an existing module" do - result = HotReload.reload_module(Enum) - assert result == :ok - end - - test "returns error for non-existent module" do - result = HotReload.reload_module(NonExistentModule12345) - assert {:error, _} = result - end - end - - describe "on_reload callback" do - test "can set reload callback" do - # Verify callback can be set without error - HotReload.on_reload(fn _module -> :ok end) - assert true - end - end - - describe "get_recent_reloads/0" do - test "starts empty" do - assert HotReload.get_recent_reloads() == [] - end - end - - describe "get_module_source/1" do - test "returns source path for compiled module" do - source = HotReload.get_module_source(Enum) - assert is_nil(source) or is_binary(source) - end - - test "returns nil for unknown module" do - assert HotReload.get_module_source(UnknownModule123) == nil - end - end - - describe "can_reload?/1" do - test "returns false for unknown modules" do - refute HotReload.can_reload?(UnknownModule123) - end - end -end diff --git a/test/term_ui/dev/perf_monitor_test.exs b/test/term_ui/dev/perf_monitor_test.exs deleted file mode 100644 index 4c3fbb46..00000000 --- a/test/term_ui/dev/perf_monitor_test.exs +++ /dev/null @@ -1,123 +0,0 @@ -defmodule TermUI.Dev.PerfMonitorTest do - use ExUnit.Case, async: true - - alias TermUI.Dev.PerfMonitor - - describe "render/2" do - test "renders performance panel" do - metrics = %{ - fps: 60.0, - frame_times: [16_000, 17_000, 15_000], - memory: 100_000_000, - process_count: 200 - } - - result = PerfMonitor.render(metrics, %{width: 80, height: 24}) - - assert result.type == :positioned - assert result.z == 195 - end - - test "renders with empty frame times" do - metrics = %{ - fps: 0.0, - frame_times: [], - memory: 50_000_000, - process_count: 100 - } - - result = PerfMonitor.render(metrics, %{width: 80, height: 24}) - assert result.type == :positioned - end - end - - describe "format_bytes/1" do - test "formats bytes" do - assert PerfMonitor.format_bytes(500) == "500 B" - end - - test "formats kilobytes" do - assert PerfMonitor.format_bytes(1024) == "1.0 KB" - assert PerfMonitor.format_bytes(2048) == "2.0 KB" - end - - test "formats megabytes" do - assert PerfMonitor.format_bytes(1024 * 1024) == "1.0 MB" - assert PerfMonitor.format_bytes(100 * 1024 * 1024) == "100.0 MB" - end - - test "formats gigabytes" do - assert PerfMonitor.format_bytes(2 * 1024 * 1024 * 1024) == "2.0 GB" - end - end - - describe "format_time/1" do - test "formats microseconds" do - assert PerfMonitor.format_time(500) == "500μs" - end - - test "formats milliseconds" do - assert PerfMonitor.format_time(1500) == "1.5ms" - assert PerfMonitor.format_time(500_000) == "500.0ms" - end - - test "formats seconds" do - assert PerfMonitor.format_time(2_500_000) == "2.5s" - end - end - - describe "get_memory_breakdown/0" do - test "returns memory breakdown" do - breakdown = PerfMonitor.get_memory_breakdown() - - assert is_map(breakdown) - assert is_integer(breakdown.total) - assert is_integer(breakdown.processes) - assert is_integer(breakdown.atom) - assert is_integer(breakdown.binary) - assert is_integer(breakdown.code) - assert is_integer(breakdown.ets) - end - end - - describe "get_message_queue_length/1" do - test "returns queue length for process" do - length = PerfMonitor.get_message_queue_length(self()) - assert is_integer(length) - assert length >= 0 - end - end - - describe "get_reductions/1" do - test "returns reductions for process" do - reductions = PerfMonitor.get_reductions(self()) - assert is_integer(reductions) - assert reductions > 0 - end - end - - describe "values_to_sparkline/3" do - test "converts values to sparkline characters" do - values = [0, 25, 50, 75, 100] - result = PerfMonitor.values_to_sparkline(values, 0, 100) - - assert is_binary(result) - assert String.length(result) == 5 - end - - test "handles empty values" do - result = PerfMonitor.values_to_sparkline([], 0, 100) - assert result == "" - end - - test "maps min to lowest bar" do - result = PerfMonitor.values_to_sparkline([0], 0, 100) - assert result == "▁" - end - - test "maps max to highest bar" do - result = PerfMonitor.values_to_sparkline([100], 0, 100) - assert result == "█" - end - end -end diff --git a/test/term_ui/dev/state_inspector_test.exs b/test/term_ui/dev/state_inspector_test.exs deleted file mode 100644 index e29cb5fd..00000000 --- a/test/term_ui/dev/state_inspector_test.exs +++ /dev/null @@ -1,135 +0,0 @@ -defmodule TermUI.Dev.StateInspectorTest do - use ExUnit.Case, async: true - - alias TermUI.Dev.StateInspector - - describe "render/2" do - test "renders empty panel when nil" do - result = StateInspector.render(nil, %{width: 80, height: 24}) - assert result.type == :empty - end - - test "renders state panel for component" do - component_info = %{ - module: TestModule, - state: %{count: 42} - } - - result = StateInspector.render(component_info, %{width: 80, height: 24}) - - assert result.type == :positioned - assert result.z == 190 - end - end - - describe "render_state_tree/2" do - test "renders empty map" do - result = StateInspector.render_state_tree(%{}, 0) - assert result == ["%{}"] - end - - test "renders simple map" do - result = StateInspector.render_state_tree(%{name: "test", count: 42}, 0) - - assert Enum.any?(result, &String.contains?(&1, "name")) - assert Enum.any?(result, &String.contains?(&1, "test")) - assert Enum.any?(result, &String.contains?(&1, "count")) - assert Enum.any?(result, &String.contains?(&1, "42")) - end - - test "renders nested map" do - state = %{ - user: %{ - name: "Alice", - age: 30 - } - } - - result = StateInspector.render_state_tree(state, 0) - - assert Enum.any?(result, &String.contains?(&1, "user")) - assert Enum.any?(result, &String.contains?(&1, "name")) - assert Enum.any?(result, &String.contains?(&1, "Alice")) - end - - test "renders empty list" do - result = StateInspector.render_state_tree([], 0) - assert result == ["[]"] - end - - test "renders list with items" do - result = StateInspector.render_state_tree([1, 2, 3], 0) - - assert Enum.any?(result, &String.contains?(&1, "[0]")) - assert Enum.any?(result, &String.contains?(&1, "1")) - end - - test "truncates long lists" do - long_list = Enum.to_list(1..20) - result = StateInspector.render_state_tree(long_list, 0) - - assert Enum.any?(result, &String.contains?(&1, "... (15 more)")) - end - - test "renders tuple" do - result = StateInspector.render_state_tree({:ok, "value"}, 0) - - assert Enum.any?(result, &String.contains?(&1, ":ok")) - end - - test "renders simple values" do - assert StateInspector.render_state_tree(:atom, 0) == [":atom"] - assert StateInspector.render_state_tree(42, 0) == ["42"] - assert StateInspector.render_state_tree("string", 0) == ["\"string\""] - assert StateInspector.render_state_tree(nil, 0) == ["nil"] - assert StateInspector.render_state_tree(true, 0) == ["true"] - end - - test "adds indentation for depth" do - state = %{nested: %{value: 1}} - result = StateInspector.render_state_tree(state, 1) - - # Should have extra indentation - assert Enum.any?(result, fn line -> String.starts_with?(line, " ") end) - end - end - - describe "diff_states/2" do - test "returns empty for identical states" do - state = %{a: 1, b: 2} - assert StateInspector.diff_states(state, state) == [] - end - - test "detects changed values" do - old = %{a: 1, b: 2} - new = %{a: 1, b: 3} - - paths = StateInspector.diff_states(old, new) - assert [:b] in paths - end - - test "detects nested changes" do - old = %{user: %{name: "Alice", age: 30}} - new = %{user: %{name: "Alice", age: 31}} - - paths = StateInspector.diff_states(old, new) - assert [:user, :age] in paths - end - - test "detects added keys" do - old = %{a: 1} - new = %{a: 1, b: 2} - - paths = StateInspector.diff_states(old, new) - assert [:b] in paths - end - - test "detects removed keys" do - old = %{a: 1, b: 2} - new = %{a: 1} - - paths = StateInspector.diff_states(old, new) - assert [:b] in paths - end - end -end diff --git a/test/term_ui/dev/ui_inspector_test.exs b/test/term_ui/dev/ui_inspector_test.exs deleted file mode 100644 index 1f5b169a..00000000 --- a/test/term_ui/dev/ui_inspector_test.exs +++ /dev/null @@ -1,151 +0,0 @@ -defmodule TermUI.Dev.UIInspectorTest do - use ExUnit.Case, async: true - - alias TermUI.Dev.UIInspector - - describe "render/3" do - test "renders overlay for components" do - components = %{ - :comp1 => %{ - module: MyModule, - state: %{}, - render_time: 1000, - bounds: %{x: 0, y: 0, width: 20, height: 10} - } - } - - result = UIInspector.render(components, nil, %{width: 80, height: 24}) - - assert result.type == :overlay - assert result.z == 200 - end - - test "renders empty overlay for no components" do - result = UIInspector.render(%{}, nil, %{width: 80, height: 24}) - - assert result.type == :overlay - end - end - - describe "render_component_boundary/3" do - test "renders boundary with label" do - info = %{ - module: MyApp.TestComponent, - state: %{}, - render_time: 1500, - bounds: %{x: 5, y: 5, width: 30, height: 10} - } - - result = UIInspector.render_component_boundary(:test, info, false) - - assert result.type == :positioned - assert result.x == 5 - assert result.y == 5 - end - - test "uses different style when selected" do - info = %{ - module: TestComponent, - state: %{}, - render_time: 100, - bounds: %{x: 0, y: 0, width: 20, height: 5} - } - - result = UIInspector.render_component_boundary(:test, info, true) - assert result.style == :selected - - result = UIInspector.render_component_boundary(:test, info, false) - assert result.style == :normal - end - end - - describe "create_labeled_border/3" do - test "creates border with centered label" do - result = UIInspector.create_labeled_border("Test", 20, "─") - - assert String.length(result) == 20 - assert String.contains?(result, "[ Test ]") - end - - test "truncates long labels" do - result = UIInspector.create_labeled_border("VeryLongLabelThatDoesNotFit", 15, "─") - - assert String.length(result) == 15 - end - end - - describe "get_module_name/1" do - test "extracts short name from module atom" do - assert UIInspector.get_module_name(MyApp.Widgets.Button) == "Button" - assert UIInspector.get_module_name(SimpleModule) == "SimpleModule" - end - - test "returns Unknown for non-atom" do - assert UIInspector.get_module_name("not an atom") == "Unknown" - end - end - - describe "format_render_time/1" do - test "formats microseconds" do - assert UIInspector.format_render_time(500) == "500μs" - end - - test "formats milliseconds" do - assert UIInspector.format_render_time(1500) == "1.5ms" - assert UIInspector.format_render_time(500_000) == "500.0ms" - end - - test "formats seconds" do - assert UIInspector.format_render_time(1_500_000) == "1.5s" - end - end - - describe "find_component_at/3" do - test "finds component at position" do - components = %{ - :comp1 => %{ - bounds: %{x: 0, y: 0, width: 20, height: 10} - }, - :comp2 => %{ - bounds: %{x: 30, y: 30, width: 10, height: 10} - } - } - - assert UIInspector.find_component_at(components, 10, 5) == :comp1 - assert UIInspector.find_component_at(components, 35, 35) == :comp2 - assert UIInspector.find_component_at(components, 50, 50) == nil - end - - test "prefers smaller component when overlapping" do - components = %{ - :parent => %{ - bounds: %{x: 0, y: 0, width: 50, height: 50} - }, - :child => %{ - bounds: %{x: 10, y: 10, width: 10, height: 10} - } - } - - # Click inside child should find child (smaller) - assert UIInspector.find_component_at(components, 15, 15) == :child - end - end - - describe "get_state_summary/1" do - test "summarizes map state" do - assert UIInspector.get_state_summary(%{a: 1, b: 2}) =~ "a" - assert UIInspector.get_state_summary(%{a: 1, b: 2}) =~ "b" - end - - test "truncates large maps" do - state = %{a: 1, b: 2, c: 3, d: 4, e: 5} - summary = UIInspector.get_state_summary(state) - assert summary =~ "..." - assert summary =~ "+2" - end - - test "summarizes list state" do - assert UIInspector.get_state_summary([1, 2, 3]) == "List[3]" - end - end -end diff --git a/test/term_ui/elm_test.exs b/test/term_ui/elm_test.exs deleted file mode 100644 index 2a7089c0..00000000 --- a/test/term_ui/elm_test.exs +++ /dev/null @@ -1,194 +0,0 @@ -defmodule TermUI.ElmTest do - use ExUnit.Case, async: true - - alias TermUI.Elm - alias TermUI.Event - - describe "normalize_update_result/2" do - test "passes through standard form" do - state = %{count: 5} - commands = [:cmd1, :cmd2] - - result = Elm.normalize_update_result({state, commands}, %{count: 0}) - - assert result == {state, commands} - end - - test "converts single-tuple to standard form" do - state = %{count: 5} - - result = Elm.normalize_update_result({state}, %{count: 0}) - - assert result == {state, []} - end - - test "converts :noreply to keep old state" do - old_state = %{count: 0} - - result = Elm.normalize_update_result(:noreply, old_state) - - assert result == {old_state, []} - end - end - - describe "component using Elm behaviour" do - defmodule Counter do - use TermUI.Elm - - def init(opts), do: %{count: Keyword.get(opts, :initial, 0)} - - def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :increment} - def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :decrement} - def event_to_msg(%Event.Key{key: :r}, _state), do: {:msg, :reset} - def event_to_msg(_, _), do: :ignore - - def update(:increment, state), do: {%{state | count: state.count + 1}, []} - def update(:decrement, state), do: {%{state | count: state.count - 1}, []} - def update(:reset, state), do: {%{state | count: 0}, []} - def update(:noop, _state), do: :noreply - - def view(state), do: {:text, "Count: #{state.count}"} - end - - test "init creates initial state" do - state = Counter.init([]) - assert state == %{count: 0} - end - - test "init accepts options" do - state = Counter.init(initial: 10) - assert state == %{count: 10} - end - - test "event_to_msg converts key events" do - state = %{count: 0} - - assert {:msg, :increment} = Counter.event_to_msg(Event.key(:up), state) - assert {:msg, :decrement} = Counter.event_to_msg(Event.key(:down), state) - assert {:msg, :reset} = Counter.event_to_msg(Event.key(:r), state) - end - - test "event_to_msg returns :ignore for unhandled events" do - state = %{count: 0} - assert :ignore = Counter.event_to_msg(Event.key(:x), state) - end - - test "update produces new state" do - state = %{count: 5} - - {new_state, commands} = Counter.update(:increment, state) - - assert new_state == %{count: 6} - assert commands == [] - end - - test "update returns :noreply to keep state" do - state = %{count: 5} - result = Counter.update(:noop, state) - assert result == :noreply - end - - test "view renders state to tree" do - state = %{count: 42} - tree = Counter.view(state) - assert tree == {:text, "Count: 42"} - end - - test "full cycle: event -> message -> update -> view" do - # Initialize - state = Counter.init([]) - assert state.count == 0 - - # Event arrives - event = Event.key(:up) - - # Convert to message - {:msg, msg} = Counter.event_to_msg(event, state) - assert msg == :increment - - # Update state - {new_state, _} = Counter.update(msg, state) - assert new_state.count == 1 - - # Render - tree = Counter.view(new_state) - assert tree == {:text, "Count: 1"} - end - end - - describe "component with commands" do - defmodule Fetcher do - use TermUI.Elm - - def init(_opts), do: %{data: nil, loading: false, error: nil} - - def event_to_msg(%Event.Key{key: :f}, _state), do: {:msg, :fetch} - def event_to_msg(_, _), do: :ignore - - def update(:fetch, state) do - command = {:http_get, "https://api.example.com/data", :data_loaded} - {%{state | loading: true}, [command]} - end - - def update({:data_loaded, {:ok, data}}, state) do - {%{state | data: data, loading: false}, []} - end - - def update({:data_loaded, {:error, reason}}, state) do - {%{state | error: reason, loading: false}, []} - end - - def view(state) do - cond do - state.loading -> {:text, "Loading..."} - state.error -> {:text, "Error: #{state.error}"} - state.data -> {:text, "Data: #{state.data}"} - true -> {:text, "Press F to fetch"} - end - end - end - - test "update returns commands" do - state = %{data: nil, loading: false, error: nil} - - {new_state, commands} = Fetcher.update(:fetch, state) - - assert new_state.loading == true - assert length(commands) == 1 - assert {:http_get, _, :data_loaded} = hd(commands) - end - - test "update handles command result" do - state = %{data: nil, loading: true, error: nil} - - {new_state, _} = Fetcher.update({:data_loaded, {:ok, "result"}}, state) - - assert new_state.data == "result" - assert new_state.loading == false - end - end - - describe "Elm.Helpers" do - import TermUI.Elm.Helpers - - test "text creates text node" do - node = text("Hello") - assert node == {:text, "Hello"} - end - - test "text converts non-string to string" do - node = text(42) - assert node == {:text, "42"} - end - - test "styled creates styled node" do - node = styled("Hello", %{fg: :blue}) - assert node == {:styled, "Hello", %{fg: :blue}} - end - - test "fragment groups nodes" do - nodes = fragment([{:text, "a"}, {:text, "b"}]) - assert nodes == {:fragment, [{:text, "a"}, {:text, "b"}]} - end - end -end diff --git a/test/term_ui/error_test.exs b/test/term_ui/error_test.exs deleted file mode 100644 index 7eb15563..00000000 --- a/test/term_ui/error_test.exs +++ /dev/null @@ -1,112 +0,0 @@ -defmodule TermUI.ErrorTest do - use ExUnit.Case - doctest TermUI.Error - - alias TermUI.Error - - describe "format/1" do - test "formats simple error atoms" do - assert Error.format(:not_found) == "not found" - assert Error.format(:timeout) == "operation timed out" - assert Error.format(:invalid_size) == "invalid size" - end - - test "formats tuple errors with string details" do - assert Error.format({:invalid_size, "must be positive"}) == - "invalid size: must be positive" - - assert Error.format({:command_failed, "exit code 1"}) == - "command failed: exit code 1" - end - - test "formats tuple errors with non-string details" do - assert Error.format({:command_failed, {:exit_code, 1}}) =~ - "command failed:" - - assert Error.format({:invalid_size, {24, 80}}) =~ - "invalid size:" - end - end - - describe "error/2" do - test "creates error tuple with details" do - assert Error.error(:invalid_size, "too small") == {:invalid_size, "too small"} - assert Error.error(:command_failed, {:exit_code, 1}) == {:command_failed, {:exit_code, 1}} - end - end - - describe "error_reason?/1" do - test "returns true for valid error atoms" do - assert Error.error_reason?(:not_found) - assert Error.error_reason?(:timeout) - assert Error.error_reason?(:invalid_size) - assert Error.error_reason?(:component_crashed) - end - - test "returns true for valid error tuples" do - assert Error.error_reason?({:not_found, "resource"}) - assert Error.error_reason?({:invalid_size, {24, 80}}) - assert Error.error_reason?({:command_failed, {:exit_code, 1}}) - end - - test "returns false for non-error atoms" do - refute Error.error_reason?(:ok) - refute Error.error_reason?(:error) - refute Error.error_reason?(:some_atom) - end - - test "returns false for non-error tuples" do - refute Error.error_reason?({:ok, "result"}) - refute Error.error_reason?({:error, "message"}) - refute Error.error_reason?({1, 2, 3}) - end - - test "returns false for other types" do - refute Error.error_reason?("string") - refute Error.error_reason?(123) - refute Error.error_reason?(%{}) - end - end - - describe "error_type/1" do - test "returns type for simple error atoms" do - assert Error.error_type(:not_found) == :not_found - assert Error.error_type(:timeout) == :timeout - end - - test "returns type for error tuples" do - assert Error.error_type({:not_found, "resource"}) == :not_found - assert Error.error_type({:invalid_size, {24, 80}}) == :invalid_size - assert Error.error_type({:command_failed, {:exit_code, 1}}) == :command_failed - end - end - - describe "type definitions" do - test "error_reason type includes all expected atoms" do - # These are compile-time checks that the types exist - # If any error reason is missing, this will cause a compile error - error_atoms = [ - :invalid_argument, - :not_found, - :not_supported, - :timeout, - :terminal_setup_failed, - :size_detection_failed, - :invalid_size, - :out_of_bounds, - :backend_unavailable, - :command_failed, - :command_not_found, - :command_not_allowed, - :invalid_configuration, - :component_crashed, - :component_unavailable - ] - - # Verify these are all valid error reasons - Enum.each(error_atoms, fn atom -> - assert Error.error_reason?(atom), "#{atom} should be a valid error reason" - end) - end - end -end diff --git a/test/term_ui/event/propagation_test.exs b/test/term_ui/event/propagation_test.exs deleted file mode 100644 index 55b2a4b7..00000000 --- a/test/term_ui/event/propagation_test.exs +++ /dev/null @@ -1,312 +0,0 @@ -defmodule TermUI.Event.PropagationTest do - use ExUnit.Case - - alias TermUI.ComponentRegistry - alias TermUI.Event - alias TermUI.Event.Propagation - - # Test component that handles events - defmodule HandlingComponent do - use GenServer - - def start_link(opts) do - test_pid = Keyword.fetch!(opts, :test_pid) - id = Keyword.fetch!(opts, :id) - GenServer.start_link(__MODULE__, %{test_pid: test_pid, id: id}) - end - - @impl true - def init(state), do: {:ok, state} - - @impl true - def handle_call({:event, event}, _from, state) do - send(state.test_pid, {:handled_by, state.id, event}) - {:reply, :handled, state} - end - end - - # Test component that doesn't handle events (bubbles) - defmodule BubblingComponent do - use GenServer - - def start_link(opts) do - test_pid = Keyword.fetch!(opts, :test_pid) - id = Keyword.fetch!(opts, :id) - GenServer.start_link(__MODULE__, %{test_pid: test_pid, id: id}) - end - - @impl true - def init(state), do: {:ok, state} - - @impl true - def handle_call({:event, event}, _from, state) do - send(state.test_pid, {:bubbled_through, state.id, event}) - {:reply, :unhandled, state} - end - end - - # Component that stops propagation - defmodule StoppingComponent do - use GenServer - - def start_link(opts) do - test_pid = Keyword.fetch!(opts, :test_pid) - id = Keyword.fetch!(opts, :id) - GenServer.start_link(__MODULE__, %{test_pid: test_pid, id: id}) - end - - @impl true - def init(state), do: {:ok, state} - - @impl true - def handle_call({:event, event}, _from, state) do - send(state.test_pid, {:stopped_at, state.id, event}) - {:reply, :stopped, state} - end - end - - setup do - start_supervised!(ComponentRegistry) - :ok - end - - describe "set_parent/2 and get_parent/1" do - test "sets and retrieves parent" do - :ok = Propagation.set_parent(:child, :parent) - - assert {:ok, :parent} = ComponentRegistry.get_parent(:child) - end - - test "returns not_found for component without parent set" do - assert {:error, :not_found} = ComponentRegistry.get_parent(:orphan) - end - - test "nil parent indicates root component" do - :ok = Propagation.set_parent(:root, nil) - - assert {:ok, nil} = ComponentRegistry.get_parent(:root) - end - end - - describe "get_parent_chain/1" do - test "returns empty for root component" do - :ok = Propagation.set_parent(:root, nil) - - assert [] = Propagation.get_parent_chain(:root) - end - - test "returns single parent" do - :ok = Propagation.set_parent(:child, :parent) - :ok = Propagation.set_parent(:parent, nil) - - assert [:parent] = Propagation.get_parent_chain(:child) - end - - test "returns full chain from child to root" do - :ok = Propagation.set_parent(:button, :panel) - :ok = Propagation.set_parent(:panel, :container) - :ok = Propagation.set_parent(:container, :root) - :ok = Propagation.set_parent(:root, nil) - - assert [:panel, :container, :root] = Propagation.get_parent_chain(:button) - end - end - - describe "get_children/1" do - test "returns children of component" do - :ok = Propagation.set_parent(:child1, :parent) - :ok = Propagation.set_parent(:child2, :parent) - :ok = Propagation.set_parent(:other, :other_parent) - - children = ComponentRegistry.get_children(:parent) - - assert length(children) == 2 - assert :child1 in children - assert :child2 in children - end - - test "returns empty list for no children" do - assert [] = ComponentRegistry.get_children(:leaf) - end - end - - describe "bubble/3" do - test "delivers event to target component" do - {:ok, pid} = HandlingComponent.start_link(test_pid: self(), id: :button) - :ok = ComponentRegistry.register(:button, pid, HandlingComponent) - :ok = Propagation.set_parent(:button, nil) - - event = Event.key(:enter) - assert :handled = Propagation.bubble(event, :button) - - assert_receive {:handled_by, :button, ^event} - end - - test "bubbles event to parent when unhandled" do - {:ok, child_pid} = BubblingComponent.start_link(test_pid: self(), id: :button) - {:ok, parent_pid} = HandlingComponent.start_link(test_pid: self(), id: :panel) - - :ok = ComponentRegistry.register(:button, child_pid, BubblingComponent) - :ok = ComponentRegistry.register(:panel, parent_pid, HandlingComponent) - :ok = Propagation.set_parent(:button, :panel) - :ok = Propagation.set_parent(:panel, nil) - - event = Event.key(:enter) - assert :handled = Propagation.bubble(event, :button) - - assert_receive {:bubbled_through, :button, ^event} - assert_receive {:handled_by, :panel, ^event} - end - - test "continues bubbling until handled" do - {:ok, pid1} = BubblingComponent.start_link(test_pid: self(), id: :button) - {:ok, pid2} = BubblingComponent.start_link(test_pid: self(), id: :panel) - {:ok, pid3} = HandlingComponent.start_link(test_pid: self(), id: :root) - - :ok = ComponentRegistry.register(:button, pid1, BubblingComponent) - :ok = ComponentRegistry.register(:panel, pid2, BubblingComponent) - :ok = ComponentRegistry.register(:root, pid3, HandlingComponent) - - :ok = Propagation.set_parent(:button, :panel) - :ok = Propagation.set_parent(:panel, :root) - :ok = Propagation.set_parent(:root, nil) - - event = Event.key(:enter) - assert :handled = Propagation.bubble(event, :button) - - assert_receive {:bubbled_through, :button, ^event} - assert_receive {:bubbled_through, :panel, ^event} - assert_receive {:handled_by, :root, ^event} - end - - test "returns unhandled when no component handles event" do - {:ok, pid1} = BubblingComponent.start_link(test_pid: self(), id: :button) - {:ok, pid2} = BubblingComponent.start_link(test_pid: self(), id: :panel) - - :ok = ComponentRegistry.register(:button, pid1, BubblingComponent) - :ok = ComponentRegistry.register(:panel, pid2, BubblingComponent) - - :ok = Propagation.set_parent(:button, :panel) - :ok = Propagation.set_parent(:panel, nil) - - event = Event.key(:enter) - assert :unhandled = Propagation.bubble(event, :button) - end - - test "stops propagation when component returns :stopped" do - {:ok, pid1} = BubblingComponent.start_link(test_pid: self(), id: :button) - {:ok, pid2} = StoppingComponent.start_link(test_pid: self(), id: :stopper) - {:ok, pid3} = HandlingComponent.start_link(test_pid: self(), id: :root) - - :ok = ComponentRegistry.register(:button, pid1, BubblingComponent) - :ok = ComponentRegistry.register(:stopper, pid2, StoppingComponent) - :ok = ComponentRegistry.register(:root, pid3, HandlingComponent) - - :ok = Propagation.set_parent(:button, :stopper) - :ok = Propagation.set_parent(:stopper, :root) - :ok = Propagation.set_parent(:root, nil) - - event = Event.key(:enter) - assert :stopped = Propagation.bubble(event, :button) - - assert_receive {:bubbled_through, :button, ^event} - assert_receive {:stopped_at, :stopper, ^event} - refute_receive {:handled_by, :root, _} - end - - test "skip_start option skips the starting component" do - {:ok, pid1} = HandlingComponent.start_link(test_pid: self(), id: :button) - {:ok, pid2} = HandlingComponent.start_link(test_pid: self(), id: :panel) - - :ok = ComponentRegistry.register(:button, pid1, HandlingComponent) - :ok = ComponentRegistry.register(:panel, pid2, HandlingComponent) - - :ok = Propagation.set_parent(:button, :panel) - :ok = Propagation.set_parent(:panel, nil) - - event = Event.key(:enter) - assert :handled = Propagation.bubble(event, :button, skip_start: true) - - refute_receive {:handled_by, :button, _} - assert_receive {:handled_by, :panel, ^event} - end - end - - describe "capture/2" do - test "propagates from root to target" do - {:ok, pid1} = BubblingComponent.start_link(test_pid: self(), id: :button) - {:ok, pid2} = BubblingComponent.start_link(test_pid: self(), id: :panel) - {:ok, pid3} = BubblingComponent.start_link(test_pid: self(), id: :root) - - :ok = ComponentRegistry.register(:button, pid1, BubblingComponent) - :ok = ComponentRegistry.register(:panel, pid2, BubblingComponent) - :ok = ComponentRegistry.register(:root, pid3, BubblingComponent) - - :ok = Propagation.set_parent(:button, :panel) - :ok = Propagation.set_parent(:panel, :root) - :ok = Propagation.set_parent(:root, nil) - - event = Event.key(:enter) - Propagation.capture(event, :button) - - # Should receive in order: root, panel, button - assert_receive {:bubbled_through, :root, ^event} - assert_receive {:bubbled_through, :panel, ^event} - assert_receive {:bubbled_through, :button, ^event} - end - - test "stops at first handler" do - {:ok, pid1} = BubblingComponent.start_link(test_pid: self(), id: :button) - {:ok, pid2} = HandlingComponent.start_link(test_pid: self(), id: :panel) - {:ok, pid3} = BubblingComponent.start_link(test_pid: self(), id: :root) - - :ok = ComponentRegistry.register(:button, pid1, BubblingComponent) - :ok = ComponentRegistry.register(:panel, pid2, HandlingComponent) - :ok = ComponentRegistry.register(:root, pid3, BubblingComponent) - - :ok = Propagation.set_parent(:button, :panel) - :ok = Propagation.set_parent(:panel, :root) - :ok = Propagation.set_parent(:root, nil) - - event = Event.key(:enter) - assert :handled = Propagation.capture(event, :button) - - assert_receive {:bubbled_through, :root, ^event} - assert_receive {:handled_by, :panel, ^event} - refute_receive {:bubbled_through, :button, _} - end - end - - describe "with_phase/2" do - test "adds propagation phase to event" do - event = Event.key(:enter) - result = Propagation.with_phase(event, :bubble) - - assert result.propagation_phase == :bubble - end - - test "works with all phases" do - event = Event.key(:enter) - - assert Propagation.with_phase(event, :capture).propagation_phase == :capture - assert Propagation.with_phase(event, :target).propagation_phase == :target - assert Propagation.with_phase(event, :bubble).propagation_phase == :bubble - end - end - - describe "stopped?/1" do - test "returns true for :stopped" do - assert Propagation.stopped?(:stopped) - end - - test "returns true for :stop" do - assert Propagation.stopped?(:stop) - end - - test "returns false for other values" do - refute Propagation.stopped?(:handled) - refute Propagation.stopped?(:unhandled) - refute Propagation.stopped?(nil) - end - end -end diff --git a/test/term_ui/event/transformation_test.exs b/test/term_ui/event/transformation_test.exs deleted file mode 100644 index 8a6289c3..00000000 --- a/test/term_ui/event/transformation_test.exs +++ /dev/null @@ -1,238 +0,0 @@ -defmodule TermUI.Event.TransformationTest do - use ExUnit.Case, async: true - - alias TermUI.Event - alias TermUI.Event.Transformation - - describe "to_local/2" do - test "transforms mouse coordinates to component-local" do - event = Event.mouse(:click, :left, 15, 10) - bounds = %{x: 10, y: 5, width: 20, height: 10} - - result = Transformation.to_local(event, bounds) - - assert result.x == 5 - assert result.y == 5 - end - - test "handles coordinates at origin" do - event = Event.mouse(:click, :left, 10, 5) - bounds = %{x: 10, y: 5, width: 20, height: 10} - - result = Transformation.to_local(event, bounds) - - assert result.x == 0 - assert result.y == 0 - end - - test "preserves other event properties" do - event = Event.mouse(:click, :left, 15, 10, modifiers: [:ctrl]) - bounds = %{x: 10, y: 5, width: 20, height: 10} - - result = Transformation.to_local(event, bounds) - - assert result.action == :click - assert result.button == :left - assert result.modifiers == [:ctrl] - end - - test "returns non-mouse events unchanged" do - event = Event.key(:enter) - bounds = %{x: 10, y: 5, width: 20, height: 10} - - result = Transformation.to_local(event, bounds) - - assert result == event - end - end - - describe "to_screen/2" do - test "transforms local coordinates to screen" do - event = Event.mouse(:click, :left, 5, 5) - bounds = %{x: 10, y: 5, width: 20, height: 10} - - result = Transformation.to_screen(event, bounds) - - assert result.x == 15 - assert result.y == 10 - end - - test "inverse of to_local" do - original = Event.mouse(:click, :left, 15, 10) - bounds = %{x: 10, y: 5, width: 20, height: 10} - - local = Transformation.to_local(original, bounds) - screen = Transformation.to_screen(local, bounds) - - assert screen.x == original.x - assert screen.y == original.y - end - end - - describe "with_metadata/2" do - test "adds metadata to event" do - event = Event.key(:enter) - result = Transformation.with_metadata(event, %{target: :button}) - - assert result.metadata == %{target: :button} - end - - test "merges with existing metadata" do - event = Event.key(:enter) |> Map.put(:metadata, %{existing: true}) - result = Transformation.with_metadata(event, %{new: :value}) - - assert result.metadata == %{existing: true, new: :value} - end - - test "overwrites conflicting keys" do - event = Event.key(:enter) |> Map.put(:metadata, %{key: :old}) - result = Transformation.with_metadata(event, %{key: :new}) - - assert result.metadata == %{key: :new} - end - end - - describe "get_metadata/3" do - test "retrieves metadata value" do - event = %{metadata: %{target: :button}} - - assert Transformation.get_metadata(event, :target) == :button - end - - test "returns default when key not found" do - event = %{metadata: %{}} - - assert Transformation.get_metadata(event, :missing, :default) == :default - end - - test "returns nil default when key not found" do - event = %{metadata: %{}} - - assert Transformation.get_metadata(event, :missing) == nil - end - - test "returns default when no metadata" do - event = %{} - - assert Transformation.get_metadata(event, :key, :default) == :default - end - end - - describe "matches?/2" do - test "matches event type" do - key_event = Event.key(:enter) - mouse_event = Event.mouse(:click, :left, 0, 0) - - assert Transformation.matches?(key_event, type: :key) - assert Transformation.matches?(mouse_event, type: :mouse) - refute Transformation.matches?(key_event, type: :mouse) - end - - test "matches specific key" do - event = Event.key(:enter) - - assert Transformation.matches?(event, key: :enter) - refute Transformation.matches?(event, key: :escape) - end - - test "matches action" do - click = Event.mouse(:click, :left, 0, 0) - move = Event.mouse(:move, nil, 0, 0) - - assert Transformation.matches?(click, action: :click) - assert Transformation.matches?(move, action: :move) - refute Transformation.matches?(click, action: :move) - end - - test "matches button" do - left = Event.mouse(:click, :left, 0, 0) - right = Event.mouse(:click, :right, 0, 0) - - assert Transformation.matches?(left, button: :left) - assert Transformation.matches?(right, button: :right) - refute Transformation.matches?(left, button: :right) - end - - test "matches all modifiers with modifiers_all" do - event = Event.key(:c, modifiers: [:ctrl, :shift]) - - assert Transformation.matches?(event, modifiers_all: [:ctrl]) - assert Transformation.matches?(event, modifiers_all: [:ctrl, :shift]) - refute Transformation.matches?(event, modifiers_all: [:ctrl, :alt]) - end - - test "matches any modifier with modifiers_any" do - event = Event.key(:c, modifiers: [:ctrl]) - - assert Transformation.matches?(event, modifiers_any: [:ctrl, :alt]) - refute Transformation.matches?(event, modifiers_any: [:shift, :alt]) - end - - test "matches multiple filters" do - event = Event.key(:c, modifiers: [:ctrl]) - - assert Transformation.matches?(event, type: :key, key: :c, modifiers_all: [:ctrl]) - refute Transformation.matches?(event, type: :key, key: :c, modifiers_all: [:shift]) - end - - test "empty filters match any event" do - event = Event.key(:enter) - - assert Transformation.matches?(event, []) - end - end - - describe "filter/2" do - test "filters list of events" do - events = [ - Event.key(:a), - Event.key(:b), - Event.mouse(:click, :left, 0, 0) - ] - - result = Transformation.filter(events, type: :key) - - assert length(result) == 2 - assert Enum.all?(result, &Event.key?/1) - end - - test "returns empty list when no matches" do - events = [Event.key(:a), Event.key(:b)] - - result = Transformation.filter(events, type: :mouse) - - assert result == [] - end - - test "filters by multiple criteria" do - events = [ - Event.key(:c, modifiers: [:ctrl]), - Event.key(:c), - Event.key(:v, modifiers: [:ctrl]) - ] - - result = Transformation.filter(events, key: :c, modifiers_all: [:ctrl]) - - assert length(result) == 1 - assert hd(result).key == :c - end - end - - describe "envelope/2" do - test "creates envelope with routing metadata" do - event = Event.key(:enter) - result = Transformation.envelope(event, source: :terminal, target: :input) - - assert Transformation.get_metadata(result, :source) == :terminal - assert Transformation.get_metadata(result, :target) == :input - assert is_integer(Transformation.get_metadata(result, :routed_at)) - end - - test "allows custom timestamp" do - event = Event.key(:enter) - result = Transformation.envelope(event, timestamp: 12_345) - - assert Transformation.get_metadata(result, :routed_at) == 12_345 - end - end -end diff --git a/test/term_ui/event_queue_test.exs b/test/term_ui/event_queue_test.exs deleted file mode 100644 index fec621fd..00000000 --- a/test/term_ui/event_queue_test.exs +++ /dev/null @@ -1,210 +0,0 @@ -defmodule TermUI.EventQueueTest do - use ExUnit.Case, async: true - - alias TermUI.EventQueue - - describe "new/1" do - test "creates queue with default max size" do - queue = EventQueue.new() - assert EventQueue.max_size(queue) == 1000 - assert EventQueue.size(queue) == 0 - assert EventQueue.empty?(queue) - end - - test "creates queue with custom max size" do - queue = EventQueue.new(max_size: 500) - assert EventQueue.max_size(queue) == 500 - end - end - - describe "push/2 and pop/1" do - test "push and pop single event" do - queue = EventQueue.new() - assert {:ok, queue} = EventQueue.push(queue, :event1) - - refute EventQueue.empty?(queue) - assert EventQueue.size(queue) == 1 - - {{:value, :event1}, queue} = EventQueue.pop(queue) - assert EventQueue.empty?(queue) - end - - test "maintains FIFO order" do - queue = EventQueue.new() - {:ok, queue} = EventQueue.push(queue, :first) - {:ok, queue} = EventQueue.push(queue, :second) - {:ok, queue} = EventQueue.push(queue, :third) - - assert EventQueue.size(queue) == 3 - - {{:value, :first}, queue} = EventQueue.pop(queue) - {{:value, :second}, queue} = EventQueue.pop(queue) - {{:value, :third}, queue} = EventQueue.pop(queue) - - assert EventQueue.empty?(queue) - end - - test "pop from empty queue returns empty" do - queue = EventQueue.new() - assert {:empty, queue} = EventQueue.pop(queue) - end - end - - describe "peek/1" do - test "returns event without removing it" do - queue = EventQueue.new() - {:ok, queue} = EventQueue.push(queue, :peek_test) - - {{:value, :peek_test}, queue} = EventQueue.peek(queue) - assert EventQueue.size(queue) == 1 - assert EventQueue.full?(queue) == false - end - - test "peek on empty queue" do - queue = EventQueue.new() - assert {:empty, queue} = EventQueue.peek(queue) - end - end - - describe "bounded behavior" do - test "drops oldest event when full" do - queue = EventQueue.new(max_size: 3) - {:ok, queue} = EventQueue.push(queue, :first) - {:ok, queue} = EventQueue.push(queue, :second) - {:ok, queue} = EventQueue.push(queue, :third) - - assert EventQueue.full?(queue) - assert EventQueue.size(queue) == 3 - - # This should drop :first - {{:dropped, :first}, queue} = EventQueue.push(queue, :fourth) - - assert EventQueue.size(queue) == 3 - - # Verify :first is gone, :second is now oldest - {{:value, :second}, queue} = EventQueue.pop(queue) - {{:value, :third}, queue} = EventQueue.pop(queue) - {{:value, :fourth}, queue} = EventQueue.pop(queue) - - assert {:empty, _} = EventQueue.pop(queue) - end - - test "tracks dropped events" do - queue = EventQueue.new(max_size: 2) - {:ok, queue} = EventQueue.push(queue, :a) - {:ok, queue} = EventQueue.push(queue, :b) - - assert EventQueue.dropped_count(queue) == 0 - - {{:dropped, :a}, queue} = EventQueue.push(queue, :c) - assert EventQueue.dropped_count(queue) == 1 - - {{:dropped, :b}, queue} = EventQueue.push(queue, :d) - assert EventQueue.dropped_count(queue) == 2 - end - - test "reset_dropped_count resets counter" do - queue = EventQueue.new(max_size: 1) - {:ok, queue} = EventQueue.push(queue, :x) - {{:dropped, :x}, queue} = EventQueue.push(queue, :y) - - assert EventQueue.dropped_count(queue) > 0 - - queue = EventQueue.reset_dropped_count(queue) - assert EventQueue.dropped_count(queue) == 0 - end - end - - describe "full? and empty?" do - test "full? returns true at capacity" do - queue = EventQueue.new(max_size: 1) - {:ok, queue} = EventQueue.push(queue, :event) - - assert EventQueue.full?(queue) - end - - test "empty? returns true for new queue" do - queue = EventQueue.new() - assert EventQueue.empty?(queue) - end - end - - describe "push!/2" do - test "always returns queue even when dropping" do - queue = EventQueue.new(max_size: 1) - {:ok, queue} = EventQueue.push(queue, :first) - - # This drops :first but still returns queue - queue = EventQueue.push!(queue, :second) - assert EventQueue.size(queue) == 1 - end - end - - describe "clear/1" do - test "clears all events" do - queue = EventQueue.new() - {:ok, queue} = EventQueue.push(queue, :a) - {:ok, queue} = EventQueue.push(queue, :b) - {:ok, queue} = EventQueue.push(queue, :c) - - assert EventQueue.size(queue) == 3 - - queue = EventQueue.clear(queue) - assert EventQueue.empty?(queue) - assert EventQueue.size(queue) == 0 - end - end - - describe "to_list/1" do - test "converts queue to list" do - queue = EventQueue.new() - {:ok, queue} = EventQueue.push(queue, :first) - {:ok, queue} = EventQueue.push(queue, :second) - {:ok, queue} = EventQueue.push(queue, :third) - - list = EventQueue.to_list(queue) - assert list == [:first, :second, :third] - end - - test "empty queue returns empty list" do - queue = EventQueue.new() - assert EventQueue.to_list(queue) == [] - end - end - - describe "integration - stress test" do - test "handles rapid push/pop without overflow" do - queue = EventQueue.new(max_size: 100) - - # Push 1000 events, should only keep 100 - queue = - Enum.reduce(1..1000, queue, fn i, q -> - {_, updated} = EventQueue.push(q, i) - updated - end) - - assert EventQueue.size(queue) == 100 - assert EventQueue.dropped_count(queue) >= 900 - end - - test "drain queue processes all events" do - queue = EventQueue.new(max_size: 10) - {:ok, queue} = EventQueue.push(queue, 1) - {:ok, queue} = EventQueue.push(queue, 2) - {:ok, queue} = EventQueue.push(queue, 3) - - # Drain all - {events, queue} = drain_all(queue, []) - assert Enum.reverse(events) == [1, 2, 3] - assert EventQueue.empty?(queue) - end - end - - # Helper to drain all events from queue - defp drain_all(queue, acc) do - case EventQueue.pop(queue) do - {{:value, event}, new_queue} -> drain_all(new_queue, [event | acc]) - {:empty, _} -> {acc, queue} - end - end -end diff --git a/test/term_ui/event_router_test.exs b/test/term_ui/event_router_test.exs deleted file mode 100644 index 37d7efab..00000000 --- a/test/term_ui/event_router_test.exs +++ /dev/null @@ -1,271 +0,0 @@ -defmodule TermUI.EventRouterTest do - use ExUnit.Case - - alias TermUI.ComponentRegistry - alias TermUI.Event - alias TermUI.EventRouter - alias TermUI.SpatialIndex - - # Test component that tracks received events - defmodule TestComponent do - use GenServer - - def start_link(opts) do - test_pid = Keyword.fetch!(opts, :test_pid) - GenServer.start_link(__MODULE__, %{test_pid: test_pid}) - end - - @impl true - def init(state), do: {:ok, state} - - @impl true - def handle_call({:event, event}, _from, state) do - send(state.test_pid, {:event_received, event}) - {:reply, :handled, state} - end - end - - # Component that doesn't handle events - defmodule UnhandlingComponent do - use GenServer - - def start_link(_opts) do - GenServer.start_link(__MODULE__, %{}) - end - - @impl true - def init(state), do: {:ok, state} - - @impl true - def handle_call({:event, _event}, _from, state) do - {:reply, :unhandled, state} - end - end - - setup do - start_supervised!(ComponentRegistry) - start_supervised!(SpatialIndex) - start_supervised!(EventRouter) - :ok - end - - describe "focus management" do - test "set_focus changes focused component" do - {:ok, pid} = TestComponent.start_link(test_pid: self()) - :ok = ComponentRegistry.register(:input, pid, TestComponent) - - :ok = EventRouter.set_focus(:input) - assert {:ok, :input} = EventRouter.get_focus() - end - - test "get_focus returns nil when no focus" do - assert {:ok, nil} = EventRouter.get_focus() - end - - test "clear_focus clears the focus" do - {:ok, pid} = TestComponent.start_link(test_pid: self()) - :ok = ComponentRegistry.register(:input, pid, TestComponent) - - :ok = EventRouter.set_focus(:input) - :ok = EventRouter.clear_focus() - - assert {:ok, nil} = EventRouter.get_focus() - end - - test "set_focus sends focus events to old and new" do - {:ok, pid1} = TestComponent.start_link(test_pid: self()) - {:ok, pid2} = TestComponent.start_link(test_pid: self()) - - :ok = ComponentRegistry.register(:input1, pid1, TestComponent) - :ok = ComponentRegistry.register(:input2, pid2, TestComponent) - - :ok = EventRouter.set_focus(:input1) - assert_receive {:event_received, %Event.Focus{action: :gained}} - - :ok = EventRouter.set_focus(:input2) - assert_receive {:event_received, %Event.Focus{action: :lost}} - assert_receive {:event_received, %Event.Focus{action: :gained}} - end - - test "set_focus to same component doesn't send events" do - {:ok, pid} = TestComponent.start_link(test_pid: self()) - :ok = ComponentRegistry.register(:input, pid, TestComponent) - - :ok = EventRouter.set_focus(:input) - assert_receive {:event_received, %Event.Focus{action: :gained}} - - :ok = EventRouter.set_focus(:input) - refute_receive {:event_received, _} - end - end - - describe "keyboard routing" do - test "routes keyboard event to focused component" do - {:ok, pid} = TestComponent.start_link(test_pid: self()) - :ok = ComponentRegistry.register(:input, pid, TestComponent) - :ok = EventRouter.set_focus(:input) - - # Clear focus event - assert_receive {:event_received, %Event.Focus{}} - - event = Event.key(:enter) - assert :handled = EventRouter.route(event) - - assert_receive {:event_received, ^event} - end - - test "returns unhandled when no focus" do - event = Event.key(:enter) - assert :unhandled = EventRouter.route(event) - end - - test "returns unhandled when focused component not found" do - :ok = EventRouter.set_focus(:nonexistent) - - event = Event.key(:enter) - assert :unhandled = EventRouter.route(event) - end - - test "returns unhandled when component doesn't handle event" do - {:ok, pid} = UnhandlingComponent.start_link([]) - :ok = ComponentRegistry.register(:input, pid, UnhandlingComponent) - :ok = EventRouter.set_focus(:input) - - event = Event.key(:enter) - assert :unhandled = EventRouter.route(event) - end - end - - describe "mouse routing" do - test "routes mouse event to component at position" do - {:ok, pid} = TestComponent.start_link(test_pid: self()) - :ok = ComponentRegistry.register(:button, pid, TestComponent) - - bounds = %{x: 10, y: 5, width: 20, height: 3} - :ok = SpatialIndex.update(:button, pid, bounds) - - event = Event.mouse(:click, :left, 15, 6) - assert :handled = EventRouter.route(event) - - assert_receive {:event_received, ^event} - end - - test "returns unhandled when no component at position" do - event = Event.mouse(:click, :left, 100, 100) - assert :unhandled = EventRouter.route(event) - end - - test "routes to highest z-index component" do - {:ok, pid1} = TestComponent.start_link(test_pid: self()) - {:ok, pid2} = TestComponent.start_link(test_pid: self()) - - :ok = ComponentRegistry.register(:background, pid1, TestComponent) - :ok = ComponentRegistry.register(:modal, pid2, TestComponent) - - bounds = %{x: 0, y: 0, width: 10, height: 10} - :ok = SpatialIndex.update(:background, pid1, bounds, z_index: 0) - :ok = SpatialIndex.update(:modal, pid2, bounds, z_index: 100) - - event = Event.mouse(:click, :left, 5, 5) - assert :handled = EventRouter.route(event) - - # Modal should receive the event - assert_receive {:event_received, ^event} - end - end - - describe "broadcast/1" do - test "sends event to all registered components" do - {:ok, pid1} = TestComponent.start_link(test_pid: self()) - {:ok, pid2} = TestComponent.start_link(test_pid: self()) - - :ok = ComponentRegistry.register(:comp1, pid1, TestComponent) - :ok = ComponentRegistry.register(:comp2, pid2, TestComponent) - - event = {:resize, 80, 24} - assert {:ok, 2} = EventRouter.broadcast(event) - - assert_receive {:event_received, ^event} - assert_receive {:event_received, ^event} - end - - test "returns count of components broadcast to" do - {:ok, pid} = TestComponent.start_link(test_pid: self()) - :ok = ComponentRegistry.register(:single, pid, TestComponent) - - assert {:ok, 1} = EventRouter.broadcast(:test) - end - - test "returns zero when no components registered" do - assert {:ok, 0} = EventRouter.broadcast(:test) - end - end - - describe "route_to/2" do - test "routes directly to specific component" do - {:ok, pid} = TestComponent.start_link(test_pid: self()) - :ok = ComponentRegistry.register(:target, pid, TestComponent) - - event = Event.key(:enter) - assert :handled = EventRouter.route_to(:target, event) - - assert_receive {:event_received, ^event} - end - - test "returns error when component not found" do - event = Event.key(:enter) - assert {:error, :not_found} = EventRouter.route_to(:nonexistent, event) - end - end - - describe "fallback handler" do - test "calls fallback handler for unrouted events" do - test_pid = self() - - handler = fn event -> - send(test_pid, {:fallback, event}) - :ok - end - - :ok = EventRouter.set_fallback_handler(handler) - - event = Event.key(:enter) - assert :unhandled = EventRouter.route(event) - - assert_receive {:fallback, ^event} - end - - test "clear_fallback_handler removes handler" do - test_pid = self() - - handler = fn event -> - send(test_pid, {:fallback, event}) - :ok - end - - :ok = EventRouter.set_fallback_handler(handler) - :ok = EventRouter.clear_fallback_handler() - - event = Event.key(:enter) - EventRouter.route(event) - - refute_receive {:fallback, _} - end - end - - describe "custom event routing" do - test "routes custom events to focused component" do - {:ok, pid} = TestComponent.start_link(test_pid: self()) - :ok = ComponentRegistry.register(:form, pid, TestComponent) - :ok = EventRouter.set_focus(:form) - - # Clear focus event - assert_receive {:event_received, %Event.Focus{}} - - event = Event.custom(:submit, %{data: "test"}) - assert :handled = EventRouter.route(event) - - assert_receive {:event_received, ^event} - end - end -end diff --git a/test/term_ui/event_test.exs b/test/term_ui/event_test.exs deleted file mode 100644 index ad85ffa1..00000000 --- a/test/term_ui/event_test.exs +++ /dev/null @@ -1,283 +0,0 @@ -defmodule TermUI.EventTest do - use ExUnit.Case, async: true - - alias TermUI.Event - alias TermUI.Event.Custom - alias TermUI.Event.Focus - alias TermUI.Event.Key - alias TermUI.Event.Mouse - alias TermUI.Event.Paste - alias TermUI.Event.Resize - alias TermUI.Event.Tick - - describe "Key event" do - test "creates key event with defaults" do - event = Event.key(:enter) - - assert %Key{} = event - assert event.key == :enter - assert event.char == nil - assert event.modifiers == [] - assert is_integer(event.timestamp) - end - - test "creates key event with char" do - event = Event.key(:a, char: "a") - - assert event.key == :a - assert event.char == "a" - end - - test "creates key event with modifiers" do - event = Event.key(:c, modifiers: [:ctrl]) - - assert event.key == :c - assert event.modifiers == [:ctrl] - end - - test "creates key event with multiple modifiers" do - event = Event.key(:s, modifiers: [:ctrl, :shift]) - - assert event.modifiers == [:ctrl, :shift] - end - - test "creates key event with custom timestamp" do - event = Event.key(:enter, timestamp: 12_345) - - assert event.timestamp == 12_345 - end - - test "key? returns true for key events" do - event = Event.key(:enter) - assert Event.key?(event) - end - - test "key? returns false for non-key events" do - refute Event.key?(%Mouse{}) - refute Event.key?(%Focus{}) - refute Event.key?("string") - refute Event.key?(nil) - end - end - - describe "Mouse event" do - test "creates mouse event with position" do - event = Event.mouse(:click, :left, 10, 20) - - assert %Mouse{} = event - assert event.action == :click - assert event.button == :left - assert event.x == 10 - assert event.y == 20 - assert event.modifiers == [] - assert is_integer(event.timestamp) - end - - test "creates move event without button" do - event = Event.mouse(:move, nil, 15, 25) - - assert event.action == :move - assert event.button == nil - end - - test "creates scroll event" do - event = Event.mouse(:scroll_up, nil, 10, 10) - - assert event.action == :scroll_up - end - - test "creates mouse event with modifiers" do - event = Event.mouse(:click, :left, 5, 5, modifiers: [:shift]) - - assert event.modifiers == [:shift] - end - - test "mouse? returns true for mouse events" do - event = Event.mouse(:click, :left, 0, 0) - assert Event.mouse?(event) - end - - test "mouse? returns false for non-mouse events" do - refute Event.mouse?(%Key{}) - refute Event.mouse?(%Focus{}) - end - end - - describe "Focus event" do - test "creates focus gained event" do - event = Event.focus(:gained) - - assert %Focus{} = event - assert event.action == :gained - assert is_integer(event.timestamp) - end - - test "creates focus lost event" do - event = Event.focus(:lost) - - assert event.action == :lost - end - - test "focus? returns true for focus events" do - event = Event.focus(:gained) - assert Event.focus?(event) - end - - test "focus? returns false for non-focus events" do - refute Event.focus?(%Key{}) - refute Event.focus?(%Mouse{}) - end - end - - describe "Custom event" do - test "creates custom event with name" do - event = Event.custom(:submit) - - assert %Custom{} = event - assert event.name == :submit - assert event.payload == nil - assert is_integer(event.timestamp) - end - - test "creates custom event with payload" do - event = Event.custom(:submit, %{value: "hello"}) - - assert event.name == :submit - assert event.payload == %{value: "hello"} - end - - test "custom? returns true for custom events" do - event = Event.custom(:test) - assert Event.custom?(event) - end - - test "custom? returns false for non-custom events" do - refute Event.custom?(%Key{}) - refute Event.custom?(%Mouse{}) - end - end - - describe "Resize event" do - test "creates resize event with dimensions" do - event = Event.resize(120, 40) - - assert %Resize{} = event - assert event.width == 120 - assert event.height == 40 - assert is_integer(event.timestamp) - end - - test "resize? returns true for resize events" do - event = Event.resize(80, 24) - assert Event.resize?(event) - end - - test "resize? returns false for non-resize events" do - refute Event.resize?(%Key{}) - refute Event.resize?(%Mouse{}) - end - end - - describe "Paste event" do - test "creates paste event with content" do - event = Event.paste("Hello, World!") - - assert %Paste{} = event - assert event.content == "Hello, World!" - assert is_integer(event.timestamp) - end - - test "creates paste event with empty string" do - event = Event.paste("") - assert event.content == "" - end - - test "paste? returns true for paste events" do - event = Event.paste("test") - assert Event.paste?(event) - end - - test "paste? returns false for non-paste events" do - refute Event.paste?(%Key{}) - refute Event.paste?(%Mouse{}) - end - end - - describe "Tick event" do - test "creates tick event with interval" do - event = Event.tick(16) - - assert %Tick{} = event - assert event.interval == 16 - assert is_integer(event.timestamp) - end - - test "tick rate calculation" do - event = Event.tick(16) - assert_in_delta Tick.rate(event), 62.5, 0.1 - end - - test "tick? returns true for tick events" do - event = Event.tick(1000) - assert Event.tick?(event) - end - - test "tick? returns false for non-tick events" do - refute Event.tick?(%Key{}) - refute Event.tick?(%Mouse{}) - end - end - - describe "type/1" do - test "returns :key for key events" do - assert Event.type(%Key{}) == :key - end - - test "returns :mouse for mouse events" do - assert Event.type(%Mouse{}) == :mouse - end - - test "returns :focus for focus events" do - assert Event.type(%Focus{}) == :focus - end - - test "returns :custom for custom events" do - assert Event.type(%Custom{}) == :custom - end - - test "returns :resize for resize events" do - assert Event.type(%Resize{}) == :resize - end - - test "returns :paste for paste events" do - assert Event.type(%Paste{}) == :paste - end - - test "returns :tick for tick events" do - assert Event.type(%Tick{}) == :tick - end - end - - describe "has_modifier?/2" do - test "returns true when modifier present in key event" do - event = Event.key(:c, modifiers: [:ctrl, :shift]) - - assert Event.has_modifier?(event, :ctrl) - assert Event.has_modifier?(event, :shift) - end - - test "returns false when modifier not present" do - event = Event.key(:c, modifiers: [:ctrl]) - - refute Event.has_modifier?(event, :shift) - refute Event.has_modifier?(event, :alt) - end - - test "works with mouse events" do - event = Event.mouse(:click, :left, 0, 0, modifiers: [:ctrl]) - - assert Event.has_modifier?(event, :ctrl) - refute Event.has_modifier?(event, :shift) - end - end -end diff --git a/test/term_ui/focus/indicator_test.exs b/test/term_ui/focus/indicator_test.exs deleted file mode 100644 index a50fd356..00000000 --- a/test/term_ui/focus/indicator_test.exs +++ /dev/null @@ -1,170 +0,0 @@ -defmodule TermUI.Focus.IndicatorTest do - use ExUnit.Case, async: true - - alias TermUI.Focus.Indicator - alias TermUI.Renderer.Style - - describe "default_style/0" do - test "returns a style map" do - style = Indicator.default_style() - - assert is_map(style) - assert Map.has_key?(style, :fg) - assert Map.has_key?(style, :bg) - assert Map.has_key?(style, :bold) - assert Map.has_key?(style, :border) - end - - test "uses cyan as default color" do - style = Indicator.default_style() - - assert style.fg == :cyan - end - - test "has bold enabled by default" do - style = Indicator.default_style() - - assert style.bold == true - end - end - - describe "get_style/2" do - test "returns default style when no custom" do - style = Indicator.get_style(:button) - - assert style == Indicator.default_style() - end - - test "merges custom style with default" do - custom = %{fg: :yellow} - opts = [styles: %{button: custom}] - - style = Indicator.get_style(:button, opts) - - assert style.fg == :yellow - # Other properties from default - assert style.bold == true - end - - test "custom style overrides all properties" do - custom = %{fg: :red, bg: :blue, bold: false, border: :double} - opts = [styles: %{button: custom}] - - style = Indicator.get_style(:button, opts) - - assert style == custom - end - end - - describe "to_render_style/1" do - test "creates Style struct from indicator" do - indicator = %{fg: :cyan, bg: nil, bold: true, border: :single} - - result = Indicator.to_render_style(indicator) - - assert %Style{} = result - end - - test "sets foreground color" do - indicator = %{fg: :cyan, bg: nil, bold: false, border: nil} - - result = Indicator.to_render_style(indicator) - - assert result.fg == :cyan - end - - test "sets background color" do - indicator = %{fg: nil, bg: :blue, bold: false, border: nil} - - result = Indicator.to_render_style(indicator) - - assert result.bg == :blue - end - - test "sets bold" do - indicator = %{fg: nil, bg: nil, bold: true, border: nil} - - result = Indicator.to_render_style(indicator) - - assert :bold in result.attrs - end - - test "handles nil values" do - indicator = %{fg: nil, bg: nil, bold: false, border: nil} - - result = Indicator.to_render_style(indicator) - - assert %Style{} = result - end - end - - describe "focus_border_color/0" do - test "returns a color atom" do - color = Indicator.focus_border_color() - - assert is_atom(color) - assert color == :cyan - end - end - - describe "animate?/0" do - test "returns a boolean" do - result = Indicator.animate?() - - assert is_boolean(result) - end - end - - describe "themes/0" do - test "returns map of themes" do - themes = Indicator.themes() - - assert is_map(themes) - assert Map.has_key?(themes, :default) - assert Map.has_key?(themes, :subtle) - assert Map.has_key?(themes, :bold) - assert Map.has_key?(themes, :minimal) - end - - test "default theme matches default_style" do - themes = Indicator.themes() - - assert themes[:default] == Indicator.default_style() - end - - test "all themes have required keys" do - themes = Indicator.themes() - - for {_name, theme} <- themes do - assert Map.has_key?(theme, :fg) - assert Map.has_key?(theme, :bg) - assert Map.has_key?(theme, :bold) - assert Map.has_key?(theme, :border) - end - end - end - - describe "get_theme/1" do - test "returns theme by name" do - theme = Indicator.get_theme(:bold) - - assert theme.fg == :yellow - assert theme.bg == :blue - end - - test "returns default for unknown theme" do - theme = Indicator.get_theme(:unknown) - - assert theme == Indicator.default_style() - end - - test "minimal theme has no styling" do - theme = Indicator.get_theme(:minimal) - - assert theme.fg == nil - assert theme.bg == nil - assert theme.bold == false - assert theme.border == nil - end - end -end diff --git a/test/term_ui/focus/traversal_test.exs b/test/term_ui/focus/traversal_test.exs deleted file mode 100644 index 2c3e2131..00000000 --- a/test/term_ui/focus/traversal_test.exs +++ /dev/null @@ -1,184 +0,0 @@ -defmodule TermUI.Focus.TraversalTest do - use ExUnit.Case - - alias TermUI.Focus.Traversal - alias TermUI.SpatialIndex - - setup do - start_supervised!(SpatialIndex) - :ok - end - - describe "calculate_order/2" do - test "orders by position when no tab indices" do - pid = self() - :ok = SpatialIndex.update(:c, pid, %{x: 0, y: 2, width: 1, height: 1}) - :ok = SpatialIndex.update(:a, pid, %{x: 0, y: 0, width: 1, height: 1}) - :ok = SpatialIndex.update(:b, pid, %{x: 0, y: 1, width: 1, height: 1}) - - result = Traversal.calculate_order([:c, :a, :b]) - - assert result == [:a, :b, :c] - end - - test "tab_index takes precedence over position" do - pid = self() - :ok = SpatialIndex.update(:a, pid, %{x: 0, y: 0, width: 1, height: 1}) - :ok = SpatialIndex.update(:b, pid, %{x: 0, y: 1, width: 1, height: 1}) - :ok = SpatialIndex.update(:c, pid, %{x: 0, y: 2, width: 1, height: 1}) - - tab_indices = %{a: 3, b: 1, c: 2} - result = Traversal.calculate_order([:a, :b, :c], tab_indices: tab_indices) - - assert result == [:b, :c, :a] - end - - test "nil tab_index sorts last" do - pid = self() - :ok = SpatialIndex.update(:a, pid, %{x: 0, y: 0, width: 1, height: 1}) - :ok = SpatialIndex.update(:b, pid, %{x: 0, y: 1, width: 1, height: 1}) - :ok = SpatialIndex.update(:c, pid, %{x: 0, y: 2, width: 1, height: 1}) - - tab_indices = %{a: 1, c: 2} - result = Traversal.calculate_order([:a, :b, :c], tab_indices: tab_indices) - - # b has nil tab_index, should be last - assert List.last(result) == :b - end - end - - describe "next/2" do - test "returns first when current is nil" do - list = [:a, :b, :c] - - assert Traversal.next(list, nil) == :a - end - - test "returns next component in list" do - list = [:a, :b, :c] - - assert Traversal.next(list, :a) == :b - assert Traversal.next(list, :b) == :c - end - - test "wraps around to first" do - list = [:a, :b, :c] - - assert Traversal.next(list, :c) == :a - end - - test "returns first when current not in list" do - list = [:a, :b, :c] - - assert Traversal.next(list, :unknown) == :a - end - - test "returns nil for empty list" do - assert Traversal.next([], :a) == nil - end - end - - describe "prev/2" do - test "returns last when current is nil" do - list = [:a, :b, :c] - - assert Traversal.prev(list, nil) == :c - end - - test "returns previous component in list" do - list = [:a, :b, :c] - - assert Traversal.prev(list, :c) == :b - assert Traversal.prev(list, :b) == :a - end - - test "wraps around to last" do - list = [:a, :b, :c] - - assert Traversal.prev(list, :a) == :c - end - - test "returns last when current not in list" do - list = [:a, :b, :c] - - assert Traversal.prev(list, :unknown) == :c - end - - test "returns nil for empty list" do - assert Traversal.prev([], :a) == nil - end - end - - describe "should_skip?/2" do - test "returns false by default" do - refute Traversal.should_skip?(:component) - end - - test "returns true when focusable is false" do - opts = [focusable: %{component: false}] - - assert Traversal.should_skip?(:component, opts) - end - - test "returns true when disabled is true" do - opts = [disabled: %{component: true}] - - assert Traversal.should_skip?(:component, opts) - end - - test "returns true when tab_index is negative" do - opts = [tab_indices: %{component: -1}] - - assert Traversal.should_skip?(:component, opts) - end - - test "returns false for positive tab_index" do - opts = [tab_indices: %{component: 5}] - - refute Traversal.should_skip?(:component, opts) - end - - test "returns false for zero tab_index" do - opts = [tab_indices: %{component: 0}] - - refute Traversal.should_skip?(:component, opts) - end - end - - describe "filter_focusable/2" do - test "removes non-focusable components" do - components = [:a, :b, :c] - opts = [focusable: %{b: false}] - - result = Traversal.filter_focusable(components, opts) - - assert result == [:a, :c] - end - - test "removes disabled components" do - components = [:a, :b, :c] - opts = [disabled: %{a: true}] - - result = Traversal.filter_focusable(components, opts) - - assert result == [:b, :c] - end - - test "removes components with negative tab_index" do - components = [:a, :b, :c] - opts = [tab_indices: %{c: -1}] - - result = Traversal.filter_focusable(components, opts) - - assert result == [:a, :b] - end - - test "returns all when no filters" do - components = [:a, :b, :c] - - result = Traversal.filter_focusable(components) - - assert result == [:a, :b, :c] - end - end -end diff --git a/test/term_ui/focus_manager_test.exs b/test/term_ui/focus_manager_test.exs deleted file mode 100644 index 9cab1823..00000000 --- a/test/term_ui/focus_manager_test.exs +++ /dev/null @@ -1,365 +0,0 @@ -defmodule TermUI.FocusManagerTest do - use ExUnit.Case - - alias TermUI.ComponentRegistry - alias TermUI.Event - alias TermUI.EventRouter - alias TermUI.FocusManager - alias TermUI.SpatialIndex - - # Test component that tracks received events - defmodule TestComponent do - use GenServer - - def start_link(opts) do - test_pid = Keyword.get(opts, :test_pid, self()) - GenServer.start_link(__MODULE__, %{test_pid: test_pid}) - end - - @impl true - def init(state), do: {:ok, state} - - @impl true - def handle_call({:event, event}, _from, state) do - send(state.test_pid, {:event_received, event}) - {:reply, :handled, state} - end - end - - setup do - start_supervised!(ComponentRegistry) - start_supervised!(SpatialIndex) - start_supervised!(EventRouter) - start_supervised!(FocusManager) - :ok - end - - describe "get_focused/0 and set_focused/1" do - test "returns nil when no focus" do - assert {:ok, nil} = FocusManager.get_focused() - end - - test "sets and gets focused component" do - {:ok, pid} = TestComponent.start_link(test_pid: self()) - :ok = ComponentRegistry.register(:input, pid, TestComponent) - - :ok = FocusManager.set_focused(:input) - - assert {:ok, :input} = FocusManager.get_focused() - end - - test "sends focus event to new component" do - {:ok, pid} = TestComponent.start_link(test_pid: self()) - :ok = ComponentRegistry.register(:input, pid, TestComponent) - - :ok = FocusManager.set_focused(:input) - - assert_receive {:event_received, %Event.Focus{action: :gained}} - end - - test "sends blur event to old component" do - {:ok, pid1} = TestComponent.start_link(test_pid: self()) - {:ok, pid2} = TestComponent.start_link(test_pid: self()) - - :ok = ComponentRegistry.register(:input1, pid1, TestComponent) - :ok = ComponentRegistry.register(:input2, pid2, TestComponent) - - :ok = FocusManager.set_focused(:input1) - assert_receive {:event_received, %Event.Focus{action: :gained}} - - :ok = FocusManager.set_focused(:input2) - assert_receive {:event_received, %Event.Focus{action: :lost}} - assert_receive {:event_received, %Event.Focus{action: :gained}} - end - - test "returns error for non-existent component" do - assert {:error, :not_found} = FocusManager.set_focused(:nonexistent) - end - - test "clear_focus clears the focus" do - {:ok, pid} = TestComponent.start_link(test_pid: self()) - :ok = ComponentRegistry.register(:input, pid, TestComponent) - - :ok = FocusManager.set_focused(:input) - :ok = FocusManager.clear_focus() - - assert {:ok, nil} = FocusManager.get_focused() - end - - test "setting same focus doesn't send duplicate events" do - {:ok, pid} = TestComponent.start_link(test_pid: self()) - :ok = ComponentRegistry.register(:input, pid, TestComponent) - - :ok = FocusManager.set_focused(:input) - # Receive focus gained from EventRouter - assert_receive {:event_received, %Event.Focus{action: :gained}} - - :ok = FocusManager.set_focused(:input) - # No more events should be sent - refute_receive {:event_received, _}, 50 - end - end - - describe "focus_next/0 and focus_prev/0" do - test "focus_next moves to first when no focus" do - {:ok, pid} = TestComponent.start_link(test_pid: self()) - :ok = ComponentRegistry.register(:input, pid, TestComponent) - :ok = SpatialIndex.update(:input, pid, %{x: 0, y: 0, width: 10, height: 1}) - - :ok = FocusManager.focus_next() - - assert {:ok, :input} = FocusManager.get_focused() - end - - test "focus_next moves to next component" do - {:ok, pid1} = TestComponent.start_link(test_pid: self()) - {:ok, pid2} = TestComponent.start_link(test_pid: self()) - - :ok = ComponentRegistry.register(:input1, pid1, TestComponent) - :ok = ComponentRegistry.register(:input2, pid2, TestComponent) - :ok = SpatialIndex.update(:input1, pid1, %{x: 0, y: 0, width: 10, height: 1}) - :ok = SpatialIndex.update(:input2, pid2, %{x: 0, y: 1, width: 10, height: 1}) - - :ok = FocusManager.set_focused(:input1) - # Clear events - assert_receive {:event_received, _} - - :ok = FocusManager.focus_next() - assert {:ok, :input2} = FocusManager.get_focused() - end - - test "focus_next wraps around" do - {:ok, pid1} = TestComponent.start_link(test_pid: self()) - {:ok, pid2} = TestComponent.start_link(test_pid: self()) - - :ok = ComponentRegistry.register(:input1, pid1, TestComponent) - :ok = ComponentRegistry.register(:input2, pid2, TestComponent) - :ok = SpatialIndex.update(:input1, pid1, %{x: 0, y: 0, width: 10, height: 1}) - :ok = SpatialIndex.update(:input2, pid2, %{x: 0, y: 1, width: 10, height: 1}) - - :ok = FocusManager.set_focused(:input2) - - :ok = FocusManager.focus_next() - assert {:ok, :input1} = FocusManager.get_focused() - end - - test "focus_prev moves to last when no focus" do - {:ok, pid1} = TestComponent.start_link(test_pid: self()) - {:ok, pid2} = TestComponent.start_link(test_pid: self()) - - :ok = ComponentRegistry.register(:input1, pid1, TestComponent) - :ok = ComponentRegistry.register(:input2, pid2, TestComponent) - :ok = SpatialIndex.update(:input1, pid1, %{x: 0, y: 0, width: 10, height: 1}) - :ok = SpatialIndex.update(:input2, pid2, %{x: 0, y: 1, width: 10, height: 1}) - - :ok = FocusManager.focus_prev() - assert {:ok, :input2} = FocusManager.get_focused() - end - - test "focus_prev wraps around" do - {:ok, pid1} = TestComponent.start_link(test_pid: self()) - {:ok, pid2} = TestComponent.start_link(test_pid: self()) - - :ok = ComponentRegistry.register(:input1, pid1, TestComponent) - :ok = ComponentRegistry.register(:input2, pid2, TestComponent) - :ok = SpatialIndex.update(:input1, pid1, %{x: 0, y: 0, width: 10, height: 1}) - :ok = SpatialIndex.update(:input2, pid2, %{x: 0, y: 1, width: 10, height: 1}) - - :ok = FocusManager.set_focused(:input1) - - :ok = FocusManager.focus_prev() - assert {:ok, :input2} = FocusManager.get_focused() - end - - test "returns error when no focusable components" do - assert {:error, :no_focusable} = FocusManager.focus_next() - assert {:error, :no_focusable} = FocusManager.focus_prev() - end - end - - describe "focus stack" do - test "push_focus pushes current to stack" do - {:ok, pid1} = TestComponent.start_link(test_pid: self()) - {:ok, pid2} = TestComponent.start_link(test_pid: self()) - - :ok = ComponentRegistry.register(:input1, pid1, TestComponent) - :ok = ComponentRegistry.register(:input2, pid2, TestComponent) - - :ok = FocusManager.set_focused(:input1) - :ok = FocusManager.push_focus(:input2) - - assert {:ok, :input2} = FocusManager.get_focused() - assert [:input1] = FocusManager.get_stack() - end - - test "pop_focus restores previous focus" do - {:ok, pid1} = TestComponent.start_link(test_pid: self()) - {:ok, pid2} = TestComponent.start_link(test_pid: self()) - - :ok = ComponentRegistry.register(:input1, pid1, TestComponent) - :ok = ComponentRegistry.register(:input2, pid2, TestComponent) - - :ok = FocusManager.set_focused(:input1) - :ok = FocusManager.push_focus(:input2) - :ok = FocusManager.pop_focus() - - assert {:ok, :input1} = FocusManager.get_focused() - assert [] = FocusManager.get_stack() - end - - test "pop_focus returns error on empty stack" do - assert {:error, :empty_stack} = FocusManager.pop_focus() - end - - test "nested push/pop works correctly" do - {:ok, pid1} = TestComponent.start_link(test_pid: self()) - {:ok, pid2} = TestComponent.start_link(test_pid: self()) - {:ok, pid3} = TestComponent.start_link(test_pid: self()) - - :ok = ComponentRegistry.register(:input1, pid1, TestComponent) - :ok = ComponentRegistry.register(:input2, pid2, TestComponent) - :ok = ComponentRegistry.register(:input3, pid3, TestComponent) - - :ok = FocusManager.set_focused(:input1) - :ok = FocusManager.push_focus(:input2) - :ok = FocusManager.push_focus(:input3) - - assert {:ok, :input3} = FocusManager.get_focused() - assert [:input2, :input1] = FocusManager.get_stack() - - :ok = FocusManager.pop_focus() - assert {:ok, :input2} = FocusManager.get_focused() - - :ok = FocusManager.pop_focus() - assert {:ok, :input1} = FocusManager.get_focused() - end - end - - describe "focus groups and trapping" do - test "register_group creates a focus group" do - :ok = FocusManager.register_group(:modal, [:btn1, :btn2, :btn3]) - - groups = FocusManager.get_groups() - assert Map.has_key?(groups, :modal) - assert groups[:modal] == [:btn1, :btn2, :btn3] - end - - test "unregister_group removes a focus group" do - :ok = FocusManager.register_group(:modal, [:btn1, :btn2]) - :ok = FocusManager.unregister_group(:modal) - - groups = FocusManager.get_groups() - refute Map.has_key?(groups, :modal) - end - - test "trap_focus restricts navigation to group" do - {:ok, pid1} = TestComponent.start_link(test_pid: self()) - {:ok, pid2} = TestComponent.start_link(test_pid: self()) - {:ok, pid3} = TestComponent.start_link(test_pid: self()) - - :ok = ComponentRegistry.register(:outside, pid1, TestComponent) - :ok = ComponentRegistry.register(:modal_btn1, pid2, TestComponent) - :ok = ComponentRegistry.register(:modal_btn2, pid3, TestComponent) - - :ok = SpatialIndex.update(:outside, pid1, %{x: 0, y: 0, width: 10, height: 1}) - :ok = SpatialIndex.update(:modal_btn1, pid2, %{x: 0, y: 1, width: 10, height: 1}) - :ok = SpatialIndex.update(:modal_btn2, pid3, %{x: 0, y: 2, width: 10, height: 1}) - - :ok = FocusManager.register_group(:modal, [:modal_btn1, :modal_btn2]) - :ok = FocusManager.trap_focus(:modal) - :ok = FocusManager.set_focused(:modal_btn1) - - # Focus should cycle within group - :ok = FocusManager.focus_next() - assert {:ok, :modal_btn2} = FocusManager.get_focused() - - :ok = FocusManager.focus_next() - assert {:ok, :modal_btn1} = FocusManager.get_focused() - end - - test "release_focus exits the trap" do - {:ok, pid1} = TestComponent.start_link(test_pid: self()) - {:ok, pid2} = TestComponent.start_link(test_pid: self()) - - :ok = ComponentRegistry.register(:outside, pid1, TestComponent) - :ok = ComponentRegistry.register(:modal_btn, pid2, TestComponent) - - :ok = SpatialIndex.update(:outside, pid1, %{x: 0, y: 0, width: 10, height: 1}) - :ok = SpatialIndex.update(:modal_btn, pid2, %{x: 0, y: 1, width: 10, height: 1}) - - :ok = FocusManager.register_group(:modal, [:modal_btn]) - :ok = FocusManager.trap_focus(:modal) - :ok = FocusManager.release_focus() - - :ok = FocusManager.set_focused(:modal_btn) - :ok = FocusManager.focus_next() - - # Should navigate to outside component now - assert {:ok, :outside} = FocusManager.get_focused() - end - - test "trap_focus returns error for unknown group" do - assert {:error, :group_not_found} = FocusManager.trap_focus(:unknown) - end - end - - describe "focused?/1" do - test "returns true when component is focused" do - {:ok, pid} = TestComponent.start_link(test_pid: self()) - :ok = ComponentRegistry.register(:input, pid, TestComponent) - - :ok = FocusManager.set_focused(:input) - - assert FocusManager.focused?(:input) - end - - test "returns false when component is not focused" do - {:ok, pid} = TestComponent.start_link(test_pid: self()) - :ok = ComponentRegistry.register(:input, pid, TestComponent) - - refute FocusManager.focused?(:input) - end - - test "returns false when different component is focused" do - {:ok, pid1} = TestComponent.start_link(test_pid: self()) - {:ok, pid2} = TestComponent.start_link(test_pid: self()) - - :ok = ComponentRegistry.register(:input1, pid1, TestComponent) - :ok = ComponentRegistry.register(:input2, pid2, TestComponent) - - :ok = FocusManager.set_focused(:input1) - - refute FocusManager.focused?(:input2) - end - end - - describe "request_auto_focus/1" do - test "sets focus when nothing is focused" do - {:ok, pid} = TestComponent.start_link(test_pid: self()) - :ok = ComponentRegistry.register(:input, pid, TestComponent) - - FocusManager.request_auto_focus(:input) - - # Give cast time to process - Process.sleep(10) - - assert {:ok, :input} = FocusManager.get_focused() - end - - test "does not change focus when something is already focused" do - {:ok, pid1} = TestComponent.start_link(test_pid: self()) - {:ok, pid2} = TestComponent.start_link(test_pid: self()) - - :ok = ComponentRegistry.register(:input1, pid1, TestComponent) - :ok = ComponentRegistry.register(:input2, pid2, TestComponent) - - :ok = FocusManager.set_focused(:input1) - FocusManager.request_auto_focus(:input2) - - # Give cast time to process - Process.sleep(10) - - assert {:ok, :input1} = FocusManager.get_focused() - end - end -end diff --git a/test/term_ui/focus_test.exs b/test/term_ui/focus_test.exs deleted file mode 100644 index 279f5c74..00000000 --- a/test/term_ui/focus_test.exs +++ /dev/null @@ -1,315 +0,0 @@ -defmodule TermUI.FocusTest do - use ExUnit.Case, async: true - - alias TermUI.Focus - - describe "enable/0" do - test "returns correct escape sequence" do - assert Focus.enable() == "\e[?1004h" - end - end - - describe "disable/0" do - test "returns correct escape sequence" do - assert Focus.disable() == "\e[?1004l" - end - end - - describe "gained_sequence/0" do - test "returns focus gained sequence" do - assert Focus.gained_sequence() == "\e[I" - end - end - - describe "lost_sequence/0" do - test "returns focus lost sequence" do - assert Focus.lost_sequence() == "\e[O" - end - end - - describe "supported?/0" do - test "returns boolean" do - result = Focus.supported?() - assert is_boolean(result) - end - end - - describe "parse/1" do - test "parses focus gained sequence" do - assert Focus.parse("\e[I") == {:focus, :gained} - end - - test "parses focus lost sequence" do - assert Focus.parse("\e[O") == {:focus, :lost} - end - - test "returns nil for non-focus input" do - assert Focus.parse("hello") == nil - assert Focus.parse("\e[A") == nil - end - end -end - -defmodule TermUI.Focus.TrackerTest do - use ExUnit.Case, async: true - - alias TermUI.Focus.Tracker - - describe "start_link/1" do - test "starts tracker" do - {:ok, tracker} = Tracker.start_link() - assert is_pid(tracker) - end - - test "starts with registered name" do - {:ok, _} = Tracker.start_link(name: :test_focus_tracker) - assert is_pid(Process.whereis(:test_focus_tracker)) - GenServer.stop(:test_focus_tracker) - end - - test "starts with initial focus state" do - {:ok, tracker} = Tracker.start_link(initial_focus: false) - refute Tracker.has_focus?(tracker) - end - end - - describe "has_focus?/1" do - test "returns true by default" do - {:ok, tracker} = Tracker.start_link() - assert Tracker.has_focus?(tracker) - end - - test "returns initial focus state" do - {:ok, tracker} = Tracker.start_link(initial_focus: false) - refute Tracker.has_focus?(tracker) - end - end - - describe "set_focus/2" do - test "updates focus state" do - {:ok, tracker} = Tracker.start_link() - - Tracker.set_focus(tracker, false) - refute Tracker.has_focus?(tracker) - - Tracker.set_focus(tracker, true) - assert Tracker.has_focus?(tracker) - end - end - - describe "on_focus_gained/2" do - test "registers and executes action on focus gained" do - {:ok, tracker} = Tracker.start_link(initial_focus: false) - - test_pid = self() - - Tracker.on_focus_gained(tracker, fn -> - send(test_pid, :focus_gained) - end) - - Tracker.set_focus(tracker, true) - - assert_receive :focus_gained, 100 - end - - test "does not execute when focus already gained" do - {:ok, tracker} = Tracker.start_link(initial_focus: true) - - test_pid = self() - - Tracker.on_focus_gained(tracker, fn -> - send(test_pid, :focus_gained) - end) - - Tracker.set_focus(tracker, true) - - refute_receive :focus_gained, 50 - end - - test "registers multiple actions" do - {:ok, tracker} = Tracker.start_link(initial_focus: false) - - test_pid = self() - - Tracker.on_focus_gained(tracker, fn -> - send(test_pid, :action1) - end) - - Tracker.on_focus_gained(tracker, fn -> - send(test_pid, :action2) - end) - - Tracker.set_focus(tracker, true) - - assert_receive :action1, 100 - assert_receive :action2, 100 - end - end - - describe "on_focus_lost/2" do - test "registers and executes action on focus lost" do - {:ok, tracker} = Tracker.start_link(initial_focus: true) - - test_pid = self() - - Tracker.on_focus_lost(tracker, fn -> - send(test_pid, :focus_lost) - end) - - Tracker.set_focus(tracker, false) - - assert_receive :focus_lost, 100 - end - - test "does not execute when focus already lost" do - {:ok, tracker} = Tracker.start_link(initial_focus: false) - - test_pid = self() - - Tracker.on_focus_lost(tracker, fn -> - send(test_pid, :focus_lost) - end) - - Tracker.set_focus(tracker, false) - - refute_receive :focus_lost, 50 - end - end - - describe "clear_actions/1" do - test "clears all registered actions" do - {:ok, tracker} = Tracker.start_link(initial_focus: false) - - test_pid = self() - - Tracker.on_focus_gained(tracker, fn -> - send(test_pid, :should_not_receive) - end) - - Tracker.clear_actions(tracker) - Tracker.set_focus(tracker, true) - - refute_receive :should_not_receive, 50 - end - end - - describe "paused?/1 and set_paused/2" do - test "returns false by default" do - {:ok, tracker} = Tracker.start_link() - refute Tracker.paused?(tracker) - end - - test "sets paused state" do - {:ok, tracker} = Tracker.start_link() - - Tracker.set_paused(tracker, true) - assert Tracker.paused?(tracker) - - Tracker.set_paused(tracker, false) - refute Tracker.paused?(tracker) - end - end - - describe "reduced_framerate?/1 and set_reduced_framerate/2" do - test "returns false by default" do - {:ok, tracker} = Tracker.start_link() - refute Tracker.reduced_framerate?(tracker) - end - - test "sets reduced framerate state" do - {:ok, tracker} = Tracker.start_link() - - Tracker.set_reduced_framerate(tracker, true) - assert Tracker.reduced_framerate?(tracker) - - Tracker.set_reduced_framerate(tracker, false) - refute Tracker.reduced_framerate?(tracker) - end - end - - describe "enable_auto_pause/1" do - test "pauses on focus lost and resumes on focus gained" do - {:ok, tracker} = Tracker.start_link(initial_focus: true) - - Tracker.enable_auto_pause(tracker) - - refute Tracker.paused?(tracker) - - Tracker.set_focus(tracker, false) - assert Tracker.paused?(tracker) - - Tracker.set_focus(tracker, true) - refute Tracker.paused?(tracker) - end - end - - describe "enable_auto_reduce_framerate/1" do - test "reduces framerate on focus lost and restores on focus gained" do - {:ok, tracker} = Tracker.start_link(initial_focus: true) - - Tracker.enable_auto_reduce_framerate(tracker) - - refute Tracker.reduced_framerate?(tracker) - - Tracker.set_focus(tracker, false) - assert Tracker.reduced_framerate?(tracker) - - Tracker.set_focus(tracker, true) - refute Tracker.reduced_framerate?(tracker) - end - end - - describe "error handling" do - test "continues after action raises error" do - {:ok, tracker} = Tracker.start_link(initial_focus: false) - - test_pid = self() - - # First action raises - Tracker.on_focus_gained(tracker, fn -> - raise "boom" - end) - - # Second action should still execute - Tracker.on_focus_gained(tracker, fn -> - send(test_pid, :second_action) - end) - - Tracker.set_focus(tracker, true) - - assert_receive :second_action, 100 - end - end - - describe "integration" do - test "full focus workflow" do - {:ok, tracker} = Tracker.start_link(initial_focus: true) - - test_pid = self() - state = %{saved: false, refreshed: false} - - # Register autosave on focus lost - Tracker.on_focus_lost(tracker, fn -> - send(test_pid, {:state_update, :saved}) - end) - - # Register refresh on focus gained - Tracker.on_focus_gained(tracker, fn -> - send(test_pid, {:state_update, :refreshed}) - end) - - # Enable auto pause - Tracker.enable_auto_pause(tracker) - - # Lose focus - Tracker.set_focus(tracker, false) - assert_receive {:state_update, :saved}, 100 - assert Tracker.paused?(tracker) - - # Gain focus - Tracker.set_focus(tracker, true) - assert_receive {:state_update, :refreshed}, 100 - refute Tracker.paused?(tracker) - end - end -end diff --git a/test/term_ui/frame_contract_test.exs b/test/term_ui/frame_contract_test.exs new file mode 100644 index 00000000..34bcfb68 --- /dev/null +++ b/test/term_ui/frame_contract_test.exs @@ -0,0 +1,57 @@ +defmodule TermUI.FrameContractTest do + use ExUnit.Case, async: true + + alias TermUI.{Cell, Frame, Style} + + test "normalizes existing cells before they reach a backend" do + unsafe = %Cell{char: "\e]52;c;payload\a", width: 1} + frame = Frame.new(4, 1, cells: %{{1, 1} => unsafe}) + + assert Frame.row_text(frame, 1) == " " + refute inspect(Frame.cells(frame)) =~ "52;c" + end + + test "normalizes text, wide graphemes, combining graphemes, and the cursor" do + frame = Frame.from_rows(["a界b", "e\u0301"], 4, 2, cursor: {99, 2}) + + assert frame.width == 4 + assert frame.height == 2 + assert frame.cursor == {4, 2} + assert %Cell{char: "界", width: 2} = Frame.cell(frame, 1, 2) + assert %Cell{wide_placeholder: true} = Frame.cell(frame, 1, 3) + assert %Cell{char: "e\u0301", width: 1} = Frame.cell(frame, 2, 1) + assert Frame.row_text(frame, 1) == "a界b" + end + + test "accepts styled spans as the same cell frame representation" do + style = Style.new() |> Style.fg(:green) |> Style.bold() + frame = Frame.from_rows([[{"ok", style}, "!"]], 4, 1) + + assert %Cell{char: "o", fg: :green, attrs: attrs} = Frame.cell(frame, 1, 1) + assert :bold in attrs + assert Frame.row_text(frame, 1) == "ok! " + end + + test "safe tiny frames and frame comparison clear old content" do + old = Frame.from_rows(["x"], 1, 1) + new = Frame.from_rows([""], 1, 1, cursor: {9, 9}) + + assert new.cursor == {1, 1} + assert Frame.diff(old, new) == [{{1, 1}, {" ", :default, :default, []}}] + end + + test "one cell contains one grapheme and updates its display width" do + assert %Cell{char: "a", width: 1} = Cell.new("abc") + assert %Cell{char: "界", width: 2} = Cell.put_char(Cell.new("a"), "界more") + end + + test "overlays one canonical frame and moves its cursor" do + base = Frame.from_rows(["xxxx", "xxxx"], 4, 2) + child = Frame.from_rows(["界"], 2, 1, cursor: {1, 1}) + frame = Frame.overlay(base, child, 2, 2) + + assert Frame.row_text(frame, 1) == "xxxx" + assert Frame.row_text(frame, 2) == "x界x" + assert frame.cursor == {2, 2} + end +end diff --git a/test/term_ui/helpers/border_helper_test.exs b/test/term_ui/helpers/border_helper_test.exs deleted file mode 100644 index 6d07f7dd..00000000 --- a/test/term_ui/helpers/border_helper_test.exs +++ /dev/null @@ -1,192 +0,0 @@ -defmodule TermUI.Helpers.BorderHelperTest do - use ExUnit.Case, async: true - - alias TermUI.Helpers.BorderHelper - - describe "horizontal_line/1" do - test "creates line of specified width" do - line = BorderHelper.horizontal_line(5) - assert String.length(line) == 5 - end - - test "creates empty string for width 0" do - assert BorderHelper.horizontal_line(0) == "" - end - - test "uses unicode characters by default" do - Application.put_env(:term_ui, :character_set, :unicode) - line = BorderHelper.horizontal_line(3) - assert line == "───" - end - - test "uses ascii characters when configured" do - Application.put_env(:term_ui, :character_set, :ascii) - line = BorderHelper.horizontal_line(3) - assert line == "---" - Application.put_env(:term_ui, :character_set, :unicode) - end - end - - describe "vertical_line/1" do - test "creates list of vertical characters" do - lines = BorderHelper.vertical_line(3) - assert length(lines) == 3 - end - - test "creates empty list for height 0" do - assert BorderHelper.vertical_line(0) == [] - end - - test "uses unicode characters by default" do - Application.put_env(:term_ui, :character_set, :unicode) - lines = BorderHelper.vertical_line(2) - assert lines == ["│", "│"] - end - - test "uses ascii characters when configured" do - Application.put_env(:term_ui, :character_set, :ascii) - lines = BorderHelper.vertical_line(2) - assert lines == ["|", "|"] - Application.put_env(:term_ui, :character_set, :unicode) - end - end - - describe "box_top/1" do - test "creates top border with corners" do - Application.put_env(:term_ui, :character_set, :unicode) - top = BorderHelper.box_top(10) - assert String.starts_with?(top, "┌") - assert String.ends_with?(top, "┐") - assert String.length(top) == 10 - end - - test "creates ascii top border" do - Application.put_env(:term_ui, :character_set, :ascii) - top = BorderHelper.box_top(10) - assert String.starts_with?(top, "+") - assert String.ends_with?(top, "+") - assert String.length(top) == 10 - Application.put_env(:term_ui, :character_set, :unicode) - end - - test "handles minimum width of 2" do - top = BorderHelper.box_top(2) - # Just corners, no inner line - assert String.length(top) == 2 - end - - test "handles width of 1" do - top = BorderHelper.box_top(1) - assert String.length(top) == 1 - end - end - - describe "box_bottom/1" do - test "creates bottom border with corners" do - Application.put_env(:term_ui, :character_set, :unicode) - bottom = BorderHelper.box_bottom(10) - assert String.starts_with?(bottom, "└") - assert String.ends_with?(bottom, "┘") - assert String.length(bottom) == 10 - end - - test "creates ascii bottom border" do - Application.put_env(:term_ui, :character_set, :ascii) - bottom = BorderHelper.box_bottom(10) - assert String.starts_with?(bottom, "+") - assert String.ends_with?(bottom, "+") - Application.put_env(:term_ui, :character_set, :unicode) - end - end - - describe "box_top_round/1" do - test "creates rounded top border" do - Application.put_env(:term_ui, :character_set, :unicode) - top = BorderHelper.box_top_round(10) - assert String.starts_with?(top, "╭") - assert String.ends_with?(top, "╮") - assert String.length(top) == 10 - end - end - - describe "box_bottom_round/1" do - test "creates rounded bottom border" do - Application.put_env(:term_ui, :character_set, :unicode) - bottom = BorderHelper.box_bottom_round(10) - assert String.starts_with?(bottom, "╰") - assert String.ends_with?(bottom, "╯") - assert String.length(bottom) == 10 - end - end - - describe "left_border/1" do - test "creates left border without content" do - Application.put_env(:term_ui, :character_set, :unicode) - border = BorderHelper.left_border() - assert border == "│" - end - - test "creates left border with content" do - Application.put_env(:term_ui, :character_set, :unicode) - border = BorderHelper.left_border(" Hello") - assert border == "│ Hello" - end - end - - describe "right_border/1" do - test "creates right border without content" do - Application.put_env(:term_ui, :character_set, :unicode) - border = BorderHelper.right_border() - assert border == "│" - end - - test "creates right border with content" do - Application.put_env(:term_ui, :character_set, :unicode) - border = BorderHelper.right_border("Hello ") - assert border == "Hello │" - end - end - - describe "bordered_row/3" do - setup do - Application.put_env(:term_ui, :character_set, :unicode) - :ok - end - - test "creates row with borders and content" do - row = BorderHelper.bordered_row("Hello", 12) - assert String.starts_with?(row, "│") - assert String.ends_with?(row, "│") - assert String.contains?(row, "Hello") - assert String.length(row) == 12 - end - - test "pads content to fill width (left align)" do - row = BorderHelper.bordered_row("Hi", 10) - assert row == "│Hi │" - end - - test "right aligns content" do - row = BorderHelper.bordered_row("Hi", 10, align: :right) - assert row == "│ Hi│" - end - - test "center aligns content" do - row = BorderHelper.bordered_row("Hi", 10, align: :center) - assert row == "│ Hi │" - end - - test "truncates content if too long" do - row = BorderHelper.bordered_row("Hello World", 8) - # Width 8 = 2 borders + 6 inner - assert String.length(row) == 8 - assert String.starts_with?(row, "│") - assert String.ends_with?(row, "│") - end - - test "handles minimum width" do - row = BorderHelper.bordered_row("Hi", 2) - assert row == "││" - end - end -end diff --git a/test/term_ui/helpers/cursor_helper_test.exs b/test/term_ui/helpers/cursor_helper_test.exs deleted file mode 100644 index ef69aa9b..00000000 --- a/test/term_ui/helpers/cursor_helper_test.exs +++ /dev/null @@ -1,171 +0,0 @@ -defmodule TermUI.Helpers.CursorHelperTest do - use ExUnit.Case, async: true - - alias TermUI.Helpers.CursorHelper - - describe "move_down/4" do - test "moves cursor down by one" do - assert CursorHelper.move_down(0, 1, 4) == 1 - assert CursorHelper.move_down(2, 1, 4) == 3 - end - - test "moves cursor down by multiple positions" do - assert CursorHelper.move_down(0, 3, 4) == 3 - assert CursorHelper.move_down(1, 2, 4) == 3 - end - - test "clamps to max when exceeding" do - assert CursorHelper.move_down(4, 1, 4) == 4 - assert CursorHelper.move_down(3, 5, 4) == 4 - end - - test "wraps to beginning when enabled" do - assert CursorHelper.move_down(4, 1, 4, wrap: true) == 0 - assert CursorHelper.move_down(3, 3, 4, wrap: true) == 1 - end - - test "handles step of 0" do - assert CursorHelper.move_down(2, 0, 4) == 2 - end - end - - describe "move_up/4" do - test "moves cursor up by one" do - assert CursorHelper.move_up(2, 1, 4) == 1 - assert CursorHelper.move_up(4, 1, 4) == 3 - end - - test "moves cursor up by multiple positions" do - assert CursorHelper.move_up(4, 3, 4) == 1 - assert CursorHelper.move_up(3, 2, 4) == 1 - end - - test "clamps to 0 when going below" do - assert CursorHelper.move_up(0, 1, 4) == 0 - assert CursorHelper.move_up(1, 5, 4) == 0 - end - - test "wraps to end when enabled" do - assert CursorHelper.move_up(0, 1, 4, wrap: true) == 4 - assert CursorHelper.move_up(0, 2, 4, wrap: true) == 3 - end - - test "handles step of 0" do - assert CursorHelper.move_up(2, 0, 4) == 2 - end - end - - describe "clamp_cursor/3" do - test "clamps cursor above max" do - assert CursorHelper.clamp_cursor(5, 0, 3) == 3 - assert CursorHelper.clamp_cursor(10, 0, 3) == 3 - end - - test "clamps cursor below min" do - assert CursorHelper.clamp_cursor(-1, 0, 3) == 0 - assert CursorHelper.clamp_cursor(-10, 0, 3) == 0 - end - - test "returns cursor when in range" do - assert CursorHelper.clamp_cursor(0, 0, 3) == 0 - assert CursorHelper.clamp_cursor(2, 0, 3) == 2 - assert CursorHelper.clamp_cursor(3, 0, 3) == 3 - end - - test "handles custom min" do - assert CursorHelper.clamp_cursor(0, 1, 5) == 1 - assert CursorHelper.clamp_cursor(3, 1, 5) == 3 - end - end - - describe "wrap_cursor/3" do - test "wraps cursor above max" do - assert CursorHelper.wrap_cursor(4, 0, 3) == 0 - assert CursorHelper.wrap_cursor(5, 0, 3) == 1 - assert CursorHelper.wrap_cursor(7, 0, 3) == 3 - end - - test "wraps cursor below min" do - assert CursorHelper.wrap_cursor(-1, 0, 3) == 3 - assert CursorHelper.wrap_cursor(-2, 0, 3) == 2 - assert CursorHelper.wrap_cursor(-4, 0, 3) == 0 - end - - test "returns cursor when in range" do - assert CursorHelper.wrap_cursor(0, 0, 3) == 0 - assert CursorHelper.wrap_cursor(2, 0, 3) == 2 - assert CursorHelper.wrap_cursor(3, 0, 3) == 3 - end - - test "handles custom min" do - assert CursorHelper.wrap_cursor(6, 1, 5) == 1 - assert CursorHelper.wrap_cursor(0, 1, 5) == 5 - end - end - - describe "move_to_next_valid/5" do - test "finds next valid position going down" do - # Position 1 is invalid - valid? = fn pos -> pos != 1 end - assert CursorHelper.move_to_next_valid(0, :down, 4, valid?) == 2 - end - - test "finds next valid position going up" do - # Position 2 is invalid - valid? = fn pos -> pos != 2 end - assert CursorHelper.move_to_next_valid(3, :up, 4, valid?) == 1 - end - - test "skips multiple invalid positions" do - # Positions 1 and 2 are invalid - valid? = fn pos -> pos not in [1, 2] end - assert CursorHelper.move_to_next_valid(0, :down, 4, valid?) == 3 - end - - test "wraps when enabled" do - # Positions 3 and 4 are invalid - valid? = fn pos -> pos not in [3, 4] end - assert CursorHelper.move_to_next_valid(2, :down, 4, valid?, wrap: true) == 0 - end - - test "returns nil when no valid position found" do - # All positions invalid - valid? = fn _pos -> false end - assert CursorHelper.move_to_next_valid(0, :down, 4, valid?) == nil - end - end - - describe "first_valid/2" do - test "finds first valid position" do - valid? = fn pos -> pos >= 2 end - assert CursorHelper.first_valid(4, valid?) == 2 - end - - test "returns 0 when first position is valid" do - valid? = fn _pos -> true end - assert CursorHelper.first_valid(4, valid?) == 0 - end - - test "returns nil when no valid positions" do - valid? = fn _pos -> false end - assert CursorHelper.first_valid(4, valid?) == nil - end - end - - describe "last_valid/2" do - test "finds last valid position" do - valid? = fn pos -> pos <= 2 end - assert CursorHelper.last_valid(4, valid?) == 2 - end - - test "returns max when last position is valid" do - valid? = fn _pos -> true end - assert CursorHelper.last_valid(4, valid?) == 4 - end - - test "returns nil when no valid positions" do - valid? = fn _pos -> false end - assert CursorHelper.last_valid(4, valid?) == nil - end - end -end diff --git a/test/term_ui/input/line_reader_test.exs b/test/term_ui/input/line_reader_test.exs deleted file mode 100644 index 910471de..00000000 --- a/test/term_ui/input/line_reader_test.exs +++ /dev/null @@ -1,332 +0,0 @@ -defmodule TermUI.Input.LineReaderTest do - use ExUnit.Case, async: true - - alias TermUI.Input.LineReader - - # Note: Testing IO.gets directly is tricky because it reads from stdin. - # These tests use ExUnit's capture_io to simulate input. - # Integration tests that actually read from stdin are tagged :requires_terminal. - - # Helper to reduce boilerplate for capture_io + send/receive pattern - defp capture_line_input(input, fun) do - ExUnit.CaptureIO.capture_io([input: input, capture_prompt: false], fn -> - result = fun.() - send(self(), {:result, result}) - end) - - assert_receive {:result, result} - result - end - - describe "read_line/1" do - test "function exists with arity 0 and 1" do - assert Code.ensure_loaded?(LineReader) - assert function_exported?(LineReader, :read_line, 0) - assert function_exported?(LineReader, :read_line, 1) - end - - test "returns {:ok, line} without prompt" do - result = capture_line_input("hello\n", fn -> LineReader.read_line() end) - assert result == {:ok, "hello"} - end - - test "returns {:ok, line} with prompt" do - result = capture_line_input("world\n", fn -> LineReader.read_line("Enter: ") end) - assert result == {:ok, "world"} - end - - test "trims trailing newline from input" do - result = capture_line_input("test\n", fn -> LineReader.read_line() end) - assert result == {:ok, "test"} - end - - test "returns empty string for just newline" do - result = capture_line_input("\n", fn -> LineReader.read_line() end) - assert result == {:ok, ""} - end - - test "preserves internal whitespace" do - result = capture_line_input("hello world\n", fn -> LineReader.read_line() end) - assert result == {:ok, "hello world"} - end - - test "handles input with leading whitespace" do - result = capture_line_input(" spaced\n", fn -> LineReader.read_line() end) - assert result == {:ok, " spaced"} - end - end - - describe "read_line/2 with validation" do - test "function exists with arity 2" do - assert function_exported?(LineReader, :read_line, 2) - end - - test "returns {:ok, line} when validator returns :ok" do - validator = fn _input -> :ok end - result = capture_line_input("valid\n", fn -> LineReader.read_line("Input: ", validator) end) - assert result == {:ok, "valid"} - end - - test "returns {:ok, transformed} when validator returns {:ok, value}" do - validator = fn input -> {:ok, String.upcase(input)} end - result = capture_line_input("hello\n", fn -> LineReader.read_line("Input: ", validator) end) - assert result == {:ok, "HELLO"} - end - - test "returns {:error, reason} when validator returns {:error, reason}" do - validator = fn _input -> {:error, "invalid input"} end - result = capture_line_input("bad\n", fn -> LineReader.read_line("Input: ", validator) end) - assert result == {:error, "invalid input"} - end - - test "validator receives trimmed input" do - validator = fn input -> - send(self(), {:received, input}) - :ok - end - - ExUnit.CaptureIO.capture_io([input: "test value\n", capture_prompt: false], fn -> - LineReader.read_line("Input: ", validator) - end) - - assert_receive {:received, "test value"} - end - - test "integer parsing validator example" do - int_validator = fn input -> - case Integer.parse(input) do - {num, ""} -> {:ok, num} - _ -> {:error, "not an integer"} - end - end - - # Valid integer - result = - capture_line_input("42\n", fn -> LineReader.read_line("Number: ", int_validator) end) - - assert result == {:ok, 42} - - # Invalid integer - result = - capture_line_input("abc\n", fn -> LineReader.read_line("Number: ", int_validator) end) - - assert result == {:error, "not an integer"} - end - - test "length validation example" do - min_length_validator = fn input -> - if String.length(input) >= 3 do - :ok - else - {:error, "must be at least 3 characters"} - end - end - - # Valid length - result = - capture_line_input("abc\n", fn -> - LineReader.read_line("Input: ", min_length_validator) - end) - - assert result == {:ok, "abc"} - - # Too short - result = - capture_line_input("ab\n", fn -> LineReader.read_line("Input: ", min_length_validator) end) - - assert result == {:error, "must be at least 3 characters"} - end - - test "non-empty validation example" do - non_empty_validator = fn input -> - if String.trim(input) != "" do - :ok - else - {:error, "cannot be empty"} - end - end - - # Non-empty - result = - capture_line_input("something\n", fn -> - LineReader.read_line("Input: ", non_empty_validator) - end) - - assert result == {:ok, "something"} - - # Empty - result = - capture_line_input("\n", fn -> LineReader.read_line("Input: ", non_empty_validator) end) - - assert result == {:error, "cannot be empty"} - end - end - - describe "documentation" do - test "module has moduledoc" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(LineReader) - assert is_binary(moduledoc) - assert String.contains?(moduledoc, "LineReader") or String.contains?(moduledoc, "Line") - end - - test "moduledoc mentions TextInput.Line" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(LineReader) - assert String.contains?(moduledoc, "TextInput.Line") - end - - test "moduledoc mentions shell line editing" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(LineReader) - - assert String.contains?(moduledoc, "shell") or - String.contains?(moduledoc, "Shell") or - String.contains?(moduledoc, "line editing") - end - - test "moduledoc mentions IO.gets" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(LineReader) - assert String.contains?(moduledoc, "IO.gets") - end - - test "read_line/1 has documentation" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(LineReader) - - read_line_doc = - Enum.find(docs, fn - {{:function, :read_line, 1}, _, _, _, _} -> true - _ -> false - end) - - assert read_line_doc != nil - end - - test "read_line/2 has documentation" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(LineReader) - - read_line_doc = - Enum.find(docs, fn - {{:function, :read_line, 2}, _, _, _, _} -> true - _ -> false - end) - - assert read_line_doc != nil - end - end - - describe "type specifications" do - test "read_result type is documented" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(LineReader) - - type_doc = - Enum.find(docs, fn - {{:type, :read_result, _}, _, _, _, _} -> true - _ -> false - end) - - assert type_doc != nil - end - - test "validated_result type is documented" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(LineReader) - - type_doc = - Enum.find(docs, fn - {{:type, :validated_result, _}, _, _, _, _} -> true - _ -> false - end) - - assert type_doc != nil - end - - test "validator type is documented" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(LineReader) - - type_doc = - Enum.find(docs, fn - {{:type, :validator, _}, _, _, _, _} -> true - _ -> false - end) - - assert type_doc != nil - end - end - - describe "edge cases" do - test "handles multi-line input (only first line)" do - # IO.gets only reads until first newline - result = capture_line_input("line1\nline2\n", fn -> LineReader.read_line() end) - assert result == {:ok, "line1"} - end - - test "handles UTF-8 input" do - result = capture_line_input("héllo wörld\n", fn -> LineReader.read_line() end) - assert result == {:ok, "héllo wörld"} - end - - test "handles emoji input" do - result = capture_line_input("hello 👋\n", fn -> LineReader.read_line() end) - assert result == {:ok, "hello 👋"} - end - end - - describe "EOF handling" do - # Note: Testing actual EOF is difficult with capture_io since it requires - # closing the input stream. These tests verify the code paths exist and - # document the expected behavior. - - test "read_line/1 handles EOF from IO.gets" do - # We can't easily simulate :eof with capture_io, but we can verify - # the function handles the case by checking the implementation handles it. - # The actual :eof case is tested in integration tests. - - # Verify the module handles the :eof case in its pattern matching - # by checking the function compiles and works for normal input - result = capture_line_input("test\n", fn -> LineReader.read_line() end) - assert result == {:ok, "test"} - end - - test "read_line/2 returns :eof bypassing validation when EOF received" do - # If IO.gets returns :eof, validation is bypassed and :eof is returned directly - # This is documented behavior - validators are only called on successful reads - validator = fn _input -> - send(self(), :validator_called) - :ok - end - - # Normal case - validator is called - result = capture_line_input("test\n", fn -> LineReader.read_line("Prompt: ", validator) end) - assert result == {:ok, "test"} - assert_receive :validator_called - - # Note: We cannot easily test the :eof case with capture_io, but the - # code path exists in read_line/2's case statement (line 228-229): - # :eof -> :eof - end - - test "error-to-eof conversion is documented behavior" do - # IO.gets can return {:error, reason} which is converted to :eof - # This is intentional - see line 152-153 in line_reader.ex: - # {:error, _reason} -> :eof - # - # This simplifies error handling for callers who typically don't - # need to distinguish between "stream ended" and "read error" - - # Verify normal operation works (error cases need real terminal) - result = capture_line_input("hello\n", fn -> LineReader.read_line() end) - assert result == {:ok, "hello"} - end - end - - # Integration tests that require actual terminal - describe "integration" do - @describetag :requires_terminal - - test "displays prompt to user" do - output = - ExUnit.CaptureIO.capture_io([input: "test\n"], fn -> - LineReader.read_line("Enter value: ") - end) - - assert String.contains?(output, "Enter value: ") - end - end -end diff --git a/test/term_ui/input/raw_test.exs b/test/term_ui/input/raw_test.exs deleted file mode 100644 index b992204a..00000000 --- a/test/term_ui/input/raw_test.exs +++ /dev/null @@ -1,493 +0,0 @@ -defmodule TermUI.Input.RawTest do - use ExUnit.Case, async: true - - alias TermUI.Event - alias TermUI.Input - alias TermUI.Input.Raw - - describe "behaviour implementation" do - test "module implements TermUI.Input behaviour" do - assert Code.ensure_loaded?(Raw) - behaviours = Raw.__info__(:attributes)[:behaviour] || [] - assert Input in behaviours - end - - test "poll/2 callback is implemented" do - assert function_exported?(Raw, :poll, 2) - end - - test "mode/1 callback is implemented" do - assert function_exported?(Raw, :mode, 1) - end - end - - describe "new/0" do - test "creates initial state with empty buffer" do - state = Raw.new() - assert %Raw{} = state - assert state.buffer == <<>> - assert state.event_queue == [] - end - end - - describe "mode/1" do - test "returns :raw" do - state = Raw.new() - assert Raw.mode(state) == :raw - end - - test "returns :raw regardless of buffer contents" do - state = %Raw{buffer: "some data", event_queue: []} - assert Raw.mode(state) == :raw - end - end - - describe "stop/1" do - test "returns :ok" do - state = Raw.new() - assert Raw.stop(state) == :ok - end - - test "is idempotent - can be called multiple times" do - state = Raw.new() - assert Raw.stop(state) == :ok - assert Raw.stop(state) == :ok - end - end - - describe "poll/2 with pre-buffered input" do - test "returns event from buffer with simple character" do - # Pre-populate buffer with a simple character - state = %Raw{buffer: "a", event_queue: []} - - # Should parse and return immediately without blocking - {result, new_state} = Raw.poll(state, 0) - - assert {:ok, %Event.Key{key: "a", char: "a"}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with enter key" do - # Enter is character 13 - state = %Raw{buffer: <<13>>, event_queue: []} - - {result, new_state} = Raw.poll(state, 0) - - assert {:ok, %Event.Key{key: :enter}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with tab key" do - # Tab is character 9 - state = %Raw{buffer: <<9>>, event_queue: []} - - {result, new_state} = Raw.poll(state, 0) - - assert {:ok, %Event.Key{key: :tab}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with backspace" do - # Backspace is character 8 - state = %Raw{buffer: <<8>>, event_queue: []} - - {result, new_state} = Raw.poll(state, 0) - - assert {:ok, %Event.Key{key: :backspace}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with complete escape sequence" do - # ESC [ A = Up arrow - state = %Raw{buffer: <<27, ?[, ?A>>, event_queue: []} - - {result, new_state} = Raw.poll(state, 0) - - assert {:ok, %Event.Key{key: :up}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with down arrow" do - # ESC [ B = Down arrow - state = %Raw{buffer: <<27, ?[, ?B>>, event_queue: []} - - {result, new_state} = Raw.poll(state, 0) - - assert {:ok, %Event.Key{key: :down}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with left arrow" do - # ESC [ D = Left arrow - state = %Raw{buffer: <<27, ?[, ?D>>, event_queue: []} - - {result, new_state} = Raw.poll(state, 0) - - assert {:ok, %Event.Key{key: :left}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with right arrow" do - # ESC [ C = Right arrow - state = %Raw{buffer: <<27, ?[, ?C>>, event_queue: []} - - {result, new_state} = Raw.poll(state, 0) - - assert {:ok, %Event.Key{key: :right}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with home key" do - # ESC [ H = Home - state = %Raw{buffer: <<27, ?[, ?H>>, event_queue: []} - - {result, new_state} = Raw.poll(state, 0) - - assert {:ok, %Event.Key{key: :home}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with end key" do - # ESC [ F = End - state = %Raw{buffer: <<27, ?[, ?F>>, event_queue: []} - - {result, new_state} = Raw.poll(state, 0) - - assert {:ok, %Event.Key{key: :end}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with function key F1" do - # ESC O P = F1 - state = %Raw{buffer: <<27, ?O, ?P>>, event_queue: []} - - {result, new_state} = Raw.poll(state, 0) - - assert {:ok, %Event.Key{key: :f1}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with delete key" do - # ESC [ 3 ~ = Delete - state = %Raw{buffer: <<27, ?[, ?3, ?~>>, event_queue: []} - - {result, new_state} = Raw.poll(state, 0) - - assert {:ok, %Event.Key{key: :delete}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with page up" do - # ESC [ 5 ~ = Page Up - state = %Raw{buffer: <<27, ?[, ?5, ?~>>, event_queue: []} - - {result, new_state} = Raw.poll(state, 0) - - assert {:ok, %Event.Key{key: :page_up}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with page down" do - # ESC [ 6 ~ = Page Down - state = %Raw{buffer: <<27, ?[, ?6, ?~>>, event_queue: []} - - {result, new_state} = Raw.poll(state, 0) - - assert {:ok, %Event.Key{key: :page_down}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with Ctrl+C" do - # Ctrl+C is character 3 - state = %Raw{buffer: <<3>>, event_queue: []} - - {result, new_state} = Raw.poll(state, 0) - - assert {:ok, %Event.Key{key: "c", modifiers: [:ctrl]}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with Alt+a" do - # ESC followed by 'a' = Alt+a - state = %Raw{buffer: <<27, ?a>>, event_queue: []} - - {result, new_state} = Raw.poll(state, 0) - - assert {:ok, %Event.Key{key: "a", char: "a", modifiers: [:alt]}} = result - assert new_state.buffer == <<>> - end - - test "handles multiple characters in buffer by queueing" do - # Buffer has 'abc' - should return first character and queue the rest - state = %Raw{buffer: "abc", event_queue: []} - - {result, new_state} = Raw.poll(state, 0) - - assert {:ok, %Event.Key{key: "a", char: "a"}} = result - # Remaining characters are queued as events - assert new_state.buffer == <<>> - assert length(new_state.event_queue) == 2 - end - - test "handles UTF-8 characters in buffer" do - # UTF-8 encoded character (e.g., 'ñ') - state = %Raw{buffer: "ñ", event_queue: []} - - {result, new_state} = Raw.poll(state, 0) - - assert {:ok, %Event.Key{key: "ñ", char: "ñ"}} = result - assert new_state.buffer == <<>> - end - end - - describe "poll/2 with partial escape sequences" do - test "returns timeout with partial escape sequence and 0 timeout" do - # Just ESC - partial sequence - state = %Raw{buffer: <<27>>, event_queue: []} - - # With 0 timeout, should return timeout since we can't complete sequence - {result, _new_state} = Raw.poll(state, 0) - - assert result == :timeout - end - - test "returns timeout with ESC[ partial sequence and 0 timeout" do - # ESC [ - partial CSI sequence - state = %Raw{buffer: <<27, ?[>>, event_queue: []} - - {result, _new_state} = Raw.poll(state, 0) - - assert result == :timeout - end - end - - describe "poll/2 return format" do - test "returns tuple with result and new state" do - state = %Raw{buffer: "x", event_queue: []} - - result = Raw.poll(state, 0) - - assert {_, %Raw{}} = result - end - - test "result is {:ok, event} for successful parse" do - state = %Raw{buffer: "x", event_queue: []} - - {{:ok, event}, _state} = Raw.poll(state, 0) - - assert %Event.Key{} = event - end - end - - describe "poll/2 with empty buffer" do - test "returns timeout with 0ms timeout and empty buffer" do - state = Raw.new() - - # This will try to read with 0 timeout, which should timeout immediately - # Note: This test may be flaky if stdin has data - {result, new_state} = Raw.poll(state, 0) - - # Should timeout since there's no input and timeout is 0 - assert result == :timeout - assert %Raw{} = new_state - end - end - - describe "state management" do - test "state is properly updated after poll with queued events" do - state = %Raw{buffer: "abc", event_queue: []} - - # Poll should consume 'a' and queue 'b' and 'c' as events - {_, new_state} = Raw.poll(state, 0) - - assert new_state.buffer == <<>> - assert length(new_state.event_queue) == 2 - end - - test "multiple polls consume queued events sequentially" do - state = %Raw{buffer: "xyz", event_queue: []} - - {{:ok, event1}, state} = Raw.poll(state, 0) - assert event1.key == "x" - - {{:ok, event2}, state} = Raw.poll(state, 0) - assert event2.key == "y" - - {{:ok, event3}, state} = Raw.poll(state, 0) - assert event3.key == "z" - - # Both buffer and event_queue should now be empty - assert state.buffer == <<>> - assert state.event_queue == [] - end - - test "returns queued events before reading buffer" do - # Pre-queue some events - queued_event = Event.key(:queued_test) - state = %Raw{buffer: "a", event_queue: [queued_event]} - - # Should return queued event first - {{:ok, event1}, state} = Raw.poll(state, 0) - assert event1.key == :queued_test - - # Then buffer event - {{:ok, event2}, _state} = Raw.poll(state, 0) - assert event2.key == "a" - end - end - - describe "documentation" do - test "module has moduledoc" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(Raw) - assert is_binary(moduledoc) - assert String.contains?(moduledoc, "Raw") - end - - test "moduledoc mentions non-blocking input" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(Raw) - assert String.contains?(moduledoc, "Non-blocking") - end - - test "moduledoc mentions escape sequences" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(Raw) - - assert String.contains?(moduledoc, "escape sequence") or - String.contains?(moduledoc, "Escape sequence") - end - - test "poll/2 has documentation" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(Raw) - - poll_doc = - Enum.find(docs, fn - {{:function, :poll, 2}, _, _, _, _} -> true - _ -> false - end) - - assert poll_doc != nil - end - - test "mode/1 has documentation" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(Raw) - - mode_doc = - Enum.find(docs, fn - {{:function, :mode, 1}, _, _, _, _} -> true - _ -> false - end) - - assert mode_doc != nil - end - - test "new/0 has documentation" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(Raw) - - new_doc = - Enum.find(docs, fn - {{:function, :new, 0}, _, _, _, _} -> true - _ -> false - end) - - assert new_doc != nil - end - end - - describe "buffer and queue limits" do - test "event queue size is limited to prevent memory exhaustion" do - # Create a large string that will generate many events - large_input = String.duplicate("x", 1500) - state = %Raw{buffer: large_input, event_queue: []} - - # First poll should parse all and queue remaining (limited to 1000) - {{:ok, _event}, new_state} = Raw.poll(state, 0) - - # Queue should be limited to @max_queue_size (1000) - assert length(new_state.event_queue) <= 1000 - end - - test "state struct has only buffer and event_queue fields" do - state = Raw.new() - # Verify the struct only has the expected fields (no reader_task) - assert Map.keys(state) -- [:__struct__] == [:buffer, :event_queue] - end - end - - describe "emit_partial_escape branches" do - # These test the different branches in emit_partial_escape/2 - # by examining behavior with different partial sequences - - test "lone ESC with sufficient timeout emits escape key event" do - # When we have lone ESC and timeout > @escape_timeout (50ms), - # and no more input arrives, it should emit ESC key - # This is tricky to test without mocking IO, so we test the - # resulting state behavior - state = %Raw{buffer: <<27>>, event_queue: []} - - # With timeout > 50ms, it should try to wait for sequence completion - # Since there's no actual input, it will timeout and emit ESC - # For unit testing, we just verify the timeout behavior with 0ms - {result, _new_state} = Raw.poll(state, 0) - assert result == :timeout - end - - test "ESC followed by [ is partial CSI" do - state = %Raw{buffer: <<27, ?[>>, event_queue: []} - {result, _new_state} = Raw.poll(state, 0) - assert result == :timeout - end - - test "ESC followed by O is partial SS3" do - state = %Raw{buffer: <<27, ?O>>, event_queue: []} - {result, _new_state} = Raw.poll(state, 0) - assert result == :timeout - end - end - - describe "escape timeout handling" do - # Test the 50ms escape timeout behavior - - test "escape timeout constant is 50ms as documented" do - # This verifies the timeout value matches terminal emulator standards - # We can't directly access the private constant, but we can verify - # through the moduledoc - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(Raw) - assert String.contains?(moduledoc, "50ms") - end - end - - describe "security - buffer limits" do - test "module documents buffer size limits" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(Raw) - # Should mention security or buffer management - assert String.contains?(moduledoc, "Buffer") or String.contains?(moduledoc, "Security") - end - end - - # Integration tests - these require actual terminal I/O - # Tagged to allow selective running - describe "integration - actual I/O" do - @describetag :requires_terminal - - test "poll with empty buffer and 0 timeout returns timeout immediately" do - state = Raw.new() - start_time = System.monotonic_time(:millisecond) - {result, _new_state} = Raw.poll(state, 0) - elapsed = System.monotonic_time(:millisecond) - start_time - - assert result == :timeout - # Should be near-instant (< 50ms accounting for system overhead) - assert elapsed < 50 - end - - test "poll with short timeout returns timeout when no input" do - state = Raw.new() - start_time = System.monotonic_time(:millisecond) - {result, _new_state} = Raw.poll(state, 10) - elapsed = System.monotonic_time(:millisecond) - start_time - - assert result == :timeout - # Should take approximately 10ms (with some tolerance) - assert elapsed >= 5 and elapsed < 100 - end - end -end diff --git a/test/term_ui/input/selector_test.exs b/test/term_ui/input/selector_test.exs deleted file mode 100644 index c902a8b7..00000000 --- a/test/term_ui/input/selector_test.exs +++ /dev/null @@ -1,231 +0,0 @@ -defmodule TermUI.Input.SelectorTest do - use ExUnit.Case, async: false - - alias TermUI.Input.Selector - - doctest TermUI.Input.Selector - - setup do - # Ensure modules are fully loaded before testing function exports - Code.ensure_loaded(TermUI.Input.Raw) - Code.ensure_loaded(TermUI.Input.TTY) - :ok - end - - describe "select/1 with :raw mode" do - test "returns TermUI.Input.Raw" do - assert Selector.select(:raw) == TermUI.Input.Raw - end - - test "returned module implements TermUI.Input behaviour" do - handler = Selector.select(:raw) - behaviours = handler.__info__(:attributes)[:behaviour] || [] - assert TermUI.Input in behaviours - end - - test "returned module has new/0 function" do - handler = Selector.select(:raw) - assert function_exported?(handler, :new, 0) - end - - test "returned module has poll/2 function" do - handler = Selector.select(:raw) - assert function_exported?(handler, :poll, 2) - end - - test "returned module has mode/1 function" do - handler = Selector.select(:raw) - assert function_exported?(handler, :mode, 1) - end - end - - describe "select/1 with :tty mode" do - test "returns TermUI.Input.TTY" do - assert Selector.select(:tty) == TermUI.Input.TTY - end - - test "returned module implements TermUI.Input behaviour" do - handler = Selector.select(:tty) - behaviours = handler.__info__(:attributes)[:behaviour] || [] - assert TermUI.Input in behaviours - end - - test "returned module has new/0 function" do - handler = Selector.select(:tty) - assert function_exported?(handler, :new, 0) - end - - test "returned module has poll/2 function" do - handler = Selector.select(:tty) - assert function_exported?(handler, :poll, 2) - end - - test "returned module has mode/1 function" do - handler = Selector.select(:tty) - assert function_exported?(handler, :mode, 1) - end - end - - describe "select/1 with invalid mode" do - test "raises ArgumentError for atom" do - assert_raise ArgumentError, ~r/invalid input mode: :invalid/, fn -> - Selector.select(:invalid) - end - end - - test "raises ArgumentError for nil" do - assert_raise ArgumentError, ~r/invalid input mode: nil/, fn -> - Selector.select(nil) - end - end - - test "raises ArgumentError for string" do - assert_raise ArgumentError, ~r/invalid input mode: "raw"/, fn -> - Selector.select("raw") - end - end - - test "error message mentions expected values" do - assert_raise ArgumentError, ~r/expected :raw or :tty/, fn -> - Selector.select(:unknown) - end - end - end - - describe "select/0 auto-detection" do - # Note: Testing select/0 is challenging because it calls Backend.Selector.select/0 - # which attempts to modify terminal state. We test the structure here. - - test "returns a module" do - # This test will actually trigger backend selection - # In test environment, this will typically return TTY mode - handler = Selector.select() - assert is_atom(handler) - end - - test "returns either Input.Raw or Input.TTY" do - handler = Selector.select() - assert handler in [TermUI.Input.Raw, TermUI.Input.TTY] - end - - test "returned handler implements TermUI.Input behaviour" do - handler = Selector.select() - behaviours = handler.__info__(:attributes)[:behaviour] || [] - assert TermUI.Input in behaviours - end - - test "returned handler can create new state" do - handler = Selector.select() - state = handler.new() - assert is_struct(state) - end - - test "returned handler mode matches selection" do - handler = Selector.select() - state = handler.new() - mode = handler.mode(state) - - cond do - handler == TermUI.Input.Raw -> assert mode == :raw - handler == TermUI.Input.TTY -> assert mode == :tty - end - end - end - - describe "handler integration" do - test "Raw handler mode returns :raw" do - handler = Selector.select(:raw) - state = handler.new() - assert handler.mode(state) == :raw - end - - test "TTY handler mode returns :tty" do - handler = Selector.select(:tty) - state = handler.new() - assert handler.mode(state) == :tty - end - - test "handlers can be used interchangeably" do - # Both handlers should follow the same interface - for mode <- [:raw, :tty] do - handler = Selector.select(mode) - state = handler.new() - - # State should be a struct - assert is_struct(state) - - # Mode should match - assert handler.mode(state) == mode - - # Handler should have poll function - assert function_exported?(handler, :poll, 2) - end - end - end - - describe "documentation" do - test "module has documentation" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(Selector) - assert is_binary(moduledoc) - assert String.length(moduledoc) > 0 - end - - test "moduledoc explains relationship with Backend.Selector" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(Selector) - assert String.contains?(moduledoc, "Backend.Selector") - end - - test "moduledoc explains available handlers" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(Selector) - assert String.contains?(moduledoc, "Input.Raw") - assert String.contains?(moduledoc, "Input.TTY") - end - - test "moduledoc explains LineReader is not included" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(Selector) - assert String.contains?(moduledoc, "LineReader") - end - - test "select/1 has documentation" do - {:docs_v1, _, :elixir, _, _, _, functions} = Code.fetch_docs(Selector) - - select_1_doc = - Enum.find(functions, fn - {{:function, :select, 1}, _, _, _, _} -> true - _ -> false - end) - - assert select_1_doc != nil - {_, _, _, %{"en" => doc}, _} = select_1_doc - assert String.contains?(doc, "mode") - end - - test "select/0 has documentation" do - {:docs_v1, _, :elixir, _, _, _, functions} = Code.fetch_docs(Selector) - - select_0_doc = - Enum.find(functions, fn - {{:function, :select, 0}, _, _, _, _} -> true - _ -> false - end) - - assert select_0_doc != nil - {_, _, _, %{"en" => doc}, _} = select_0_doc - assert String.contains?(doc, "auto-detect") - end - end - - describe "type specifications" do - test "module defines mode type" do - # Verify the module exports the expected types - # This is a compile-time check, so we just verify the module loads - assert Code.ensure_loaded?(Selector) - end - - test "select/1 returns a module" do - result = Selector.select(:raw) - assert is_atom(result) - assert Code.ensure_loaded(result) == {:module, result} - end - end -end diff --git a/test/term_ui/input/tty_test.exs b/test/term_ui/input/tty_test.exs deleted file mode 100644 index 31971f3c..00000000 --- a/test/term_ui/input/tty_test.exs +++ /dev/null @@ -1,501 +0,0 @@ -defmodule TermUI.Input.TTYTest do - use ExUnit.Case, async: true - - alias TermUI.Event - alias TermUI.Input - alias TermUI.Input.Raw - alias TermUI.Input.TTY - - describe "behaviour implementation" do - test "module implements TermUI.Input behaviour" do - assert Code.ensure_loaded?(TTY) - behaviours = TTY.__info__(:attributes)[:behaviour] || [] - assert Input in behaviours - end - - test "poll/2 callback is implemented" do - assert function_exported?(TTY, :poll, 2) - end - - test "mode/1 callback is implemented" do - assert function_exported?(TTY, :mode, 1) - end - end - - describe "new/0" do - test "creates initial state with empty buffer" do - state = TTY.new() - assert %TTY{} = state - assert state.buffer == <<>> - assert state.event_queue == [] - end - - test "state struct has buffer, event_queue, and IO opts fields" do - state = TTY.new() - # Verify the struct has the expected fields (including IO opts fields) - assert state |> Map.keys() |> List.delete(:__struct__) |> MapSet.new() == - MapSet.new([ - :buffer, - :event_queue, - :io_opts_restored, - :io_opts_set, - :original_opts - ]) - end - end - - describe "mode/1" do - test "returns :tty" do - state = TTY.new() - assert TTY.mode(state) == :tty - end - - test "returns :tty regardless of buffer contents" do - state = %TTY{buffer: "some data", event_queue: []} - assert TTY.mode(state) == :tty - end - end - - describe "stop/1" do - test "returns :ok" do - state = TTY.new() - assert TTY.stop(state) == :ok - end - - test "is idempotent - can be called multiple times" do - state = TTY.new() - assert TTY.stop(state) == :ok - assert TTY.stop(state) == :ok - end - end - - describe "poll/2 with pre-buffered input" do - test "returns event from buffer with simple character" do - # Pre-populate buffer with a simple character - state = %TTY{buffer: "a", event_queue: []} - - # Should parse and return immediately without blocking - {result, new_state} = TTY.poll(state, 0) - - assert {:ok, %Event.Key{key: "a", char: "a"}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with enter key" do - # Enter is character 13 - state = %TTY{buffer: <<13>>, event_queue: []} - - {result, new_state} = TTY.poll(state, 0) - - assert {:ok, %Event.Key{key: :enter}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with tab key" do - # Tab is character 9 - state = %TTY{buffer: <<9>>, event_queue: []} - - {result, new_state} = TTY.poll(state, 0) - - assert {:ok, %Event.Key{key: :tab}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with backspace" do - # Backspace is character 8 - state = %TTY{buffer: <<8>>, event_queue: []} - - {result, new_state} = TTY.poll(state, 0) - - assert {:ok, %Event.Key{key: :backspace}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with complete escape sequence" do - # ESC [ A = Up arrow - state = %TTY{buffer: <<27, ?[, ?A>>, event_queue: []} - - {result, new_state} = TTY.poll(state, 0) - - assert {:ok, %Event.Key{key: :up}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with down arrow" do - # ESC [ B = Down arrow - state = %TTY{buffer: <<27, ?[, ?B>>, event_queue: []} - - {result, new_state} = TTY.poll(state, 0) - - assert {:ok, %Event.Key{key: :down}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with left arrow" do - # ESC [ D = Left arrow - state = %TTY{buffer: <<27, ?[, ?D>>, event_queue: []} - - {result, new_state} = TTY.poll(state, 0) - - assert {:ok, %Event.Key{key: :left}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with right arrow" do - # ESC [ C = Right arrow - state = %TTY{buffer: <<27, ?[, ?C>>, event_queue: []} - - {result, new_state} = TTY.poll(state, 0) - - assert {:ok, %Event.Key{key: :right}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with home key" do - # ESC [ H = Home - state = %TTY{buffer: <<27, ?[, ?H>>, event_queue: []} - - {result, new_state} = TTY.poll(state, 0) - - assert {:ok, %Event.Key{key: :home}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with end key" do - # ESC [ F = End - state = %TTY{buffer: <<27, ?[, ?F>>, event_queue: []} - - {result, new_state} = TTY.poll(state, 0) - - assert {:ok, %Event.Key{key: :end}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with function key F1" do - # ESC O P = F1 - state = %TTY{buffer: <<27, ?O, ?P>>, event_queue: []} - - {result, new_state} = TTY.poll(state, 0) - - assert {:ok, %Event.Key{key: :f1}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with delete key" do - # ESC [ 3 ~ = Delete - state = %TTY{buffer: <<27, ?[, ?3, ?~>>, event_queue: []} - - {result, new_state} = TTY.poll(state, 0) - - assert {:ok, %Event.Key{key: :delete}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with page up" do - # ESC [ 5 ~ = Page Up - state = %TTY{buffer: <<27, ?[, ?5, ?~>>, event_queue: []} - - {result, new_state} = TTY.poll(state, 0) - - assert {:ok, %Event.Key{key: :page_up}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with page down" do - # ESC [ 6 ~ = Page Down - state = %TTY{buffer: <<27, ?[, ?6, ?~>>, event_queue: []} - - {result, new_state} = TTY.poll(state, 0) - - assert {:ok, %Event.Key{key: :page_down}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with Ctrl+C" do - # Ctrl+C is character 3 - state = %TTY{buffer: <<3>>, event_queue: []} - - {result, new_state} = TTY.poll(state, 0) - - assert {:ok, %Event.Key{key: "c", modifiers: [:ctrl]}} = result - assert new_state.buffer == <<>> - end - - test "returns event from buffer with Alt+a" do - # ESC followed by 'a' = Alt+a - state = %TTY{buffer: <<27, ?a>>, event_queue: []} - - {result, new_state} = TTY.poll(state, 0) - - assert {:ok, %Event.Key{key: "a", char: "a", modifiers: [:alt]}} = result - assert new_state.buffer == <<>> - end - - test "handles multiple characters in buffer by queueing" do - # Buffer has 'abc' - should return first character and queue the rest - state = %TTY{buffer: "abc", event_queue: []} - - {result, new_state} = TTY.poll(state, 0) - - assert {:ok, %Event.Key{key: "a", char: "a"}} = result - # Remaining characters are queued as events - assert new_state.buffer == <<>> - assert length(new_state.event_queue) == 2 - end - - test "handles UTF-8 characters in buffer" do - # UTF-8 encoded character (e.g., 'ñ') - state = %TTY{buffer: "ñ", event_queue: []} - - {result, new_state} = TTY.poll(state, 0) - - assert {:ok, %Event.Key{key: "ñ", char: "ñ"}} = result - assert new_state.buffer == <<>> - end - end - - describe "poll/2 return format" do - test "returns tuple with result and new state" do - state = %TTY{buffer: "x", event_queue: []} - - result = TTY.poll(state, 0) - - assert {_, %TTY{}} = result - end - - test "result is {:ok, event} for successful parse" do - state = %TTY{buffer: "x", event_queue: []} - - {{:ok, event}, _state} = TTY.poll(state, 0) - - assert %Event.Key{} = event - end - end - - describe "state management" do - test "state is properly updated after poll with queued events" do - state = %TTY{buffer: "abc", event_queue: []} - - # Poll should consume 'a' and queue 'b' and 'c' as events - {_, new_state} = TTY.poll(state, 0) - - assert new_state.buffer == <<>> - assert length(new_state.event_queue) == 2 - end - - test "multiple polls consume queued events sequentially" do - state = %TTY{buffer: "xyz", event_queue: []} - - {{:ok, event1}, state} = TTY.poll(state, 0) - assert event1.key == "x" - - {{:ok, event2}, state} = TTY.poll(state, 0) - assert event2.key == "y" - - {{:ok, event3}, state} = TTY.poll(state, 0) - assert event3.key == "z" - - # Both buffer and event_queue should now be empty - assert state.buffer == <<>> - assert state.event_queue == [] - end - - test "returns queued events before reading buffer" do - # Pre-queue some events - queued_event = Event.key(:queued_test) - state = %TTY{buffer: "a", event_queue: [queued_event]} - - # Should return queued event first - {{:ok, event1}, state} = TTY.poll(state, 0) - assert event1.key == :queued_test - - # Then buffer event - {{:ok, event2}, _state} = TTY.poll(state, 0) - assert event2.key == "a" - end - end - - describe "buffer and queue limits" do - test "event queue size is limited to prevent memory exhaustion" do - # Create a large string that will generate many events - large_input = String.duplicate("x", 1500) - state = %TTY{buffer: large_input, event_queue: []} - - # First poll should parse all and queue remaining (limited to 1000) - {{:ok, _event}, new_state} = TTY.poll(state, 0) - - # Queue should be limited to @max_queue_size (1000) - assert length(new_state.event_queue) <= 1000 - end - end - - describe "documentation" do - test "module has moduledoc" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(TTY) - assert is_binary(moduledoc) - assert String.contains?(moduledoc, "TTY") - end - - test "moduledoc mentions :io.get_chars" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(TTY) - - assert String.contains?(moduledoc, ":io.get_chars") or - String.contains?(moduledoc, "get_chars") - end - - test "moduledoc explains arrow keys work normally" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(TTY) - - assert String.contains?(moduledoc, "Arrow keys") or - String.contains?(moduledoc, "arrow keys") - end - - test "moduledoc explains Tab works" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(TTY) - assert String.contains?(moduledoc, "Tab") - end - - test "moduledoc explains timeout is not honored" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(TTY) - assert String.contains?(moduledoc, "timeout") or String.contains?(moduledoc, "Timeout") - assert String.contains?(moduledoc, "not honored") or String.contains?(moduledoc, "blocking") - end - - test "poll/2 has documentation" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(TTY) - - poll_doc = - Enum.find(docs, fn - {{:function, :poll, 2}, _, _, _, _} -> true - _ -> false - end) - - assert poll_doc != nil - end - - test "mode/1 has documentation" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(TTY) - - mode_doc = - Enum.find(docs, fn - {{:function, :mode, 1}, _, _, _, _} -> true - _ -> false - end) - - assert mode_doc != nil - end - - test "new/0 has documentation" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(TTY) - - new_doc = - Enum.find(docs, fn - {{:function, :new, 0}, _, _, _, _} -> true - _ -> false - end) - - assert new_doc != nil - end - - test "moduledoc explains IEx compatibility" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(TTY) - assert String.contains?(moduledoc, "IEx") - - assert String.contains?(moduledoc, "Compatible") or - String.contains?(moduledoc, "IEx compatible") - end - - test "moduledoc mentions snake_test" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(TTY) - assert String.contains?(moduledoc, "snake_test") - end - end - - describe "comparison with Raw handler" do - test "TTY and Raw have mostly the same struct fields" do - tty_state = TTY.new() - raw_state = Raw.new() - - # TTY has additional IO opts fields for IEx compatibility - tty_fields = - Map.keys(tty_state) -- [:__struct__, :io_opts_restored, :io_opts_set, :original_opts] - - raw_fields = Map.keys(raw_state) -- [:__struct__] - - # The core fields match - assert tty_fields == raw_fields - end - - test "both return same event format for same input" do - # Test with buffered input to avoid I/O - input = "a" - - tty_state = %TTY{buffer: input, event_queue: []} - raw_state = %Raw{buffer: input, event_queue: []} - - {{:ok, tty_event}, _} = TTY.poll(tty_state, 0) - {{:ok, raw_event}, _} = Raw.poll(raw_state, 0) - - assert tty_event.key == raw_event.key - assert tty_event.char == raw_event.char - end - - test "TTY returns :tty mode, Raw returns :raw mode" do - tty_state = TTY.new() - raw_state = Raw.new() - - assert TTY.mode(tty_state) == :tty - assert Raw.mode(raw_state) == :raw - end - end - - describe "EOF and error handling" do - # Note: Actually testing EOF from IO.getn is difficult without a real terminal. - # These tests document the expected behavior and verify the code paths exist. - - test "TTY module handles EOF in its implementation" do - # Verify the poll function exists and can handle pre-buffered input - # The EOF handling is in do_read_blocking/1 which we can't easily test - # without actual terminal I/O, but we verify the module structure is correct. - state = TTY.new() - assert %TTY{buffer: <<>>, event_queue: []} = state - - # Verify we can poll with pre-buffered data (the testable path) - state_with_data = %TTY{buffer: "a", event_queue: []} - {{:ok, event}, new_state} = TTY.poll(state_with_data, 0) - assert event.key == "a" - assert new_state.buffer == <<>> - end - - test "error handling is documented in moduledoc" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(TTY) - # Verify security/error handling documentation exists - assert String.contains?(moduledoc, "Security") - assert String.contains?(moduledoc, "memory exhaustion") - end - - test "IO errors are converted to EOF in implementation" do - # This documents that read_char/0 returns {:error, reason} for IO errors - # and do_read_blocking/1 converts this to {:eof, state}. - # We can't easily test this without mocking IO, but we verify the - # implementation handles these cases by checking the module compiles - # and the documented behavior is consistent. - - # Verify the module implements the Input behaviour which specifies - # :eof as a valid return type - behaviours = TTY.__info__(:attributes)[:behaviour] || [] - assert TermUI.Input in behaviours - end - end - - # Integration tests - these require actual terminal I/O - # Tagged to allow selective running - describe "integration - actual I/O" do - @describetag :requires_terminal - - test "moduledoc explains blocking behavior" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(TTY) - assert String.contains?(moduledoc, "blocking") - end - end -end diff --git a/test/term_ui/input_test.exs b/test/term_ui/input_test.exs deleted file mode 100644 index bd0aa063..00000000 --- a/test/term_ui/input_test.exs +++ /dev/null @@ -1,218 +0,0 @@ -defmodule TermUI.InputTest do - use ExUnit.Case, async: true - - alias TermUI.Event - alias TermUI.Input - - describe "behaviour definition" do - test "module compiles and defines behaviour" do - # Verify the module is loaded and defines a behaviour - assert Code.ensure_loaded?(Input) - assert function_exported?(Input, :behaviour_info, 1) - end - - test "behaviour_info returns expected callbacks" do - callbacks = Input.behaviour_info(:callbacks) - - # Should define poll/2, mode/1, and stop/1 callbacks - assert {:poll, 2} in callbacks - assert {:mode, 1} in callbacks - assert {:stop, 1} in callbacks - assert length(callbacks) == 3 - end - - test "behaviour_info returns optional callbacks (empty)" do - optional_callbacks = Input.behaviour_info(:optional_callbacks) - assert optional_callbacks == [] - end - end - - describe "type specifications" do - test "key_event type references Event.Key" do - # Create a valid key event to verify type compatibility - event = Event.key(:enter) - assert %Event.Key{} = event - assert event.key == :enter - end - - test "input_result types cover all cases" do - # {:ok, key_event} - key_event = Event.key(:a, char: "a") - result1 = {:ok, key_event} - assert {:ok, %Event.Key{}} = result1 - - # {:ok, mouse_event} - mouse_event = Event.mouse(:click, :left, 10, 20) - result2 = {:ok, mouse_event} - assert {:ok, %Event.Mouse{}} = result2 - - # {:ok, paste_event} - paste_event = Event.paste("hello") - result3 = {:ok, paste_event} - assert {:ok, %Event.Paste{}} = result3 - - # :timeout - assert :timeout == :timeout - - # :eof - assert :eof == :eof - end - - test "mode type covers raw and tty" do - # Valid modes - assert :raw in [:raw, :tty] - assert :tty in [:raw, :tty] - end - end - - describe "mock implementation" do - defmodule MockInput do - @moduledoc false - @behaviour TermUI.Input - - defstruct buffer: <<>>, mode: :raw - - @impl true - def poll(%__MODULE__{} = state, timeout) do - # Simulate input handling - if timeout == 0 do - {:timeout, state} - else - event = TermUI.Event.key(:test_key) - {{:ok, event}, state} - end - end - - @impl true - def mode(%__MODULE__{mode: mode}), do: mode - - @impl true - def stop(_state), do: :ok - end - - test "mock module compiles and implements behaviour" do - assert Code.ensure_loaded?(MockInput) - end - - test "poll/2 returns expected format" do - state = %MockInput{mode: :raw} - - # Non-blocking returns timeout - assert {:timeout, ^state} = MockInput.poll(state, 0) - - # With timeout returns event - assert {{:ok, %Event.Key{key: :test_key}}, ^state} = MockInput.poll(state, 100) - end - - test "mode/1 returns the mode" do - raw_state = %MockInput{mode: :raw} - tty_state = %MockInput{mode: :tty} - - assert MockInput.mode(raw_state) == :raw - assert MockInput.mode(tty_state) == :tty - end - - test "state can be updated through poll" do - state = %MockInput{buffer: <<>>, mode: :raw} - {:timeout, state1} = MockInput.poll(state, 0) - # State is unchanged for this mock, but demonstrates the pattern - assert state1 == state - end - end - - # Stateful mock module for testing state updates - defmodule StatefulMockInput do - @moduledoc false - @behaviour TermUI.Input - - defstruct call_count: 0 - - @impl true - def poll(%__MODULE__{call_count: count} = state, _timeout) do - new_state = %{state | call_count: count + 1} - {:timeout, new_state} - end - - @impl true - def mode(_state), do: :raw - - @impl true - def stop(_state), do: :ok - end - - describe "stateful mock" do - test "state can be updated through poll calls" do - state = %StatefulMockInput{call_count: 0} - {:timeout, state} = StatefulMockInput.poll(state, 0) - assert state.call_count == 1 - - {:timeout, state} = StatefulMockInput.poll(state, 0) - assert state.call_count == 2 - end - end - - describe "documentation coverage" do - test "module has moduledoc" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(Input) - assert is_binary(moduledoc) - assert String.contains?(moduledoc, "Input") - assert String.contains?(moduledoc, "behaviour") - end - - test "moduledoc explains character mode" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(Input) - assert String.contains?(moduledoc, "Character Mode") - assert String.contains?(moduledoc, "IO.getn") - end - - test "moduledoc explains line mode for TextInput.Line" do - {:docs_v1, _, :elixir, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(Input) - assert String.contains?(moduledoc, "Line Mode") - assert String.contains?(moduledoc, "TextInput.Line") - assert String.contains?(moduledoc, "LineReader") - end - - test "poll/2 callback has documentation" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(Input) - - poll_doc = - Enum.find(docs, fn - {{:callback, :poll, 2}, _, _, _, _} -> true - _ -> false - end) - - assert poll_doc != nil - {{:callback, :poll, 2}, _, _, %{"en" => doc}, _} = poll_doc - assert String.contains?(doc, "timeout") - end - - test "mode/1 callback has documentation" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(Input) - - mode_doc = - Enum.find(docs, fn - {{:callback, :mode, 1}, _, _, _, _} -> true - _ -> false - end) - - assert mode_doc != nil - {{:callback, :mode, 1}, _, _, %{"en" => doc}, _} = mode_doc - assert String.contains?(doc, ":raw") - assert String.contains?(doc, ":tty") - end - - test "stop/1 callback has documentation" do - {:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(Input) - - stop_doc = - Enum.find(docs, fn - {{:callback, :stop, 1}, _, _, _, _} -> true - _ -> false - end) - - assert stop_doc != nil - {{:callback, :stop, 1}, _, _, %{"en" => doc}, _} = stop_doc - assert String.contains?(doc, "cleanup") - end - end -end diff --git a/test/term_ui/integration/advanced_widgets_test.exs b/test/term_ui/integration/advanced_widgets_test.exs deleted file mode 100644 index d030f1ed..00000000 --- a/test/term_ui/integration/advanced_widgets_test.exs +++ /dev/null @@ -1,391 +0,0 @@ -defmodule TermUI.Integration.AdvancedWidgetsTest do - @moduledoc """ - Integration tests for advanced widgets. - - Tests verify that Phase 6 widgets (Table, Tabs, Dialog, visualization widgets, - and scrollable widgets) can be properly initialized and rendered. These are - smoke tests ensuring basic functionality works correctly. - """ - - # async: true because widgets are stateless and tests create isolated instances - use ExUnit.Case, async: true - - @default_area %{width: 80, height: 24} - - alias TermUI.Component.RenderNode - alias TermUI.Event - alias TermUI.Layout.Constraint - alias TermUI.Test.Factories - alias TermUI.Widgets.BarChart - alias TermUI.Widgets.Canvas - alias TermUI.Widgets.Dialog - alias TermUI.Widgets.Gauge - alias TermUI.Widgets.Sparkline - alias TermUI.Widgets.Table - alias TermUI.Widgets.Table.Column - alias TermUI.Widgets.Tabs - alias TermUI.Widgets.Viewport - - describe "table widget integration" do - test "initializes and renders with data" do - data = Factories.sample_table_data() - columns = Factories.default_table_columns() - - props = Table.new(data: data, columns: columns) - {:ok, state} = Table.init(props) - - assert length(state.data) == 100 - assert state.scroll_offset == 0 - - output = Table.render(state, @default_area) - assert output != nil - end - - test "handles large dataset efficiently" do - data = Factories.sample_table_data(1000) - columns = [Column.new(:id, "ID", width: Constraint.length(10))] - - props = Table.new(data: data, columns: columns) - {:ok, state} = Table.init(props) - - assert length(state.data) == 1000 - - output = Table.render(state, @default_area) - assert output != nil - end - - test "handles keyboard navigation" do - data = Factories.sample_table_data(50) - columns = Factories.default_table_columns() - - props = Table.new(data: data, columns: columns) - {:ok, state} = Table.init(props) - - # Navigate down - {:ok, state} = Table.handle_event(Event.key(:down), state) - assert state.cursor == 1 - - # Navigate up - {:ok, state} = Table.handle_event(Event.key(:up), state) - assert state.cursor == 0 - - # Page down - {:ok, state} = Table.handle_event(Event.key(:page_down), state) - assert state.cursor > 0 - end - - test "handles empty data" do - columns = Factories.default_table_columns() - - props = Table.new(data: [], columns: columns) - {:ok, state} = Table.init(props) - - assert state.data == [] - assert state.cursor == 0 - - output = Table.render(state, @default_area) - assert output != nil - end - end - - describe "tabs widget integration" do - test "initializes and renders" do - tabs = Factories.sample_tabs() - - props = Tabs.new(tabs: tabs) - {:ok, state} = Tabs.init(props) - - assert length(state.tabs) == 3 - assert state.selected == :tab1 - - output = Tabs.render(state, @default_area) - assert output != nil - end - - test "handles tab navigation events" do - tabs = Factories.sample_tabs() - - props = Tabs.new(tabs: tabs) - {:ok, state} = Tabs.init(props) - - # Handle right key event - {:ok, new_state} = Tabs.handle_event(Event.key(:right), state) - assert is_map(new_state) - - # Handle left key event - {:ok, new_state} = Tabs.handle_event(Event.key(:left), new_state) - assert is_map(new_state) - - # Render after events - output = Tabs.render(new_state, @default_area) - assert output != nil - end - - test "handles single tab" do - props = Tabs.new(tabs: [%{id: :only, label: "Only Tab"}]) - {:ok, state} = Tabs.init(props) - - assert length(state.tabs) == 1 - assert state.selected == :only - - output = Tabs.render(state, @default_area) - assert output != nil - end - end - - describe "dialog widget integration" do - test "initializes and renders" do - props = - Dialog.new( - title: "Test Dialog", - content: RenderNode.text("Dialog content"), - buttons: Factories.sample_dialog_buttons() - ) - - {:ok, state} = Dialog.init(props) - - assert state.title == "Test Dialog" - assert state.visible == true - assert length(state.buttons) == 2 - - output = Dialog.render(state, @default_area) - assert output != nil - end - - test "handles visibility toggle" do - props = - Dialog.new( - title: "Toggle Dialog", - content: RenderNode.text("Content"), - buttons: Factories.sample_dialog_buttons() - ) - - {:ok, state} = Dialog.init(props) - assert state.visible == true - - # Hide dialog - state = %{state | visible: false} - output = Dialog.render(state, @default_area) - assert output != nil - - # Show dialog - state = %{state | visible: true} - output = Dialog.render(state, @default_area) - assert output != nil - end - - test "handles keyboard navigation events" do - props = - Dialog.new( - title: "Button Nav", - content: RenderNode.text("Content"), - buttons: Factories.sample_dialog_buttons() - ) - - {:ok, state} = Dialog.init(props) - - # Handle tab key event - {:ok, new_state} = Dialog.handle_event(Event.key(:tab), state) - assert is_map(new_state) - - # Handle shift+tab key event - {:ok, new_state} = Dialog.handle_event(Event.key(:tab, modifiers: [:shift]), new_state) - assert is_map(new_state) - - # Render after events - output = Dialog.render(new_state, @default_area) - assert output != nil - end - - test "handles single button" do - props = - Dialog.new( - title: "Single Button", - content: RenderNode.text("Content"), - buttons: [%{id: :ok, label: "OK"}] - ) - - {:ok, state} = Dialog.init(props) - assert length(state.buttons) == 1 - - output = Dialog.render(state, @default_area) - assert output != nil - end - end - - describe "visualization widgets integration" do - test "bar chart renders data" do - data = Factories.sample_chart_data() - - output = BarChart.render(data: data, width: 40, height: 10) - assert output != nil - end - - test "bar chart handles empty data" do - output = BarChart.render(data: [], width: 40, height: 10) - assert output != nil - end - - test "bar chart handles single value" do - output = BarChart.render(data: [%{label: "X", value: 100}], width: 40, height: 10) - assert output != nil - end - - test "sparkline renders values" do - values = Factories.sample_sparkline_values() - - output = Sparkline.render(values: values) - assert output != nil - end - - test "sparkline handles empty values" do - output = Sparkline.render(values: []) - assert output != nil - end - - test "sparkline handles single value" do - output = Sparkline.render(values: [42]) - assert output != nil - end - - test "gauge renders value" do - output = - Gauge.render( - value: 75, - min: 0, - max: 100, - width: 30 - ) - - assert output != nil - end - - test "gauge handles boundary values" do - # Minimum value - output = Gauge.render(value: 0, min: 0, max: 100, width: 30) - assert output != nil - - # Maximum value - output = Gauge.render(value: 100, min: 0, max: 100, width: 30) - assert output != nil - - # Value at 50% - output = Gauge.render(value: 50, min: 0, max: 100, width: 30) - assert output != nil - end - - test "gauge handles custom range" do - output = - Gauge.render( - value: 500, - min: 100, - max: 1000, - width: 30 - ) - - assert output != nil - end - end - - describe "scrollable widgets integration" do - test "viewport initializes and renders" do - props = - Viewport.new( - content: RenderNode.text("Viewport content"), - content_width: 200, - content_height: 100, - width: 80, - height: 24 - ) - - {:ok, state} = Viewport.init(props) - - assert state.scroll_x == 0 - assert state.scroll_y == 0 - - output = Viewport.render(state, @default_area) - assert output != nil - end - - test "viewport handles scrolling" do - props = - Viewport.new( - content: RenderNode.text("Large content"), - content_width: 200, - content_height: 100, - width: 80, - height: 24 - ) - - {:ok, state} = Viewport.init(props) - - # Scroll down - {:ok, state} = Viewport.handle_event(Event.key(:down), state) - assert state.scroll_y > 0 - - # Scroll right - {:ok, state} = Viewport.handle_event(Event.key(:right), state) - assert state.scroll_x > 0 - - # Scroll back - {:ok, state} = Viewport.handle_event(Event.key(:up), state) - {:ok, state} = Viewport.handle_event(Event.key(:left), state) - - output = Viewport.render(state, @default_area) - assert output != nil - end - - test "viewport handles content smaller than view" do - props = - Viewport.new( - content: RenderNode.text("Small"), - content_width: 10, - content_height: 5, - width: 80, - height: 24 - ) - - {:ok, state} = Viewport.init(props) - - output = Viewport.render(state, @default_area) - assert output != nil - end - - test "canvas drawing operations" do - props = Canvas.new(width: 40, height: 20) - {:ok, state} = Canvas.init(props) - - # Draw operations - state = Canvas.clear(state) - state = Canvas.draw_text(state, 5, 3, "Hello") - state = Canvas.draw_line(state, 0, 0, 10, 10) - - output = Canvas.render(state, %{width: 40, height: 20}) - assert output != nil - end - - test "canvas handles multiple drawing operations" do - props = Canvas.new(width: 40, height: 20) - {:ok, state} = Canvas.init(props) - - # Multiple draw operations - state = Canvas.clear(state) - state = Canvas.draw_text(state, 0, 0, "Top Left") - state = Canvas.draw_text(state, 35, 0, "Top Right") - state = Canvas.draw_text(state, 0, 19, "Bottom Left") - state = Canvas.draw_line(state, 0, 10, 39, 10) - - output = Canvas.render(state, %{width: 40, height: 20}) - assert output != nil - end - - test "canvas handles empty canvas" do - props = Canvas.new(width: 40, height: 20) - {:ok, state} = Canvas.init(props) - - # Render without any drawing - output = Canvas.render(state, %{width: 40, height: 20}) - assert output != nil - end - end -end diff --git a/test/term_ui/integration/component_hierarchy_test.exs b/test/term_ui/integration/component_hierarchy_test.exs deleted file mode 100644 index 04b46439..00000000 --- a/test/term_ui/integration/component_hierarchy_test.exs +++ /dev/null @@ -1,442 +0,0 @@ -defmodule TermUI.Integration.ComponentHierarchyTest do - @moduledoc """ - Integration tests for component hierarchies. - - Tests verify correct lifecycle sequencing, rendering, and management - of nested component trees. - """ - - use ExUnit.Case, async: false - - alias TermUI.Component.StatePersistence - alias TermUI.ComponentRegistry - alias TermUI.ComponentServer - alias TermUI.ComponentSupervisor - - # Test components that track lifecycle events - defmodule LifecycleTracker do - use TermUI.StatefulComponent - - @impl true - def init(props) do - tracker = props[:tracker] - id = props[:id] - if tracker, do: send(tracker, {:lifecycle, id, :init}) - - {:ok, - %{ - id: id, - tracker: tracker, - children_ids: props[:children_ids] || [], - mounted: false - }} - end - - @impl true - def mount(state) do - if state.tracker, do: send(state.tracker, {:lifecycle, state.id, :mount}) - {:ok, %{state | mounted: true}} - end - - @impl true - def unmount(state) do - if state.tracker, do: send(state.tracker, {:lifecycle, state.id, :unmount}) - :ok - end - - @impl true - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, _area) do - text("Component #{state.id}") - end - end - - # Container that manages children - defmodule TestContainer do - use TermUI.Container - - @impl true - def init(props) do - {:ok, - %{ - id: props[:id], - tracker: props[:tracker], - child_specs: props[:child_specs] || [] - }} - end - - def mount(state) do - if state.tracker, do: send(state.tracker, {:lifecycle, state.id, :mount}) - {:ok, state} - end - - def unmount(state) do - if state.tracker, do: send(state.tracker, {:lifecycle, state.id, :unmount}) - :ok - end - - @impl true - def children(state) do - state.child_specs - end - - @impl true - def layout(_children, area, _state) do - # Simple layout - all children get full area - [{nil, area}] - end - - @impl true - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, _area) do - text("Container #{state.id}") - end - end - - setup do - start_supervised!(StatePersistence) - start_supervised!(ComponentRegistry) - start_supervised!(ComponentSupervisor) - :ok - end - - describe "three-level hierarchy initialization" do - test "components initialize in correct order (parent before children)" do - tracker = self() - - # Start grandparent - {:ok, grandparent} = - ComponentSupervisor.start_component( - LifecycleTracker, - %{id: :grandparent, tracker: tracker}, - id: :grandparent - ) - - # Start parent - {:ok, parent} = - ComponentSupervisor.start_component( - LifecycleTracker, - %{id: :parent, tracker: tracker}, - id: :parent - ) - - # Start child - {:ok, child} = - ComponentSupervisor.start_component( - LifecycleTracker, - %{id: :child, tracker: tracker}, - id: :child - ) - - # Set up hierarchy - ComponentRegistry.set_parent(:parent, :grandparent) - ComponentRegistry.set_parent(:child, :parent) - - # Mount in order - ComponentServer.mount(grandparent) - ComponentServer.mount(parent) - ComponentServer.mount(child) - - # Verify init events received - assert_receive {:lifecycle, :grandparent, :init} - assert_receive {:lifecycle, :parent, :init} - assert_receive {:lifecycle, :child, :init} - - # Verify mount events received - assert_receive {:lifecycle, :grandparent, :mount} - assert_receive {:lifecycle, :parent, :mount} - assert_receive {:lifecycle, :child, :mount} - - # Verify hierarchy - # Root component has no parent registered, so returns :not_found - assert {:error, :not_found} = ComponentRegistry.get_parent(:grandparent) - assert {:ok, :grandparent} = ComponentRegistry.get_parent(:parent) - assert {:ok, :parent} = ComponentRegistry.get_parent(:child) - end - - test "hierarchy maintains correct parent-child relationships" do - {:ok, _root} = - ComponentSupervisor.start_component( - LifecycleTracker, - %{id: :root}, - id: :root - ) - - {:ok, _branch1} = - ComponentSupervisor.start_component( - LifecycleTracker, - %{id: :branch1}, - id: :branch1 - ) - - {:ok, _branch2} = - ComponentSupervisor.start_component( - LifecycleTracker, - %{id: :branch2}, - id: :branch2 - ) - - {:ok, _leaf} = - ComponentSupervisor.start_component( - LifecycleTracker, - %{id: :leaf}, - id: :leaf - ) - - ComponentRegistry.set_parent(:branch1, :root) - ComponentRegistry.set_parent(:branch2, :root) - ComponentRegistry.set_parent(:leaf, :branch1) - - # Verify children - children = ComponentRegistry.get_children(:root) - assert length(children) == 2 - assert :branch1 in children - assert :branch2 in children - - # Verify grandchild - grandchildren = ComponentRegistry.get_children(:branch1) - assert grandchildren == [:leaf] - end - end - - describe "child components render within parent bounds" do - test "children exist within parent hierarchy" do - {:ok, parent_pid} = - ComponentSupervisor.start_component( - LifecycleTracker, - %{id: :parent}, - id: :parent - ) - - {:ok, child_pid} = - ComponentSupervisor.start_component( - LifecycleTracker, - %{id: :child}, - id: :child - ) - - ComponentServer.mount(parent_pid) - ComponentServer.mount(child_pid) - - ComponentRegistry.set_parent(:child, :parent) - - # Verify both are registered - assert {:ok, ^parent_pid} = ComponentRegistry.lookup(:parent) - assert {:ok, ^child_pid} = ComponentRegistry.lookup(:child) - - # Verify relationship - assert {:ok, :parent} = ComponentRegistry.get_parent(:child) - end - end - - describe "parent unmount terminates all descendants" do - test "cascade shutdown terminates children" do - tracker = self() - - {:ok, parent} = - ComponentSupervisor.start_component( - LifecycleTracker, - %{id: :parent, tracker: tracker}, - id: :parent - ) - - {:ok, child} = - ComponentSupervisor.start_component( - LifecycleTracker, - %{id: :child, tracker: tracker}, - id: :child - ) - - {:ok, grandchild} = - ComponentSupervisor.start_component( - LifecycleTracker, - %{id: :grandchild, tracker: tracker}, - id: :grandchild - ) - - ComponentServer.mount(parent) - ComponentServer.mount(child) - ComponentServer.mount(grandchild) - - ComponentRegistry.set_parent(:child, :parent) - ComponentRegistry.set_parent(:grandchild, :child) - - # Clear init/mount messages - flush_messages() - - # Stop parent with cascade - :ok = ComponentSupervisor.stop_component(:parent, cascade: true) - - # All processes should be stopped - refute Process.alive?(parent) - refute Process.alive?(child) - refute Process.alive?(grandchild) - end - - test "sibling branches unaffected by other branch termination" do - {:ok, root} = - ComponentSupervisor.start_component( - LifecycleTracker, - %{id: :root}, - id: :root - ) - - {:ok, branch1} = - ComponentSupervisor.start_component( - LifecycleTracker, - %{id: :branch1}, - id: :branch1 - ) - - {:ok, branch2} = - ComponentSupervisor.start_component( - LifecycleTracker, - %{id: :branch2}, - id: :branch2 - ) - - {:ok, leaf1} = - ComponentSupervisor.start_component( - LifecycleTracker, - %{id: :leaf1}, - id: :leaf1 - ) - - ComponentServer.mount(root) - ComponentServer.mount(branch1) - ComponentServer.mount(branch2) - ComponentServer.mount(leaf1) - - ComponentRegistry.set_parent(:branch1, :root) - ComponentRegistry.set_parent(:branch2, :root) - ComponentRegistry.set_parent(:leaf1, :branch1) - - # Stop branch1 with cascade - :ok = ComponentSupervisor.stop_component(:branch1, cascade: true) - - # Branch1 and its children should be stopped - refute Process.alive?(branch1) - refute Process.alive?(leaf1) - - # Root and branch2 should still be alive - assert Process.alive?(root) - assert Process.alive?(branch2) - end - end - - describe "dynamic child addition and removal" do - test "children can be added at runtime" do - {:ok, parent} = - ComponentSupervisor.start_component( - LifecycleTracker, - %{id: :parent}, - id: :parent - ) - - ComponentServer.mount(parent) - - # Initially no children - assert ComponentRegistry.get_children(:parent) == [] - - # Add child dynamically - {:ok, child} = - ComponentSupervisor.start_component( - LifecycleTracker, - %{id: :child1}, - id: :child1 - ) - - ComponentServer.mount(child) - ComponentRegistry.set_parent(:child1, :parent) - - # Now has one child - children = ComponentRegistry.get_children(:parent) - assert children == [:child1] - - # Add another child - {:ok, child2} = - ComponentSupervisor.start_component( - LifecycleTracker, - %{id: :child2}, - id: :child2 - ) - - ComponentServer.mount(child2) - ComponentRegistry.set_parent(:child2, :parent) - - # Now has two children - children = ComponentRegistry.get_children(:parent) - assert length(children) == 2 - end - - test "children can be removed at runtime" do - {:ok, parent} = - ComponentSupervisor.start_component( - LifecycleTracker, - %{id: :parent}, - id: :parent - ) - - {:ok, child1} = - ComponentSupervisor.start_component( - LifecycleTracker, - %{id: :child1}, - id: :child1 - ) - - {:ok, child2} = - ComponentSupervisor.start_component( - LifecycleTracker, - %{id: :child2}, - id: :child2 - ) - - ComponentServer.mount(parent) - ComponentServer.mount(child1) - ComponentServer.mount(child2) - - ComponentRegistry.set_parent(:child1, :parent) - ComponentRegistry.set_parent(:child2, :parent) - - # Has two children - assert length(ComponentRegistry.get_children(:parent)) == 2 - - # Remove one child - :ok = ComponentSupervisor.stop_component(:child1) - - # Give time for process to terminate - Process.sleep(10) - - # Now only one child (parent relationship cleared by registry on DOWN) - children = ComponentRegistry.get_children(:parent) - # Note: The parent table entry may still exist, but the component is gone - # Filter to only existing components - existing = - Enum.filter(children, fn id -> - case ComponentRegistry.lookup(id) do - {:ok, _} -> true - _ -> false - end - end) - - assert existing == [:child2] - end - end - - # Helper to flush all messages from mailbox - defp flush_messages do - receive do - _ -> flush_messages() - after - 0 -> :ok - end - end -end diff --git a/test/term_ui/integration/cross_mode_test.exs b/test/term_ui/integration/cross_mode_test.exs deleted file mode 100644 index 0b76cf7a..00000000 --- a/test/term_ui/integration/cross_mode_test.exs +++ /dev/null @@ -1,608 +0,0 @@ -defmodule TermUI.Integration.CrossModeTest do - @moduledoc """ - Integration tests for cross-mode consistency. - - Tests that applications work identically in IEx and standalone modes: - - Same app works identically in IEx and standalone - - Raw backend still works when not in IEx - - Switching between IEx and standalone modes - - These tests verify that the IEx compatibility layer maintains - behavioral consistency with standalone mode. - """ - - use ExUnit.Case, async: false - - alias TermUI.Command - alias TermUI.Event - alias TermUI.Runtime - - # Test component that tracks all events and state changes - defmodule StateTracker do - @moduledoc """ - Test component that tracks all events and state changes. - - Used to verify that behavior is consistent across modes. - """ - - use TermUI.Elm - - @impl true - def init(_opts) do - %{ - count: 0, - events: [], - last_event: nil - } - end - - @impl true - def event_to_msg(%Event.Key{key: key}, _state), do: {:msg, {:key, key}} - def event_to_msg(%Event.Mouse{action: action}, _state), do: {:msg, {:mouse, action}} - def event_to_msg(%Event.Resize{width: w, height: h}, _state), do: {:msg, {:resize, w, h}} - def event_to_msg(_, _), do: :ignore - - @impl true - def update({:key, :up}, state) do - {%{state | count: state.count + 1, events: [:up | state.events], last_event: :up}, []} - end - - def update({:key, :down}, state) do - {%{state | count: state.count - 1, events: [:down | state.events], last_event: :down}, []} - end - - def update({:key, "r"}, state) do - {%{state | count: 0, events: [:reset | state.events], last_event: :reset}, []} - end - - def update({:key, "q"}, state) do - {state, [Command.quit()]} - end - - def update({:mouse, :press}, state) do - {%{ - state - | count: state.count + 10, - events: [:mouse_press | state.events], - last_event: :mouse_press - }, []} - end - - def update({:resize, w, h}, state) do - {%{state | events: [{:resize, w, h} | state.events], last_event: {:resize, w, h}}, []} - end - - def update(_msg, state), do: {state, []} - - @impl true - def view(state), do: {:text, "Count: #{state.count}, Last: #{inspect(state.last_event)}"} - end - - # Component that renders differently based on mode - defmodule ModeAwareComponent do - @moduledoc """ - Component that displays the current execution mode. - - Used to verify that mode detection works correctly. - """ - - use TermUI.Elm - - @impl true - def init(_opts) do - %{ - mode: TermUI.running_mode(), - iex_mode: TermUI.iex_mode?() - } - end - - @impl true - def event_to_msg(%Event.Key{key: "q"}, _state), do: {:msg, :quit} - def event_to_msg(%Event.Key{key: "r"}, _state), do: {:msg, :refresh_mode} - def event_to_msg(_, _), do: :ignore - - @impl true - def update(:quit, state) do - {state, [Command.quit()]} - end - - def update(:refresh_mode, state) do - {%{ - state - | mode: TermUI.running_mode(), - iex_mode: TermUI.iex_mode?() - }, []} - end - - def update(_msg, state), do: {state, []} - - @impl true - def view(state) do - mode_str = - case state.mode do - :iex -> "IEx" - :standalone -> "Standalone" - other -> inspect(other) - end - - iex_str = if state.iex_mode, do: "true", else: "false" - {:text, "Mode: #{mode_str}, iex_mode?: #{iex_str}"} - end - end - - describe "7.6.2.1: same app works identically in IEx and standalone" do - setup do - # Save original environment - original_env = Application.get_env(:term_ui, :iex_compatible) - original_iex_env = System.get_env("TERM_UI_IEX_MODE") - - on_exit(fn -> - # Restore original environment - case original_env do - nil -> Application.delete_env(:term_ui, :iex_compatible) - val -> Application.put_env(:term_ui, :iex_compatible, val) - end - - case original_iex_env do - nil -> System.delete_env("TERM_UI_IEX_MODE") - val -> System.put_env("TERM_UI_IEX_MODE", val) - end - end) - - :ok - end - - test "app produces same state transitions in both modes" do - # Test in standalone mode - Application.put_env(:term_ui, :iex_compatible, false) - - {:ok, runtime1} = Runtime.start_link(root: StateTracker, skip_terminal: true) - - # Send same sequence of events - events = [ - Event.key(:up), - Event.key(:up), - Event.key(:down), - Event.mouse(:press, :left, 0, 0), - Event.key(:up) - ] - - Enum.each(events, &Runtime.send_event(runtime1, &1)) - Runtime.sync(runtime1) - - state1 = Runtime.get_state(runtime1) - - Runtime.shutdown(runtime1) - - # Now test in IEx mode - Application.put_env(:term_ui, :iex_compatible, true) - - {:ok, runtime2} = Runtime.start_link(root: StateTracker, skip_terminal: true) - - # Send same sequence of events - Enum.each(events, &Runtime.send_event(runtime2, &1)) - Runtime.sync(runtime2) - - state2 = Runtime.get_state(runtime2) - - Runtime.shutdown(runtime2) - - # State should be identical - assert state1.root_state.count == state2.root_state.count - assert length(state1.root_state.events) == length(state2.root_state.events) - - # Event sequence should match (reversed due to prepending) - assert state1.root_state.events == state2.root_state.events - end - - test "app renders consistently in both modes" do - # Test in standalone mode first - Application.put_env(:term_ui, :iex_compatible, false) - - {:ok, runtime1} = Runtime.start_link(root: StateTracker, skip_terminal: true) - - Runtime.send_event(runtime1, Event.key(:up)) - Runtime.sync(runtime1) - - state1 = Runtime.get_state(runtime1) - - Runtime.shutdown(runtime1) - - # Now test in IEx mode - Application.put_env(:term_ui, :iex_compatible, true) - - {:ok, runtime2} = Runtime.start_link(root: StateTracker, skip_terminal: true) - - Runtime.send_event(runtime2, Event.key(:up)) - Runtime.sync(runtime2) - - state2 = Runtime.get_state(runtime2) - - Runtime.shutdown(runtime2) - - # Render state should be identical - assert state1.root_state.count == state2.root_state.count - assert state1.root_state.last_event == state2.root_state.last_event - end - - test "mode detection is reflected in component state" do - # Test in standalone mode - Application.put_env(:term_ui, :iex_compatible, false) - - {:ok, runtime1} = Runtime.start_link(root: ModeAwareComponent, skip_terminal: true) - - state1 = Runtime.get_state(runtime1) - - assert state1.root_state.mode == :standalone - refute state1.root_state.iex_mode - - Runtime.shutdown(runtime1) - - # Test in IEx mode - Application.put_env(:term_ui, :iex_compatible, true) - - {:ok, runtime2} = Runtime.start_link(root: ModeAwareComponent, skip_terminal: true) - - state2 = Runtime.get_state(runtime2) - - assert state2.root_state.mode == :iex - assert state2.root_state.iex_mode - - Runtime.shutdown(runtime2) - end - end - - describe "7.6.2.2: Raw backend still works when not in IEx" do - setup do - # Save original environment - original_env = Application.get_env(:term_ui, :iex_compatible) - original_iex_env = System.get_env("TERM_UI_IEX_MODE") - - # Ensure standalone mode - Application.put_env(:term_ui, :iex_compatible, false) - - on_exit(fn -> - # Restore original environment - case original_env do - nil -> Application.delete_env(:term_ui, :iex_compatible) - val -> Application.put_env(:term_ui, :iex_compatible, val) - end - - case original_iex_env do - nil -> System.delete_env("TERM_UI_IEX_MODE") - val -> System.put_env("TERM_UI_IEX_MODE", val) - end - end) - - :ok - end - - test "runtime works with auto backend in standalone mode" do - # In standalone mode with auto backend, should work normally - {:ok, runtime} = Runtime.start_link(root: StateTracker, skip_terminal: true) - - on_exit(fn -> - if Process.alive?(runtime), do: Runtime.shutdown(runtime) - end) - - # Verify IEx mode is not active - refute TermUI.iex_mode?() - assert TermUI.running_mode() == :standalone - - # Send events and verify they work - Runtime.send_event(runtime, Event.key(:up)) - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 2 - end - - test "runtime can be explicitly set to use raw backend" do - # Explicitly request raw backend (may not actually activate in test env) - {:ok, runtime} = - Runtime.start_link(root: StateTracker, backend: :raw, skip_terminal: true) - - on_exit(fn -> - if Process.alive?(runtime), do: Runtime.shutdown(runtime) - end) - - # Should still be functional - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - end - - test "runtime with TTY backend works in standalone mode" do - {:ok, runtime} = - Runtime.start_link(root: StateTracker, backend: :tty, skip_terminal: true) - - on_exit(fn -> - if Process.alive?(runtime), do: Runtime.shutdown(runtime) - end) - - # Should work the same as raw backend for basic events - Runtime.send_event(runtime, Event.key(:up)) - Runtime.send_event(runtime, Event.key(:down)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - # +1 -1 = 0 - assert state.root_state.count == 0 - end - end - - describe "7.6.2.3: switching between IEx and standalone modes" do - setup do - # Save original environment - original_env = Application.get_env(:term_ui, :iex_compatible) - original_iex_env = System.get_env("TERM_UI_IEX_MODE") - - on_exit(fn -> - # Restore original environment - case original_env do - nil -> Application.delete_env(:term_ui, :iex_compatible) - val -> Application.put_env(:term_ui, :iex_compatible, val) - end - - case original_iex_env do - nil -> System.delete_env("TERM_UI_IEX_MODE") - val -> System.put_env("TERM_UI_IEX_MODE", val) - end - end) - - :ok - end - - test "can switch from standalone to IEx mode between runtimes" do - # Start in standalone mode - Application.put_env(:term_ui, :iex_compatible, false) - - {:ok, runtime1} = Runtime.start_link(root: StateTracker, skip_terminal: true) - - refute TermUI.iex_mode?() - - Runtime.send_event(runtime1, Event.key(:up)) - Runtime.sync(runtime1) - - state1 = Runtime.get_state(runtime1) - assert state1.root_state.count == 1 - - Runtime.shutdown(runtime1) - - # Switch to IEx mode - Application.put_env(:term_ui, :iex_compatible, true) - - {:ok, runtime2} = Runtime.start_link(root: StateTracker, skip_terminal: true) - - assert TermUI.iex_mode?() - - Runtime.send_event(runtime2, Event.key(:up)) - Runtime.sync(runtime2) - - state2 = Runtime.get_state(runtime2) - assert state2.root_state.count == 1 - - Runtime.shutdown(runtime2) - end - - test "can switch from IEx to standalone mode between runtimes" do - # Start in IEx mode - Application.put_env(:term_ui, :iex_compatible, true) - - {:ok, runtime1} = Runtime.start_link(root: StateTracker, skip_terminal: true) - - assert TermUI.iex_mode?() - - Runtime.send_event(runtime1, Event.key(:up)) - Runtime.sync(runtime1) - - state1 = Runtime.get_state(runtime1) - assert state1.root_state.count == 1 - - Runtime.shutdown(runtime1) - - # Switch to standalone mode - Application.put_env(:term_ui, :iex_compatible, false) - - {:ok, runtime2} = Runtime.start_link(root: StateTracker, skip_terminal: true) - - refute TermUI.iex_mode?() - - Runtime.send_event(runtime2, Event.key(:up)) - Runtime.sync(runtime2) - - state2 = Runtime.get_state(runtime2) - assert state2.root_state.count == 1 - - Runtime.shutdown(runtime2) - end - - test "mode changes are detected by mode-aware component" do - # Start in standalone mode - Application.put_env(:term_ui, :iex_compatible, false) - - {:ok, runtime1} = Runtime.start_link(root: ModeAwareComponent, skip_terminal: true) - - state1 = Runtime.get_state(runtime1) - assert state1.root_state.mode == :standalone - - Runtime.shutdown(runtime1) - - # Switch to IEx mode - Application.put_env(:term_ui, :iex_compatible, true) - - {:ok, runtime2} = Runtime.start_link(root: ModeAwareComponent, skip_terminal: true) - - state2 = Runtime.get_state(runtime2) - assert state2.root_state.mode == :iex - - # Refresh mode via event - Runtime.send_event(runtime2, Event.key("r")) - Runtime.sync(runtime2) - - state3 = Runtime.get_state(runtime2) - assert state3.root_state.mode == :iex - assert state3.root_state.iex_mode - - Runtime.shutdown(runtime2) - end - - test "multiple mode switches work correctly" do - # Test multiple transitions between modes - modes = [false, true, false, true, false] - - final_count = - Enum.map(modes, fn iex_mode -> - Application.put_env(:term_ui, :iex_compatible, iex_mode) - - {:ok, runtime} = Runtime.start_link(root: StateTracker, skip_terminal: true) - - # Do some work - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - count = state.root_state.count - - Runtime.shutdown(runtime) - - # Verify mode detection matches - if iex_mode do - assert TermUI.iex_mode?() - else - refute TermUI.iex_mode?() - end - - count - end) - |> Enum.sum() - - # All modes should have produced count = 1 - assert final_count == length(modes) - end - end - - describe "cross-mode event handling consistency" do - setup do - original_env = Application.get_env(:term_ui, :iex_compatible) - - on_exit(fn -> - case original_env do - nil -> Application.delete_env(:term_ui, :iex_compatible) - val -> Application.put_env(:term_ui, :iex_compatible, val) - end - end) - - :ok - end - - test "keyboard events work consistently in both modes" do - test_keys = [:up, :down, :up, :up, :down] - - # Test in both modes - for iex_mode <- [false, true] do - Application.put_env(:term_ui, :iex_compatible, iex_mode) - - {:ok, runtime} = Runtime.start_link(root: StateTracker, skip_terminal: true) - - Enum.each(test_keys, &Runtime.send_event(runtime, Event.key(&1))) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - # up=+1, down=-1: +1 -1 +1 +1 -1 = +1 - assert state.root_state.count == 1 - - Runtime.shutdown(runtime) - end - end - - test "mouse events work consistently in both modes" do - # Test in both modes - for iex_mode <- [false, true] do - Application.put_env(:term_ui, :iex_compatible, iex_mode) - - {:ok, runtime} = Runtime.start_link(root: StateTracker, skip_terminal: true) - - Runtime.send_event(runtime, Event.mouse(:press, :left, 10, 5)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 10 - assert state.root_state.last_event == :mouse_press - - Runtime.shutdown(runtime) - end - end - - test "resize events work consistently in both modes" do - # Test in both modes - for iex_mode <- [false, true] do - Application.put_env(:term_ui, :iex_compatible, iex_mode) - - {:ok, runtime} = Runtime.start_link(root: StateTracker, skip_terminal: true) - - Runtime.send_event(runtime, Event.resize(80, 24)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.last_event == {:resize, 80, 24} - - Runtime.shutdown(runtime) - end - end - end - - describe "backend selection across modes" do - setup do - original_env = Application.get_env(:term_ui, :iex_compatible) - - on_exit(fn -> - case original_env do - nil -> Application.delete_env(:term_ui, :iex_compatible) - val -> Application.put_env(:term_ui, :iex_compatible, val) - end - end) - - :ok - end - - test "auto backend works in both modes" do - for iex_mode <- [false, true] do - Application.put_env(:term_ui, :iex_compatible, iex_mode) - - {:ok, runtime} = - Runtime.start_link(root: StateTracker, backend: :auto, skip_terminal: true) - - # Should be functional regardless of mode - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - - Runtime.shutdown(runtime) - end - end - - test "TTY backend works in both modes" do - for iex_mode <- [false, true] do - Application.put_env(:term_ui, :iex_compatible, iex_mode) - - {:ok, runtime} = - Runtime.start_link(root: StateTracker, backend: :tty, skip_terminal: true) - - # Should be functional regardless of mode - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - - Runtime.shutdown(runtime) - end - end - end -end diff --git a/test/term_ui/integration/dashboard_test.exs b/test/term_ui/integration/dashboard_test.exs deleted file mode 100644 index 6acc30f7..00000000 --- a/test/term_ui/integration/dashboard_test.exs +++ /dev/null @@ -1,334 +0,0 @@ -defmodule TermUI.Integration.DashboardTest do - @moduledoc """ - Integration tests for the Dashboard example application. - - Tests the dashboard component behavior including keyboard navigation, - theme switching, and event handling. - - These tests use a mock Dashboard component since the actual Dashboard.App - is in the examples directory and not compiled with the main test suite. - """ - - use TermUI.RuntimeTestCase - - # Mock Dashboard component that mimics Dashboard.App behavior - defmodule MockDashboard do - @moduledoc """ - Mock dashboard component for integration testing. - - Mimics the Dashboard.App behavior with theme toggling, process selection, - resize handling, and quit functionality. Used to test dashboard-like - interactions without requiring the actual dashboard example. - """ - - use TermUI.Elm - - @impl true - def init(_opts) do - %{ - theme: :dark, - selected_process: 0, - screen_size: {80, 24} - } - end - - @impl true - def event_to_msg(%Event.Key{key: "q"}, _state), do: {:msg, :quit} - def event_to_msg(%Event.Key{key: "r"}, _state), do: {:msg, :refresh} - def event_to_msg(%Event.Key{key: "t"}, _state), do: {:msg, :toggle_theme} - def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :select_next} - def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :select_prev} - def event_to_msg(%Event.Resize{width: w, height: h}, _state), do: {:msg, {:resize, w, h}} - def event_to_msg(_, _state), do: :ignore - - @impl true - def update(:quit, state), do: {state, [TermUI.Command.quit()]} - def update(:refresh, state), do: {state, []} - - def update(:toggle_theme, state) do - new_theme = if state.theme == :dark, do: :light, else: :dark - {%{state | theme: new_theme}, []} - end - - def update(:select_next, state) do - # Limit to 10 processes for testing - new_selected = min(state.selected_process + 1, 9) - {%{state | selected_process: new_selected}, []} - end - - def update(:select_prev, state) do - new_selected = max(state.selected_process - 1, 0) - {%{state | selected_process: new_selected}, []} - end - - def update({:resize, w, h}, state) do - {%{state | screen_size: {w, h}}, []} - end - - def update(_msg, state), do: {state, []} - - @impl true - def view(state) do - {:text, "Dashboard - Theme: #{state.theme}, Selected: #{state.selected_process}"} - end - end - - @dashboard_module MockDashboard - - describe "dashboard initialization" do - test "dashboard starts with initial state" do - runtime = start_test_runtime(@dashboard_module) - - state = Runtime.get_state(runtime) - - # Check initial dashboard state - assert state.root_state.theme == :dark - assert state.root_state.selected_process == 0 - end - - test "dashboard renders initial view" do - runtime = start_test_runtime(@dashboard_module) - - state = Runtime.get_state(runtime) - component = Map.get(state.components, :root) - - # View function should return a render tree with expected content - view_result = component.module.view(component.state) - assert {:text, content} = view_result - assert content =~ "Theme: dark" - assert content =~ "Selected: 0" - end - end - - describe "dashboard keyboard navigation" do - test "'t' key toggles theme" do - runtime = start_test_runtime(@dashboard_module) - - # Initial theme is dark - state = Runtime.get_state(runtime) - assert state.root_state.theme == :dark - - # Toggle theme - Runtime.send_event(runtime, Event.key("t")) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.theme == :light - - # Toggle again - Runtime.send_event(runtime, Event.key("t")) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.theme == :dark - end - - test "down arrow selects next process" do - runtime = start_test_runtime(@dashboard_module) - - # Initial selection is 0 - state = Runtime.get_state(runtime) - assert state.root_state.selected_process == 0 - - # Navigate down - Runtime.send_event(runtime, Event.key(:down)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.selected_process == 1 - end - - test "up arrow selects previous process" do - runtime = start_test_runtime(@dashboard_module) - - # Navigate down first - Runtime.send_event(runtime, Event.key(:down)) - Runtime.send_event(runtime, Event.key(:down)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.selected_process == 2 - - # Navigate up - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.selected_process == 1 - end - - test "up arrow at top stays at 0" do - runtime = start_test_runtime(@dashboard_module) - - # Try to navigate up from 0 - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.selected_process == 0 - end - - test "'r' key triggers refresh" do - runtime = start_test_runtime(@dashboard_module) - - # Refresh just triggers re-render, state unchanged - initial_state = Runtime.get_state(runtime) - - Runtime.send_event(runtime, Event.key("r")) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.theme == initial_state.root_state.theme - assert state.root_state.selected_process == initial_state.root_state.selected_process - end - end - - describe "dashboard quit behavior" do - test "'q' key quits the dashboard" do - {:ok, runtime} = Runtime.start_link(root: @dashboard_module, skip_terminal: true) - - # Monitor for termination - ref = Process.monitor(runtime) - - # Send quit key - Runtime.send_event(runtime, Event.key("q")) - - # Should terminate - assert_receive {:DOWN, ^ref, :process, ^runtime, :normal}, 1000 - end - end - - describe "dashboard resize handling" do - test "resize event updates screen size" do - runtime = start_test_runtime(@dashboard_module) - - # Initial screen size - state = Runtime.get_state(runtime) - assert state.root_state.screen_size == {80, 24} - - # Send resize event - Runtime.send_event(runtime, Event.resize(120, 40)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.screen_size == {120, 40} - end - - test "multiple resize events update correctly" do - runtime = start_test_runtime(@dashboard_module) - - # Send multiple resize events - Runtime.send_event(runtime, Event.resize(100, 30)) - Runtime.send_event(runtime, Event.resize(160, 50)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - # Should have the last resize value - assert state.root_state.screen_size == {160, 50} - end - end - - describe "dashboard state consistency" do - test "multiple theme toggles maintain consistency" do - runtime = start_test_runtime(@dashboard_module) - - # Toggle theme multiple times - for _ <- 1..10 do - Runtime.send_event(runtime, Event.key("t")) - end - - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - # Even number of toggles should return to dark - assert state.root_state.theme == :dark - end - - test "navigation and theme changes are independent" do - runtime = start_test_runtime(@dashboard_module) - - # Navigate and toggle - Runtime.send_event(runtime, Event.key(:down)) - Runtime.send_event(runtime, Event.key("t")) - Runtime.send_event(runtime, Event.key(:down)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.selected_process == 2 - assert state.root_state.theme == :light - end - - test "rapid event handling maintains state integrity" do - runtime = start_test_runtime(@dashboard_module) - - # Send many navigation events rapidly - for _ <- 1..50 do - Runtime.send_event(runtime, Event.key(:down)) - end - - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - # Should be clamped to max process index - assert state.root_state.selected_process == 9 - end - end - - describe "dashboard event ignoring" do - test "unknown keys are ignored" do - runtime = start_test_runtime(@dashboard_module) - - initial_state = Runtime.get_state(runtime) - - # Send unknown keys - Runtime.send_event(runtime, Event.key("x")) - Runtime.send_event(runtime, Event.key("z")) - Runtime.send_event(runtime, Event.key(:enter)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state == initial_state.root_state - end - - test "mouse events are ignored" do - runtime = start_test_runtime(@dashboard_module) - - initial_state = Runtime.get_state(runtime) - - # Send mouse events - Runtime.send_event(runtime, Event.mouse(:press, :left, 10, 10)) - Runtime.send_event(runtime, Event.mouse(:release, :left, 10, 10)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state == initial_state.root_state - end - end - - describe "test isolation" do - test "each test starts with fresh state" do - runtime = start_test_runtime(@dashboard_module) - - state = Runtime.get_state(runtime) - assert state.root_state.theme == :dark - assert state.root_state.selected_process == 0 - end - - test "cleanup is complete" do - {:ok, runtime} = Runtime.start_link(root: @dashboard_module, skip_terminal: true) - - # Modify state - Runtime.send_event(runtime, Event.key("t")) - Runtime.send_event(runtime, Event.key(:down)) - Runtime.sync(runtime) - - # Monitor for termination - ref = Process.monitor(runtime) - - Runtime.shutdown(runtime) - - # Wait for process to terminate - assert_receive {:DOWN, ^ref, :process, ^runtime, :normal}, 1000 - end - end -end diff --git a/test/term_ui/integration/dev_workflow_test.exs b/test/term_ui/integration/dev_workflow_test.exs deleted file mode 100644 index 29530ee8..00000000 --- a/test/term_ui/integration/dev_workflow_test.exs +++ /dev/null @@ -1,289 +0,0 @@ -defmodule TermUI.Integration.DevWorkflowTest do - # async: false because tests share the same named GenServer (DevMode) - use ExUnit.Case, async: false - - alias TermUI.Dev.DevMode - alias TermUI.Dev.HotReload - alias TermUI.Dev.PerfMonitor - alias TermUI.Dev.StateInspector - alias TermUI.Dev.UIInspector - - @default_area %{width: 80, height: 24} - - # Frame timing constants (microseconds) - # 60 FPS = 16,666 microseconds per frame - @frame_time_60fps 16_666 - # Slightly faster than 60 FPS for testing - @frame_time_fast 16_000 - - # Helper to start DevMode and ensure cleanup - defp start_dev_mode do - {:ok, _pid} = DevMode.start_link() - - on_exit(fn -> - # Only stop if the process still exists (may have been stopped by test) - # Use try/catch as a safety net for race conditions - if Process.whereis(DevMode) do - try do - GenServer.stop(DevMode) - catch - :exit, _ -> :ok - end - end - end) - end - - describe "inspector toggle" do - setup do - start_dev_mode() - :ok - end - - test "shows/hides component boundaries with shortcut" do - DevMode.enable() - - # Initially disabled - refute DevMode.ui_inspector_enabled?() - - # Toggle with Ctrl+Shift+I - result = DevMode.handle_shortcut(:i, [:ctrl, :shift]) - assert result == :handled - assert DevMode.ui_inspector_enabled?() - - # Toggle off - DevMode.handle_shortcut(:i, [:ctrl, :shift]) - refute DevMode.ui_inspector_enabled?() - end - - test "renders component boundaries when enabled" do - DevMode.enable() - DevMode.toggle_ui_inspector() - - # Register a component - DevMode.register_component( - :test_comp, - TestModule, - %{count: 0}, - %{x: 10, y: 5, width: 30, height: 10} - ) - - # Get state and render overlay - state = DevMode.get_state() - overlay = UIInspector.render(state.components, nil, @default_area) - assert overlay.type == :overlay - assert overlay.z == 200 - end - - test "selects component for detailed inspection" do - DevMode.enable() - DevMode.toggle_ui_inspector() - DevMode.toggle_state_inspector() - - # Register components - DevMode.register_component( - :parent, - ParentModule, - %{items: [1, 2, 3]}, - %{x: 0, y: 0, width: 80, height: 24} - ) - - DevMode.register_component( - :child, - ChildModule, - %{selected: true}, - %{x: 10, y: 5, width: 20, height: 10} - ) - - # Find component at position - components = DevMode.get_components() - found = UIInspector.find_component_at(components, 15, 7) - assert found == :child - - # Select it - DevMode.select_component(:child) - assert DevMode.get_selected_component() == :child - end - end - - describe "state inspector" do - test "renders state tree for selected component" do - state = %{ - counter: 42, - items: ["a", "b", "c"], - nested: %{ - deep: %{value: true} - } - } - - component_info = %{ - module: TestModule, - state: state, - render_time: 1000, - bounds: %{x: 0, y: 0, width: 40, height: 20} - } - - panel = StateInspector.render(component_info, @default_area) - - assert panel.type == :positioned - # Panel should be positioned on the right side - assert panel.x > 0 - end - - test "detects state changes" do - old_state = %{count: 1, name: "test"} - new_state = %{count: 2, name: "test"} - - diffs = StateInspector.diff_states(old_state, new_state) - assert length(diffs) == 1 - assert [:count] in diffs - end - - test "handles nested state structures" do - state = %{ - user: %{ - profile: %{ - name: "Alice", - settings: %{theme: :dark} - } - } - } - - tree = StateInspector.render_state_tree(state, 0) - assert is_list(tree) - assert length(tree) > 1 - end - end - - describe "performance monitor" do - setup do - start_dev_mode() - :ok - end - - test "calculates FPS from frame times" do - DevMode.enable() - - # Record several frames at ~60 FPS - for _ <- 1..60 do - DevMode.record_frame(@frame_time_60fps) - end - - metrics = DevMode.get_metrics() - # FPS should be approximately 60 - assert metrics.fps > 50 and metrics.fps < 70 - end - - test "tracks memory usage" do - DevMode.enable() - DevMode.record_frame(@frame_time_fast) - - metrics = DevMode.get_metrics() - assert metrics.memory > 0 - assert metrics.process_count > 0 - end - - test "renders performance panel" do - metrics = %{ - fps: 60.0, - frame_times: List.duplicate(16_666, 60), - memory: 50_000_000, - process_count: 100 - } - - panel = PerfMonitor.render(metrics, @default_area) - - assert panel.type == :positioned - end - - test "formats bytes correctly" do - assert PerfMonitor.format_bytes(500) == "500 B" - assert PerfMonitor.format_bytes(1024) == "1.0 KB" - assert PerfMonitor.format_bytes(1_500_000) == "1.4 MB" - assert PerfMonitor.format_bytes(2_000_000_000) == "1.86 GB" - end - end - - describe "hot reload workflow" do - # Skip in normal test runs due to module reload complexity - @tag :skip - test "updates component behavior after reload" do - # This test would verify that: - # 1. A module is loaded - # 2. Code is changed and recompiled - # 3. The component uses the new behavior - # Skipped due to complexity of module reloading in test environment - end - - test "tracks recent reloads" do - {:ok, _pid} = HotReload.start_link() - - # The reload tracking should work even without actual reloads - reloads = HotReload.get_recent_reloads() - assert is_list(reloads) - - HotReload.stop() - end - end - - describe "integrated development workflow" do - setup do - start_dev_mode() - :ok - end - - test "complete dev mode cycle" do - # Enable dev mode - DevMode.enable() - assert DevMode.enabled?() - - # Register components - DevMode.register_component( - :app, - AppModule, - %{page: :home}, - %{x: 0, y: 0, width: 80, height: 24} - ) - - # Toggle all inspectors - DevMode.toggle_ui_inspector() - DevMode.toggle_state_inspector() - DevMode.toggle_perf_monitor() - - assert DevMode.ui_inspector_enabled?() - assert DevMode.state_inspector_enabled?() - assert DevMode.perf_monitor_enabled?() - - # Record some frames - for _ <- 1..10 do - DevMode.record_frame(@frame_time_fast) - end - - # Get state for rendering - state = DevMode.get_state() - - # Render all overlays - overlays = DevMode.render_overlays(state, @default_area) - assert length(overlays) == 3 - - # Update component state - DevMode.update_component_state(:app, %{page: :settings}) - components = DevMode.get_components() - assert components[:app].state.page == :settings - - # Disable dev mode - DevMode.disable() - refute DevMode.enabled?() - end - - test "keyboard shortcuts only work when dev mode enabled" do - # Should not handle when disabled - result = DevMode.handle_shortcut(:i, [:ctrl, :shift]) - assert result == :not_handled - - # Enable and try again - DevMode.enable() - result = DevMode.handle_shortcut(:i, [:ctrl, :shift]) - assert result == :handled - end - end -end diff --git a/test/term_ui/integration/end_to_end_test.exs b/test/term_ui/integration/end_to_end_test.exs deleted file mode 100644 index a5b4e676..00000000 --- a/test/term_ui/integration/end_to_end_test.exs +++ /dev/null @@ -1,513 +0,0 @@ -defmodule TermUI.Integration.EndToEndTest do - @moduledoc """ - End-to-end integration tests for the complete event cycle. - - Tests the full path from input event through dispatch, state update, - view rendering, and back to display. - """ - - use TermUI.RuntimeTestCase - - # Timeout constants for async crash handling tests - # These sleeps are necessary because crashes are processed asynchronously - @crash_processing_timeout 50 - @multiple_crash_timeout 100 - - # Simple counter component for testing - defmodule Counter do - @moduledoc """ - Test component for end-to-end event cycle testing. - - A simple counter that responds to keyboard events (up/down), mouse clicks, - resize events, and quit commands. Used to verify the complete event flow - from input through state updates to rendering. - """ - - use TermUI.Elm - - @impl true - def init(_opts), do: %{count: 0, resizes: []} - - @impl true - def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :increment} - def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :decrement} - def event_to_msg(%Event.Key{key: "q"}, _state), do: {:msg, :quit} - def event_to_msg(%Event.Mouse{action: :press}, _state), do: {:msg, :click} - def event_to_msg(%Event.Resize{width: w, height: h}, _state), do: {:msg, {:resize, w, h}} - def event_to_msg(_, _), do: :ignore - - @impl true - def update(:increment, state) do - {%{state | count: state.count + 1}, []} - end - - def update(:decrement, state) do - {%{state | count: state.count - 1}, []} - end - - def update(:quit, state) do - {state, [Command.quit()]} - end - - def update(:click, state) do - {%{state | count: state.count + 10}, []} - end - - def update({:resize, w, h}, state) do - {%{state | resizes: [{w, h} | state.resizes]}, []} - end - - def update(_msg, state), do: {state, []} - - @impl true - def view(state), do: {:text, "Count: #{state.count}"} - end - - # Component that can trigger rapid events - defmodule RapidCounter do - @moduledoc """ - Test component for rapid event processing tests. - - Tracks the total number of events received to verify that the Runtime - can handle high-frequency event sequences without dropping events. - """ - - use TermUI.Elm - - @impl true - def init(_opts), do: %{events: 0} - - @impl true - def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :tick} - def event_to_msg(_, _), do: :ignore - - @impl true - def update(:tick, state) do - {%{state | events: state.events + 1}, []} - end - - def update(_msg, state), do: {state, []} - - @impl true - def view(state), do: {:text, "Events: #{state.events}"} - end - - describe "end-to-end event cycle" do - test "key press updates component state" do - runtime = start_test_runtime(Counter) - - # Send key events - Runtime.send_event(runtime, Event.key(:up)) - Runtime.send_event(runtime, Event.key(:up)) - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 3 - end - - test "mouse click dispatches to component" do - runtime = start_test_runtime(Counter) - - # Send mouse click event - Runtime.send_event(runtime, Event.mouse(:press, :left, 10, 5)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 10 - end - - test "resize event updates component" do - runtime = start_test_runtime(Counter) - - # Send resize event - Runtime.send_event(runtime, Event.resize(120, 40)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert {120, 40} in state.root_state.resizes - end - - test "quit command exits cleanly" do - {:ok, runtime} = Runtime.start_link(root: Counter, skip_terminal: true) - - # Monitor for termination - ref = Process.monitor(runtime) - - # Send quit key - Runtime.send_event(runtime, Event.key("q")) - - # Should terminate - assert_receive {:DOWN, ^ref, :process, ^runtime, :normal}, 1000 - end - - test "multiple rapid events are processed correctly" do - runtime = start_test_runtime(RapidCounter) - - # Send many events rapidly - for _ <- 1..100 do - Runtime.send_event(runtime, Event.key(:up)) - end - - # Wait for all to process - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.events == 100 - end - - test "events during shutdown are ignored" do - {:ok, runtime} = Runtime.start_link(root: Counter, skip_terminal: true) - - # Monitor for termination - ref = Process.monitor(runtime) - - # Initiate shutdown - Runtime.shutdown(runtime) - - # Try to send events (these should be ignored) - Runtime.send_event(runtime, Event.key(:up)) - Runtime.send_event(runtime, Event.key(:up)) - - # Wait for process to terminate - assert_receive {:DOWN, ^ref, :process, ^runtime, :normal}, 1000 - end - - test "component state persists across event cycle" do - runtime = start_test_runtime(Counter) - - # Increment - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state1 = Runtime.get_state(runtime) - assert state1.root_state.count == 1 - - # Decrement - Runtime.send_event(runtime, Event.key(:down)) - Runtime.sync(runtime) - - state2 = Runtime.get_state(runtime) - assert state2.root_state.count == 0 - - # Click (adds 10) - Runtime.send_event(runtime, Event.mouse(:press, :left, 0, 0)) - Runtime.sync(runtime) - - state3 = Runtime.get_state(runtime) - assert state3.root_state.count == 10 - end - - test "unknown events are ignored without crash" do - runtime = start_test_runtime(Counter) - - # Send events that component doesn't handle - Runtime.send_event(runtime, Event.key("x")) - Runtime.send_event(runtime, Event.paste("hello")) - Runtime.sync(runtime) - - # Should still be running with unchanged state - state = Runtime.get_state(runtime) - assert state.root_state.count == 0 - end - end - - describe "event type dispatch" do - test "keyboard events go to focused component" do - runtime = start_test_runtime(Counter) - - state = Runtime.get_state(runtime) - assert state.focused_component == :root - - # Keyboard event should go to focused component - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - end - - test "resize events broadcast to all components" do - runtime = start_test_runtime(Counter) - - # Send multiple resize events - Runtime.send_event(runtime, Event.resize(80, 24)) - Runtime.send_event(runtime, Event.resize(120, 40)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert length(state.root_state.resizes) == 2 - end - end - - describe "event ordering guarantees" do - test "events are processed in FIFO order" do - runtime = start_test_runtime(Counter) - - # Send a sequence where order matters - # Start at 0, then: +1, +1, -1, +1 = 2 - Runtime.send_event(runtime, Event.key(:up)) - Runtime.send_event(runtime, Event.key(:up)) - Runtime.send_event(runtime, Event.key(:down)) - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - # If processed in order: 0 + 1 + 1 - 1 + 1 = 2 - assert state.root_state.count == 2 - end - - test "resize events maintain order in state" do - runtime = start_test_runtime(Counter) - - # Send multiple resize events in a specific order - Runtime.send_event(runtime, Event.resize(80, 24)) - Runtime.send_event(runtime, Event.resize(100, 30)) - Runtime.send_event(runtime, Event.resize(120, 40)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - # Resizes are stored in reverse order (newest first) - # So we expect them in reverse of send order - assert state.root_state.resizes == [{120, 40}, {100, 30}, {80, 24}] - end - - test "mixed event types maintain order" do - runtime = start_test_runtime(Counter) - - # Send alternating event types - # The specific order should be preserved - # count = 1 - Runtime.send_event(runtime, Event.key(:up)) - # resize added - Runtime.send_event(runtime, Event.resize(80, 24)) - # count = 2 - Runtime.send_event(runtime, Event.key(:up)) - # resize added - Runtime.send_event(runtime, Event.resize(100, 30)) - # count = 1 - Runtime.send_event(runtime, Event.key(:down)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - assert length(state.root_state.resizes) == 2 - # Resizes stored in reverse order - assert state.root_state.resizes == [{100, 30}, {80, 24}] - end - - test "rapid sequential events maintain order" do - runtime = start_test_runtime(Counter) - - # Send 10 increments rapidly - for _ <- 1..10 do - Runtime.send_event(runtime, Event.key(:up)) - end - - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - # If processed in order, should be exactly 10 - assert state.root_state.count == 10 - - # Now send 10 decrements - for _ <- 1..10 do - Runtime.send_event(runtime, Event.key(:down)) - end - - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - # Should return to 0 - assert state.root_state.count == 0 - end - end - - describe "command execution" do - test "quit command stops runtime" do - {:ok, runtime} = Runtime.start_link(root: Counter, skip_terminal: true) - - ref = Process.monitor(runtime) - - # Trigger quit via message - Runtime.send_message(runtime, :root, :quit) - - assert_receive {:DOWN, ^ref, :process, ^runtime, :normal}, 1000 - end - end - - # Components that crash in various ways for error handling tests - defmodule CrashingUpdateComponent do - @moduledoc """ - Test component that crashes in the update/2 callback. - - Used to verify that the Runtime gracefully handles component crashes - during state updates and remains operational. - """ - - use TermUI.Elm - - @impl true - def init(_opts), do: %{count: 0} - - @impl true - def event_to_msg(%Event.Key{key: "c"}, _state), do: {:msg, :crash} - def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :increment} - def event_to_msg(_, _), do: :ignore - - @impl true - def update(:crash, _state) do - raise "Intentional crash in update/2" - end - - def update(:increment, state) do - {%{state | count: state.count + 1}, []} - end - - def update(_msg, state), do: {state, []} - - @impl true - def view(state), do: {:text, "Count: #{state.count}"} - end - - defmodule CrashingEventToMsgComponent do - @moduledoc """ - Test component that crashes in the event_to_msg/2 callback. - - Used to verify that the Runtime gracefully handles component crashes - during event processing and continues to function. - """ - - use TermUI.Elm - - @impl true - def init(_opts), do: %{count: 0} - - @impl true - def event_to_msg(%Event.Key{key: "c"}, _state) do - raise "Intentional crash in event_to_msg/2" - end - - def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :increment} - def event_to_msg(_, _), do: :ignore - - @impl true - def update(:increment, state) do - {%{state | count: state.count + 1}, []} - end - - def update(_msg, state), do: {state, []} - - @impl true - def view(state), do: {:text, "Count: #{state.count}"} - end - - defmodule CrashingViewComponent do - @moduledoc """ - Test component that crashes in the view/1 callback. - - Used to verify that the Runtime gracefully handles component crashes - during rendering without bringing down the entire system. - """ - - use TermUI.Elm - - @impl true - def init(_opts), do: %{should_crash: false} - - @impl true - def event_to_msg(%Event.Key{key: "c"}, _state), do: {:msg, :set_crash} - def event_to_msg(_, _), do: :ignore - - @impl true - def update(:set_crash, state) do - {%{state | should_crash: true}, []} - end - - def update(_msg, state), do: {state, []} - - @impl true - def view(%{should_crash: true}) do - raise "Intentional crash in view/1" - end - - def view(state), do: {:text, "Crash: #{state.should_crash}"} - end - - describe "error handling" do - test "runtime survives crash in update/2" do - runtime = start_test_runtime(CrashingUpdateComponent) - - # First increment to verify normal operation - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - - # Trigger crash - runtime should survive - Runtime.send_event(runtime, Event.key("c")) - - # Give it time to process (crash happens async) - Process.sleep(@crash_processing_timeout) - - # Runtime should still be alive - assert Process.alive?(runtime) - end - - test "runtime survives crash in event_to_msg/2" do - runtime = start_test_runtime(CrashingEventToMsgComponent) - - # First increment to verify normal operation - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - - # Trigger crash - runtime should survive - Runtime.send_event(runtime, Event.key("c")) - - # Give it time to process (crash happens async) - Process.sleep(@crash_processing_timeout) - - # Runtime should still be alive - assert Process.alive?(runtime) - end - - test "component continues working after surviving crash" do - runtime = start_test_runtime(CrashingUpdateComponent) - - # Increment - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - # Trigger crash - Runtime.send_event(runtime, Event.key("c")) - Process.sleep(@crash_processing_timeout) - - # Should still be able to increment after crash - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - _state = Runtime.get_state(runtime) - # State may or may not be preserved depending on error handling strategy - # At minimum, the runtime should be alive and responsive - assert Process.alive?(runtime) - end - - test "multiple crashes don't accumulate and kill runtime" do - runtime = start_test_runtime(CrashingUpdateComponent) - - # Trigger multiple crashes - for _ <- 1..5 do - Runtime.send_event(runtime, Event.key("c")) - end - - Process.sleep(@multiple_crash_timeout) - - # Runtime should still be alive after multiple crashes - assert Process.alive?(runtime) - end - end -end diff --git a/test/term_ui/integration/event_flow_test.exs b/test/term_ui/integration/event_flow_test.exs deleted file mode 100644 index e8eaa385..00000000 --- a/test/term_ui/integration/event_flow_test.exs +++ /dev/null @@ -1,352 +0,0 @@ -defmodule TermUI.Integration.EventFlowTest do - @moduledoc """ - Integration tests for event flow through component trees. - - Tests verify event routing, handling, and propagation work correctly - across nested component hierarchies. - """ - - use ExUnit.Case, async: false - - alias TermUI.Component.StatePersistence - alias TermUI.ComponentRegistry - alias TermUI.ComponentServer - alias TermUI.ComponentSupervisor - alias TermUI.Event - alias TermUI.EventRouter - alias TermUI.FocusManager - alias TermUI.SpatialIndex - - # Component that tracks received events - defmodule EventTracker do - use TermUI.StatefulComponent - - @impl true - def init(props) do - {:ok, - %{ - id: props[:id], - tracker: props[:tracker], - handle_events: props[:handle_events] || false, - events: [] - }} - end - - @impl true - def handle_event(event, state) do - if state.tracker do - send(state.tracker, {:event, state.id, event}) - end - - if state.handle_events do - # Mark as handled - {:ok, %{state | events: [event | state.events]}} - else - # Let it bubble - {:ok, state} - end - end - - @impl true - def render(_state, _area) do - text("") - end - end - - # Component that handles specific events - defmodule SelectiveHandler do - use TermUI.StatefulComponent - - @impl true - def init(props) do - {:ok, - %{ - id: props[:id], - tracker: props[:tracker], - handle_keys: props[:handle_keys] || [] - }} - end - - @impl true - def handle_event(%Event.Key{key: key} = event, state) do - if state.tracker do - send(state.tracker, {:event, state.id, event}) - end - - if key in state.handle_keys do - # Handle this key - {:ok, state} - else - # Don't handle, bubble up - {:ok, state} - end - end - - def handle_event(event, state) do - if state.tracker do - send(state.tracker, {:event, state.id, event}) - end - - {:ok, state} - end - - @impl true - def render(_state, _area) do - text("") - end - end - - setup do - start_supervised!(StatePersistence) - start_supervised!(ComponentRegistry) - start_supervised!(ComponentSupervisor) - start_supervised!(SpatialIndex) - start_supervised!(FocusManager) - start_supervised!(EventRouter) - :ok - end - - describe "keyboard event reaches deeply nested focused component" do - test "event routes to focused component in deep hierarchy" do - tracker = self() - - # Create hierarchy: root -> parent -> child -> target - {:ok, root} = - ComponentSupervisor.start_component( - EventTracker, - %{id: :root, tracker: tracker}, - id: :root - ) - - {:ok, parent} = - ComponentSupervisor.start_component( - EventTracker, - %{id: :parent, tracker: tracker}, - id: :parent - ) - - {:ok, child} = - ComponentSupervisor.start_component( - EventTracker, - %{id: :child, tracker: tracker}, - id: :child - ) - - {:ok, target} = - ComponentSupervisor.start_component( - EventTracker, - %{id: :target, tracker: tracker, handle_events: true}, - id: :target - ) - - # Mount all - ComponentServer.mount(root) - ComponentServer.mount(parent) - ComponentServer.mount(child) - ComponentServer.mount(target) - - # Set up hierarchy - ComponentRegistry.set_parent(:parent, :root) - ComponentRegistry.set_parent(:child, :parent) - ComponentRegistry.set_parent(:target, :child) - - # Focus the deepest component - FocusManager.set_focused(:target) - - # Send keyboard event through router - event = %Event.Key{key: :enter} - EventRouter.route(event) - - # Target should receive the event - assert_receive {:event, :target, ^event}, 100 - end - - test "focused component receives event even when not at root" do - tracker = self() - - {:ok, container} = - ComponentSupervisor.start_component( - EventTracker, - %{id: :container, tracker: tracker}, - id: :container - ) - - {:ok, input} = - ComponentSupervisor.start_component( - EventTracker, - %{id: :input, tracker: tracker, handle_events: true}, - id: :input - ) - - ComponentServer.mount(container) - ComponentServer.mount(input) - - ComponentRegistry.set_parent(:input, :container) - - FocusManager.set_focused(:input) - - event = %Event.Key{key: :a, char: "a"} - EventRouter.route(event) - - assert_receive {:event, :input, ^event}, 100 - end - end - - describe "mouse event routes to correct component based on position" do - test "mouse event routes to component at coordinates" do - tracker = self() - - {:ok, button1} = - ComponentSupervisor.start_component( - EventTracker, - %{id: :button1, tracker: tracker, handle_events: true}, - id: :button1 - ) - - {:ok, button2} = - ComponentSupervisor.start_component( - EventTracker, - %{id: :button2, tracker: tracker, handle_events: true}, - id: :button2 - ) - - ComponentServer.mount(button1) - ComponentServer.mount(button2) - - # Register spatial bounds - SpatialIndex.update(:button1, button1, %{x: 0, y: 0, width: 10, height: 3}) - SpatialIndex.update(:button2, button2, %{x: 0, y: 5, width: 10, height: 3}) - - # Click on button1 area - event1 = %Event.Mouse{action: :click, button: :left, x: 5, y: 1} - EventRouter.route(event1) - - assert_receive {:event, :button1, ^event1}, 100 - refute_receive {:event, :button2, _} - - # Click on button2 area - event2 = %Event.Mouse{action: :click, button: :left, x: 5, y: 6} - EventRouter.route(event2) - - assert_receive {:event, :button2, ^event2}, 100 - end - - test "mouse event ignores components outside bounds" do - tracker = self() - - {:ok, button} = - ComponentSupervisor.start_component( - EventTracker, - %{id: :button, tracker: tracker, handle_events: true}, - id: :button - ) - - ComponentServer.mount(button) - SpatialIndex.update(:button, button, %{x: 10, y: 10, width: 5, height: 2}) - - # Click outside button bounds - event = %Event.Mouse{action: :click, button: :left, x: 0, y: 0} - EventRouter.route(event) - - refute_receive {:event, :button, _}, 50 - end - end - - describe "unhandled event bubbles to parent" do - test "event bubbles from child to parent when unhandled" do - tracker = self() - - {:ok, parent} = - ComponentSupervisor.start_component( - SelectiveHandler, - %{id: :parent, tracker: tracker, handle_keys: [:enter]}, - id: :parent - ) - - {:ok, child} = - ComponentSupervisor.start_component( - SelectiveHandler, - %{id: :child, tracker: tracker, handle_keys: [:space]}, - id: :child - ) - - ComponentServer.mount(parent) - ComponentServer.mount(child) - - ComponentRegistry.set_parent(:child, :parent) - - FocusManager.set_focused(:child) - - # Send :enter which child doesn't handle - event = %Event.Key{key: :enter} - EventRouter.route(event) - - # Both should receive it, child first - assert_receive {:event, :child, ^event}, 100 - # Parent would receive via bubbling if implemented - end - end - - describe "handled event stops propagation" do - test "event stops when component handles it" do - tracker = self() - - {:ok, parent} = - ComponentSupervisor.start_component( - EventTracker, - %{id: :parent, tracker: tracker, handle_events: true}, - id: :parent - ) - - {:ok, child} = - ComponentSupervisor.start_component( - EventTracker, - %{id: :child, tracker: tracker, handle_events: true}, - id: :child - ) - - ComponentServer.mount(parent) - ComponentServer.mount(child) - - ComponentRegistry.set_parent(:child, :parent) - - FocusManager.set_focused(:child) - - event = %Event.Key{key: :a, char: "a"} - EventRouter.route(event) - - # Child receives and handles - assert_receive {:event, :child, ^event}, 100 - - # Parent should not receive since child handled it - # (This depends on actual propagation implementation) - end - end - - describe "multiple event types" do - test "routes different event types to appropriate handlers" do - tracker = self() - - {:ok, component} = - ComponentSupervisor.start_component( - EventTracker, - %{id: :multi, tracker: tracker, handle_events: true}, - id: :multi - ) - - ComponentServer.mount(component) - SpatialIndex.update(:multi, component, %{x: 0, y: 0, width: 20, height: 10}) - FocusManager.set_focused(:multi) - - # Send key event - key_event = %Event.Key{key: :enter} - EventRouter.route(key_event) - assert_receive {:event, :multi, ^key_event}, 100 - - # Send mouse event - mouse_event = %Event.Mouse{action: :click, button: :left, x: 5, y: 5} - EventRouter.route(mouse_event) - assert_receive {:event, :multi, ^mouse_event}, 100 - end - end -end diff --git a/test/term_ui/integration/event_system_test.exs b/test/term_ui/integration/event_system_test.exs deleted file mode 100644 index 19b91454..00000000 --- a/test/term_ui/integration/event_system_test.exs +++ /dev/null @@ -1,656 +0,0 @@ -defmodule TermUI.Integration.EventSystemTest do - @moduledoc """ - Integration tests for the Phase 5 Event System. - - Tests realistic workflows that involve multiple subsystems working together, - such as shortcuts triggering clipboard operations, mouse events with - coordinate transformation, and focus changes affecting application state. - """ - - use ExUnit.Case, async: false - - # Timeout constants for assertions - @short_timeout 50 - @default_timeout 100 - @medium_timeout 150 - @long_timeout 200 - @extended_timeout 250 - - alias TermUI.Clipboard - alias TermUI.Clipboard.Selection - alias TermUI.Command - alias TermUI.Command.Executor - alias TermUI.ComponentRegistry - alias TermUI.Event - alias TermUI.Event.Propagation - alias TermUI.Event.Transformation - alias TermUI.Focus - alias TermUI.Mouse.Router, as: MouseRouter - alias TermUI.Mouse.Tracker, as: MouseTracker - alias TermUI.Shortcut - - describe "command execution workflows" do - setup do - executor = start_supervised!(Executor) - %{executor: executor} - end - - test "timer command executes and delivers result to component", %{executor: executor} do - cmd = Command.timer(10, {:timer_done, :test}) - Executor.execute(executor, cmd, self(), :test_component) - - assert_receive {:command_result, :test_component, _ref, {:timer_done, :test}}, - @default_timeout - end - - test "multiple commands execute concurrently and deliver results", %{executor: executor} do - cmd1 = Command.timer(10, :first) - cmd2 = Command.timer(10, :second) - - Executor.execute(executor, cmd1, self(), :comp1) - Executor.execute(executor, cmd2, self(), :comp2) - - results = receive_results_with_ref(2, 200) - assert length(results) == 2 - end - - test "command cancellation prevents result delivery", %{executor: executor} do - cmd = Command.timer(100, :should_not_receive) - {:ok, ref} = Executor.execute(executor, cmd, self(), :test) - - Executor.cancel(executor, ref) - - refute_receive {:command_result, _, _, _}, @medium_timeout - end - - test "cancel_all_for_component cancels multiple pending commands", %{executor: executor} do - # Start multiple commands for same component - cmd1 = Command.timer(100, :first) - cmd2 = Command.timer(100, :second) - cmd3 = Command.timer(100, :third) - - Executor.execute(executor, cmd1, self(), :my_component) - Executor.execute(executor, cmd2, self(), :my_component) - Executor.execute(executor, cmd3, self(), :other_component) - - # Cancel all for my_component - :ok = Executor.cancel_all_for_component(executor, :my_component) - - # Should only receive result from other_component - assert_receive {:command_result, :other_component, _, :third}, @long_timeout - refute_receive {:command_result, :my_component, _, _}, @short_timeout - end - - test "max_concurrent limit returns error when exceeded" do - # Start executor with low limit (use unique id to avoid conflict with setup executor) - executor = start_supervised!({Executor, max_concurrent: 2}, id: :limited_executor) - - cmd1 = Command.timer(100, :first) - cmd2 = Command.timer(100, :second) - cmd3 = Command.timer(100, :third) - - {:ok, _} = Executor.execute(executor, cmd1, self(), :comp1) - {:ok, _} = Executor.execute(executor, cmd2, self(), :comp2) - - # Third should fail - assert {:error, :max_concurrent_reached} = Executor.execute(executor, cmd3, self(), :comp3) - - # After one completes, should be able to execute another - assert_receive {:command_result, _, _, _}, @long_timeout - - cmd4 = Command.timer(10, :fourth) - assert {:ok, _} = Executor.execute(executor, cmd4, self(), :comp4) - end - - test "command timeout delivers error result", %{executor: executor} do - # Create a command that would take longer than timeout - cmd = %Command{ - type: :timer, - payload: 200, - on_result: :should_timeout, - timeout: 50 - } - - Executor.execute(executor, cmd, self(), :test) - - # Should receive timeout error, not the result - assert_receive {:command_result, :test, _, {:error, :timeout}}, @default_timeout - refute_receive {:command_result, :test, _, :should_timeout}, @extended_timeout - end - end - - describe "mouse drag with routing and tracking" do - test "tracks complete drag sequence with coordinate transformation" do - components = %{ - panel: %{bounds: %{x: 100, y: 50, width: 200, height: 100}, z_index: 0} - } - - tracker = MouseTracker.new(drag_threshold: 1) - - # Press at global coordinates - route to component - press = Event.mouse(:press, :left, 120, 70) - {component_id, local_event} = MouseRouter.route(components, press) - - assert component_id == :panel - assert local_event.x == 20 - assert local_event.y == 20 - - # Track drag state - {tracker, _events} = MouseTracker.process(tracker, press) - assert MouseTracker.button_down(tracker) == :left - - # Move beyond threshold - drag starts - move = Event.mouse(:move, nil, 150, 90) - {tracker, events} = MouseTracker.process(tracker, move) - - assert MouseTracker.dragging?(tracker) - assert [{:drag_start, :left, 120, 70}, {:drag_move, :left, 150, 90, 30, 20}] = events - - # Release - drag ends - release = Event.mouse(:release, :left, 180, 100) - {tracker, events} = MouseTracker.process(tracker, release) - - refute MouseTracker.dragging?(tracker) - assert [{:drag_end, :left, 180, 100}] = events - end - - test "tracks hover state with component routing" do - components = %{ - button1: %{bounds: %{x: 0, y: 0, width: 50, height: 30}, z_index: 0}, - button2: %{bounds: %{x: 60, y: 0, width: 50, height: 30}, z_index: 0} - } - - tracker = MouseTracker.new() - - # Move over button1 - move1 = Event.mouse(:move, nil, 25, 15) - {id1, _} = MouseRouter.route(components, move1) - {tracker, events} = MouseTracker.update_hover(tracker, id1) - - assert id1 == :button1 - assert events == [{:hover_enter, :button1}] - - # Move to button2 - leave button1, enter button2 - move2 = Event.mouse(:move, nil, 85, 15) - {id2, _} = MouseRouter.route(components, move2) - {_tracker, events} = MouseTracker.update_hover(tracker, id2) - - assert id2 == :button2 - assert events == [{:hover_leave, :button1}, {:hover_enter, :button2}] - end - - test "routes to highest z-order with overlapping components" do - components = %{ - background: %{bounds: %{x: 0, y: 0, width: 100, height: 100}, z_index: 0}, - dialog: %{bounds: %{x: 20, y: 20, width: 60, height: 60}, z_index: 10} - } - - # Click in overlap area routes to higher z-index - event = Event.mouse(:click, :left, 50, 50) - {id, local_event} = MouseRouter.route(components, event) - - assert id == :dialog - assert local_event.x == 30 - assert local_event.y == 30 - end - end - - describe "shortcut system workflows" do - setup do - registry = start_supervised!(Shortcut) - %{registry: registry} - end - - test "global shortcut triggers from any context", %{registry: registry} do - Shortcut.register(registry, %Shortcut{ - key: :q, - modifiers: [:ctrl], - action: {:function, fn -> :quit end}, - scope: :global - }) - - event = Event.key(:q, modifiers: [:ctrl]) - - # Matches in any mode or focused component - assert {:ok, _} = Shortcut.match(registry, event, %{mode: :normal}) - assert {:ok, _} = Shortcut.match(registry, event, %{mode: :edit}) - assert {:ok, _} = Shortcut.match(registry, event, %{focused_component: :editor}) - end - - test "mode-scoped shortcut respects application mode", %{registry: registry} do - Shortcut.register(registry, %Shortcut{ - key: :i, - modifiers: [], - action: {:function, fn -> :insert end}, - scope: {:mode, :normal} - }) - - event = Event.key(:i) - - # Only matches in normal mode - assert :no_match = Shortcut.match(registry, event, %{mode: :edit}) - assert {:ok, _} = Shortcut.match(registry, event, %{mode: :normal}) - end - - test "component-scoped shortcut respects focus", %{registry: registry} do - Shortcut.register(registry, %Shortcut{ - key: :enter, - modifiers: [], - action: {:function, fn -> :submit end}, - scope: {:component, :form} - }) - - event = Event.key(:enter) - - # Only matches when form is focused - assert :no_match = Shortcut.match(registry, event, %{focused_component: :list}) - assert {:ok, _} = Shortcut.match(registry, event, %{focused_component: :form}) - end - - test "key sequence completes across multiple key events", %{registry: registry} do - Shortcut.register(registry, %Shortcut{ - key: :g, - modifiers: [], - action: {:function, fn -> :go_top end}, - sequence: [:g, :g] - }) - - event = Event.key(:g) - - # First key starts sequence - assert :no_match = Shortcut.match(registry, event) - - # Second key completes sequence - assert {:ok, shortcut} = Shortcut.match(registry, event) - assert shortcut.sequence == [:g, :g] - assert Shortcut.execute(shortcut) == :go_top - end - - test "priority resolves conflicting shortcuts", %{registry: registry} do - Shortcut.register(registry, %Shortcut{ - key: :s, - modifiers: [:ctrl], - action: {:function, fn -> :low_priority end}, - priority: 0 - }) - - Shortcut.register(registry, %Shortcut{ - key: :s, - modifiers: [:ctrl], - action: {:function, fn -> :high_priority end}, - priority: 10 - }) - - event = Event.key(:s, modifiers: [:ctrl]) - {:ok, shortcut} = Shortcut.match(registry, event) - - assert Shortcut.execute(shortcut) == :high_priority - end - - test "shortcut triggers clipboard copy operation", %{registry: registry} do - # Register Ctrl+C shortcut that performs copy - Shortcut.register(registry, %Shortcut{ - key: :c, - modifiers: [:ctrl], - action: - {:function, - fn -> - text = "Document content here" - selection = Selection.new() |> Selection.start(9) |> Selection.extend(16) - content = Selection.extract(selection, text) - sequence = Clipboard.write_sequence(content) - {:copied, content, sequence} - end}, - description: "Copy selection to clipboard" - }) - - event = Event.key(:c, modifiers: [:ctrl]) - {:ok, shortcut} = Shortcut.match(registry, event) - {:copied, content, sequence} = Shortcut.execute(shortcut) - - assert content == "content" - assert String.contains?(sequence, Base.encode64("content")) - end - end - - describe "clipboard workflow integration" do - test "complete copy workflow: select, extract, write to clipboard" do - text = "The quick brown fox jumps over the lazy dog" - - # Select "quick brown" - selection = - Selection.new() - |> Selection.start(4) - |> Selection.extend(15) - - # Extract selected content - content = Selection.extract(selection, text) - assert content == "quick brown" - - # Generate clipboard write sequence - sequence = Clipboard.write_sequence(content) - assert String.contains?(sequence, Base.encode64("quick brown")) - end - - test "complete cut workflow: select, copy, delete" do - text = "Hello World" - - # Select "World" - selection = - Selection.new() - |> Selection.start(6) - |> Selection.extend(11) - - # Extract for clipboard - content = Selection.extract(selection, text) - assert content == "World" - - # Generate clipboard sequence - _sequence = Clipboard.write_sequence(content) - - # Delete selected content - {start_pos, end_pos} = Selection.range(selection) - remaining = String.slice(text, 0, start_pos) <> String.slice(text, end_pos..-1//1) - - assert remaining == "Hello " - end - - test "selection expansion simulates shift+arrow navigation" do - text = "Hello World Example" - - # Start with cursor at position 6 (beginning of "World") - # Simulate Shift+Right five times to select "World" - selection = - Selection.new() - |> Selection.start(6) - |> Selection.extend(7) - |> Selection.extend(8) - |> Selection.extend(9) - |> Selection.extend(10) - |> Selection.extend(11) - - assert Selection.extract(selection, text) == "World" - end - end - - describe "focus event workflows" do - test "focus lost triggers registered actions" do - tracker = start_supervised!({Focus.Tracker, initial_focus: true}) - test_pid = self() - - # Register multiple focus lost actions - Focus.Tracker.on_focus_lost(tracker, fn -> - send(test_pid, :autosave_triggered) - end) - - Focus.Tracker.on_focus_lost(tracker, fn -> - send(test_pid, :cleanup_triggered) - end) - - # Lose focus - Focus.Tracker.set_focus(tracker, false) - - # Both actions should execute - assert_receive :autosave_triggered, @default_timeout - assert_receive :cleanup_triggered, @default_timeout - end - - test "focus gained triggers refresh actions" do - tracker = start_supervised!({Focus.Tracker, initial_focus: false}) - test_pid = self() - - Focus.Tracker.on_focus_gained(tracker, fn -> - send(test_pid, :refresh_triggered) - end) - - # Gain focus - Focus.Tracker.set_focus(tracker, true) - - assert_receive :refresh_triggered, @default_timeout - end - - test "auto-pause pauses on focus lost and resumes on focus gained" do - tracker = start_supervised!({Focus.Tracker, initial_focus: true}) - - Focus.Tracker.enable_auto_pause(tracker) - - # Initially not paused - refute Focus.Tracker.paused?(tracker) - - # Lose focus - should pause - Focus.Tracker.set_focus(tracker, false) - assert Focus.Tracker.paused?(tracker) - - # Gain focus - should resume - Focus.Tracker.set_focus(tracker, true) - refute Focus.Tracker.paused?(tracker) - end - - test "focus lost triggers autosave then pauses animations" do - tracker = start_supervised!({Focus.Tracker, initial_focus: true}) - test_pid = self() - - # Register autosave action - Focus.Tracker.on_focus_lost(tracker, fn -> - send(test_pid, :autosave) - end) - - # Enable auto-pause - Focus.Tracker.enable_auto_pause(tracker) - - # Lose focus - Focus.Tracker.set_focus(tracker, false) - - # Both should happen - assert_receive :autosave, @default_timeout - assert Focus.Tracker.paused?(tracker) - end - end - - describe "cross-system integration" do - test "executes shortcut with command execution workflow" do - registry = start_supervised!(Shortcut) - executor = start_supervised!(Executor) - - # Register shortcut that returns a command - Shortcut.register(registry, %Shortcut{ - key: :r, - modifiers: [:ctrl], - action: {:command, Command.timer(10, :refreshed)}, - description: "Refresh" - }) - - # Match shortcut - event = Event.key(:r, modifiers: [:ctrl]) - {:ok, shortcut} = Shortcut.match(registry, event) - - # Execute shortcut returns command - {:execute_command, cmd} = Shortcut.execute(shortcut) - - # Execute command - Executor.execute(executor, cmd, self(), :app) - - # Receive command result - assert_receive {:command_result, :app, _ref, :refreshed}, @default_timeout - end - - test "mouse click triggers shortcut-like action via routing" do - components = %{ - save_button: %{bounds: %{x: 10, y: 10, width: 80, height: 30}, z_index: 0} - } - - # Click on button - event = Event.mouse(:click, :left, 50, 25) - {component_id, local_event} = MouseRouter.route(components, event) - - assert component_id == :save_button - assert local_event.action == :click - - # Component could register a shortcut or handle the click directly - # This demonstrates routing working with events - end - - test "focus change affects shortcut scope matching" do - registry = start_supervised!(Shortcut) - _tracker = start_supervised!({Focus.Tracker, initial_focus: true}) - - # Register component-scoped shortcut - Shortcut.register(registry, %Shortcut{ - key: :enter, - modifiers: [], - action: {:function, fn -> :submit end}, - scope: {:component, :form} - }) - - event = Event.key(:enter) - - # When form is focused, shortcut matches - context = %{focused_component: :form} - assert {:ok, _} = Shortcut.match(registry, event, context) - - # When something else is focused, shortcut doesn't match - context = %{focused_component: :list} - assert :no_match = Shortcut.match(registry, event, context) - end - end - - describe "event propagation and transformation" do - # Test component that handles events - defmodule HandlingComponent do - use GenServer - - def start_link(opts) do - test_pid = Keyword.fetch!(opts, :test_pid) - id = Keyword.fetch!(opts, :id) - GenServer.start_link(__MODULE__, %{test_pid: test_pid, id: id}) - end - - @impl true - def init(state), do: {:ok, state} - - @impl true - def handle_call({:event, event}, _from, state) do - send(state.test_pid, {:handled_by, state.id, event}) - {:reply, :handled, state} - end - end - - # Test component that bubbles events - defmodule BubblingComponent do - use GenServer - - def start_link(opts) do - test_pid = Keyword.fetch!(opts, :test_pid) - id = Keyword.fetch!(opts, :id) - GenServer.start_link(__MODULE__, %{test_pid: test_pid, id: id}) - end - - @impl true - def init(state), do: {:ok, state} - - @impl true - def handle_call({:event, event}, _from, state) do - send(state.test_pid, {:bubbled_through, state.id, event}) - {:reply, :unhandled, state} - end - end - - setup do - start_supervised!(ComponentRegistry) - :ok - end - - test "event bubbles through component hierarchy until handled" do - {:ok, button_pid} = BubblingComponent.start_link(test_pid: self(), id: :button) - {:ok, panel_pid} = BubblingComponent.start_link(test_pid: self(), id: :panel) - {:ok, root_pid} = HandlingComponent.start_link(test_pid: self(), id: :root) - - :ok = ComponentRegistry.register(:button, button_pid, BubblingComponent) - :ok = ComponentRegistry.register(:panel, panel_pid, BubblingComponent) - :ok = ComponentRegistry.register(:root, root_pid, HandlingComponent) - - :ok = Propagation.set_parent(:button, :panel) - :ok = Propagation.set_parent(:panel, :root) - :ok = Propagation.set_parent(:root, nil) - - event = Event.key(:enter) - assert :handled = Propagation.bubble(event, :button) - - # Event should bubble through all components in order - assert_receive {:bubbled_through, :button, ^event} - assert_receive {:bubbled_through, :panel, ^event} - assert_receive {:handled_by, :root, ^event} - end - - test "mouse event transforms coordinates through routing and propagation" do - components = %{ - button: %{bounds: %{x: 50, y: 30, width: 100, height: 40}, z_index: 0} - } - - # Global mouse click - event = Event.mouse(:click, :left, 75, 45) - - # Route to component - {component_id, local_event} = MouseRouter.route(components, event) - - assert component_id == :button - assert local_event.x == 25 - assert local_event.y == 15 - - # Transform with metadata for routing - envelope = Transformation.envelope(local_event, source: :terminal, target: :button) - - assert Transformation.get_metadata(envelope, :source) == :terminal - assert Transformation.get_metadata(envelope, :target) == :button - end - - test "event filtering finds matching events from batch" do - events = [ - Event.key(:a), - Event.key(:c, modifiers: [:ctrl]), - Event.mouse(:click, :left, 10, 20), - Event.key(:v, modifiers: [:ctrl]) - ] - - # Filter for Ctrl+key combinations - ctrl_keys = Transformation.filter(events, type: :key, modifiers_all: [:ctrl]) - - assert length(ctrl_keys) == 2 - assert Enum.all?(ctrl_keys, fn e -> :ctrl in e.modifiers end) - end - - test "coordinate transformation roundtrip preserves position" do - bounds = %{x: 100, y: 50, width: 200, height: 100} - - # Create event at screen coordinates - original = Event.mouse(:click, :left, 150, 80) - - # Transform to local coordinates - local = Transformation.to_local(original, bounds) - assert local.x == 50 - assert local.y == 30 - - # Transform back to screen - screen = Transformation.to_screen(local, bounds) - assert screen.x == original.x - assert screen.y == original.y - end - end - - # Helper Functions - - defp receive_results_with_ref(count, timeout) do - receive_results_with_ref(count, timeout, []) - end - - defp receive_results_with_ref(0, _timeout, acc), do: Enum.reverse(acc) - - defp receive_results_with_ref(count, timeout, acc) do - receive do - {:command_result, component, _ref, result} -> - receive_results_with_ref(count - 1, timeout, [{component, result} | acc]) - after - timeout -> Enum.reverse(acc) - end - end -end diff --git a/test/term_ui/integration/fault_tolerance_test.exs b/test/term_ui/integration/fault_tolerance_test.exs deleted file mode 100644 index 8f661361..00000000 --- a/test/term_ui/integration/fault_tolerance_test.exs +++ /dev/null @@ -1,432 +0,0 @@ -defmodule TermUI.Integration.FaultToleranceTest do - @moduledoc """ - Integration tests for fault tolerance. - - Tests verify crash recovery, state persistence, and proper isolation - of failures in component hierarchies. - """ - - use ExUnit.Case, async: false - - alias TermUI.Component.StatePersistence - alias TermUI.ComponentRegistry - alias TermUI.ComponentServer - alias TermUI.ComponentSupervisor - - # Component that can crash on demand - defmodule CrashableComponent do - use TermUI.StatefulComponent - - @impl true - def init(props) do - {:ok, - %{ - id: props[:id], - tracker: props[:tracker], - counter: props[:counter] || 0 - }} - end - - @impl true - def handle_event(:crash, _state) do - raise "Intentional crash" - end - - def handle_event({:increment, value}, state) do - {:ok, %{state | counter: state.counter + value}} - end - - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, _area) do - text("Counter: #{state.counter}") - end - end - - # Component that tracks lifecycle for crash detection - defmodule MonitoredComponent do - use TermUI.StatefulComponent - - @impl true - def init(props) do - if props[:tracker] do - send(props[:tracker], {:lifecycle, props[:id], :init}) - end - - {:ok, - %{ - id: props[:id], - tracker: props[:tracker], - value: props[:value] || 0 - }} - end - - @impl true - def mount(state) do - if state.tracker do - send(state.tracker, {:lifecycle, state.id, :mount}) - end - - {:ok, state} - end - - @impl true - def handle_event(:crash, _state) do - raise "Crash!" - end - - def handle_event({:set, value}, state) do - {:ok, %{state | value: value}} - end - - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, _area) do - text("Value: #{state.value}") - end - end - - setup do - start_supervised!(StatePersistence) - start_supervised!(ComponentRegistry) - start_supervised!({ComponentSupervisor, max_restarts: 10, max_seconds: 5}) - :ok - end - - describe "crashed child component restarts without affecting parent" do - test "parent remains alive when child crashes" do - {:ok, parent} = - ComponentSupervisor.start_component( - MonitoredComponent, - %{id: :parent, value: 100}, - id: :parent - ) - - {:ok, child} = - ComponentSupervisor.start_component( - CrashableComponent, - %{id: :child, counter: 50}, - id: :child, - restart: :transient - ) - - ComponentServer.mount(parent) - ComponentServer.mount(child) - - ComponentRegistry.set_parent(:child, :parent) - - # Verify initial state - assert ComponentServer.get_state(parent).value == 100 - assert ComponentServer.get_state(child).counter == 50 - - # Crash the child - catch_exit do - ComponentServer.send_event(child, :crash) - end - - # Give supervisor time to restart - Process.sleep(50) - - # Parent should still be alive and unchanged - assert Process.alive?(parent) - assert ComponentServer.get_state(parent).value == 100 - - # Child should have been restarted by supervisor - # (with transient restart strategy) - end - - test "parent can still receive events after child crash" do - {:ok, parent} = - ComponentSupervisor.start_component( - CrashableComponent, - %{id: :parent, counter: 0}, - id: :parent - ) - - {:ok, child} = - ComponentSupervisor.start_component( - CrashableComponent, - %{id: :child, counter: 0}, - id: :child, - restart: :transient - ) - - ComponentServer.mount(parent) - ComponentServer.mount(child) - - ComponentRegistry.set_parent(:child, :parent) - - # Crash child - catch_exit do - ComponentServer.send_event(child, :crash) - end - - Process.sleep(50) - - # Parent should still work - :ok = ComponentServer.send_event(parent, {:increment, 10}) - assert ComponentServer.get_state(parent).counter == 10 - end - end - - describe "crashed component state recovers from persistence" do - test "state is persisted and recovered on restart" do - # Start component with recovery enabled - {:ok, pid} = - ComponentSupervisor.start_component( - CrashableComponent, - %{id: :recoverable, counter: 0}, - id: :recoverable, - restart: :transient, - recovery: :last_state - ) - - ComponentServer.mount(pid) - - # Modify state - :ok = ComponentServer.send_event(pid, {:increment, 42}) - state_before = ComponentServer.get_state(pid) - assert state_before.counter == 42 - - # Crash the component - state should be persisted - catch_exit do - ComponentServer.send_event(pid, :crash) - end - - # Give time for supervisor to restart - Process.sleep(100) - - # Check that state was persisted - case StatePersistence.recover(:recoverable, :last_state) do - {:ok, recovered_state} -> - assert recovered_state.counter == 42 - - :not_found -> - # State might have been cleared after successful restart - :ok - end - end - - test "restart count is tracked" do - {:ok, pid} = - ComponentSupervisor.start_component( - CrashableComponent, - %{id: :counted}, - id: :counted, - restart: :transient - ) - - ComponentServer.mount(pid) - - # Initial restart count should be 0 - assert StatePersistence.get_restart_count(:counted) == 0 - - # Crash the component - catch_exit do - ComponentServer.send_event(pid, :crash) - end - - Process.sleep(50) - - # Restart count should be incremented - # (counted when recovered state is used) - count = StatePersistence.get_restart_count(:counted) - # May or may not have recovery depending on timing - assert count >= 0 - end - end - - describe "sibling components continue functioning during restart" do - test "siblings unaffected by peer crash" do - {:ok, sibling1} = - ComponentSupervisor.start_component( - CrashableComponent, - %{id: :sibling1, counter: 10}, - id: :sibling1 - ) - - {:ok, sibling2} = - ComponentSupervisor.start_component( - CrashableComponent, - %{id: :sibling2, counter: 20}, - id: :sibling2, - restart: :transient - ) - - {:ok, sibling3} = - ComponentSupervisor.start_component( - CrashableComponent, - %{id: :sibling3, counter: 30}, - id: :sibling3 - ) - - ComponentServer.mount(sibling1) - ComponentServer.mount(sibling2) - ComponentServer.mount(sibling3) - - # Crash sibling2 - catch_exit do - ComponentServer.send_event(sibling2, :crash) - end - - Process.sleep(50) - - # Siblings 1 and 3 should be unaffected - assert Process.alive?(sibling1) - assert Process.alive?(sibling3) - assert ComponentServer.get_state(sibling1).counter == 10 - assert ComponentServer.get_state(sibling3).counter == 30 - - # Can still interact with siblings - :ok = ComponentServer.send_event(sibling1, {:increment, 5}) - assert ComponentServer.get_state(sibling1).counter == 15 - end - - test "hierarchy isolation - cousin crashes don't affect other branches" do - {:ok, parent1} = - ComponentSupervisor.start_component( - CrashableComponent, - %{id: :parent1, counter: 100}, - id: :parent1 - ) - - {:ok, child1} = - ComponentSupervisor.start_component( - CrashableComponent, - %{id: :child1, counter: 10}, - id: :child1, - restart: :transient - ) - - {:ok, parent2} = - ComponentSupervisor.start_component( - CrashableComponent, - %{id: :parent2, counter: 200}, - id: :parent2 - ) - - {:ok, child2} = - ComponentSupervisor.start_component( - CrashableComponent, - %{id: :child2, counter: 20}, - id: :child2 - ) - - ComponentServer.mount(parent1) - ComponentServer.mount(child1) - ComponentServer.mount(parent2) - ComponentServer.mount(child2) - - ComponentRegistry.set_parent(:child1, :parent1) - ComponentRegistry.set_parent(:child2, :parent2) - - # Crash child1 - catch_exit do - ComponentServer.send_event(child1, :crash) - end - - Process.sleep(50) - - # Parent2 and child2 should be completely unaffected - assert Process.alive?(parent2) - assert Process.alive?(child2) - assert ComponentServer.get_state(parent2).counter == 200 - assert ComponentServer.get_state(child2).counter == 20 - end - end - - describe "restart storm triggers supervisor shutdown" do - test "rapid restarts trigger intensity limit" do - # Create component with tight restart limits - {:ok, pid} = - ComponentSupervisor.start_component( - CrashableComponent, - %{id: :storm}, - id: :storm, - restart: :permanent, - max_restarts: 2, - max_seconds: 5 - ) - - ComponentServer.mount(pid) - - # Track initial component count - initial_count = ComponentSupervisor.count_children() - assert initial_count >= 1 - - # Crash multiple times rapidly - # Note: The supervisor's overall limit will trigger, not component-specific - Enum.each(1..3, fn _ -> - case ComponentRegistry.lookup(:storm) do - {:ok, current_pid} -> - catch_exit do - ComponentServer.send_event(current_pid, :crash) - end - - Process.sleep(10) - - {:error, :not_found} -> - :ok - end - end) - - Process.sleep(100) - - # After restart storm, component might be gone - # (supervisor may have given up) - end - end - - describe "recovery modes" do - test "reset recovery mode starts fresh" do - {:ok, pid} = - ComponentSupervisor.start_component( - CrashableComponent, - %{id: :reset_test, counter: 0}, - id: :reset_test, - restart: :transient, - recovery: :reset - ) - - ComponentServer.mount(pid) - - # Modify state - :ok = ComponentServer.send_event(pid, {:increment, 100}) - assert ComponentServer.get_state(pid).counter == 100 - - # Persist state manually (simulating crash) - StatePersistence.persist(:reset_test, %{counter: 100}) - - # With :reset mode, recovery should return :not_found - assert :not_found = StatePersistence.recover(:reset_test, :reset) - end - - test "temporary restart never restarts" do - {:ok, pid} = - ComponentSupervisor.start_component( - CrashableComponent, - %{id: :temporary}, - id: :temporary, - restart: :temporary - ) - - ComponentServer.mount(pid) - - # Crash it - catch_exit do - ComponentServer.send_event(pid, :crash) - end - - Process.sleep(50) - - # Should not be restarted - assert {:error, :not_found} = ComponentRegistry.lookup(:temporary) - end - end -end diff --git a/test/term_ui/integration/focus_integration_test.exs b/test/term_ui/integration/focus_integration_test.exs deleted file mode 100644 index 84dda731..00000000 --- a/test/term_ui/integration/focus_integration_test.exs +++ /dev/null @@ -1,408 +0,0 @@ -defmodule TermUI.Integration.FocusIntegrationTest do - @moduledoc """ - Integration tests for focus management in realistic UIs. - - Tests verify Tab traversal, focus trapping, and focus restoration - work correctly with multiple focusable components. - """ - - use ExUnit.Case, async: false - - alias TermUI.Component.StatePersistence - alias TermUI.ComponentRegistry - alias TermUI.ComponentServer - alias TermUI.ComponentSupervisor - alias TermUI.Event - alias TermUI.EventRouter - alias TermUI.FocusManager - alias TermUI.SpatialIndex - - # Focusable input component - defmodule FocusableInput do - use TermUI.StatefulComponent - - @impl true - def init(props) do - {:ok, - %{ - id: props[:id], - tracker: props[:tracker], - focusable: Map.get(props, :focusable, true) - }} - end - - @impl true - def handle_event(event, state) do - if state.tracker do - send(state.tracker, {:event, state.id, event}) - end - - {:ok, state} - end - - @impl true - def render(_state, _area) do - text("") - end - end - - setup do - start_supervised!(StatePersistence) - start_supervised!(ComponentRegistry) - start_supervised!(ComponentSupervisor) - start_supervised!(SpatialIndex) - start_supervised!(FocusManager) - start_supervised!(EventRouter) - :ok - end - - describe "Tab traversal through form with multiple inputs" do - test "Tab moves focus to next component in order" do - tracker = self() - - # Create form with 3 inputs - {:ok, input1} = - ComponentSupervisor.start_component( - FocusableInput, - %{id: :input1, tracker: tracker}, - id: :input1 - ) - - {:ok, input2} = - ComponentSupervisor.start_component( - FocusableInput, - %{id: :input2, tracker: tracker}, - id: :input2 - ) - - {:ok, input3} = - ComponentSupervisor.start_component( - FocusableInput, - %{id: :input3, tracker: tracker}, - id: :input3 - ) - - ComponentServer.mount(input1) - ComponentServer.mount(input2) - ComponentServer.mount(input3) - - # Register with spatial positions for tab order (top to bottom) - SpatialIndex.update(:input1, input1, %{x: 0, y: 0, width: 20, height: 1}) - SpatialIndex.update(:input2, input2, %{x: 0, y: 2, width: 20, height: 1}) - SpatialIndex.update(:input3, input3, %{x: 0, y: 4, width: 20, height: 1}) - - # Focus first input - FocusManager.set_focused(:input1) - assert {:ok, :input1} = FocusManager.get_focused() - - # Tab to next - FocusManager.focus_next() - assert {:ok, :input2} = FocusManager.get_focused() - - # Tab to next - FocusManager.focus_next() - assert {:ok, :input3} = FocusManager.get_focused() - end - - test "Shift+Tab moves focus to previous component" do - {:ok, input1} = - ComponentSupervisor.start_component( - FocusableInput, - %{id: :input1}, - id: :input1 - ) - - {:ok, input2} = - ComponentSupervisor.start_component( - FocusableInput, - %{id: :input2}, - id: :input2 - ) - - {:ok, input3} = - ComponentSupervisor.start_component( - FocusableInput, - %{id: :input3}, - id: :input3 - ) - - ComponentServer.mount(input1) - ComponentServer.mount(input2) - ComponentServer.mount(input3) - - SpatialIndex.update(:input1, input1, %{x: 0, y: 0, width: 20, height: 1}) - SpatialIndex.update(:input2, input2, %{x: 0, y: 2, width: 20, height: 1}) - SpatialIndex.update(:input3, input3, %{x: 0, y: 4, width: 20, height: 1}) - - # Focus last input - FocusManager.set_focused(:input3) - assert {:ok, :input3} = FocusManager.get_focused() - - # Shift+Tab to previous - FocusManager.focus_prev() - assert {:ok, :input2} = FocusManager.get_focused() - - # Shift+Tab to previous - FocusManager.focus_prev() - assert {:ok, :input1} = FocusManager.get_focused() - end - - test "Tab wraps from last to first" do - {:ok, input1} = - ComponentSupervisor.start_component( - FocusableInput, - %{id: :input1}, - id: :input1 - ) - - {:ok, input2} = - ComponentSupervisor.start_component( - FocusableInput, - %{id: :input2}, - id: :input2 - ) - - ComponentServer.mount(input1) - ComponentServer.mount(input2) - - SpatialIndex.update(:input1, input1, %{x: 0, y: 0, width: 20, height: 1}) - SpatialIndex.update(:input2, input2, %{x: 0, y: 2, width: 20, height: 1}) - - # Focus last - FocusManager.set_focused(:input2) - - # Tab should wrap to first - FocusManager.focus_next() - assert {:ok, :input1} = FocusManager.get_focused() - end - end - - describe "focus trap in modal" do - test "trap_focus restricts traversal to group" do - # Create modal components - {:ok, modal_input1} = - ComponentSupervisor.start_component( - FocusableInput, - %{id: :modal_input1}, - id: :modal_input1 - ) - - {:ok, modal_input2} = - ComponentSupervisor.start_component( - FocusableInput, - %{id: :modal_input2}, - id: :modal_input2 - ) - - ComponentServer.mount(modal_input1) - ComponentServer.mount(modal_input2) - - SpatialIndex.update(:modal_input1, modal_input1, %{x: 10, y: 5, width: 20, height: 1}) - SpatialIndex.update(:modal_input2, modal_input2, %{x: 10, y: 7, width: 20, height: 1}) - - # Register and trap focus in modal group - FocusManager.register_group(:modal, [:modal_input1, :modal_input2]) - FocusManager.trap_focus(:modal) - FocusManager.set_focused(:modal_input1) - - # Tab should stay within modal - FocusManager.focus_next() - assert {:ok, :modal_input2} = FocusManager.get_focused() - - # Tab again should wrap within modal - FocusManager.focus_next() - assert {:ok, :modal_input1} = FocusManager.get_focused() - end - - test "release_focus restores normal traversal" do - {:ok, bg_input} = - ComponentSupervisor.start_component( - FocusableInput, - %{id: :bg_input}, - id: :bg_input - ) - - {:ok, modal_input} = - ComponentSupervisor.start_component( - FocusableInput, - %{id: :modal_input}, - id: :modal_input - ) - - ComponentServer.mount(bg_input) - ComponentServer.mount(modal_input) - - SpatialIndex.update(:bg_input, bg_input, %{x: 0, y: 0, width: 20, height: 1}) - SpatialIndex.update(:modal_input, modal_input, %{x: 10, y: 5, width: 20, height: 1}) - - # Register modal group - FocusManager.register_group(:modal, [:modal_input]) - - # Trap and then release - FocusManager.trap_focus(:modal) - FocusManager.set_focused(:modal_input) - FocusManager.release_focus() - - # Now Tab should work normally - FocusManager.focus_next() - # Should be able to access bg_input now - assert {:ok, :bg_input} = FocusManager.get_focused() - end - end - - describe "focus returns to previous component after modal closes" do - test "focus restores to previous component" do - {:ok, input} = - ComponentSupervisor.start_component( - FocusableInput, - %{id: :input}, - id: :input - ) - - {:ok, modal_btn} = - ComponentSupervisor.start_component( - FocusableInput, - %{id: :modal_btn}, - id: :modal_btn - ) - - ComponentServer.mount(input) - ComponentServer.mount(modal_btn) - - SpatialIndex.update(:input, input, %{x: 0, y: 0, width: 20, height: 1}) - SpatialIndex.update(:modal_btn, modal_btn, %{x: 10, y: 5, width: 20, height: 1}) - - # Focus input first - FocusManager.set_focused(:input) - assert {:ok, :input} = FocusManager.get_focused() - - # Open modal (push focus) - FocusManager.push_focus(:modal_btn) - assert {:ok, :modal_btn} = FocusManager.get_focused() - - # Close modal (pop focus) - FocusManager.pop_focus() - assert {:ok, :input} = FocusManager.get_focused() - end - - test "nested modals restore correctly" do - {:ok, main_input} = - ComponentSupervisor.start_component( - FocusableInput, - %{id: :main_input}, - id: :main_input - ) - - {:ok, modal1_btn} = - ComponentSupervisor.start_component( - FocusableInput, - %{id: :modal1_btn}, - id: :modal1_btn - ) - - {:ok, modal2_btn} = - ComponentSupervisor.start_component( - FocusableInput, - %{id: :modal2_btn}, - id: :modal2_btn - ) - - ComponentServer.mount(main_input) - ComponentServer.mount(modal1_btn) - ComponentServer.mount(modal2_btn) - - SpatialIndex.update(:main_input, main_input, %{x: 0, y: 0, width: 20, height: 1}) - SpatialIndex.update(:modal1_btn, modal1_btn, %{x: 10, y: 5, width: 20, height: 1}) - SpatialIndex.update(:modal2_btn, modal2_btn, %{x: 15, y: 8, width: 20, height: 1}) - - # Focus main - FocusManager.set_focused(:main_input) - - # Open modal1 - FocusManager.push_focus(:modal1_btn) - - # Open modal2 - FocusManager.push_focus(:modal2_btn) - assert {:ok, :modal2_btn} = FocusManager.get_focused() - - # Close modal2 - FocusManager.pop_focus() - assert {:ok, :modal1_btn} = FocusManager.get_focused() - - # Close modal1 - FocusManager.pop_focus() - assert {:ok, :main_input} = FocusManager.get_focused() - end - end - - describe "programmatic focus change during event handling" do - test "component can change focus when handling event" do - tracker = self() - - {:ok, button} = - ComponentSupervisor.start_component( - FocusableInput, - %{id: :button, tracker: tracker}, - id: :button - ) - - {:ok, input} = - ComponentSupervisor.start_component( - FocusableInput, - %{id: :input, tracker: tracker}, - id: :input - ) - - ComponentServer.mount(button) - ComponentServer.mount(input) - - SpatialIndex.update(:button, button, %{x: 0, y: 0, width: 10, height: 1}) - SpatialIndex.update(:input, input, %{x: 0, y: 2, width: 20, height: 1}) - - FocusManager.set_focused(:button) - assert {:ok, :button} = FocusManager.get_focused() - - # Simulate button click that focuses input - FocusManager.set_focused(:input) - assert {:ok, :input} = FocusManager.get_focused() - - # Input should now receive events - event = %Event.Key{key: :a, char: "a"} - EventRouter.route(event) - - assert_receive {:event, :input, ^event}, 100 - end - - test "focus change updates correctly" do - tracker = self() - - {:ok, comp1} = - ComponentSupervisor.start_component( - FocusableInput, - %{id: :comp1, tracker: tracker}, - id: :comp1 - ) - - {:ok, comp2} = - ComponentSupervisor.start_component( - FocusableInput, - %{id: :comp2, tracker: tracker}, - id: :comp2 - ) - - ComponentServer.mount(comp1) - ComponentServer.mount(comp2) - - SpatialIndex.update(:comp1, comp1, %{x: 0, y: 0, width: 10, height: 1}) - SpatialIndex.update(:comp2, comp2, %{x: 0, y: 2, width: 10, height: 1}) - - FocusManager.set_focused(:comp1) - - # Change focus - FocusManager.set_focused(:comp2) - - # The new focus should be comp2 - assert {:ok, :comp2} = FocusManager.get_focused() - end - end -end diff --git a/test/term_ui/integration/iex_lifecycle_test.exs b/test/term_ui/integration/iex_lifecycle_test.exs deleted file mode 100644 index 3ce4734f..00000000 --- a/test/term_ui/integration/iex_lifecycle_test.exs +++ /dev/null @@ -1,475 +0,0 @@ -defmodule TermUI.Integration.IExLifecycleTest do - @moduledoc """ - Integration tests for IEx lifecycle. - - Tests the complete application lifecycle when running in IEx mode: - - Start, render, input, update, render, shutdown cycle - - Keyboard input handling - - Crash recovery and cleanup - - Multiple start/stop cycles - - These tests simulate IEx environment by setting process dictionary - and configuration options to force IEx-compatible mode. - """ - - use ExUnit.Case, async: false - - alias TermUI.Command - alias TermUI.Event - alias TermUI.Runtime - - # Note: These tests use async: false because they manipulate global - # process state and application configuration. - - # Simple counter component for testing - defmodule Counter do - @moduledoc """ - Test component for IEx lifecycle testing. - - A simple counter that responds to keyboard events and quit commands. - """ - - use TermUI.Elm - - @impl true - def init(_opts), do: %{count: 0} - - @impl true - def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :increment} - def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :decrement} - def event_to_msg(%Event.Key{key: "q"}, _state), do: {:msg, :quit} - def event_to_msg(%Event.Key{key: "r"}, _state), do: {:msg, :reset} - def event_to_msg(_, _), do: :ignore - - @impl true - def update(:increment, state) do - {%{state | count: state.count + 1}, []} - end - - def update(:decrement, state) do - {%{state | count: state.count - 1}, []} - end - - def update(:quit, state) do - {state, [Command.quit()]} - end - - def update(:reset, state) do - {%{state | count: 0}, []} - end - - def update(_msg, state), do: {state, []} - - @impl true - def view(state), do: {:text, "Count: #{state.count}"} - end - - # Component that tracks lifecycle events - defmodule LifecycleTracker do - @moduledoc """ - Test component that tracks lifecycle events. - - Records init, update, and view calls for verification. - """ - - use TermUI.Elm - - @impl true - def init(_opts) do - %{init_called: true, updates: [], views: 0, data: %{}} - end - - @impl true - def event_to_msg(%Event.Key{key: "t"}, _state), do: {:msg, :tick} - def event_to_msg(%Event.Key{key: "q"}, _state), do: {:msg, :quit} - def event_to_msg(_, _), do: :ignore - - @impl true - def update(:tick, state) do - {%{state | updates: [:tick | state.updates]}, []} - end - - def update(msg, state) do - {%{state | updates: [msg | state.updates]}, []} - end - - @impl true - def view(state) do - # Increment view counter (stored separately to avoid infinite loop) - new_state = %{state | views: state.views + 1} - {:text, "Views: #{new_state.views}, Updates: #{length(state.updates)}"} - end - end - - # Component that crashes on specific message - defmodule CrashingComponent do - @moduledoc """ - Test component that crashes on command. - - Used to verify cleanup and recovery from crashes. - """ - - use TermUI.Elm - - @impl true - def init(_opts), do: %{count: 0} - - @impl true - def event_to_msg(%Event.Key{key: "c"}, _state), do: {:msg, :crash} - def event_to_msg(%Event.Key{key: "i"}, _state), do: {:msg, :increment} - def event_to_msg(_, _), do: :ignore - - @impl true - def update(:crash, _state) do - raise "Intentional crash for testing" - end - - def update(:increment, state) do - {%{state | count: state.count + 1}, []} - end - - def update(_msg, state), do: {state, []} - - @impl true - def view(state), do: {:text, "Count: #{state.count}"} - end - - describe "IEx lifecycle simulation" do - setup do - # Save original environment state - original_env = Application.get_env(:term_ui, :iex_compatible) - original_iex_env = System.get_env("TERM_UI_IEX_MODE") - - # Simulate IEx environment by setting config - Application.put_env(:term_ui, :iex_compatible, true) - - on_exit(fn -> - # Restore original environment - case original_env do - nil -> Application.delete_env(:term_ui, :iex_compatible) - val -> Application.put_env(:term_ui, :iex_compatible, val) - end - - case original_iex_env do - nil -> System.delete_env("TERM_UI_IEX_MODE") - val -> System.put_env("TERM_UI_IEX_MODE", val) - end - end) - - :ok - end - - test "7.6.1.1: start -> render -> input -> update -> render -> shutdown cycle in IEx mode" do - # Verify IEx mode is active - assert TermUI.iex_mode?() - assert TermUI.running_mode() == :iex - - # Start runtime - {:ok, runtime} = Runtime.start_link(root: Counter, skip_terminal: true) - ref = Process.monitor(runtime) - - # Verify runtime started successfully - assert Process.alive?(runtime) - - # Send input event (keyboard press) - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - # Verify state was updated - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - - # Send another event to trigger another render cycle - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 2 - - # Send quit to shutdown - Runtime.send_event(runtime, Event.key("q")) - - # Verify clean shutdown - assert_receive {:DOWN, ^ref, :process, ^runtime, :normal}, 1000 - refute Process.alive?(runtime) - end - - test "7.6.1.2: keyboard input works correctly in IEx mode" do - # Start runtime with counter - {:ok, runtime} = Runtime.start_link(root: Counter, skip_terminal: true) - - on_exit(fn -> - if Process.alive?(runtime), do: Runtime.shutdown(runtime) - end) - - # Test increment key (up arrow) - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - - # Test multiple increments - for _ <- 1..5 do - Runtime.send_event(runtime, Event.key(:up)) - end - - Runtime.sync(runtime) - state = Runtime.get_state(runtime) - assert state.root_state.count == 6 - - # Test decrement key (down arrow) - Runtime.send_event(runtime, Event.key(:down)) - Runtime.sync(runtime) - state = Runtime.get_state(runtime) - assert state.root_state.count == 5 - - # Test reset key - Runtime.send_event(runtime, Event.key("r")) - Runtime.sync(runtime) - state = Runtime.get_state(runtime) - assert state.root_state.count == 0 - end - - test "7.6.1.3: cleanup on crash in IEx mode" do - # Start runtime with crashing component - {:ok, runtime} = Runtime.start_link(root: CrashingComponent, skip_terminal: true) - ref = Process.monitor(runtime) - - # Verify normal operation first - Runtime.send_event(runtime, Event.key("i")) - Runtime.sync(runtime) - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - - # Trigger crash - Runtime.send_event(runtime, Event.key("c")) - - # The runtime should survive the crash (component may be restarted) - # Wait for crash to be processed - Process.sleep(100) - - # Runtime should still be alive (or cleanly shut down) - # Either behavior is acceptable for crash handling - alive = Process.alive?(runtime) - - if alive do - # If alive, verify it still responds - Runtime.send_event(runtime, Event.key("i")) - Runtime.sync(runtime) - - # State may have been reset, but runtime should work - assert Process.alive?(runtime) - - # Shutdown and wait for exit - Runtime.shutdown(runtime) - assert_receive {:DOWN, ^ref, :process, ^runtime, :normal}, 1000 - else - # If shut down, verify clean exit - assert_receive {:DOWN, ^ref, :process, ^runtime, _reason}, 500 - end - - # Either way, no zombie processes should remain - refute Process.alive?(runtime) - end - - test "7.6.1.4: multiple start/stop cycles in IEx session" do - # Simulate multiple IEx sessions in sequence - for cycle <- 1..3 do - # Start a runtime - {:ok, runtime} = Runtime.start_link(root: Counter, skip_terminal: true) - ref = Process.monitor(runtime) - - # Verify it started - assert Process.alive?(runtime) - - # Do some work - for _ <- 1..cycle do - Runtime.send_event(runtime, Event.key(:up)) - end - - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.count == cycle - - # Shutdown cleanly and wait for exit - Runtime.shutdown(runtime) - assert_receive {:DOWN, ^ref, :process, ^runtime, :normal}, 1000 - - # Verify shutdown completed - refute Process.alive?(runtime) - end - - # All cycles completed successfully - assert true - end - - test "IEx mode is detected correctly via config" do - # With config set to true, iex_mode? should return true - assert TermUI.iex_mode?() - assert TermUI.running_mode() == :iex - end - end - - describe "IEx mode detection override via environment variable" do - setup do - # Save original environment - original_env = Application.get_env(:term_ui, :iex_compatible) - original_iex_env = System.get_env("TERM_UI_IEX_MODE") - - on_exit(fn -> - # Restore original environment - case original_env do - nil -> Application.delete_env(:term_ui, :iex_compatible) - val -> Application.put_env(:term_ui, :iex_compatible, val) - end - - case original_iex_env do - nil -> System.delete_env("TERM_UI_IEX_MODE") - val -> System.put_env("TERM_UI_IEX_MODE", val) - end - end) - - :ok - end - - test "environment variable overrides config" do - # Set config to false but env var to true - Application.put_env(:term_ui, :iex_compatible, false) - System.put_env("TERM_UI_IEX_MODE", "true") - - # Env var should take precedence - assert TermUI.iex_mode?() - assert TermUI.running_mode() == :iex - end - - test "environment variable 'false' overrides config true" do - # Set config to true but env var to false - Application.put_env(:term_ui, :iex_compatible, true) - System.put_env("TERM_UI_IEX_MODE", "false") - - # Env var should take precedence - refute TermUI.iex_mode?() - assert TermUI.running_mode() == :standalone - end - end - - describe "lifecycle event tracking" do - setup do - original_env = Application.get_env(:term_ui, :iex_compatible) - - Application.put_env(:term_ui, :iex_compatible, true) - - on_exit(fn -> - case original_env do - nil -> Application.delete_env(:term_ui, :iex_compatible) - val -> Application.put_env(:term_ui, :iex_compatible, val) - end - end) - - :ok - end - - test "init is called on startup" do - {:ok, runtime} = Runtime.start_link(root: LifecycleTracker, skip_terminal: true) - - on_exit(fn -> - if Process.alive?(runtime), do: Runtime.shutdown(runtime) - end) - - state = Runtime.get_state(runtime) - assert state.root_state.init_called == true - end - - test "update is called for each event" do - {:ok, runtime} = Runtime.start_link(root: LifecycleTracker, skip_terminal: true) - - on_exit(fn -> - if Process.alive?(runtime), do: Runtime.shutdown(runtime) - end) - - # Send multiple events - for _ <- 1..5 do - Runtime.send_event(runtime, Event.key("t")) - end - - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert length(state.root_state.updates) == 5 - end - - test "view is callable and returns valid result" do - {:ok, runtime} = Runtime.start_link(root: LifecycleTracker, skip_terminal: true) - - on_exit(fn -> - if Process.alive?(runtime), do: Runtime.shutdown(runtime) - end) - - # Trigger an update which will trigger a view - Runtime.send_event(runtime, Event.key("t")) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - # Verify the view state structure exists and is valid - assert is_integer(state.root_state.views) - # In skip_terminal mode, view may not be called, so views may be 0 - # But we can verify the state structure is correct - assert state.root_state.views >= 0 - end - end - - describe "runtime backend selection in IEx mode" do - setup do - original_env = Application.get_env(:term_ui, :iex_compatible) - - Application.put_env(:term_ui, :iex_compatible, true) - - on_exit(fn -> - case original_env do - nil -> Application.delete_env(:term_ui, :iex_compatible) - val -> Application.put_env(:term_ui, :iex_compatible, val) - end - end) - - :ok - end - - test "runtime starts with TTY backend when in IEx mode" do - # In IEx mode, the backend selector should prefer TTY - # Start runtime with backend: :auto (default) - {:ok, runtime} = Runtime.start_link(root: Counter, skip_terminal: true) - - on_exit(fn -> - if Process.alive?(runtime), do: Runtime.shutdown(runtime) - end) - - # Runtime should be alive and functional - assert Process.alive?(runtime) - - # Send an event to verify it's working - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - end - - test "runtime can be explicitly set to TTY backend" do - {:ok, runtime} = Runtime.start_link(root: Counter, backend: :tty, skip_terminal: true) - - on_exit(fn -> - if Process.alive?(runtime), do: Runtime.shutdown(runtime) - end) - - # Runtime should be alive and functional - assert Process.alive?(runtime) - - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - end - end -end diff --git a/test/term_ui/integration/multi_component_test.exs b/test/term_ui/integration/multi_component_test.exs deleted file mode 100644 index 04637aca..00000000 --- a/test/term_ui/integration/multi_component_test.exs +++ /dev/null @@ -1,374 +0,0 @@ -defmodule TermUI.Integration.MultiComponentTest do - @moduledoc """ - Integration tests for multi-component applications. - - Tests event flow, focus management, and message passing between - multiple interactive components. - """ - - use TermUI.RuntimeTestCase - - # Root component that manages child components - defmodule MultiRoot do - @moduledoc """ - Multi-component root for focus and event routing tests. - - Manages two child components (child_a, child_b) with focus switching - via Tab key or mouse clicks. Tests focus management, event routing - to focused components, and broadcast events like resize. - """ - - use TermUI.Elm - - @impl true - def init(_opts) do - %{ - focused: :child_a, - child_a: %{count: 0}, - child_b: %{count: 0}, - broadcasts_received: 0 - } - end - - @impl true - def event_to_msg(%Event.Key{key: :tab}, _state), do: {:msg, :toggle_focus} - def event_to_msg(%Event.Key{key: :up}, state), do: {:msg, {:increment, state.focused}} - def event_to_msg(%Event.Key{key: :down}, state), do: {:msg, {:decrement, state.focused}} - def event_to_msg(%Event.Resize{}, _state), do: {:msg, :resize_received} - def event_to_msg(%Event.Mouse{x: x}, _state) when x < 40, do: {:msg, :focus_a} - def event_to_msg(%Event.Mouse{x: x}, _state) when x >= 40, do: {:msg, :focus_b} - def event_to_msg(_, _), do: :ignore - - @impl true - def update(:toggle_focus, state) do - new_focused = if state.focused == :child_a, do: :child_b, else: :child_a - {%{state | focused: new_focused}, []} - end - - def update({:increment, :child_a}, state) do - child_a = %{state.child_a | count: state.child_a.count + 1} - {%{state | child_a: child_a}, []} - end - - def update({:increment, :child_b}, state) do - child_b = %{state.child_b | count: state.child_b.count + 1} - {%{state | child_b: child_b}, []} - end - - def update({:decrement, :child_a}, state) do - child_a = %{state.child_a | count: state.child_a.count - 1} - {%{state | child_a: child_a}, []} - end - - def update({:decrement, :child_b}, state) do - child_b = %{state.child_b | count: state.child_b.count - 1} - {%{state | child_b: child_b}, []} - end - - def update(:focus_a, state) do - {%{state | focused: :child_a}, []} - end - - def update(:focus_b, state) do - {%{state | focused: :child_b}, []} - end - - def update(:resize_received, state) do - {%{state | broadcasts_received: state.broadcasts_received + 1}, []} - end - - def update(_msg, state), do: {state, []} - - @impl true - def view(state) do - {:text, "A: #{state.child_a.count}, B: #{state.child_b.count}, Focus: #{state.focused}"} - end - end - - # Component that tracks messages from parent - defmodule MessageTracker do - @moduledoc """ - Test component for message passing verification. - - Tracks messages sent directly to the component and command results, - used to verify the message passing and command result delivery systems. - """ - - use TermUI.Elm - - @impl true - def init(_opts) do - %{ - messages: [], - results: [] - } - end - - @impl true - def event_to_msg(%Event.Key{key: "m"}, _state), do: {:msg, :send_message} - def event_to_msg(_, _), do: :ignore - - @impl true - def update(:send_message, state) do - {%{state | messages: [:sent | state.messages]}, []} - end - - def update({:result, value}, state) do - {%{state | results: [value | state.results]}, []} - end - - def update(_msg, state), do: {state, []} - - @impl true - def view(state) do - {:text, "Messages: #{length(state.messages)}, Results: #{length(state.results)}"} - end - end - - describe "focus management" do - test "keyboard events go to focused component" do - runtime = start_test_runtime(MultiRoot) - - # Initial focus is child_a - state = Runtime.get_state(runtime) - assert state.root_state.focused == :child_a - - # Increment should affect child_a - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.child_a.count == 1 - assert state.root_state.child_b.count == 0 - end - - test "tab toggles focus between components" do - runtime = start_test_runtime(MultiRoot) - - # Toggle focus - Runtime.send_event(runtime, Event.key(:tab)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.focused == :child_b - - # Toggle again - Runtime.send_event(runtime, Event.key(:tab)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.focused == :child_a - end - - test "focus change routes keyboard to new component" do - runtime = start_test_runtime(MultiRoot) - - # Increment child_a - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - # Toggle to child_b - Runtime.send_event(runtime, Event.key(:tab)) - Runtime.sync(runtime) - - # Increment should now affect child_b - Runtime.send_event(runtime, Event.key(:up)) - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.child_a.count == 1 - assert state.root_state.child_b.count == 2 - end - - test "mouse click can change focus" do - runtime = start_test_runtime(MultiRoot) - - # Click on right side (x >= 40) to focus child_b - Runtime.send_event(runtime, Event.mouse(:press, :left, 50, 10)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.focused == :child_b - - # Click on left side (x < 40) to focus child_a - Runtime.send_event(runtime, Event.mouse(:press, :left, 20, 10)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.focused == :child_a - end - - test "mouse focus boundary at x=40" do - runtime = start_test_runtime(MultiRoot) - - # x=39 should focus child_a (last pixel of left side) - Runtime.send_event(runtime, Event.mouse(:press, :left, 39, 10)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.focused == :child_a - - # x=40 should focus child_b (first pixel of right side) - Runtime.send_event(runtime, Event.mouse(:press, :left, 40, 10)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.focused == :child_b - - # x=0 should focus child_a (leftmost pixel) - Runtime.send_event(runtime, Event.mouse(:press, :left, 0, 10)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.focused == :child_a - - # x=79 should focus child_b (rightmost pixel in 80-column terminal) - Runtime.send_event(runtime, Event.mouse(:press, :left, 79, 10)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.focused == :child_b - end - end - - describe "broadcast events" do - test "resize events reach component" do - runtime = start_test_runtime(MultiRoot) - - # Send resize events - Runtime.send_event(runtime, Event.resize(120, 40)) - Runtime.send_event(runtime, Event.resize(80, 24)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.broadcasts_received == 2 - end - end - - describe "message passing" do - test "direct messages update component state" do - runtime = start_test_runtime(MessageTracker) - - # Send direct message - Runtime.send_message(runtime, :root, :send_message) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert length(state.root_state.messages) == 1 - end - - test "multiple messages are processed in order" do - runtime = start_test_runtime(MessageTracker) - - # Send multiple messages - Runtime.send_message(runtime, :root, :send_message) - Runtime.send_message(runtime, :root, :send_message) - Runtime.send_message(runtime, :root, :send_message) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert length(state.root_state.messages) == 3 - end - - test "command results return to component" do - runtime = start_test_runtime(MessageTracker) - - # Simulate command result - Runtime.command_result(runtime, :root, make_ref(), {:result, :success}) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert length(state.root_state.results) == 1 - end - end - - describe "component isolation" do - test "components maintain independent state" do - runtime = start_test_runtime(MultiRoot) - - # Increment child_a twice - Runtime.send_event(runtime, Event.key(:up)) - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.child_a.count == 2 - assert state.root_state.child_b.count == 0 - - # Toggle to child_b and increment once - Runtime.send_event(runtime, Event.key(:tab)) - Runtime.sync(runtime) - - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.child_a.count == 2 - assert state.root_state.child_b.count == 1 - - # Decrement child_b - Runtime.send_event(runtime, Event.key(:down)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.child_a.count == 2 - assert state.root_state.child_b.count == 0 - end - end - - describe "complex interactions" do - test "rapid focus changes and events" do - runtime = start_test_runtime(MultiRoot) - - # Rapid interactions - for _ <- 1..10 do - Runtime.send_event(runtime, Event.key(:up)) - Runtime.send_event(runtime, Event.key(:tab)) - end - - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - # After 10 iterations: each component gets incremented when focused - # Tab toggles focus, so alternating increments - total = state.root_state.child_a.count + state.root_state.child_b.count - assert total == 10 - end - - test "mixed event types in sequence" do - runtime = start_test_runtime(MultiRoot) - - # Mix of keyboard, mouse, and resize events - # Initial focus is child_a - # child_a: 1 - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - # focus child_b - Runtime.send_event(runtime, Event.mouse(:press, :left, 50, 10)) - Runtime.sync(runtime) - - # child_b: 1 - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - # broadcast - Runtime.send_event(runtime, Event.resize(100, 50)) - Runtime.sync(runtime) - - # focus child_a - Runtime.send_event(runtime, Event.key(:tab)) - Runtime.sync(runtime) - - # child_a: 2 - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.child_a.count == 2 - assert state.root_state.child_b.count == 1 - assert state.root_state.broadcasts_received == 1 - end - end -end diff --git a/test/term_ui/integration/property_test.exs b/test/term_ui/integration/property_test.exs deleted file mode 100644 index 679fef2a..00000000 --- a/test/term_ui/integration/property_test.exs +++ /dev/null @@ -1,332 +0,0 @@ -defmodule TermUI.Integration.PropertyTest do - @moduledoc """ - Property-based tests for event sequences using StreamData. - - These tests generate arbitrary event sequences and verify that the Runtime - maintains invariants regardless of the specific events sent. - - ## Invariants Tested - - 1. **Runtime Stability** - Runtime never crashes from any event sequence - 2. **State Consistency** - State transformations are predictable - 3. **Focus Validity** - Focus is always valid in multi-component scenarios - 4. **Clean Shutdown** - No zombie processes after runtime stops - - ## Why Property-Based Testing? - - Traditional unit tests verify specific scenarios, but can miss edge cases. - Property-based tests generate random inputs to find unexpected failures. - """ - - use TermUI.RuntimeTestCase - use ExUnitProperties - - # Simple counter component for property testing - defmodule PropertyCounter do - @moduledoc """ - Counter component for property-based testing. - - Tracks both count (up/down) and total events received, enabling - property tests to verify event processing invariants with random - event sequences. - """ - - use TermUI.Elm - - @impl true - def init(_opts), do: %{count: 0, events_received: 0} - - @impl true - def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :increment} - def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :decrement} - def event_to_msg(%Event.Mouse{}, _state), do: {:msg, :mouse} - def event_to_msg(%Event.Resize{}, _state), do: {:msg, :resize} - def event_to_msg(_, _state), do: :ignore - - @impl true - def update(:increment, state) do - {%{state | count: state.count + 1, events_received: state.events_received + 1}, []} - end - - def update(:decrement, state) do - {%{state | count: state.count - 1, events_received: state.events_received + 1}, []} - end - - def update(:mouse, state) do - {%{state | events_received: state.events_received + 1}, []} - end - - def update(:resize, state) do - {%{state | events_received: state.events_received + 1}, []} - end - - @impl true - def view(state), do: {:text, "Count: #{state.count}"} - end - - # Multi-component for focus testing - defmodule PropertyMultiRoot do - @moduledoc """ - Multi-component root for property-based focus testing. - - Manages focus between two children with keyboard (Tab) and mouse - input. Used to verify focus invariants (always valid) and count - consistency across random event sequences. - """ - - use TermUI.Elm - - @impl true - def init(_opts) do - %{ - focused: :child_a, - child_a_count: 0, - child_b_count: 0 - } - end - - @impl true - def event_to_msg(%Event.Key{key: :tab}, _state), do: {:msg, :toggle_focus} - def event_to_msg(%Event.Key{key: :up}, state), do: {:msg, {:increment, state.focused}} - def event_to_msg(%Event.Mouse{x: x}, _state) when x < 40, do: {:msg, :focus_a} - def event_to_msg(%Event.Mouse{x: x}, _state) when x >= 40, do: {:msg, :focus_b} - def event_to_msg(_, _state), do: :ignore - - @impl true - def update(:toggle_focus, %{focused: :child_a} = state) do - {%{state | focused: :child_b}, []} - end - - def update(:toggle_focus, %{focused: :child_b} = state) do - {%{state | focused: :child_a}, []} - end - - def update({:increment, :child_a}, state) do - {%{state | child_a_count: state.child_a_count + 1}, []} - end - - def update({:increment, :child_b}, state) do - {%{state | child_b_count: state.child_b_count + 1}, []} - end - - def update(:focus_a, state), do: {%{state | focused: :child_a}, []} - def update(:focus_b, state), do: {%{state | focused: :child_b}, []} - - @impl true - def view(state) do - {:text, "Focus: #{state.focused}, A: #{state.child_a_count}, B: #{state.child_b_count}"} - end - end - - # Event Generators - - defp key_event do - gen all(key <- one_of([constant(:up), constant(:down), constant(:left), constant(:right)])) do - Event.key(key) - end - end - - defp mouse_event do - gen all( - x <- integer(0..79), - y <- integer(0..23), - action <- one_of([constant(:press), constant(:release)]), - button <- one_of([constant(:left), constant(:right)]) - ) do - Event.mouse(action, button, x, y) - end - end - - defp resize_event do - gen all( - width <- integer(40..200), - height <- integer(20..60) - ) do - Event.resize(width, height) - end - end - - defp any_event do - one_of([key_event(), mouse_event(), resize_event()]) - end - - defp event_sequence(max_length \\ 50) do - list_of(any_event(), max_length: max_length) - end - - # Property Tests - - describe "runtime stability properties" do - property "runtime survives any event sequence" do - check all(events <- event_sequence(30)) do - runtime = start_test_runtime(PropertyCounter) - - # Send all events - for event <- events do - Runtime.send_event(runtime, event) - end - - Runtime.sync(runtime) - - # Runtime should still be alive - assert Process.alive?(runtime) - - # Should be able to get state - state = Runtime.get_state(runtime) - assert is_map(state) - assert is_map(state.root_state) - end - end - - property "all events are processed" do - check all(events <- event_sequence(20)) do - runtime = start_test_runtime(PropertyCounter) - - # Send events - for event <- events do - Runtime.send_event(runtime, event) - end - - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - - # Count how many events should have been processed (not ignored) - processable_events = - Enum.count(events, fn - %Event.Key{key: key} when key in [:up, :down] -> true - %Event.Mouse{} -> true - %Event.Resize{} -> true - _ -> false - end) - - # All processable events should have been handled - assert state.root_state.events_received == processable_events - end - end - end - - describe "state consistency properties" do - property "counter reflects up/down balance" do - check all(events <- event_sequence(20)) do - runtime = start_test_runtime(PropertyCounter) - - for event <- events do - Runtime.send_event(runtime, event) - end - - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - - # Count ups and downs - ups = Enum.count(events, &match?(%Event.Key{key: :up}, &1)) - downs = Enum.count(events, &match?(%Event.Key{key: :down}, &1)) - - # Count should equal ups - downs - assert state.root_state.count == ups - downs - end - end - - property "state fields remain valid types" do - check all(events <- event_sequence(30)) do - runtime = start_test_runtime(PropertyCounter) - - for event <- events do - Runtime.send_event(runtime, event) - end - - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - - # Type invariants - assert is_integer(state.root_state.count) - assert is_integer(state.root_state.events_received) - assert state.root_state.events_received >= 0 - end - end - end - - describe "multi-component focus properties" do - property "focus is always valid" do - check all(events <- event_sequence(20)) do - runtime = start_test_runtime(PropertyMultiRoot) - - for event <- events do - Runtime.send_event(runtime, event) - end - - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - - # Focus must be one of the valid values - assert state.root_state.focused in [:child_a, :child_b] - end - end - - property "component counts are non-negative" do - check all(events <- event_sequence(25)) do - runtime = start_test_runtime(PropertyMultiRoot) - - for event <- events do - Runtime.send_event(runtime, event) - end - - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - - # Counts should never go negative (no decrement in this component) - assert state.root_state.child_a_count >= 0 - assert state.root_state.child_b_count >= 0 - end - end - - property "total increments match up key presses" do - check all(events <- event_sequence(20)) do - runtime = start_test_runtime(PropertyMultiRoot) - - for event <- events do - Runtime.send_event(runtime, event) - end - - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - - # Count total up keys - up_count = Enum.count(events, &match?(%Event.Key{key: :up}, &1)) - - # Total increments should match - total_increments = state.root_state.child_a_count + state.root_state.child_b_count - assert total_increments == up_count - end - end - end - - describe "cleanup properties" do - property "runtime shuts down cleanly after any event sequence" do - check all(events <- event_sequence(15)) do - {:ok, runtime} = Runtime.start_link(root: PropertyCounter, skip_terminal: true) - ref = Process.monitor(runtime) - - for event <- events do - Runtime.send_event(runtime, event) - end - - Runtime.sync(runtime) - - # Shutdown - Runtime.shutdown(runtime) - - # Should terminate - assert_receive {:DOWN, ^ref, :process, ^runtime, :normal}, 1000 - - # Should not be alive - refute Process.alive?(runtime) - end - end - end -end diff --git a/test/term_ui/integration/testing_framework_test.exs b/test/term_ui/integration/testing_framework_test.exs deleted file mode 100644 index eea36c4b..00000000 --- a/test/term_ui/integration/testing_framework_test.exs +++ /dev/null @@ -1,358 +0,0 @@ -defmodule TermUI.Integration.TestingFrameworkTest do - # async: true because test utilities are stateless and create isolated resources - use ExUnit.Case, async: true - use TermUI.Test.Assertions - - alias TermUI.Event - alias TermUI.Renderer.Cell - alias TermUI.Test.{ComponentHarness, EventSimulator, TestRenderer} - alias TermUI.Test.Components.{Counter, Label, TextInput} - - # Ensure modules are loaded for function_exported? checks in ComponentHarness - Code.ensure_loaded!(Counter) - Code.ensure_loaded!(TextInput) - Code.ensure_loaded!(Label) - - describe "test renderer accuracy" do - test "captures text content correctly" do - {:ok, renderer} = TestRenderer.new(10, 40) - - # Write multiple strings - TestRenderer.write_string(renderer, 1, 1, "Hello") - TestRenderer.write_string(renderer, 2, 1, "World") - TestRenderer.write_string(renderer, 3, 5, "Offset") - - # Verify content - assert TestRenderer.get_text_at(renderer, 1, 1, 5) == "Hello" - assert TestRenderer.get_text_at(renderer, 2, 1, 5) == "World" - assert TestRenderer.get_text_at(renderer, 3, 5, 6) == "Offset" - - # Verify row retrieval - row1 = TestRenderer.get_row_text(renderer, 1) - assert String.starts_with?(row1, "Hello") - - TestRenderer.destroy(renderer) - end - - test "captures styles correctly" do - {:ok, renderer} = TestRenderer.new(10, 40) - - # Set cells with different styles - TestRenderer.set_cell(renderer, 1, 1, Cell.new("R", fg: :red)) - TestRenderer.set_cell(renderer, 1, 2, Cell.new("G", fg: :green, attrs: [:bold])) - TestRenderer.set_cell(renderer, 1, 3, Cell.new("B", fg: :blue, bg: :white)) - - # Verify styles - style1 = TestRenderer.get_style_at(renderer, 1, 1) - assert style1.fg == :red - - style2 = TestRenderer.get_style_at(renderer, 1, 2) - assert style2.fg == :green - assert MapSet.member?(style2.attrs, :bold) - - style3 = TestRenderer.get_style_at(renderer, 1, 3) - assert style3.fg == :blue - assert style3.bg == :white - - TestRenderer.destroy(renderer) - end - - test "snapshot comparison detects changes" do - {:ok, renderer} = TestRenderer.new(5, 20) - TestRenderer.write_string(renderer, 1, 1, "Initial") - - # Take snapshot - snapshot = TestRenderer.snapshot(renderer) - - # Verify match - assert TestRenderer.matches_snapshot?(renderer, snapshot) - - # Modify buffer - TestRenderer.write_string(renderer, 1, 1, "Changed") - - # Should not match - refute TestRenderer.matches_snapshot?(renderer, snapshot) - - # Get diffs - diffs = TestRenderer.diff_snapshot(renderer, snapshot) - assert length(diffs) > 0 - - TestRenderer.destroy(renderer) - end - - test "finds text in buffer" do - {:ok, renderer} = TestRenderer.new(10, 40) - - TestRenderer.write_string(renderer, 3, 10, "Error: Something went wrong") - TestRenderer.write_string(renderer, 7, 5, "Another Error here") - - # Find all occurrences - positions = TestRenderer.find_text(renderer, "Error") - assert length(positions) == 2 - - # Verify positions - assert {3, 10} in positions - assert {7, 13} in positions - - TestRenderer.destroy(renderer) - end - end - - describe "event simulation produces expected changes" do - test "key events have correct structure" do - event = EventSimulator.simulate_key(:enter) - assert %Event.Key{} = event - assert event.key == :enter - assert event.modifiers == [] - - event = EventSimulator.simulate_key(:c, modifiers: [:ctrl]) - assert :ctrl in event.modifiers - - event = EventSimulator.simulate_key(:a, char: "a") - assert event.char == "a" - end - - test "mouse events have correct coordinates" do - event = EventSimulator.simulate_click(15, 20) - assert %Event.Mouse{} = event - assert event.x == 15 - assert event.y == 20 - assert event.action == :click - assert event.button == :left - - event = EventSimulator.simulate_click(10, 5, :right) - assert event.button == :right - end - - test "type simulation creates character sequence" do - events = EventSimulator.simulate_type("Hello") - assert length(events) == 5 - - # Verify characters - chars = Enum.map(events, & &1.char) - assert chars == ["H", "e", "l", "l", "o"] - - # Capital H should have shift - first = hd(events) - assert :shift in first.modifiers - end - - test "shortcuts create correct key combinations" do - # Copy - event = EventSimulator.simulate_shortcut(:copy) - assert event.key == :c - assert :ctrl in event.modifiers - - # Undo - event = EventSimulator.simulate_shortcut(:undo) - assert event.key == :z - assert :ctrl in event.modifiers - - # Redo - event = EventSimulator.simulate_shortcut(:redo) - assert event.key == :z - assert :ctrl in event.modifiers - assert :shift in event.modifiers - end - - test "event sequence maintains order" do - events = EventSimulator.simulate_sequence([:tab, :down, :down, :enter]) - assert length(events) == 4 - - keys = Enum.map(events, & &1.key) - assert keys == [:tab, :down, :down, :enter] - end - end - - describe "assertions detect conditions" do - test "text assertions work correctly" do - {:ok, renderer} = TestRenderer.new(10, 40) - TestRenderer.write_string(renderer, 1, 1, "Success") - - # Should pass - assert_text(renderer, 1, 1, "Success") - assert_text_contains(renderer, 1, 1, 10, "cess") - assert_text_exists(renderer, "Success") - - # Negative assertions should pass - refute_text(renderer, 1, 1, "Failure") - refute_text_exists(renderer, "Failure") - - TestRenderer.destroy(renderer) - end - - test "style assertions work correctly" do - {:ok, renderer} = TestRenderer.new(10, 40) - cell = Cell.new("X", fg: :red, bg: :blue, attrs: [:bold]) - TestRenderer.set_cell(renderer, 1, 1, cell) - - # Should pass - assert_style(renderer, 1, 1, fg: :red) - assert_style(renderer, 1, 1, bg: :blue) - assert_style(renderer, 1, 1, attrs: [:bold]) - assert_attr(renderer, 1, 1, :bold) - - # Negative assertions - refute_attr(renderer, 1, 1, :italic) - - TestRenderer.destroy(renderer) - end - - test "state assertions work correctly" do - state = %{ - user: %{ - name: "Alice", - age: 30 - }, - items: [1, 2, 3] - } - - # Should pass - assert_state(state, [:user, :name], "Alice") - assert_state(state, [:user, :age], 30) - assert_state(state, [:items], [1, 2, 3]) - assert_state_exists(state, [:user]) - - # Negative assertions - refute_state(state, [:user, :name], "Bob") - end - - test "snapshot assertions work correctly" do - {:ok, renderer} = TestRenderer.new(5, 20) - TestRenderer.write_string(renderer, 1, 1, "Test") - snapshot = TestRenderer.snapshot(renderer) - - # Should pass - buffer unchanged - assert_snapshot(renderer, snapshot) - - TestRenderer.destroy(renderer) - end - - test "assertions produce clear error messages" do - {:ok, renderer} = TestRenderer.new(5, 20) - TestRenderer.write_string(renderer, 1, 1, "Actual") - - # Verify assertion raises with useful message - assert_raise ExUnit.AssertionError, ~r/Text assertion failed/, fn -> - assert_text(renderer, 1, 1, "Expected") - end - - TestRenderer.destroy(renderer) - end - end - - describe "component harness isolates components" do - test "mounts component with initial state" do - {:ok, harness} = ComponentHarness.mount_test(Counter, initial: 5) - - assert harness.module == Counter - assert ComponentHarness.get_state(harness) == %{count: 5} - - ComponentHarness.unmount(harness) - end - - test "renders component to buffer" do - {:ok, harness} = ComponentHarness.mount_test(Counter, initial: 10) - harness = ComponentHarness.render(harness) - - renderer = ComponentHarness.get_renderer(harness) - assert TestRenderer.text_at?(renderer, 1, 1, "Count: 10") - - ComponentHarness.unmount(harness) - end - - test "processes events and updates state" do - {:ok, harness} = ComponentHarness.mount_test(Counter, initial: 0) - - # Send events - harness = ComponentHarness.send_event(harness, Event.key(:up)) - assert ComponentHarness.get_state(harness) == %{count: 1} - - harness = ComponentHarness.send_event(harness, Event.key(:up)) - assert ComponentHarness.get_state(harness) == %{count: 2} - - harness = ComponentHarness.send_event(harness, Event.key(:down)) - assert ComponentHarness.get_state(harness) == %{count: 1} - - ComponentHarness.unmount(harness) - end - - test "tracks event history" do - {:ok, harness} = ComponentHarness.mount_test(Counter) - - harness = ComponentHarness.send_event(harness, Event.key(:up)) - harness = ComponentHarness.send_event(harness, Event.key(:down)) - - events = ComponentHarness.get_events(harness) - assert length(events) == 2 - - ComponentHarness.unmount(harness) - end - - test "resets to initial state" do - {:ok, harness} = ComponentHarness.mount_test(Counter, initial: 0) - - # Modify state - harness = ComponentHarness.send_event(harness, Event.key(:up)) - harness = ComponentHarness.render(harness) - assert ComponentHarness.get_state(harness) == %{count: 1} - - # Reset - {:ok, harness} = ComponentHarness.reset(harness) - assert ComponentHarness.get_state(harness) == %{count: 0} - assert ComponentHarness.get_events(harness) == [] - assert ComponentHarness.get_renders(harness) == [] - - ComponentHarness.unmount(harness) - end - - test "complete test workflow" do - # This demonstrates a typical test workflow - {:ok, harness} = ComponentHarness.mount_test(Counter, initial: 0) - - # Initial render - harness = ComponentHarness.render(harness) - renderer = ComponentHarness.get_renderer(harness) - assert_text(renderer, 1, 1, "Count: 0") - - # Simulate user interaction - harness = ComponentHarness.event_cycle(harness, Event.key(:up)) - harness = ComponentHarness.event_cycle(harness, Event.key(:up)) - harness = ComponentHarness.event_cycle(harness, Event.key(:up)) - - # Verify final state - assert ComponentHarness.get_state(harness) == %{count: 3} - assert_text(renderer, 1, 1, "Count: 3") - - ComponentHarness.unmount(harness) - end - end - - describe "integration between test utilities" do - test "event simulator works with component harness" do - {:ok, harness} = ComponentHarness.mount_test(TextInput) - - # Use event simulator to type text - events = EventSimulator.simulate_type("hello") - harness = ComponentHarness.send_events(harness, events) - - assert ComponentHarness.get_state(harness).text == "hello" - - ComponentHarness.unmount(harness) - end - - test "assertions work with harness renderer" do - {:ok, harness} = ComponentHarness.mount_test(Label, text: "Important") - harness = ComponentHarness.render(harness) - - renderer = ComponentHarness.get_renderer(harness) - - # Use assertions - assert_text(renderer, 1, 1, "Important") - assert_text_exists(renderer, "Important") - refute_text_exists(renderer, "Missing") - - ComponentHarness.unmount(harness) - end - end -end diff --git a/test/term_ui/layout/alignment_test.exs b/test/term_ui/layout/alignment_test.exs deleted file mode 100644 index 16adcbab..00000000 --- a/test/term_ui/layout/alignment_test.exs +++ /dev/null @@ -1,422 +0,0 @@ -defmodule TermUI.Layout.AlignmentTest do - use ExUnit.Case, async: true - - alias TermUI.Layout.Alignment - - # Helper to create test rects - defp make_rects(sizes, direction \\ :horizontal) do - {rects, _pos} = - Enum.map_reduce(sizes, 0, fn size, pos -> - rect = - case direction do - :horizontal -> %{x: pos, y: 0, width: size, height: 10} - :vertical -> %{x: 0, y: pos, width: 10, height: size} - end - - {rect, pos + size} - end) - - rects - end - - describe "apply/3 - justify :start" do - test "positions at beginning (default)" do - rects = make_rects([20, 30]) - area = %{x: 0, y: 0, width: 100, height: 20} - - result = Alignment.apply(rects, area) - - assert [ - %{x: 0, width: 20}, - %{x: 20, width: 30} - ] = result - end - - test "positions at area offset" do - rects = make_rects([20, 30]) - area = %{x: 10, y: 5, width: 100, height: 20} - - result = Alignment.apply(rects, area, justify: :start) - - assert [ - %{x: 10, width: 20}, - %{x: 30, width: 30} - ] = result - end - end - - describe "apply/3 - justify :center" do - test "centers components in available space" do - rects = make_rects([20, 30]) - area = %{x: 0, y: 0, width: 100, height: 20} - - result = Alignment.apply(rects, area, justify: :center) - - # Total content = 50, space = 50, offset = 25 - assert [ - %{x: 25, width: 20}, - %{x: 45, width: 30} - ] = result - end - - test "centers with area offset" do - rects = make_rects([20]) - area = %{x: 10, y: 0, width: 100, height: 20} - - result = Alignment.apply(rects, area, justify: :center) - - # Content = 20, space = 80, offset = 40 - assert [%{x: 50, width: 20}] = result - end - end - - describe "apply/3 - justify :end" do - test "positions at end of available space" do - rects = make_rects([20, 30]) - area = %{x: 0, y: 0, width: 100, height: 20} - - result = Alignment.apply(rects, area, justify: :end) - - # Total content = 50, offset = 50 - assert [ - %{x: 50, width: 20}, - %{x: 70, width: 30} - ] = result - end - - test "positions at end with area offset" do - rects = make_rects([20]) - area = %{x: 10, y: 0, width: 100, height: 20} - - result = Alignment.apply(rects, area, justify: :end) - - # Content = 20, offset = 80 - assert [%{x: 90, width: 20}] = result - end - end - - describe "apply/3 - justify :space_between" do - test "distributes space between components" do - rects = make_rects([20, 20, 20]) - area = %{x: 0, y: 0, width: 100, height: 20} - - result = Alignment.apply(rects, area, justify: :space_between) - - # Total content = 60, space = 40, between = 20 - assert [ - %{x: 0, width: 20}, - %{x: 40, width: 20}, - %{x: 80, width: 20} - ] = result - end - - test "handles single component" do - rects = make_rects([20]) - area = %{x: 0, y: 0, width: 100, height: 20} - - result = Alignment.apply(rects, area, justify: :space_between) - - # Single component stays at start - assert [%{x: 0, width: 20}] = result - end - - test "handles two components" do - rects = make_rects([20, 20]) - area = %{x: 0, y: 0, width: 100, height: 20} - - result = Alignment.apply(rects, area, justify: :space_between) - - # Space = 60, between = 60 - assert [ - %{x: 0, width: 20}, - %{x: 80, width: 20} - ] = result - end - end - - describe "apply/3 - justify :space_around" do - test "distributes space around components" do - rects = make_rects([20, 20]) - area = %{x: 0, y: 0, width: 100, height: 20} - - result = Alignment.apply(rects, area, justify: :space_around) - - # Total content = 40, space = 60, unit = 15 - # First at 15, second at 15 + 20 + 30 = 65 - assert [ - %{x: 15, width: 20}, - %{x: 65, width: 20} - ] = result - end - - test "handles empty list" do - result = Alignment.apply([], %{x: 0, y: 0, width: 100, height: 20}, justify: :space_around) - assert [] = result - end - end - - describe "apply/3 - align :start" do - test "positions at cross-axis start (default)" do - rects = [%{x: 0, y: 0, width: 20, height: 10}] - area = %{x: 0, y: 0, width: 100, height: 50} - - result = Alignment.apply(rects, area, align: :start) - - assert [%{y: 0, height: 10}] = result - end - end - - describe "apply/3 - align :center" do - test "centers on cross-axis" do - rects = [%{x: 0, y: 0, width: 20, height: 10}] - area = %{x: 0, y: 0, width: 100, height: 50} - - result = Alignment.apply(rects, area, align: :center) - - # Height = 10, space = 40, offset = 20 - assert [%{y: 20, height: 10}] = result - end - - test "centers multiple components" do - rects = [ - %{x: 0, y: 0, width: 20, height: 10}, - %{x: 20, y: 0, width: 30, height: 20} - ] - - area = %{x: 0, y: 0, width: 100, height: 50} - - result = Alignment.apply(rects, area, align: :center) - - # Each centered independently - assert [ - # (50 - 10) / 2 = 20 - %{y: 20, height: 10}, - # (50 - 20) / 2 = 15 - %{y: 15, height: 20} - ] = result - end - end - - describe "apply/3 - align :end" do - test "positions at cross-axis end" do - rects = [%{x: 0, y: 0, width: 20, height: 10}] - area = %{x: 0, y: 0, width: 100, height: 50} - - result = Alignment.apply(rects, area, align: :end) - - # Height = 10, offset = 40 - assert [%{y: 40, height: 10}] = result - end - end - - describe "apply/3 - align :stretch" do - test "expands to fill cross-axis" do - rects = [%{x: 0, y: 0, width: 20, height: 10}] - area = %{x: 0, y: 0, width: 100, height: 50} - - result = Alignment.apply(rects, area, align: :stretch) - - assert [%{y: 0, height: 50}] = result - end - - test "stretches multiple components" do - rects = [ - %{x: 0, y: 0, width: 20, height: 10}, - %{x: 20, y: 0, width: 30, height: 20} - ] - - area = %{x: 0, y: 0, width: 100, height: 50} - - result = Alignment.apply(rects, area, align: :stretch) - - assert [ - %{y: 0, height: 50}, - %{y: 0, height: 50} - ] = result - end - end - - describe "apply/3 - align_self" do - test "overrides container alignment per component" do - rects = [ - %{x: 0, y: 0, width: 20, height: 10}, - %{x: 20, y: 0, width: 30, height: 10}, - %{x: 50, y: 0, width: 20, height: 10} - ] - - area = %{x: 0, y: 0, width: 100, height: 50} - - result = - Alignment.apply(rects, area, - align: :start, - align_self: [:center, nil, :end] - ) - - assert [ - # center - %{y: 20, height: 10}, - # start (nil = use container) - %{y: 0, height: 10}, - # end - %{y: 40, height: 10} - ] = result - end - end - - describe "apply/3 - vertical direction" do - test "justify centers vertically" do - rects = [ - %{x: 0, y: 0, width: 10, height: 20}, - %{x: 0, y: 20, width: 10, height: 30} - ] - - area = %{x: 0, y: 0, width: 50, height: 100} - - result = Alignment.apply(rects, area, direction: :vertical, justify: :center) - - # Total content = 50, space = 50, offset = 25 - assert [ - %{y: 25, height: 20}, - %{y: 45, height: 30} - ] = result - end - - test "align centers horizontally in vertical layout" do - rects = [%{x: 0, y: 0, width: 10, height: 20}] - area = %{x: 0, y: 0, width: 50, height: 100} - - result = Alignment.apply(rects, area, direction: :vertical, align: :center) - - # Width = 10, space = 40, offset = 20 - assert [%{x: 20, width: 10}] = result - end - - test "stretch expands width in vertical layout" do - rects = [%{x: 0, y: 0, width: 10, height: 20}] - area = %{x: 0, y: 0, width: 50, height: 100} - - result = Alignment.apply(rects, area, direction: :vertical, align: :stretch) - - assert [%{x: 0, width: 50}] = result - end - end - - describe "apply_margins/2" do - test "applies uniform margin to all rects" do - rects = [ - %{x: 0, y: 0, width: 100, height: 50}, - %{x: 100, y: 0, width: 100, height: 50} - ] - - margin = %{top: 5, right: 5, bottom: 5, left: 5} - - result = Alignment.apply_margins(rects, margin) - - assert [ - %{x: 5, y: 5, width: 90, height: 40}, - %{x: 105, y: 5, width: 90, height: 40} - ] = result - end - - test "applies per-rect margins" do - rects = [ - %{x: 0, y: 0, width: 100, height: 50}, - %{x: 100, y: 0, width: 100, height: 50} - ] - - margins = [ - %{top: 5, right: 5, bottom: 5, left: 5}, - %{top: 10, right: 10, bottom: 10, left: 10} - ] - - result = Alignment.apply_margins(rects, margins) - - assert [ - %{x: 5, y: 5, width: 90, height: 40}, - %{x: 110, y: 10, width: 80, height: 30} - ] = result - end - - test "handles zero margin" do - rects = [%{x: 0, y: 0, width: 100, height: 50}] - margin = %{top: 0, right: 0, bottom: 0, left: 0} - - result = Alignment.apply_margins(rects, margin) - - assert [%{x: 0, y: 0, width: 100, height: 50}] = result - end - end - - describe "apply_padding/2" do - test "reduces content area" do - rect = %{x: 10, y: 20, width: 100, height: 50} - padding = %{top: 5, right: 10, bottom: 5, left: 10} - - result = Alignment.apply_padding(rect, padding) - - assert %{x: 20, y: 25, width: 80, height: 40} = result - end - - test "handles zero padding" do - rect = %{x: 10, y: 20, width: 100, height: 50} - padding = %{top: 0, right: 0, bottom: 0, left: 0} - - result = Alignment.apply_padding(rect, padding) - - assert %{x: 10, y: 20, width: 100, height: 50} = result - end - - test "clamps to zero for excessive padding" do - rect = %{x: 0, y: 0, width: 20, height: 20} - padding = %{top: 15, right: 15, bottom: 15, left: 15} - - result = Alignment.apply_padding(rect, padding) - - assert result.width == 0 - assert result.height == 0 - end - end - - describe "parse_spacing/1" do - test "parses single value" do - result = Alignment.parse_spacing(10) - assert %{top: 10, right: 10, bottom: 10, left: 10} = result - end - - test "parses vertical/horizontal tuple" do - result = Alignment.parse_spacing({5, 10}) - assert %{top: 5, right: 10, bottom: 5, left: 10} = result - end - - test "parses four-value tuple" do - result = Alignment.parse_spacing({1, 2, 3, 4}) - assert %{top: 1, right: 2, bottom: 3, left: 4} = result - end - - test "parses map with defaults" do - result = Alignment.parse_spacing(%{top: 5, left: 10}) - assert %{top: 5, right: 0, bottom: 0, left: 10} = result - end - end - - describe "combined justify and align" do - test "applies both simultaneously" do - rects = [%{x: 0, y: 0, width: 20, height: 10}] - area = %{x: 0, y: 0, width: 100, height: 50} - - result = Alignment.apply(rects, area, justify: :center, align: :center) - - # Centered both ways - assert [%{x: 40, y: 20, width: 20, height: 10}] = result - end - - test "end/end positions in bottom-right" do - rects = [%{x: 0, y: 0, width: 20, height: 10}] - area = %{x: 0, y: 0, width: 100, height: 50} - - result = Alignment.apply(rects, area, justify: :end, align: :end) - - assert [%{x: 80, y: 40, width: 20, height: 10}] = result - end - end -end diff --git a/test/term_ui/layout/cache_test.exs b/test/term_ui/layout/cache_test.exs deleted file mode 100644 index d6f12d70..00000000 --- a/test/term_ui/layout/cache_test.exs +++ /dev/null @@ -1,368 +0,0 @@ -defmodule TermUI.Layout.CacheTest do - use ExUnit.Case - - alias TermUI.Layout.{Cache, Constraint} - - setup do - # Start cache for each test with small size for testing eviction - {:ok, pid} = Cache.start_link(max_size: 10, eviction_count: 3, name: :test_cache) - on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end) - :ok - end - - describe "solve/3 - basic caching" do - test "returns correct result" do - constraints = [Constraint.length(20), Constraint.fill()] - area = %{x: 0, y: 0, width: 100, height: 10} - - result = Cache.solve(constraints, area) - - assert [ - %{x: 0, y: 0, width: 20, height: 10}, - %{x: 20, y: 0, width: 80, height: 10} - ] = result - end - - test "caches and returns same result" do - constraints = [Constraint.length(30), Constraint.fill()] - area = %{x: 0, y: 0, width: 100, height: 10} - - result1 = Cache.solve(constraints, area) - result2 = Cache.solve(constraints, area) - - assert result1 == result2 - end - - test "records hit on second call" do - constraints = [Constraint.length(20), Constraint.fill()] - area = %{x: 0, y: 0, width: 100, height: 10} - - Cache.reset_stats() - - Cache.solve(constraints, area) - stats1 = Cache.stats() - assert stats1.misses == 1 - assert stats1.hits == 0 - - Cache.solve(constraints, area) - stats2 = Cache.stats() - assert stats2.misses == 1 - assert stats2.hits == 1 - end - - test "different dimensions are different cache entries" do - constraints = [Constraint.fill()] - area1 = %{x: 0, y: 0, width: 100, height: 10} - area2 = %{x: 0, y: 0, width: 200, height: 10} - - Cache.reset_stats() - - Cache.solve(constraints, area1) - Cache.solve(constraints, area2) - - stats = Cache.stats() - assert stats.misses == 2 - assert stats.hits == 0 - assert stats.size == 2 - end - - test "different constraints are different cache entries" do - area = %{x: 0, y: 0, width: 100, height: 10} - constraints1 = [Constraint.length(20), Constraint.fill()] - constraints2 = [Constraint.length(30), Constraint.fill()] - - Cache.reset_stats() - - Cache.solve(constraints1, area) - Cache.solve(constraints2, area) - - stats = Cache.stats() - assert stats.misses == 2 - assert stats.size == 2 - end - end - - describe "solve_uncached/3" do - test "returns result without caching" do - constraints = [Constraint.length(20), Constraint.fill()] - area = %{x: 0, y: 0, width: 100, height: 10} - - Cache.reset_stats() - - result = Cache.solve_uncached(constraints, area) - - assert [ - %{x: 0, y: 0, width: 20, height: 10}, - %{x: 20, y: 0, width: 80, height: 10} - ] = result - - stats = Cache.stats() - assert stats.size == 0 - assert stats.hits == 0 - assert stats.misses == 0 - end - end - - describe "lookup/1 and insert/2" do - test "lookup returns miss for unknown key" do - key = {:erlang.phash2([]), 100, 10} - assert :miss = Cache.lookup(key) - end - - test "insert and lookup returns result" do - key = {:erlang.phash2([Constraint.fill()]), 100, 10} - result = [%{x: 0, y: 0, width: 100, height: 10}] - - Cache.insert(key, result) - assert {:ok, ^result} = Cache.lookup(key) - end - - test "lookup updates access time" do - constraints = [Constraint.fill()] - area = %{x: 0, y: 0, width: 100, height: 10} - - # First solve - Cache.solve(constraints, area) - - # Wait a bit - Process.sleep(10) - - # Second lookup should update access time - Cache.solve(constraints, area) - - # Entry should still be there and be "recent" - key = {:erlang.phash2(constraints), 100, 10} - {:ok, _result} = Cache.lookup(key) - end - end - - describe "invalidate/1" do - test "removes specific entry" do - constraints = [Constraint.fill()] - area = %{x: 0, y: 0, width: 100, height: 10} - - Cache.solve(constraints, area) - assert Cache.size() == 1 - - key = {:erlang.phash2(constraints), 100, 10} - Cache.invalidate(key) - - assert Cache.size() == 0 - end - end - - describe "invalidate_constraints/1" do - test "removes all entries for constraint set" do - constraints = [Constraint.fill()] - area1 = %{x: 0, y: 0, width: 100, height: 10} - area2 = %{x: 0, y: 0, width: 200, height: 20} - - Cache.solve(constraints, area1) - Cache.solve(constraints, area2) - assert Cache.size() == 2 - - Cache.invalidate_constraints(constraints) - assert Cache.size() == 0 - end - - test "does not remove entries for other constraints" do - constraints1 = [Constraint.fill()] - constraints2 = [Constraint.length(50)] - area = %{x: 0, y: 0, width: 100, height: 10} - - Cache.solve(constraints1, area) - Cache.solve(constraints2, area) - assert Cache.size() == 2 - - Cache.invalidate_constraints(constraints1) - assert Cache.size() == 1 - end - end - - describe "clear/0" do - test "removes all entries" do - area = %{x: 0, y: 0, width: 100, height: 10} - - for i <- 1..5 do - Cache.solve([Constraint.length(i), Constraint.fill()], area) - end - - assert Cache.size() == 5 - - Cache.clear() - assert Cache.size() == 0 - end - end - - describe "stats/0" do - test "returns accurate statistics" do - Cache.reset_stats() - - constraints = [Constraint.fill()] - area = %{x: 0, y: 0, width: 100, height: 10} - - # 1 miss - Cache.solve(constraints, area) - # 3 hits - Cache.solve(constraints, area) - Cache.solve(constraints, area) - Cache.solve(constraints, area) - - stats = Cache.stats() - assert stats.size == 1 - assert stats.hits == 3 - assert stats.misses == 1 - assert stats.hit_rate == 0.75 - end - - test "hit_rate is 0.0 when no requests" do - Cache.reset_stats() - stats = Cache.stats() - assert stats.hit_rate == 0.0 - end - end - - describe "reset_stats/0" do - test "resets hit and miss counters" do - constraints = [Constraint.fill()] - area = %{x: 0, y: 0, width: 100, height: 10} - - Cache.solve(constraints, area) - Cache.solve(constraints, area) - - stats1 = Cache.stats() - assert stats1.hits > 0 or stats1.misses > 0 - - Cache.reset_stats() - - stats2 = Cache.stats() - assert stats2.hits == 0 - assert stats2.misses == 0 - end - end - - describe "LRU eviction" do - test "evicts oldest entries when over limit" do - area = %{x: 0, y: 0, width: 100, height: 10} - - # Fill cache beyond max_size (10) - for i <- 1..15 do - Cache.solve([Constraint.length(i), Constraint.fill()], area) - # Small delay to ensure different access times - Process.sleep(1) - end - - # Force synchronous eviction multiple times to get below max - Cache.evict_now(:test_cache) - Cache.evict_now(:test_cache) - - # Should be at or below max_size after multiple evictions - assert Cache.size() <= 10 - end - - test "keeps recently accessed entries" do - area = %{x: 0, y: 0, width: 100, height: 10} - - # Add initial entries - for i <- 1..8 do - Cache.solve([Constraint.length(i), Constraint.fill()], area) - Process.sleep(1) - end - - # Access first entry to make it recent - Cache.solve([Constraint.length(1), Constraint.fill()], area) - Process.sleep(1) - - # Add more entries to trigger eviction - for i <- 9..15 do - Cache.solve([Constraint.length(i), Constraint.fill()], area) - Process.sleep(1) - end - - # Force synchronous eviction - Cache.evict_now(:test_cache) - - # First entry should still be there (was accessed recently) - key = {:erlang.phash2([Constraint.length(1), Constraint.fill()]), 100, 10} - assert {:ok, _} = Cache.lookup(key) - end - end - - describe "warm/1" do - test "pre-populates cache" do - Cache.reset_stats() - - layouts = [ - {[Constraint.length(20), Constraint.fill()], %{x: 0, y: 0, width: 100, height: 10}, []}, - {[Constraint.length(30), Constraint.fill()], %{x: 0, y: 0, width: 100, height: 10}, []}, - {[Constraint.fill()], %{x: 0, y: 0, width: 200, height: 20}, []} - ] - - Cache.warm(layouts) - - # Cache should be populated - assert Cache.size() == 3 - - # Stats should be reset after warming - stats = Cache.stats() - assert stats.hits == 0 - assert stats.misses == 0 - end - - test "subsequent calls are hits" do - layouts = [ - {[Constraint.fill()], %{x: 0, y: 0, width: 100, height: 10}, []} - ] - - Cache.warm(layouts) - - # Now access should be a hit - Cache.solve([Constraint.fill()], %{x: 0, y: 0, width: 100, height: 10}) - - stats = Cache.stats() - assert stats.hits == 1 - assert stats.misses == 0 - end - end - - describe "size/0" do - test "returns current entry count" do - assert Cache.size() == 0 - - area = %{x: 0, y: 0, width: 100, height: 10} - Cache.solve([Constraint.fill()], area) - - assert Cache.size() == 1 - - Cache.solve([Constraint.length(20)], area) - - assert Cache.size() == 2 - end - end - - describe "solver options" do - test "caches with gap option" do - constraints = [Constraint.length(20), Constraint.length(20)] - area = %{x: 0, y: 0, width: 100, height: 10} - - result = Cache.solve(constraints, area, gap: 10) - - assert [ - %{x: 0, y: 0, width: 20, height: 10}, - %{x: 30, y: 0, width: 20, height: 10} - ] = result - end - - test "caches vertical layouts" do - constraints = [Constraint.length(5), Constraint.fill()] - area = %{x: 0, y: 0, width: 50, height: 20} - - result = Cache.solve(constraints, area, direction: :vertical) - - assert [ - %{x: 0, y: 0, width: 50, height: 5}, - %{x: 0, y: 5, width: 50, height: 15} - ] = result - end - end -end diff --git a/test/term_ui/layout/constraint_test.exs b/test/term_ui/layout/constraint_test.exs deleted file mode 100644 index f333cc0e..00000000 --- a/test/term_ui/layout/constraint_test.exs +++ /dev/null @@ -1,522 +0,0 @@ -defmodule TermUI.Layout.ConstraintTest do - use ExUnit.Case, async: true - - alias TermUI.Layout.Constraint - alias TermUI.Layout.Constraint.Fill - alias TermUI.Layout.Constraint.Length - alias TermUI.Layout.Constraint.Max - alias TermUI.Layout.Constraint.Min - alias TermUI.Layout.Constraint.Percentage - alias TermUI.Layout.Constraint.Ratio - - describe "length/1" do - test "creates length constraint with exact size" do - constraint = Constraint.length(20) - assert %Length{value: 20} = constraint - end - - test "accepts zero" do - constraint = Constraint.length(0) - assert %Length{value: 0} = constraint - end - - test "raises for negative values" do - assert_raise ArgumentError, ~r/must be non-negative/, fn -> - Constraint.length(-1) - end - end - - test "raises for non-integer values" do - assert_raise ArgumentError, ~r/must be a non-negative integer/, fn -> - Constraint.length(20.5) - end - end - end - - describe "percentage/1" do - test "creates percentage constraint" do - constraint = Constraint.percentage(50) - assert %Percentage{value: 50} = constraint - end - - test "accepts float values" do - constraint = Constraint.percentage(33.33) - assert %Percentage{value: 33.33} = constraint - end - - test "accepts zero" do - constraint = Constraint.percentage(0) - assert %Percentage{value: 0} = constraint - end - - test "accepts 100" do - constraint = Constraint.percentage(100) - assert %Percentage{value: 100} = constraint - end - - test "raises for values over 100" do - assert_raise ArgumentError, ~r/must be between 0 and 100/, fn -> - Constraint.percentage(101) - end - end - - test "raises for negative values" do - assert_raise ArgumentError, ~r/must be between 0 and 100/, fn -> - Constraint.percentage(-1) - end - end - - test "raises for non-numeric values" do - assert_raise ArgumentError, ~r/must be a number/, fn -> - Constraint.percentage("50") - end - end - end - - describe "ratio/1" do - test "creates ratio constraint" do - constraint = Constraint.ratio(2) - assert %Ratio{value: 2} = constraint - end - - test "accepts float values" do - constraint = Constraint.ratio(1.5) - assert %Ratio{value: 1.5} = constraint - end - - test "raises for zero" do - assert_raise ArgumentError, ~r/must be positive/, fn -> - Constraint.ratio(0) - end - end - - test "raises for negative values" do - assert_raise ArgumentError, ~r/must be positive/, fn -> - Constraint.ratio(-1) - end - end - - test "raises for non-numeric values" do - assert_raise ArgumentError, ~r/must be a positive number/, fn -> - Constraint.ratio("1") - end - end - end - - describe "min/1" do - test "creates min constraint with fill default" do - constraint = Constraint.min(10) - assert %Min{value: 10, constraint: %Fill{}} = constraint - end - - test "accepts zero" do - constraint = Constraint.min(0) - assert %Min{value: 0, constraint: %Fill{}} = constraint - end - - test "raises for negative values" do - assert_raise ArgumentError, ~r/must be non-negative/, fn -> - Constraint.min(-1) - end - end - - test "raises for non-integer values" do - assert_raise ArgumentError, ~r/must be a non-negative integer/, fn -> - Constraint.min(10.5) - end - end - end - - describe "max/1" do - test "creates max constraint with fill default" do - constraint = Constraint.max(100) - assert %Max{value: 100, constraint: %Fill{}} = constraint - end - - test "accepts zero" do - constraint = Constraint.max(0) - assert %Max{value: 0, constraint: %Fill{}} = constraint - end - - test "raises for negative values" do - assert_raise ArgumentError, ~r/must be non-negative/, fn -> - Constraint.max(-1) - end - end - - test "raises for non-integer values" do - assert_raise ArgumentError, ~r/must be a non-negative integer/, fn -> - Constraint.max(100.5) - end - end - end - - describe "min_max/2" do - test "creates combined min/max constraint" do - constraint = Constraint.min_max(10, 100) - assert %Min{value: 10, constraint: %Max{value: 100, constraint: %Fill{}}} = constraint - end - - test "accepts equal min and max" do - constraint = Constraint.min_max(50, 50) - assert %Min{value: 50, constraint: %Max{value: 50, constraint: %Fill{}}} = constraint - end - - test "raises when min > max" do - assert_raise ArgumentError, ~r/cannot be greater than max/, fn -> - Constraint.min_max(100, 10) - end - end - - test "raises for invalid values" do - assert_raise ArgumentError, ~r/requires non-negative integers/, fn -> - Constraint.min_max(-1, 100) - end - end - end - - describe "fill/0" do - test "creates fill constraint" do - constraint = Constraint.fill() - assert %Fill{} = constraint - end - end - - describe "with_min/2" do - test "adds min bound to constraint" do - constraint = Constraint.percentage(50) |> Constraint.with_min(10) - assert %Min{value: 10, constraint: %Percentage{value: 50}} = constraint - end - - test "can be chained" do - constraint = - Constraint.percentage(50) - |> Constraint.with_min(10) - |> Constraint.with_max(100) - - assert %Max{value: 100, constraint: %Min{value: 10, constraint: %Percentage{value: 50}}} = - constraint - end - - test "raises for non-integer values" do - assert_raise ArgumentError, ~r/requires non-negative integer/, fn -> - Constraint.percentage(50) |> Constraint.with_min(10.5) - end - end - end - - describe "with_max/2" do - test "adds max bound to constraint" do - constraint = Constraint.percentage(50) |> Constraint.with_max(100) - assert %Max{value: 100, constraint: %Percentage{value: 50}} = constraint - end - - test "raises for non-integer values" do - assert_raise ArgumentError, ~r/requires non-negative integer/, fn -> - Constraint.percentage(50) |> Constraint.with_max(100.5) - end - end - end - - describe "resolve/3 - length" do - test "returns exact requested size" do - constraint = Constraint.length(20) - assert 20 = Constraint.resolve(constraint, 100) - end - - test "truncates when exceeding available space" do - constraint = Constraint.length(150) - assert 100 = Constraint.resolve(constraint, 100) - end - - test "returns zero for zero length" do - constraint = Constraint.length(0) - assert 0 = Constraint.resolve(constraint, 100) - end - end - - describe "resolve/3 - percentage" do - test "calculates correct fraction of parent" do - constraint = Constraint.percentage(50) - assert 50 = Constraint.resolve(constraint, 100) - end - - test "rounds to nearest integer" do - constraint = Constraint.percentage(33.33) - assert 33 = Constraint.resolve(constraint, 100) - end - - test "handles zero percentage" do - constraint = Constraint.percentage(0) - assert 0 = Constraint.resolve(constraint, 100) - end - - test "handles 100 percentage" do - constraint = Constraint.percentage(100) - assert 100 = Constraint.resolve(constraint, 100) - end - - test "works with small available space" do - constraint = Constraint.percentage(50) - assert 5 = Constraint.resolve(constraint, 10) - end - end - - describe "resolve/3 - ratio" do - test "distributes space proportionally" do - constraint = Constraint.ratio(2) - result = Constraint.resolve(constraint, 100, remaining: 60, total_ratio: 3) - assert 40 = result - end - - test "handles single ratio taking all remaining" do - constraint = Constraint.ratio(1) - result = Constraint.resolve(constraint, 100, remaining: 30, total_ratio: 1) - assert 30 = result - end - - test "returns zero when no remaining space" do - constraint = Constraint.ratio(2) - result = Constraint.resolve(constraint, 100, remaining: 0, total_ratio: 3) - assert 0 = result - end - - test "returns zero when total_ratio is zero" do - constraint = Constraint.ratio(2) - result = Constraint.resolve(constraint, 100, remaining: 60, total_ratio: 0) - assert 0 = result - end - end - - describe "resolve/3 - fill" do - test "uses all remaining space" do - constraint = Constraint.fill() - result = Constraint.resolve(constraint, 100, remaining: 30) - assert 30 = result - end - - test "returns zero when no remaining space" do - constraint = Constraint.fill() - result = Constraint.resolve(constraint, 100, remaining: 0) - assert 0 = result - end - - test "defaults to zero without remaining option" do - constraint = Constraint.fill() - result = Constraint.resolve(constraint, 100) - assert 0 = result - end - end - - describe "resolve/3 - min" do - test "enforces minimum size" do - constraint = Constraint.percentage(10) |> Constraint.with_min(20) - result = Constraint.resolve(constraint, 100) - # percentage gives 10, min enforces 20 - assert 20 = result - end - - test "does not affect when inner exceeds min" do - constraint = Constraint.percentage(50) |> Constraint.with_min(20) - result = Constraint.resolve(constraint, 100) - # percentage gives 50, which exceeds min 20 - assert 50 = result - end - - test "works with fill" do - constraint = Constraint.min(10) - result = Constraint.resolve(constraint, 100, remaining: 5) - # fill gives 5, min enforces 10 - assert 10 = result - end - end - - describe "resolve/3 - max" do - test "enforces maximum size" do - constraint = Constraint.percentage(80) |> Constraint.with_max(50) - result = Constraint.resolve(constraint, 100) - # percentage gives 80, max enforces 50 - assert 50 = result - end - - test "does not affect when inner is below max" do - constraint = Constraint.percentage(30) |> Constraint.with_max(50) - result = Constraint.resolve(constraint, 100) - # percentage gives 30, which is below max 50 - assert 30 = result - end - - test "works with fill" do - constraint = Constraint.max(50) - result = Constraint.resolve(constraint, 100, remaining: 80) - # fill gives 80, max enforces 50 - assert 50 = result - end - end - - describe "resolve/3 - combined bounds" do - test "applies both min and max" do - constraint = - Constraint.percentage(5) - |> Constraint.with_min(10) - |> Constraint.with_max(50) - - result = Constraint.resolve(constraint, 100) - # percentage gives 5, min enforces 10, max allows up to 50 - assert 10 = result - end - - test "max takes precedence when inner exceeds both" do - constraint = - Constraint.percentage(80) - |> Constraint.with_min(10) - |> Constraint.with_max(50) - - result = Constraint.resolve(constraint, 100) - # percentage gives 80, max enforces 50 - assert 50 = result - end - - test "min_max works correctly" do - constraint = Constraint.min_max(10, 50) - - # Below min - result1 = Constraint.resolve(constraint, 100, remaining: 5) - assert 10 = result1 - - # Above max - result2 = Constraint.resolve(constraint, 100, remaining: 80) - assert 50 = result2 - - # Within bounds - result3 = Constraint.resolve(constraint, 100, remaining: 30) - assert 30 = result3 - end - end - - describe "type/1" do - test "returns :length for length constraint" do - assert :length = Constraint.type(Constraint.length(20)) - end - - test "returns :percentage for percentage constraint" do - assert :percentage = Constraint.type(Constraint.percentage(50)) - end - - test "returns :ratio for ratio constraint" do - assert :ratio = Constraint.type(Constraint.ratio(2)) - end - - test "returns :fill for fill constraint" do - assert :fill = Constraint.type(Constraint.fill()) - end - - test "returns tuple for bounded constraints" do - assert {:min, :percentage} = - Constraint.type(Constraint.percentage(50) |> Constraint.with_min(10)) - - assert {:max, :fill} = Constraint.type(Constraint.max(100)) - end - end - - describe "fixed?/1" do - test "returns true for length constraint" do - assert Constraint.fixed?(Constraint.length(20)) - end - - test "returns true for bounded length" do - assert Constraint.fixed?(Constraint.length(20) |> Constraint.with_min(10)) - assert Constraint.fixed?(Constraint.length(20) |> Constraint.with_max(30)) - end - - test "returns false for percentage" do - refute Constraint.fixed?(Constraint.percentage(50)) - end - - test "returns false for ratio" do - refute Constraint.fixed?(Constraint.ratio(2)) - end - - test "returns false for fill" do - refute Constraint.fixed?(Constraint.fill()) - end - end - - describe "flexible?/1" do - test "returns true for ratio constraint" do - assert Constraint.flexible?(Constraint.ratio(2)) - end - - test "returns true for fill constraint" do - assert Constraint.flexible?(Constraint.fill()) - end - - test "returns true for bounded flexible" do - assert Constraint.flexible?(Constraint.ratio(2) |> Constraint.with_min(10)) - assert Constraint.flexible?(Constraint.fill() |> Constraint.with_max(100)) - end - - test "returns false for length" do - refute Constraint.flexible?(Constraint.length(20)) - end - - test "returns false for percentage" do - refute Constraint.flexible?(Constraint.percentage(50)) - end - end - - describe "get_min/1" do - test "returns min value from min constraint" do - assert 10 = Constraint.get_min(Constraint.min(10)) - assert 20 = Constraint.get_min(Constraint.percentage(50) |> Constraint.with_min(20)) - end - - test "returns nil for non-min constraints" do - assert nil == Constraint.get_min(Constraint.length(20)) - assert nil == Constraint.get_min(Constraint.percentage(50)) - end - end - - describe "get_max/1" do - test "returns max value from max constraint" do - assert 100 = Constraint.get_max(Constraint.max(100)) - assert 50 = Constraint.get_max(Constraint.percentage(50) |> Constraint.with_max(50)) - end - - test "returns max from nested min/max" do - assert 100 = Constraint.get_max(Constraint.min_max(10, 100)) - end - - test "returns nil for non-max constraints" do - assert nil == Constraint.get_max(Constraint.length(20)) - assert nil == Constraint.get_max(Constraint.min(10)) - end - end - - describe "unwrap/1" do - test "unwraps min constraint" do - constraint = Constraint.percentage(50) |> Constraint.with_min(10) - assert %Percentage{value: 50} = Constraint.unwrap(constraint) - end - - test "unwraps max constraint" do - constraint = Constraint.ratio(2) |> Constraint.with_max(100) - assert %Ratio{value: 2} = Constraint.unwrap(constraint) - end - - test "unwraps nested bounds" do - constraint = - Constraint.fill() - |> Constraint.with_min(10) - |> Constraint.with_max(100) - - assert %Fill{} = Constraint.unwrap(constraint) - end - - test "returns base constraints unchanged" do - assert %Length{value: 20} = Constraint.unwrap(Constraint.length(20)) - assert %Percentage{value: 50} = Constraint.unwrap(Constraint.percentage(50)) - assert %Fill{} = Constraint.unwrap(Constraint.fill()) - end - end -end diff --git a/test/term_ui/layout/integration_test.exs b/test/term_ui/layout/integration_test.exs deleted file mode 100644 index b7b82353..00000000 --- a/test/term_ui/layout/integration_test.exs +++ /dev/null @@ -1,531 +0,0 @@ -defmodule TermUI.Layout.IntegrationTest do - use ExUnit.Case, async: true - - alias TermUI.Layout.Alignment - alias TermUI.Layout.Cache - alias TermUI.Layout.Constraint - alias TermUI.Layout.Solver - - describe "three-pane layout" do - test "sidebar, main, and detail with ratio constraints" do - # Classic three-pane: sidebar (1), main (3), detail panel (fixed 30) - constraints = [ - Constraint.ratio(1), - Constraint.ratio(3), - Constraint.length(30) - ] - - area = %{x: 0, y: 0, width: 150, height: 40} - rects = Solver.solve_to_rects(constraints, area, direction: :horizontal) - - assert length(rects) == 3 - - # Detail gets exactly 30 - detail = Enum.at(rects, 2) - assert detail.width == 30 - - # Remaining 120 split 1:3 = 30:90 - sidebar = Enum.at(rects, 0) - main = Enum.at(rects, 1) - assert sidebar.width == 30 - assert main.width == 90 - - # All have full height - assert Enum.all?(rects, &(&1.height == 40)) - - # Positions are contiguous - assert sidebar.x == 0 - assert main.x == 30 - assert detail.x == 120 - end - - test "three-pane adapts to different sizes" do - constraints = [ - Constraint.ratio(1), - Constraint.ratio(2), - Constraint.length(20) - ] - - # Smaller terminal - small_area = %{x: 0, y: 0, width: 80, height: 24} - rects = Solver.solve_to_rects(constraints, small_area, direction: :horizontal) - - # Detail still 20, remaining 60 split 1:2 = 20:40 - assert Enum.at(rects, 0).width == 20 - assert Enum.at(rects, 1).width == 40 - assert Enum.at(rects, 2).width == 20 - - # Larger terminal - large_area = %{x: 0, y: 0, width: 200, height: 50} - rects = Solver.solve_to_rects(constraints, large_area, direction: :horizontal) - - # Detail still 20, remaining 180 split 1:2 = 60:120 - assert Enum.at(rects, 0).width == 60 - assert Enum.at(rects, 1).width == 120 - assert Enum.at(rects, 2).width == 20 - end - end - - describe "form layout" do - test "labels and inputs with percentage and fill" do - # Form row: fixed label, flexible input - constraints = [ - Constraint.percentage(30), - Constraint.fill() - ] - - area = %{x: 0, y: 0, width: 100, height: 1} - rects = Solver.solve_to_rects(constraints, area, direction: :horizontal) - - label = Enum.at(rects, 0) - input = Enum.at(rects, 1) - - assert label.width == 30 - assert input.width == 70 - end - - test "multiple form rows stacked vertically" do - # Three form rows - row_constraints = [ - Constraint.length(3), - Constraint.length(3), - Constraint.length(3) - ] - - area = %{x: 0, y: 0, width: 80, height: 9} - rows = Solver.solve_to_rects(row_constraints, area, direction: :vertical) - - # Each row is 3 cells high - assert Enum.all?(rows, &(&1.height == 3)) - - # Rows are stacked - assert Enum.at(rows, 0).y == 0 - assert Enum.at(rows, 1).y == 3 - assert Enum.at(rows, 2).y == 6 - end - - test "form with min-width labels" do - constraints = [ - Constraint.percentage(20) |> Constraint.with_min(15), - Constraint.fill() - ] - - # Small area where 20% would be < 15 - small_area = %{x: 0, y: 0, width: 50, height: 1} - rects = Solver.solve_to_rects(constraints, small_area, direction: :horizontal) - - label = Enum.at(rects, 0) - # Min enforced: 15 instead of 10 - assert label.width == 15 - end - end - - describe "nested containers" do - test "horizontal container with vertical children" do - # Outer: two columns - outer_constraints = [ - Constraint.ratio(1), - Constraint.ratio(1) - ] - - outer_area = %{x: 0, y: 0, width: 80, height: 24} - columns = Solver.solve_to_rects(outer_constraints, outer_area, direction: :horizontal) - - # Each column is 40 wide - assert Enum.at(columns, 0).width == 40 - assert Enum.at(columns, 1).width == 40 - - # Inner: stack items vertically in first column - inner_constraints = [ - Constraint.length(5), - Constraint.fill(), - Constraint.length(3) - ] - - column_area = Enum.at(columns, 0) - items = Solver.solve_to_rects(inner_constraints, column_area, direction: :vertical) - - # Items positioned within column - assert Enum.at(items, 0).height == 5 - # 24 - 5 - 3 - assert Enum.at(items, 1).height == 16 - assert Enum.at(items, 2).height == 3 - - # All items have column width - assert Enum.all?(items, &(&1.width == 40)) - end - - test "three levels of nesting" do - # Level 1: split horizontally - l1_constraints = [Constraint.ratio(1), Constraint.ratio(2)] - l1_area = %{x: 0, y: 0, width: 120, height: 30} - l1_rects = Solver.solve_to_rects(l1_constraints, l1_area, direction: :horizontal) - - # Level 2: split first column vertically - l2_constraints = [Constraint.length(10), Constraint.fill()] - l2_area = Enum.at(l1_rects, 0) - l2_rects = Solver.solve_to_rects(l2_constraints, l2_area, direction: :vertical) - - # Level 3: split remaining space horizontally - l3_constraints = [Constraint.percentage(50), Constraint.percentage(50)] - l3_area = Enum.at(l2_rects, 1) - l3_rects = Solver.solve_to_rects(l3_constraints, l3_area, direction: :horizontal) - - # Verify dimensions propagate correctly - assert Enum.at(l1_rects, 0).width == 40 - assert Enum.at(l2_rects, 0).height == 10 - assert Enum.at(l2_rects, 1).height == 20 - assert Enum.at(l3_rects, 0).width == 20 - assert Enum.at(l3_rects, 1).width == 20 - end - end - - describe "alignment integration" do - test "centered content in larger container" do - constraints = [Constraint.length(20), Constraint.length(20)] - area = %{x: 0, y: 0, width: 100, height: 10} - - # Solve then center - rects = Solver.solve_to_rects(constraints, area, direction: :horizontal) - - aligned = - Alignment.apply(rects, area, - direction: :horizontal, - justify: :center, - align: :center - ) - - # Total content is 40, centered in 100 = offset 30 - assert Enum.at(aligned, 0).x == 30 - assert Enum.at(aligned, 1).x == 50 - end - - test "space-between distribution" do - constraints = [ - Constraint.length(10), - Constraint.length(10), - Constraint.length(10) - ] - - area = %{x: 0, y: 0, width: 100, height: 10} - rects = Solver.solve_to_rects(constraints, area, direction: :horizontal) - - aligned = - Alignment.apply(rects, area, - direction: :horizontal, - justify: :space_between - ) - - # 3 items of 10 = 30, remaining 70 split between 2 gaps = 35 each - assert Enum.at(aligned, 0).x == 0 - assert Enum.at(aligned, 1).x == 45 - assert Enum.at(aligned, 2).x == 90 - end - - test "align-self overrides" do - constraints = [Constraint.length(20), Constraint.length(20), Constraint.length(20)] - area = %{x: 0, y: 0, width: 60, height: 20} - - rects = Solver.solve_to_rects(constraints, area, direction: :horizontal) - - # Different alignment per item - aligned = - Alignment.apply(rects, area, - direction: :horizontal, - align: :start, - align_self: [:start, :center, :end] - ) - - # First at top, second centered, third at bottom - assert Enum.at(aligned, 0).y == 0 - # Center: (20 - height) / 2, but rect height is 20, so centered at 0 - # Actually the rects have height 20, cross axis is height - # With align_self :center on a 20-height item in 20-height area = 0 - assert Enum.at(aligned, 1).y == 0 - assert Enum.at(aligned, 2).y == 0 - end - - test "margins reduce component size" do - constraints = [Constraint.fill()] - area = %{x: 0, y: 0, width: 100, height: 50} - - rects = Solver.solve_to_rects(constraints, area, direction: :horizontal) - margin = Alignment.parse_spacing(5) - with_margins = Alignment.apply_margins(rects, margin) - - rect = Enum.at(with_margins, 0) - assert rect.x == 5 - assert rect.y == 5 - assert rect.width == 90 - assert rect.height == 40 - end - end - - describe "cache integration" do - setup do - # Cache uses a singleton ETS table, just ensure it's started - case Cache.start_link([]) do - {:ok, _} -> :ok - {:error, {:already_started, _}} -> :ok - end - - # Ensure cleanup after test to prevent state leakage - on_exit(fn -> - try do - Cache.clear() - rescue - ArgumentError -> :ok - end - end) - - :ok - end - - test "repeated layouts use cache" do - constraints = [Constraint.ratio(1), Constraint.ratio(2)] - area = %{x: 0, y: 0, width: 100, height: 20} - - # Clear to get clean stats - Cache.clear() - - # First call - cache miss - result1 = Cache.solve(constraints, area) - - # Second call - cache hit - result2 = Cache.solve(constraints, area) - - assert result1 == result2 - - stats = Cache.stats() - assert stats.hits >= 1 - end - - test "different sizes create different cache entries" do - constraints = [Constraint.fill()] - area1 = %{x: 0, y: 0, width: 100, height: 20} - area2 = %{x: 0, y: 0, width: 200, height: 20} - - Cache.clear() - - Cache.solve(constraints, area1) - Cache.solve(constraints, area2) - - stats = Cache.stats() - assert stats.size == 2 - end - - test "cache clear removes all entries" do - constraints = [Constraint.length(50)] - area = %{x: 0, y: 0, width: 100, height: 20} - - Cache.solve(constraints, area) - stats_before = Cache.stats() - assert stats_before.size >= 1 - - Cache.clear() - stats_after = Cache.stats() - assert stats_after.size == 0 - end - - test "evict_now triggers synchronous eviction" do - Cache.clear() - - # Add some entries - for i <- 1..10 do - constraints = [Constraint.length(i)] - area = %{x: 0, y: 0, width: 100, height: 20} - Cache.solve(constraints, area) - end - - stats_before = Cache.stats() - assert stats_before.size == 10 - - # Eviction only removes entries if over max_size - # With default max_size of 500, nothing will be evicted - # But we can verify the function runs without error - Cache.evict_now() - - stats_after = Cache.stats() - # Size unchanged since we're under max_size - assert stats_after.size == 10 - end - - test "cache tracks access for LRU" do - Cache.clear() - - constraints = [Constraint.fill()] - area = %{x: 0, y: 0, width: 100, height: 20} - - # First access - Cache.solve(constraints, area) - - # Wait a moment then access again - Process.sleep(1) - Cache.solve(constraints, area) - - # Should have 1 hit (second access) - stats = Cache.stats() - assert stats.hits >= 1 - assert stats.misses >= 1 - end - - test "warm preloads cache entries" do - Cache.clear() - - entries = [ - {[Constraint.length(10)], %{x: 0, y: 0, width: 100, height: 20}, []}, - {[Constraint.fill()], %{x: 0, y: 0, width: 200, height: 30}, []} - ] - - Cache.warm(entries) - - stats = Cache.stats() - # Stats reset after warm, so size should be 2 but hits/misses reset - assert stats.size == 2 - - # Subsequent solves should hit cache - Cache.solve([Constraint.length(10)], %{x: 0, y: 0, width: 100, height: 20}) - Cache.solve([Constraint.fill()], %{x: 0, y: 0, width: 200, height: 30}) - - stats_after = Cache.stats() - assert stats_after.hits == 2 - end - end - - describe "resize handling" do - test "layout recalculates on size change" do - constraints = [Constraint.percentage(50), Constraint.fill()] - - # Initial size - area1 = %{x: 0, y: 0, width: 100, height: 24} - rects1 = Solver.solve_to_rects(constraints, area1, direction: :horizontal) - - assert Enum.at(rects1, 0).width == 50 - assert Enum.at(rects1, 1).width == 50 - - # After resize - area2 = %{x: 0, y: 0, width: 200, height: 24} - rects2 = Solver.solve_to_rects(constraints, area2, direction: :horizontal) - - assert Enum.at(rects2, 0).width == 100 - assert Enum.at(rects2, 1).width == 100 - end - - test "min constraints protect during shrink" do - constraints = [ - Constraint.percentage(30) |> Constraint.with_min(20), - Constraint.fill() - ] - - # Large enough for 30% - large = %{x: 0, y: 0, width: 100, height: 24} - rects = Solver.solve_to_rects(constraints, large, direction: :horizontal) - assert Enum.at(rects, 0).width == 30 - - # Too small - min kicks in - small = %{x: 0, y: 0, width: 50, height: 24} - rects = Solver.solve_to_rects(constraints, small, direction: :horizontal) - assert Enum.at(rects, 0).width == 20 - end - end - - describe "edge cases" do - test "single component fills entire area" do - constraints = [Constraint.fill()] - area = %{x: 10, y: 5, width: 80, height: 20} - - rects = Solver.solve_to_rects(constraints, area, direction: :horizontal) - rect = Enum.at(rects, 0) - - assert rect.x == 10 - assert rect.y == 5 - assert rect.width == 80 - assert rect.height == 20 - end - - test "all fixed constraints" do - constraints = [ - Constraint.length(10), - Constraint.length(20), - Constraint.length(30) - ] - - area = %{x: 0, y: 0, width: 100, height: 10} - rects = Solver.solve_to_rects(constraints, area, direction: :horizontal) - - assert Enum.at(rects, 0).width == 10 - assert Enum.at(rects, 1).width == 20 - assert Enum.at(rects, 2).width == 30 - end - - test "zero-size area produces zero-size rects" do - constraints = [Constraint.fill()] - area = %{x: 0, y: 0, width: 0, height: 0} - - rects = Solver.solve_to_rects(constraints, area, direction: :horizontal) - rect = Enum.at(rects, 0) - - assert rect.width == 0 - assert rect.height == 0 - end - - test "empty constraints list produces empty rects" do - constraints = [] - area = %{x: 0, y: 0, width: 100, height: 20} - - rects = Solver.solve_to_rects(constraints, area, direction: :horizontal) - - assert rects == [] - end - - test "constraints exceeding available space are reduced" do - # Total fixed: 150, available: 100 - constraints = [ - Constraint.length(50), - Constraint.length(50), - Constraint.length(50) - ] - - area = %{x: 0, y: 0, width: 100, height: 10} - rects = Solver.solve_to_rects(constraints, area, direction: :horizontal) - - # Should reduce proportionally, total should not exceed available - total_width = Enum.reduce(rects, 0, fn r, acc -> acc + r.width end) - assert total_width <= 100 - end - - test "all ratios with no remaining space get zero" do - # Fixed takes all space - constraints = [ - Constraint.length(100), - Constraint.ratio(1), - Constraint.ratio(2) - ] - - area = %{x: 0, y: 0, width: 100, height: 10} - rects = Solver.solve_to_rects(constraints, area, direction: :horizontal) - - assert Enum.at(rects, 0).width == 100 - assert Enum.at(rects, 1).width == 0 - assert Enum.at(rects, 2).width == 0 - end - - test "percentage over 100 raises error" do - assert_raise ArgumentError, ~r/percentage must be between 0 and 100/, fn -> - Constraint.percentage(150) - end - end - - test "min constraint is enforced even when exceeding available" do - constraints = [ - Constraint.fill() |> Constraint.with_min(200) - ] - - area = %{x: 0, y: 0, width: 100, height: 10} - rects = Solver.solve_to_rects(constraints, area, direction: :horizontal) - - # Min is enforced - may exceed available (overflow scenario) - assert Enum.at(rects, 0).width == 200 - end - end -end diff --git a/test/term_ui/layout/solver_test.exs b/test/term_ui/layout/solver_test.exs deleted file mode 100644 index 4d986835..00000000 --- a/test/term_ui/layout/solver_test.exs +++ /dev/null @@ -1,459 +0,0 @@ -defmodule TermUI.Layout.SolverTest do - use ExUnit.Case, async: true - - import ExUnit.CaptureLog - - alias TermUI.Layout.{Constraint, Solver} - - describe "solve/2 - basic constraints" do - test "solves single length constraint" do - constraints = [Constraint.length(50)] - assert [50] = Solver.solve(constraints, 100) - end - - test "solves multiple length constraints" do - constraints = [Constraint.length(20), Constraint.length(30)] - assert [20, 30] = Solver.solve(constraints, 100) - end - - test "solves single percentage constraint" do - constraints = [Constraint.percentage(50)] - assert [50] = Solver.solve(constraints, 100) - end - - test "solves multiple percentage constraints" do - constraints = [Constraint.percentage(30), Constraint.percentage(70)] - assert [30, 70] = Solver.solve(constraints, 100) - end - - test "solves single ratio constraint" do - constraints = [Constraint.ratio(1)] - assert [100] = Solver.solve(constraints, 100) - end - - test "solves multiple ratio constraints" do - constraints = [Constraint.ratio(1), Constraint.ratio(2)] - sizes = Solver.solve(constraints, 90) - assert [30, 60] = sizes - end - - test "solves fill constraint" do - constraints = [Constraint.fill()] - assert [100] = Solver.solve(constraints, 100) - end - - test "solves multiple fills equally" do - constraints = [Constraint.fill(), Constraint.fill()] - sizes = Solver.solve(constraints, 100) - assert [50, 50] = sizes - end - - test "handles empty constraints" do - assert [] = Solver.solve([], 100) - end - - test "handles zero available space" do - constraints = [Constraint.fill()] - assert [0] = Solver.solve(constraints, 0) - end - end - - describe "solve/2 - mixed constraints" do - test "length + fill" do - constraints = [Constraint.length(20), Constraint.fill()] - assert [20, 80] = Solver.solve(constraints, 100) - end - - test "length + percentage + fill" do - constraints = [ - Constraint.length(20), - Constraint.percentage(30), - Constraint.fill() - ] - - sizes = Solver.solve(constraints, 100) - # 20 fixed + 30% of 100 + fill remainder - assert [20, 30, 50] = sizes - end - - test "percentage + ratio" do - constraints = [ - Constraint.percentage(50), - Constraint.ratio(1), - Constraint.ratio(1) - ] - - sizes = Solver.solve(constraints, 100) - # 50 percentage, then 25+25 for ratios - assert [50, 25, 25] = sizes - end - - test "three-pane layout: fixed sidebar, 1:2 ratio main/detail" do - constraints = [ - Constraint.length(200), - Constraint.ratio(1), - Constraint.ratio(2) - ] - - sizes = Solver.solve(constraints, 1000) - assert [200, 267, 533] = sizes - end - - test "fill between fixed elements" do - constraints = [ - Constraint.length(10), - Constraint.fill(), - Constraint.length(10) - ] - - assert [10, 80, 10] = Solver.solve(constraints, 100) - end - end - - describe "solve/2 - bounded constraints" do - test "percentage with min" do - constraints = [Constraint.percentage(10) |> Constraint.with_min(20)] - assert [20] = Solver.solve(constraints, 100) - end - - test "percentage with max" do - constraints = [Constraint.percentage(80) |> Constraint.with_max(50)] - assert [50] = Solver.solve(constraints, 100) - end - - test "fill with min" do - constraints = [ - Constraint.length(90), - Constraint.fill() |> Constraint.with_min(20) - ] - - # Fill gets 10, but min enforces 20 - # This causes conflict - solver handles it - sizes = Solver.solve(constraints, 100) - [fixed, fill] = sizes - - # Min bound should be respected - assert fill >= 20 - # Total should not exceed available - # Allow some overflow in conflict cases - assert fixed + fill <= 110 - end - - test "fill with max" do - constraints = [ - Constraint.length(20), - Constraint.fill() |> Constraint.with_max(50) - ] - - sizes = Solver.solve(constraints, 100) - assert [20, 50] = sizes - end - - test "min_max bounds" do - constraint = Constraint.min_max(10, 50) - # Fill with bounds - assert [30] = Solver.solve([constraint], 30) - assert [10] = Solver.solve([constraint], 5) - assert [50] = Solver.solve([constraint], 80) - end - end - - describe "solve/2 - conflict resolution" do - test "fixed sizes exceed available space" do - constraints = [Constraint.length(60), Constraint.length(60)] - - log = - capture_log(fn -> - sizes = Solver.solve(constraints, 100) - # Should scale proportionally - assert Enum.sum(sizes) <= 100 - end) - - assert log =~ "exceed" - end - - test "percentages exceed 100%" do - constraints = [Constraint.percentage(60), Constraint.percentage(60)] - sizes = Solver.solve(constraints, 100) - # 60 + 60 = 120, exceeds 100 - assert Enum.sum(sizes) <= 100 - end - - test "min bounds conflict" do - constraints = [ - Constraint.fill() |> Constraint.with_min(60), - Constraint.fill() |> Constraint.with_min(60) - ] - - log = - capture_log(fn -> - sizes = Solver.solve(constraints, 100) - # Both want at least 60, but only 100 available - assert length(sizes) == 2 - end) - - assert log =~ "min" or log =~ "exceed" - end - - test "prioritizes min bounds over other reductions" do - constraints = [ - Constraint.length(30), - Constraint.fill() |> Constraint.with_min(80) - ] - - log = - capture_log(fn -> - sizes = Solver.solve(constraints, 100) - [fixed, fill] = sizes - # Min bound should be respected - assert fill >= 80 or fixed < 30 - end) - - # Should warn about conflict - # Just ensure it completes - assert log =~ "" or true - end - end - - describe "solve/2 - fast paths" do - test "all-fixed fast path" do - constraints = [ - Constraint.length(10), - Constraint.length(20), - Constraint.length(30) - ] - - assert [10, 20, 30] = Solver.solve(constraints, 100) - end - - test "single-fill fast path" do - constraints = [ - Constraint.length(10), - Constraint.fill(), - Constraint.length(20) - ] - - assert [10, 70, 20] = Solver.solve(constraints, 100) - end - - test "single-fill with bounds" do - constraints = [ - Constraint.length(10), - Constraint.fill() |> Constraint.with_max(50), - Constraint.length(20) - ] - - assert [10, 50, 20] = Solver.solve(constraints, 100) - end - end - - describe "solve_to_rects/3 - horizontal layout" do - test "basic horizontal layout" do - constraints = [Constraint.length(20), Constraint.fill()] - area = %{x: 0, y: 0, width: 100, height: 10} - - rects = Solver.solve_to_rects(constraints, area) - - assert [ - %{x: 0, y: 0, width: 20, height: 10}, - %{x: 20, y: 0, width: 80, height: 10} - ] = rects - end - - test "horizontal layout with offset" do - constraints = [Constraint.length(30), Constraint.length(30)] - area = %{x: 10, y: 5, width: 60, height: 20} - - rects = Solver.solve_to_rects(constraints, area) - - assert [ - %{x: 10, y: 5, width: 30, height: 20}, - %{x: 40, y: 5, width: 30, height: 20} - ] = rects - end - - test "horizontal layout with gap" do - constraints = [Constraint.length(20), Constraint.length(20), Constraint.length(20)] - area = %{x: 0, y: 0, width: 70, height: 10} - - rects = Solver.solve_to_rects(constraints, area, gap: 5) - - assert [ - %{x: 0, y: 0, width: 20, height: 10}, - %{x: 25, y: 0, width: 20, height: 10}, - %{x: 50, y: 0, width: 20, height: 10} - ] = rects - end - - test "gap reduces available space for fill" do - constraints = [Constraint.length(20), Constraint.fill()] - area = %{x: 0, y: 0, width: 100, height: 10} - - rects = Solver.solve_to_rects(constraints, area, gap: 10) - - # Available = 100 - 10 (gap) = 90 - assert [ - %{x: 0, y: 0, width: 20, height: 10}, - %{x: 30, y: 0, width: 70, height: 10} - ] = rects - end - end - - describe "solve_to_rects/3 - vertical layout" do - test "basic vertical layout" do - constraints = [Constraint.length(5), Constraint.fill()] - area = %{x: 0, y: 0, width: 50, height: 20} - - rects = Solver.solve_to_rects(constraints, area, direction: :vertical) - - assert [ - %{x: 0, y: 0, width: 50, height: 5}, - %{x: 0, y: 5, width: 50, height: 15} - ] = rects - end - - test "vertical layout with offset and gap" do - constraints = [Constraint.length(3), Constraint.length(3)] - area = %{x: 5, y: 10, width: 40, height: 10} - - rects = Solver.solve_to_rects(constraints, area, direction: :vertical, gap: 2) - - assert [ - %{x: 5, y: 10, width: 40, height: 3}, - %{x: 5, y: 15, width: 40, height: 3} - ] = rects - end - end - - describe "solve_horizontal/3 and solve_vertical/3" do - test "solve_horizontal is shorthand for horizontal direction" do - constraints = [Constraint.length(20), Constraint.fill()] - area = %{x: 0, y: 0, width: 100, height: 10} - - assert Solver.solve_horizontal(constraints, area) == - Solver.solve_to_rects(constraints, area, direction: :horizontal) - end - - test "solve_vertical is shorthand for vertical direction" do - constraints = [Constraint.length(5), Constraint.fill()] - area = %{x: 0, y: 0, width: 50, height: 20} - - assert Solver.solve_vertical(constraints, area) == - Solver.solve_to_rects(constraints, area, direction: :vertical) - end - end - - describe "solve/2 - edge cases" do - test "very small available space" do - constraints = [Constraint.ratio(1), Constraint.ratio(1)] - sizes = Solver.solve(constraints, 1) - assert Enum.sum(sizes) <= 1 - end - - test "many constraints" do - constraints = for _ <- 1..10, do: Constraint.ratio(1) - sizes = Solver.solve(constraints, 100) - assert length(sizes) == 10 - assert Enum.sum(sizes) == 100 - end - - test "deeply nested bounds" do - constraint = - Constraint.percentage(50) - |> Constraint.with_min(10) - |> Constraint.with_max(80) - - assert [50] = Solver.solve([constraint], 100) - # 50% of 10 = 5, but min is 10 - assert [10] = Solver.solve([constraint], 20) - # 50% of 200 = 100, but max is 80 - assert [80] = Solver.solve([constraint], 200) - end - - test "ratio with very small values" do - constraints = [Constraint.ratio(0.1), Constraint.ratio(0.9)] - sizes = Solver.solve(constraints, 100) - assert [10, 90] = sizes - end - end - - describe "solve_to_rects/3 - non-overlapping verification" do - test "horizontal rects don't overlap" do - constraints = [ - Constraint.ratio(1), - Constraint.ratio(1), - Constraint.ratio(1) - ] - - area = %{x: 0, y: 0, width: 90, height: 10} - rects = Solver.solve_to_rects(constraints, area) - - # Verify no overlaps - for i <- 0..(length(rects) - 2) do - rect1 = Enum.at(rects, i) - rect2 = Enum.at(rects, i + 1) - assert rect1.x + rect1.width <= rect2.x - end - end - - test "vertical rects don't overlap" do - constraints = [ - Constraint.ratio(1), - Constraint.ratio(1), - Constraint.ratio(1) - ] - - area = %{x: 0, y: 0, width: 10, height: 90} - rects = Solver.solve_to_rects(constraints, area, direction: :vertical) - - # Verify no overlaps - for i <- 0..(length(rects) - 2) do - rect1 = Enum.at(rects, i) - rect2 = Enum.at(rects, i + 1) - assert rect1.y + rect1.height <= rect2.y - end - end - end - - describe "performance characteristics" do - test "solves typical layout quickly" do - constraints = [ - Constraint.length(30), - Constraint.percentage(20), - Constraint.fill(), - Constraint.length(30) - ] - - # Should complete in reasonable time (< 10ms) - {time, _result} = - :timer.tc(fn -> - for _ <- 1..1000 do - Solver.solve(constraints, 1000) - end - end) - - # 1000 solves should take less than 100ms - assert time < 100_000 - end - - test "rectangle calculation adds minimal overhead" do - constraints = [ - Constraint.length(100), - Constraint.fill(), - Constraint.length(100) - ] - - area = %{x: 0, y: 0, width: 1000, height: 50} - - {time, _result} = - :timer.tc(fn -> - for _ <- 1..1000 do - Solver.solve_to_rects(constraints, area) - end - end) - - # Should still be fast - assert time < 100_000 - end - end -end diff --git a/test/term_ui/message_queue_test.exs b/test/term_ui/message_queue_test.exs deleted file mode 100644 index c40555a7..00000000 --- a/test/term_ui/message_queue_test.exs +++ /dev/null @@ -1,184 +0,0 @@ -defmodule TermUI.MessageQueueTest do - use ExUnit.Case, async: true - - alias TermUI.MessageQueue - - describe "new/1" do - test "creates empty queue" do - queue = MessageQueue.new() - assert MessageQueue.empty?(queue) - assert MessageQueue.size(queue) == 0 - end - - test "accepts max_size option" do - queue = MessageQueue.new(max_size: 10) - assert queue.max_size == 10 - end - end - - describe "enqueue/2" do - test "adds message to queue" do - queue = MessageQueue.new() - queue = MessageQueue.enqueue(queue, :test) - - assert MessageQueue.size(queue) == 1 - refute MessageQueue.empty?(queue) - end - - test "preserves message order" do - queue = - MessageQueue.new() - |> MessageQueue.enqueue(:first) - |> MessageQueue.enqueue(:second) - |> MessageQueue.enqueue(:third) - - {messages, _} = MessageQueue.flush(queue) - assert messages == [:first, :second, :third] - end - - test "drops messages when at max capacity" do - queue = MessageQueue.new(max_size: 2) - - queue = - queue - |> MessageQueue.enqueue(:first) - |> MessageQueue.enqueue(:second) - |> MessageQueue.enqueue(:third) - - assert MessageQueue.size(queue) == 2 - assert MessageQueue.overflow_count(queue) == 1 - end - end - - describe "enqueue_all/2" do - test "enqueues multiple messages" do - queue = MessageQueue.new() - queue = MessageQueue.enqueue_all(queue, [:first, :second, :third]) - - assert MessageQueue.size(queue) == 3 - {messages, _} = MessageQueue.flush(queue) - assert messages == [:first, :second, :third] - end - end - - describe "flush/1" do - test "returns all messages and empties queue" do - queue = - MessageQueue.new() - |> MessageQueue.enqueue(:a) - |> MessageQueue.enqueue(:b) - - {messages, new_queue} = MessageQueue.flush(queue) - - assert messages == [:a, :b] - assert MessageQueue.empty?(new_queue) - end - - test "returns empty list for empty queue" do - queue = MessageQueue.new() - {messages, _} = MessageQueue.flush(queue) - assert messages == [] - end - end - - describe "peek/1" do - test "returns front message without removing" do - queue = - MessageQueue.new() - |> MessageQueue.enqueue(:first) - |> MessageQueue.enqueue(:second) - - assert {:value, :first} = MessageQueue.peek(queue) - assert MessageQueue.size(queue) == 2 - end - - test "returns :empty for empty queue" do - queue = MessageQueue.new() - assert :empty = MessageQueue.peek(queue) - end - end - - describe "dequeue/1" do - test "removes and returns front message" do - queue = - MessageQueue.new() - |> MessageQueue.enqueue(:first) - |> MessageQueue.enqueue(:second) - - {{:value, msg}, new_queue} = MessageQueue.dequeue(queue) - - assert msg == :first - assert MessageQueue.size(new_queue) == 1 - end - - test "returns :empty for empty queue" do - queue = MessageQueue.new() - {:empty, _} = MessageQueue.dequeue(queue) - end - end - - describe "clear/1" do - test "removes all messages and resets overflow" do - queue = - MessageQueue.new(max_size: 2) - |> MessageQueue.enqueue(:a) - |> MessageQueue.enqueue(:b) - |> MessageQueue.enqueue(:c) - - queue = MessageQueue.clear(queue) - - assert MessageQueue.empty?(queue) - assert MessageQueue.overflow_count(queue) == 0 - end - end - - describe "process/3" do - test "applies function to all messages" do - queue = - MessageQueue.new() - |> MessageQueue.enqueue(1) - |> MessageQueue.enqueue(2) - |> MessageQueue.enqueue(3) - - {sum, new_queue} = MessageQueue.process(queue, 0, fn msg, acc -> acc + msg end) - - assert sum == 6 - assert MessageQueue.empty?(new_queue) - end - - test "collects state and commands" do - queue = - MessageQueue.new() - |> MessageQueue.enqueue(:increment) - |> MessageQueue.enqueue(:increment) - |> MessageQueue.enqueue({:add, 5}) - - update_fn = fn - :increment, {count, cmds} -> {count + 1, cmds} - {:add, n}, {count, cmds} -> {count + n, [:added | cmds]} - end - - {{final_count, commands}, _} = MessageQueue.process(queue, {0, []}, update_fn) - - assert final_count == 7 - assert commands == [:added] - end - end - - describe "message batching scenario" do - test "multiple messages apply before single render" do - # Simulate rapid input - queue = - MessageQueue.new() - |> MessageQueue.enqueue({:key, :up}) - |> MessageQueue.enqueue({:key, :up}) - |> MessageQueue.enqueue({:key, :up}) - - # Process batch - {messages, _} = MessageQueue.flush(queue) - - # All messages should be processed - assert length(messages) == 3 - end - end -end diff --git a/test/term_ui/message_test.exs b/test/term_ui/message_test.exs deleted file mode 100644 index 5f036e15..00000000 --- a/test/term_ui/message_test.exs +++ /dev/null @@ -1,112 +0,0 @@ -defmodule TermUI.MessageTest do - use ExUnit.Case, async: true - - alias TermUI.Message - - describe "valid?/1" do - test "atoms are valid messages" do - assert Message.valid?(:increment) - assert Message.valid?(:submit) - end - - test "nil is not a valid message" do - refute Message.valid?(nil) - end - - test "tuples with at least one element are valid" do - assert Message.valid?({:select}) - assert Message.valid?({:select, 3}) - assert Message.valid?({:update, :name, "value"}) - end - - test "structs are valid messages" do - assert Message.valid?(%{__struct__: MyMsg}) - end - - test "other types are not valid" do - refute Message.valid?("string") - refute Message.valid?(123) - refute Message.valid?([]) - end - end - - describe "name/1" do - test "returns atom for atom messages" do - assert Message.name(:increment) == :increment - end - - test "returns first element for tuple messages" do - assert Message.name({:select, 3}) == :select - assert Message.name({:update, :name, "value"}) == :update - end - - test "returns module for struct messages" do - assert Message.name(%{__struct__: MyMsg}) == MyMsg - end - end - - describe "payload/1" do - test "returns nil for atom messages" do - assert Message.payload(:increment) == nil - end - - test "returns nil for single-element tuples" do - assert Message.payload({:submit}) == nil - end - - test "returns second element for 2-element tuples" do - assert Message.payload({:select, 3}) == 3 - end - - test "returns list for longer tuples" do - assert Message.payload({:update, :name, "value"}) == [:name, "value"] - end - - test "returns struct itself for struct messages" do - msg = %{__struct__: MyMsg, value: 42} - assert Message.payload(msg) == msg - end - end - - describe "wrap/1" do - test "wraps message in tuple" do - assert Message.wrap(:increment) == {:msg, :increment} - assert Message.wrap({:select, 3}) == {:msg, {:select, 3}} - end - end - - describe "type predicates" do - test "atom?/1 returns true for atoms" do - assert Message.atom?(:increment) - refute Message.atom?({:select, 3}) - refute Message.atom?(nil) - end - - test "tuple?/1 returns true for tuples" do - assert Message.tuple?({:select, 3}) - refute Message.tuple?(:increment) - end - - test "struct?/1 returns true for structs" do - assert Message.struct?(%{__struct__: MyMsg}) - refute Message.struct?(:increment) - end - end - - describe "match?/2" do - test "matches atom messages" do - assert Message.match?(:submit, :submit) - refute Message.match?(:submit, :cancel) - end - - test "matches tuple messages by first element" do - assert Message.match?({:select, 3}, :select) - refute Message.match?({:select, 3}, :update) - end - - test "matches struct messages by module" do - assert Message.match?(%{__struct__: MyMsg}, MyMsg) - refute Message.match?(%{__struct__: MyMsg}, OtherMsg) - end - end -end diff --git a/test/term_ui/mouse_test.exs b/test/term_ui/mouse_test.exs index d0a4f0aa..99db5f8d 100644 --- a/test/term_ui/mouse_test.exs +++ b/test/term_ui/mouse_test.exs @@ -1,435 +1,66 @@ defmodule TermUI.MouseTest do use ExUnit.Case, async: true - alias TermUI.Mouse - - describe "enable_mouse/0" do - test "returns escape sequences for normal tracking with SGR" do - seq = Mouse.enable_mouse() - assert seq =~ "\e[?1000h" - assert seq =~ "\e[?1006h" - end - end - - describe "enable_mouse_button/0" do - test "returns escape sequences for button motion tracking" do - seq = Mouse.enable_mouse_button() - assert seq =~ "\e[?1002h" - assert seq =~ "\e[?1006h" - end - end - - describe "enable_mouse_motion/0" do - test "returns escape sequences for all motion tracking" do - seq = Mouse.enable_mouse_motion() - assert seq =~ "\e[?1003h" - assert seq =~ "\e[?1006h" - end - end - - describe "disable_mouse/0" do - test "returns escape sequences to disable all tracking" do - seq = Mouse.disable_mouse() - assert seq =~ "\e[?1006l" - assert seq =~ "\e[?1003l" - assert seq =~ "\e[?1002l" - assert seq =~ "\e[?1000l" - end - end - - describe "sgr_extended_on/0" do - test "returns SGR Extended mode sequence" do - assert Mouse.sgr_extended_on() == "\e[?1006h" - end - end - - describe "scroll_action?/1" do - test "returns true for scroll actions" do - assert Mouse.scroll_action?(:scroll_up) - assert Mouse.scroll_action?(:scroll_down) - end - - test "returns false for non-scroll actions" do - refute Mouse.scroll_action?(:press) - refute Mouse.scroll_action?(:move) - end - end - - describe "click_action?/1" do - test "returns true for click actions" do - assert Mouse.click_action?(:press) - assert Mouse.click_action?(:release) - assert Mouse.click_action?(:click) - end - - test "returns false for non-click actions" do - refute Mouse.click_action?(:move) - refute Mouse.click_action?(:scroll_up) - end - end - - describe "motion_action?/1" do - test "returns true for motion actions" do - assert Mouse.motion_action?(:move) - assert Mouse.motion_action?(:drag) - end - - test "returns false for non-motion actions" do - refute Mouse.motion_action?(:press) - refute Mouse.motion_action?(:scroll_up) - end - end - - describe "default_scroll_lines/0" do - test "returns default scroll amount" do - assert Mouse.default_scroll_lines() == 3 - end - end -end - -defmodule TermUI.Mouse.TrackerTest do - use ExUnit.Case, async: true - - alias TermUI.Event + alias TermUI.{Event, Mouse} alias TermUI.Mouse.Tracker - describe "new/1" do - test "creates new tracker with defaults" do - tracker = Tracker.new() - - refute Tracker.dragging?(tracker) - assert Tracker.button_down(tracker) == nil - assert Tracker.hovered_component(tracker) == nil - end - - test "accepts drag threshold option" do - tracker = Tracker.new(drag_threshold: 10) - assert tracker.drag_threshold == 10 - end - end - - describe "process/2 with press" do - test "records button down and position" do - tracker = Tracker.new() - event = Event.mouse(:press, :left, 10, 20) - - {tracker, events} = Tracker.process(tracker, event) - - assert Tracker.button_down(tracker) == :left - assert tracker.press_position == {10, 20} - assert events == [] - end - end - - describe "process/2 with release" do - test "clears button down state" do - tracker = Tracker.new() - press = Event.mouse(:press, :left, 10, 20) - release = Event.mouse(:release, :left, 10, 20) - - {tracker, _} = Tracker.process(tracker, press) - {tracker, events} = Tracker.process(tracker, release) - - assert Tracker.button_down(tracker) == nil - assert events == [] - end - - test "emits drag_end if dragging" do - tracker = Tracker.new(drag_threshold: 1) - press = Event.mouse(:press, :left, 10, 20) - move = Event.mouse(:move, nil, 15, 25) - release = Event.mouse(:release, :left, 15, 25) - - {tracker, _} = Tracker.process(tracker, press) - {tracker, _} = Tracker.process(tracker, move) - {tracker, events} = Tracker.process(tracker, release) - - assert events == [{:drag_end, :left, 15, 25}] - refute Tracker.dragging?(tracker) - end - end - - describe "process/2 with move" do - test "starts drag when threshold exceeded" do - tracker = Tracker.new(drag_threshold: 3) - press = Event.mouse(:press, :left, 10, 20) - move = Event.mouse(:move, nil, 15, 20) - - {tracker, _} = Tracker.process(tracker, press) - {tracker, events} = Tracker.process(tracker, move) - - assert Tracker.dragging?(tracker) - assert [{:drag_start, :left, 10, 20}, {:drag_move, :left, 15, 20, 5, 0}] = events - end - - test "does not start drag before threshold" do - tracker = Tracker.new(drag_threshold: 10) - press = Event.mouse(:press, :left, 10, 20) - move = Event.mouse(:move, nil, 12, 21) - - {tracker, _} = Tracker.process(tracker, press) - {tracker, events} = Tracker.process(tracker, move) - - refute Tracker.dragging?(tracker) - assert events == [] - end - - test "emits drag_move when already dragging" do - tracker = Tracker.new(drag_threshold: 1) - press = Event.mouse(:press, :left, 10, 20) - move1 = Event.mouse(:move, nil, 15, 25) - move2 = Event.mouse(:move, nil, 20, 30) - - {tracker, _} = Tracker.process(tracker, press) - {tracker, _} = Tracker.process(tracker, move1) - {tracker, events} = Tracker.process(tracker, move2) - - assert [{:drag_move, :left, 20, 30, 5, 5}] = events - end - - test "no events when no button pressed" do - tracker = Tracker.new() - move = Event.mouse(:move, nil, 10, 20) - - {_tracker, events} = Tracker.process(tracker, move) + test "routes to the top region and creates local coordinates" do + regions = [ + Mouse.region(:base, 0, 0, 20, 10), + Mouse.region(:dialog, 5, 2, 8, 4, z_index: 10) + ] - assert events == [] - end - end - - describe "update_hover/2" do - test "emits enter event when hovering new component" do - tracker = Tracker.new() - - {tracker, events} = Tracker.update_hover(tracker, :button1) - - assert Tracker.hovered_component(tracker) == :button1 - assert events == [{:hover_enter, :button1}] - end - - test "emits leave then enter when changing component" do - tracker = Tracker.new() - - {tracker, _} = Tracker.update_hover(tracker, :button1) - {tracker, events} = Tracker.update_hover(tracker, :button2) - - assert Tracker.hovered_component(tracker) == :button2 - assert events == [{:hover_leave, :button1}, {:hover_enter, :button2}] - end - - test "emits leave event when leaving component" do - tracker = Tracker.new() - - {tracker, _} = Tracker.update_hover(tracker, :button1) - {tracker, events} = Tracker.update_hover(tracker, nil) - - assert Tracker.hovered_component(tracker) == nil - assert events == [{:hover_leave, :button1}] - end - - test "no events when component unchanged" do - tracker = Tracker.new() - - {tracker, _} = Tracker.update_hover(tracker, :button1) - {_tracker, events} = Tracker.update_hover(tracker, :button1) - - assert events == [] - end - end - - describe "reset_drag/1" do - test "clears drag state" do - tracker = Tracker.new(drag_threshold: 1) - press = Event.mouse(:press, :left, 10, 20) - move = Event.mouse(:move, nil, 15, 25) - - {tracker, _} = Tracker.process(tracker, press) - {tracker, _} = Tracker.process(tracker, move) - - assert Tracker.dragging?(tracker) - - tracker = Tracker.reset_drag(tracker) - - refute Tracker.dragging?(tracker) - assert Tracker.button_down(tracker) == nil - end - end -end - -defmodule TermUI.Mouse.RouterTest do - use ExUnit.Case, async: true - - alias TermUI.Event - alias TermUI.Mouse.Router - - describe "hit_test/3" do - test "finds component at position" do - components = %{ - button1: %{bounds: %{x: 0, y: 0, width: 10, height: 5}, z_index: 0} - } + event = Event.mouse(:press, :left, 7, 4) - assert {_, _, _} = Router.hit_test(components, 5, 2) - end - - test "returns nil when no component at position" do - components = %{ - button1: %{bounds: %{x: 0, y: 0, width: 10, height: 5}, z_index: 0} - } - - assert nil == Router.hit_test(components, 20, 20) - end - - test "returns topmost component when overlapping" do - components = %{ - bottom: %{bounds: %{x: 0, y: 0, width: 20, height: 20}, z_index: 0}, - top: %{bounds: %{x: 5, y: 5, width: 10, height: 10}, z_index: 1} - } - - {id, _, _} = Router.hit_test(components, 10, 10) - assert id == :top - end - - test "returns local coordinates" do - components = %{ - button: %{bounds: %{x: 10, y: 20, width: 30, height: 15}, z_index: 0} - } - - {_id, local_x, local_y} = Router.hit_test(components, 15, 25) - assert local_x == 5 - assert local_y == 5 - end + assert {:ok, :dialog, %Event.Mouse{x: 2, y: 2}} = Mouse.route(regions, event) + assert {:ok, region, {2, 2}} = Mouse.hit_test(regions, 7, 4) + assert region.id == :dialog end - describe "route/2" do - test "routes event to component with transformed coordinates" do - components = %{ - button: %{bounds: %{x: 10, y: 20, width: 30, height: 15}, z_index: 0} - } - - event = Event.mouse(:click, :left, 15, 25) - {id, transformed} = Router.route(components, event) + test "equal z-index uses the last composed region" do + regions = [Mouse.region(:first, 0, 0, 5, 5), Mouse.region(:second, 0, 0, 5, 5)] - assert id == :button - assert transformed.x == 5 - assert transformed.y == 5 - assert transformed.action == :click - end - - test "returns nil when no component at position" do - components = %{ - button: %{bounds: %{x: 10, y: 20, width: 30, height: 15}, z_index: 0} - } - - event = Event.mouse(:click, :left, 0, 0) - assert nil == Router.route(components, event) - end + assert {:ok, :second, _event} = + Mouse.route(regions, Event.mouse(:release, :left, 1, 1)) end - describe "hit_test_all/3" do - test "returns all components at position ordered by z-index" do - components = %{ - bottom: %{bounds: %{x: 0, y: 0, width: 20, height: 20}, z_index: 0}, - middle: %{bounds: %{x: 0, y: 0, width: 20, height: 20}, z_index: 1}, - top: %{bounds: %{x: 0, y: 0, width: 20, height: 20}, z_index: 2} - } - - results = Router.hit_test_all(components, 10, 10) - ids = Enum.map(results, fn {id, _, _} -> id end) + test "route all preserves front-to-back order" do + regions = [ + Mouse.region(:base, 0, 0, 10, 10), + Mouse.region(:overlay, 0, 0, 10, 10, z_index: 1) + ] - assert ids == [:top, :middle, :bottom] - end + assert [{:overlay, %Event.Mouse{}}, {:base, %Event.Mouse{}}] = + Mouse.route_all(regions, Event.mouse(:move, nil, 2, 3)) end - describe "to_local/3" do - test "transforms global to local coordinates" do - bounds = %{x: 10, y: 20, width: 30, height: 15} + test "tracker reports drag deltas and resets on release" do + tracker = Tracker.new(drag_threshold: 1) - {local_x, local_y} = Router.to_local(bounds, 25, 30) + {tracker, []} = Tracker.update(tracker, Event.mouse(:press, :left, 2, 3)) - assert local_x == 15 - assert local_y == 10 - end - end - - describe "to_global/3" do - test "transforms local to global coordinates" do - bounds = %{x: 10, y: 20, width: 30, height: 15} + {tracker, events} = Tracker.update(tracker, Event.mouse(:drag, :left, 4, 6)) - {global_x, global_y} = Router.to_global(bounds, 5, 5) + assert events == [ + {:drag_start, :left, 2, 3}, + {:drag, :left, 4, 6, 2, 3} + ] - assert global_x == 15 - assert global_y == 25 - end - end + assert Tracker.dragging?(tracker) - describe "point_in_bounds?/3" do - test "returns true for point inside bounds" do - bounds = %{x: 10, y: 20, width: 30, height: 15} + {tracker, [{:drag_end, :left, 4, 6}]} = + Tracker.update(tracker, Event.mouse(:release, :left, 4, 6)) - assert Router.point_in_bounds?(15, 25, bounds) - # top-left corner - assert Router.point_in_bounds?(10, 20, bounds) - # just inside bottom-right - assert Router.point_in_bounds?(39, 34, bounds) - end - - test "returns false for point outside bounds" do - bounds = %{x: 10, y: 20, width: 30, height: 15} - - # left - refute Router.point_in_bounds?(5, 25, bounds) - # right - refute Router.point_in_bounds?(50, 25, bounds) - # above - refute Router.point_in_bounds?(15, 10, bounds) - # below - refute Router.point_in_bounds?(15, 40, bounds) - # bottom-right (exclusive) - refute Router.point_in_bounds?(40, 35, bounds) - end - end - - describe "bounds_overlap?/2" do - test "returns true for overlapping bounds" do - a = %{x: 0, y: 0, width: 20, height: 20} - b = %{x: 10, y: 10, width: 20, height: 20} - - assert Router.bounds_overlap?(a, b) - end - - test "returns false for non-overlapping bounds" do - a = %{x: 0, y: 0, width: 10, height: 10} - b = %{x: 20, y: 20, width: 10, height: 10} - - refute Router.bounds_overlap?(a, b) - end - - test "returns false for adjacent bounds" do - a = %{x: 0, y: 0, width: 10, height: 10} - b = %{x: 10, y: 0, width: 10, height: 10} - - refute Router.bounds_overlap?(a, b) - end + refute Tracker.dragging?(tracker) end - describe "clip_to_bounds/3" do - test "clips coordinates to be within bounds" do - bounds = %{x: 10, y: 20, width: 30, height: 15} - - # Inside - unchanged - assert Router.clip_to_bounds(15, 25, bounds) == {15, 25} - - # Outside left - assert Router.clip_to_bounds(5, 25, bounds) == {10, 25} - - # Outside right - assert Router.clip_to_bounds(50, 25, bounds) == {39, 25} + test "tracker reports hover transitions without a process" do + tracker = Tracker.new() + {tracker, [{:hover_enter, :one}]} = Tracker.hover(tracker, :one) - # Outside above - assert Router.clip_to_bounds(15, 10, bounds) == {15, 20} + {tracker, [{:hover_leave, :one}, {:hover_enter, :two}]} = + Tracker.hover(tracker, :two) - # Outside below - assert Router.clip_to_bounds(15, 50, bounds) == {15, 34} - end + assert Tracker.hovered(tracker) == :two end end diff --git a/test/term_ui/performance_test.exs b/test/term_ui/performance_test.exs deleted file mode 100644 index 84163885..00000000 --- a/test/term_ui/performance_test.exs +++ /dev/null @@ -1,533 +0,0 @@ -defmodule TermUI.PerformanceTest do - # async: false because tests use shared ETS tables - use ExUnit.Case, async: false - - alias TermUI.Layout.Cache - alias TermUI.Layout.Constraint - alias TermUI.Layout.Solver - alias TermUI.Style - alias TermUI.Theme - - # Performance targets (in microseconds) - # 1ms - @layout_solve_target_us 1000 - # 0.1ms - @cache_lookup_target_us 100 - # 0.5ms - @style_resolution_target_us 500 - # 5ms - @frame_target_us 5000 - - # Multiplier for slower CI environments - @ci_multiplier if System.get_env("CI"), do: 3, else: 1 - - # Warmup iterations to stabilize JIT - @warmup_iterations 10 - - # Helper to run with warmup - defp measure_with_warmup(iterations, fun) do - # Warmup runs to stabilize JIT/BEAM - for _ <- 1..@warmup_iterations, do: fun.() - - # Timed run - {time_us, _result} = - :timer.tc(fn -> - for _ <- 1..iterations, do: fun.() - end) - - time_us / iterations - end - - defp adjusted_target(base_target) do - base_target * @ci_multiplier - end - - describe "layout solver performance" do - test "simple constraints solve quickly" do - constraints = [ - Constraint.length(20), - Constraint.fill(), - Constraint.length(20) - ] - - avg_us = - measure_with_warmup(100, fn -> - Solver.solve(constraints, 100) - end) - - target = adjusted_target(@layout_solve_target_us) - - assert avg_us < target, - "Simple solve took #{avg_us}us, target is #{target}us" - end - - test "percentage constraints solve quickly" do - constraints = [ - Constraint.percentage(25), - Constraint.percentage(50), - Constraint.percentage(25) - ] - - avg_us = - measure_with_warmup(100, fn -> - Solver.solve(constraints, 200) - end) - - target = adjusted_target(@layout_solve_target_us) - - assert avg_us < target, - "Percentage solve took #{avg_us}us, target is #{target}us" - end - - test "ratio constraints solve quickly" do - constraints = [ - Constraint.ratio(1), - Constraint.ratio(2), - Constraint.ratio(3) - ] - - avg_us = - measure_with_warmup(100, fn -> - Solver.solve(constraints, 300) - end) - - target = adjusted_target(@layout_solve_target_us) - - assert avg_us < target, - "Ratio solve took #{avg_us}us, target is #{target}us" - end - - test "mixed constraints solve within target" do - constraints = [ - Constraint.length(30), - Constraint.percentage(20) |> Constraint.with_min(15), - Constraint.ratio(1), - Constraint.fill() - ] - - avg_us = - measure_with_warmup(100, fn -> - Solver.solve(constraints, 200) - end) - - target = adjusted_target(@layout_solve_target_us) - - assert avg_us < target, - "Mixed solve took #{avg_us}us, target is #{target}us" - end - - test "10 constraints solve within 2x target" do - constraints = - for i <- 1..10 do - case rem(i, 3) do - 0 -> Constraint.length(10) - 1 -> Constraint.ratio(1) - 2 -> Constraint.percentage(5) - end - end - - avg_us = - measure_with_warmup(100, fn -> - Solver.solve(constraints, 500) - end) - - # Allow 2x for larger constraint sets - target = adjusted_target(@layout_solve_target_us * 2) - - assert avg_us < target, - "10-constraint solve took #{avg_us}us, target is #{target}us" - end - end - - describe "cache performance" do - setup do - # Cache uses singleton ETS table - case Cache.start_link([]) do - {:ok, _} -> :ok - {:error, {:already_started, _}} -> :ok - end - - Cache.clear() - - # Ensure cleanup after test (safely handle if table doesn't exist) - on_exit(fn -> - try do - Cache.clear() - rescue - ArgumentError -> :ok - end - end) - - :ok - end - - test "cache hit is fast" do - constraints = [Constraint.fill()] - area = %{x: 0, y: 0, width: 100, height: 20} - - # Prime the cache - Cache.solve(constraints, area) - - # Measure hits with warmup - avg_us = - measure_with_warmup(1000, fn -> - Cache.solve(constraints, area) - end) - - target = adjusted_target(@cache_lookup_target_us) - - assert avg_us < target, - "Cache hit took #{avg_us}us, target is #{target}us" - end - - test "direct ETS lookup is very fast" do - constraints = [Constraint.fill()] - area = %{x: 0, y: 0, width: 100, height: 20} - - # Prime the cache - Cache.solve(constraints, area) - - # Get the key format from cache_key - key = {constraints, area.width, area.height} - - # Measure direct lookups with warmup - avg_us = - measure_with_warmup(1000, fn -> - Cache.lookup(key) - end) - - # Direct lookup should be even faster - target = adjusted_target(@cache_lookup_target_us / 2) - - assert avg_us < target, - "Direct lookup took #{avg_us}us, target is #{target}us" - end - - test "cache achieves good hit rate" do - Cache.clear() - - constraints = [ - Constraint.ratio(1), - Constraint.ratio(2) - ] - - # Simulate realistic usage: same constraints, different sizes - areas = [ - %{x: 0, y: 0, width: 80, height: 20}, - %{x: 0, y: 0, width: 100, height: 20}, - %{x: 0, y: 0, width: 120, height: 20}, - %{x: 0, y: 0, width: 100, height: 20}, - %{x: 0, y: 0, width: 80, height: 20}, - %{x: 0, y: 0, width: 100, height: 20}, - %{x: 0, y: 0, width: 120, height: 20}, - %{x: 0, y: 0, width: 100, height: 20} - ] - - for area <- areas do - Cache.solve(constraints, area) - end - - stats = Cache.stats() - - # 3 unique sizes, 8 total calls = 5 hits - hit_rate = stats.hits / (stats.hits + stats.misses) * 100 - - assert hit_rate >= 60, - "Cache hit rate #{hit_rate}%, expected >= 60%" - end - end - - describe "style resolution performance" do - test "style creation is fast" do - avg_us = - measure_with_warmup(1000, fn -> - Style.new() - |> Style.fg(:blue) - |> Style.bg(:white) - |> Style.bold() - end) - - target = adjusted_target(@style_resolution_target_us) - - assert avg_us < target, - "Style creation took #{avg_us}us, target is #{target}us" - end - - test "style merge is fast" do - base = Style.new() |> Style.fg(:blue) |> Style.bg(:white) - overlay = Style.new() |> Style.bold() |> Style.underline() - - avg_us = - measure_with_warmup(1000, fn -> - Style.merge(base, overlay) - end) - - target = adjusted_target(@style_resolution_target_us) - - assert avg_us < target, - "Style merge took #{avg_us}us, target is #{target}us" - end - - test "style inheritance chain is fast" do - # Create inheritance chain - styles = [ - Style.new() |> Style.fg(:blue), - Style.new() |> Style.bg(:white), - Style.new() |> Style.bold(), - Style.new() |> Style.fg(:red), - Style.new() |> Style.underline() - ] - - avg_us = - measure_with_warmup(1000, fn -> - Enum.reduce(styles, Style.new(), fn child, parent -> - Style.inherit(child, parent) - end) - end) - - target = adjusted_target(@style_resolution_target_us) - - assert avg_us < target, - "5-level inheritance took #{avg_us}us, target is #{target}us" - end - - test "variant selection is fast" do - variants = - Style.build_variants(%{ - normal: Style.new() |> Style.fg(:white), - focused: Style.new() |> Style.fg(:blue) |> Style.bold(), - disabled: Style.new() |> Style.fg(:bright_black) - }) - - avg_us = - measure_with_warmup(1000, fn -> - Style.get_variant(variants, :focused) - end) - - target = adjusted_target(@style_resolution_target_us / 5) - - assert avg_us < target, - "Variant selection took #{avg_us}us, target is #{target}us" - end - end - - describe "theme performance" do - setup do - name = :"perf_theme_#{:erlang.unique_integer([:positive])}" - {:ok, _} = Theme.start_link(name: name, theme: :dark) - %{server: name} - end - - test "theme access via ETS is fast", %{server: server} do - avg_us = - measure_with_warmup(1000, fn -> - Theme.get_theme(server) - end) - - target = adjusted_target(@cache_lookup_target_us) - - assert avg_us < target, - "Theme access took #{avg_us}us, target is #{target}us" - end - - test "color lookup is fast", %{server: server} do - avg_us = - measure_with_warmup(1000, fn -> - Theme.get_color(:primary, server) - end) - - target = adjusted_target(@cache_lookup_target_us) - - assert avg_us < target, - "Color lookup took #{avg_us}us, target is #{target}us" - end - - test "component style access is fast", %{server: server} do - avg_us = - measure_with_warmup(1000, fn -> - Theme.get_component_style(:button, :focused, server) - end) - - target = adjusted_target(@cache_lookup_target_us * 2) - - assert avg_us < target, - "Component style took #{avg_us}us, target is #{target}us" - end - end - - describe "full frame simulation" do - setup do - # Cache uses singleton - case Cache.start_link([]) do - {:ok, _} -> :ok - {:error, {:already_started, _}} -> :ok - end - - Cache.clear() - - theme_name = :"frame_theme_#{:erlang.unique_integer([:positive])}" - {:ok, _} = Theme.start_link(name: theme_name, theme: :dark) - - # Ensure cleanup after test (safely handle if table doesn't exist) - on_exit(fn -> - try do - Cache.clear() - rescue - ArgumentError -> :ok - end - end) - - %{theme: theme_name} - end - - test "typical frame completes within target", %{theme: theme} do - # Simulate a typical frame: - # 1. Solve layout (3 panels) - # 2. Resolve styles for 10 components - # 3. Get theme colors - - constraints = [ - Constraint.ratio(1), - Constraint.ratio(3), - Constraint.length(30) - ] - - area = %{x: 0, y: 0, width: 150, height: 40} - - # Warmup - for _ <- 1..@warmup_iterations do - _layout = Cache.solve(constraints, area) - base_style = Style.new() |> Style.fg(:white) |> Style.bg(:black) - - for _ <- 1..10 do - component_style = Style.new() |> Style.bold() - _effective = Style.inherit(component_style, base_style) - _color = Theme.get_color(:primary, theme) - end - end - - {time_us, _result} = - :timer.tc(fn -> - # Layout solve - _layout = Cache.solve(constraints, area) - - # Style resolution for components - base_style = Style.new() |> Style.fg(:white) |> Style.bg(:black) - - for _ <- 1..10 do - component_style = Style.new() |> Style.bold() - _effective = Style.inherit(component_style, base_style) - _color = Theme.get_color(:primary, theme) - end - end) - - target = adjusted_target(@frame_target_us) - - assert time_us < target, - "Frame took #{time_us}us, target is #{target}us" - end - - test "complex frame still completes within 2x target", %{theme: theme} do - # More complex frame: - # - Nested layouts - # - Deep style inheritance - # - Multiple theme lookups - - outer_constraints = [Constraint.ratio(1), Constraint.ratio(2)] - inner_constraints = [Constraint.length(5), Constraint.fill(), Constraint.length(3)] - outer_area = %{x: 0, y: 0, width: 120, height: 30} - inner_area = %{x: 0, y: 0, width: 80, height: 30} - - # Warmup - for _ <- 1..@warmup_iterations do - _outer = Cache.solve(outer_constraints, outer_area) - _inner = Cache.solve(inner_constraints, inner_area) - - styles = [ - Style.new() |> Style.fg(:blue), - Style.new() |> Style.bg(:white), - Style.new() |> Style.bold(), - Style.new() |> Style.underline() - ] - - Enum.reduce(styles, Style.new(), fn child, parent -> - Style.inherit(child, parent) - end) - - for color <- [:background, :foreground, :primary, :secondary] do - Theme.get_color(color, theme) - end - - for semantic <- [:success, :warning, :error] do - Theme.get_semantic(semantic, theme) - end - end - - {time_us, _result} = - :timer.tc(fn -> - # Nested layout - _outer = Cache.solve(outer_constraints, outer_area) - _inner = Cache.solve(inner_constraints, inner_area) - - # Deep style chain - styles = [ - Style.new() |> Style.fg(:blue), - Style.new() |> Style.bg(:white), - Style.new() |> Style.bold(), - Style.new() |> Style.underline() - ] - - _final = - Enum.reduce(styles, Style.new(), fn child, parent -> - Style.inherit(child, parent) - end) - - # Theme lookups - for color <- [:background, :foreground, :primary, :secondary] do - Theme.get_color(color, theme) - end - - for semantic <- [:success, :warning, :error] do - Theme.get_semantic(semantic, theme) - end - end) - - target = adjusted_target(@frame_target_us * 2) - - assert time_us < target, - "Complex frame took #{time_us}us, target is #{target}us" - end - end - - describe "scalability" do - test "solver scales linearly with constraints" do - times = - for n <- [5, 10, 20] do - constraints = - for _ <- 1..n do - Constraint.ratio(1) - end - - avg_us = - measure_with_warmup(100, fn -> - Solver.solve(constraints, n * 50) - end) - - {n, avg_us} - end - - # Check roughly linear scaling - [{n1, t1}, {n2, t2}, {n3, t3}] = times - - # Ratio of time to constraints should be similar - ratio1 = t1 / n1 - ratio2 = t2 / n2 - ratio3 = t3 / n3 - - # Allow 3x variance for linear scaling (adjusted for CI) - multiplier = 3 * @ci_multiplier - assert ratio2 < ratio1 * multiplier, "Scaling not linear: #{n1}->#{n2}" - assert ratio3 < ratio2 * multiplier, "Scaling not linear: #{n2}->#{n3}" - end - end -end diff --git a/test/term_ui/persistent_terms_test.exs b/test/term_ui/persistent_terms_test.exs deleted file mode 100644 index 42b4cd85..00000000 --- a/test/term_ui/persistent_terms_test.exs +++ /dev/null @@ -1,212 +0,0 @@ -defmodule TermUI.PersistentTermsTest do - use ExUnit.Case, async: false - - alias TermUI.PersistentTerms - - describe "store_backend_context/2" do - test "stores backend mode" do - # Clean up before test - PersistentTerms.cleanup() - - PersistentTerms.store_backend_context(:raw, nil) - assert PersistentTerms.backend_mode() == :raw - - PersistentTerms.store_backend_context(:tty, %{colors: :true_color}) - assert PersistentTerms.backend_mode() == :tty - - # Clean up after test - PersistentTerms.cleanup() - end - - test "stores capabilities" do - PersistentTerms.cleanup() - - capabilities = %{colors: :true_color, unicode: true, dimensions: {24, 80}} - PersistentTerms.store_backend_context(:tty, capabilities) - - assert PersistentTerms.capabilities() == capabilities - - PersistentTerms.cleanup() - end - - test "detects capabilities when backend is :raw" do - PersistentTerms.cleanup() - - # When raw mode is used, capabilities should still be detected - PersistentTerms.store_backend_context(:raw, nil) - - caps = PersistentTerms.capabilities() - assert is_map(caps) - # Should have detected some capabilities - assert Map.has_key?(caps, :colors) or Map.has_key?(caps, :unicode) - - PersistentTerms.cleanup() - end - - test "sets character set based on capabilities" do - PersistentTerms.cleanup() - - # Unicode supported - PersistentTerms.store_backend_context(:tty, %{unicode: true}) - assert PersistentTerms.character_set() == :unicode - - # Unicode not supported - PersistentTerms.store_backend_context(:tty, %{unicode: false}) - assert PersistentTerms.character_set() == :ascii - - # No capabilities info - defaults to unicode - PersistentTerms.store_backend_context(:tty, nil) - assert PersistentTerms.character_set() == :unicode - - PersistentTerms.cleanup() - end - end - - describe "backend_mode/0" do - test "returns nil when not set" do - PersistentTerms.cleanup() - assert PersistentTerms.backend_mode() == nil - end - - test "returns stored backend mode" do - PersistentTerms.cleanup() - :persistent_term.put(:term_ui_backend_mode, :raw) - assert PersistentTerms.backend_mode() == :raw - PersistentTerms.cleanup() - end - end - - describe "capabilities/0" do - test "returns nil when not set" do - PersistentTerms.cleanup() - assert PersistentTerms.capabilities() == nil - end - - test "returns stored capabilities" do - PersistentTerms.cleanup() - caps = %{colors: :true_color, unicode: true} - :persistent_term.put(:term_ui_capabilities, caps) - assert PersistentTerms.capabilities() == caps - PersistentTerms.cleanup() - end - end - - describe "character_set/0" do - test "falls back to application config when not set" do - PersistentTerms.cleanup() - - # Set application config - Application.put_env(:term_ui, :character_set, :ascii) - - assert PersistentTerms.character_set() == :ascii - - # Clean up - Application.delete_env(:term_ui, :character_set) - end - - test "returns stored character set" do - PersistentTerms.cleanup() - :persistent_term.put(:term_ui_character_set, :unicode) - assert PersistentTerms.character_set() == :unicode - PersistentTerms.cleanup() - end - - test "defaults to unicode when neither persistent_term nor config is set" do - PersistentTerms.cleanup() - Application.delete_env(:term_ui, :character_set) - - assert PersistentTerms.character_set() == :unicode - end - end - - describe "cleanup/0" do - test "removes all persistent terms" do - # Set up all terms - :persistent_term.put(:term_ui_backend_mode, :raw) - :persistent_term.put(:term_ui_capabilities, %{colors: :true_color}) - :persistent_term.put(:term_ui_character_set, :unicode) - - # Verify they're set - assert :persistent_term.get(:term_ui_backend_mode, :not_set) == :raw - assert :persistent_term.get(:term_ui_capabilities, :not_set) == %{colors: :true_color} - assert :persistent_term.get(:term_ui_character_set, :not_set) == :unicode - - # Clean up - PersistentTerms.cleanup() - - # Verify they're gone (using default to avoid exception) - assert :persistent_term.get(:term_ui_backend_mode, :gone) == :gone - assert :persistent_term.get(:term_ui_capabilities, :gone) == :gone - assert :persistent_term.get(:term_ui_character_set, :gone) == :gone - end - - test "does not crash when called multiple times" do - PersistentTerms.cleanup() - assert :ok = PersistentTerms.cleanup() - assert :ok = PersistentTerms.cleanup() - end - end - - describe "any_terms?/0" do - test "returns false when no terms are set" do - PersistentTerms.cleanup() - refute PersistentTerms.any_terms?() - end - - test "returns true when any term is set" do - PersistentTerms.cleanup() - - refute PersistentTerms.any_terms?() - - :persistent_term.put(:term_ui_backend_mode, :raw) - assert PersistentTerms.any_terms?() - - PersistentTerms.cleanup() - end - - test "returns false after cleanup" do - :persistent_term.put(:term_ui_backend_mode, :raw) - assert PersistentTerms.any_terms?() - - PersistentTerms.cleanup() - refute PersistentTerms.any_terms?() - end - end - - describe "integration with Runtime" do - test "cleanup is called when Runtime terminates" do - # Set up terms before starting Runtime - :persistent_term.put(:term_ui_backend_mode, :raw) - :persistent_term.put(:term_ui_capabilities, %{colors: :true_color}) - :persistent_term.put(:term_ui_character_set, :unicode) - - # Start a Runtime (with skip_terminal to avoid terminal setup in tests) - # We don't name it so we can control its lifecycle - {:ok, pid} = - TermUI.Runtime.start_link( - root: TermUI.Test.Components.Counter, - skip_terminal: true - ) - - # Runtime should have overwritten the terms during init - assert :persistent_term.get(:term_ui_backend_mode, :not_set) == :skip - assert PersistentTerms.any_terms?() - - # Monitor the process and stop it - ref = Process.monitor(pid) - GenServer.stop(pid) - - # Wait for terminate/2 to complete - assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, 500 - - # Give a small additional delay for cleanup - Process.sleep(50) - - # Terms should be cleaned up - refute PersistentTerms.any_terms?() - assert :persistent_term.get(:term_ui_backend_mode, :gone) == :gone - assert :persistent_term.get(:term_ui_capabilities, :gone) == :gone - assert :persistent_term.get(:term_ui_character_set, :gone) == :gone - end - end -end diff --git a/test/term_ui/platform_test.exs b/test/term_ui/platform_test.exs deleted file mode 100644 index 31e1df69..00000000 --- a/test/term_ui/platform_test.exs +++ /dev/null @@ -1,410 +0,0 @@ -defmodule TermUI.PlatformTest do - use ExUnit.Case, async: true - - alias TermUI.Platform - - describe "platform/0" do - test "returns a valid platform atom" do - platform = Platform.platform() - assert platform in [:linux, :macos, :windows, :freebsd, :unknown] - end - - test "returns consistent results" do - platform1 = Platform.platform() - platform2 = Platform.platform() - assert platform1 == platform2 - end - end - - describe "os_version/0" do - test "returns a version tuple or nil" do - version = Platform.os_version() - - case version do - {major, minor, patch} -> - assert is_integer(major) and major >= 0 - assert is_integer(minor) and minor >= 0 - assert is_integer(patch) and patch >= 0 - - nil -> - assert true - end - end - end - - describe "unix?/0" do - test "returns boolean" do - result = Platform.unix?() - assert is_boolean(result) - end - - test "is true for Unix platforms" do - if Platform.platform() in [:linux, :macos, :freebsd] do - assert Platform.unix?() == true - end - end - - test "is false for Windows" do - if Platform.platform() == :windows do - assert Platform.unix?() == false - end - end - end - - describe "windows?/0" do - test "returns boolean" do - result = Platform.windows?() - assert is_boolean(result) - end - - test "is true only for Windows" do - if Platform.platform() == :windows do - assert Platform.windows?() == true - else - assert Platform.windows?() == false - end - end - end - - describe "wsl?/0" do - test "returns boolean" do - result = Platform.wsl?() - assert is_boolean(result) - end - - test "is always false on non-Linux platforms" do - if Platform.platform() != :linux do - assert Platform.wsl?() == false - end - end - end - - describe "macos?/0" do - test "returns boolean" do - result = Platform.macos?() - assert is_boolean(result) - end - - test "matches platform detection" do - assert Platform.macos?() == (Platform.platform() == :macos) - end - end - - describe "linux?/0" do - test "returns boolean" do - result = Platform.linux?() - assert is_boolean(result) - end - - test "is false if WSL" do - if Platform.wsl?() do - assert Platform.linux?() == false - end - end - end - - describe "terminal_size/0" do - test "returns tuple of positive integers" do - {rows, cols} = Platform.terminal_size() - - assert is_integer(rows) and rows > 0 - assert is_integer(cols) and cols > 0 - end - - test "returns reasonable default values" do - {rows, cols} = Platform.terminal_size() - - # Should be at least default size - assert rows >= 1 - assert cols >= 1 - end - end - - describe "supports_feature?/1" do - test "returns boolean for valid features" do - features = [:signals, :pty, :terminfo, :vt_sequences] - - for feature <- features do - result = Platform.supports_feature?(feature) - assert is_boolean(result), "Expected boolean for #{feature}, got #{inspect(result)}" - end - end - - test "returns false for unknown features" do - assert Platform.supports_feature?(:unknown_feature) == false - assert Platform.supports_feature?(:nonexistent) == false - end - - test "Unix platforms support all standard features" do - if Platform.unix?() do - assert Platform.supports_feature?(:signals) == true - assert Platform.supports_feature?(:pty) == true - assert Platform.supports_feature?(:terminfo) == true - assert Platform.supports_feature?(:vt_sequences) == true - end - end - - test "Windows supports VT sequences" do - if Platform.windows?() do - assert Platform.supports_feature?(:vt_sequences) == true - assert Platform.supports_feature?(:signals) == false - assert Platform.supports_feature?(:pty) == false - assert Platform.supports_feature?(:terminfo) == false - end - end - end - - describe "info/0" do - test "returns map with all expected keys" do - info = Platform.info() - - assert is_map(info) - assert Map.has_key?(info, :platform) - assert Map.has_key?(info, :os_version) - assert Map.has_key?(info, :unix) - assert Map.has_key?(info, :windows) - assert Map.has_key?(info, :wsl) - assert Map.has_key?(info, :terminal_size) - end - - test "info values are consistent with individual functions" do - info = Platform.info() - - assert info.platform == Platform.platform() - assert info.os_version == Platform.os_version() - assert info.unix == Platform.unix?() - assert info.windows == Platform.windows?() - assert info.wsl == Platform.wsl?() - assert info.terminal_size == Platform.terminal_size() - end - end -end - -defmodule TermUI.Platform.UnixTest do - use ExUnit.Case, async: true - - alias TermUI.Platform - alias TermUI.Platform.Unix - - # Only run these tests on Unix platforms - @moduletag :unix - - setup do - if Platform.unix?() do - :ok - else - {:skip, "Unix-only tests"} - end - end - - describe "info/0" do - test "returns map with expected keys" do - info = Unix.info() - - assert is_map(info) - assert Map.has_key?(info, :platform) - assert Map.has_key?(info, :kernel_version) - assert Map.has_key?(info, :terminfo_paths) - assert Map.has_key?(info, :supports_signals) - assert Map.has_key?(info, :supports_pty) - end - - test "supports_signals is true" do - info = Unix.info() - assert info.supports_signals == true - end - - test "supports_pty is true" do - info = Unix.info() - assert info.supports_pty == true - end - end - - describe "detect_unix_variant/0" do - test "returns valid Unix variant" do - variant = Unix.detect_unix_variant() - assert variant in [:linux, :macos, :freebsd, :unknown] - end - end - - describe "kernel_version/0" do - test "returns version string or nil" do - version = Unix.kernel_version() - - case version do - nil -> assert true - str -> assert is_binary(str) - end - end - end - - describe "terminfo_paths/0" do - test "returns list of paths" do - paths = Unix.terminfo_paths() - - assert is_list(paths) - assert length(paths) > 0 - - for path <- paths do - assert is_binary(path) - end - end - - test "includes standard paths" do - paths = Unix.terminfo_paths() - - # At least one standard path should be included - standard_paths = [ - "/usr/share/terminfo", - "/usr/lib/terminfo", - "/lib/terminfo" - ] - - assert Enum.any?(paths, fn path -> path in standard_paths end) - end - end - - describe "capability_hints/0" do - test "returns map with capability hints" do - hints = Unix.capability_hints() - - assert is_map(hints) - assert Map.has_key?(hints, :supports_mouse) - assert Map.has_key?(hints, :supports_bracketed_paste) - assert Map.has_key?(hints, :supports_focus_events) - assert Map.has_key?(hints, :supports_alternate_screen) - end - - test "all capabilities are true for Unix" do - hints = Unix.capability_hints() - - assert hints.supports_mouse == true - assert hints.supports_bracketed_paste == true - assert hints.supports_focus_events == true - assert hints.supports_alternate_screen == true - end - end - - describe "supported_signals/0" do - test "returns list of signal atoms" do - signals = Unix.supported_signals() - - assert is_list(signals) - assert :sigwinch in signals - assert :sigterm in signals - assert :sigint in signals - end - end - - describe "signal_available?/1" do - test "returns true for supported signals" do - assert Unix.signal_available?(:sigwinch) == true - assert Unix.signal_available?(:sigterm) == true - assert Unix.signal_available?(:sigint) == true - end - - test "returns false for unsupported signals" do - assert Unix.signal_available?(:unknown_signal) == false - end - end -end - -defmodule TermUI.Platform.WindowsTest do - use ExUnit.Case, async: true - - alias TermUI.Platform.Windows - - describe "info/0" do - test "returns map with expected keys" do - info = Windows.info() - - assert is_map(info) - assert Map.has_key?(info, :platform) - assert Map.has_key?(info, :implementation_status) - assert info.platform == :windows - assert info.implementation_status == :stub - end - - test "indicates stub status" do - info = Windows.info() - assert info.notes =~ "NIF" - end - end - - describe "vt_support_available?/0" do - test "returns boolean" do - result = Windows.vt_support_available?() - assert is_boolean(result) - end - end - - describe "windows_version/0" do - test "returns version tuple or nil" do - version = Windows.windows_version() - - case version do - {major, minor, build} -> - assert is_integer(major) - assert is_integer(minor) - assert is_integer(build) - - nil -> - assert true - end - end - end - - describe "enable_vt_processing/0" do - test "returns ok tuple or error" do - result = Windows.enable_vt_processing() - - case result do - {:ok, :stub} -> assert true - {:error, msg} -> assert is_binary(msg) - end - end - end - - describe "disable_vt_processing/0" do - test "returns :ok" do - assert Windows.disable_vt_processing() == :ok - end - end - - describe "capability_hints/0" do - test "returns map with capability hints" do - hints = Windows.capability_hints() - - assert is_map(hints) - assert Map.has_key?(hints, :supports_mouse) - assert Map.has_key?(hints, :supports_bracketed_paste) - assert Map.has_key?(hints, :requires_vt_mode) - end - end - - describe "terminal_size/0" do - test "returns tuple of positive integers" do - {rows, cols} = Windows.terminal_size() - - assert is_integer(rows) and rows > 0 - assert is_integer(cols) and cols > 0 - end - end - - describe "minimum_version/0" do - test "returns Windows 10 version tuple" do - {major, minor, build} = Windows.minimum_version() - - assert major == 10 - assert minor == 0 - assert build == 10_586 - end - end - - describe "meets_minimum_version?/0" do - test "returns boolean" do - result = Windows.meets_minimum_version?() - assert is_boolean(result) - end - end -end diff --git a/test/term_ui/public_contract_test.exs b/test/term_ui/public_contract_test.exs new file mode 100644 index 00000000..fddcf384 --- /dev/null +++ b/test/term_ui/public_contract_test.exs @@ -0,0 +1,36 @@ +defmodule TermUI.PublicContractTest do + use ExUnit.Case, async: true + + alias TermUI.{Command, Elm, Event, Frame} + + test "commands are effect data without component identities" do + assert %Command{kind: :message, value: :next} = Command.message(:next) + assert %Command{kind: :timer, value: {10, :tick}} = Command.timer(10, :tick) + assert %Command{kind: :shutdown, value: :normal} = Command.shutdown() + + command = Command.async(fn -> 1 end, &{:done, &1}) + refute Map.has_key?(command, :component_id) + assert command.kind == :async + end + + test "events separate text from named and modified keys" do + assert %Event.Text{text: "界"} = Event.text("界", timestamp: 1) + assert %Event.Key{key: :enter, modifiers: []} = Event.key(:enter, timestamp: 1) + assert %Event.Key{key: "c", modifiers: [:ctrl]} = Event.key("c", modifiers: [:ctrl, :ctrl]) + assert %Event.Paste{content: "a\nb"} = Event.paste("a\nb") + assert %Event.Mouse{action: :press, x: 2, y: 3} = Event.mouse(:press, :left, 2, 3) + assert %Event.Resize{width: 80, height: 24} = Event.resize(80, 24) + assert %Event.Focus{action: :gained} = Event.focus(:gained) + end + + test "Elm normalization keeps state and command lists explicit" do + frame = Frame.from_rows(["ok"], 2, 1) + shutdown = Command.shutdown() + assert {%{value: 1}, []} = Elm.normalize_init_result(%{value: 1}) + + assert {%{value: 2}, [^shutdown]} = + Elm.normalize_update_result({%{value: 2}, [shutdown]}, %{}) + + assert %Frame{} = frame + end +end diff --git a/test/term_ui/renderer/buffer_manager_test.exs b/test/term_ui/renderer/buffer_manager_test.exs deleted file mode 100644 index 6a0076c2..00000000 --- a/test/term_ui/renderer/buffer_manager_test.exs +++ /dev/null @@ -1,493 +0,0 @@ -defmodule TermUI.Renderer.BufferManagerTest do - use ExUnit.Case, async: true - - alias TermUI.Renderer.Buffer - alias TermUI.Renderer.BufferManager - alias TermUI.Renderer.Cell - - describe "start_link/1" do - test "creates manager with specified dimensions" do - {:ok, pid} = BufferManager.start_link(rows: 10, cols: 20, name: :test_manager_1) - assert is_pid(pid) - assert Process.alive?(pid) - - assert {10, 20} = BufferManager.dimensions(:test_manager_1) - - GenServer.stop(pid) - end - - test "requires rows and cols options" do - Process.flag(:trap_exit, true) - - assert {:error, _} = BufferManager.start_link(rows: 10, name: :test_missing_cols) - assert {:error, _} = BufferManager.start_link(cols: 20, name: :test_missing_rows) - - Process.flag(:trap_exit, false) - end - - test "supports independent unnamed managers" do - {:ok, first} = BufferManager.start_link(rows: 2, cols: 3, name: nil) - {:ok, second} = BufferManager.start_link(rows: 4, cols: 5, name: nil) - - assert BufferManager.dimensions(first) == {2, 3} - assert BufferManager.dimensions(second) == {4, 5} - - GenServer.stop(first) - GenServer.stop(second) - end - end - - describe "get_current_buffer/1" do - setup do - {:ok, pid} = BufferManager.start_link(rows: 5, cols: 10, name: :test_current) - on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end) - %{server: :test_current} - end - - test "returns valid buffer", %{server: server} do - buffer = BufferManager.get_current_buffer(server) - assert %Buffer{} = buffer - assert {5, 10} = Buffer.dimensions(buffer) - end - - test "buffer is writable", %{server: server} do - buffer = BufferManager.get_current_buffer(server) - cell = Cell.new("X", fg: :red) - assert :ok = Buffer.set_cell(buffer, 1, 1, cell) - - retrieved = Buffer.get_cell(buffer, 1, 1) - assert retrieved.char == "X" - assert retrieved.fg == :red - end - end - - describe "get_previous_buffer/1" do - setup do - {:ok, pid} = BufferManager.start_link(rows: 5, cols: 10, name: :test_previous) - on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end) - %{server: :test_previous} - end - - test "returns valid buffer", %{server: server} do - buffer = BufferManager.get_previous_buffer(server) - assert %Buffer{} = buffer - assert {5, 10} = Buffer.dimensions(buffer) - end - - test "previous buffer is different from current", %{server: server} do - current = BufferManager.get_current_buffer(server) - previous = BufferManager.get_previous_buffer(server) - - # Different ETS tables - refute current.table == previous.table - end - end - - describe "swap_buffers/1" do - setup do - {:ok, pid} = BufferManager.start_link(rows: 5, cols: 10, name: :test_swap) - on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end) - %{server: :test_swap} - end - - test "exchanges current and previous buffers", %{server: server} do - current_before = BufferManager.get_current_buffer(server) - previous_before = BufferManager.get_previous_buffer(server) - - # Write to current - cell = Cell.new("A") - Buffer.set_cell(current_before, 1, 1, cell) - - # Swap - assert :ok = BufferManager.swap_buffers(server) - - current_after = BufferManager.get_current_buffer(server) - previous_after = BufferManager.get_previous_buffer(server) - - # Current should now be what was previous - assert current_after.table == previous_before.table - # Previous should now be what was current (with our "A") - assert previous_after.table == current_before.table - - # Verify content moved - assert Buffer.get_cell(previous_after, 1, 1).char == "A" - assert Buffer.get_cell(current_after, 1, 1).char == " " - end - - test "swap is reversible", %{server: server} do - current_original = BufferManager.get_current_buffer(server) - - BufferManager.swap_buffers(server) - BufferManager.swap_buffers(server) - - current_after = BufferManager.get_current_buffer(server) - assert current_after.table == current_original.table - end - end - - describe "dimensions/1" do - test "returns buffer dimensions" do - {:ok, pid} = BufferManager.start_link(rows: 24, cols: 80, name: :test_dims) - on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end) - - assert {24, 80} = BufferManager.dimensions(:test_dims) - end - end - - describe "resize/3" do - setup do - {:ok, pid} = BufferManager.start_link(rows: 10, cols: 10, name: :test_resize) - on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end) - %{server: :test_resize} - end - - test "updates dimensions", %{server: server} do - assert :ok = BufferManager.resize(server, 20, 30) - assert {20, 30} = BufferManager.dimensions(server) - end - - test "preserves content within new dimensions", %{server: server} do - buffer = BufferManager.get_current_buffer(server) - cell = Cell.new("X", fg: :blue) - Buffer.set_cell(buffer, 5, 5, cell) - - BufferManager.resize(server, 20, 30) - - new_buffer = BufferManager.get_current_buffer(server) - retrieved = Buffer.get_cell(new_buffer, 5, 5) - assert retrieved.char == "X" - assert retrieved.fg == :blue - end - - test "clips content outside new dimensions", %{server: server} do - buffer = BufferManager.get_current_buffer(server) - cell = Cell.new("X") - Buffer.set_cell(buffer, 8, 8, cell) - - BufferManager.resize(server, 5, 5) - - new_buffer = BufferManager.get_current_buffer(server) - # Cell at 8,8 should not exist in 5x5 buffer - retrieved = Buffer.get_cell(new_buffer, 8, 8) - # Out of bounds returns empty - assert retrieved.char == " " - end - - test "resizes both buffers", %{server: server} do - BufferManager.resize(server, 15, 25) - - current = BufferManager.get_current_buffer(server) - previous = BufferManager.get_previous_buffer(server) - - assert {15, 25} = Buffer.dimensions(current) - assert {15, 25} = Buffer.dimensions(previous) - end - end - - describe "clear operations" do - setup do - {:ok, pid} = BufferManager.start_link(rows: 10, cols: 10, name: :test_clear) - on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end) - - # Write some content - buffer = BufferManager.get_current_buffer(:test_clear) - - for row <- 1..5, col <- 1..5 do - Buffer.set_cell(buffer, row, col, Cell.new("X")) - end - - %{server: :test_clear} - end - - test "clear_current/1 clears entire buffer", %{server: server} do - BufferManager.clear_current(server) - - buffer = BufferManager.get_current_buffer(server) - - for row <- 1..10, col <- 1..10 do - cell = Buffer.get_cell(buffer, row, col) - assert cell.char == " " - end - end - - test "clear_row/2 clears single row", %{server: server} do - BufferManager.clear_row(server, 3) - - buffer = BufferManager.get_current_buffer(server) - - # Row 3 should be empty - for col <- 1..10 do - assert Buffer.get_cell(buffer, 3, col).char == " " - end - - # Row 2 should still have content - assert Buffer.get_cell(buffer, 2, 1).char == "X" - end - - test "clear_region/5 clears rectangular area", %{server: server} do - BufferManager.clear_region(server, 2, 2, 3, 3) - - buffer = BufferManager.get_current_buffer(server) - - # Region 2-4, 2-4 should be clear - for row <- 2..4, col <- 2..4 do - assert Buffer.get_cell(buffer, row, col).char == " " - end - - # Outside region should have content - assert Buffer.get_cell(buffer, 1, 1).char == "X" - assert Buffer.get_cell(buffer, 5, 5).char == "X" - end - end - - describe "dirty flag" do - setup do - {:ok, pid} = BufferManager.start_link(rows: 5, cols: 5, name: :test_dirty) - on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end) - %{server: :test_dirty} - end - - test "starts not dirty", %{server: server} do - refute BufferManager.dirty?(server) - end - - test "mark_dirty/1 sets flag", %{server: server} do - BufferManager.mark_dirty(server) - assert BufferManager.dirty?(server) - end - - test "clear_dirty/1 clears flag", %{server: server} do - BufferManager.mark_dirty(server) - BufferManager.clear_dirty(server) - refute BufferManager.dirty?(server) - end - - test "dirty flag persists across multiple checks", %{server: server} do - BufferManager.mark_dirty(server) - assert BufferManager.dirty?(server) - assert BufferManager.dirty?(server) - assert BufferManager.dirty?(server) - end - end - - describe "convenience functions" do - setup do - {:ok, pid} = BufferManager.start_link(rows: 10, cols: 20, name: :test_convenience) - on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end) - %{server: :test_convenience} - end - - test "set_cell/4 sets cell in current buffer", %{server: server} do - cell = Cell.new("Y", fg: :green) - assert :ok = BufferManager.set_cell(server, 3, 5, cell) - - retrieved = BufferManager.get_cell(server, 3, 5) - assert retrieved.char == "Y" - assert retrieved.fg == :green - end - - test "set_cells/2 sets multiple cells", %{server: server} do - cells = [ - {1, 1, Cell.new("A")}, - {1, 2, Cell.new("B")}, - {1, 3, Cell.new("C")} - ] - - assert :ok = BufferManager.set_cells(server, cells) - - assert BufferManager.get_cell(server, 1, 1).char == "A" - assert BufferManager.get_cell(server, 1, 2).char == "B" - assert BufferManager.get_cell(server, 1, 3).char == "C" - end - - test "get_cell/3 gets cell from current buffer", %{server: server} do - cell = BufferManager.get_cell(server, 1, 1) - assert cell.char == " " - assert cell.fg == :default - end - - test "write_string/5 writes string to current buffer", %{server: server} do - written = BufferManager.write_string(server, 2, 3, "Hello") - assert written == 5 - - assert BufferManager.get_cell(server, 2, 3).char == "H" - assert BufferManager.get_cell(server, 2, 4).char == "e" - assert BufferManager.get_cell(server, 2, 5).char == "l" - assert BufferManager.get_cell(server, 2, 6).char == "l" - assert BufferManager.get_cell(server, 2, 7).char == "o" - end - end - - describe "concurrent writes" do - setup do - {:ok, pid} = BufferManager.start_link(rows: 100, cols: 100, name: :test_concurrent) - on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end) - %{server: :test_concurrent} - end - - test "multiple processes can write to buffer concurrently", %{server: server} do - buffer = BufferManager.get_current_buffer(server) - - # Spawn 10 processes, each writing to different rows - tasks = - for i <- 1..10 do - Task.async(fn -> - row = i * 5 - - for col <- 1..50 do - cell = Cell.new("#{rem(i, 10)}") - Buffer.set_cell(buffer, row, col, cell) - end - end) - end - - # Wait for all tasks - Task.await_many(tasks) - - # Verify each row has correct content - for i <- 1..10 do - row = i * 5 - expected_char = "#{rem(i, 10)}" - - for col <- 1..50 do - cell = Buffer.get_cell(buffer, row, col) - assert cell.char == expected_char - end - end - end - - test "overlapping writes don't corrupt buffer", %{server: server} do - buffer = BufferManager.get_current_buffer(server) - - # Multiple processes write to same cell - tasks = - for i <- 1..100 do - Task.async(fn -> - cell = Cell.new("#{rem(i, 10)}") - Buffer.set_cell(buffer, 1, 1, cell) - end) - end - - Task.await_many(tasks) - - # Cell should have a valid value (one of the writes) - cell = Buffer.get_cell(buffer, 1, 1) - assert cell.char in Enum.map(0..9, &Integer.to_string/1) - end - end - - describe "termination cleanup" do - test "cleans up ETS tables on stop" do - {:ok, pid} = BufferManager.start_link(rows: 5, cols: 5, name: :test_cleanup) - - current = BufferManager.get_current_buffer(:test_cleanup) - previous = BufferManager.get_previous_buffer(:test_cleanup) - - current_table = current.table - previous_table = previous.table - - # Verify tables exist - assert :ets.info(current_table) != :undefined - assert :ets.info(previous_table) != :undefined - - # Stop the manager - GenServer.stop(pid) - - # Tables should be deleted - assert :ets.info(current_table) == :undefined - assert :ets.info(previous_table) == :undefined - end - - test "cleans up on crash" do - # Start in a separate process so we can kill it without killing the test - test_pid = self() - - spawn(fn -> - {:ok, pid} = BufferManager.start_link(rows: 5, cols: 5, name: :test_crash_cleanup) - - current = BufferManager.get_current_buffer(:test_crash_cleanup) - previous = BufferManager.get_previous_buffer(:test_crash_cleanup) - - send(test_pid, {:tables, current.table, previous.table, pid}) - - # Keep alive until killed - receive do - :stop -> :ok - end - end) - - # Get table references - {current_table, previous_table, manager_pid} = - receive do - {:tables, c, p, pid} -> {c, p, pid} - after - 1000 -> flunk("Timeout waiting for tables") - end - - # Verify tables exist - assert :ets.info(current_table) != :undefined - assert :ets.info(previous_table) != :undefined - - # Kill the manager process - Process.exit(manager_pid, :kill) - - # Give it a moment to clean up - Process.sleep(10) - - # Tables should be deleted (ETS tables are owned by the process) - assert :ets.info(current_table) == :undefined - assert :ets.info(previous_table) == :undefined - end - end - - describe "integration scenarios" do - setup do - {:ok, pid} = BufferManager.start_link(rows: 24, cols: 80, name: :test_integration) - on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end) - %{server: :test_integration} - end - - test "typical render cycle", %{server: server} do - # 1. Get current buffer and write content - buffer = BufferManager.get_current_buffer(server) - Buffer.write_string(buffer, 1, 1, "Hello World") - BufferManager.mark_dirty(server) - - # 2. Check dirty and render - assert BufferManager.dirty?(server) - _current = BufferManager.get_current_buffer(server) - _previous = BufferManager.get_previous_buffer(server) - # ... diff and render would happen here ... - - # 3. Swap buffers and clear dirty - BufferManager.swap_buffers(server) - BufferManager.clear_dirty(server) - - # 4. Next frame - previous now has "Hello World" - previous = BufferManager.get_previous_buffer(server) - assert Buffer.get_cell(previous, 1, 1).char == "H" - - # 5. Current is now empty (ready for next frame) - current = BufferManager.get_current_buffer(server) - assert Buffer.get_cell(current, 1, 1).char == " " - end - - test "resize during usage", %{server: server} do - # Write content - BufferManager.write_string(server, 10, 10, "Test") - - # Resize smaller - BufferManager.resize(server, 8, 8) - - # Content outside new bounds is lost - assert BufferManager.get_cell(server, 10, 10).char == " " - - # Resize larger - BufferManager.resize(server, 30, 100) - - # Write in new area - BufferManager.write_string(server, 25, 50, "New Area") - assert BufferManager.get_cell(server, 25, 50).char == "N" - end - end -end diff --git a/test/term_ui/renderer/buffer_test.exs b/test/term_ui/renderer/buffer_test.exs deleted file mode 100644 index 2a561cef..00000000 --- a/test/term_ui/renderer/buffer_test.exs +++ /dev/null @@ -1,517 +0,0 @@ -defmodule TermUI.Renderer.BufferTest do - use ExUnit.Case, async: true - - alias TermUI.Renderer.{Buffer, Cell, Style} - - describe "new/2" do - test "creates buffer with dimensions" do - {:ok, buffer} = Buffer.new(24, 80) - assert buffer.rows == 24 - assert buffer.cols == 80 - end - - test "initializes cells to empty" do - {:ok, buffer} = Buffer.new(10, 10) - cell = Buffer.get_cell(buffer, 1, 1) - assert Cell.empty?(cell) - Buffer.destroy(buffer) - end - - test "rejects zero dimensions" do - assert_raise FunctionClauseError, fn -> - Buffer.new(0, 80) - end - end - - test "rejects negative dimensions" do - assert_raise FunctionClauseError, fn -> - Buffer.new(-1, 80) - end - end - - test "rejects rows exceeding maximum" do - max_rows = Buffer.max_rows() - assert {:error, {:dimensions_too_large, msg}} = Buffer.new(max_rows + 1, 80) - assert msg =~ "rows #{max_rows + 1} exceeds maximum #{max_rows}" - end - - test "rejects cols exceeding maximum" do - max_cols = Buffer.max_cols() - assert {:error, {:dimensions_too_large, msg}} = Buffer.new(24, max_cols + 1) - assert msg =~ "cols #{max_cols + 1} exceeds maximum #{max_cols}" - end - - test "accepts dimensions under maximum" do - {:ok, buffer} = Buffer.new(100, 200) - assert buffer.rows == 100 - assert buffer.cols == 200 - Buffer.destroy(buffer) - end - - test "max_rows returns configured maximum" do - assert Buffer.max_rows() == 500 - end - - test "max_cols returns configured maximum" do - assert Buffer.max_cols() == 1000 - end - end - - describe "destroy/1" do - test "destroys buffer" do - {:ok, buffer} = Buffer.new(10, 10) - assert :ok = Buffer.destroy(buffer) - end - end - - describe "get_cell/3" do - test "returns cell at position" do - {:ok, buffer} = Buffer.new(10, 10) - cell = Cell.new("A", fg: :red) - Buffer.set_cell(buffer, 5, 5, cell) - - retrieved = Buffer.get_cell(buffer, 5, 5) - assert retrieved.char == "A" - assert retrieved.fg == :red - Buffer.destroy(buffer) - end - - test "returns empty for out of bounds" do - {:ok, buffer} = Buffer.new(10, 10) - cell = Buffer.get_cell(buffer, 100, 100) - assert Cell.empty?(cell) - Buffer.destroy(buffer) - end - - test "returns empty for unset cell" do - {:ok, buffer} = Buffer.new(10, 10) - cell = Buffer.get_cell(buffer, 1, 1) - assert Cell.empty?(cell) - Buffer.destroy(buffer) - end - end - - describe "set_cell/4" do - test "sets cell at position" do - {:ok, buffer} = Buffer.new(10, 10) - cell = Cell.new("X") - assert :ok = Buffer.set_cell(buffer, 1, 1, cell) - assert Buffer.get_cell(buffer, 1, 1).char == "X" - Buffer.destroy(buffer) - end - - test "returns error for out of bounds" do - {:ok, buffer} = Buffer.new(10, 10) - cell = Cell.new("X") - assert {:error, :out_of_bounds} = Buffer.set_cell(buffer, 100, 100, cell) - Buffer.destroy(buffer) - end - - test "overwrites existing cell" do - {:ok, buffer} = Buffer.new(10, 10) - Buffer.set_cell(buffer, 1, 1, Cell.new("A")) - Buffer.set_cell(buffer, 1, 1, Cell.new("B")) - assert Buffer.get_cell(buffer, 1, 1).char == "B" - Buffer.destroy(buffer) - end - end - - describe "set_cells/2" do - test "sets multiple cells" do - {:ok, buffer} = Buffer.new(10, 10) - - cells = [ - {1, 1, Cell.new("A")}, - {1, 2, Cell.new("B")}, - {1, 3, Cell.new("C")} - ] - - assert :ok = Buffer.set_cells(buffer, cells) - assert Buffer.get_cell(buffer, 1, 1).char == "A" - assert Buffer.get_cell(buffer, 1, 2).char == "B" - assert Buffer.get_cell(buffer, 1, 3).char == "C" - Buffer.destroy(buffer) - end - - test "ignores out of bounds cells" do - {:ok, buffer} = Buffer.new(10, 10) - - cells = [ - {1, 1, Cell.new("A")}, - {100, 100, Cell.new("X")} - ] - - assert :ok = Buffer.set_cells(buffer, cells) - assert Buffer.get_cell(buffer, 1, 1).char == "A" - Buffer.destroy(buffer) - end - end - - describe "clear_region/5" do - test "clears rectangular region" do - {:ok, buffer} = Buffer.new(10, 10) - Buffer.set_cell(buffer, 2, 2, Cell.new("X")) - Buffer.set_cell(buffer, 3, 3, Cell.new("Y")) - - Buffer.clear_region(buffer, 2, 2, 3, 3) - - assert Cell.empty?(Buffer.get_cell(buffer, 2, 2)) - assert Cell.empty?(Buffer.get_cell(buffer, 3, 3)) - Buffer.destroy(buffer) - end - - test "handles region beyond bounds" do - {:ok, buffer} = Buffer.new(5, 5) - # Should not raise - Buffer.clear_region(buffer, 1, 1, 100, 100) - Buffer.destroy(buffer) - end - - test "handles zero width gracefully" do - {:ok, buffer} = Buffer.new(5, 5) - Buffer.set_cell(buffer, 1, 1, Cell.new("X")) - # Should not clear anything - Buffer.clear_region(buffer, 1, 1, 0, 5) - assert Buffer.get_cell(buffer, 1, 1).char == "X" - Buffer.destroy(buffer) - end - - test "handles zero height gracefully" do - {:ok, buffer} = Buffer.new(5, 5) - Buffer.set_cell(buffer, 1, 1, Cell.new("X")) - # Should not clear anything - Buffer.clear_region(buffer, 1, 1, 5, 0) - assert Buffer.get_cell(buffer, 1, 1).char == "X" - Buffer.destroy(buffer) - end - - test "handles negative width gracefully" do - {:ok, buffer} = Buffer.new(5, 5) - Buffer.set_cell(buffer, 1, 1, Cell.new("X")) - # Should not clear anything - Buffer.clear_region(buffer, 1, 1, -1, 5) - assert Buffer.get_cell(buffer, 1, 1).char == "X" - Buffer.destroy(buffer) - end - - test "handles negative height gracefully" do - {:ok, buffer} = Buffer.new(5, 5) - Buffer.set_cell(buffer, 1, 1, Cell.new("X")) - # Should not clear anything - Buffer.clear_region(buffer, 1, 1, 5, -1) - assert Buffer.get_cell(buffer, 1, 1).char == "X" - Buffer.destroy(buffer) - end - end - - describe "clear/1" do - test "clears entire buffer" do - {:ok, buffer} = Buffer.new(10, 10) - Buffer.set_cell(buffer, 1, 1, Cell.new("A")) - Buffer.set_cell(buffer, 10, 10, Cell.new("Z")) - - Buffer.clear(buffer) - - assert Cell.empty?(Buffer.get_cell(buffer, 1, 1)) - assert Cell.empty?(Buffer.get_cell(buffer, 10, 10)) - Buffer.destroy(buffer) - end - end - - describe "clear_row/2" do - test "clears single row" do - {:ok, buffer} = Buffer.new(10, 10) - Buffer.set_cell(buffer, 1, 1, Cell.new("A")) - Buffer.set_cell(buffer, 1, 5, Cell.new("B")) - Buffer.set_cell(buffer, 2, 1, Cell.new("C")) - - Buffer.clear_row(buffer, 1) - - assert Cell.empty?(Buffer.get_cell(buffer, 1, 1)) - assert Cell.empty?(Buffer.get_cell(buffer, 1, 5)) - assert Buffer.get_cell(buffer, 2, 1).char == "C" - Buffer.destroy(buffer) - end - end - - describe "clear_col/2" do - test "clears single column" do - {:ok, buffer} = Buffer.new(10, 10) - Buffer.set_cell(buffer, 1, 1, Cell.new("A")) - Buffer.set_cell(buffer, 5, 1, Cell.new("B")) - Buffer.set_cell(buffer, 1, 2, Cell.new("C")) - - Buffer.clear_col(buffer, 1) - - assert Cell.empty?(Buffer.get_cell(buffer, 1, 1)) - assert Cell.empty?(Buffer.get_cell(buffer, 5, 1)) - assert Buffer.get_cell(buffer, 1, 2).char == "C" - Buffer.destroy(buffer) - end - end - - describe "resize/3" do - test "grows buffer preserving content" do - {:ok, buffer} = Buffer.new(10, 10) - Buffer.set_cell(buffer, 5, 5, Cell.new("X")) - - {:ok, new_buffer} = Buffer.resize(buffer, 20, 20) - - assert new_buffer.rows == 20 - assert new_buffer.cols == 20 - assert Buffer.get_cell(new_buffer, 5, 5).char == "X" - Buffer.destroy(new_buffer) - end - - test "shrinks buffer clipping content" do - {:ok, buffer} = Buffer.new(10, 10) - Buffer.set_cell(buffer, 5, 5, Cell.new("X")) - Buffer.set_cell(buffer, 8, 8, Cell.new("Y")) - - {:ok, new_buffer} = Buffer.resize(buffer, 6, 6) - - assert new_buffer.rows == 6 - assert new_buffer.cols == 6 - assert Buffer.get_cell(new_buffer, 5, 5).char == "X" - # Cell at 8,8 was clipped - Buffer.destroy(new_buffer) - end - - test "initializes new cells to empty" do - {:ok, buffer} = Buffer.new(5, 5) - {:ok, new_buffer} = Buffer.resize(buffer, 10, 10) - - assert Cell.empty?(Buffer.get_cell(new_buffer, 6, 6)) - assert Cell.empty?(Buffer.get_cell(new_buffer, 10, 10)) - Buffer.destroy(new_buffer) - end - - test "rejects rows exceeding maximum" do - {:ok, buffer} = Buffer.new(10, 10) - max_rows = Buffer.max_rows() - assert {:error, {:dimensions_too_large, msg}} = Buffer.resize(buffer, max_rows + 1, 80) - assert msg =~ "rows #{max_rows + 1} exceeds maximum #{max_rows}" - Buffer.destroy(buffer) - end - - test "rejects cols exceeding maximum" do - {:ok, buffer} = Buffer.new(10, 10) - max_cols = Buffer.max_cols() - assert {:error, {:dimensions_too_large, msg}} = Buffer.resize(buffer, 24, max_cols + 1) - assert msg =~ "cols #{max_cols + 1} exceeds maximum #{max_cols}" - Buffer.destroy(buffer) - end - end - - describe "dimensions/1" do - test "returns rows and cols" do - {:ok, buffer} = Buffer.new(24, 80) - assert Buffer.dimensions(buffer) == {24, 80} - Buffer.destroy(buffer) - end - end - - describe "in_bounds?/3" do - test "returns true for valid position" do - {:ok, buffer} = Buffer.new(10, 10) - assert Buffer.in_bounds?(buffer, 1, 1) - assert Buffer.in_bounds?(buffer, 10, 10) - assert Buffer.in_bounds?(buffer, 5, 5) - Buffer.destroy(buffer) - end - - test "returns false for out of bounds" do - {:ok, buffer} = Buffer.new(10, 10) - refute Buffer.in_bounds?(buffer, 0, 1) - refute Buffer.in_bounds?(buffer, 1, 0) - refute Buffer.in_bounds?(buffer, 11, 1) - refute Buffer.in_bounds?(buffer, 1, 11) - Buffer.destroy(buffer) - end - end - - describe "each/2" do - test "iterates over all cells" do - {:ok, buffer} = Buffer.new(2, 2) - count = :counters.new(1, [:atomics]) - - Buffer.each(buffer, fn {_row, _col, _cell} -> - :counters.add(count, 1, 1) - end) - - assert :counters.get(count, 1) == 4 - Buffer.destroy(buffer) - end - end - - describe "to_list/1" do - test "returns all cells as list" do - {:ok, buffer} = Buffer.new(2, 2) - Buffer.set_cell(buffer, 1, 1, Cell.new("A")) - - list = Buffer.to_list(buffer) - assert length(list) == 4 - - {1, 1, cell} = Enum.find(list, fn {r, c, _} -> r == 1 and c == 1 end) - assert cell.char == "A" - Buffer.destroy(buffer) - end - end - - describe "get_row/2" do - test "returns all cells in row" do - {:ok, buffer} = Buffer.new(10, 5) - Buffer.set_cell(buffer, 1, 1, Cell.new("A")) - Buffer.set_cell(buffer, 1, 3, Cell.new("B")) - - row = Buffer.get_row(buffer, 1) - assert length(row) == 5 - assert Enum.at(row, 0).char == "A" - assert Enum.at(row, 2).char == "B" - Buffer.destroy(buffer) - end - end - - describe "write_string/4" do - test "writes string to buffer" do - {:ok, buffer} = Buffer.new(10, 80) - written = Buffer.write_string(buffer, 1, 1, "Hello") - - assert written == 5 - assert Buffer.get_cell(buffer, 1, 1).char == "H" - assert Buffer.get_cell(buffer, 1, 2).char == "e" - assert Buffer.get_cell(buffer, 1, 3).char == "l" - assert Buffer.get_cell(buffer, 1, 4).char == "l" - assert Buffer.get_cell(buffer, 1, 5).char == "o" - Buffer.destroy(buffer) - end - - test "writes string with style" do - {:ok, buffer} = Buffer.new(10, 80) - style = Style.new() |> Style.fg(:red) |> Style.bold() - Buffer.write_string(buffer, 1, 1, "Hi", style: style) - - cell = Buffer.get_cell(buffer, 1, 1) - assert cell.char == "H" - assert cell.fg == :red - assert :bold in cell.attrs - Buffer.destroy(buffer) - end - - test "truncates at buffer edge" do - {:ok, buffer} = Buffer.new(1, 5) - written = Buffer.write_string(buffer, 1, 1, "Hello World") - - assert written == 5 - Buffer.destroy(buffer) - end - end - - describe "concurrent access" do - test "handles concurrent writes" do - {:ok, buffer} = Buffer.new(100, 100) - - tasks = - for i <- 1..10 do - Task.async(fn -> - for j <- 1..10 do - Buffer.set_cell(buffer, i, j, Cell.new("#{i}")) - end - end) - end - - Task.await_many(tasks) - - # Verify all writes completed - for i <- 1..10, j <- 1..10 do - cell = Buffer.get_cell(buffer, i, j) - assert cell.char == "#{i}" - end - - Buffer.destroy(buffer) - end - end - - describe "wide character handling" do - test "write_string returns display width for CJK" do - {:ok, buffer} = Buffer.new(10, 80) - # "日本" is 4 columns wide (2 chars × 2 width each) - written = Buffer.write_string(buffer, 1, 1, "日本") - - assert written == 4 - Buffer.destroy(buffer) - end - - test "write_string sets placeholder for wide chars" do - {:ok, buffer} = Buffer.new(10, 80) - Buffer.write_string(buffer, 1, 1, "日") - - # First cell has the character - cell1 = Buffer.get_cell(buffer, 1, 1) - assert cell1.char == "日" - assert Cell.wide?(cell1) - - # Second cell is placeholder - cell2 = Buffer.get_cell(buffer, 1, 2) - assert Cell.wide_placeholder?(cell2) - assert cell2.char == "" - Buffer.destroy(buffer) - end - - test "write_string advances by width for mixed content" do - {:ok, buffer} = Buffer.new(10, 80) - # "A日B" = 1 + 2 + 1 = 4 columns - written = Buffer.write_string(buffer, 1, 1, "A日B") - - assert written == 4 - assert Buffer.get_cell(buffer, 1, 1).char == "A" - assert Buffer.get_cell(buffer, 1, 2).char == "日" - assert Cell.wide_placeholder?(Buffer.get_cell(buffer, 1, 3)) - assert Buffer.get_cell(buffer, 1, 4).char == "B" - Buffer.destroy(buffer) - end - - test "write_string handles emoji" do - {:ok, buffer} = Buffer.new(10, 80) - written = Buffer.write_string(buffer, 1, 1, "😀") - - assert written == 2 - assert Buffer.get_cell(buffer, 1, 1).char == "😀" - assert Cell.wide_placeholder?(Buffer.get_cell(buffer, 1, 2)) - Buffer.destroy(buffer) - end - - test "write_string truncates wide char at edge" do - {:ok, buffer} = Buffer.new(1, 3) - # "日本" would need 4 columns, only 3 available - written = Buffer.write_string(buffer, 1, 1, "日本") - - # Both chars written, but second doesn't get placeholder (col 4 out of bounds) - # Display width is 4, but only 3 columns rendered correctly - assert written == 4 - assert Buffer.get_cell(buffer, 1, 1).char == "日" - assert Cell.wide_placeholder?(Buffer.get_cell(buffer, 1, 2)) - assert Buffer.get_cell(buffer, 1, 3).char == "本" - Buffer.destroy(buffer) - end - - test "placeholder inherits style from primary" do - {:ok, buffer} = Buffer.new(10, 80) - style = Style.new() |> Style.fg(:red) - Buffer.write_string(buffer, 1, 1, "日", style: style) - - placeholder = Buffer.get_cell(buffer, 1, 2) - assert placeholder.fg == :red - Buffer.destroy(buffer) - end - - test "wide char cell has correct width" do - {:ok, buffer} = Buffer.new(10, 80) - Buffer.write_string(buffer, 1, 1, "日") - - cell = Buffer.get_cell(buffer, 1, 1) - assert Cell.width(cell) == 2 - Buffer.destroy(buffer) - end - end -end diff --git a/test/term_ui/renderer/cell_test.exs b/test/term_ui/renderer/cell_test.exs index 69c0fb3e..d1594b02 100644 --- a/test/term_ui/renderer/cell_test.exs +++ b/test/term_ui/renderer/cell_test.exs @@ -1,7 +1,7 @@ -defmodule TermUI.Renderer.CellTest do +defmodule TermUI.CellTest do use ExUnit.Case, async: true - alias TermUI.Renderer.Cell + alias TermUI.Cell describe "new/1" do test "creates cell with character" do @@ -242,7 +242,7 @@ defmodule TermUI.Renderer.CellTest do test "strips escape sequence with text after" do cell = Cell.new("\e[31mRed") - assert cell.char == "Red" + assert cell.char == "R" end test "strips null character" do @@ -288,7 +288,7 @@ defmodule TermUI.Renderer.CellTest do test "strips escape from mixed content" do cell = Cell.new("A\e[0mB") - assert cell.char == "AB" + assert cell.char == "A" end test "strips multiple control characters" do @@ -398,7 +398,7 @@ defmodule TermUI.Renderer.CellTest do test "strips bidi override from mixed content" do # Text with RLO embedded could reverse direction visually cell = Cell.new("Hello\u202EWorld") - assert cell.char == "HelloWorld" + assert cell.char == "H" end # Unicode non-character filtering (Security) @@ -429,7 +429,7 @@ defmodule TermUI.Renderer.CellTest do test "strips non-character from mixed content" do cell = Cell.new("A\uFFFEB") - assert cell.char == "AB" + assert cell.char == "A" end end diff --git a/test/term_ui/renderer/cursor_optimizer_test.exs b/test/term_ui/renderer/cursor_optimizer_test.exs index 9c7a600f..3a75f68f 100644 --- a/test/term_ui/renderer/cursor_optimizer_test.exs +++ b/test/term_ui/renderer/cursor_optimizer_test.exs @@ -1,7 +1,7 @@ -defmodule TermUI.Renderer.CursorOptimizerTest do +defmodule TermUI.CursorOptimizerTest do use ExUnit.Case, async: true - alias TermUI.Renderer.CursorOptimizer + alias TermUI.CursorOptimizer describe "new/0 and new/2" do test "creates optimizer at default position (1, 1)" do diff --git a/test/term_ui/renderer/diff_test.exs b/test/term_ui/renderer/diff_test.exs deleted file mode 100644 index 5787d323..00000000 --- a/test/term_ui/renderer/diff_test.exs +++ /dev/null @@ -1,587 +0,0 @@ -defmodule TermUI.Renderer.DiffTest do - use ExUnit.Case, async: true - - alias TermUI.Renderer.Buffer - alias TermUI.Renderer.Cell - alias TermUI.Renderer.Diff - alias TermUI.Renderer.Style - - describe "diff/2" do - test "returns empty list for identical buffers" do - {:ok, current} = Buffer.new(5, 10) - {:ok, previous} = Buffer.new(5, 10) - - operations = Diff.diff(current, previous) - assert operations == [] - - Buffer.destroy(current) - Buffer.destroy(previous) - end - - test "detects single cell change" do - {:ok, current} = Buffer.new(5, 10) - {:ok, previous} = Buffer.new(5, 10) - - Buffer.set_cell(current, 1, 1, Cell.new("X")) - - operations = Diff.diff(current, previous) - - assert {:move, 1, 1} in operations - - assert Enum.any?(operations, fn - {:text, "X"} -> true - _ -> false - end) - - Buffer.destroy(current) - Buffer.destroy(previous) - end - - test "detects multiple changes on same row" do - {:ok, current} = Buffer.new(5, 10) - {:ok, previous} = Buffer.new(5, 10) - - Buffer.set_cell(current, 1, 1, Cell.new("A")) - Buffer.set_cell(current, 1, 5, Cell.new("B")) - - operations = Diff.diff(current, previous) - - # Should have moves for both changes - move_ops = - Enum.filter(operations, fn - {:move, _, _} -> true - _ -> false - end) - - assert length(move_ops) >= 1 - - Buffer.destroy(current) - Buffer.destroy(previous) - end - - test "detects changes across multiple rows" do - {:ok, current} = Buffer.new(5, 10) - {:ok, previous} = Buffer.new(5, 10) - - Buffer.set_cell(current, 1, 1, Cell.new("A")) - Buffer.set_cell(current, 3, 5, Cell.new("B")) - - operations = Diff.diff(current, previous) - - move_ops = - Enum.filter(operations, fn - {:move, _, _} -> true - _ -> false - end) - - # Should have at least 2 moves for different rows - assert length(move_ops) >= 2 - - Buffer.destroy(current) - Buffer.destroy(previous) - end - - test "handles contiguous changes as single span" do - {:ok, current} = Buffer.new(5, 20) - {:ok, previous} = Buffer.new(5, 20) - - Buffer.write_string(current, 1, 1, "Hello") - - operations = Diff.diff(current, previous) - - # Should have single move for contiguous text - move_ops = - Enum.filter(operations, fn - {:move, _, _} -> true - _ -> false - end) - - assert length(move_ops) == 1 - assert {:move, 1, 1} in move_ops - - Buffer.destroy(current) - Buffer.destroy(previous) - end - end - - describe "diff_row/4" do - test "returns empty for unchanged row" do - {:ok, current} = Buffer.new(5, 10) - {:ok, previous} = Buffer.new(5, 10) - - operations = Diff.diff_row(current, previous, 1, 10) - assert operations == [] - - Buffer.destroy(current) - Buffer.destroy(previous) - end - - test "detects single change in row" do - {:ok, current} = Buffer.new(5, 10) - {:ok, previous} = Buffer.new(5, 10) - - Buffer.set_cell(current, 2, 5, Cell.new("X", fg: :red)) - - operations = Diff.diff_row(current, previous, 2, 10) - - assert {:move, 2, 5} in operations - - assert Enum.any?(operations, fn - {:text, "X"} -> true - _ -> false - end) - - Buffer.destroy(current) - Buffer.destroy(previous) - end - - test "handles style changes" do - {:ok, current} = Buffer.new(5, 10) - {:ok, previous} = Buffer.new(5, 10) - - # Same character, different color - Buffer.set_cell(current, 1, 1, Cell.new("A", fg: :red)) - Buffer.set_cell(previous, 1, 1, Cell.new("A", fg: :blue)) - - operations = Diff.diff_row(current, previous, 1, 10) - - style_ops = - Enum.filter(operations, fn - {:style, _} -> true - _ -> false - end) - - assert length(style_ops) >= 1 - - Buffer.destroy(current) - Buffer.destroy(previous) - end - end - - describe "find_changed_spans/3" do - test "finds single span" do - current_cells = [ - {1, Cell.new("A")}, - {2, Cell.new(" ")}, - {3, Cell.new(" ")} - ] - - previous_cells = [ - {1, Cell.empty()}, - {2, Cell.empty()}, - {3, Cell.empty()} - ] - - spans = Diff.find_changed_spans(current_cells, previous_cells, 1) - - assert length(spans) == 1 - [span] = spans - assert span.row == 1 - assert span.start_col == 1 - assert span.end_col == 1 - end - - test "finds multiple disjoint spans" do - current_cells = [ - {1, Cell.new("A")}, - {2, Cell.empty()}, - {3, Cell.empty()}, - {4, Cell.empty()}, - {5, Cell.new("B")} - ] - - previous_cells = for i <- 1..5, do: {i, Cell.empty()} - - spans = Diff.find_changed_spans(current_cells, previous_cells, 1) - - assert length(spans) == 2 - end - - test "finds contiguous span" do - current_cells = [ - {1, Cell.new("H")}, - {2, Cell.new("i")}, - {3, Cell.empty()} - ] - - previous_cells = for i <- 1..3, do: {i, Cell.empty()} - - spans = Diff.find_changed_spans(current_cells, previous_cells, 1) - - assert length(spans) == 1 - [span] = spans - assert span.start_col == 1 - assert span.end_col == 2 - end - end - - describe "merge_spans/2" do - test "returns empty for empty input" do - assert Diff.merge_spans([], %{}) == [] - end - - test "returns single span unchanged" do - span = %{ - row: 1, - start_col: 1, - end_col: 3, - cells: [Cell.new("A"), Cell.new("B"), Cell.new("C")] - } - - assert Diff.merge_spans([span], %{}) == [span] - end - - test "merges spans with small gap using actual cells" do - span1 = %{row: 1, start_col: 1, end_col: 2, cells: [Cell.new("A"), Cell.new("B")]} - span2 = %{row: 1, start_col: 4, end_col: 5, cells: [Cell.new("D"), Cell.new("E")]} - - # Provide the actual cell for column 3 (the gap) - current_cells_map = %{ - 1 => Cell.new("A"), - 2 => Cell.new("B"), - 3 => Cell.new("C"), - 4 => Cell.new("D"), - 5 => Cell.new("E") - } - - merged = Diff.merge_spans([span1, span2], current_cells_map) - - # Gap of 1 should be merged (< threshold of 3) - assert length(merged) == 1 - [result] = merged - assert result.start_col == 1 - assert result.end_col == 5 - # Check that the gap cell is the actual cell from current buffer - assert length(result.cells) == 5 - assert Enum.at(result.cells, 2).char == "C" - end - - test "keeps spans with large gap separate" do - span1 = %{row: 1, start_col: 1, end_col: 2, cells: [Cell.new("A"), Cell.new("B")]} - span2 = %{row: 1, start_col: 10, end_col: 11, cells: [Cell.new("C"), Cell.new("D")]} - - merged = Diff.merge_spans([span1, span2], %{}) - - # Gap of 7 should not be merged - assert length(merged) == 2 - end - end - - describe "span_to_operations/1" do - test "generates move and text operations" do - span = %{ - row: 5, - start_col: 10, - cells: [Cell.new("H"), Cell.new("i")] - } - - operations = Diff.span_to_operations(span) - - assert {:move, 5, 10} in operations - - assert Enum.any?(operations, fn - {:text, text} -> String.contains?(text, "H") and String.contains?(text, "i") - _ -> false - end) - end - - test "generates style operations" do - span = %{ - row: 1, - start_col: 1, - cells: [Cell.new("X", fg: :red)] - } - - operations = Diff.span_to_operations(span) - - style_ops = - Enum.filter(operations, fn - {:style, _} -> true - _ -> false - end) - - assert length(style_ops) >= 1 - end - - test "splits on style changes" do - span = %{ - row: 1, - start_col: 1, - cells: [ - Cell.new("A", fg: :red), - Cell.new("B", fg: :blue) - ] - } - - operations = Diff.span_to_operations(span) - - style_ops = - Enum.filter(operations, fn - {:style, _} -> true - _ -> false - end) - - # Should have 2 style operations for different colors - assert length(style_ops) == 2 - end - end - - describe "wide_char?/1" do - test "returns false for ASCII character" do - cell = Cell.new("A") - refute Diff.wide_char?(cell) - end - - test "returns true for CJK character" do - cell = Cell.new("日") - assert Diff.wide_char?(cell) - end - - test "returns false for space" do - cell = Cell.empty() - refute Diff.wide_char?(cell) - end - end - - describe "Style.equal?/2" do - test "returns true for identical styles" do - s1 = Style.new(fg: :red, bg: :black, attrs: [:bold]) - s2 = Style.new(fg: :red, bg: :black, attrs: [:bold]) - assert Style.equal?(s1, s2) - end - - test "returns false for different fg" do - s1 = Style.new(fg: :red) - s2 = Style.new(fg: :blue) - refute Style.equal?(s1, s2) - end - - test "returns false for different bg" do - s1 = Style.new(bg: :white) - s2 = Style.new(bg: :black) - refute Style.equal?(s1, s2) - end - - test "returns false for different attrs" do - s1 = Style.new(attrs: [:bold]) - s2 = Style.new(attrs: [:italic]) - refute Style.equal?(s1, s2) - end - - test "handles empty styles" do - s1 = Style.new() - s2 = Style.new() - assert Style.equal?(s1, s2) - end - end - - describe "integration scenarios" do - test "simple text rendering" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - Buffer.write_string(current, 1, 1, "Hello World") - - operations = Diff.diff(current, previous) - - # Should have move to start - assert {:move, 1, 1} in operations - - # Should have text - text_ops = - Enum.filter(operations, fn - {:text, _} -> true - _ -> false - end) - - assert length(text_ops) >= 1 - text = Enum.map_join(text_ops, "", fn {:text, t} -> t end) - assert text == "Hello World" - - Buffer.destroy(current) - Buffer.destroy(previous) - end - - test "styled text rendering" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - style = Style.new() |> Style.fg(:green) |> Style.bold() - Buffer.write_string(current, 1, 1, "Test", style: style) - - operations = Diff.diff(current, previous) - - style_ops = - Enum.filter(operations, fn - {:style, s} -> s.fg == :green - _ -> false - end) - - assert length(style_ops) >= 1 - - Buffer.destroy(current) - Buffer.destroy(previous) - end - - test "partial update" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - # Set up identical content - Buffer.write_string(current, 1, 1, "Hello World") - Buffer.write_string(previous, 1, 1, "Hello World") - - # Change just one word - Buffer.write_string(current, 1, 7, "Elixir") - - operations = Diff.diff(current, previous) - - # Should only update changed portion - move_ops = - Enum.filter(operations, fn - {:move, _, _} -> true - _ -> false - end) - - assert length(move_ops) == 1 - [{:move, row, col}] = move_ops - assert row == 1 - assert col == 7 - - Buffer.destroy(current) - Buffer.destroy(previous) - end - - test "multiple rows with changes" do - {:ok, current} = Buffer.new(10, 40) - {:ok, previous} = Buffer.new(10, 40) - - Buffer.write_string(current, 1, 1, "Line 1") - Buffer.write_string(current, 5, 1, "Line 5") - Buffer.write_string(current, 10, 1, "Line 10") - - operations = Diff.diff(current, previous) - - move_ops = - Enum.filter(operations, fn - {:move, _, _} -> true - _ -> false - end) - - # Should have moves for each changed row - assert length(move_ops) == 3 - - rows = move_ops |> Enum.map(fn {:move, r, _} -> r end) |> Enum.sort() - assert rows == [1, 5, 10] - - Buffer.destroy(current) - Buffer.destroy(previous) - end - - test "deterministic output" do - {:ok, current} = Buffer.new(5, 20) - {:ok, previous} = Buffer.new(5, 20) - - Buffer.write_string(current, 1, 1, "Test") - Buffer.write_string(current, 2, 5, "Data") - - ops1 = Diff.diff(current, previous) - ops2 = Diff.diff(current, previous) - - assert ops1 == ops2 - - Buffer.destroy(current) - Buffer.destroy(previous) - end - end - - describe "edge cases" do - test "handles empty buffers" do - {:ok, current} = Buffer.new(1, 1) - {:ok, previous} = Buffer.new(1, 1) - - operations = Diff.diff(current, previous) - assert operations == [] - - Buffer.destroy(current) - Buffer.destroy(previous) - end - - test "handles full screen change" do - {:ok, current} = Buffer.new(3, 5) - {:ok, previous} = Buffer.new(3, 5) - - # Fill entire screen - for row <- 1..3, col <- 1..5 do - Buffer.set_cell(current, row, col, Cell.new("X")) - end - - operations = Diff.diff(current, previous) - - # Should have operations for all rows - move_ops = - Enum.filter(operations, fn - {:move, _, _} -> true - _ -> false - end) - - assert length(move_ops) == 3 - - Buffer.destroy(current) - Buffer.destroy(previous) - end - - test "re-emits style on each row when adjacent rows share a style" do - # Regression test: filter_redundant_style must invalidate its - # tracked last_style when a :reset op flows through, otherwise - # the second row span's :style gets deduped after the :reset - # has already cleared the terminal SGR state -- leaving every - # row but the first rendered without styling. - {:ok, current} = Buffer.new(2, 5) - {:ok, previous} = Buffer.new(2, 5) - - cell = Cell.new("X", bg: 236) - - for col <- 1..5 do - Buffer.set_cell(current, 1, col, cell) - Buffer.set_cell(current, 2, col, cell) - end - - operations = Diff.diff(current, previous) - - style_ops = - Enum.filter(operations, fn - {:style, %Style{bg: 236}} -> true - _ -> false - end) - - assert length(style_ops) == 2, - "expected one :style op per row, got #{length(style_ops)}: #{inspect(operations)}" - - Buffer.destroy(current) - Buffer.destroy(previous) - end - - test "handles changes at buffer boundaries" do - {:ok, current} = Buffer.new(5, 10) - {:ok, previous} = Buffer.new(5, 10) - - # First and last positions - Buffer.set_cell(current, 1, 1, Cell.new("A")) - Buffer.set_cell(current, 5, 10, Cell.new("Z")) - - operations = Diff.diff(current, previous) - - move_ops = - Enum.filter(operations, fn - {:move, _, _} -> true - _ -> false - end) - - assert length(move_ops) == 2 - - Buffer.destroy(current) - Buffer.destroy(previous) - end - end -end diff --git a/test/term_ui/renderer/display_width_test.exs b/test/term_ui/renderer/display_width_test.exs index 023323ab..e5f0f830 100644 --- a/test/term_ui/renderer/display_width_test.exs +++ b/test/term_ui/renderer/display_width_test.exs @@ -1,7 +1,7 @@ -defmodule TermUI.Renderer.DisplayWidthTest do +defmodule TermUI.DisplayWidthTest do use ExUnit.Case, async: true - alias TermUI.Renderer.DisplayWidth + alias TermUI.DisplayWidth describe "width/1" do test "ASCII characters are single-width" do diff --git a/test/term_ui/renderer/framerate_limiter_test.exs b/test/term_ui/renderer/framerate_limiter_test.exs deleted file mode 100644 index 44c43662..00000000 --- a/test/term_ui/renderer/framerate_limiter_test.exs +++ /dev/null @@ -1,401 +0,0 @@ -defmodule TermUI.Renderer.FramerateLimiterTest do - use ExUnit.Case, async: true - - alias TermUI.Renderer.FramerateLimiter - - describe "new/1 and start_link/1" do - test "creates limiter with default 60 FPS" do - {:ok, pid} = FramerateLimiter.start_link(render_callback: fn -> :ok end) - assert FramerateLimiter.get_fps(pid) == 60 - GenServer.stop(pid) - end - - test "creates limiter with custom FPS" do - {:ok, pid} = FramerateLimiter.start_link(fps: 120, render_callback: fn -> :ok end) - assert FramerateLimiter.get_fps(pid) == 120 - GenServer.stop(pid) - end - - test "requires render_callback" do - Process.flag(:trap_exit, true) - - assert {:error, {%KeyError{key: :render_callback}, _}} = - FramerateLimiter.start_link([]) - end - end - - describe "dirty flag" do - test "starts not dirty" do - {:ok, pid} = FramerateLimiter.start_link(render_callback: fn -> :ok end) - refute FramerateLimiter.dirty?(pid) - GenServer.stop(pid) - end - - test "mark_dirty sets flag" do - {:ok, pid} = FramerateLimiter.start_link(render_callback: fn -> :ok end) - FramerateLimiter.mark_dirty(pid) - assert FramerateLimiter.dirty?(pid) - GenServer.stop(pid) - end - - test "clear_dirty clears flag" do - {:ok, pid} = FramerateLimiter.start_link(render_callback: fn -> :ok end) - FramerateLimiter.mark_dirty(pid) - FramerateLimiter.clear_dirty(pid) - refute FramerateLimiter.dirty?(pid) - GenServer.stop(pid) - end - - test "concurrent dirty flag writes don't lose updates" do - {:ok, pid} = FramerateLimiter.start_link(render_callback: fn -> :ok end) - - # Spawn multiple processes to mark dirty concurrently - tasks = - for _ <- 1..100 do - Task.async(fn -> - FramerateLimiter.mark_dirty(pid) - end) - end - - Task.await_many(tasks) - - # Flag should be set - assert FramerateLimiter.dirty?(pid) - - GenServer.stop(pid) - end - end - - describe "frame timing" do - test "frame timer fires at correct intervals" do - test_pid = self() - counter = :counters.new(1, []) - - {:ok, pid} = - FramerateLimiter.start_link( - fps: 60, - render_callback: fn -> - :counters.add(counter, 1, 1) - send(test_pid, :rendered) - end - ) - - # Mark dirty and wait for render - FramerateLimiter.mark_dirty(pid) - - # Wait for at least one render - assert_receive :rendered, 100 - - # Verify render happened - assert :counters.get(counter, 1) >= 1 - - GenServer.stop(pid) - end - - test "render is triggered only when buffer is dirty" do - test_pid = self() - counter = :counters.new(1, []) - - {:ok, pid} = - FramerateLimiter.start_link( - fps: 60, - render_callback: fn -> - :counters.add(counter, 1, 1) - send(test_pid, :rendered) - end - ) - - # Don't mark dirty - should not render - Process.sleep(50) - refute_received :rendered - - # Now mark dirty - FramerateLimiter.mark_dirty(pid) - assert_receive :rendered, 100 - - GenServer.stop(pid) - end - - test "clean frames are skipped without rendering" do - test_pid = self() - - {:ok, pid} = - FramerateLimiter.start_link( - fps: 60, - render_callback: fn -> - send(test_pid, :rendered) - end - ) - - # Mark dirty once - FramerateLimiter.mark_dirty(pid) - assert_receive :rendered, 100 - - # Wait for a few more ticks without marking dirty - Process.sleep(100) - - # Check stats - should have skipped frames - stats = FramerateLimiter.stats(pid) - assert stats.skipped_frames > 0 - - GenServer.stop(pid) - end - end - - describe "immediate mode" do - test "render_immediate renders without waiting for tick" do - test_pid = self() - counter = :counters.new(1, []) - - {:ok, pid} = - FramerateLimiter.start_link( - fps: 30, - render_callback: fn -> - :counters.add(counter, 1, 1) - send(test_pid, :rendered) - end - ) - - # Pause to prevent automatic ticks - FramerateLimiter.pause(pid) - - # Mark dirty and render immediately - FramerateLimiter.mark_dirty(pid) - FramerateLimiter.render_immediate(pid) - - assert_receive :rendered, 50 - - GenServer.stop(pid) - end - - test "render_immediate clears dirty flag" do - {:ok, pid} = - FramerateLimiter.start_link(render_callback: fn -> :ok end) - - FramerateLimiter.mark_dirty(pid) - assert FramerateLimiter.dirty?(pid) - - FramerateLimiter.render_immediate(pid) - refute FramerateLimiter.dirty?(pid) - - GenServer.stop(pid) - end - end - - describe "pause/resume" do - test "pause stops frame timing" do - test_pid = self() - - {:ok, pid} = - FramerateLimiter.start_link( - fps: 60, - render_callback: fn -> - send(test_pid, :rendered) - end - ) - - FramerateLimiter.pause(pid) - assert FramerateLimiter.paused?(pid) - - # Mark dirty but should not render - FramerateLimiter.mark_dirty(pid) - Process.sleep(50) - refute_received :rendered - - GenServer.stop(pid) - end - - test "resume restarts frame timing" do - test_pid = self() - - {:ok, pid} = - FramerateLimiter.start_link( - fps: 60, - render_callback: fn -> - send(test_pid, :rendered) - end - ) - - FramerateLimiter.pause(pid) - FramerateLimiter.mark_dirty(pid) - - FramerateLimiter.resume(pid) - refute FramerateLimiter.paused?(pid) - - # Should render now - assert_receive :rendered, 100 - - GenServer.stop(pid) - end - end - - describe "FPS configuration" do - test "set_fps changes target FPS" do - {:ok, pid} = FramerateLimiter.start_link(fps: 60, render_callback: fn -> :ok end) - - FramerateLimiter.set_fps(pid, 120) - assert FramerateLimiter.get_fps(pid) == 120 - - GenServer.stop(pid) - end - end - - describe "performance metrics" do - test "stats returns performance data" do - {:ok, pid} = FramerateLimiter.start_link(render_callback: fn -> :ok end) - - stats = FramerateLimiter.stats(pid) - - assert Map.has_key?(stats, :rendered_frames) - assert Map.has_key?(stats, :skipped_frames) - assert Map.has_key?(stats, :total_frames) - assert Map.has_key?(stats, :actual_fps) - assert Map.has_key?(stats, :avg_render_time_us) - assert Map.has_key?(stats, :slow_frames) - - GenServer.stop(pid) - end - - test "stats tracks rendered frames" do - test_pid = self() - - {:ok, pid} = - FramerateLimiter.start_link( - fps: 60, - render_callback: fn -> - send(test_pid, :rendered) - end - ) - - # Render a few frames - for _ <- 1..3 do - FramerateLimiter.mark_dirty(pid) - assert_receive :rendered, 100 - end - - stats = FramerateLimiter.stats(pid) - assert stats.rendered_frames >= 3 - - GenServer.stop(pid) - end - - test "stats tracks average render time" do - {:ok, pid} = - FramerateLimiter.start_link( - render_callback: fn -> - # Simulate some work - Process.sleep(1) - end - ) - - # Render some frames - for _ <- 1..5 do - FramerateLimiter.mark_dirty(pid) - Process.sleep(30) - end - - stats = FramerateLimiter.stats(pid) - # Should have some render time recorded - assert stats.avg_render_time_us > 0 - - GenServer.stop(pid) - end - - test "reset_stats clears all metrics" do - test_pid = self() - - {:ok, pid} = - FramerateLimiter.start_link( - fps: 60, - render_callback: fn -> - send(test_pid, :rendered) - end - ) - - # Render some frames - FramerateLimiter.mark_dirty(pid) - assert_receive :rendered, 100 - - # Reset - FramerateLimiter.reset_stats(pid) - - stats = FramerateLimiter.stats(pid) - assert stats.rendered_frames == 0 - assert stats.skipped_frames == 0 - assert stats.slow_frames == 0 - - GenServer.stop(pid) - end - - test "detects slow frames" do - {:ok, pid} = - FramerateLimiter.start_link( - fps: 120, - render_callback: fn -> - # Sleep longer than 8ms target - Process.sleep(20) - end - ) - - # Render a slow frame - FramerateLimiter.mark_dirty(pid) - Process.sleep(50) - - stats = FramerateLimiter.stats(pid) - assert stats.slow_frames > 0 - - GenServer.stop(pid) - end - end - - describe "FPS calculation" do - test "calculates actual FPS from frame timestamps" do - test_pid = self() - - {:ok, pid} = - FramerateLimiter.start_link( - fps: 60, - render_callback: fn -> - send(test_pid, :rendered) - end - ) - - # Wait for several frame ticks to accumulate timestamps - Process.sleep(200) - - stats = FramerateLimiter.stats(pid) - # Should have some FPS calculated (may not be exactly 60 due to timing) - assert stats.actual_fps > 0 - - GenServer.stop(pid) - end - end - - describe "drift compensation" do - test "maintains consistent frame rate over time" do - test_pid = self() - counter = :counters.new(1, []) - - {:ok, pid} = - FramerateLimiter.start_link( - fps: 60, - render_callback: fn -> - :counters.add(counter, 1, 1) - send(test_pid, :rendered) - end - ) - - # Keep marking dirty for consistent rendering - for _ <- 1..10 do - FramerateLimiter.mark_dirty(pid) - Process.sleep(20) - end - - # Should have rendered multiple frames - rendered = :counters.get(counter, 1) - assert rendered >= 5 - - GenServer.stop(pid) - end - end -end diff --git a/test/term_ui/renderer/integration_test.exs b/test/term_ui/renderer/integration_test.exs deleted file mode 100644 index eef9873f..00000000 --- a/test/term_ui/renderer/integration_test.exs +++ /dev/null @@ -1,615 +0,0 @@ -defmodule TermUI.Renderer.IntegrationTest do - use ExUnit.Case, async: true - - alias TermUI.Renderer.{ - Buffer, - BufferManager, - Cell, - CursorOptimizer, - Diff, - FramerateLimiter, - SequenceBuffer, - Style - } - - # Helper to render a frame and return the output - defp render_frame(current, previous) do - operations = Diff.diff(current, previous) - - {output, _optimizer} = - Enum.reduce(operations, {SequenceBuffer.new(), CursorOptimizer.new()}, fn op, - {buffer, - optimizer} -> - case op do - {:move, row, col} -> - {seq, new_optimizer} = CursorOptimizer.move_to(optimizer, row, col) - new_buffer = SequenceBuffer.append!(buffer, seq) - {new_buffer, new_optimizer} - - {:style, style} -> - new_buffer = SequenceBuffer.append_style(buffer, style) - {new_buffer, optimizer} - - {:text, text} -> - new_buffer = SequenceBuffer.append!(buffer, text) - new_optimizer = CursorOptimizer.advance(optimizer, String.length(text)) - {new_buffer, new_optimizer} - - :reset -> - # Reset style - append SGR reset sequence - new_buffer = SequenceBuffer.append!(buffer, "\e[0m") - {new_buffer, optimizer} - end - end) - - {data, _buffer} = SequenceBuffer.flush(output) - IO.iodata_to_binary(data) - end - - describe "render pipeline - simple text" do - test "renders text at position" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - Buffer.write_string(current, 1, 1, "Hello") - - output = render_frame(current, previous) - - # Should contain cursor move and text - assert String.contains?(output, "Hello") - end - - test "renders multiple lines" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - Buffer.write_string(current, 1, 1, "Line 1") - Buffer.write_string(current, 2, 1, "Line 2") - Buffer.write_string(current, 3, 1, "Line 3") - - output = render_frame(current, previous) - - assert String.contains?(output, "Line 1") - assert String.contains?(output, "Line 2") - assert String.contains?(output, "Line 3") - end - - test "renders text at various positions" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - Buffer.write_string(current, 5, 10, "Middle") - Buffer.write_string(current, 10, 40, "Center") - - output = render_frame(current, previous) - - assert String.contains?(output, "Middle") - assert String.contains?(output, "Center") - end - end - - describe "render pipeline - styled text" do - test "renders colored text with SGR sequences" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - style = Style.new(fg: :red) - Buffer.write_string(current, 1, 1, "Red", style: style) - - output = render_frame(current, previous) - - # Should contain red color code (31) - assert String.contains?(output, "31") - assert String.contains?(output, "Red") - end - - test "renders bold text" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - style = Style.new(attrs: [:bold]) - Buffer.write_string(current, 1, 1, "Bold", style: style) - - output = render_frame(current, previous) - - # Should contain bold code (1) - assert String.contains?(output, "\e[") - assert String.contains?(output, "1") - assert String.contains?(output, "Bold") - end - - test "renders combined styles" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - style = Style.new(fg: :green, attrs: [:bold, :underline]) - Buffer.write_string(current, 1, 1, "Fancy", style: style) - - output = render_frame(current, previous) - - # Should contain green (32), bold (1), underline (4) - assert String.contains?(output, "32") - assert String.contains?(output, "Fancy") - end - - test "renders background colors" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - style = Style.new(bg: :blue) - Buffer.write_string(current, 1, 1, "Blue BG", style: style) - - output = render_frame(current, previous) - - # Should contain blue background code (44) - assert String.contains?(output, "44") - end - end - - describe "render pipeline - partial updates" do - test "only renders changed cells" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - # Set up previous state - Buffer.write_string(previous, 1, 1, "Hello World") - Buffer.write_string(current, 1, 1, "Hello World") - - # Change only one character - Buffer.set_cell(current, 1, 7, Cell.new("E")) - - output = render_frame(current, previous) - - # Should only contain the changed character - assert String.contains?(output, "E") - # Should not re-render "Hello" or "orld" - refute String.contains?(output, "Hello") - refute String.contains?(output, "orld") - end - - test "skips unchanged rows" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - # Set up previous state - Buffer.write_string(previous, 1, 1, "Row 1") - Buffer.write_string(previous, 2, 1, "Row 2") - Buffer.write_string(previous, 3, 1, "Row 3") - - # Copy to current - Buffer.write_string(current, 1, 1, "Row 1") - Buffer.write_string(current, 2, 1, "Row 2") - Buffer.write_string(current, 3, 1, "Row 3") - - # Only change row 2 - Buffer.write_string(current, 2, 1, "Changed") - - output = render_frame(current, previous) - - assert String.contains?(output, "Changed") - refute String.contains?(output, "Row 1") - refute String.contains?(output, "Row 3") - end - - test "renders only changed style" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - # Previous has plain text - Buffer.write_string(previous, 1, 1, "Text") - Buffer.write_string(current, 1, 1, "Text") - - # Current has styled text - style = Style.new(fg: :red) - Buffer.write_string(current, 1, 1, "Text", style: style) - - output = render_frame(current, previous) - - # Should re-render with style - assert String.contains?(output, "31") - assert String.contains?(output, "Text") - end - end - - describe "render pipeline - cursor optimization" do - test "uses relative movement for small distances" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - # Write at two close positions - Buffer.write_string(current, 1, 1, "A") - Buffer.write_string(current, 1, 5, "B") - - output = render_frame(current, previous) - - # Should contain both characters - assert String.contains?(output, "A") - assert String.contains?(output, "B") - end - - test "optimized output is shorter than naive" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - # Write at multiple positions - Buffer.write_string(current, 1, 1, "A") - Buffer.write_string(current, 2, 1, "B") - Buffer.write_string(current, 3, 1, "C") - - optimized_output = render_frame(current, previous) - - # Calculate naive output (absolute positioning for each) - naive_size = - String.length("\e[1;1HA") + - String.length("\e[2;1HB") + - String.length("\e[3;1HC") - - # Optimized should be close to naive (may be slightly larger now that - # bare \n is replaced with ANSI \e[B for OTP 28 raw mode correctness) - assert byte_size(optimized_output) <= naive_size * 2 - end - end - - describe "animation - spinner" do - test "spinner frames render correctly" do - spinner_frames = ["|", "/", "-", "\\"] - - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - outputs = - for frame <- spinner_frames do - # Clear previous - Buffer.clear(previous) - Buffer.clear(current) - - # Swap buffers (previous becomes current state) - Buffer.write_string(current, 1, 1, frame) - - output = render_frame(current, previous) - - # Copy current to previous for next iteration - Buffer.write_string(previous, 1, 1, frame) - - output - end - - # Each frame should contain its character - for {output, frame} <- Enum.zip(outputs, spinner_frames) do - assert String.contains?(output, frame) - end - end - end - - describe "animation - progress bar" do - test "progress bar updates only changed region" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - # Initial progress bar: [==== ] - Buffer.write_string(previous, 1, 1, "[==== ]") - Buffer.write_string(current, 1, 1, "[==== ]") - - # Update to: [===== ] - Buffer.write_string(current, 1, 1, "[===== ]") - - output = render_frame(current, previous) - - # Should contain the change (the 5th = and space) - # But not re-render the entire bar - assert byte_size(output) < byte_size("[===== ]") + 20 - end - - test "progress bar renders multiple updates" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - progress_states = [ - "[ ]", - "[== ]", - "[==== ]", - "[====== ]", - "[======== ]", - "[==========]" - ] - - for state <- progress_states do - Buffer.clear(current) - Buffer.write_string(current, 1, 1, state) - - output = render_frame(current, previous) - assert byte_size(output) > 0 - - # Update previous for next iteration - Buffer.clear(previous) - Buffer.write_string(previous, 1, 1, state) - end - end - end - - describe "animation - update coalescing" do - test "high-frequency updates produce single render" do - test_pid = self() - render_count = :counters.new(1, []) - - {:ok, manager} = BufferManager.start_link(rows: 24, cols: 80) - - {:ok, limiter} = - FramerateLimiter.start_link( - name: :coalescing_test_limiter, - fps: 60, - render_callback: fn -> - :counters.add(render_count, 1, 1) - send(test_pid, :rendered) - end - ) - - # Mark dirty many times quickly - for _ <- 1..100 do - FramerateLimiter.mark_dirty(limiter) - end - - # Wait for frames - Process.sleep(100) - - # Should have coalesced to much fewer renders - renders = :counters.get(render_count, 1) - assert renders < 20 - - GenServer.stop(limiter) - GenServer.stop(manager) - end - end - - describe "resize handling" do - test "resize triggers buffer reallocation" do - {:ok, manager} = BufferManager.start_link(rows: 24, cols: 80) - - # Write some content - BufferManager.write_string(manager, 1, 1, "Test") - - # Resize - BufferManager.resize(manager, 40, 120) - - # Check new dimensions - assert BufferManager.dimensions(manager) == {40, 120} - - GenServer.stop(manager) - end - - test "content is preserved after resize" do - {:ok, manager} = BufferManager.start_link(rows: 24, cols: 80) - - # Write content - BufferManager.write_string(manager, 1, 1, "Preserved") - - # Resize larger - BufferManager.resize(manager, 40, 120) - - # Check content is preserved - buffer = BufferManager.get_current_buffer(manager) - cell = Buffer.get_cell(buffer, 1, 1) - assert cell.char == "P" - - GenServer.stop(manager) - end - - test "content is truncated on shrink" do - {:ok, manager} = BufferManager.start_link(rows: 24, cols: 80) - - # Write content at far position - BufferManager.write_string(manager, 20, 70, "Far") - - # Resize smaller - BufferManager.resize(manager, 10, 40) - - # Content beyond new bounds is gone - buffer = BufferManager.get_current_buffer(manager) - # Row 20 is now out of bounds (buffer only has 10 rows) - assert Buffer.dimensions(buffer) == {10, 40} - - GenServer.stop(manager) - end - - test "rapid resize sequence" do - {:ok, manager} = BufferManager.start_link(rows: 24, cols: 80) - - # Rapid resize sequence - for {rows, cols} <- [{30, 100}, {20, 60}, {40, 120}, {24, 80}] do - BufferManager.resize(manager, rows, cols) - assert BufferManager.dimensions(manager) == {rows, cols} - end - - GenServer.stop(manager) - end - end - - describe "performance benchmarking" do - @tag :benchmark - test "full screen render performance" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - # Fill screen with content - for row <- 1..24 do - text = String.duplicate("X", 80) - Buffer.write_string(current, row, 1, text) - end - - # Measure render time - {time_us, output} = - :timer.tc(fn -> - render_frame(current, previous) - end) - - # Should complete in reasonable time (< 10ms) - assert time_us < 10_000 - - # Should produce output - assert byte_size(output) > 0 - - # Log for visibility - IO.puts("\nFull screen render: #{time_us}μs, #{byte_size(output)} bytes") - end - - @tag :benchmark - test "incremental render performance" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - # Set up identical buffers - for row <- 1..24 do - text = String.duplicate("X", 80) - Buffer.write_string(current, row, 1, text) - Buffer.write_string(previous, row, 1, text) - end - - # Change only one cell - Buffer.set_cell(current, 12, 40, Cell.new("O")) - - # Measure render time - {time_us, output} = - :timer.tc(fn -> - render_frame(current, previous) - end) - - # Should be very fast (< 10ms, with margin for CI/slow machines) - assert time_us < 10_000 - - # Should produce minimal output - assert byte_size(output) < 50 - - IO.puts("\nIncremental render: #{time_us}μs, #{byte_size(output)} bytes") - end - - @tag :benchmark - test "diff algorithm performance" do - {:ok, current} = Buffer.new(40, 120) - {:ok, previous} = Buffer.new(40, 120) - - # Set up different content - for row <- 1..40 do - text = String.duplicate("A", 120) - Buffer.write_string(previous, row, 1, text) - char = if rem(row, 2) == 0, do: "B", else: "A" - text = String.duplicate(char, 120) - Buffer.write_string(current, row, 1, text) - end - - # Measure diff time - {time_us, operations} = - :timer.tc(fn -> - Diff.diff(current, previous) - end) - - # Should complete in reasonable time (allowing for system load variance) - # Increased threshold for CI/shared environments under load - assert time_us < 15_000 - - # Should produce operations - assert length(operations) > 0 - - cells = 40 * 120 - cells_per_ms = cells / (time_us / 1000) - IO.puts("\nDiff: #{time_us}μs for #{cells} cells (#{round(cells_per_ms)} cells/ms)") - end - - @tag :benchmark - test "cursor optimization savings" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - # Write at column 1 on multiple rows (ideal for CR optimization) - for row <- 1..20 do - Buffer.write_string(current, row, 1, "Line #{row}") - end - - # Render with optimization - optimized = render_frame(current, previous) - - # Calculate naive (absolute positioning) - naive_size = - Enum.reduce(1..20, 0, fn row, acc -> - text = "Line #{row}" - # ESC[row;1H = 4 base + digits(row) + digits(1) + text - # ESC [ row ; col H = 1+1+digits(row)+1+1+1 = 5 + digits(row) - pos_cost = 5 + if row >= 10, do: 2, else: 1 - acc + pos_cost + String.length(text) - end) - - savings = ((naive_size - byte_size(optimized)) / naive_size * 100) |> Float.round(1) - - IO.puts( - "\nCursor optimization: #{byte_size(optimized)} bytes vs #{naive_size} naive (#{savings}% savings)" - ) - - # Optimized should be reasonable (may be slightly larger now that - # bare \n is replaced with ANSI \e[B for OTP 28 raw mode correctness) - assert byte_size(optimized) < naive_size * 2 - end - end - - describe "edge cases" do - test "empty buffer produces no output" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - output = render_frame(current, previous) - assert output == "" - end - - test "identical buffers produce no output" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - Buffer.write_string(current, 1, 1, "Same") - Buffer.write_string(previous, 1, 1, "Same") - - output = render_frame(current, previous) - assert output == "" - end - - test "single character change" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - Buffer.set_cell(current, 1, 1, Cell.new("X")) - - output = render_frame(current, previous) - assert String.contains?(output, "X") - end - - test "unicode characters" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - Buffer.write_string(current, 1, 1, "Hello 世界") - - output = render_frame(current, previous) - assert String.contains?(output, "Hello") - assert String.contains?(output, "世界") - end - - test "256 colors" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - style = Style.new(fg: 196) - Buffer.write_string(current, 1, 1, "256", style: style) - - output = render_frame(current, previous) - assert String.contains?(output, "38;5;196") - end - - test "true color RGB" do - {:ok, current} = Buffer.new(24, 80) - {:ok, previous} = Buffer.new(24, 80) - - style = Style.new(fg: {255, 128, 64}) - Buffer.write_string(current, 1, 1, "RGB", style: style) - - output = render_frame(current, previous) - assert String.contains?(output, "38;2;255;128;64") - end - end -end diff --git a/test/term_ui/renderer/sequence_buffer_test.exs b/test/term_ui/renderer/sequence_buffer_test.exs deleted file mode 100644 index f9fbd425..00000000 --- a/test/term_ui/renderer/sequence_buffer_test.exs +++ /dev/null @@ -1,655 +0,0 @@ -defmodule TermUI.Renderer.SequenceBufferTest do - use ExUnit.Case, async: true - - alias TermUI.Renderer.SequenceBuffer - alias TermUI.Renderer.Style - - describe "new/0 and new/1" do - test "creates empty buffer with default threshold" do - buffer = SequenceBuffer.new() - assert SequenceBuffer.size(buffer) == 0 - assert SequenceBuffer.empty?(buffer) - end - - test "creates buffer with custom threshold" do - buffer = SequenceBuffer.new(threshold: 1024) - assert buffer.threshold == 1024 - end - end - - describe "append/2" do - test "appends data to buffer" do - buffer = SequenceBuffer.new() - {:ok, buffer} = SequenceBuffer.append(buffer, "Hello") - assert SequenceBuffer.size(buffer) == 5 - end - - test "accumulates multiple appends" do - buffer = SequenceBuffer.new() - {:ok, buffer} = SequenceBuffer.append(buffer, "Hello") - {:ok, buffer} = SequenceBuffer.append(buffer, " ") - {:ok, buffer} = SequenceBuffer.append(buffer, "World") - assert SequenceBuffer.size(buffer) == 11 - end - - test "triggers auto-flush when threshold exceeded" do - buffer = SequenceBuffer.new(threshold: 10) - {:ok, buffer} = SequenceBuffer.append(buffer, "12345") - {:flush, data, buffer} = SequenceBuffer.append(buffer, "67890!") - - assert IO.iodata_to_binary(data) == "1234567890!" - assert SequenceBuffer.size(buffer) == 0 - end - - test "handles iolist data" do - buffer = SequenceBuffer.new() - {:ok, buffer} = SequenceBuffer.append(buffer, ["Hello", " ", "World"]) - assert SequenceBuffer.size(buffer) == 11 - end - end - - describe "append!/2" do - test "appends without returning flush status" do - buffer = SequenceBuffer.new() - buffer = SequenceBuffer.append!(buffer, "Test") - assert SequenceBuffer.size(buffer) == 4 - end - - test "handles auto-flush silently" do - buffer = SequenceBuffer.new(threshold: 5) - buffer = SequenceBuffer.append!(buffer, "12345678") - # Buffer was auto-flushed - assert SequenceBuffer.size(buffer) == 0 - end - end - - describe "flush/1" do - test "returns accumulated data" do - buffer = SequenceBuffer.new() - buffer = SequenceBuffer.append!(buffer, "Hello") - buffer = SequenceBuffer.append!(buffer, " World") - - {data, _buffer} = SequenceBuffer.flush(buffer) - assert IO.iodata_to_binary(data) == "Hello World" - end - - test "resets buffer after flush" do - buffer = SequenceBuffer.new() - buffer = SequenceBuffer.append!(buffer, "Test") - {_data, buffer} = SequenceBuffer.flush(buffer) - - assert SequenceBuffer.size(buffer) == 0 - assert SequenceBuffer.empty?(buffer) - end - - test "increments flush count" do - buffer = SequenceBuffer.new() - buffer = SequenceBuffer.append!(buffer, "Test") - {_data, buffer} = SequenceBuffer.flush(buffer) - - {_bytes, count} = SequenceBuffer.stats(buffer) - assert count == 1 - end - - test "tracks total bytes" do - buffer = SequenceBuffer.new() - buffer = SequenceBuffer.append!(buffer, "12345") - {_data, buffer} = SequenceBuffer.flush(buffer) - - {bytes, _count} = SequenceBuffer.stats(buffer) - assert bytes == 5 - end - - test "handles empty buffer" do - buffer = SequenceBuffer.new() - {data, buffer} = SequenceBuffer.flush(buffer) - - assert data == [] - {bytes, count} = SequenceBuffer.stats(buffer) - assert bytes == 0 - assert count == 1 - end - end - - describe "SGR combining" do - test "add_sgr_param/2 accumulates parameters" do - buffer = SequenceBuffer.new() - buffer = SequenceBuffer.add_sgr_param(buffer, "1") - buffer = SequenceBuffer.add_sgr_param(buffer, "31") - - assert length(buffer.pending_sgr) == 2 - end - - test "emit_pending_sgr/1 outputs combined sequence" do - buffer = SequenceBuffer.new() - buffer = SequenceBuffer.add_sgr_param(buffer, "1") - buffer = SequenceBuffer.add_sgr_param(buffer, "31") - buffer = SequenceBuffer.emit_pending_sgr(buffer) - - {data, _buffer} = SequenceBuffer.flush(buffer) - binary = IO.iodata_to_binary(data) - - # Should be ESC[1;31m - assert binary == "\e[1;31m" - end - - test "emit_pending_sgr/1 clears pending" do - buffer = SequenceBuffer.new() - buffer = SequenceBuffer.add_sgr_param(buffer, "1") - buffer = SequenceBuffer.emit_pending_sgr(buffer) - - assert buffer.pending_sgr == [] - end - - test "flush emits pending SGR" do - buffer = SequenceBuffer.new() - buffer = SequenceBuffer.add_sgr_param(buffer, "4") - {data, _buffer} = SequenceBuffer.flush(buffer) - - binary = IO.iodata_to_binary(data) - assert binary == "\e[4m" - end - end - - describe "append_style/2" do - test "emits full SGR for first style" do - buffer = SequenceBuffer.new() - style = Style.new(fg: :red, attrs: [:bold]) - buffer = SequenceBuffer.append_style(buffer, style) - - {data, _buffer} = SequenceBuffer.flush(buffer) - binary = IO.iodata_to_binary(data) - - assert String.contains?(binary, "31") - assert String.contains?(binary, "1") - end - - test "emits only delta for subsequent style" do - buffer = SequenceBuffer.new() - style1 = Style.new(fg: :red, attrs: [:bold]) - style2 = Style.new(fg: :blue, attrs: [:bold]) - - buffer = SequenceBuffer.append_style(buffer, style1) - {_data, buffer} = SequenceBuffer.flush(buffer) - - buffer = SequenceBuffer.append_style(buffer, style2) - {data, _buffer} = SequenceBuffer.flush(buffer) - binary = IO.iodata_to_binary(data) - - # Should only emit blue (34), not bold again - assert String.contains?(binary, "34") - refute String.contains?(binary, ";1") - end - - test "emits nothing for identical style" do - buffer = SequenceBuffer.new() - style = Style.new(fg: :red) - - buffer = SequenceBuffer.append_style(buffer, style) - {_data, buffer} = SequenceBuffer.flush(buffer) - - buffer = SequenceBuffer.append_style(buffer, style) - {data, _buffer} = SequenceBuffer.flush(buffer) - - assert data == [] - end - - test "handles attribute removal" do - buffer = SequenceBuffer.new() - style1 = Style.new(attrs: [:bold, :underline]) - style2 = Style.new(attrs: [:bold]) - - buffer = SequenceBuffer.append_style(buffer, style1) - {_data, buffer} = SequenceBuffer.flush(buffer) - - buffer = SequenceBuffer.append_style(buffer, style2) - {data, _buffer} = SequenceBuffer.flush(buffer) - binary = IO.iodata_to_binary(data) - - # Should emit underline off (24) - assert String.contains?(binary, "24") - end - end - - describe "size tracking" do - test "size/1 returns current buffer size" do - buffer = SequenceBuffer.new() - assert SequenceBuffer.size(buffer) == 0 - - buffer = SequenceBuffer.append!(buffer, "12345") - assert SequenceBuffer.size(buffer) == 5 - end - - test "empty?/1 returns true for empty buffer" do - buffer = SequenceBuffer.new() - assert SequenceBuffer.empty?(buffer) - end - - test "empty?/1 returns false for non-empty buffer" do - buffer = SequenceBuffer.new() - buffer = SequenceBuffer.append!(buffer, "x") - refute SequenceBuffer.empty?(buffer) - end - end - - describe "to_iodata/1" do - test "returns buffer contents without flushing" do - buffer = SequenceBuffer.new() - buffer = SequenceBuffer.append!(buffer, "Test") - - data = SequenceBuffer.to_iodata(buffer) - assert IO.iodata_to_binary(data) == "Test" - - # Buffer still has content - assert SequenceBuffer.size(buffer) == 4 - end - end - - describe "reset_style/1" do - test "clears last style tracking" do - buffer = SequenceBuffer.new() - style = Style.new(fg: :red) - buffer = SequenceBuffer.append_style(buffer, style) - - buffer = SequenceBuffer.reset_style(buffer) - assert buffer.last_style == nil - end - end - - describe "clear/1" do - test "clears buffer without flushing" do - buffer = SequenceBuffer.new() - buffer = SequenceBuffer.append!(buffer, "Test") - buffer = SequenceBuffer.clear(buffer) - - assert SequenceBuffer.empty?(buffer) - - # Stats unchanged - {bytes, count} = SequenceBuffer.stats(buffer) - assert bytes == 0 - assert count == 0 - end - end - - describe "stats/1" do - test "tracks cumulative bytes across flushes" do - buffer = SequenceBuffer.new() - buffer = SequenceBuffer.append!(buffer, "12345") - {_data, buffer} = SequenceBuffer.flush(buffer) - - buffer = SequenceBuffer.append!(buffer, "67890") - {_data, buffer} = SequenceBuffer.flush(buffer) - - {bytes, count} = SequenceBuffer.stats(buffer) - assert bytes == 10 - assert count == 2 - end - end - - describe "color SGR codes" do - test "generates correct foreground color codes" do - buffer = SequenceBuffer.new() - - for {color, code} <- [ - {:black, "30"}, - {:red, "31"}, - {:green, "32"}, - {:yellow, "33"}, - {:blue, "34"}, - {:magenta, "35"}, - {:cyan, "36"}, - {:white, "37"} - ] do - style = Style.new(fg: color) - buf = SequenceBuffer.append_style(buffer, style) - {data, _} = SequenceBuffer.flush(buf) - binary = IO.iodata_to_binary(data) - assert String.contains?(binary, code), "Expected #{code} for #{color}" - end - end - - test "generates correct background color codes" do - buffer = SequenceBuffer.new() - - for {color, code} <- [ - {:black, "40"}, - {:red, "41"}, - {:green, "42"}, - {:yellow, "43"}, - {:blue, "44"}, - {:magenta, "45"}, - {:cyan, "46"}, - {:white, "47"} - ] do - style = Style.new(bg: color) - buf = SequenceBuffer.append_style(buffer, style) - {data, _} = SequenceBuffer.flush(buf) - binary = IO.iodata_to_binary(data) - assert String.contains?(binary, code), "Expected #{code} for #{color}" - end - end - - test "generates correct bright foreground color codes" do - buffer = SequenceBuffer.new() - - for {color, code} <- [ - {:bright_black, "90"}, - {:bright_red, "91"}, - {:bright_green, "92"}, - {:bright_yellow, "93"}, - {:bright_blue, "94"}, - {:bright_magenta, "95"}, - {:bright_cyan, "96"}, - {:bright_white, "97"} - ] do - style = Style.new(fg: color) - buf = SequenceBuffer.append_style(buffer, style) - {data, _} = SequenceBuffer.flush(buf) - binary = IO.iodata_to_binary(data) - assert String.contains?(binary, code), "Expected #{code} for #{color}" - end - end - - test "generates correct bright background color codes" do - buffer = SequenceBuffer.new() - - for {color, code} <- [ - {:bright_black, "100"}, - {:bright_red, "101"}, - {:bright_green, "102"}, - {:bright_yellow, "103"}, - {:bright_blue, "104"}, - {:bright_magenta, "105"}, - {:bright_cyan, "106"}, - {:bright_white, "107"} - ] do - style = Style.new(bg: color) - buf = SequenceBuffer.append_style(buffer, style) - {data, _} = SequenceBuffer.flush(buf) - binary = IO.iodata_to_binary(data) - assert String.contains?(binary, code), "Expected #{code} for #{color}" - end - end - - test "generates 256-color codes" do - buffer = SequenceBuffer.new() - style = Style.new(fg: 196) - buffer = SequenceBuffer.append_style(buffer, style) - {data, _buffer} = SequenceBuffer.flush(buffer) - binary = IO.iodata_to_binary(data) - - assert String.contains?(binary, "38;5;196") - end - - test "generates RGB color codes" do - buffer = SequenceBuffer.new() - style = Style.new(fg: {255, 128, 64}) - buffer = SequenceBuffer.append_style(buffer, style) - {data, _buffer} = SequenceBuffer.flush(buffer) - binary = IO.iodata_to_binary(data) - - assert String.contains?(binary, "38;2;255;128;64") - end - end - - describe "integration scenarios" do - test "typical render frame" do - buffer = SequenceBuffer.new() - - # Move cursor and set style - buffer = SequenceBuffer.append!(buffer, "\e[1;1H") - style = Style.new(fg: :green, attrs: [:bold]) - buffer = SequenceBuffer.append_style(buffer, style) - buffer = SequenceBuffer.append!(buffer, "Status: OK") - - # Another styled section - style2 = Style.new(fg: :red, attrs: [:bold]) - buffer = SequenceBuffer.append_style(buffer, style2) - buffer = SequenceBuffer.append!(buffer, " Warning") - - # Flush frame - {data, buffer} = SequenceBuffer.flush(buffer) - binary = IO.iodata_to_binary(data) - - assert String.contains?(binary, "\e[1;1H") - assert String.contains?(binary, "Status: OK") - assert String.contains?(binary, "Warning") - - {bytes, count} = SequenceBuffer.stats(buffer) - assert bytes > 0 - assert count == 1 - end - - test "multiple frames" do - buffer = SequenceBuffer.new() - - # Frame 1 - buffer = SequenceBuffer.append!(buffer, "Frame 1") - {_data, buffer} = SequenceBuffer.flush(buffer) - - # Frame 2 - buffer = SequenceBuffer.append!(buffer, "Frame 2") - {_data, buffer} = SequenceBuffer.flush(buffer) - - {bytes, count} = SequenceBuffer.stats(buffer) - assert bytes == 14 - assert count == 2 - end - end - - # ============================================================================= - # BUG FIX TEST: append!/2 was discarding auto-flushed data - # - # Bug: When threshold exceeded, append!/2 returned {:flush, _data, buffer} - # and silently discarded _data instead of writing it to IO. - # This caused large renders (>4KB) to lose most of their output. - # ============================================================================= - - describe "append!/2 auto-flush data preservation (bug fix)" do - import ExUnit.CaptureIO - - test "append!/2 writes flushed data to IO when threshold exceeded" do - # Create buffer with small threshold to trigger auto-flush - buffer = SequenceBuffer.new(threshold: 50) - - # First append stays under threshold - buffer = SequenceBuffer.append!(buffer, String.duplicate("A", 30)) - assert SequenceBuffer.size(buffer) == 30 - - # Second append exceeds threshold - should trigger auto-flush - # Bug: the flushed data was being discarded - output = - capture_io(fn -> - _buffer = SequenceBuffer.append!(buffer, String.duplicate("B", 30)) - end) - - # The auto-flushed data (AAA...BBB...) should have been written to IO - # Bug behavior: output == "" (data discarded) - # Fixed behavior: output contains the flushed data - assert String.length(output) >= 50, - "Auto-flushed data should be written to IO. " <> - "Expected >= 50 bytes, got #{String.length(output)} bytes. " <> - "Output: #{inspect(output)}" - - assert String.contains?(output, "AAAA"), - "Output should contain the first append's data" - end - - test "append!/2 preserves all data across multiple auto-flushes" do - buffer = SequenceBuffer.new(threshold: 20) - - # Accumulate data across multiple auto-flushes - total_output = - capture_io(fn -> - buffer = SequenceBuffer.append!(buffer, String.duplicate("1", 15)) - buffer = SequenceBuffer.append!(buffer, String.duplicate("2", 15)) - buffer = SequenceBuffer.append!(buffer, String.duplicate("3", 15)) - {final_data, _} = SequenceBuffer.flush(buffer) - IO.write(final_data) - end) - - # Should have all data: 111...222...333... - assert String.contains?(total_output, "1111"), - "Should contain first batch" - - assert String.contains?(total_output, "2222"), - "Should contain second batch" - - assert String.contains?(total_output, "3333"), - "Should contain third batch" - - # Total should be 45 characters - total_chars = String.length(String.replace(total_output, ~r/[^123]/, "")) - - assert total_chars == 45, - "Should have all 45 characters. Got #{total_chars}" - end - end - - describe "edge cases for coverage" do - test "style with only attributes emits attribute codes" do - buffer = SequenceBuffer.new() - # Style with only attributes, no colors - style = Style.new(attrs: [:bold]) - buffer = SequenceBuffer.append_style(buffer, style) - {data, _buffer} = SequenceBuffer.flush(buffer) - binary = IO.iodata_to_binary(data) - - # Should contain bold code but no color codes - assert String.contains?(binary, "1") - refute String.contains?(binary, "38;") - refute String.contains?(binary, "48;") - end - - test "style with nil colors does not emit SGR codes" do - buffer = SequenceBuffer.new() - # Style with nil colors (default new()) - style = Style.new() - buffer = SequenceBuffer.append_style(buffer, style) - {data, _buffer} = SequenceBuffer.flush(buffer) - binary = IO.iodata_to_binary(data) - - # Empty style should produce no output - assert binary == "" - end - - test "removing multiple attributes emits correct off codes" do - buffer = SequenceBuffer.new() - - # First style with multiple attributes - style1 = Style.new(attrs: [:bold, :italic, :underline]) - buffer = SequenceBuffer.append_style(buffer, style1) - {_data, buffer} = SequenceBuffer.flush(buffer) - - # Second style removes all attributes - style2 = Style.new() - buffer = SequenceBuffer.append_style(buffer, style2) - {data, _buffer} = SequenceBuffer.flush(buffer) - binary = IO.iodata_to_binary(data) - - # Should contain off codes for removed attributes - # bold off - assert String.contains?(binary, "22") - # italic off - assert String.contains?(binary, "23") - # underline off - assert String.contains?(binary, "24") - end - - test "removing blink attribute emits correct off code" do - buffer = SequenceBuffer.new() - - style1 = Style.new(attrs: [:blink]) - buffer = SequenceBuffer.append_style(buffer, style1) - {_data, buffer} = SequenceBuffer.flush(buffer) - - style2 = Style.new() - buffer = SequenceBuffer.append_style(buffer, style2) - {data, _buffer} = SequenceBuffer.flush(buffer) - binary = IO.iodata_to_binary(data) - - # blink off - assert String.contains?(binary, "25") - end - - test "removing reverse attribute emits correct off code" do - buffer = SequenceBuffer.new() - - style1 = Style.new(attrs: [:reverse]) - buffer = SequenceBuffer.append_style(buffer, style1) - {_data, buffer} = SequenceBuffer.flush(buffer) - - style2 = Style.new() - buffer = SequenceBuffer.append_style(buffer, style2) - {data, _buffer} = SequenceBuffer.flush(buffer) - binary = IO.iodata_to_binary(data) - - # reverse off - assert String.contains?(binary, "27") - end - - test "removing hidden attribute emits correct off code" do - buffer = SequenceBuffer.new() - - style1 = Style.new(attrs: [:hidden]) - buffer = SequenceBuffer.append_style(buffer, style1) - {_data, buffer} = SequenceBuffer.flush(buffer) - - style2 = Style.new() - buffer = SequenceBuffer.append_style(buffer, style2) - {data, _buffer} = SequenceBuffer.flush(buffer) - binary = IO.iodata_to_binary(data) - - # hidden off - assert String.contains?(binary, "28") - end - - test "removing strikethrough attribute emits correct off code" do - buffer = SequenceBuffer.new() - - style1 = Style.new(attrs: [:strikethrough]) - buffer = SequenceBuffer.append_style(buffer, style1) - {_data, buffer} = SequenceBuffer.flush(buffer) - - style2 = Style.new() - buffer = SequenceBuffer.append_style(buffer, style2) - {data, _buffer} = SequenceBuffer.flush(buffer) - binary = IO.iodata_to_binary(data) - - # strikethrough off - assert String.contains?(binary, "29") - end - - test "removing dim attribute uses same off code as bold" do - buffer = SequenceBuffer.new() - - style1 = Style.new(attrs: [:dim]) - buffer = SequenceBuffer.append_style(buffer, style1) - {_data, buffer} = SequenceBuffer.flush(buffer) - - style2 = Style.new() - buffer = SequenceBuffer.append_style(buffer, style2) - {data, _buffer} = SequenceBuffer.flush(buffer) - binary = IO.iodata_to_binary(data) - - # dim off (same as bold) - assert String.contains?(binary, "22") - end - - test "background color change emits new background code" do - buffer = SequenceBuffer.new() - - style1 = Style.new(bg: :red) - buffer = SequenceBuffer.append_style(buffer, style1) - {_data, buffer} = SequenceBuffer.flush(buffer) - - style2 = Style.new(bg: :blue) - buffer = SequenceBuffer.append_style(buffer, style2) - {data, _buffer} = SequenceBuffer.flush(buffer) - binary = IO.iodata_to_binary(data) - - # blue background - assert String.contains?(binary, "44") - end - end -end diff --git a/test/term_ui/renderer/style_test.exs b/test/term_ui/renderer/style_test.exs deleted file mode 100644 index 634b682e..00000000 --- a/test/term_ui/renderer/style_test.exs +++ /dev/null @@ -1,375 +0,0 @@ -defmodule TermUI.Renderer.StyleTest do - use ExUnit.Case, async: true - - alias TermUI.Renderer.{Cell, Style} - - describe "new/0" do - test "creates empty style" do - style = Style.new() - assert is_nil(style.fg) - assert is_nil(style.bg) - assert MapSet.size(style.attrs) == 0 - end - end - - describe "new/1" do - test "creates style with options" do - style = Style.new(fg: :red, bg: :blue, attrs: [:bold]) - assert style.fg == :red - assert style.bg == :blue - assert :bold in style.attrs - end - end - - describe "fluent builder" do - test "fg/2 sets foreground" do - style = Style.new() |> Style.fg(:red) - assert style.fg == :red - end - - test "bg/2 sets background" do - style = Style.new() |> Style.bg(:blue) - assert style.bg == :blue - end - - test "bold/1 adds bold attribute" do - style = Style.new() |> Style.bold() - assert :bold in style.attrs - end - - test "dim/1 adds dim attribute" do - style = Style.new() |> Style.dim() - assert :dim in style.attrs - end - - test "italic/1 adds italic attribute" do - style = Style.new() |> Style.italic() - assert :italic in style.attrs - end - - test "underline/1 adds underline attribute" do - style = Style.new() |> Style.underline() - assert :underline in style.attrs - end - - test "blink/1 adds blink attribute" do - style = Style.new() |> Style.blink() - assert :blink in style.attrs - end - - test "reverse/1 adds reverse attribute" do - style = Style.new() |> Style.reverse() - assert :reverse in style.attrs - end - - test "hidden/1 adds hidden attribute" do - style = Style.new() |> Style.hidden() - assert :hidden in style.attrs - end - - test "strikethrough/1 adds strikethrough attribute" do - style = Style.new() |> Style.strikethrough() - assert :strikethrough in style.attrs - end - - test "chaining multiple operations" do - style = - Style.new() - |> Style.fg(:red) - |> Style.bg(:black) - |> Style.bold() - |> Style.underline() - - assert style.fg == :red - assert style.bg == :black - assert :bold in style.attrs - assert :underline in style.attrs - end - end - - describe "add_attr/2" do - test "adds attribute" do - style = Style.new() |> Style.add_attr(:bold) - assert :bold in style.attrs - end - end - - describe "remove_attr/2" do - test "removes attribute" do - style = Style.new(attrs: [:bold, :italic]) |> Style.remove_attr(:bold) - refute :bold in style.attrs - assert :italic in style.attrs - end - end - - describe "merge/2" do - test "override replaces base colors" do - base = Style.new(fg: :white, bg: :black) - override = Style.new(fg: :red) - merged = Style.merge(base, override) - - assert merged.fg == :red - assert merged.bg == :black - end - - test "nil in override doesn't replace base" do - base = Style.new(fg: :white, bg: :black) - override = Style.new() - merged = Style.merge(base, override) - - assert merged.fg == :white - assert merged.bg == :black - end - - test "attributes are combined" do - base = Style.new(attrs: [:bold]) - override = Style.new(attrs: [:italic]) - merged = Style.merge(base, override) - - assert :bold in merged.attrs - assert :italic in merged.attrs - end - - test "merging empty styles" do - merged = Style.merge(Style.new(), Style.new()) - assert is_nil(merged.fg) - assert is_nil(merged.bg) - assert MapSet.size(merged.attrs) == 0 - end - - test "complete style merge" do - base = Style.new(fg: :white, bg: :black, attrs: [:bold]) - override = Style.new(fg: :red, attrs: [:underline]) - merged = Style.merge(base, override) - - assert merged.fg == :red - assert merged.bg == :black - assert :bold in merged.attrs - assert :underline in merged.attrs - end - end - - describe "to_cell/2" do - test "creates cell with character and style" do - style = Style.new() |> Style.fg(:red) |> Style.bold() - cell = Style.to_cell(style, "X") - - assert cell.char == "X" - assert cell.fg == :red - assert cell.bg == :default - assert :bold in cell.attrs - end - - test "uses default for unset colors" do - style = Style.new() - cell = Style.to_cell(style, "A") - - assert cell.fg == :default - assert cell.bg == :default - end - - test "preserves all attributes" do - style = Style.new(attrs: [:bold, :italic, :underline]) - cell = Style.to_cell(style, "X") - - assert :bold in cell.attrs - assert :italic in cell.attrs - assert :underline in cell.attrs - end - end - - describe "apply_to_cell/2" do - test "overrides cell colors with style" do - cell = Cell.new("A", fg: :white) - style = Style.new() |> Style.fg(:red) - new_cell = Style.apply_to_cell(style, cell) - - assert new_cell.fg == :red - assert new_cell.char == "A" - end - - test "preserves cell values for unset style values" do - cell = Cell.new("A", fg: :white, bg: :black) - style = Style.new() |> Style.fg(:red) - new_cell = Style.apply_to_cell(style, cell) - - assert new_cell.fg == :red - assert new_cell.bg == :black - end - - test "combines attributes" do - cell = Cell.new("A", attrs: [:bold]) - style = Style.new(attrs: [:italic]) - new_cell = Style.apply_to_cell(style, cell) - - assert :bold in new_cell.attrs - assert :italic in new_cell.attrs - end - end - - describe "reset/1" do - test "returns empty style" do - style = Style.new(fg: :red, attrs: [:bold]) - reset_style = Style.reset(style) - - assert is_nil(reset_style.fg) - assert is_nil(reset_style.bg) - assert MapSet.size(reset_style.attrs) == 0 - end - end - - describe "empty?/1" do - test "returns true for empty style" do - assert Style.empty?(Style.new()) - end - - test "returns false when fg set" do - refute Style.empty?(Style.new(fg: :red)) - end - - test "returns false when bg set" do - refute Style.empty?(Style.new(bg: :blue)) - end - - test "returns false when attrs set" do - refute Style.empty?(Style.new(attrs: [:bold])) - end - end - - describe "input validation" do - test "new/1 raises on invalid foreground color" do - assert_raise ArgumentError, fn -> - Style.new(fg: :invalid_color) - end - end - - test "new/1 raises on invalid background color" do - assert_raise ArgumentError, fn -> - Style.new(bg: :invalid_color) - end - end - - test "new/1 raises on invalid attribute" do - assert_raise ArgumentError, fn -> - Style.new(attrs: [:invalid_attr]) - end - end - - test "fg/2 raises on invalid color" do - assert_raise ArgumentError, fn -> - Style.new() |> Style.fg(:invalid_color) - end - end - - test "bg/2 raises on invalid color" do - assert_raise ArgumentError, fn -> - Style.new() |> Style.bg(:invalid_color) - end - end - - test "add_attr/2 raises on invalid attribute" do - assert_raise ArgumentError, fn -> - Style.new() |> Style.add_attr(:invalid_attr) - end - end - - test "new/1 raises on out-of-range 256-color" do - assert_raise ArgumentError, fn -> - Style.new(fg: 256) - end - end - - test "new/1 raises on out-of-range RGB" do - assert_raise ArgumentError, fn -> - Style.new(fg: {256, 0, 0}) - end - end - - test "accepts 256-color values" do - style = Style.new(fg: 196, bg: 21) - assert style.fg == 196 - assert style.bg == 21 - end - - test "accepts RGB color values" do - style = Style.new(fg: {255, 128, 0}, bg: {0, 0, 255}) - assert style.fg == {255, 128, 0} - assert style.bg == {0, 0, 255} - end - - test "accepts all named colors" do - style = Style.new(fg: :bright_red, bg: :bright_blue) - assert style.fg == :bright_red - assert style.bg == :bright_blue - end - - test "accepts all valid attributes" do - style = - Style.new( - attrs: [:bold, :dim, :italic, :underline, :blink, :reverse, :hidden, :strikethrough] - ) - - assert MapSet.size(style.attrs) == 8 - end - end - - describe "equal?/2" do - test "returns true for identical styles" do - s1 = Style.new(fg: :red, bg: :blue, attrs: [:bold, :italic]) - s2 = Style.new(fg: :red, bg: :blue, attrs: [:bold, :italic]) - assert Style.equal?(s1, s2) - end - - test "returns true for empty styles" do - assert Style.equal?(Style.new(), Style.new()) - end - - test "returns false for different foreground colors" do - s1 = Style.new(fg: :red) - s2 = Style.new(fg: :blue) - refute Style.equal?(s1, s2) - end - - test "returns false for different background colors" do - s1 = Style.new(bg: :red) - s2 = Style.new(bg: :blue) - refute Style.equal?(s1, s2) - end - - test "returns false for different attributes" do - s1 = Style.new(attrs: [:bold]) - s2 = Style.new(attrs: [:italic]) - refute Style.equal?(s1, s2) - end - - test "returns false when one has attribute other doesn't" do - s1 = Style.new(attrs: [:bold]) - s2 = Style.new() - refute Style.equal?(s1, s2) - end - - test "attribute order doesn't matter" do - s1 = Style.new(attrs: [:bold, :italic]) - s2 = Style.new(attrs: [:italic, :bold]) - assert Style.equal?(s1, s2) - end - - test "returns true for styles with 256-color" do - s1 = Style.new(fg: 196, bg: 21) - s2 = Style.new(fg: 196, bg: 21) - assert Style.equal?(s1, s2) - end - - test "returns true for styles with RGB color" do - s1 = Style.new(fg: {255, 128, 0}) - s2 = Style.new(fg: {255, 128, 0}) - assert Style.equal?(s1, s2) - end - - test "returns false for nil vs set color" do - s1 = Style.new(fg: :red) - s2 = Style.new() - refute Style.equal?(s1, s2) - end - end -end diff --git a/test/term_ui/runtime/node_renderer_test.exs b/test/term_ui/runtime/node_renderer_test.exs deleted file mode 100644 index 30700dc3..00000000 --- a/test/term_ui/runtime/node_renderer_test.exs +++ /dev/null @@ -1,172 +0,0 @@ -defmodule TermUI.Runtime.NodeRendererTest do - use ExUnit.Case, async: true - - alias TermUI.Renderer.Buffer - alias TermUI.Renderer.BufferManager - alias TermUI.Runtime.NodeRenderer - - setup do - # Generate a unique name for each test to avoid conflicts - name = :"buffer_manager_#{System.unique_integer([:positive])}" - {:ok, pid} = BufferManager.start_link(rows: 30, cols: 50, name: name) - - on_exit(fn -> - if Process.alive?(pid), do: GenServer.stop(pid) - end) - - {:ok, bm: pid} - end - - describe "render_to_buffer/4" do - test "renders text node", %{bm: bm} do - NodeRenderer.render_to_buffer({:text, "Hello"}, bm, 1, 1) - - buffer = BufferManager.get_current_buffer(bm) - assert Buffer.get_cell(buffer, 1, 1).char == "H" - assert Buffer.get_cell(buffer, 1, 2).char == "e" - assert Buffer.get_cell(buffer, 1, 3).char == "l" - assert Buffer.get_cell(buffer, 1, 4).char == "l" - assert Buffer.get_cell(buffer, 1, 5).char == "o" - end - - test "renders list of text nodes vertically", %{bm: bm} do - NodeRenderer.render_to_buffer([{:text, "Line1"}, {:text, "Line2"}], bm, 1, 1) - - buffer = BufferManager.get_current_buffer(bm) - assert Buffer.get_cell(buffer, 1, 1).char == "L" - assert Buffer.get_cell(buffer, 2, 1).char == "L" - end - end - - describe "viewport rendering" do - test "renders viewport content without scroll", %{bm: bm} do - viewport_node = %{ - type: :viewport, - content: {:text, "Hello World"}, - scroll_x: 0, - scroll_y: 0, - width: 20, - height: 5 - } - - {width, height} = NodeRenderer.render_to_buffer(viewport_node, bm, 1, 1) - - assert width == 20 - assert height == 5 - - buffer = BufferManager.get_current_buffer(bm) - assert Buffer.get_cell(buffer, 1, 1).char == "H" - assert Buffer.get_cell(buffer, 1, 2).char == "e" - assert Buffer.get_cell(buffer, 1, 5).char == "o" - end - - test "renders viewport content with horizontal scroll", %{bm: bm} do - viewport_node = %{ - type: :viewport, - content: {:text, "Hello World"}, - scroll_x: 6, - scroll_y: 0, - width: 10, - height: 5 - } - - NodeRenderer.render_to_buffer(viewport_node, bm, 1, 1) - - buffer = BufferManager.get_current_buffer(bm) - # After scrolling 6 chars, "World" should be at position 1 - assert Buffer.get_cell(buffer, 1, 1).char == "W" - assert Buffer.get_cell(buffer, 1, 2).char == "o" - assert Buffer.get_cell(buffer, 1, 3).char == "r" - end - - test "renders viewport content with vertical scroll", %{bm: bm} do - # Multi-line content - content = [{:text, "Line 1"}, {:text, "Line 2"}, {:text, "Line 3"}, {:text, "Line 4"}] - - viewport_node = %{ - type: :viewport, - content: content, - scroll_x: 0, - scroll_y: 2, - width: 20, - height: 2 - } - - NodeRenderer.render_to_buffer(viewport_node, bm, 1, 1) - - buffer = BufferManager.get_current_buffer(bm) - # After scrolling 2 lines, "Line 3" should be at row 1 - assert Buffer.get_cell(buffer, 1, 1).char == "L" - assert Buffer.get_cell(buffer, 1, 6).char == "3" - # And "Line 4" at row 2 - assert Buffer.get_cell(buffer, 2, 6).char == "4" - end - - test "clips content to viewport dimensions", %{bm: bm} do - # Content that exceeds viewport - viewport_node = %{ - type: :viewport, - content: {:text, "This is a very long line that should be clipped"}, - scroll_x: 0, - scroll_y: 0, - width: 10, - height: 1 - } - - {width, height} = NodeRenderer.render_to_buffer(viewport_node, bm, 5, 5) - - assert width == 10 - assert height == 1 - - buffer = BufferManager.get_current_buffer(bm) - # Content starts at (5, 5) - assert Buffer.get_cell(buffer, 5, 5).char == "T" - assert Buffer.get_cell(buffer, 5, 14).char == " " - end - - test "handles empty content", %{bm: bm} do - viewport_node = %{ - type: :viewport, - content: {:text, ""}, - scroll_x: 0, - scroll_y: 0, - width: 10, - height: 5 - } - - {width, height} = NodeRenderer.render_to_buffer(viewport_node, bm, 1, 1) - - assert width == 10 - assert height == 5 - end - - test "combined horizontal and vertical scroll", %{bm: bm} do - # Create a grid-like content - content = [ - {:text, "ABCDEFGHIJ"}, - {:text, "KLMNOPQRST"}, - {:text, "UVWXYZ0123"}, - {:text, "4567890abc"} - ] - - viewport_node = %{ - type: :viewport, - content: content, - scroll_x: 2, - scroll_y: 1, - width: 5, - height: 2 - } - - NodeRenderer.render_to_buffer(viewport_node, bm, 1, 1) - - buffer = BufferManager.get_current_buffer(bm) - # Row 1 should show "MNOPQ" (from "KLMNOPQRST" starting at col 3) - assert Buffer.get_cell(buffer, 1, 1).char == "M" - assert Buffer.get_cell(buffer, 1, 2).char == "N" - # Row 2 should show "WXYZ0" (from "UVWXYZ0123" starting at col 3) - assert Buffer.get_cell(buffer, 2, 1).char == "W" - assert Buffer.get_cell(buffer, 2, 2).char == "X" - end - end -end diff --git a/test/term_ui/runtime/resize_test.exs b/test/term_ui/runtime/resize_test.exs deleted file mode 100644 index 4495e43b..00000000 --- a/test/term_ui/runtime/resize_test.exs +++ /dev/null @@ -1,75 +0,0 @@ -defmodule TermUI.Runtime.ResizeTest do - use ExUnit.Case, async: false - - alias TermUI.Event - alias TermUI.Runtime - - # Simple test component - defmodule TestComponent do - @behaviour TermUI.Elm - - def init(_opts), do: %{resizes: []} - - def update({:resize, width, height}, state) do - %{state | resizes: [{width, height} | state.resizes]} - end - - def update(_msg, state), do: state - - def view(_state), do: TermUI.Elm.text("test") - - def event_to_msg(%Event.Resize{width: w, height: h}, _state) do - {:msg, {:resize, w, h}} - end - - def event_to_msg(_event, _state), do: :ignore - end - - describe "resize handling" do - test "Runtime handles terminal_resize message" do - {:ok, runtime} = Runtime.start_link(root: TestComponent, skip_terminal: true) - - # Send resize message - send(runtime, {:terminal_resize, {50, 100}}) - - # Give it time to process - Process.sleep(50) - - state = Runtime.get_state(runtime) - - # With skip_terminal: true, dimensions won't be updated - # because handle_resize checks terminal_started - # This tests that the message is handled without crashing - assert state != nil - - Runtime.shutdown(runtime) - end - - test "resize event is created with correct dimensions" do - resize_event = Event.Resize.new(120, 40) - - assert resize_event.width == 120 - assert resize_event.height == 40 - assert is_integer(resize_event.timestamp) - end - end - - describe "Event.Resize struct" do - test "has default values" do - resize = %Event.Resize{} - assert resize.width == 80 - assert resize.height == 24 - end - - test "new/2 creates event with dimensions" do - resize = Event.Resize.new(200, 50) - assert resize.width == 200 - assert resize.height == 50 - end - - test "new/3 accepts timestamp option" do - resize = Event.Resize.new(100, 50, timestamp: 12_345) - assert resize.timestamp == 12_345 - end - end -end diff --git a/test/term_ui/runtime/shutdown_test.exs b/test/term_ui/runtime/shutdown_test.exs deleted file mode 100644 index 7f2700fe..00000000 --- a/test/term_ui/runtime/shutdown_test.exs +++ /dev/null @@ -1,272 +0,0 @@ -defmodule TermUI.Runtime.ShutdownTest do - use ExUnit.Case, async: false - - alias TermUI.Command - alias TermUI.Runtime - - # Simple test component that can return quit command - defmodule QuitComponent do - @behaviour TermUI.Elm - - def init(_opts), do: %{quit_on_next: false} - - def update(:prepare_quit, state) do - %{state | quit_on_next: true} - end - - def update(:quit, state) do - {state, [Command.quit()]} - end - - def update(:quit_with_reason, state) do - {state, [Command.quit(:user_requested)]} - end - - def update(_msg, state), do: state - - def view(_state), do: {:text, "quit component"} - - def event_to_msg(_event, _state), do: :ignore - end - - defmodule LifecycleComponent do - use TermUI.Elm - - def init(opts), do: %{owner: Keyword.fetch!(opts, :owner), dimensions: opts[:dimensions]} - def update(_message, state), do: {state, []} - def view(_state), do: {:text, "lifecycle component"} - - def terminate(reason, state) do - send(state.owner, {:root_terminated, reason, state.dimensions}) - :ok - end - end - - describe "quit command" do - test "Command.quit/0 creates quit command" do - cmd = Command.quit() - assert cmd.type == :quit - assert cmd.payload == :normal - end - - test "Command.quit/1 creates quit command with reason" do - cmd = Command.quit(:user_requested) - assert cmd.type == :quit - assert cmd.payload == :user_requested - end - - test "quit command is valid" do - cmd = Command.quit() - assert Command.valid?(cmd) - end - - test "quit command with custom reason is valid" do - cmd = Command.quit({:error, :some_reason}) - assert Command.valid?(cmd) - end - end - - describe "Runtime shutdown via quit command" do - test "quit command triggers shutdown" do - {:ok, runtime} = Runtime.start_link(root: QuitComponent, skip_terminal: true) - - # Verify runtime is alive - assert Process.alive?(runtime) - - # Send quit message to component - Runtime.send_message(runtime, :root, :quit) - - # Wait for shutdown - Process.sleep(100) - - # Runtime should have stopped - refute Process.alive?(runtime) - end - - test "shutdown sets shutting_down flag" do - {:ok, runtime} = Runtime.start_link(root: QuitComponent, skip_terminal: true) - - # Get initial state - state = Runtime.get_state(runtime) - refute state.shutting_down - - # Trigger shutdown - Runtime.shutdown(runtime) - - # Wait a bit for state to update - Process.sleep(50) - - # Try to get state - may fail if already stopped - # The important thing is that shutdown was initiated - case Process.alive?(runtime) do - true -> - state = Runtime.get_state(runtime) - assert state.shutting_down - - false -> - # Already stopped, which means shutdown worked - assert true - end - end - end - - describe "Runtime.shutdown/1" do - test "shutdown stops the runtime" do - {:ok, runtime} = Runtime.start_link(root: QuitComponent, skip_terminal: true) - - assert Process.alive?(runtime) - - Runtime.shutdown(runtime) - - # Wait for shutdown - Process.sleep(100) - - refute Process.alive?(runtime) - end - - test "shutdown clears pending commands" do - {:ok, runtime} = Runtime.start_link(root: QuitComponent, skip_terminal: true) - - # Shutdown - Runtime.shutdown(runtime) - - # Wait briefly - Process.sleep(50) - - # State should be cleared if process is still alive - if Process.alive?(runtime) do - state = Runtime.get_state(runtime) - assert state.pending_commands == %{} - end - end - - test "shutdown clears components" do - {:ok, runtime} = Runtime.start_link(root: QuitComponent, skip_terminal: true) - - Runtime.shutdown(runtime) - - Process.sleep(50) - - if Process.alive?(runtime) do - state = Runtime.get_state(runtime) - assert state.components == %{} - end - end - end - - describe "terminate/2" do - test "terminate is called on normal shutdown" do - {:ok, runtime} = Runtime.start_link(root: QuitComponent, skip_terminal: true) - - # Monitor the process to confirm it terminates - ref = Process.monitor(runtime) - - Runtime.shutdown(runtime) - - # Wait for DOWN message - assert_receive {:DOWN, ^ref, :process, ^runtime, :normal}, 1000 - end - - test "terminate handles missing input_reader gracefully" do - # skip_terminal: true means input_reader is nil - {:ok, runtime} = Runtime.start_link(root: QuitComponent, skip_terminal: true) - - state = Runtime.get_state(runtime) - assert state.input_reader == nil - - # Should not crash - Runtime.shutdown(runtime) - - Process.sleep(100) - refute Process.alive?(runtime) - end - - test "calls the root cleanup with its final state" do - {:ok, runtime} = - Runtime.start_link( - root: LifecycleComponent, - owner: self(), - dimensions: {100, 40}, - skip_terminal: true - ) - - Runtime.shutdown(runtime) - - assert_receive {:root_terminated, :normal, {100, 40}}, 1_000 - end - - test "stops the command executor with the runtime" do - {:ok, runtime} = - Runtime.start_link(root: LifecycleComponent, owner: self(), skip_terminal: true) - - executor = Runtime.get_state(runtime).command_executor - runtime_ref = Process.monitor(runtime) - executor_ref = Process.monitor(executor) - - Runtime.shutdown(runtime) - - assert_receive {:DOWN, ^runtime_ref, :process, ^runtime, :normal}, 1_000 - assert_receive {:DOWN, ^executor_ref, :process, ^executor, :normal}, 1_000 - end - end - - describe "trap_exit" do - test "runtime traps exits to ensure cleanup" do - {:ok, runtime} = Runtime.start_link(root: QuitComponent, skip_terminal: true) - - # The runtime should trap exits to ensure terminate/2 is called - # We can verify this by checking the process info - {:trap_exit, trapping} = Process.info(runtime, :trap_exit) - assert trapping == true - - # Cleanup - Runtime.shutdown(runtime) - Process.sleep(100) - end - end - - describe "events during shutdown" do - test "events are ignored during shutdown" do - {:ok, runtime} = Runtime.start_link(root: QuitComponent, skip_terminal: true) - - # Initiate shutdown - Runtime.shutdown(runtime) - - # Try to send an event - should not crash - Runtime.send_event(runtime, %TermUI.Event.Key{key: "a"}) - - # Give it time to process - Process.sleep(100) - - # Process should be stopped or stopping - # The important thing is no crash occurred - end - - test "messages are ignored during shutdown" do - {:ok, runtime} = - Runtime.start_link(root: LifecycleComponent, owner: self(), skip_terminal: true) - - ref = Process.monitor(runtime) - - Runtime.shutdown(runtime) - send(runtime, :late_application_message) - - assert_receive {:DOWN, ^ref, :process, ^runtime, :normal}, 1_000 - end - end - - describe "double shutdown" do - test "double shutdown is safe" do - {:ok, runtime} = Runtime.start_link(root: QuitComponent, skip_terminal: true) - - ref = Process.monitor(runtime) - - # Call shutdown twice - Runtime.shutdown(runtime) - Runtime.shutdown(runtime) - - # Should terminate normally - assert_receive {:DOWN, ^ref, :process, ^runtime, _reason}, 1000 - end - end -end diff --git a/test/term_ui/runtime_test.exs b/test/term_ui/runtime_test.exs deleted file mode 100644 index 9633b30d..00000000 --- a/test/term_ui/runtime_test.exs +++ /dev/null @@ -1,1004 +0,0 @@ -defmodule TermUI.RuntimeTest do - use ExUnit.Case, async: false - import ExUnit.CaptureLog - - alias TermUI.Event - alias TermUI.Runtime - - # Helper to start runtime without terminal (for test isolation) - defp start_test_runtime(opts) do - Runtime.start_link([skip_terminal: true] ++ opts) - end - - # Clean up persistent_term values between tests - setup do - # Store original values - original_backend_mode = :persistent_term.get(:term_ui_backend_mode, :not_set) - original_capabilities = :persistent_term.get(:term_ui_capabilities, :not_set) - - on_exit(fn -> - # Restore or clean up persistent_term - if original_backend_mode != :not_set do - :persistent_term.put(:term_ui_backend_mode, original_backend_mode) - else - :persistent_term.erase(:term_ui_backend_mode) - end - - if original_capabilities != :not_set do - :persistent_term.put(:term_ui_capabilities, original_capabilities) - else - :persistent_term.erase(:term_ui_capabilities) - end - end) - - :ok - end - - # Test component that implements Elm behaviour - defmodule Counter do - use TermUI.Elm - - def init(opts), do: %{count: Keyword.get(opts, :initial, 0)} - - def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :increment} - def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :decrement} - def event_to_msg(%Event.Key{key: :q}, _state), do: {:msg, :quit} - def event_to_msg(%Event.Resize{width: w, height: h}, _state), do: {:msg, {:resize, w, h}} - def event_to_msg(_, _), do: :ignore - - def update(:increment, state), do: {%{state | count: state.count + 1}, []} - def update(:decrement, state), do: {%{state | count: state.count - 1}, []} - def update(:quit, state), do: {state, [:quit]} - def update({:resize, w, h}, state), do: {Map.merge(state, %{width: w, height: h}), []} - def update(_, state), do: {state, []} - - def view(state), do: {:text, "Count: #{state.count}"} - end - - # Test component without init - defmodule NoInit do - use TermUI.Elm - - def event_to_msg(_, _), do: :ignore - def update(_, state), do: {state, []} - def view(_state), do: {:text, "No init"} - end - - defmodule SessionRoot do - use TermUI.Elm - - def init(opts) do - %{owner: Keyword.fetch!(opts, :owner), view: nil} - end - - def handle_info({:session_view, view}, state) do - {%{state | view: view}, [{:send, state.owner, {:session_view_applied, view}}]} - end - - def handle_info(:noop, _state), do: :noreply - - def update(_message, state), do: {state, []} - def view(state), do: {:text, inspect(state.view)} - end - - defmodule StyledBlankRoot do - use TermUI.Elm - - alias TermUI.Component.RenderNode - alias TermUI.Renderer.Cell - - def init(_opts), do: %{} - def update(_message, state), do: {state, []} - - def view(_state) do - RenderNode.cells([%{x: 0, y: 0, cell: Cell.new(" ", attrs: [:reverse])}], - width: 2, - height: 2 - ) - end - end - - defmodule CaptureBackend do - @behaviour TermUI.Backend - - def init(opts), do: {:ok, %{owner: Keyword.fetch!(opts, :owner), size: {2, 2}}} - def shutdown(_state), do: :ok - def size(state), do: {:ok, state.size} - def move_cursor(state, _position), do: {:ok, state} - def hide_cursor(state), do: {:ok, state} - def show_cursor(state), do: {:ok, state} - def clear(state), do: {:ok, state} - - def draw_cells(state, cells) do - send(state.owner, {:draw_cells, cells}) - {:ok, state} - end - - def flush(state), do: {:ok, state} - def poll_event(state, _timeout), do: {:timeout, state} - end - - defmodule RunFailureRoot do - use TermUI.Elm - - def init(opts) do - owner = Keyword.fetch!(opts, :owner) - marker = Keyword.fetch!(opts, :marker) - send(owner, {:run_root_initialized, self(), marker}) - %{} - end - - def update(_message, state), do: {state, []} - def view(_state), do: {:text, "failure test"} - end - - defmodule RunFailureBackend do - @behaviour TermUI.Backend - - def init(opts) do - {:ok, - %{ - owner: Keyword.fetch!(opts, :owner), - failure: Keyword.fetch!(opts, :failure), - size: {2, 2} - }} - end - - def shutdown(_state), do: :ok - def size(state), do: {:ok, state.size} - def move_cursor(state, _position), do: {:ok, state} - def hide_cursor(state), do: {:ok, state} - def show_cursor(state), do: {:ok, state} - def clear(state), do: {:ok, state} - - def draw_cells(%{failure: :draw} = state, _cells) do - fail_when_released(state, :draw, :draw_failed) - end - - def draw_cells(state, _cells), do: {:ok, state} - - def flush(%{failure: :flush} = state) do - fail_when_released(state, :flush, :flush_failed) - end - - def flush(state), do: {:ok, state} - def poll_event(state, _timeout), do: {:timeout, state} - - defp fail_when_released(state, stage, reason) do - send(state.owner, {:backend_failure_ready, self(), stage}) - - receive do - {:release_backend_failure, ^stage} -> {:error, reason} - end - end - end - - defp assert_run_failure(stage, expected_reason) do - owner = self() - marker = make_ref() - runtime_name = :"run_failure_#{System.unique_integer([:positive])}" - - caller = - spawn(fn -> - previous_trap_exit = Process.flag(:trap_exit, false) - send(owner, {:run_caller_ready, self(), previous_trap_exit}) - - result = - Runtime.run( - root: RunFailureRoot, - owner: owner, - marker: marker, - name: runtime_name, - backend: {RunFailureBackend, [owner: owner, failure: stage]}, - render_interval: 60_000 - ) - - send(owner, {:run_result, self(), result}) - end) - - caller_ref = Process.monitor(caller) - assert_receive {:run_caller_ready, ^caller, false}, 1_000 - assert_receive {:run_root_initialized, runtime, ^marker}, 1_000 - - state = Runtime.get_state(runtime) - buffer_manager = state.buffer_manager - command_executor = state.command_executor - runtime_ref = Process.monitor(runtime) - buffer_ref = Process.monitor(buffer_manager) - executor_ref = Process.monitor(command_executor) - - assert Process.whereis(runtime_name) == runtime - - Runtime.force_render(runtime) - assert_receive {:backend_failure_ready, ^runtime, ^stage}, 1_000 - assert caller in elem(Process.info(runtime, :monitored_by), 1) - - send(runtime, {:release_backend_failure, stage}) - - assert_receive {:run_result, ^caller, {:error, ^expected_reason}}, 1_000 - assert_receive {:DOWN, ^runtime_ref, :process, ^runtime, ^expected_reason}, 1_000 - assert_receive {:DOWN, ^buffer_ref, :process, ^buffer_manager, :normal}, 1_000 - assert_receive {:DOWN, ^executor_ref, :process, ^command_executor, :normal}, 1_000 - assert_receive {:DOWN, ^caller_ref, :process, ^caller, :normal}, 1_000 - refute Process.whereis(runtime_name) - end - - describe "start_link/1" do - test "starts runtime with root component" do - {:ok, runtime} = start_test_runtime(root: Counter) - - state = Runtime.get_state(runtime) - assert state.root_module == Counter - assert state.root_state == %{count: 0} - refute state.shutting_down - end - - test "starts runtime with registered name" do - {:ok, _runtime} = start_test_runtime(root: Counter, name: :test_runtime) - - state = Runtime.get_state(:test_runtime) - assert state.root_module == Counter - - GenServer.stop(:test_runtime) - end - - test "passes options to component init" do - {:ok, runtime} = start_test_runtime(root: Counter, initial: 10) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 10 - end - - test "handles component without init function" do - {:ok, runtime} = start_test_runtime(root: NoInit) - - state = Runtime.get_state(runtime) - assert state.root_state == %{} - end - - test "sets custom render interval" do - {:ok, runtime} = start_test_runtime(root: Counter, render_interval: 100) - - state = Runtime.get_state(runtime) - assert state.render_interval == 100 - end - end - - describe "run/1" do - test "returns a draw failure and stops runtime-owned processes" do - assert_run_failure(:draw, {:shutdown, {:backend_draw_failed, :draw_failed}}) - end - - test "returns a flush failure and stops runtime-owned processes" do - assert_run_failure(:flush, {:shutdown, {:backend_flush_failed, :flush_failed}}) - end - end - - describe "send_event/2" do - test "dispatches keyboard event to focused component" do - {:ok, runtime} = start_test_runtime(root: Counter) - - Runtime.send_event(runtime, Event.key(:up)) - # Wait for message processing - Process.sleep(50) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - end - - test "processes multiple events in sequence" do - {:ok, runtime} = start_test_runtime(root: Counter) - - Runtime.send_event(runtime, Event.key(:up)) - Runtime.send_event(runtime, Event.key(:up)) - Runtime.send_event(runtime, Event.key(:up)) - Process.sleep(50) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 3 - end - - test "ignores events during shutdown" do - {:ok, runtime} = start_test_runtime(root: Counter) - - Runtime.shutdown(runtime) - Runtime.send_event(runtime, Event.key(:up)) - Process.sleep(50) - - # Process should have stopped after shutdown - # Events during shutdown should be ignored without crash - refute Process.alive?(runtime) - end - - test "broadcasts resize events" do - {:ok, runtime} = start_test_runtime(root: Counter) - - Runtime.send_event(runtime, Event.resize(120, 40)) - Process.sleep(50) - - state = Runtime.get_state(runtime) - assert state.root_state.width == 120 - assert state.root_state.height == 40 - end - - test "dispatches paste events to focused component" do - {:ok, runtime} = start_test_runtime(root: Counter) - - # Paste events go to focused component but Counter ignores them - Runtime.send_event(runtime, Event.paste("hello")) - Process.sleep(50) - - state = Runtime.get_state(runtime) - # State unchanged since Counter ignores paste - assert state.root_state.count == 0 - end - end - - describe "send_message/3" do - test "sends message directly to component" do - {:ok, runtime} = start_test_runtime(root: Counter) - - Runtime.send_message(runtime, :root, :increment) - Process.sleep(50) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - end - - test "ignores messages to non-existent component" do - {:ok, runtime} = start_test_runtime(root: Counter) - - Runtime.send_message(runtime, :nonexistent, :increment) - Process.sleep(50) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 0 - end - - test "ignores messages during shutdown" do - {:ok, runtime} = start_test_runtime(root: Counter) - - Runtime.shutdown(runtime) - Runtime.send_message(runtime, :root, :increment) - Process.sleep(50) - - # Process should have stopped after shutdown - # Messages during shutdown should be ignored without crash - refute Process.alive?(runtime) - end - end - - describe "application messages" do - test "routes external messages through the Elm result contract" do - {:ok, runtime} = start_test_runtime(root: SessionRoot, owner: self()) - view = %{revision: 2, status: :running} - - send(runtime, {:session_view, view}) - assert :ok = Runtime.sync(runtime) - - assert Runtime.get_state(runtime).root_state.view == view - assert_receive {:session_view_applied, ^view} - end - - test "keeps application state when handle_info returns noreply" do - {:ok, runtime} = start_test_runtime(root: SessionRoot, owner: self()) - Process.sleep(20) - original = Runtime.get_state(runtime).root_state - refute Runtime.get_state(runtime).dirty - - send(runtime, :noop) - assert :ok = Runtime.sync(runtime) - - assert Runtime.get_state(runtime).root_state == original - refute Runtime.get_state(runtime).dirty - end - end - - describe "dirty flag and rendering" do - test "renders attributes on a blank cell" do - {:ok, runtime} = - Runtime.start_link( - root: StyledBlankRoot, - backend: {CaptureBackend, [owner: self()]}, - render_interval: 1 - ) - - assert_receive {:draw_cells, cells}, 1_000 - assert {{1, 1}, {" ", :default, :default, attributes}} = List.keyfind(cells, {1, 1}, 0) - assert :reverse in attributes - - Runtime.shutdown(runtime) - end - - test "marks dirty when state changes" do - {:ok, runtime} = start_test_runtime(root: Counter, render_interval: 10) - - # Initial state should be dirty for first render - state = Runtime.get_state(runtime) - assert state.dirty == true - - # After render tick, should be clean - Process.sleep(50) - state = Runtime.get_state(runtime) - assert state.dirty == false - - # After event that changes state, should be dirty then clean after render - Runtime.send_event(runtime, Event.key(:up)) - Process.sleep(50) - state = Runtime.get_state(runtime) - # Count should update - assert state.root_state.count == 1 - end - - test "force_render bypasses framerate limiter" do - {:ok, runtime} = start_test_runtime(root: Counter, render_interval: 10_000) - - # Initial dirty - state = Runtime.get_state(runtime) - assert state.dirty == true - - # Force render - Runtime.force_render(runtime) - Process.sleep(10) - - state = Runtime.get_state(runtime) - assert state.dirty == false - end - end - - describe "command collection" do - test "collects commands from update results" do - {:ok, runtime} = start_test_runtime(root: Counter) - - # Send event that produces a quit command - # This should trigger shutdown - Runtime.send_event(runtime, Event.key(:q)) - Process.sleep(100) - - # Process should have stopped due to quit command - refute Process.alive?(runtime) - end - end - - describe "command_result/4" do - test "sends command result as message to component" do - {:ok, runtime} = start_test_runtime(root: Counter) - - # Simulate a command completion - Runtime.command_result(runtime, :root, make_ref(), :some_result) - Process.sleep(50) - - # Result is enqueued as message (Counter ignores unknown messages) - state = Runtime.get_state(runtime) - assert state.root_state.count == 0 - end - end - - describe "shutdown/1" do - test "initiates graceful shutdown" do - {:ok, runtime} = start_test_runtime(root: Counter) - - # Monitor the process - ref = Process.monitor(runtime) - - Runtime.shutdown(runtime) - - # Process should stop after shutdown - assert_receive {:DOWN, ^ref, :process, ^runtime, :normal}, 1000 - end - - test "clears pending commands on shutdown" do - {:ok, runtime} = start_test_runtime(root: Counter) - - Runtime.shutdown(runtime) - Process.sleep(100) - - # Process should have stopped after cleanup - refute Process.alive?(runtime) - end - - test "clears components on shutdown" do - {:ok, runtime} = start_test_runtime(root: Counter) - - Runtime.shutdown(runtime) - Process.sleep(100) - - # Process should have stopped after cleanup - refute Process.alive?(runtime) - end - - test "headless shutdown does not run terminal cleanup" do - log = - capture_log(fn -> - {:ok, runtime} = start_test_runtime(root: Counter) - GenServer.stop(runtime) - end) - - refute log =~ "stty" - end - end - - describe "event dispatch routing" do - test "keyboard events go to focused component" do - {:ok, runtime} = start_test_runtime(root: Counter) - - # Default focus is :root - state = Runtime.get_state(runtime) - assert state.focused_component == :root - - Runtime.send_event(runtime, Event.key(:up)) - Process.sleep(50) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - end - - test "mouse events go to root (spatial index not implemented)" do - {:ok, runtime} = start_test_runtime(root: Counter) - - # Mouse events currently just go to root - Runtime.send_event(runtime, Event.mouse(:click, :left, 10, 10)) - Process.sleep(50) - - # Counter ignores mouse events - state = Runtime.get_state(runtime) - assert state.root_state.count == 0 - end - - test "focus events broadcast to all components" do - {:ok, runtime} = start_test_runtime(root: Counter) - - Runtime.send_event(runtime, Event.focus(:gained)) - Process.sleep(50) - - # Counter ignores focus events - state = Runtime.get_state(runtime) - assert state.root_state.count == 0 - end - - test "tick events broadcast to all components" do - {:ok, runtime} = start_test_runtime(root: Counter) - - Runtime.send_event(runtime, Event.tick(16)) - Process.sleep(50) - - # Counter ignores tick events - state = Runtime.get_state(runtime) - assert state.root_state.count == 0 - end - end - - describe "component initialization" do - test "initializes component registry with root" do - {:ok, runtime} = start_test_runtime(root: Counter, initial: 5) - - state = Runtime.get_state(runtime) - - assert Map.has_key?(state.components, :root) - assert state.components.root.module == Counter - assert state.components.root.state.count == 5 - end - end - - describe "render timing" do - test "uses default render interval" do - {:ok, runtime} = start_test_runtime(root: Counter) - - state = Runtime.get_state(runtime) - assert state.render_interval == 16 - end - - test "schedules render ticks" do - {:ok, runtime} = start_test_runtime(root: Counter, render_interval: 10) - - # Initial should be dirty - state = Runtime.get_state(runtime) - assert state.dirty == true - - # Wait for render tick - Process.sleep(30) - - state = Runtime.get_state(runtime) - assert state.dirty == false - end - end - - describe "message batching" do - test "processes multiple messages before render" do - {:ok, runtime} = start_test_runtime(root: Counter, render_interval: 100) - - # Send multiple events quickly - for _ <- 1..5 do - Runtime.send_event(runtime, Event.key(:up)) - end - - # Wait for processing - Process.sleep(150) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 5 - end - end - - describe "full cycle integration" do - test "event -> message -> update -> view cycle" do - {:ok, runtime} = start_test_runtime(root: Counter) - - # Initial state - state = Runtime.get_state(runtime) - assert state.root_state.count == 0 - - # Send event - Runtime.send_event(runtime, Event.key(:up)) - Process.sleep(50) - - # State updated - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - - # View would be called on render - %{module: module, state: component_state} = state.components.root - view_result = module.view(component_state) - assert view_result == {:text, "Count: 1"} - end - - test "handles decrement correctly" do - {:ok, runtime} = start_test_runtime(root: Counter, initial: 5) - - Runtime.send_event(runtime, Event.key(:down)) - Process.sleep(50) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 4 - end - - test "state changes trigger dirty flag" do - {:ok, runtime} = start_test_runtime(root: Counter, render_interval: 10) - - # Wait for initial render - Process.sleep(50) - - state = Runtime.get_state(runtime) - assert state.dirty == false - - # Send event that changes state - Runtime.send_event(runtime, Event.key(:up)) - Process.sleep(50) - - state = Runtime.get_state(runtime) - # Count should update - assert state.root_state.count == 1 - end - end - - describe "backend selection" do - test "stores backend mode in state when skip_terminal is used" do - {:ok, runtime} = start_test_runtime(root: Counter) - - state = Runtime.get_state(runtime) - assert state.backend_mode == :skip - assert state.backend == nil - end - - test "stores backend mode in persistent_term" do - {:ok, _runtime} = start_test_runtime(root: Counter) - - assert Runtime.backend_mode() == :skip - end - - test "stores capabilities in persistent_term" do - {:ok, _runtime} = start_test_runtime(root: Counter) - - # skip_terminal mode doesn't set capabilities - assert Runtime.capabilities() == nil - end - - test "backend_mode/0 returns nil when no runtime started" do - # Ensure we're not picking up values from other tests - :persistent_term.erase(:term_ui_backend_mode) - assert Runtime.backend_mode() == nil - end - - test "capabilities/0 returns nil when no runtime started" do - # Ensure we're not picking up values from other tests - :persistent_term.erase(:term_ui_capabilities) - assert Runtime.capabilities() == nil - end - end - - describe "backend option handling" do - test "accepts :auto backend option" do - # With skip_terminal, the actual backend selection is bypassed - # but the option should still be accepted - {:ok, runtime} = Runtime.start_link(root: Counter, backend: :auto, skip_terminal: true) - - state = Runtime.get_state(runtime) - assert state.backend_mode == :skip - end - - test "accepts :tty backend option" do - {:ok, runtime} = Runtime.start_link(root: Counter, backend: :tty, skip_terminal: true) - - state = Runtime.get_state(runtime) - assert state.backend_mode == :skip - end - - test "accepts TermUI.Backend.TTY explicit backend" do - {:ok, runtime} = - Runtime.start_link(root: Counter, backend: TermUI.Backend.TTY, skip_terminal: true) - - state = Runtime.get_state(runtime) - assert state.backend_mode == :skip - end - end - - describe "backend selector integration" do - test "calls Selector.select/1 during initialization" do - # Verify that the selector is being called by checking that it works - # We can't easily test the actual raw mode without a terminal - # but we can test that the option is passed through - - # Start with explicit TTY backend - {:ok, runtime} = - Runtime.start_link(root: Counter, backend: TermUI.Backend.TTY, skip_terminal: true) - - state = Runtime.get_state(runtime) - # With skip_terminal, backend_mode is :skip - assert state.backend_mode == :skip - end - - test "stores backend module in state" do - {:ok, runtime} = start_test_runtime(root: Counter) - - state = Runtime.get_state(runtime) - # With skip_terminal, backend is nil - assert state.backend == nil - end - end - - describe "input handler integration" do - test "does not use input handler by default" do - {:ok, runtime} = start_test_runtime(root: Counter) - - state = Runtime.get_state(runtime) - # By default, input_handler is nil (legacy InputReader is used) - assert state.input_handler == nil - assert state.input_state == nil - end - - test "initializes input handler when use_input_handler is true" do - {:ok, runtime} = - Runtime.start_link(root: Counter, use_input_handler: true, skip_terminal: true) - - state = Runtime.get_state(runtime) - # With skip_terminal and backend_mode :skip, no input handler is initialized - assert state.input_handler == nil - end - - test "initializes input handler for raw backend mode" do - # We can't test actual raw mode without a terminal, but we can verify - # the logic path by checking the state structure - {:ok, runtime} = - Runtime.start_link( - root: Counter, - backend: :raw, - use_input_handler: true, - skip_terminal: true - ) - - state = Runtime.get_state(runtime) - # Backend mode :skip means no handler selected - assert state.input_handler == nil - end - - test "initializes input handler for TTY backend mode" do - {:ok, runtime} = - Runtime.start_link( - root: Counter, - backend: :tty, - use_input_handler: true, - skip_terminal: true - ) - - state = Runtime.get_state(runtime) - # Backend mode :skip means no handler selected - assert state.input_handler == nil - end - - test "input_handler defaults to nil when use_input_handler is false" do - {:ok, runtime} = - Runtime.start_link(root: Counter, use_input_handler: false, skip_terminal: true) - - state = Runtime.get_state(runtime) - assert state.input_handler == nil - assert state.input_state == nil - end - - test "input_handler_reader defaults to nil with skip_terminal" do - {:ok, runtime} = start_test_runtime(root: Counter) - - state = Runtime.get_state(runtime) - assert state[:input_handler_reader] == nil - end - end - - describe "async input handler reader" do - # Mock input handler that sends events from its poll function. - # Uses an Agent to feed events into the handler on demand. - defmodule MockInputHandler do - @behaviour TermUI.Input - - defstruct [:agent] - - def new do - # not used directly - tests create state with start/0 - %__MODULE__{agent: nil} - end - - def start do - {:ok, agent} = Agent.start_link(fn -> {:block, nil} end) - %__MODULE__{agent: agent} - end - - @doc "Queue an event to be returned by the next poll call" - def push_event(%__MODULE__{agent: agent}, event) do - Agent.update(agent, fn _state -> {:event, event} end) - end - - @doc "Signal EOF to be returned by the next poll call" - def push_eof(%__MODULE__{agent: agent}) do - Agent.update(agent, fn _state -> :eof end) - end - - @impl true - def poll(%__MODULE__{agent: agent} = state, _timeout) do - # Spin-wait for an event or eof signal (simulates blocking IO) - result = spin_wait(agent) - - case result do - {:event, event} -> - {{:ok, event}, state} - - :eof -> - {:eof, state} - end - end - - defp spin_wait(agent) do - case Agent.get(agent, & &1) do - {:block, _} -> - Process.sleep(5) - spin_wait(agent) - - {:event, _event} = result -> - # Consume the event - Agent.update(agent, fn _state -> {:block, nil} end) - result - - :eof -> - result = :eof - Agent.update(agent, fn _state -> {:block, nil} end) - result - end - end - - @impl true - def mode(%__MODULE__{}), do: :tty - - @impl true - def stop(%__MODULE__{agent: agent}) do - if agent && Process.alive?(agent), do: Agent.stop(agent) - :ok - end - end - - test "async reader dispatches events to the runtime without blocking" do - # Start runtime with skip_terminal - {:ok, runtime} = start_test_runtime(root: Counter) - - # Create a mock input handler and manually wire it up - mock_state = MockInputHandler.start() - - # Spawn the async reader targeting the runtime - reader_pid = - spawn_link(fn -> - # Use the same loop the runtime uses internally - loop(MockInputHandler, mock_state, runtime) - end) - - # Push a key event - MockInputHandler.push_event(mock_state, Event.key(:up)) - Process.sleep(50) - - # Verify the event was dispatched - runtime should still be responsive - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - - # Push another event - MockInputHandler.push_event(mock_state, Event.key(:up)) - Process.sleep(50) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 2 - - # Cleanup - Process.unlink(reader_pid) - Process.exit(reader_pid, :shutdown) - MockInputHandler.stop(mock_state) - end - - test "runtime remains responsive while async reader is waiting for input" do - {:ok, runtime} = start_test_runtime(root: Counter) - - # Create a mock handler that blocks (no events pushed) - mock_state = MockInputHandler.start() - - reader_pid = - spawn_link(fn -> - loop(MockInputHandler, mock_state, runtime) - end) - - # The reader is now blocking in poll(). Verify the runtime is still responsive. - # Send events directly via Runtime.send_event (simulating other input sources) - Runtime.send_event(runtime, Event.key(:up)) - Runtime.sync(runtime) - - state = Runtime.get_state(runtime) - assert state.root_state.count == 1 - - # Runtime can still be queried - state2 = Runtime.get_state(runtime) - assert state2.root_state.count == 1 - - # Cleanup - Process.unlink(reader_pid) - Process.exit(reader_pid, :shutdown) - MockInputHandler.stop(mock_state) - end - - test "input_eof message triggers shutdown" do - {:ok, runtime} = start_test_runtime(root: Counter) - ref = Process.monitor(runtime) - - # Simulate what the async reader sends on EOF - send(runtime, :input_eof) - - # Runtime should shut down - assert_receive {:DOWN, ^ref, :process, ^runtime, _reason}, 1000 - end - - test "reader process exit is handled gracefully" do - {:ok, runtime} = start_test_runtime(root: Counter) - - # Simulate a reader crash by sending an EXIT message directly. - # The runtime traps exits, so {:EXIT, pid, reason} arrives as a message. - fake_reader_pid = spawn(fn -> :ok end) - Process.sleep(10) - - # Set the input_handler_reader in state so the EXIT handler recognizes it - # We can't set it directly, so instead simulate the message the runtime handles - send(runtime, {:EXIT, fake_reader_pid, :some_crash_reason}) - Process.sleep(50) - - # Runtime should still be alive and responsive - assert Process.alive?(runtime) - state = Runtime.get_state(runtime) - assert state.root_state.count == 0 - end - - # Helper: same loop the runtime uses internally - defp loop(handler, input_state, target) do - case handler.poll(input_state, 16) do - {{:ok, event}, new_state} -> - send(target, {:input, event}) - loop(handler, new_state, target) - - {:timeout, new_state} -> - loop(handler, new_state, target) - - {:eof, _new_state} -> - send(target, :input_eof) - end - end - end -end diff --git a/test/term_ui/sanitize_test.exs b/test/term_ui/sanitize_test.exs deleted file mode 100644 index 853d5479..00000000 --- a/test/term_ui/sanitize_test.exs +++ /dev/null @@ -1,238 +0,0 @@ -defmodule TermUI.SanitizeTest do - use ExUnit.Case, async: true - - alias TermUI.Sanitize - - describe "sanitize/2" do - test "leaves normal text unchanged" do - assert Sanitize.sanitize("Hello, World!") == "Hello, World!" - end - - test "bracket mode replaces ESC with [ESC]" do - assert Sanitize.sanitize("\e[31mRed\e[0m") == "[ESC][31mRed[ESC][0m" - end - - test "remove mode strips ANSI codes" do - assert Sanitize.sanitize("\e[31mRed\e[0m", escape: :remove) == "Red" - end - - test "keep mode preserves escapes" do - input = "\e[31mRed\e[0m" - assert Sanitize.sanitize(input, escape: :keep) == input - end - - test "truncates long strings" do - long = String.duplicate("a", 20_000) - result = Sanitize.sanitize(long) - assert String.length(result) == 10_000 - end - - test "respects custom max_length" do - long = String.duplicate("a", 5000) - result = Sanitize.sanitize(long, max_length: 100) - assert String.length(result) == 100 - end - - test "handles cursor positioning sequences" do - assert Sanitize.sanitize("\e[2J\e[H") == "[ESC][2J[ESC][H" - end - - test "handles OSC sequences" do - # OSC 0 ; title ST - input = "\e]0;Title\a" - result = Sanitize.sanitize(input, escape: :bracket) - assert result == "[ESC]]0;Title[BEL]" - end - - test "handles DCS sequences" do - input = "\eP@mlx-term" - result = Sanitize.sanitize(input) - assert String.starts_with?(result, "[ESC]") - end - end - - describe "has_ansi?/1" do - test "detects CSI sequences" do - assert Sanitize.has_ansi?("\e[31m") - end - - test "detects OSC sequences" do - assert Sanitize.has_ansi?("\e]0;Title\a") - end - - test "returns false for plain text" do - refute Sanitize.has_ansi?("Hello, World!") - end - - test "returns false for empty string" do - refute Sanitize.has_ansi?("") - end - - test "detects simple ESC sequences" do - assert Sanitize.has_ansi?("\eM") - end - end - - describe "strip_ansi/1" do - test "removes CSI color codes" do - assert Sanitize.strip_ansi("\e[31mRed\e[0m") == "Red" - end - - test "removes cursor positioning" do - assert Sanitize.strip_ansi("\e[2J\e[HHello") == "Hello" - end - - test "removes multiple escape sequences" do - input = "\e[31m\e[1mBold Red\e[0m" - assert Sanitize.strip_ansi(input) == "Bold Red" - end - - test "handles text without escapes" do - assert Sanitize.strip_ansi("Normal text") == "Normal text" - end - - test "handles empty string" do - assert Sanitize.strip_ansi("") == "" - end - - test "removes OSC title sequences" do - input = "\e]0;My Title\aHello" - assert Sanitize.strip_ansi(input) == "Hello" - end - end - - describe "validate/1" do - test "returns :ok for safe text" do - assert Sanitize.validate("Safe text 123") == :ok - end - - test "returns :ok for text with newlines" do - assert Sanitize.validate("Line 1\nLine 2") == :ok - end - - test "returns :ok for text with tabs" do - assert Sanitize.validate("Column 1\tColumn 2") == :ok - end - - test "returns error for ANSI escapes" do - assert {:error, :contains_ansi} = Sanitize.validate("\e[31mRed") - end - - test "returns error for null bytes" do - assert {:error, :contains_null_byte} = Sanitize.validate("Null\x00byte") - end - - test "returns error for control characters" do - assert {:error, :contains_control_chars} = Sanitize.validate("Beep\a") - assert {:error, :contains_control_chars} = Sanitize.validate("BS\b") - end - - test "returns error for vertical tab" do - assert {:error, :contains_control_chars} = Sanitize.validate("VT\v") - end - - test "validates empty string" do - assert Sanitize.validate("") == :ok - end - end - - describe "escape_bracket/1" do - test "replaces ESC with [ESC]" do - assert Sanitize.escape_bracket("\e[31m") == "[ESC][31m" - end - - test "replaces BEL with [BEL]" do - assert Sanitize.escape_bracket("\a") == "[BEL]" - end - - test "replaces backspace with [BS]" do - assert Sanitize.escape_bracket("\b") == "[BS]" - end - - test "replaces VT with [VT]" do - assert Sanitize.escape_bracket("\v") == "[VT]" - end - - test "replaces FF with [FF]" do - assert Sanitize.escape_bracket("\f") == "[FF]" - end - - test "leaves normal text unchanged" do - assert Sanitize.escape_bracket("Hello") == "Hello" - end - end - - describe "security - injection prevention" do - test "neutralizes screen clear attacks" do - attack = "\e[2JThis was cleared" - result = Sanitize.sanitize(attack) - refute String.contains?(result, "\e") - end - - test "neutralizes cursor movement attacks" do - attack = "\e[10;20HOverwritten text" - result = Sanitize.sanitize(attack) - refute String.contains?(result, "\e") - end - - test "neutralizes color manipulation" do - attack = "\e[31m\e[47mInvisible text" - result = Sanitize.sanitize(attack) - refute String.contains?(result, "\e") - end - - test "handles mixed attack patterns" do - attack = "\e[2J\e[10;10H\e[31mAttack\e[0m" - result = Sanitize.sanitize(attack, escape: :remove) - assert result == "Attack" - end - end - - describe "edge cases" do - test "handles UTF-8 text" do - assert Sanitize.validate("Hello 世界 🌍") == :ok - end - - test "handles very long escape sequences" do - long_escape = "\e[" <> String.duplicate("1;", 1000) <> "m" - result = Sanitize.sanitize(long_escape, escape: :remove) - assert result == "" - end - - test "handles malformed escape sequences" do - # Incomplete CSI - assert Sanitize.sanitize("\e[31") == "[ESC][31" - # Just ESC - assert Sanitize.sanitize("\e") == "[ESC]" - end - - test "handles mixed valid and invalid sequences" do - # \e[I is a valid CSI sequence with 'I' as terminator (0x49 is in 0x40-0x7E) - input = "Normal\e[31mRed\e[IncompleteMore" - result = Sanitize.sanitize(input, escape: :remove) - # \e[31m and \e[I are both valid CSI sequences, so both get removed - assert result == "NormalRedncompleteMore" - end - end - - describe "integration - realistic scenarios" do - test "sanitizes user input with embedded escapes" do - user_input = "Name: \e[31mHacked\e[0m" - result = Sanitize.sanitize(user_input, escape: :remove) - assert result == "Name: Hacked" - end - - test "preserves legitimate whitespace" do - text = " Indented \n Text " - result = Sanitize.sanitize(text, escape: :remove) - assert result == text - end - - test "truncates and sanitizes" do - long_attack = String.duplicate("\e[31m", 1000) <> "Real text" - result = Sanitize.sanitize(long_attack, max_length: 100, escape: :remove) - assert String.length(result) <= 100 - refute String.contains?(result, "\e") - end - end -end diff --git a/test/term_ui/selection_test.exs b/test/term_ui/selection_test.exs new file mode 100644 index 00000000..d729487f --- /dev/null +++ b/test/term_ui/selection_test.exs @@ -0,0 +1,42 @@ +defmodule TermUI.SelectionTest do + use ExUnit.Case, async: true + + alias TermUI.Selection + + test "tracks a directional grapheme selection" do + selection = Selection.new() |> Selection.start(3) |> Selection.extend(1) + + assert Selection.active?(selection) + assert Selection.range(selection) == {1, 3} + assert Selection.anchor(selection) == 3 + assert Selection.head(selection) == 1 + assert Selection.length(selection) == 2 + end + + test "extract and replace preserve Unicode graphemes" do + selection = Selection.new() |> Selection.start(1) |> Selection.extend(3) + + assert Selection.extract(selection, "a界🙂z") == "界🙂" + assert {"aXz", 2, cleared} = Selection.replace(selection, "a界🙂z", "X") + refute Selection.active?(cleared) + end + + test "select all, word, and line use grapheme positions" do + assert Selection.new() |> Selection.select_all("a界🙂") |> Selection.range() == {0, 3} + + assert Selection.new() |> Selection.select_word("one two", 5) |> Selection.extract("one two") == + "two" + + assert Selection.new() + |> Selection.select_line("one\ntwo\nthree", 6) + |> Selection.extract("one\ntwo\nthree") == "two" + end + + test "contains uses a half-open range and clear removes the selection" do + selection = Selection.new() |> Selection.start(2) |> Selection.extend(5) + + assert Selection.contains?(selection, 2) + refute Selection.contains?(selection, 5) + refute selection |> Selection.clear() |> Selection.active?() + end +end diff --git a/test/term_ui/shortcut_test.exs b/test/term_ui/shortcut_test.exs deleted file mode 100644 index bbd465f9..00000000 --- a/test/term_ui/shortcut_test.exs +++ /dev/null @@ -1,418 +0,0 @@ -defmodule TermUI.ShortcutTest do - use ExUnit.Case, async: true - - alias TermUI.Event - alias TermUI.Shortcut - - describe "start_link/1" do - test "starts registry" do - {:ok, registry} = Shortcut.start_link() - assert is_pid(registry) - end - - test "starts with registered name" do - {:ok, _} = Shortcut.start_link(name: :test_shortcuts) - assert is_pid(Process.whereis(:test_shortcuts)) - GenServer.stop(:test_shortcuts) - end - end - - describe "register/2" do - test "registers a shortcut" do - {:ok, registry} = Shortcut.start_link() - - shortcut = %Shortcut{ - key: :q, - modifiers: [:ctrl], - action: {:function, fn -> :quit end} - } - - assert :ok = Shortcut.register(registry, shortcut) - assert length(Shortcut.list(registry)) == 1 - end - - test "registers multiple shortcuts" do - {:ok, registry} = Shortcut.start_link() - - Shortcut.register(registry, %Shortcut{ - key: :q, - modifiers: [:ctrl], - action: {:function, fn -> :quit end} - }) - - Shortcut.register(registry, %Shortcut{ - key: :s, - modifiers: [:ctrl], - action: {:function, fn -> :save end} - }) - - assert length(Shortcut.list(registry)) == 2 - end - end - - describe "unregister/3" do - test "removes a shortcut" do - {:ok, registry} = Shortcut.start_link() - - Shortcut.register(registry, %Shortcut{ - key: :q, - modifiers: [:ctrl], - action: {:function, fn -> :quit end} - }) - - assert length(Shortcut.list(registry)) == 1 - - Shortcut.unregister(registry, :q, [:ctrl]) - assert Shortcut.list(registry) == [] - end - end - - describe "match/3" do - test "matches shortcut by key" do - {:ok, registry} = Shortcut.start_link() - - shortcut = %Shortcut{key: :q, modifiers: [], action: {:function, fn -> :quit end}} - Shortcut.register(registry, shortcut) - - event = Event.key(:q) - assert {:ok, matched} = Shortcut.match(registry, event) - assert matched.key == :q - end - - test "matches shortcut with modifiers" do - {:ok, registry} = Shortcut.start_link() - - shortcut = %Shortcut{key: :s, modifiers: [:ctrl], action: {:function, fn -> :save end}} - Shortcut.register(registry, shortcut) - - event = Event.key(:s, modifiers: [:ctrl]) - assert {:ok, matched} = Shortcut.match(registry, event) - assert matched.key == :s - end - - test "requires all modifiers to match" do - {:ok, registry} = Shortcut.start_link() - - shortcut = %Shortcut{ - key: :s, - modifiers: [:ctrl, :shift], - action: {:function, fn -> :save_as end} - } - - Shortcut.register(registry, shortcut) - - # Missing shift - event = Event.key(:s, modifiers: [:ctrl]) - assert :no_match = Shortcut.match(registry, event) - - # Has all modifiers - event = Event.key(:s, modifiers: [:ctrl, :shift]) - assert {:ok, _} = Shortcut.match(registry, event) - end - - test "returns no_match when no shortcut matches" do - {:ok, registry} = Shortcut.start_link() - - event = Event.key(:x) - assert :no_match = Shortcut.match(registry, event) - end - - test "returns highest priority shortcut on conflict" do - {:ok, registry} = Shortcut.start_link() - - low = %Shortcut{ - key: :s, - modifiers: [:ctrl], - action: {:function, fn -> :low end}, - priority: 0 - } - - high = %Shortcut{ - key: :s, - modifiers: [:ctrl], - action: {:function, fn -> :high end}, - priority: 10 - } - - Shortcut.register(registry, low) - Shortcut.register(registry, high) - - event = Event.key(:s, modifiers: [:ctrl]) - {:ok, matched} = Shortcut.match(registry, event) - - # Execute to check which one matched - result = Shortcut.execute(matched) - assert result == :high - end - end - - describe "match/3 with scopes" do - test "global shortcuts always match" do - {:ok, registry} = Shortcut.start_link() - - shortcut = %Shortcut{ - key: :q, - modifiers: [:ctrl], - action: {:function, fn -> :quit end}, - scope: :global - } - - Shortcut.register(registry, shortcut) - - event = Event.key(:q, modifiers: [:ctrl]) - assert {:ok, _} = Shortcut.match(registry, event, %{mode: :edit}) - end - - test "mode shortcuts only match in that mode" do - {:ok, registry} = Shortcut.start_link() - - shortcut = %Shortcut{ - key: :i, - modifiers: [], - action: {:function, fn -> :insert end}, - scope: {:mode, :normal} - } - - Shortcut.register(registry, shortcut) - - event = Event.key(:i) - - # Not in normal mode - assert :no_match = Shortcut.match(registry, event, %{mode: :edit}) - - # In normal mode - assert {:ok, _} = Shortcut.match(registry, event, %{mode: :normal}) - end - - test "component shortcuts only match when component focused" do - {:ok, registry} = Shortcut.start_link() - - shortcut = %Shortcut{ - key: :enter, - modifiers: [], - action: {:function, fn -> :submit end}, - scope: {:component, :text_input} - } - - Shortcut.register(registry, shortcut) - - event = Event.key(:enter) - - # Different component focused - assert :no_match = Shortcut.match(registry, event, %{focused_component: :button}) - - # Correct component focused - assert {:ok, _} = Shortcut.match(registry, event, %{focused_component: :text_input}) - end - end - - describe "match/3 with sequences" do - test "matches key sequence" do - {:ok, registry} = Shortcut.start_link() - - shortcut = %Shortcut{ - key: :g, - modifiers: [], - action: {:function, fn -> :go_top end}, - sequence: [:g, :g] - } - - Shortcut.register(registry, shortcut) - - event1 = Event.key(:g) - event2 = Event.key(:g) - - # First key - no match yet - assert :no_match = Shortcut.match(registry, event1) - - # Second key - sequence complete - assert {:ok, matched} = Shortcut.match(registry, event2) - assert matched.sequence == [:g, :g] - end - - test "clears sequence on timeout" do - {:ok, registry} = Shortcut.start_link() - - shortcut = %Shortcut{ - key: :g, - modifiers: [], - action: {:function, fn -> :go_top end}, - sequence: [:g, :g] - } - - Shortcut.register(registry, shortcut) - - event = Event.key(:g) - - # First key starts sequence - Shortcut.match(registry, event) - - # Clear sequence - Shortcut.clear_sequence(registry) - - # Next key starts fresh, doesn't match - assert :no_match = Shortcut.match(registry, event) - end - end - - describe "execute/1" do - test "executes function action" do - shortcut = %Shortcut{ - key: :q, - modifiers: [:ctrl], - action: {:function, fn -> :quit_result end} - } - - assert :quit_result = Shortcut.execute(shortcut) - end - - test "returns message tuple for message action" do - shortcut = %Shortcut{ - key: :s, - modifiers: [:ctrl], - action: {:message, :root, :save} - } - - assert {:send_message, :root, :save} = Shortcut.execute(shortcut) - end - - test "returns command tuple for command action" do - command = {:file_write, "/path", "content"} - - shortcut = %Shortcut{ - key: :s, - modifiers: [:ctrl], - action: {:command, command} - } - - assert {:execute_command, ^command} = Shortcut.execute(shortcut) - end - end - - describe "list/1" do - test "returns all registered shortcuts" do - {:ok, registry} = Shortcut.start_link() - - Shortcut.register(registry, %Shortcut{key: :a, action: {:function, fn -> :a end}}) - Shortcut.register(registry, %Shortcut{key: :b, action: {:function, fn -> :b end}}) - Shortcut.register(registry, %Shortcut{key: :c, action: {:function, fn -> :c end}}) - - shortcuts = Shortcut.list(registry) - assert length(shortcuts) == 3 - end - end - - describe "list_for_scope/2" do - test "filters shortcuts by scope" do - {:ok, registry} = Shortcut.start_link() - - Shortcut.register(registry, %Shortcut{ - key: :q, - action: {:function, fn -> :quit end}, - scope: :global - }) - - Shortcut.register(registry, %Shortcut{ - key: :i, - action: {:function, fn -> :insert end}, - scope: {:mode, :normal} - }) - - Shortcut.register(registry, %Shortcut{ - key: :d, - action: {:function, fn -> :delete end}, - scope: {:mode, :normal} - }) - - global = Shortcut.list_for_scope(registry, :global) - assert length(global) == 1 - - normal = Shortcut.list_for_scope(registry, {:mode, :normal}) - assert length(normal) == 2 - end - end - - describe "format/1" do - test "formats simple key" do - shortcut = %Shortcut{key: :q, modifiers: []} - assert Shortcut.format(shortcut) == "Q" - end - - test "formats key with modifier" do - shortcut = %Shortcut{key: :s, modifiers: [:ctrl]} - assert Shortcut.format(shortcut) == "Ctrl+S" - end - - test "formats key with multiple modifiers" do - shortcut = %Shortcut{key: :s, modifiers: [:ctrl, :shift]} - assert Shortcut.format(shortcut) == "Ctrl+Shift+S" - end - - test "orders modifiers consistently" do - shortcut = %Shortcut{key: :s, modifiers: [:shift, :ctrl, :alt]} - assert Shortcut.format(shortcut) == "Ctrl+Alt+Shift+S" - end - - test "formats special keys" do - assert Shortcut.format(%Shortcut{key: :enter, modifiers: []}) == "ENTER" - assert Shortcut.format(%Shortcut{key: :escape, modifiers: []}) == "ESCAPE" - assert Shortcut.format(%Shortcut{key: :tab, modifiers: []}) == "TAB" - end - end - - describe "wildcard matching" do - test "matches any key with :any" do - {:ok, registry} = Shortcut.start_link() - - shortcut = %Shortcut{ - key: :any, - modifiers: [:ctrl], - action: {:function, fn -> :any_ctrl end} - } - - Shortcut.register(registry, shortcut) - - event = Event.key(:x, modifiers: [:ctrl]) - assert {:ok, _} = Shortcut.match(registry, event) - - event = Event.key(:y, modifiers: [:ctrl]) - assert {:ok, _} = Shortcut.match(registry, event) - end - end - - describe "integration" do - test "full workflow: register, match, execute" do - {:ok, registry} = Shortcut.start_link() - - # Register shortcuts - Shortcut.register(registry, %Shortcut{ - key: :q, - modifiers: [:ctrl], - action: {:function, fn -> :quit end}, - description: "Quit application" - }) - - Shortcut.register(registry, %Shortcut{ - key: :s, - modifiers: [:ctrl], - action: {:message, :editor, :save}, - description: "Save file" - }) - - # Match and execute quit - event = Event.key(:q, modifiers: [:ctrl]) - {:ok, shortcut} = Shortcut.match(registry, event) - assert Shortcut.execute(shortcut) == :quit - - # Match and execute save - event = Event.key(:s, modifiers: [:ctrl]) - {:ok, shortcut} = Shortcut.match(registry, event) - assert Shortcut.execute(shortcut) == {:send_message, :editor, :save} - - # List shortcuts - shortcuts = Shortcut.list(registry) - assert length(shortcuts) == 2 - assert Enum.all?(shortcuts, fn s -> s.description != nil end) - end - end -end diff --git a/test/term_ui/source_conventions_test.exs b/test/term_ui/source_conventions_test.exs new file mode 100644 index 00000000..46935c6f --- /dev/null +++ b/test/term_ui/source_conventions_test.exs @@ -0,0 +1,60 @@ +defmodule TermUI.SourceConventionsTest do + use ExUnit.Case, async: true + + alias TermUI.{Cell, Command, Event, Frame, Style} + alias TermUI.Widget.Table.Column + + @source_files Path.wildcard("lib/**/*.ex") + + test "all explicit production structs derive their shape from Zoi" do + violations = + Enum.flat_map(@source_files, fn path -> + path + |> File.read!() + |> String.split("\n") + |> Enum.with_index(1) + |> Enum.flat_map(fn {line, number} -> + if Regex.match?(~r/^\s*defstruct\b/, line) and + not String.contains?(line, "defstruct Zoi.Struct.struct_fields(") do + ["#{path}:#{number}"] + else + [] + end + end) + end) + + assert violations == [], + "explicit structs must derive fields and defaults from a Zoi schema:\n" <> + Enum.join(violations, "\n") + end + + test "public data schemas accept valid structs" do + values = [ + {Cell.schema(), Cell.new("A")}, + {Style.schema(), Style.new(fg: :cyan)}, + {Frame.schema(), Frame.new(20, 5)}, + {Event.schema(), Event.resize(20, 5)}, + {Command.schema(), Command.message(:ready)}, + {Column.schema(), Column.new(:name, "Name")} + ] + + Enum.each(values, fn {schema, value} -> + assert {:ok, ^value} = Zoi.parse(schema, value) + end) + end + + test "public data schemas reject invalid struct fields" do + invalid = [ + {Cell.schema(), %Cell{width: 3}}, + {Style.schema(), %Style{attrs: MapSet.new([:unknown])}}, + {Frame.schema(), %Frame{width: 0, height: 1}}, + {Event.schema(), %Event.Resize{width: 0, height: 1}}, + {Command.schema(), %Command{kind: :unknown, value: nil}}, + {Column.schema(), %Column{key: :name, label: "Name", align: :diagonal}} + ] + + Enum.each(invalid, fn {schema, value} -> + assert {:error, [_error | _rest]} = Zoi.parse(schema, value) + end) + end +end diff --git a/test/term_ui/spatial_index_test.exs b/test/term_ui/spatial_index_test.exs deleted file mode 100644 index e50adc1c..00000000 --- a/test/term_ui/spatial_index_test.exs +++ /dev/null @@ -1,198 +0,0 @@ -defmodule TermUI.SpatialIndexTest do - use ExUnit.Case - - alias TermUI.SpatialIndex - - setup do - # Start spatial index for each test - start_supervised!(SpatialIndex) - :ok - end - - describe "update/4 and find_at/2" do - test "registers component and finds at position" do - pid = self() - bounds = %{x: 10, y: 5, width: 20, height: 3} - - :ok = SpatialIndex.update(:button, pid, bounds) - - # Position inside bounds - assert {:ok, {:button, ^pid}} = SpatialIndex.find_at(15, 6) - end - - test "returns not_found when no component at position" do - assert {:error, :not_found} = SpatialIndex.find_at(100, 100) - end - - test "finds component at edge of bounds" do - pid = self() - bounds = %{x: 0, y: 0, width: 10, height: 5} - - :ok = SpatialIndex.update(:panel, pid, bounds) - - # Top-left corner - assert {:ok, {:panel, ^pid}} = SpatialIndex.find_at(0, 0) - - # Bottom-right corner (exclusive) - assert {:error, :not_found} = SpatialIndex.find_at(10, 5) - - # Just inside - assert {:ok, {:panel, ^pid}} = SpatialIndex.find_at(9, 4) - end - - test "position outside bounds returns not_found" do - pid = self() - bounds = %{x: 10, y: 10, width: 5, height: 5} - - :ok = SpatialIndex.update(:box, pid, bounds) - - # Before bounds - assert {:error, :not_found} = SpatialIndex.find_at(9, 10) - assert {:error, :not_found} = SpatialIndex.find_at(10, 9) - - # After bounds - assert {:error, :not_found} = SpatialIndex.find_at(15, 10) - assert {:error, :not_found} = SpatialIndex.find_at(10, 15) - end - - test "updates existing component bounds" do - pid = self() - old_bounds = %{x: 0, y: 0, width: 10, height: 10} - new_bounds = %{x: 20, y: 20, width: 10, height: 10} - - :ok = SpatialIndex.update(:moving, pid, old_bounds) - assert {:ok, {:moving, ^pid}} = SpatialIndex.find_at(5, 5) - - :ok = SpatialIndex.update(:moving, pid, new_bounds) - assert {:error, :not_found} = SpatialIndex.find_at(5, 5) - assert {:ok, {:moving, ^pid}} = SpatialIndex.find_at(25, 25) - end - end - - describe "z-order handling" do - test "returns highest z-index component when overlapping" do - pid1 = spawn(fn -> Process.sleep(:infinity) end) - pid2 = spawn(fn -> Process.sleep(:infinity) end) - - bounds = %{x: 0, y: 0, width: 10, height: 10} - - :ok = SpatialIndex.update(:background, pid1, bounds, z_index: 0) - :ok = SpatialIndex.update(:modal, pid2, bounds, z_index: 100) - - # Modal (higher z-index) should be returned - assert {:ok, {:modal, ^pid2}} = SpatialIndex.find_at(5, 5) - end - - test "default z-index is 0" do - pid1 = spawn(fn -> Process.sleep(:infinity) end) - pid2 = spawn(fn -> Process.sleep(:infinity) end) - - bounds = %{x: 0, y: 0, width: 10, height: 10} - - :ok = SpatialIndex.update(:first, pid1, bounds) - :ok = SpatialIndex.update(:second, pid2, bounds, z_index: 1) - - # Second should win with z_index: 1 vs default 0 - assert {:ok, {:second, ^pid2}} = SpatialIndex.find_at(5, 5) - end - - test "same z-index returns one of the components" do - pid1 = spawn(fn -> Process.sleep(:infinity) end) - pid2 = spawn(fn -> Process.sleep(:infinity) end) - - bounds = %{x: 0, y: 0, width: 10, height: 10} - - :ok = SpatialIndex.update(:a, pid1, bounds, z_index: 0) - :ok = SpatialIndex.update(:b, pid2, bounds, z_index: 0) - - # Either component is acceptable - {:ok, {id, _pid}} = SpatialIndex.find_at(5, 5) - assert id in [:a, :b] - end - end - - describe "find_all_at/2" do - test "returns all components at position sorted by z-index" do - pid1 = spawn(fn -> Process.sleep(:infinity) end) - pid2 = spawn(fn -> Process.sleep(:infinity) end) - pid3 = spawn(fn -> Process.sleep(:infinity) end) - - bounds = %{x: 0, y: 0, width: 10, height: 10} - - :ok = SpatialIndex.update(:bottom, pid1, bounds, z_index: 0) - :ok = SpatialIndex.update(:middle, pid2, bounds, z_index: 50) - :ok = SpatialIndex.update(:top, pid3, bounds, z_index: 100) - - result = SpatialIndex.find_all_at(5, 5) - - assert length(result) == 3 - assert [{:top, ^pid3, 100}, {:middle, ^pid2, 50}, {:bottom, ^pid1, 0}] = result - end - - test "returns empty list when no component at position" do - assert [] = SpatialIndex.find_all_at(100, 100) - end - end - - describe "remove/1" do - test "removes component from index" do - pid = self() - bounds = %{x: 0, y: 0, width: 10, height: 10} - - :ok = SpatialIndex.update(:temp, pid, bounds) - assert {:ok, {:temp, ^pid}} = SpatialIndex.find_at(5, 5) - - :ok = SpatialIndex.remove(:temp) - assert {:error, :not_found} = SpatialIndex.find_at(5, 5) - end - - test "remove non-existent component succeeds" do - :ok = SpatialIndex.remove(:nonexistent) - end - end - - describe "get_bounds/1" do - test "returns bounds for registered component" do - pid = self() - bounds = %{x: 10, y: 20, width: 30, height: 40} - - :ok = SpatialIndex.update(:panel, pid, bounds) - - assert {:ok, ^bounds} = SpatialIndex.get_bounds(:panel) - end - - test "returns not_found for unregistered component" do - assert {:error, :not_found} = SpatialIndex.get_bounds(:unknown) - end - end - - describe "clear/0" do - test "removes all entries" do - pid = self() - bounds = %{x: 0, y: 0, width: 10, height: 10} - - :ok = SpatialIndex.update(:a, pid, bounds) - :ok = SpatialIndex.update(:b, pid, bounds) - - assert SpatialIndex.count() == 2 - - :ok = SpatialIndex.clear() - - assert SpatialIndex.count() == 0 - assert {:error, :not_found} = SpatialIndex.find_at(5, 5) - end - end - - describe "count/0" do - test "returns number of indexed components" do - assert SpatialIndex.count() == 0 - - pid = self() - :ok = SpatialIndex.update(:a, pid, %{x: 0, y: 0, width: 1, height: 1}) - assert SpatialIndex.count() == 1 - - :ok = SpatialIndex.update(:b, pid, %{x: 1, y: 1, width: 1, height: 1}) - assert SpatialIndex.count() == 2 - end - end -end diff --git a/test/term_ui/stateful_component_test.exs b/test/term_ui/stateful_component_test.exs deleted file mode 100644 index 775cae58..00000000 --- a/test/term_ui/stateful_component_test.exs +++ /dev/null @@ -1,311 +0,0 @@ -defmodule TermUI.StatefulComponentTest do - use ExUnit.Case, async: true - - alias TermUI.Component.RenderNode - - # Test counter component - defmodule Counter do - use TermUI.StatefulComponent - - @impl true - def init(props) do - {:ok, %{count: props[:initial] || 0}} - end - - @impl true - def handle_event({:increment, n}, state) do - {:ok, %{state | count: state.count + n}} - end - - def handle_event(:decrement, state) do - {:ok, %{state | count: state.count - 1}} - end - - def handle_event(:reset, state) do - {:ok, %{state | count: 0}} - end - - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, _area) do - text("Count: #{state.count}") - end - end - - # Component with commands - defmodule CommandComponent do - use TermUI.StatefulComponent - - @impl true - def init(props) do - if props[:send_init] do - {:ok, %{value: 0}, [{:send, props[:parent], :initialized}]} - else - {:ok, %{value: 0}} - end - end - - @impl true - def handle_event(:submit, state) do - commands = [{:send, self(), {:submitted, state.value}}] - {:ok, state, commands} - end - - def handle_event({:set, value}, state) do - {:ok, %{state | value: value}} - end - - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, _area) do - text("Value: #{state.value}") - end - end - - # Component with all optional callbacks - defmodule FullComponent do - use TermUI.StatefulComponent - - @impl true - def init(_props) do - {:ok, %{value: 0}} - end - - @impl true - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(state, _area) do - text("#{state.value}") - end - - @impl true - def terminate(reason, state) do - send(self(), {:terminated, reason, state}) - :ok - end - - @impl true - def handle_info({:update, value}, state) do - {:ok, %{state | value: value}} - end - - def handle_info(_msg, state) do - {:ok, state} - end - - @impl true - def handle_call(:get_value, _from, state) do - {:reply, state.value, state} - end - - def handle_call({:set_value, value}, _from, state) do - {:reply, :ok, %{state | value: value}} - end - end - - # Component that stops - defmodule StoppingComponent do - use TermUI.StatefulComponent - - @impl true - def init(props) do - if props[:fail_init] do - {:stop, :init_failed} - else - {:ok, %{}} - end - end - - @impl true - def handle_event(:stop, state) do - {:stop, :normal, state} - end - - def handle_event(_event, state) do - {:ok, state} - end - - @impl true - def render(_state, _area) do - text("") - end - end - - describe "init/1" do - test "receives props and returns initial state" do - {:ok, state} = Counter.init(%{initial: 10}) - assert state.count == 10 - end - - test "uses default when prop not provided" do - {:ok, state} = Counter.init(%{}) - assert state.count == 0 - end - - test "can return commands" do - {:ok, state, commands} = CommandComponent.init(%{send_init: true, parent: self()}) - assert state.value == 0 - assert commands == [{:send, self(), :initialized}] - end - - test "can return stop" do - {:stop, reason} = StoppingComponent.init(%{fail_init: true}) - assert reason == :init_failed - end - end - - describe "handle_event/2" do - test "receives events and updates state" do - {:ok, state} = Counter.init(%{initial: 5}) - {:ok, new_state} = Counter.handle_event({:increment, 3}, state) - assert new_state.count == 8 - end - - test "handles multiple event types" do - {:ok, state} = Counter.init(%{initial: 10}) - {:ok, state} = Counter.handle_event(:decrement, state) - assert state.count == 9 - {:ok, state} = Counter.handle_event(:reset, state) - assert state.count == 0 - end - - test "unknown events return unchanged state" do - {:ok, state} = Counter.init(%{initial: 5}) - {:ok, new_state} = Counter.handle_event(:unknown, state) - assert new_state.count == 5 - end - - test "can return commands" do - {:ok, state} = CommandComponent.init(%{}) - {:ok, state} = CommandComponent.handle_event({:set, 42}, state) - {:ok, _state, commands} = CommandComponent.handle_event(:submit, state) - assert [{:send, _pid, {:submitted, 42}}] = commands - end - - test "can return stop" do - {:ok, state} = StoppingComponent.init(%{}) - {:stop, reason, _state} = StoppingComponent.handle_event(:stop, state) - assert reason == :normal - end - end - - describe "render/2" do - test "receives state and area" do - {:ok, state} = Counter.init(%{initial: 42}) - area = %{x: 0, y: 0, width: 80, height: 24} - result = Counter.render(state, area) - assert result.type == :text - assert result.content == "Count: 42" - end - - test "updates with new state" do - {:ok, state} = Counter.init(%{initial: 0}) - {:ok, state} = Counter.handle_event({:increment, 5}, state) - area = %{x: 0, y: 0, width: 80, height: 24} - result = Counter.render(state, area) - assert result.content == "Count: 5" - end - end - - describe "optional callbacks" do - test "terminate is called with reason and state" do - FullComponent.terminate(:shutdown, %{value: 100}) - assert_receive {:terminated, :shutdown, %{value: 100}} - end - - test "handle_info processes messages" do - {:ok, state} = FullComponent.init(%{}) - {:ok, new_state} = FullComponent.handle_info({:update, 42}, state) - assert new_state.value == 42 - end - - test "handle_info ignores unknown messages" do - {:ok, state} = FullComponent.init(%{}) - {:ok, new_state} = FullComponent.handle_info(:unknown, state) - assert new_state.value == 0 - end - - test "handle_call returns reply" do - {:ok, state} = FullComponent.init(%{}) - {:ok, state} = FullComponent.handle_info({:update, 42}, state) - {:reply, value, _state} = FullComponent.handle_call(:get_value, self(), state) - assert value == 42 - end - - test "handle_call can update state" do - {:ok, state} = FullComponent.init(%{}) - {:reply, :ok, new_state} = FullComponent.handle_call({:set_value, 100}, self(), state) - assert new_state.value == 100 - end - end - - describe "__using__ macro" do - test "provides default terminate" do - {:ok, state} = Counter.init(%{}) - result = Counter.terminate(:normal, state) - assert result == :ok - end - - test "provides default handle_info" do - {:ok, state} = Counter.init(%{}) - {:ok, new_state} = Counter.handle_info(:message, state) - assert new_state == state - end - - test "provides default handle_call" do - {:ok, state} = Counter.init(%{}) - {:reply, :ok, new_state} = Counter.handle_call(:request, self(), state) - assert new_state == state - end - - test "imports helpers" do - # Verify text() is available - {:ok, state} = Counter.init(%{initial: 0}) - area = %{x: 0, y: 0, width: 80, height: 24} - result = Counter.render(state, area) - assert %RenderNode{} = result - end - end - - describe "command types" do - test "send command" do - {:ok, state} = CommandComponent.init(%{}) - {:ok, state} = CommandComponent.handle_event({:set, 10}, state) - {:ok, _state, commands} = CommandComponent.handle_event(:submit, state) - assert [{:send, _pid, {:submitted, 10}}] = commands - end - end - - describe "state management patterns" do - test "accumulating changes" do - {:ok, state} = Counter.init(%{initial: 0}) - - state = - Enum.reduce(1..5, state, fn n, acc -> - {:ok, new_state} = Counter.handle_event({:increment, n}, acc) - new_state - end) - - assert state.count == 15 - end - - test "state isolation between instances" do - {:ok, state1} = Counter.init(%{initial: 0}) - {:ok, state2} = Counter.init(%{initial: 100}) - - {:ok, state1} = Counter.handle_event({:increment, 1}, state1) - - assert state1.count == 1 - assert state2.count == 100 - end - end -end diff --git a/test/term_ui/style_integration_test.exs b/test/term_ui/style_integration_test.exs deleted file mode 100644 index 0bff2f5c..00000000 --- a/test/term_ui/style_integration_test.exs +++ /dev/null @@ -1,375 +0,0 @@ -defmodule TermUI.StyleIntegrationTest do - use ExUnit.Case, async: true - - alias TermUI.Style - - describe "style inheritance chain" do - test "child inherits from parent" do - parent = Style.new() |> Style.fg(:blue) |> Style.bg(:white) - child = Style.new() |> Style.bold() - - effective = Style.inherit(child, parent) - - assert effective.fg == :blue - assert effective.bg == :white - assert Style.has_attr?(effective, :bold) - end - - test "child overrides parent" do - parent = Style.new() |> Style.fg(:blue) - child = Style.new() |> Style.fg(:red) - - effective = Style.inherit(child, parent) - - assert effective.fg == :red - end - - test "multi-level inheritance" do - grandparent = Style.new() |> Style.fg(:blue) |> Style.bg(:black) - parent = Style.new() |> Style.fg(:cyan) - child = Style.new() |> Style.bold() - - # First inherit grandparent -> parent - parent_effective = Style.inherit(parent, grandparent) - # Then inherit parent -> child - child_effective = Style.inherit(child, parent_effective) - - # Grandparent bg inherited through - assert child_effective.bg == :black - # Parent fg override - assert child_effective.fg == :cyan - # Child attrs - assert Style.has_attr?(child_effective, :bold) - end - - test "deep inheritance chain (5 levels)" do - styles = [ - Style.new() |> Style.fg(:red), - Style.new() |> Style.bg(:white), - Style.new() |> Style.bold(), - Style.new() |> Style.fg(:blue), - Style.new() |> Style.underline() - ] - - # Fold through inheritance - effective = - Enum.reduce(styles, Style.new(), fn child, parent -> - Style.inherit(child, parent) - end) - - # Last fg wins - assert effective.fg == :blue - # bg from level 2 - assert effective.bg == :white - # Only last level has attrs (inheritance replaces when child has any) - assert Style.has_attr?(effective, :underline) - end - end - - describe "style merging" do - test "merge combines attributes" do - base = Style.new() |> Style.bold() - overlay = Style.new() |> Style.italic() - - merged = Style.merge(base, overlay) - - assert Style.has_attr?(merged, :bold) - assert Style.has_attr?(merged, :italic) - end - - test "merge overlay wins for colors" do - base = Style.new() |> Style.fg(:blue) |> Style.bg(:black) - overlay = Style.new() |> Style.fg(:red) - - merged = Style.merge(base, overlay) - - assert merged.fg == :red - assert merged.bg == :black - end - - test "merge chain" do - theme = Style.new() |> Style.fg(:white) |> Style.bg(:black) - component = Style.new() |> Style.fg(:blue) - state = Style.new() |> Style.bold() - - # Theme -> component -> state - result = - theme - |> Style.merge(component) - |> Style.merge(state) - - assert result.fg == :blue - assert result.bg == :black - assert Style.has_attr?(result, :bold) - end - end - - describe "variant selection" do - test "select variant based on state" do - variants = - Style.build_variants(%{ - normal: Style.new() |> Style.fg(:white), - focused: Style.new() |> Style.fg(:blue) |> Style.bold(), - disabled: Style.new() |> Style.fg(:bright_black) - }) - - normal = Style.get_variant(variants, :normal) - focused = Style.get_variant(variants, :focused) - disabled = Style.get_variant(variants, :disabled) - - assert normal.fg == :white - refute Style.has_attr?(normal, :bold) - - assert focused.fg == :blue - assert Style.has_attr?(focused, :bold) - - assert disabled.fg == :bright_black - end - - test "variants inherit from normal" do - variants = - Style.build_variants(%{ - normal: Style.new() |> Style.fg(:white) |> Style.bg(:black), - focused: Style.new() |> Style.fg(:blue) - }) - - focused = Style.get_variant(variants, :focused) - - # Inherits bg from normal - assert focused.bg == :black - # Override fg - assert focused.fg == :blue - end - - test "fallback to normal for unknown state" do - variants = %{ - normal: Style.new() |> Style.fg(:white) - } - - result = Style.get_variant(variants, :unknown_state) - assert result.fg == :white - end - end - - describe "color conversion integration" do - test "RGB to indexed to named conversion chain" do - rgb = {:rgb, 255, 0, 0} - - # Convert to indexed - indexed = Style.convert_for_terminal(rgb, :color_256) - assert {:indexed, _} = indexed - - # Convert to named - named = Style.convert_for_terminal(indexed, :color_16) - assert named == :bright_red - end - - test "style with mixed color types" do - style = - Style.new() - |> Style.fg({:rgb, 255, 128, 0}) - |> Style.bg({:indexed, 232}) - - # Both can be converted to named - fg_named = Style.to_named(style.fg) - bg_named = Style.to_named(style.bg) - - assert is_atom(fg_named) - assert is_atom(bg_named) - end - - test "semantic colors resolve correctly" do - colors = [:primary, :secondary, :success, :warning, :error, :info, :muted] - - for semantic <- colors do - color = Style.semantic(semantic) - assert is_atom(color) or is_tuple(color) - end - end - - test "named to RGB to indexed conversion chain" do - named = :red - - # Convert named to RGB tuple - {r, g, b} = Style.to_rgb(named) - assert is_integer(r) and r >= 0 and r <= 255 - assert is_integer(g) and g >= 0 and g <= 255 - assert is_integer(b) and b >= 0 and b <= 255 - - # Convert RGB to indexed (returns just the index number) - idx = Style.rgb_to_indexed({r, g, b}) - assert is_integer(idx) and idx >= 0 and idx <= 255 - end - - test "edge RGB values convert correctly" do - # Pure black - black_rgb = {:rgb, 0, 0, 0} - black_named = Style.to_named(black_rgb) - assert black_named == :black - - # Pure white - white_rgb = {:rgb, 255, 255, 255} - white_named = Style.to_named(white_rgb) - assert white_named == :bright_white - - # Edge values for indexed (0 = black, 255 = white) - black_indexed = {:indexed, 0} - assert Style.to_named(black_indexed) == :black - - white_indexed = {:indexed, 15} - assert Style.to_named(white_indexed) == :bright_white - end - - test "round-trip conversion preserves color identity" do - # Named -> RGB -> Indexed -> Named - original = :blue - rgb_tuple = Style.to_rgb(original) - idx = Style.rgb_to_indexed(rgb_tuple) - # Wrap index as indexed tuple for to_named - back_to_named = Style.to_named({:indexed, idx}) - - # Should map back to same color or close equivalent - assert back_to_named == original or back_to_named == :bright_blue - end - end - - describe "complex style scenarios" do - test "button states with inheritance" do - # Base button style - base = - Style.new() - |> Style.fg(:white) - |> Style.bg(:bright_black) - - # Variants - variants = - Style.build_variants(%{ - normal: base, - focused: Style.new() |> Style.bg(:blue) |> Style.bold(), - pressed: Style.new() |> Style.bg(:cyan) |> Style.reverse(), - disabled: Style.new() |> Style.fg(:bright_black) |> Style.bg(:black) - }) - - # Simulate state changes - states = [:normal, :focused, :pressed, :disabled] - - for state <- states do - style = Style.get_variant(variants, state) - assert %Style{} = style - # All states should have fg (from base or override) - assert style.fg != nil, "State #{state} should have fg color" - end - end - - test "text input with placeholder and content styles" do - placeholder_style = - Style.new() - |> Style.fg(:bright_black) - |> Style.italic() - - content_style = - Style.new() - |> Style.fg(:white) - - focused_modifier = Style.new() |> Style.bg(:blue) - - # Placeholder unfocused - placeholder_unfocused = placeholder_style - assert placeholder_unfocused.fg == :bright_black - assert Style.has_attr?(placeholder_unfocused, :italic) - - # Placeholder focused - placeholder_focused = Style.merge(placeholder_style, focused_modifier) - assert placeholder_focused.bg == :blue - assert Style.has_attr?(placeholder_focused, :italic) - - # Content focused - content_focused = Style.merge(content_style, focused_modifier) - assert content_focused.fg == :white - assert content_focused.bg == :blue - end - - test "nested container style propagation" do - # Container defines base style - container_style = - Style.new() - |> Style.fg(:white) - |> Style.bg(:black) - - # Inner container adds border style - inner_style = Style.new() |> Style.fg(:bright_black) - - # Widget in inner container - widget_style = Style.new() |> Style.bold() - - # Build inheritance chain - inner_effective = Style.inherit(inner_style, container_style) - widget_effective = Style.inherit(widget_style, inner_effective) - - # Widget inherits container bg - assert widget_effective.bg == :black - # But gets inner fg - assert widget_effective.fg == :bright_black - # And its own attrs - assert Style.has_attr?(widget_effective, :bold) - end - end - - describe "builder pattern fluency" do - test "long builder chain" do - style = - Style.new() - |> Style.fg(:blue) - |> Style.bg(:white) - |> Style.bold() - |> Style.italic() - |> Style.underline() - - assert style.fg == :blue - assert style.bg == :white - assert Style.has_attr?(style, :bold) - assert Style.has_attr?(style, :italic) - assert Style.has_attr?(style, :underline) - end - - test "from/1 with all options" do - style = - Style.from( - fg: :cyan, - bg: :black, - bold: true, - italic: true, - underline: true - ) - - assert style.fg == :cyan - assert style.bg == :black - assert Style.has_attr?(style, :bold) - assert Style.has_attr?(style, :italic) - assert Style.has_attr?(style, :underline) - end - end - - describe "immutability" do - test "modifications don't affect original" do - original = Style.new() |> Style.fg(:blue) - modified = Style.fg(original, :red) - - assert original.fg == :blue - assert modified.fg == :red - end - - test "merge doesn't modify inputs" do - base = Style.new() |> Style.fg(:blue) - overlay = Style.new() |> Style.bold() - - _merged = Style.merge(base, overlay) - - # Originals unchanged - assert base.fg == :blue - refute Style.has_attr?(base, :bold) - assert overlay.fg == nil - assert Style.has_attr?(overlay, :bold) - end - end -end diff --git a/test/term_ui/terminal/escape_parser_test.exs b/test/term_ui/terminal/escape_parser_test.exs index 3f5e383b..082086ce 100644 --- a/test/term_ui/terminal/escape_parser_test.exs +++ b/test/term_ui/terminal/escape_parser_test.exs @@ -4,36 +4,47 @@ defmodule TermUI.Terminal.EscapeParserTest do alias TermUI.Event alias TermUI.Terminal.EscapeParser + test "rejects zero SGR mouse coordinates without raising" do + assert {[%TermUI.Event.Key{key: :unknown}], ""} = + EscapeParser.parse("\e[<0;0;0M") + end + describe "parse/1 - single characters" do test "parses lowercase letters" do {events, remaining} = EscapeParser.parse("a") assert remaining == <<>> - assert [%Event.Key{key: "a", modifiers: []}] = events + assert [%Event.Text{text: "a"}] = events end test "parses uppercase letters" do {events, remaining} = EscapeParser.parse("A") assert remaining == <<>> - assert [%Event.Key{key: "A"}] = events + assert [%Event.Text{text: "A"}] = events end test "parses numbers" do {events, remaining} = EscapeParser.parse("5") assert remaining == <<>> - assert [%Event.Key{key: "5"}] = events + assert [%Event.Text{text: "5"}] = events end test "parses special characters" do {events, remaining} = EscapeParser.parse("@") assert remaining == <<>> - assert [%Event.Key{key: "@"}] = events + assert [%Event.Text{text: "@"}] = events + end + + test "parses space as text" do + {events, remaining} = EscapeParser.parse(" ") + assert remaining == <<>> + assert [%Event.Text{text: " "}] = events end test "parses multiple characters" do {events, remaining} = EscapeParser.parse("abc") assert remaining == <<>> assert length(events) == 3 - assert [%Event.Key{key: "a"}, %Event.Key{key: "b"}, %Event.Key{key: "c"}] = events + assert [%Event.Text{text: "a"}, %Event.Text{text: "b"}, %Event.Text{text: "c"}] = events end end @@ -288,21 +299,21 @@ defmodule TermUI.Terminal.EscapeParserTest do # é is 0xC3 0xA9 {events, remaining} = EscapeParser.parse("é") assert remaining == <<>> - assert [%Event.Key{key: "é"}] = events + assert [%Event.Text{text: "é"}] = events end test "parses 3-byte UTF-8 character" do # € is 0xE2 0x82 0xAC {events, remaining} = EscapeParser.parse("€") assert remaining == <<>> - assert [%Event.Key{key: "€"}] = events + assert [%Event.Text{text: "€"}] = events end test "parses 4-byte UTF-8 character" do # 😀 is 0xF0 0x9F 0x98 0x80 {events, remaining} = EscapeParser.parse("😀") assert remaining == <<>> - assert [%Event.Key{key: "😀"}] = events + assert [%Event.Text{text: "😀"}] = events end end @@ -386,7 +397,7 @@ defmodule TermUI.Terminal.EscapeParserTest do input = "\e[200~abc\e[201~xyz" {events, remaining} = EscapeParser.parse(input) - assert [%TermUI.Event.Paste{content: "abc"}, %TermUI.Event.Key{key: "x"} | _] = events + assert [%TermUI.Event.Paste{content: "abc"}, %TermUI.Event.Text{text: "x"} | _] = events assert remaining == "" end diff --git a/test/term_ui/terminal/input_reader_test.exs b/test/term_ui/terminal/input_reader_test.exs deleted file mode 100644 index 6e4f449e..00000000 --- a/test/term_ui/terminal/input_reader_test.exs +++ /dev/null @@ -1,118 +0,0 @@ -defmodule TermUI.Terminal.InputReaderTest do - use ExUnit.Case, async: false - - alias TermUI.Terminal.InputReader - - # All InputReader tests require a real terminal/TTY since the underlying - # Port driver uses `cat` to read from stdin which isn't available in - # non-interactive test environments. - - describe "start_link/1 and stop/1" do - @tag :requires_terminal - test "starts and stops cleanly" do - {:ok, reader} = InputReader.start_link(target: self()) - assert Process.alive?(reader) - - :ok = InputReader.stop(reader) - refute Process.alive?(reader) - end - - @tag :requires_terminal - test "requires target option" do - assert_raise KeyError, fn -> - InputReader.start_link([]) - end - end - - @tag :requires_terminal - test "accepts name option" do - {:ok, reader} = InputReader.start_link(target: self(), name: :test_reader) - assert Process.whereis(:test_reader) == reader - - :ok = InputReader.stop(reader) - end - end - - describe "event delivery" do - # Note: These tests are limited because we can't easily inject input - # into the stdin port. The InputReader uses `cat` as the port command - # which reads from stdin, making direct testing challenging. - # - # For full integration testing, see the dashboard example which - # demonstrates real keyboard input handling. - - @tag :requires_terminal - test "reader process state has correct structure" do - # Start InputReader, but it may fail in test environment if stdin isn't available - case InputReader.start_link(target: self()) do - {:ok, reader} -> - # Give process time to stabilize - Process.sleep(10) - - # Check if process is still alive (may have died due to stdin issues) - if Process.alive?(reader) do - # Use sys to get state - {:status, _pid, _module, [_pdict, _state, _parent, _debug, _state_data]} = - :sys.get_status(reader) - - # The state is wrapped in GenServer format - # Just verify the process is running - assert Process.alive?(reader) - - :ok = InputReader.stop(reader) - else - # Process died, expected in test environment without proper stdin - assert true - end - - {:error, _reason} -> - # Failed to start, expected in some test environments - assert true - end - end - end - - describe "escape timeout handling" do - # These tests verify the timeout behavior for disambiguating - # ESC key vs ESC sequences. Since we can't inject data into - # the stdin port, we test this through the EscapeParser directly. - # - # The InputReader's timeout logic is: - # 1. Receive partial escape sequence - # 2. Set 50ms timer - # 3. On timeout, emit ESC and any remaining keys - - @tag :requires_terminal - test "timeout constant is reasonable" do - # The escape timeout should be fast enough to feel responsive - # but slow enough to catch escape sequences - # Typically 50ms is a good balance - # We can't access the constant directly, but we test the behavior - {:ok, reader} = InputReader.start_link(target: self()) - assert Process.alive?(reader) - :ok = InputReader.stop(reader) - end - end - - describe "termination" do - @tag :requires_terminal - test "closes port on termination" do - {:ok, reader} = InputReader.start_link(target: self()) - - # Stop the reader - :ok = InputReader.stop(reader) - - # Process should be dead - refute Process.alive?(reader) - end - - @tag :requires_terminal - test "handles normal shutdown" do - {:ok, reader} = InputReader.start_link(target: self()) - - # GenServer.stop with normal reason - GenServer.stop(reader, :normal) - refute Process.alive?(reader) - end - end -end diff --git a/test/term_ui/terminal/mouse_test.exs b/test/term_ui/terminal/mouse_test.exs deleted file mode 100644 index b971a0c8..00000000 --- a/test/term_ui/terminal/mouse_test.exs +++ /dev/null @@ -1,149 +0,0 @@ -defmodule TermUI.Terminal.MouseTest do - use ExUnit.Case, async: true - - alias TermUI.Event - alias TermUI.Terminal.EscapeParser - - describe "mouse event parsing - button press" do - test "parses left button press" do - # ESC [ < 0 ; 10 ; 20 M - {events, remaining} = EscapeParser.parse("\e[<0;10;20M") - assert remaining == <<>> - assert [%Event.Mouse{action: :press, button: :left, x: 9, y: 19}] = events - end - - test "parses middle button press" do - {events, remaining} = EscapeParser.parse("\e[<1;5;5M") - assert remaining == <<>> - assert [%Event.Mouse{action: :press, button: :middle, x: 4, y: 4}] = events - end - - test "parses right button press" do - {events, remaining} = EscapeParser.parse("\e[<2;1;1M") - assert remaining == <<>> - assert [%Event.Mouse{action: :press, button: :right, x: 0, y: 0}] = events - end - end - - describe "mouse event parsing - button release" do - test "parses button release" do - # ESC [ < 0 ; 10 ; 20 m (lowercase m for release) - {events, remaining} = EscapeParser.parse("\e[<0;10;20m") - assert remaining == <<>> - assert [%Event.Mouse{action: :release, x: 9, y: 19}] = events - end - end - - describe "mouse event parsing - scroll wheel" do - test "parses scroll up" do - # Button code 64 = scroll up - {events, remaining} = EscapeParser.parse("\e[<64;15;10M") - assert remaining == <<>> - assert [%Event.Mouse{action: :scroll_up, button: nil, x: 14, y: 9}] = events - end - - test "parses scroll down" do - # Button code 65 = scroll down - {events, remaining} = EscapeParser.parse("\e[<65;15;10M") - assert remaining == <<>> - assert [%Event.Mouse{action: :scroll_down, button: nil, x: 14, y: 9}] = events - end - end - - describe "mouse event parsing - drag" do - test "parses left button drag" do - # Button code 32 = motion flag + left button (0) - {events, remaining} = EscapeParser.parse("\e[<32;20;30M") - assert remaining == <<>> - assert [%Event.Mouse{action: :drag, button: :left, x: 19, y: 29}] = events - end - - test "parses right button drag" do - # Button code 34 = motion flag (32) + right button (2) - {events, remaining} = EscapeParser.parse("\e[<34;5;5M") - assert remaining == <<>> - assert [%Event.Mouse{action: :drag, button: :right, x: 4, y: 4}] = events - end - end - - describe "mouse event parsing - modifiers" do - test "parses Shift+click" do - # Button code 4 = shift modifier - {events, remaining} = EscapeParser.parse("\e[<4;10;10M") - assert remaining == <<>> - assert [%Event.Mouse{modifiers: modifiers}] = events - assert :shift in modifiers - end - - test "parses Alt+click" do - # Button code 8 = alt modifier - {events, remaining} = EscapeParser.parse("\e[<8;10;10M") - assert remaining == <<>> - assert [%Event.Mouse{modifiers: modifiers}] = events - assert :alt in modifiers - end - - test "parses Ctrl+click" do - # Button code 16 = ctrl modifier - {events, remaining} = EscapeParser.parse("\e[<16;10;10M") - assert remaining == <<>> - assert [%Event.Mouse{modifiers: modifiers}] = events - assert :ctrl in modifiers - end - - test "parses multiple modifiers" do - # Button code 28 = shift (4) + alt (8) + ctrl (16) - {events, remaining} = EscapeParser.parse("\e[<28;10;10M") - assert remaining == <<>> - assert [%Event.Mouse{modifiers: modifiers}] = events - assert :shift in modifiers - assert :alt in modifiers - assert :ctrl in modifiers - end - end - - describe "mouse event parsing - coordinate conversion" do - test "converts 1-indexed to 0-indexed coordinates" do - # Terminal sends 1,1 for top-left - {events, remaining} = EscapeParser.parse("\e[<0;1;1M") - assert remaining == <<>> - assert [%Event.Mouse{x: 0, y: 0}] = events - end - - test "handles large coordinates" do - {events, remaining} = EscapeParser.parse("\e[<0;255;100M") - assert remaining == <<>> - assert [%Event.Mouse{x: 254, y: 99}] = events - end - end - - describe "mouse event parsing - incomplete sequences" do - test "returns incomplete for partial mouse sequence" do - {events, remaining} = EscapeParser.parse("\e[<0;10;") - assert events == [] - assert remaining == "\e[<0;10;" - end - - test "returns incomplete for mouse sequence without terminator" do - {events, remaining} = EscapeParser.parse("\e[<0;10;20") - assert events == [] - assert remaining == "\e[<0;10;20" - end - end - - describe "mouse events mixed with keyboard" do - test "parses mouse followed by key" do - {events, remaining} = EscapeParser.parse("\e[<0;5;5Ma") - assert remaining == <<>> - assert length(events) == 2 - assert [%Event.Mouse{}, %Event.Key{key: "a"}] = events - end - - test "parses key followed by mouse" do - {events, remaining} = EscapeParser.parse("x\e[<0;5;5M") - assert remaining == <<>> - assert length(events) == 2 - assert [%Event.Key{key: "x"}, %Event.Mouse{}] = events - end - end -end diff --git a/test/term_ui/terminal/raw_mode_test.exs b/test/term_ui/terminal/raw_mode_test.exs deleted file mode 100644 index a03d7808..00000000 --- a/test/term_ui/terminal/raw_mode_test.exs +++ /dev/null @@ -1,224 +0,0 @@ -defmodule TermUI.Terminal.RawModeTest do - use ExUnit.Case, async: false - - alias TermUI.Terminal - - describe "raw mode enable/disable" do - test "enable_raw_mode returns ok tuple" do - # This test needs the Terminal GenServer running - {:ok, _pid} = Terminal.start_link() - - result = Terminal.enable_raw_mode() - - case result do - {:ok, _state} -> - # Successfully enabled - now disable - assert :ok = Terminal.disable_raw_mode() - - {:error, :not_a_terminal} -> - # Expected when running in non-interactive environment (CI, tests) - assert true - - {:error, reason} -> - # Some other error - document for compatibility research - IO.puts("Raw mode enable failed with: #{inspect(reason)}") - assert true - end - - Terminal.restore() - GenServer.stop(Terminal) - end - - test "disable_raw_mode returns ok when not in raw mode" do - {:ok, _pid} = Terminal.start_link() - - # Should be safe to disable even when not enabled - result = Terminal.disable_raw_mode() - assert result == :ok - - GenServer.stop(Terminal) - end - - test "raw_mode? returns false initially" do - {:ok, _pid} = Terminal.start_link() - - refute Terminal.raw_mode?() - - GenServer.stop(Terminal) - end - - test "raw_mode? returns true after enable" do - {:ok, _pid} = Terminal.start_link() - - result = Terminal.enable_raw_mode() - - case result do - {:ok, _state} -> - assert Terminal.raw_mode?() - Terminal.disable_raw_mode() - - {:error, _reason} -> - # Not a terminal in test environment - refute Terminal.raw_mode?() - end - - GenServer.stop(Terminal) - end - - test "raw_mode? returns false after disable" do - {:ok, _pid} = Terminal.start_link() - - case Terminal.enable_raw_mode() do - {:ok, _state} -> - assert Terminal.raw_mode?() - Terminal.disable_raw_mode() - refute Terminal.raw_mode?() - - {:error, _reason} -> - refute Terminal.raw_mode?() - end - - GenServer.stop(Terminal) - end - end - - describe "restore/0" do - test "restore returns ok" do - {:ok, _pid} = Terminal.start_link() - - result = Terminal.restore() - assert result == :ok - - GenServer.stop(Terminal) - end - - test "restore clears raw mode state" do - {:ok, _pid} = Terminal.start_link() - - case Terminal.enable_raw_mode() do - {:ok, _state} -> - Terminal.restore() - refute Terminal.raw_mode?() - - {:error, _reason} -> - Terminal.restore() - refute Terminal.raw_mode?() - end - - GenServer.stop(Terminal) - end - - test "restore can be called multiple times safely" do - {:ok, _pid} = Terminal.start_link() - - assert :ok = Terminal.restore() - assert :ok = Terminal.restore() - assert :ok = Terminal.restore() - - GenServer.stop(Terminal) - end - end - - describe "get_state/0" do - test "get_state returns state struct" do - {:ok, _pid} = Terminal.start_link() - - state = Terminal.get_state() - assert is_struct(state, TermUI.Terminal.State) - assert state.cursor_visible == true - assert state.raw_mode_active == false - - GenServer.stop(Terminal) - end - - test "state reflects raw mode activation" do - {:ok, _pid} = Terminal.start_link() - - case Terminal.enable_raw_mode() do - {:ok, _result} -> - state = Terminal.get_state() - assert state.raw_mode_active == true - # Original settings should be captured - assert state.original_settings != nil - Terminal.disable_raw_mode() - - {:error, _reason} -> - state = Terminal.get_state() - assert state.raw_mode_active == false - end - - GenServer.stop(Terminal) - end - end - - describe "terminal detection" do - test "returns not_a_terminal error when not a tty" do - # In test environment, stdin is typically not a terminal - # So we expect this error - {:ok, _pid} = Terminal.start_link() - - result = Terminal.enable_raw_mode() - - case result do - {:error, :not_a_terminal} -> - # Expected in test environment - assert true - - {:ok, _state} -> - # Running with a real terminal - Terminal.disable_raw_mode() - assert true - - {:error, _reason} -> - # Some other error - assert true - end - - GenServer.stop(Terminal) - end - end - - describe "double enable/disable" do - test "double enable is safe" do - {:ok, _pid} = Terminal.start_link() - - result1 = Terminal.enable_raw_mode() - result2 = Terminal.enable_raw_mode() - - # Second enable should return the same state - case {result1, result2} do - {{:ok, _}, {:ok, _}} -> - # Both succeeded - assert Terminal.raw_mode?() - Terminal.disable_raw_mode() - - {{:error, _}, {:error, _}} -> - # Both failed (not a terminal) - refute Terminal.raw_mode?() - - _ -> - # Unexpected combination - Terminal.disable_raw_mode() - end - - GenServer.stop(Terminal) - end - - test "double disable is safe" do - {:ok, _pid} = Terminal.start_link() - - case Terminal.enable_raw_mode() do - {:ok, _state} -> - assert :ok = Terminal.disable_raw_mode() - assert :ok = Terminal.disable_raw_mode() - refute Terminal.raw_mode?() - - {:error, _reason} -> - assert :ok = Terminal.disable_raw_mode() - assert :ok = Terminal.disable_raw_mode() - end - - GenServer.stop(Terminal) - end - end -end diff --git a/test/term_ui/terminal/state_test.exs b/test/term_ui/terminal/state_test.exs deleted file mode 100644 index b011c687..00000000 --- a/test/term_ui/terminal/state_test.exs +++ /dev/null @@ -1,35 +0,0 @@ -defmodule TermUI.Terminal.StateTest do - use ExUnit.Case, async: true - - alias TermUI.Terminal.State - - describe "new/0" do - test "creates state with default values" do - state = State.new() - - assert state.raw_mode_active == false - assert state.alternate_screen_active == false - assert state.cursor_visible == true - assert state.mouse_tracking == :off - assert state.bracketed_paste == false - assert state.focus_events == false - assert state.original_settings == nil - assert state.size == nil - assert state.resize_callbacks == [] - end - end - - describe "new/2" do - test "creates state with specified size" do - state = State.new(24, 80) - - assert state.size == {24, 80} - assert state.raw_mode_active == false - end - - test "accepts various terminal sizes" do - state = State.new(50, 200) - assert state.size == {50, 200} - end - end -end diff --git a/test/term_ui/terminal_test.exs b/test/term_ui/terminal_test.exs deleted file mode 100644 index 0c767016..00000000 --- a/test/term_ui/terminal_test.exs +++ /dev/null @@ -1,292 +0,0 @@ -defmodule TermUI.TerminalTest do - use ExUnit.Case - - alias TermUI.Terminal - alias TermUI.Terminal.State - - setup do - # Start a fresh Terminal GenServer for each test - case Process.whereis(Terminal) do - nil -> - :ok - - pid -> - ref = Process.monitor(pid) - Process.exit(pid, :shutdown) - - receive do - {:DOWN, ^ref, :process, ^pid, _} -> :ok - after - 100 -> :ok - end - end - - {:ok, pid} = Terminal.start_link() - - on_exit(fn -> - case Process.whereis(Terminal) do - nil -> - :ok - - pid when is_pid(pid) -> - if Process.alive?(pid) do - ref = Process.monitor(pid) - Process.exit(pid, :shutdown) - - receive do - {:DOWN, ^ref, :process, ^pid, _} -> :ok - after - 100 -> :ok - end - end - end - end) - - {:ok, pid: pid} - end - - describe "start_link/1" do - test "starts the GenServer" do - # Already started in setup, verify it's running - assert Process.whereis(Terminal) != nil - end - - test "sets trap_exit flag" do - pid = Process.whereis(Terminal) - {:trap_exit, trap} = Process.info(pid, :trap_exit) - assert trap == true - end - end - - describe "get_state/0" do - test "returns initial state with default values" do - state = Terminal.get_state() - - assert %State{} = state - assert state.raw_mode_active == false - assert state.alternate_screen_active == false - assert state.cursor_visible == true - end - end - - describe "enable_raw_mode/0" do - test "returns error when not in a terminal context" do - # In test environment, we're typically not in a real terminal - result = Terminal.enable_raw_mode() - - case result do - {:ok, state} -> - # If it somehow succeeded (real terminal), verify state - assert state.raw_mode_active == true - Terminal.disable_raw_mode() - - {:error, reason} -> - # Expected in test environment - assert reason in [:not_a_terminal, :enotsup] or match?({:otp_version, _}, reason) - end - end - - test "is idempotent when already active" do - # First call - result1 = Terminal.enable_raw_mode() - - case result1 do - {:ok, _state} -> - # Second call should return same state - {:ok, state2} = Terminal.enable_raw_mode() - assert state2.raw_mode_active == true - Terminal.disable_raw_mode() - - {:error, _} -> - :ok - end - end - end - - describe "disable_raw_mode/0" do - test "returns ok when raw mode is not active" do - assert Terminal.disable_raw_mode() == :ok - end - - test "updates state when disabled" do - # Try to enable first - Terminal.enable_raw_mode() - Terminal.disable_raw_mode() - - state = Terminal.get_state() - assert state.raw_mode_active == false - end - end - - describe "raw_mode?/0" do - test "returns false when raw mode is not active" do - assert Terminal.raw_mode?() == false - end - end - - describe "enter_alternate_screen/0" do - test "updates state to track alternate screen is active" do - :ok = Terminal.enter_alternate_screen() - state = Terminal.get_state() - - assert state.alternate_screen_active == true - end - - test "is idempotent" do - :ok = Terminal.enter_alternate_screen() - :ok = Terminal.enter_alternate_screen() - - state = Terminal.get_state() - assert state.alternate_screen_active == true - end - end - - describe "leave_alternate_screen/0" do - test "updates state to track alternate screen is inactive" do - Terminal.enter_alternate_screen() - :ok = Terminal.leave_alternate_screen() - - state = Terminal.get_state() - assert state.alternate_screen_active == false - end - - test "returns ok when not in alternate screen" do - assert Terminal.leave_alternate_screen() == :ok - end - end - - describe "hide_cursor/0" do - test "updates state to track cursor is hidden" do - :ok = Terminal.hide_cursor() - state = Terminal.get_state() - - assert state.cursor_visible == false - end - end - - describe "show_cursor/0" do - test "updates state to track cursor is visible" do - Terminal.hide_cursor() - :ok = Terminal.show_cursor() - - state = Terminal.get_state() - assert state.cursor_visible == true - end - end - - describe "get_terminal_size/0" do - test "returns size tuple or error" do - result = Terminal.get_terminal_size() - - case result do - {:ok, {rows, cols}} -> - assert is_integer(rows) and rows > 0 - assert is_integer(cols) and cols > 0 - - {:error, _reason} -> - # Expected in test environment without real terminal - :ok - end - end - - test "caches size in state" do - case Terminal.get_terminal_size() do - {:ok, {rows, cols}} -> - state = Terminal.get_state() - assert state.size == {rows, cols} - - {:error, _} -> - :ok - end - end - end - - describe "register_resize_callback/1" do - test "adds pid to callback list" do - :ok = Terminal.register_resize_callback(self()) - state = Terminal.get_state() - - assert self() in state.resize_callbacks - end - - test "does not duplicate pids" do - :ok = Terminal.register_resize_callback(self()) - :ok = Terminal.register_resize_callback(self()) - - state = Terminal.get_state() - assert Enum.count(state.resize_callbacks, &(&1 == self())) == 1 - end - end - - describe "unregister_resize_callback/1" do - test "removes pid from callback list" do - Terminal.register_resize_callback(self()) - :ok = Terminal.unregister_resize_callback(self()) - - state = Terminal.get_state() - refute self() in state.resize_callbacks - end - - test "handles unregistering non-existent pid" do - assert :ok = Terminal.unregister_resize_callback(self()) - end - end - - describe "restore/0" do - test "resets all terminal state" do - # Set up some state - Terminal.enter_alternate_screen() - Terminal.hide_cursor() - Terminal.register_resize_callback(self()) - - :ok = Terminal.restore() - - state = Terminal.get_state() - assert state.alternate_screen_active == false - assert state.cursor_visible == true - assert state.raw_mode_active == false - end - end - - describe "resize notifications" do - test "sends message to registered callbacks on sigwinch" do - Terminal.register_resize_callback(self()) - - # Simulate SIGWINCH - send(Process.whereis(Terminal), :sigwinch) - - # May or may not receive message depending on whether size detection works - receive do - {:terminal_resize, {rows, cols}} -> - assert is_integer(rows) - assert is_integer(cols) - after - 100 -> - # No message received is ok in test environment - :ok - end - end - end - - describe "process exit handling" do - test "cleans up state on restore call" do - # Set up state - Terminal.enter_alternate_screen() - Terminal.hide_cursor() - - # Verify state is set - state_before = Terminal.get_state() - assert state_before.alternate_screen_active == true - assert state_before.cursor_visible == false - - # Call restore to simulate cleanup - :ok = Terminal.restore() - - # Verify cleanup occurred - state = Terminal.get_state() - assert state.alternate_screen_active == false - assert state.cursor_visible == true - assert state.raw_mode_active == false - end - end -end diff --git a/test/term_ui/test/assertions_test.exs b/test/term_ui/test/assertions_test.exs deleted file mode 100644 index 0e11aabd..00000000 --- a/test/term_ui/test/assertions_test.exs +++ /dev/null @@ -1,315 +0,0 @@ -defmodule TermUI.Test.AssertionsTest do - use ExUnit.Case, async: true - use TermUI.Test.Assertions - - alias TermUI.Renderer.Cell - alias TermUI.Test.TestRenderer - - describe "assert_text/4" do - test "passes when text matches" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 1, 1, "Hello") - assert_text(renderer, 1, 1, "Hello") - TestRenderer.destroy(renderer) - end - - test "fails when text differs" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 1, 1, "Hello") - - assert_raise ExUnit.AssertionError, ~r/Text assertion failed/, fn -> - assert_text(renderer, 1, 1, "World") - end - - TestRenderer.destroy(renderer) - end - end - - describe "refute_text/4" do - test "passes when text differs" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 1, 1, "Hello") - refute_text(renderer, 1, 1, "World") - TestRenderer.destroy(renderer) - end - - test "fails when text matches" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 1, 1, "Hello") - - assert_raise ExUnit.AssertionError, ~r/Text refutation failed/, fn -> - refute_text(renderer, 1, 1, "Hello") - end - - TestRenderer.destroy(renderer) - end - end - - describe "assert_text_contains/5" do - test "passes when region contains text" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 1, 1, "Hello, World!") - assert_text_contains(renderer, 1, 1, 13, "World") - TestRenderer.destroy(renderer) - end - - test "fails when region does not contain text" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 1, 1, "Hello") - - assert_raise ExUnit.AssertionError, ~r/Text contains assertion failed/, fn -> - assert_text_contains(renderer, 1, 1, 5, "World") - end - - TestRenderer.destroy(renderer) - end - end - - describe "refute_text_contains/5" do - test "passes when region does not contain text" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 1, 1, "Hello") - refute_text_contains(renderer, 1, 1, 5, "World") - TestRenderer.destroy(renderer) - end - - test "fails when region contains text" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 1, 1, "Hello, World!") - - assert_raise ExUnit.AssertionError, ~r/Text contains refutation failed/, fn -> - refute_text_contains(renderer, 1, 1, 13, "World") - end - - TestRenderer.destroy(renderer) - end - end - - describe "assert_text_exists/2" do - test "passes when text found anywhere" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 5, 10, "Error") - assert_text_exists(renderer, "Error") - TestRenderer.destroy(renderer) - end - - test "fails when text not found" do - {:ok, renderer} = TestRenderer.new(10, 80) - - assert_raise ExUnit.AssertionError, ~r/Text existence assertion failed/, fn -> - assert_text_exists(renderer, "NotFound") - end - - TestRenderer.destroy(renderer) - end - end - - describe "refute_text_exists/2" do - test "passes when text not found" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 1, 1, "Hello") - refute_text_exists(renderer, "NotFound") - TestRenderer.destroy(renderer) - end - - test "fails when text found" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 1, 1, "Error") - - assert_raise ExUnit.AssertionError, ~r/Text existence refutation failed/, fn -> - refute_text_exists(renderer, "Error") - end - - TestRenderer.destroy(renderer) - end - end - - describe "assert_style/4" do - test "passes when fg matches" do - {:ok, renderer} = TestRenderer.new(10, 80) - cell = Cell.new("X", fg: :red) - TestRenderer.set_cell(renderer, 1, 1, cell) - assert_style(renderer, 1, 1, fg: :red) - TestRenderer.destroy(renderer) - end - - test "passes when bg matches" do - {:ok, renderer} = TestRenderer.new(10, 80) - cell = Cell.new("X", bg: :blue) - TestRenderer.set_cell(renderer, 1, 1, cell) - assert_style(renderer, 1, 1, bg: :blue) - TestRenderer.destroy(renderer) - end - - test "passes when attrs match" do - {:ok, renderer} = TestRenderer.new(10, 80) - cell = Cell.new("X", attrs: [:bold]) - TestRenderer.set_cell(renderer, 1, 1, cell) - assert_style(renderer, 1, 1, attrs: [:bold]) - TestRenderer.destroy(renderer) - end - - test "fails when fg differs" do - {:ok, renderer} = TestRenderer.new(10, 80) - cell = Cell.new("X", fg: :red) - TestRenderer.set_cell(renderer, 1, 1, cell) - - assert_raise ExUnit.AssertionError, ~r/Style assertion failed/, fn -> - assert_style(renderer, 1, 1, fg: :blue) - end - - TestRenderer.destroy(renderer) - end - end - - describe "assert_attr/4" do - test "passes when cell has attribute" do - {:ok, renderer} = TestRenderer.new(10, 80) - cell = Cell.new("X", attrs: [:bold, :underline]) - TestRenderer.set_cell(renderer, 1, 1, cell) - assert_attr(renderer, 1, 1, :bold) - TestRenderer.destroy(renderer) - end - - test "fails when cell lacks attribute" do - {:ok, renderer} = TestRenderer.new(10, 80) - cell = Cell.new("X") - TestRenderer.set_cell(renderer, 1, 1, cell) - - assert_raise ExUnit.AssertionError, ~r/Attribute assertion failed/, fn -> - assert_attr(renderer, 1, 1, :bold) - end - - TestRenderer.destroy(renderer) - end - end - - describe "refute_attr/4" do - test "passes when cell lacks attribute" do - {:ok, renderer} = TestRenderer.new(10, 80) - cell = Cell.new("X") - TestRenderer.set_cell(renderer, 1, 1, cell) - refute_attr(renderer, 1, 1, :bold) - TestRenderer.destroy(renderer) - end - - test "fails when cell has attribute" do - {:ok, renderer} = TestRenderer.new(10, 80) - cell = Cell.new("X", attrs: [:bold]) - TestRenderer.set_cell(renderer, 1, 1, cell) - - assert_raise ExUnit.AssertionError, ~r/Attribute refutation failed/, fn -> - refute_attr(renderer, 1, 1, :bold) - end - - TestRenderer.destroy(renderer) - end - end - - describe "assert_state/3" do - test "passes when state at path matches" do - state = %{counter: %{value: 42}} - assert_state(state, [:counter, :value], 42) - end - - test "fails when state at path differs" do - state = %{counter: %{value: 42}} - - assert_raise ExUnit.AssertionError, ~r/State assertion failed/, fn -> - assert_state(state, [:counter, :value], 100) - end - end - end - - describe "refute_state/3" do - test "passes when state at path differs" do - state = %{counter: %{value: 42}} - refute_state(state, [:counter, :value], 100) - end - - test "fails when state at path matches" do - state = %{counter: %{value: 42}} - - assert_raise ExUnit.AssertionError, ~r/State refutation failed/, fn -> - refute_state(state, [:counter, :value], 42) - end - end - end - - describe "assert_state_exists/2" do - test "passes when state at path exists" do - state = %{counter: %{value: 42}} - assert_state_exists(state, [:counter, :value]) - end - - test "fails when state at path is nil" do - state = %{counter: %{value: nil}} - - assert_raise ExUnit.AssertionError, ~r/State existence assertion failed/, fn -> - assert_state_exists(state, [:counter, :value]) - end - end - end - - describe "assert_snapshot/2" do - test "passes when buffer matches snapshot" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 1, 1, "Test") - snapshot = TestRenderer.snapshot(renderer) - assert_snapshot(renderer, snapshot) - TestRenderer.destroy(renderer) - end - - test "fails when buffer differs from snapshot" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 1, 1, "Test") - snapshot = TestRenderer.snapshot(renderer) - TestRenderer.write_string(renderer, 1, 1, "Changed") - - assert_raise ExUnit.AssertionError, ~r/Snapshot assertion failed/, fn -> - assert_snapshot(renderer, snapshot) - end - - TestRenderer.destroy(renderer) - end - end - - describe "assert_empty/1" do - test "passes for empty buffer" do - {:ok, renderer} = TestRenderer.new(10, 80) - assert_empty(renderer) - TestRenderer.destroy(renderer) - end - - test "fails for non-empty buffer" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 1, 1, "Text") - - assert_raise ExUnit.AssertionError, ~r/Empty buffer assertion failed/, fn -> - assert_empty(renderer) - end - - TestRenderer.destroy(renderer) - end - end - - describe "assert_row/3" do - test "passes when row matches" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 1, 1, "Hello") - assert_row(renderer, 1, "Hello") - TestRenderer.destroy(renderer) - end - - test "fails when row differs" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 1, 1, "Hello") - - assert_raise ExUnit.AssertionError, ~r/Row assertion failed/, fn -> - assert_row(renderer, 1, "World") - end - - TestRenderer.destroy(renderer) - end - end -end diff --git a/test/term_ui/test/component_harness_test.exs b/test/term_ui/test/component_harness_test.exs deleted file mode 100644 index 9dc359bb..00000000 --- a/test/term_ui/test/component_harness_test.exs +++ /dev/null @@ -1,293 +0,0 @@ -defmodule TermUI.Test.ComponentHarnessTest do - use ExUnit.Case, async: true - - alias TermUI.Event - alias TermUI.Test.ComponentHarness - alias TermUI.Test.TestRenderer - - # Test component for harness testing - defmodule Counter do - import TermUI.Component.Helpers - - def init(props) do - %{count: Keyword.get(props, :initial, 0)} - end - - def render(state) do - text("Count: #{state.count}") - end - - def handle_event(%Event.Key{key: :up}, state) do - {:noreply, %{state | count: state.count + 1}} - end - - def handle_event(%Event.Key{key: :down}, state) do - {:noreply, %{state | count: max(0, state.count - 1)}} - end - - def handle_event(_event, state) do - {:noreply, state} - end - end - - # Simple component without events - defmodule StaticLabel do - import TermUI.Component.Helpers - - def init(props) do - %{text: Keyword.get(props, :text, "Label")} - end - - def render(state) do - text(state.text) - end - end - - describe "mount_test/2" do - test "mounts component with default dimensions" do - {:ok, harness} = ComponentHarness.mount_test(Counter) - assert harness.module == Counter - assert harness.state == %{count: 0} - ComponentHarness.unmount(harness) - end - - test "mounts component with initial props" do - {:ok, harness} = ComponentHarness.mount_test(Counter, initial: 10) - assert harness.state == %{count: 10} - ComponentHarness.unmount(harness) - end - - test "creates renderer with custom dimensions" do - {:ok, harness} = ComponentHarness.mount_test(Counter, width: 40, height: 10) - {rows, cols} = TestRenderer.dimensions(harness.renderer) - assert rows == 10 - assert cols == 40 - ComponentHarness.unmount(harness) - end - end - - describe "unmount/1" do - test "cleans up renderer" do - {:ok, harness} = ComponentHarness.mount_test(Counter) - assert :ok = ComponentHarness.unmount(harness) - end - end - - describe "render/1" do - test "renders component to buffer" do - {:ok, harness} = ComponentHarness.mount_test(Counter, initial: 5) - harness = ComponentHarness.render(harness) - - renderer = ComponentHarness.get_renderer(harness) - assert TestRenderer.text_at?(renderer, 1, 1, "Count: 5") - ComponentHarness.unmount(harness) - end - - test "stores render result" do - {:ok, harness} = ComponentHarness.mount_test(Counter) - harness = ComponentHarness.render(harness) - - render = ComponentHarness.get_render(harness) - assert render != nil - ComponentHarness.unmount(harness) - end - end - - describe "send_event/2" do - test "updates component state" do - {:ok, harness} = ComponentHarness.mount_test(Counter, initial: 5) - - harness = ComponentHarness.send_event(harness, Event.key(:up)) - assert ComponentHarness.get_state(harness) == %{count: 6} - - harness = ComponentHarness.send_event(harness, Event.key(:down)) - assert ComponentHarness.get_state(harness) == %{count: 5} - - ComponentHarness.unmount(harness) - end - - test "stores sent events" do - {:ok, harness} = ComponentHarness.mount_test(Counter) - - harness = ComponentHarness.send_event(harness, Event.key(:up)) - harness = ComponentHarness.send_event(harness, Event.key(:up)) - - events = ComponentHarness.get_events(harness) - assert length(events) == 2 - ComponentHarness.unmount(harness) - end - - test "handles unhandled events gracefully" do - {:ok, harness} = ComponentHarness.mount_test(StaticLabel, text: "Test") - - # StaticLabel doesn't have handle_event - harness = ComponentHarness.send_event(harness, Event.key(:enter)) - assert harness.state == %{text: "Test"} - ComponentHarness.unmount(harness) - end - end - - describe "send_events/2" do - test "sends multiple events in sequence" do - {:ok, harness} = ComponentHarness.mount_test(Counter, initial: 0) - - events = [ - Event.key(:up), - Event.key(:up), - Event.key(:up) - ] - - harness = ComponentHarness.send_events(harness, events) - assert ComponentHarness.get_state(harness) == %{count: 3} - ComponentHarness.unmount(harness) - end - end - - describe "get_state/1" do - test "returns current state" do - {:ok, harness} = ComponentHarness.mount_test(Counter, initial: 42) - assert ComponentHarness.get_state(harness) == %{count: 42} - ComponentHarness.unmount(harness) - end - end - - describe "get_renderer/1" do - test "returns test renderer" do - {:ok, harness} = ComponentHarness.mount_test(Counter) - renderer = ComponentHarness.get_renderer(harness) - assert %TestRenderer{} = renderer - ComponentHarness.unmount(harness) - end - end - - describe "get_render/1" do - test "returns nil before first render" do - {:ok, harness} = ComponentHarness.mount_test(Counter) - assert ComponentHarness.get_render(harness) == nil - ComponentHarness.unmount(harness) - end - - test "returns most recent render" do - {:ok, harness} = ComponentHarness.mount_test(Counter) - harness = ComponentHarness.render(harness) - assert ComponentHarness.get_render(harness) != nil - ComponentHarness.unmount(harness) - end - end - - describe "get_renders/1" do - test "returns all render results" do - {:ok, harness} = ComponentHarness.mount_test(Counter) - - harness = ComponentHarness.render(harness) - harness = ComponentHarness.send_event(harness, Event.key(:up)) - harness = ComponentHarness.render(harness) - - renders = ComponentHarness.get_renders(harness) - assert length(renders) == 2 - ComponentHarness.unmount(harness) - end - end - - describe "get_area/1" do - test "returns render area" do - {:ok, harness} = ComponentHarness.mount_test(Counter, width: 100, height: 50) - area = ComponentHarness.get_area(harness) - assert area.width == 100 - assert area.height == 50 - ComponentHarness.unmount(harness) - end - end - - describe "update_state/2" do - test "updates state with function" do - {:ok, harness} = ComponentHarness.mount_test(Counter, initial: 5) - - harness = - ComponentHarness.update_state(harness, fn state -> - %{state | count: state.count * 2} - end) - - assert ComponentHarness.get_state(harness) == %{count: 10} - ComponentHarness.unmount(harness) - end - end - - describe "set_state/2" do - test "sets state directly" do - {:ok, harness} = ComponentHarness.mount_test(Counter) - - harness = ComponentHarness.set_state(harness, %{count: 100}) - assert ComponentHarness.get_state(harness) == %{count: 100} - ComponentHarness.unmount(harness) - end - end - - describe "get_state_at/2" do - test "returns state value at path" do - {:ok, harness} = ComponentHarness.mount_test(Counter, initial: 42) - assert ComponentHarness.get_state_at(harness, [:count]) == 42 - ComponentHarness.unmount(harness) - end - end - - describe "render_cycle/1" do - test "renders component and returns harness" do - {:ok, harness} = ComponentHarness.mount_test(Counter) - harness = ComponentHarness.render_cycle(harness) - assert length(ComponentHarness.get_renders(harness)) == 1 - ComponentHarness.unmount(harness) - end - end - - describe "event_cycle/2" do - test "sends event and renders" do - {:ok, harness} = ComponentHarness.mount_test(Counter, initial: 0) - - harness = ComponentHarness.event_cycle(harness, Event.key(:up)) - - assert ComponentHarness.get_state(harness) == %{count: 1} - assert length(ComponentHarness.get_renders(harness)) == 1 - ComponentHarness.unmount(harness) - end - end - - describe "reset/1" do - test "resets to initial state" do - {:ok, harness} = ComponentHarness.mount_test(Counter, initial: 5) - - harness = ComponentHarness.send_event(harness, Event.key(:up)) - harness = ComponentHarness.render(harness) - assert ComponentHarness.get_state(harness) == %{count: 6} - - {:ok, harness} = ComponentHarness.reset(harness) - assert ComponentHarness.get_state(harness) == %{count: 5} - assert ComponentHarness.get_events(harness) == [] - assert ComponentHarness.get_renders(harness) == [] - ComponentHarness.unmount(harness) - end - end - - describe "integration test" do - test "full component lifecycle" do - # Mount - {:ok, harness} = ComponentHarness.mount_test(Counter, initial: 0) - - # Initial render - harness = ComponentHarness.render(harness) - renderer = ComponentHarness.get_renderer(harness) - assert TestRenderer.text_at?(renderer, 1, 1, "Count: 0") - - # Interact - harness = ComponentHarness.send_event(harness, Event.key(:up)) - harness = ComponentHarness.send_event(harness, Event.key(:up)) - - # Re-render - harness = ComponentHarness.render(harness) - assert TestRenderer.text_at?(renderer, 1, 1, "Count: 2") - - # Cleanup - ComponentHarness.unmount(harness) - end - end -end diff --git a/test/term_ui/test/event_simulator_test.exs b/test/term_ui/test/event_simulator_test.exs deleted file mode 100644 index fe81a7dd..00000000 --- a/test/term_ui/test/event_simulator_test.exs +++ /dev/null @@ -1,221 +0,0 @@ -defmodule TermUI.Test.EventSimulatorTest do - use ExUnit.Case, async: true - - alias TermUI.Event - alias TermUI.Event.Focus - alias TermUI.Event.Key - alias TermUI.Event.Mouse - alias TermUI.Event.Paste - alias TermUI.Event.Resize - alias TermUI.Test.EventSimulator - - describe "simulate_key/2" do - test "creates key event" do - event = EventSimulator.simulate_key(:enter) - assert %Key{} = event - assert event.key == :enter - end - - test "includes character" do - event = EventSimulator.simulate_key(:a, char: "a") - assert event.char == "a" - end - - test "includes modifiers" do - event = EventSimulator.simulate_key(:c, modifiers: [:ctrl]) - assert :ctrl in event.modifiers - end - end - - describe "simulate_click/4" do - test "creates click event at position" do - event = EventSimulator.simulate_click(10, 20) - assert %Mouse{} = event - assert event.action == :click - assert event.button == :left - assert event.x == 10 - assert event.y == 20 - end - - test "supports different buttons" do - event = EventSimulator.simulate_click(10, 20, :right) - assert event.button == :right - end - - test "includes modifiers" do - event = EventSimulator.simulate_click(10, 20, :left, modifiers: [:ctrl]) - assert :ctrl in event.modifiers - end - end - - describe "simulate_double_click/4" do - test "creates double click event" do - event = EventSimulator.simulate_double_click(5, 10) - assert event.action == :double_click - end - end - - describe "simulate_move/3" do - test "creates move event" do - event = EventSimulator.simulate_move(15, 25) - assert event.action == :move - assert event.x == 15 - assert event.y == 25 - end - end - - describe "simulate_drag/4" do - test "creates drag event" do - event = EventSimulator.simulate_drag(10, 20) - assert event.action == :drag - end - end - - describe "simulate_scroll_up/3" do - test "creates scroll up event" do - event = EventSimulator.simulate_scroll_up(10, 20) - assert event.action == :scroll_up - end - end - - describe "simulate_scroll_down/3" do - test "creates scroll down event" do - event = EventSimulator.simulate_scroll_down(10, 20) - assert event.action == :scroll_down - end - end - - describe "simulate_type/2" do - test "creates events for each character" do - events = EventSimulator.simulate_type("Hello") - assert length(events) == 5 - assert Enum.all?(events, &match?(%Key{}, &1)) - end - - test "sets character on each event" do - events = EventSimulator.simulate_type("Hi") - assert hd(events).char == "H" - assert List.last(events).char == "i" - end - - test "adds shift for uppercase" do - events = EventSimulator.simulate_type("A") - assert :shift in hd(events).modifiers - end - - test "handles special characters" do - events = EventSimulator.simulate_type(" ") - assert hd(events).key == :space - end - end - - describe "simulate_sequence/1" do - test "creates events from key atoms" do - events = EventSimulator.simulate_sequence([:tab, :enter]) - assert length(events) == 2 - assert hd(events).key == :tab - assert List.last(events).key == :enter - end - - test "handles key-options tuples" do - events = EventSimulator.simulate_sequence([{:a, char: "a"}, :enter]) - assert hd(events).char == "a" - end - end - - describe "simulate_focus_gained/1" do - test "creates focus gained event" do - event = EventSimulator.simulate_focus_gained() - assert %Focus{} = event - assert event.action == :gained - end - end - - describe "simulate_focus_lost/1" do - test "creates focus lost event" do - event = EventSimulator.simulate_focus_lost() - assert event.action == :lost - end - end - - describe "simulate_resize/3" do - test "creates resize event" do - event = EventSimulator.simulate_resize(120, 40) - assert %Resize{} = event - assert event.width == 120 - assert event.height == 40 - end - end - - describe "simulate_paste/2" do - test "creates paste event" do - event = EventSimulator.simulate_paste("Hello, World!") - assert %Paste{} = event - assert event.content == "Hello, World!" - end - end - - describe "simulate_shortcut/1" do - test "creates copy shortcut" do - event = EventSimulator.simulate_shortcut(:copy) - assert event.key == :c - assert :ctrl in event.modifiers - end - - test "creates paste shortcut" do - event = EventSimulator.simulate_shortcut(:paste) - assert event.key == :v - assert :ctrl in event.modifiers - end - - test "creates save shortcut" do - event = EventSimulator.simulate_shortcut(:save) - assert event.key == :s - assert :ctrl in event.modifiers - end - - test "creates quit shortcut" do - event = EventSimulator.simulate_shortcut(:quit) - assert event.key == :q - assert :ctrl in event.modifiers - end - - test "creates undo shortcut" do - event = EventSimulator.simulate_shortcut(:undo) - assert event.key == :z - assert :ctrl in event.modifiers - end - - test "creates redo shortcut" do - event = EventSimulator.simulate_shortcut(:redo) - assert event.key == :z - assert :ctrl in event.modifiers - assert :shift in event.modifiers - end - end - - describe "simulate_function_key/1" do - test "creates function key events" do - event = EventSimulator.simulate_function_key(1) - assert event.key == :f1 - - event = EventSimulator.simulate_function_key(12) - assert event.key == :f12 - end - end - - describe "simulate_navigation/2" do - test "creates navigation key events" do - event = EventSimulator.simulate_navigation(:up) - assert event.key == :up - - event = EventSimulator.simulate_navigation(:page_down) - assert event.key == :page_down - end - - test "includes modifiers" do - event = EventSimulator.simulate_navigation(:up, modifiers: [:shift]) - assert :shift in event.modifiers - end - end -end diff --git a/test/term_ui/test/test_renderer_test.exs b/test/term_ui/test/test_renderer_test.exs deleted file mode 100644 index 48443ad1..00000000 --- a/test/term_ui/test/test_renderer_test.exs +++ /dev/null @@ -1,248 +0,0 @@ -defmodule TermUI.Test.TestRendererTest do - use ExUnit.Case, async: true - - alias TermUI.Renderer.Cell - alias TermUI.Test.TestRenderer - - describe "new/2 and destroy/1" do - test "creates renderer with correct dimensions" do - {:ok, renderer} = TestRenderer.new(24, 80) - assert {24, 80} == TestRenderer.dimensions(renderer) - TestRenderer.destroy(renderer) - end - - test "initializes with empty cells" do - {:ok, renderer} = TestRenderer.new(10, 10) - cell = TestRenderer.get_cell(renderer, 1, 1) - assert cell.char == " " - assert cell.fg == :default - TestRenderer.destroy(renderer) - end - end - - describe "set_cell/4 and get_cell/3" do - test "sets and gets cell correctly" do - {:ok, renderer} = TestRenderer.new(10, 10) - cell = Cell.new("X", fg: :red) - :ok = TestRenderer.set_cell(renderer, 1, 1, cell) - - result = TestRenderer.get_cell(renderer, 1, 1) - assert result.char == "X" - assert result.fg == :red - TestRenderer.destroy(renderer) - end - - test "returns error for out of bounds" do - {:ok, renderer} = TestRenderer.new(10, 10) - cell = Cell.new("X") - assert {:error, :out_of_bounds} = TestRenderer.set_cell(renderer, 11, 1, cell) - TestRenderer.destroy(renderer) - end - end - - describe "write_string/5" do - test "writes string at position" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 1, 1, "Hello") - - assert TestRenderer.get_text_at(renderer, 1, 1, 5) == "Hello" - TestRenderer.destroy(renderer) - end - - test "returns number of columns written" do - {:ok, renderer} = TestRenderer.new(10, 80) - written = TestRenderer.write_string(renderer, 1, 1, "Hello") - assert written == 5 - TestRenderer.destroy(renderer) - end - end - - describe "get_text_at/4" do - test "returns text at position with width" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 1, 1, "Hello, World!") - - assert TestRenderer.get_text_at(renderer, 1, 1, 5) == "Hello" - assert TestRenderer.get_text_at(renderer, 1, 8, 5) == "World" - TestRenderer.destroy(renderer) - end - - test "returns spaces for empty cells" do - {:ok, renderer} = TestRenderer.new(10, 80) - assert TestRenderer.get_text_at(renderer, 1, 1, 5) == " " - TestRenderer.destroy(renderer) - end - end - - describe "get_style_at/3" do - test "returns style at position" do - {:ok, renderer} = TestRenderer.new(10, 80) - cell = Cell.new("X", fg: :red, bg: :blue, attrs: [:bold]) - TestRenderer.set_cell(renderer, 1, 1, cell) - - style = TestRenderer.get_style_at(renderer, 1, 1) - assert style.fg == :red - assert style.bg == :blue - assert MapSet.member?(style.attrs, :bold) - TestRenderer.destroy(renderer) - end - end - - describe "get_row_text/2" do - test "returns entire row as text" do - {:ok, renderer} = TestRenderer.new(10, 20) - TestRenderer.write_string(renderer, 1, 1, "Hello") - - row = TestRenderer.get_row_text(renderer, 1) - assert String.starts_with?(row, "Hello") - assert String.length(row) == 20 - TestRenderer.destroy(renderer) - end - end - - describe "text_at?/4" do - test "returns true when text matches" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 1, 1, "Hello") - - assert TestRenderer.text_at?(renderer, 1, 1, "Hello") - assert TestRenderer.text_at?(renderer, 1, 1, "He") - TestRenderer.destroy(renderer) - end - - test "returns false when text differs" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 1, 1, "Hello") - - refute TestRenderer.text_at?(renderer, 1, 1, "World") - TestRenderer.destroy(renderer) - end - end - - describe "text_contains?/5" do - test "returns true when text contains substring" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 1, 1, "Hello, World!") - - assert TestRenderer.text_contains?(renderer, 1, 1, 13, "World") - TestRenderer.destroy(renderer) - end - - test "returns false when text does not contain substring" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 1, 1, "Hello") - - refute TestRenderer.text_contains?(renderer, 1, 1, 5, "World") - TestRenderer.destroy(renderer) - end - end - - describe "find_text/2" do - test "finds text positions in buffer" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 1, 1, "Error here") - TestRenderer.write_string(renderer, 3, 10, "Another Error") - - positions = TestRenderer.find_text(renderer, "Error") - assert {1, 1} in positions - assert {3, 18} in positions - TestRenderer.destroy(renderer) - end - - test "returns empty list when not found" do - {:ok, renderer} = TestRenderer.new(10, 80) - TestRenderer.write_string(renderer, 1, 1, "Hello") - - assert TestRenderer.find_text(renderer, "Error") == [] - TestRenderer.destroy(renderer) - end - end - - describe "snapshot/1 and matches_snapshot?/2" do - test "creates snapshot of buffer" do - {:ok, renderer} = TestRenderer.new(5, 10) - TestRenderer.write_string(renderer, 1, 1, "Test") - - snapshot = TestRenderer.snapshot(renderer) - assert snapshot.rows == 5 - assert snapshot.cols == 10 - assert snapshot.cells[{1, 1}].char == "T" - TestRenderer.destroy(renderer) - end - - test "matches identical buffer" do - {:ok, renderer} = TestRenderer.new(5, 10) - TestRenderer.write_string(renderer, 1, 1, "Test") - snapshot = TestRenderer.snapshot(renderer) - - assert TestRenderer.matches_snapshot?(renderer, snapshot) - TestRenderer.destroy(renderer) - end - - test "does not match modified buffer" do - {:ok, renderer} = TestRenderer.new(5, 10) - TestRenderer.write_string(renderer, 1, 1, "Test") - snapshot = TestRenderer.snapshot(renderer) - - TestRenderer.write_string(renderer, 1, 1, "Changed") - refute TestRenderer.matches_snapshot?(renderer, snapshot) - TestRenderer.destroy(renderer) - end - end - - describe "diff_snapshot/2" do - test "returns differences between buffer and snapshot" do - {:ok, renderer} = TestRenderer.new(5, 10) - TestRenderer.write_string(renderer, 1, 1, "Test") - snapshot = TestRenderer.snapshot(renderer) - - TestRenderer.write_string(renderer, 1, 1, "Best") - diffs = TestRenderer.diff_snapshot(renderer, snapshot) - - # First character changed from T to B - assert length(diffs) > 0 - TestRenderer.destroy(renderer) - end - end - - describe "clear/1" do - test "clears all cells to empty" do - {:ok, renderer} = TestRenderer.new(10, 10) - TestRenderer.write_string(renderer, 1, 1, "Hello") - TestRenderer.clear(renderer) - - assert TestRenderer.get_text_at(renderer, 1, 1, 5) == " " - TestRenderer.destroy(renderer) - end - end - - describe "to_string/1" do - test "converts buffer to printable string" do - {:ok, renderer} = TestRenderer.new(3, 10) - TestRenderer.write_string(renderer, 1, 1, "Line 1") - TestRenderer.write_string(renderer, 2, 1, "Line 2") - - result = TestRenderer.to_string(renderer) - assert result =~ "Line 1" - assert result =~ "Line 2" - TestRenderer.destroy(renderer) - end - end - - describe "in_bounds?/3" do - test "returns true for valid positions" do - {:ok, renderer} = TestRenderer.new(10, 20) - assert TestRenderer.in_bounds?(renderer, 1, 1) - assert TestRenderer.in_bounds?(renderer, 10, 20) - TestRenderer.destroy(renderer) - end - - test "returns false for invalid positions" do - {:ok, renderer} = TestRenderer.new(10, 20) - refute TestRenderer.in_bounds?(renderer, 0, 1) - refute TestRenderer.in_bounds?(renderer, 11, 1) - refute TestRenderer.in_bounds?(renderer, 1, 21) - TestRenderer.destroy(renderer) - end - end -end diff --git a/test/term_ui/theme_integration_test.exs b/test/term_ui/theme_integration_test.exs deleted file mode 100644 index 31312197..00000000 --- a/test/term_ui/theme_integration_test.exs +++ /dev/null @@ -1,311 +0,0 @@ -defmodule TermUI.ThemeIntegrationTest do - use ExUnit.Case, async: true - - alias TermUI.Renderer.Style - alias TermUI.Theme - - setup do - name = :"theme_integration_#{:erlang.unique_integer([:positive])}" - {:ok, pid} = Theme.start_link(name: name, theme: :dark) - %{server: name, pid: pid} - end - - describe "application startup" do - test "starts with default dark theme", %{server: server} do - theme = Theme.get_theme(server) - - assert theme.name == :dark - assert theme.colors.background == :black - assert theme.colors.foreground == :white - end - - test "starts with specified theme" do - name = :"startup_test_#{:erlang.unique_integer([:positive])}" - {:ok, _} = Theme.start_link(name: name, theme: :light) - - theme = Theme.get_theme(name) - assert theme.name == :light - assert theme.colors.background == :white - end - - test "starts with custom theme" do - name = :"custom_startup_#{:erlang.unique_integer([:positive])}" - {:ok, custom} = Theme.from(name: :my_custom, colors: %{primary: :magenta}) - {:ok, _} = Theme.start_link(name: name, theme: custom) - - theme = Theme.get_theme(name) - assert theme.name == :my_custom - assert theme.colors.primary == :magenta - end - end - - describe "theme switching" do - test "switch updates all colors", %{server: server} do - # Initial dark - assert Theme.get_color(:background, server) == :black - assert Theme.get_color(:foreground, server) == :white - - # Switch to light - :ok = Theme.set_theme(:light, server) - - assert Theme.get_color(:background, server) == :white - assert Theme.get_color(:foreground, server) == :black - end - - test "switch updates semantic colors", %{server: server} do - # Dark theme has :cyan for info - assert Theme.get_semantic(:info, server) == :cyan - - # Light theme has :blue for info - :ok = Theme.set_theme(:light, server) - assert Theme.get_semantic(:info, server) == :blue - end - - test "switch updates component styles", %{server: server} do - dark_button = Theme.get_component_style(:button, :normal, server) - - :ok = Theme.set_theme(:light, server) - light_button = Theme.get_component_style(:button, :normal, server) - - # Colors should differ - assert dark_button.fg != light_button.fg or dark_button.bg != light_button.bg - end - - test "subscribers notified on switch", %{server: server} do - :ok = Theme.subscribe(server) - - :ok = Theme.set_theme(:light, server) - - assert_receive {:theme_changed, theme} - assert theme.name == :light - end - - test "multiple switches work correctly", %{server: server} do - themes = [:light, :high_contrast, :dark, :light] - - for theme_name <- themes do - :ok = Theme.set_theme(theme_name, server) - theme = Theme.get_theme(server) - assert theme.name == theme_name - end - end - end - - describe "custom theme integration" do - test "create and apply custom theme", %{server: server} do - {:ok, custom} = - Theme.from( - base: :dark, - name: :corporate, - colors: %{primary: :cyan, accent: :yellow}, - semantic: %{success: :bright_green} - ) - - :ok = Theme.set_theme(custom, server) - - assert Theme.get_theme(server).name == :corporate - assert Theme.get_color(:primary, server) == :cyan - assert Theme.get_semantic(:success, server) == :bright_green - # Inherited from dark - assert Theme.get_color(:background, server) == :black - end - - test "custom theme with component style overrides", %{server: server} do - custom_button = %{ - normal: Style.new() |> Style.fg(:yellow) |> Style.bg(:blue) - } - - {:ok, custom} = - Theme.from( - name: :custom, - components: %{button: custom_button} - ) - - :ok = Theme.set_theme(custom, server) - - button = Theme.get_component_style(:button, :normal, server) - assert button.fg == :yellow - assert button.bg == :blue - - # Other button variants preserved from base (dark theme defaults) - focused = Theme.get_component_style(:button, :focused, server) - assert focused.fg == :white - assert focused.bg == :blue - assert Style.has_attr?(focused, :bold) - end - - test "theme validation catches errors" do - incomplete_theme = %Theme{ - name: :incomplete, - colors: %{background: :black}, - semantic: %{}, - components: %{} - } - - {:error, errors} = Theme.validate(incomplete_theme) - assert length(errors) > 0 - end - end - - describe "component style resolution" do - test "get component style for all built-in components", %{server: server} do - components = [:button, :text_input, :text, :border] - variants = [:normal, :focused, :disabled] - - for component <- components do - for variant <- variants do - style = Theme.get_component_style(component, variant, server) - # Not all components have all variants - if style do - assert %Style{} = style - end - end - end - end - - test "style_from_theme with overrides", %{server: server} do - # Get button style with fg override - style = Theme.style_from_theme(:button, :normal, [fg: :magenta], server) - - assert style.fg == :magenta - # bg from dark theme button normal - assert style.bg == :bright_black - end - - test "style_from_theme falls back for unknown component", %{server: server} do - style = Theme.style_from_theme(:nonexistent, :normal, [fg: :blue], server) - - assert style.fg == :blue - end - end - - describe "color and semantic access" do - test "all base colors accessible", %{server: server} do - colors = [:background, :foreground, :primary, :secondary, :accent] - - for color <- colors do - value = Theme.get_color(color, server) - assert value != nil, "Missing color: #{color}" - end - end - - test "all semantic colors accessible", %{server: server} do - semantics = [:success, :warning, :error, :info, :muted] - - for semantic <- semantics do - value = Theme.get_semantic(semantic, server) - assert value != nil, "Missing semantic: #{semantic}" - end - end - - test "colors consistent across theme access methods", %{server: server} do - theme = Theme.get_theme(server) - - # Direct theme access vs helper function - assert theme.colors.primary == Theme.get_color(:primary, server) - assert theme.semantic.error == Theme.get_semantic(:error, server) - end - end - - describe "multi-subscriber scenarios" do - test "multiple subscribers all notified", %{server: server} do - # Spawn multiple subscriber processes - parent = self() - - pids = - for i <- 1..3 do - spawn(fn -> - Theme.subscribe(server) - send(parent, {:subscribed, i}) - - receive do - {:theme_changed, theme} -> - send(parent, {:received, i, theme.name}) - end - end) - end - - # Guarantee cleanup even if test fails - on_exit(fn -> - for pid <- pids, Process.alive?(pid), do: Process.exit(pid, :kill) - end) - - # Wait for all subscriptions - for i <- 1..3 do - assert_receive {:subscribed, ^i} - end - - # Switch theme - :ok = Theme.set_theme(:light, server) - - # All should receive - for i <- 1..3 do - assert_receive {:received, ^i, :light} - end - end - - test "dead subscriber cleaned up", %{server: server} do - # Subscribe from a process that will die - pid = - spawn(fn -> - Theme.subscribe(server) - - receive do - :done -> :ok - end - end) - - # Guarantee cleanup even if test fails - on_exit(fn -> - if Process.alive?(pid), do: Process.exit(pid, :kill) - end) - - # Kill the subscriber - Process.exit(pid, :kill) - Process.sleep(50) - - # Theme switch should not error - :ok = Theme.set_theme(:light, server) - end - end - - describe "theme persistence simulation" do - test "theme survives server restart with same config" do - # Start with light theme - name1 = :"persist_test_1_#{:erlang.unique_integer([:positive])}" - {:ok, pid1} = Theme.start_link(name: name1, theme: :light) - assert Theme.get_theme(name1).name == :light - - # Stop it - GenServer.stop(pid1) - - # Start new server with same theme config (simulating persistence) - name2 = :"persist_test_2_#{:erlang.unique_integer([:positive])}" - {:ok, _} = Theme.start_link(name: name2, theme: :light) - assert Theme.get_theme(name2).name == :light - end - end - - describe "high contrast accessibility" do - test "high contrast uses bright colors", %{server: server} do - :ok = Theme.set_theme(:high_contrast, server) - - # Check that colors are bright variants - fg = Theme.get_color(:foreground, server) - assert fg == :bright_white - - success = Theme.get_semantic(:success, server) - assert success == :bright_green - - error = Theme.get_semantic(:error, server) - assert error == :bright_red - end - - test "high contrast button has bold", %{server: server} do - :ok = Theme.set_theme(:high_contrast, server) - - button = Theme.get_component_style(:button, :normal, server) - assert Style.has_attr?(button, :bold) - end - end -end diff --git a/test/term_ui/theme_test.exs b/test/term_ui/theme_test.exs deleted file mode 100644 index c96215fc..00000000 --- a/test/term_ui/theme_test.exs +++ /dev/null @@ -1,471 +0,0 @@ -defmodule TermUI.ThemeTest do - use ExUnit.Case, async: true - - alias TermUI.Renderer.Style - alias TermUI.Theme - - setup do - # Start a unique theme server for each test - name = :"theme_test_#{:erlang.unique_integer([:positive])}" - {:ok, pid} = Theme.start_link(name: name, theme: :dark) - %{server: name, pid: pid} - end - - describe "theme structure" do - test "dark theme has all required color fields" do - {:ok, theme} = Theme.get_builtin(:dark) - - assert theme.colors.background == :black - assert theme.colors.foreground == :white - assert theme.colors.primary == :blue - assert theme.colors.secondary == :cyan - assert theme.colors.accent == :magenta - end - - test "dark theme has all required semantic fields" do - {:ok, theme} = Theme.get_builtin(:dark) - - assert theme.semantic.success == :green - assert theme.semantic.warning == :yellow - assert theme.semantic.error == :red - assert theme.semantic.info == :cyan - assert theme.semantic.muted == :bright_black - end - - test "theme has component styles" do - {:ok, theme} = Theme.get_builtin(:dark) - - assert Map.has_key?(theme.components, :button) - assert Map.has_key?(theme.components, :text_input) - assert Map.has_key?(theme.components, :text) - assert Map.has_key?(theme.components, :border) - end - - test "component styles have variants" do - {:ok, theme} = Theme.get_builtin(:dark) - - button = theme.components.button - assert Map.has_key?(button, :normal) - assert Map.has_key?(button, :focused) - assert Map.has_key?(button, :disabled) - end - - test "component variant styles are Style structs" do - {:ok, theme} = Theme.get_builtin(:dark) - - style = theme.components.button.normal - assert %Style{} = style - end - end - - describe "built-in themes" do - test "dark theme loads correctly" do - {:ok, theme} = Theme.get_builtin(:dark) - assert theme.name == :dark - end - - test "light theme loads correctly" do - {:ok, theme} = Theme.get_builtin(:light) - assert theme.name == :light - assert theme.colors.background == :white - assert theme.colors.foreground == :black - end - - test "high_contrast theme loads correctly" do - {:ok, theme} = Theme.get_builtin(:high_contrast) - assert theme.name == :high_contrast - assert theme.colors.foreground == :bright_white - end - - test "invalid theme returns error" do - assert {:error, :not_found} = Theme.get_builtin(:nonexistent) - end - - test "list_builtin returns all theme names" do - themes = Theme.list_builtin() - assert :dark in themes - assert :light in themes - assert :high_contrast in themes - end - end - - describe "theme loading" do - test "from/1 creates theme from keyword list" do - {:ok, theme} = Theme.from(name: :custom, colors: %{primary: :magenta}) - - assert theme.name == :custom - assert theme.colors.primary == :magenta - # Inherits other colors from dark (default base) - assert theme.colors.background == :black - end - - test "from/1 uses specified base theme" do - {:ok, theme} = Theme.from(base: :light, name: :custom) - - assert theme.name == :custom - assert theme.colors.background == :white - end - - test "from/1 returns error for invalid base" do - {:error, reason} = Theme.from(base: :invalid) - assert {:invalid_base_theme, :invalid} = reason - end - - test "from/1 merges semantic colors" do - {:ok, theme} = Theme.from(semantic: %{error: :bright_red}) - - assert theme.semantic.error == :bright_red - # Others unchanged - assert theme.semantic.success == :green - end - - test "from/1 merges component styles" do - custom_button = %{ - normal: Style.new() |> Style.fg(:cyan) - } - - {:ok, theme} = Theme.from(components: %{button: custom_button}) - - assert theme.components.button.normal.fg == :cyan - # Other button variants preserved - assert theme.components.button.focused != nil - end - end - - describe "theme validation" do - test "valid theme passes validation" do - {:ok, theme} = Theme.get_builtin(:dark) - assert :ok = Theme.validate(theme) - end - - test "theme missing colors fails validation" do - theme = %Theme{ - name: :invalid, - colors: %{background: :black}, - semantic: %{ - success: :green, - warning: :yellow, - error: :red, - info: :cyan, - muted: :white, - help: :white, - placeholder: :white - }, - components: %{} - } - - {:error, errors} = Theme.validate(theme) - assert length(errors) == 1 - assert hd(errors) =~ "Missing required colors" - end - - test "theme missing semantic colors fails validation" do - theme = %Theme{ - name: :invalid, - colors: %{ - background: :black, - foreground: :white, - primary: :blue, - secondary: :cyan, - accent: :magenta - }, - semantic: %{success: :green}, - components: %{} - } - - {:error, errors} = Theme.validate(theme) - assert length(errors) == 1 - assert hd(errors) =~ "Missing required semantic" - end - end - - describe "runtime theme switching" do - test "get_theme returns current theme", %{server: server} do - theme = Theme.get_theme(server) - assert theme.name == :dark - end - - test "set_theme changes current theme by name", %{server: server} do - :ok = Theme.set_theme(:light, server) - theme = Theme.get_theme(server) - assert theme.name == :light - end - - test "set_theme accepts Theme struct", %{server: server} do - {:ok, custom} = Theme.from(name: :custom) - :ok = Theme.set_theme(custom, server) - - theme = Theme.get_theme(server) - assert theme.name == :custom - end - - test "set_theme returns error for invalid theme name", %{server: server} do - {:error, :not_found} = Theme.set_theme(:invalid, server) - - # Theme unchanged - theme = Theme.get_theme(server) - assert theme.name == :dark - end - end - - describe "theme subscriptions" do - test "subscribers receive theme change notification", %{server: server} do - :ok = Theme.subscribe(server) - :ok = Theme.set_theme(:light, server) - - assert_receive {:theme_changed, theme} - assert theme.name == :light - end - - test "unsubscribe stops notifications", %{server: server} do - :ok = Theme.subscribe(server) - :ok = Theme.unsubscribe(server) - :ok = Theme.set_theme(:light, server) - - refute_receive {:theme_changed, _}, 100 - end - - test "subscriber auto-unsubscribes on process death", %{server: server} do - # Spawn a process that subscribes and dies - test_pid = self() - - spawn(fn -> - :ok = Theme.subscribe(server) - send(test_pid, :subscribed) - end) - - assert_receive :subscribed - - # Wait for process to die and be cleaned up - Process.sleep(50) - - # Set theme should not error (no dead subscribers) - :ok = Theme.set_theme(:light, server) - end - end - - describe "theme value access" do - test "get_color returns base colors", %{server: server} do - assert Theme.get_color(:background, server) == :black - assert Theme.get_color(:primary, server) == :blue - end - - test "get_color returns nil for unknown color", %{server: server} do - assert Theme.get_color(:unknown, server) == nil - end - - test "get_semantic returns semantic colors", %{server: server} do - assert Theme.get_semantic(:error, server) == :red - assert Theme.get_semantic(:success, server) == :green - end - - test "get_semantic returns nil for unknown", %{server: server} do - assert Theme.get_semantic(:unknown, server) == nil - end - - test "get_component_style returns component variant", %{server: server} do - style = Theme.get_component_style(:button, :focused, server) - assert %Style{} = style - assert MapSet.member?(style.attrs, :bold) - end - - test "get_component_style returns nil for unknown component", %{server: server} do - assert Theme.get_component_style(:unknown, :normal, server) == nil - end - - test "get_component_style returns nil for unknown variant", %{server: server} do - assert Theme.get_component_style(:button, :unknown, server) == nil - end - end - - describe "style_from_theme" do - test "returns base component style", %{server: server} do - style = Theme.style_from_theme(:button, :normal, [], server) - assert %Style{} = style - end - - test "merges overrides with theme style", %{server: server} do - style = Theme.style_from_theme(:button, :normal, [fg: :red], server) - - assert style.fg == :red - # Background from theme - assert style.bg != nil - end - - test "returns styled override for unknown component", %{server: server} do - style = Theme.style_from_theme(:unknown, :normal, [fg: :blue], server) - - assert style.fg == :blue - end - end - - describe "ETS caching" do - test "theme is cached in ETS for fast reads", %{server: server} do - # First read populates ETS - theme1 = Theme.get_theme(server) - - # Subsequent reads come from ETS - theme2 = Theme.get_theme(server) - - assert theme1 == theme2 - end - - test "set_theme updates ETS cache", %{server: server} do - Theme.set_theme(:light, server) - theme = Theme.get_theme(server) - assert theme.name == :light - end - end - - describe "multiple servers" do - test "independent theme servers maintain separate state" do - {:ok, _} = Theme.start_link(name: :server_a, theme: :dark) - {:ok, _} = Theme.start_link(name: :server_b, theme: :light) - - assert Theme.get_theme(:server_a).name == :dark - assert Theme.get_theme(:server_b).name == :light - end - end - - describe "theme comparison" do - test "built-in themes have distinct color schemes" do - {:ok, dark} = Theme.get_builtin(:dark) - {:ok, light} = Theme.get_builtin(:light) - - assert dark.colors.background != light.colors.background - assert dark.colors.foreground != light.colors.foreground - end - - test "high_contrast uses bright colors" do - {:ok, theme} = Theme.get_builtin(:high_contrast) - - # High contrast uses bright variants - assert theme.colors.foreground == :bright_white - assert theme.semantic.success == :bright_green - assert theme.semantic.error == :bright_red - end - end - - describe "edge cases" do - test "empty overrides preserve base theme" do - {:ok, theme} = Theme.from([]) - {:ok, dark} = Theme.get_builtin(:dark) - - assert theme.colors == dark.colors - assert theme.semantic == dark.semantic - end - - test "component merge preserves unmodified variants" do - {:ok, theme} = - Theme.from( - components: %{ - button: %{ - normal: Style.new() |> Style.fg(:cyan) - } - } - ) - - # Modified variant - assert theme.components.button.normal.fg == :cyan - # Unmodified variants preserved - assert theme.components.button.focused != nil - assert theme.components.button.disabled != nil - end - end - - describe "monochrome compatibility" do - test "selected items have reverse attribute in dark theme" do - {:ok, theme} = Theme.get_builtin(:dark) - style = theme.components.item.selected - - assert MapSet.member?(style.attrs, :reverse) - end - - test "focused items have bold attribute in dark theme" do - {:ok, theme} = Theme.get_builtin(:dark) - style = theme.components.item.focused - - assert MapSet.member?(style.attrs, :bold) - end - - test "error status has underline attribute in dark theme" do - {:ok, theme} = Theme.get_builtin(:dark) - style = theme.components.status.error - - assert MapSet.member?(style.attrs, :underline) - end - - test "terminated status has underline attribute in dark theme" do - {:ok, theme} = Theme.get_builtin(:dark) - style = theme.components.status.terminated - - assert MapSet.member?(style.attrs, :underline) - end - - test "warning status has bold attribute in dark theme" do - {:ok, theme} = Theme.get_builtin(:dark) - style = theme.components.status.warning - - assert MapSet.member?(style.attrs, :bold) - end - - test "unknown status has dim attribute in dark theme" do - {:ok, theme} = Theme.get_builtin(:dark) - style = theme.components.status.unknown - - assert MapSet.member?(style.attrs, :dim) - end - - test "focused divider has reverse attribute in dark theme" do - {:ok, theme} = Theme.get_builtin(:dark) - style = theme.components.divider.focused - - assert MapSet.member?(style.attrs, :reverse) - assert MapSet.member?(style.attrs, :bold) - end - - test "selected items have reverse attribute in light theme" do - {:ok, theme} = Theme.get_builtin(:light) - style = theme.components.item.selected - - assert MapSet.member?(style.attrs, :reverse) - end - - test "focused items have bold attribute in light theme" do - {:ok, theme} = Theme.get_builtin(:light) - style = theme.components.item.focused - - assert MapSet.member?(style.attrs, :bold) - end - - test "error status has underline attribute in light theme" do - {:ok, theme} = Theme.get_builtin(:light) - style = theme.components.status.error - - assert MapSet.member?(style.attrs, :underline) - end - - test "selected items have reverse attribute in high_contrast theme" do - {:ok, theme} = Theme.get_builtin(:high_contrast) - style = theme.components.item.selected - - assert MapSet.member?(style.attrs, :reverse) - assert MapSet.member?(style.attrs, :bold) - end - - test "focused items have bold attribute in high_contrast theme" do - {:ok, theme} = Theme.get_builtin(:high_contrast) - style = theme.components.item.focused - - assert MapSet.member?(style.attrs, :bold) - end - - test "error status has underline and bold attributes in high_contrast theme" do - {:ok, theme} = Theme.get_builtin(:high_contrast) - style = theme.components.status.error - - assert MapSet.member?(style.attrs, :underline) - assert MapSet.member?(style.attrs, :bold) - end - end -end diff --git a/test/term_ui/view_cache_test.exs b/test/term_ui/view_cache_test.exs deleted file mode 100644 index b656c616..00000000 --- a/test/term_ui/view_cache_test.exs +++ /dev/null @@ -1,186 +0,0 @@ -defmodule TermUI.ViewCacheTest do - use ExUnit.Case, async: true - - alias TermUI.ViewCache - - describe "new/0" do - test "creates empty cache" do - cache = ViewCache.new() - assert cache.state_hash == nil - assert cache.render_tree == nil - assert cache.hits == 0 - assert cache.misses == 0 - end - end - - describe "get/2 and put/3" do - test "returns :miss for empty cache" do - cache = ViewCache.new() - assert :miss = ViewCache.get(cache, %{count: 0}) - end - - test "returns :hit when state matches" do - state = %{count: 5} - render_tree = {:text, "Count: 5"} - - cache = - ViewCache.new() - |> ViewCache.put(state, render_tree) - - assert {:hit, ^render_tree} = ViewCache.get(cache, state) - end - - test "returns :miss when state differs" do - state1 = %{count: 5} - state2 = %{count: 10} - render_tree = {:text, "Count: 5"} - - cache = - ViewCache.new() - |> ViewCache.put(state1, render_tree) - - assert :miss = ViewCache.get(cache, state2) - end - end - - describe "record_hit/1 and record_miss/2" do - test "increments hit counter" do - cache = ViewCache.new() - cache = ViewCache.record_hit(cache) - cache = ViewCache.record_hit(cache) - - assert cache.hits == 2 - end - - test "increments miss counter and records time" do - cache = ViewCache.new() - cache = ViewCache.record_miss(cache, 500) - - assert cache.misses == 1 - assert cache.last_render_time_us == 500 - end - end - - describe "invalidate/1" do - test "clears cached state and render tree" do - state = %{count: 5} - render_tree = {:text, "Count: 5"} - - cache = - ViewCache.new() - |> ViewCache.put(state, render_tree) - |> ViewCache.invalidate() - - assert :miss = ViewCache.get(cache, state) - end - end - - describe "stats/1" do - test "returns hit rate statistics" do - cache = ViewCache.new() - - cache = - cache - |> ViewCache.record_hit() - |> ViewCache.record_hit() - |> ViewCache.record_hit() - |> ViewCache.record_miss(100) - - stats = ViewCache.stats(cache) - - assert stats.hits == 3 - assert stats.misses == 1 - assert_in_delta stats.hit_rate, 75.0, 0.1 - end - - test "handles zero total" do - cache = ViewCache.new() - stats = ViewCache.stats(cache) - - assert stats.hit_rate == 0.0 - end - end - - describe "check_performance/1" do - test "returns :ok for fast renders" do - cache = %{ViewCache.new() | last_render_time_us: 500} - assert :ok = ViewCache.check_performance(cache) - end - - test "returns warning for slow renders" do - cache = %{ViewCache.new() | last_render_time_us: 2000} - assert {:slow_view, 2000} = ViewCache.check_performance(cache) - end - end - - describe "memoize/3" do - test "calls view function on miss" do - cache = ViewCache.new() - state = %{count: 5} - - view_fn = fn s -> {:text, "Count: #{s.count}"} end - - {render_tree, new_cache} = ViewCache.memoize(cache, state, view_fn) - - assert render_tree == {:text, "Count: 5"} - assert new_cache.misses == 1 - end - - test "uses cache on hit" do - state = %{count: 5} - render_tree = {:text, "Count: 5"} - - cache = - ViewCache.new() - |> ViewCache.put(state, render_tree) - - call_count = :counters.new(1, []) - - view_fn = fn s -> - :counters.add(call_count, 1, 1) - {:text, "Count: #{s.count}"} - end - - {result, new_cache} = ViewCache.memoize(cache, state, view_fn) - - assert result == render_tree - assert new_cache.hits == 1 - assert :counters.get(call_count, 1) == 0 - end - - test "recalculates when state changes" do - state1 = %{count: 5} - state2 = %{count: 10} - - cache = ViewCache.new() - - view_fn = fn s -> {:text, "Count: #{s.count}"} end - - {_, cache} = ViewCache.memoize(cache, state1, view_fn) - {render_tree, cache} = ViewCache.memoize(cache, state2, view_fn) - - assert render_tree == {:text, "Count: 10"} - assert cache.misses == 2 - end - end - - describe "view memoization scenario" do - test "skips render for unchanged state" do - state = %{count: 0} - cache = ViewCache.new() - - view_fn = fn s -> {:text, "Count: #{s.count}"} end - - # First render - miss - {_, cache} = ViewCache.memoize(cache, state, view_fn) - - # Same state - hit - {_, cache} = ViewCache.memoize(cache, state, view_fn) - {_, cache} = ViewCache.memoize(cache, state, view_fn) - - stats = ViewCache.stats(cache) - assert stats.hits == 2 - assert stats.misses == 1 - end - end -end diff --git a/test/term_ui/widget/block_test.exs b/test/term_ui/widget/block_test.exs deleted file mode 100644 index 1c270870..00000000 --- a/test/term_ui/widget/block_test.exs +++ /dev/null @@ -1,272 +0,0 @@ -defmodule TermUI.Widget.BlockTest do - use ExUnit.Case, async: true - - alias TermUI.Component.RenderNode - alias TermUI.Widget.Block - - @area %{x: 0, y: 0, width: 20, height: 10} - - describe "init/1" do - test "initializes with props in state" do - props = %{border: :double, title: "Test"} - {:ok, state} = Block.init(props) - assert state.props == props - end - end - - describe "children/1" do - test "returns empty list" do - {:ok, state} = Block.init(%{}) - assert Block.children(state) == [] - end - end - - describe "layout/3" do - test "assigns each child the given area" do - {:ok, state} = Block.init(%{}) - children = [{:child1, %{}}, {:child2, %{}}] - result = Block.layout(children, @area, state) - - assert length(result) == 2 - assert Enum.all?(result, fn {_child, area} -> area == @area end) - end - end - - describe "handle_event/2" do - test "returns state unchanged for any event" do - {:ok, state} = Block.init(%{}) - {:ok, new_state} = Block.handle_event(:any_event, state) - assert new_state == state - end - end - - describe "render/2" do - test "renders single border" do - props = %{border: :single} - {:ok, state} = Block.init(props) - result = Block.render(state, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - - # Find corner characters - chars = for c <- cells, do: {c.x, c.y, c.cell.char} - - assert {0, 0, "┌"} in chars - assert {19, 0, "┐"} in chars - assert {0, 9, "└"} in chars - assert {19, 9, "┘"} in chars - end - - test "renders double border" do - props = %{border: :double} - {:ok, state} = Block.init(props) - result = Block.render(state, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - chars = for c <- cells, do: {c.x, c.y, c.cell.char} - - assert {0, 0, "╔"} in chars - assert {19, 0, "╗"} in chars - end - - test "renders rounded border" do - props = %{border: :rounded} - {:ok, state} = Block.init(props) - result = Block.render(state, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - chars = for c <- cells, do: {c.x, c.y, c.cell.char} - - assert {0, 0, "╭"} in chars - assert {19, 0, "╮"} in chars - end - - test "renders thick border" do - props = %{border: :thick} - {:ok, state} = Block.init(props) - result = Block.render(state, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - chars = for c <- cells, do: {c.x, c.y, c.cell.char} - - assert {0, 0, "┏"} in chars - assert {19, 0, "┓"} in chars - end - - test "renders horizontal border lines" do - props = %{border: :single} - {:ok, state} = Block.init(props) - result = Block.render(state, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - - # Top horizontal line (excluding corners) - top_line = Enum.filter(cells, fn c -> c.y == 0 && c.x > 0 && c.x < 19 end) - assert Enum.all?(top_line, fn c -> c.cell.char == "─" end) - - # Bottom horizontal line - bottom_line = Enum.filter(cells, fn c -> c.y == 9 && c.x > 0 && c.x < 19 end) - assert Enum.all?(bottom_line, fn c -> c.cell.char == "─" end) - end - - test "renders vertical border lines" do - props = %{border: :single} - {:ok, state} = Block.init(props) - result = Block.render(state, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - - # Left vertical line - left_line = Enum.filter(cells, fn c -> c.x == 0 && c.y > 0 && c.y < 9 end) - assert Enum.all?(left_line, fn c -> c.cell.char == "│" end) - - # Right vertical line - right_line = Enum.filter(cells, fn c -> c.x == 19 && c.y > 0 && c.y < 9 end) - assert Enum.all?(right_line, fn c -> c.cell.char == "│" end) - end - - test "renders left-aligned title" do - props = %{border: :single, title: "Test", title_align: :left} - {:ok, state} = Block.init(props) - result = Block.render(state, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - - # Title should start at x=1 (after corner) - t_cell = Enum.find(cells, fn c -> c.y == 0 && c.x == 1 end) - assert t_cell.cell.char == "T" - end - - test "renders center-aligned title" do - props = %{border: :single, title: "Hi", title_align: :center} - {:ok, state} = Block.init(props) - result = Block.render(state, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - - # "Hi" is 2 chars, inner width is 18, so left padding = 8 - h_cell = Enum.find(cells, fn c -> c.y == 0 && c.cell.char == "H" end) - # 1 (corner) + 8 (padding) - assert h_cell.x == 9 - end - - test "renders right-aligned title" do - props = %{border: :single, title: "Hi", title_align: :right} - {:ok, state} = Block.init(props) - result = Block.render(state, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - - # "Hi" should end at x=18 (before corner at 19) - i_cell = Enum.find(cells, fn c -> c.y == 0 && c.cell.char == "i" end) - assert i_cell.x == 18 - end - - test "truncates long title" do - props = %{border: :single, title: "This is a very long title"} - {:ok, state} = Block.init(props) - result = Block.render(state, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - # Title should fit in inner width (18 chars) - title_cells = Enum.filter(cells, fn c -> c.y == 0 && c.x > 0 && c.x < 19 end) - assert length(title_cells) == 18 - end - - test "applies style to border" do - props = %{border: :single, style: %{fg: :blue}} - {:ok, state} = Block.init(props) - result = Block.render(state, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - first_cell = hd(cells) - assert first_cell.cell.fg == :blue - end - - test "handles small area" do - small_area = %{x: 0, y: 0, width: 3, height: 3} - props = %{border: :single} - {:ok, state} = Block.init(props) - result = Block.render(state, small_area) - - assert %RenderNode{type: :cells, cells: cells} = result - assert length(cells) > 0 - end - - test "handles zero width" do - zero_area = %{x: 0, y: 0, width: 0, height: 5} - props = %{border: :single} - {:ok, state} = Block.init(props) - result = Block.render(state, zero_area) - - assert %RenderNode{type: :cells, cells: []} = result - end - - test "handles zero height" do - zero_area = %{x: 0, y: 0, width: 5, height: 0} - props = %{border: :single} - {:ok, state} = Block.init(props) - result = Block.render(state, zero_area) - - assert %RenderNode{type: :cells, cells: []} = result - end - end - - describe "inner_area/2" do - test "calculates inner area with border" do - props = %{border: :single} - inner = Block.inner_area(props, @area) - - assert inner.x == 1 - assert inner.y == 1 - assert inner.width == 18 - assert inner.height == 8 - end - - test "calculates inner area without border" do - props = %{border: :none} - inner = Block.inner_area(props, @area) - - assert inner.x == 0 - assert inner.y == 0 - assert inner.width == 20 - assert inner.height == 10 - end - - test "calculates inner area with integer padding" do - props = %{border: :single, padding: 2} - inner = Block.inner_area(props, @area) - - # 1 border + 2 padding - assert inner.x == 3 - assert inner.y == 3 - # 20 - 2 - 4 - assert inner.width == 14 - # 10 - 2 - 4 - assert inner.height == 4 - end - - test "calculates inner area with directional padding" do - props = %{border: :single, padding: %{top: 1, right: 2, bottom: 3, left: 4}} - inner = Block.inner_area(props, @area) - - # 1 border + 4 left - assert inner.x == 5 - # 1 border + 1 top - assert inner.y == 2 - # 20 - 2 - 4 - 2 - assert inner.width == 12 - # 10 - 2 - 1 - 3 - assert inner.height == 4 - end - - test "handles negative inner dimensions" do - small_area = %{x: 0, y: 0, width: 2, height: 2} - props = %{border: :single, padding: 5} - inner = Block.inner_area(props, small_area) - - assert inner.width == 0 - assert inner.height == 0 - end - end -end diff --git a/test/term_ui/widget/button_test.exs b/test/term_ui/widget/button_test.exs deleted file mode 100644 index 02c10af1..00000000 --- a/test/term_ui/widget/button_test.exs +++ /dev/null @@ -1,202 +0,0 @@ -defmodule TermUI.Widget.ButtonTest do - use ExUnit.Case, async: true - - alias TermUI.Component.RenderNode - alias TermUI.Event - alias TermUI.Widget.Button - - @area %{x: 0, y: 0, width: 20, height: 1} - - describe "init/1" do - test "initializes with default values" do - {:ok, state} = Button.init(%{}) - - assert state.pressed == false - assert state.hovered == false - assert state.disabled == false - end - - test "initializes disabled from props" do - {:ok, state} = Button.init(%{disabled: true}) - assert state.disabled == true - end - - test "stores props in state" do - props = %{label: "Submit", on_click: fn -> :ok end} - {:ok, state} = Button.init(props) - assert state.props == props - end - end - - describe "handle_event/2 keyboard" do - test "enter key triggers click when not disabled" do - {:ok, state} = Button.init(%{}) - {:ok, new_state, commands} = Button.handle_event(%Event.Key{key: :enter}, state) - - assert new_state.pressed == true - assert [{:send, _pid, :click}] = commands - end - - test "space key triggers click when not disabled" do - {:ok, state} = Button.init(%{}) - {:ok, new_state, commands} = Button.handle_event(%Event.Key{key: :space}, state) - - assert new_state.pressed == true - assert [{:send, _pid, :click}] = commands - end - - test "enter key does nothing when disabled" do - {:ok, state} = Button.init(%{disabled: true}) - {:ok, new_state} = Button.handle_event(%Event.Key{key: :enter}, state) - - assert new_state.pressed == false - assert new_state == state - end - - test "ignores other keys" do - {:ok, state} = Button.init(%{}) - {:ok, new_state} = Button.handle_event(%Event.Key{key: :up}, state) - - assert new_state == state - end - end - - describe "handle_event/2 mouse" do - test "click triggers when not disabled" do - {:ok, state} = Button.init(%{}) - {:ok, new_state, commands} = Button.handle_event(%Event.Mouse{action: :click}, state) - - assert new_state.pressed == true - assert [{:send, _pid, :click}] = commands - end - - test "click does nothing when disabled" do - {:ok, state} = Button.init(%{disabled: true}) - {:ok, new_state} = Button.handle_event(%Event.Mouse{action: :click}, state) - - assert new_state == state - end - - test "press sets pressed state" do - {:ok, state} = Button.init(%{}) - {:ok, new_state} = Button.handle_event(%Event.Mouse{action: :press}, state) - - assert new_state.pressed == true - end - - test "release clears pressed state" do - {:ok, state} = Button.init(%{}) - state = %{state | pressed: true} - {:ok, new_state} = Button.handle_event(%Event.Mouse{action: :release}, state) - - assert new_state.pressed == false - end - end - - describe "handle_event/2 focus" do - test "focus gained does not change state" do - {:ok, state} = Button.init(%{}) - {:ok, new_state} = Button.handle_event(%Event.Focus{action: :gained}, state) - - assert new_state == state - end - - test "focus lost clears pressed state" do - {:ok, state} = Button.init(%{}) - state = %{state | pressed: true} - {:ok, new_state} = Button.handle_event(%Event.Focus{action: :lost}, state) - - assert new_state.pressed == false - end - end - - describe "handle_info/2" do - test "click message invokes on_click callback" do - test_pid = self() - callback = fn -> send(test_pid, :clicked) end - props = %{on_click: callback} - {:ok, state} = Button.init(props) - - {:ok, new_state} = Button.handle_info(:click, state) - - assert_receive :clicked - assert new_state.pressed == false - end - - test "click message handles missing callback" do - {:ok, state} = Button.init(%{}) - {:ok, new_state} = Button.handle_info(:click, state) - - assert new_state.pressed == false - end - - test "ignores unknown messages" do - {:ok, state} = Button.init(%{}) - {:ok, new_state} = Button.handle_info(:unknown, state) - - assert new_state == state - end - end - - describe "render/2" do - test "renders centered label" do - props = %{label: "OK"} - {:ok, state} = Button.init(props) - result = Button.render(state, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - assert length(cells) == 20 - - # Find O and K positions - should be centered - chars_with_pos = Enum.map(cells, fn c -> {c.x, c.cell.char} end) - o_pos = Enum.find_value(chars_with_pos, fn {x, c} -> if c == "O", do: x end) - k_pos = Enum.find_value(chars_with_pos, fn {x, c} -> if c == "K", do: x end) - - # "OK" centered in 20 chars: padding = 9 - assert o_pos == 9 - assert k_pos == 10 - end - - test "renders disabled button with gray text" do - props = %{label: "Disabled", disabled: true} - {:ok, state} = Button.init(props) - result = Button.render(state, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - first_char = Enum.find(cells, fn c -> c.cell.char != " " end) - assert first_char.cell.fg == :bright_black - end - - test "applies pressed style when pressed" do - props = %{label: "Press", pressed_style: %{fg: :black, bg: :white}} - {:ok, state} = Button.init(props) - state = %{state | pressed: true} - result = Button.render(state, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - first_char = Enum.find(cells, fn c -> c.cell.char != " " end) - assert first_char.cell.fg == :black - assert first_char.cell.bg == :white - end - - test "truncates long label to fit width" do - props = %{label: "This is a very long button label"} - {:ok, state} = Button.init(props) - result = Button.render(state, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - assert length(cells) == 20 - end - - test "uses default label when not provided" do - props = %{} - {:ok, state} = Button.init(props) - result = Button.render(state, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - chars = Enum.map(cells, & &1.cell.char) - text = chars |> Enum.join() |> String.trim() - assert text == "Button" - end - end -end diff --git a/test/term_ui/widget/catalog_test.exs b/test/term_ui/widget/catalog_test.exs new file mode 100644 index 00000000..7a7fc9a9 --- /dev/null +++ b/test/term_ui/widget/catalog_test.exs @@ -0,0 +1,187 @@ +defmodule TermUI.Widget.CatalogTest do + use ExUnit.Case, async: true + + alias TermUI.{Event, Frame} + alias TermUI.Widget + + alias TermUI.Widget.{ + AlertDialog, + BarChart, + Block, + Button, + Canvas, + ClusterDashboard, + CommandPalette, + ContextMenu, + Dialog, + FormBuilder, + Gauge, + Label, + LineChart, + LineInput, + List, + LogViewer, + Menu, + PickList, + ProcessMonitor, + Progress, + ScrollBar, + Sparkline, + SplitPane, + Stream, + SupervisionTree, + Table, + Tabs, + TextArea, + Toast, + TreeView, + Viewport + } + + alias TermUI.Widget.Table.Column + + test "the restored widget catalog renders only canonical frames" do + modules_and_states = [ + {Label, Label.init(text: "label")}, + {Block, Block.init(title: "Block", content: "body")}, + {Button, Button.init(id: :save, label: "Save")}, + {List, List.init(items: ["one", "two"])}, + {PickList, PickList.init(items: ["one", "two"])}, + {Progress, Progress.init(value: 50)}, + {TextArea, TextArea.init(value: "one\ntwo")}, + {LineInput, LineInput.init(label: "Name")}, + {Viewport, Viewport.init(content: "one\ntwo")}, + {ScrollBar, ScrollBar.init(content_size: 100, viewport_size: 10)}, + {Tabs, Tabs.init(tabs: [{:one, "One"}, {:two, "Two"}])}, + {Table, Table.init(columns: [Column.new(:name, "Name")], rows: [%{name: "one"}])}, + {TreeView, TreeView.init(nodes: [TreeView.leaf(:one, "One")])}, + {Menu, Menu.init(items: [Menu.action(:open, "Open")])}, + {ContextMenu, ContextMenu.init(items: [Menu.action(:open, "Open")])}, + {Dialog, Dialog.init(title: "Title", content: "Body", buttons: [{:ok, "OK"}])}, + {AlertDialog, AlertDialog.init(type: :warning, message: "Careful")}, + {CommandPalette, CommandPalette.init(commands: ["Open"])}, + {FormBuilder, FormBuilder.init(fields: [%{id: :name, label: "Name", type: :text}])}, + {SplitPane, SplitPane.init(first: "left", second: "right")}, + {Toast, Toast.init(message: "Saved", type: :success)}, + {Gauge, Gauge.init(value: 60)}, + {Sparkline, Sparkline.init(values: [1, 3, 2])}, + {BarChart, BarChart.init(data: [{"A", 3}, {"B", 5}])}, + {LineChart, LineChart.init(series: [[1, 3, 2]])}, + {Canvas, Canvas.init(width: 10, height: 4) |> Canvas.draw_text(0, 0, "canvas")}, + {LogViewer, LogViewer.init(entries: ["log"])}, + {Stream, Stream.init(items: ["stream"])}, + {ProcessMonitor, ProcessMonitor.init(snapshots: [%{pid: "<0.1.0>", memory: 10}])}, + {SupervisionTree, SupervisionTree.init(nodes: [TreeView.leaf(:root, "Root")])}, + {ClusterDashboard, ClusterDashboard.init(nodes: [%{node: :local, status: :up}])} + ] + + for {module, state} <- modules_and_states do + assert %Frame{width: 40, height: 8} = Widget.view(module, state, {40, 8}) + end + end + + test "buttons, lists, menus, forms, tabs, and trees return parent messages" do + button = Button.init(id: :save, label: "Save") + assert {_button, [{:pressed, :save}]} = Button.update(Event.key(:enter), button) + + list = List.init(items: ["one", "two"]) + {list, []} = List.update(Event.key(:down), list) + assert {_list, [{:selected, "two"}]} = List.update(Event.key(:enter), list) + + menu = Menu.init(items: [Menu.separator(), Menu.action(:open, "Open")]) + assert {_menu, [{:selected, :open}]} = Menu.update(Event.key(:enter), menu) + + form = FormBuilder.init(fields: [%{id: :name, label: "Name", type: :text, required: true}]) + {form, [{:changed, :name, "Ada"}]} = FormBuilder.update(Event.text("Ada"), form) + assert {_form, [{:submit, %{name: "Ada"}}]} = FormBuilder.update(Event.key(:enter), form) + + tabs = Tabs.init(tabs: [{:one, "One"}, {:two, "Two"}]) + {tabs, _messages} = Tabs.update(Event.key(:right), tabs) + assert {tabs, [{:selected, :two}]} = Tabs.update(Event.key(:enter), tabs) + assert Tabs.selected(tabs).id == :two + + branch = TreeView.branch(:root, "Root", [TreeView.leaf(:child, "Child")]) + tree = TreeView.init(nodes: [branch]) + assert {tree, [{:expanded, :root}]} = TreeView.update(Event.key(:right), tree) + assert length(TreeView.visible(tree)) == 2 + end + + test "space text activates keyboard widgets and keeps text and mode guards" do + button = Button.init(id: :save, label: "Save") + assert {_button, [{:pressed, :save}]} = Button.update(Event.text(" "), button) + + dialog = Dialog.init(buttons: [{:ok, "OK"}]) + assert {_dialog, [{:selected, :ok}]} = Dialog.update(Event.text(" "), dialog) + + tabs = Tabs.init(tabs: [{:one, "One"}, {:two, "Two"}]) + {tabs, _messages} = Tabs.update(Event.key(:right), tabs) + assert {tabs, [{:selected, :two}]} = Tabs.update(Event.text(" "), tabs) + assert Tabs.selected(tabs).id == :two + + menu = Menu.init(items: [Menu.action(:open, "Open")]) + assert {_menu, [{:selected, :open}]} = Menu.update(Event.text(" "), menu) + + checkbox = FormBuilder.init(fields: [%{id: :enabled, label: "Enabled", type: :checkbox}]) + + assert {_checkbox, [{:changed, :enabled, true}]} = + FormBuilder.update(Event.text(" "), checkbox) + + text = FormBuilder.init(fields: [%{id: :name, label: "Name", type: :text}]) + assert {_text, [{:changed, :name, " "}]} = FormBuilder.update(Event.text(" "), text) + + multiple = List.init(items: ["one"], mode: :multiple) + assert {_multiple, [{:toggled, "one"}]} = List.update(Event.text(" "), multiple) + + single = List.init(items: ["one"], mode: :single) + assert {^single, []} = List.update(Event.text(" "), single) + + tree = TreeView.init(nodes: [TreeView.leaf(:one, "One")]) + assert {_tree, [{:selected, :one}]} = TreeView.update(Event.text(" "), tree) + + stream = Stream.init(items: ["event"]) + assert {_stream, [{:paused, true}]} = Stream.update(Event.text(" "), stream) + end + + test "multiline input places an exact-width cursor on the next row" do + state = TextArea.init(value: "abcd") + frame = TextArea.view(state, {4, 2}) + + assert Frame.row_text(frame, 1) == "abcd" + assert frame.cursor == {1, 2} + + assert {state, [{:changed, "abcd\n"}]} = TextArea.update(Event.key(:enter), state) + assert TextArea.view(state, {4, 3}).cursor == {1, 2} + end + + test "canvas retains character and braille drawing features" do + canvas = + Canvas.init(width: 8, height: 3) |> Canvas.draw_rect(0, 0, 4, 3) |> Canvas.set_dot(10, 0) + + frame = Canvas.view(canvas, {8, 3}) + + assert Frame.cell(frame, 1, 1).char == "┌" + assert String.starts_with?(Frame.cell(frame, 1, 6).char, "⠁") + assert Canvas.braille_resolution(canvas) == {16, 12} + end + + test "bounded log, stream, and toast data stays pure" do + logs = + LogViewer.init(limit: 2) + |> LogViewer.append("one") + |> LogViewer.append("two") + |> LogViewer.append("three") + + assert Enum.map(logs.entries, & &1.message) == ["two", "three"] + + stream = Stream.init(limit: 2) |> Stream.push(1) |> Stream.push(2) |> Stream.push(3) + assert stream.items == [2, 3] + + manager = + Toast.Manager.new(limit: 2) + |> Toast.Manager.add("one") + |> Toast.Manager.add("two") + |> Toast.Manager.add("three") + + assert length(manager.toasts) == 2 + end +end diff --git a/test/term_ui/widget/diff_viewer_test.exs b/test/term_ui/widget/diff_viewer_test.exs new file mode 100644 index 00000000..affa6411 --- /dev/null +++ b/test/term_ui/widget/diff_viewer_test.exs @@ -0,0 +1,51 @@ +defmodule TermUI.Widget.DiffViewerTest do + use ExUnit.Case, async: true + + alias TermUI.{Event, Frame} + alias TermUI.Widget.DiffViewer + + test "compares two texts with line numbers and changed-line pairing" do + state = DiffViewer.init(before: "same\nold\nend", after: "same\nnew\nend", context: 3) + + assert Enum.any?( + state.rows, + &(&1.kind == :changed and &1.old_text == "old" and &1.new_text == "new") + ) + + frame = DiffViewer.view(state, {50, 10}) + text = Enum.map_join(1..frame.height, "\n", &Frame.row_text(frame, &1)) + assert text =~ "-old" + assert text =~ "+new" + end + + test "switches to a side-by-side frame" do + state = DiffViewer.init(before: "old", after: "new") + assert {state, [{:mode, :split}]} = DiffViewer.update(Event.text("s"), state) + + frame = DiffViewer.view(state, {60, 5}) + assert Frame.row_text(frame, 1) =~ "before" + assert Frame.row_text(frame, 1) =~ "after" + assert Frame.row_text(frame, 2) =~ "old" + assert Frame.row_text(frame, 2) =~ "new" + end + + test "renders an existing unified diff and collapses long context" do + diff = """ + --- a/file + +++ b/file + @@ -1,8 +1,8 @@ + one + two + three + four + -old + +new + five + six + """ + + state = DiffViewer.init(unified_diff: diff, context: 1) + assert Enum.any?(state.rows, &(&1.kind == :fold)) + assert %Frame{} = DiffViewer.view(state, {50, 12}) + end +end diff --git a/test/term_ui/widget/label_test.exs b/test/term_ui/widget/label_test.exs deleted file mode 100644 index 3379bc85..00000000 --- a/test/term_ui/widget/label_test.exs +++ /dev/null @@ -1,146 +0,0 @@ -defmodule TermUI.Widget.LabelTest do - use ExUnit.Case, async: true - - alias TermUI.Component.RenderNode - alias TermUI.Widget.Label - - @area %{x: 0, y: 0, width: 20, height: 1} - - describe "render/2" do - test "renders simple text" do - props = %{text: "Hello"} - result = Label.render(props, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - # padded to area width - assert length(cells) == 20 - - # Check first 5 cells contain "Hello" - chars = Enum.map(Enum.take(cells, 5), fn %{cell: cell} -> cell.char end) - assert chars == ["H", "e", "l", "l", "o"] - end - - test "truncates long text with ellipsis" do - props = %{text: "This is a very long text that should be truncated"} - result = Label.render(props, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - assert length(cells) == 20 - - # Last character should be ellipsis - last_cell = List.last(cells) - assert last_cell.cell.char == "…" - end - - test "does not truncate when disabled" do - props = %{text: "Short", truncate: false} - result = Label.render(props, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - chars = Enum.map(cells, fn %{cell: cell} -> cell.char end) - # Should not have ellipsis, just padding - refute "…" in chars - end - - test "aligns text left by default" do - props = %{text: "Hi"} - result = Label.render(props, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - [first, second | _rest] = cells - assert first.cell.char == "H" - assert second.cell.char == "i" - assert first.x == 0 - end - - test "aligns text center" do - props = %{text: "Hi", align: :center} - result = Label.render(props, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - # "Hi" is 2 chars, area is 20, so padding is 9 on each side - # First H should be at position 9 - h_cell = Enum.find(cells, fn c -> c.cell.char == "H" end) - assert h_cell.x == 9 - end - - test "aligns text right" do - props = %{text: "Hi", align: :right} - result = Label.render(props, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - # H should be at position 18, i at 19 - h_cell = Enum.find(cells, fn c -> c.cell.char == "H" end) - assert h_cell.x == 18 - end - - test "applies foreground color" do - props = %{text: "Red", style: %{fg: :red}} - result = Label.render(props, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - first_cell = hd(cells) - assert first_cell.cell.fg == :red - end - - test "applies background color" do - props = %{text: "Blue", style: %{bg: :blue}} - result = Label.render(props, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - first_cell = hd(cells) - assert first_cell.cell.bg == :blue - end - - test "applies bold attribute" do - props = %{text: "Bold", style: %{bold: true}} - result = Label.render(props, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - first_cell = hd(cells) - assert :bold in first_cell.cell.attrs - end - - test "wraps text when enabled" do - area = %{x: 0, y: 0, width: 5, height: 3} - props = %{text: "HelloWorld", wrap: true} - result = Label.render(props, area) - - assert %RenderNode{type: :cells, cells: cells} = result - # Should wrap at 5 chars, so "Hello" on row 0, "World" on row 1 - row0_cells = Enum.filter(cells, fn c -> c.y == 0 end) - row1_cells = Enum.filter(cells, fn c -> c.y == 1 end) - - row0_chars = Enum.map(row0_cells, fn c -> c.cell.char end) - row1_chars = Enum.map(row1_cells, fn c -> c.cell.char end) - - assert row0_chars == ["H", "e", "l", "l", "o"] - assert row1_chars == ["W", "o", "r", "l", "d"] - end - - test "respects area height when wrapping" do - area = %{x: 0, y: 0, width: 5, height: 1} - props = %{text: "HelloWorld", wrap: true} - result = Label.render(props, area) - - assert %RenderNode{type: :cells, cells: cells} = result - # Only first line should be rendered - assert Enum.all?(cells, fn c -> c.y == 0 end) - end - - test "handles empty text" do - props = %{text: ""} - result = Label.render(props, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - # Should render 20 space characters - assert Enum.all?(cells, fn c -> c.cell.char == " " end) - end - end - - describe "describe/0" do - test "returns component description" do - assert Label.describe() == "Label widget for displaying text" - end - end -end diff --git a/test/term_ui/widget/list_test.exs b/test/term_ui/widget/list_test.exs deleted file mode 100644 index e79a0583..00000000 --- a/test/term_ui/widget/list_test.exs +++ /dev/null @@ -1,277 +0,0 @@ -defmodule TermUI.Widget.ListTest do - use ExUnit.Case, async: true - - alias TermUI.Component.RenderNode - alias TermUI.Event - alias TermUI.Widget.List, as: ListWidget - - @area %{x: 0, y: 0, width: 20, height: 5} - @items ["Apple", "Banana", "Cherry", "Date", "Elderberry", "Fig", "Grape"] - - describe "init/1" do - test "initializes with default values" do - {:ok, state} = ListWidget.init(%{}) - - assert state.selected_index == 0 - assert state.selected_indices == MapSet.new() - assert state.scroll_offset == 0 - assert state.item_count == 0 - end - - test "counts items from props" do - {:ok, state} = ListWidget.init(%{items: @items}) - assert state.item_count == 7 - end - - test "stores props in state" do - props = %{items: @items} - {:ok, state} = ListWidget.init(props) - assert state.props == props - end - end - - describe "handle_event/2 navigation" do - test "down key moves selection down" do - {:ok, state} = ListWidget.init(%{items: @items}) - {:ok, new_state} = ListWidget.handle_event(%Event.Key{key: :down}, state) - - assert new_state.selected_index == 1 - end - - test "up key moves selection up" do - {:ok, state} = ListWidget.init(%{items: @items}) - state = %{state | selected_index: 3} - {:ok, new_state} = ListWidget.handle_event(%Event.Key{key: :up}, state) - - assert new_state.selected_index == 2 - end - - test "up key stops at top" do - {:ok, state} = ListWidget.init(%{items: @items}) - {:ok, new_state} = ListWidget.handle_event(%Event.Key{key: :up}, state) - - assert new_state.selected_index == 0 - end - - test "down key stops at bottom" do - {:ok, state} = ListWidget.init(%{items: @items}) - state = %{state | selected_index: 6} - {:ok, new_state} = ListWidget.handle_event(%Event.Key{key: :down}, state) - - assert new_state.selected_index == 6 - end - - test "home key moves to first item" do - {:ok, state} = ListWidget.init(%{items: @items}) - state = %{state | selected_index: 5} - {:ok, new_state} = ListWidget.handle_event(%Event.Key{key: :home}, state) - - assert new_state.selected_index == 0 - end - - test "end key moves to last item" do - {:ok, state} = ListWidget.init(%{items: @items}) - {:ok, new_state} = ListWidget.handle_event(%Event.Key{key: :end}, state) - - assert new_state.selected_index == 6 - end - - test "page_up moves up by 10" do - {:ok, state} = ListWidget.init(%{items: @items}) - state = %{state | selected_index: 6} - {:ok, new_state} = ListWidget.handle_event(%Event.Key{key: :page_up}, state) - - assert new_state.selected_index == 0 - end - - test "page_down moves down by 10" do - {:ok, state} = ListWidget.init(%{items: @items}) - {:ok, new_state} = ListWidget.handle_event(%Event.Key{key: :page_down}, state) - - assert new_state.selected_index == 6 - end - end - - describe "handle_event/2 selection" do - test "enter triggers select command" do - {:ok, state} = ListWidget.init(%{items: @items}) - state = %{state | selected_index: 2} - {:ok, _state, commands} = ListWidget.handle_event(%Event.Key{key: :enter}, state) - - assert [{:send, _pid, {:select, 2}}] = commands - end - - test "space triggers toggle command" do - {:ok, state} = ListWidget.init(%{items: @items}) - state = %{state | selected_index: 1} - {:ok, _state, commands} = ListWidget.handle_event(%Event.Key{key: :space}, state) - - assert [{:send, _pid, {:toggle, 1}}] = commands - end - end - - describe "handle_info/2" do - test "select message invokes callback" do - test_pid = self() - callback = fn item -> send(test_pid, {:selected, item}) end - props = %{items: @items, on_select: callback} - {:ok, state} = ListWidget.init(props) - state = %{state | selected_index: 2} - - {:ok, _state} = ListWidget.handle_info({:select, 2}, state) - assert_receive {:selected, "Cherry"} - end - - test "toggle adds to selected_indices in multi-select" do - props = %{items: @items, multi_select: true} - {:ok, state} = ListWidget.init(props) - - {:ok, new_state} = ListWidget.handle_info({:toggle, 1}, state) - assert MapSet.member?(new_state.selected_indices, 1) - end - - test "toggle removes from selected_indices" do - props = %{items: @items, multi_select: true} - {:ok, state} = ListWidget.init(props) - state = %{state | selected_indices: MapSet.new([1, 2])} - - {:ok, new_state} = ListWidget.handle_info({:toggle, 1}, state) - refute MapSet.member?(new_state.selected_indices, 1) - assert MapSet.member?(new_state.selected_indices, 2) - end - - test "toggle does nothing without multi_select" do - props = %{items: @items} - {:ok, state} = ListWidget.init(props) - - {:ok, new_state} = ListWidget.handle_info({:toggle, 1}, state) - assert new_state.selected_indices == MapSet.new() - end - - test "set_items updates item count" do - {:ok, state} = ListWidget.init(%{items: @items}) - {:ok, new_state} = ListWidget.handle_info({:set_items, ["A", "B", "C"]}, state) - - assert new_state.item_count == 3 - end - - test "set_items adjusts selection if needed" do - {:ok, state} = ListWidget.init(%{items: @items}) - state = %{state | selected_index: 6} - - {:ok, new_state} = ListWidget.handle_info({:set_items, ["A", "B"]}, state) - assert new_state.selected_index == 1 - end - end - - describe "render/2" do - test "renders visible items" do - props = %{items: @items} - {:ok, state} = ListWidget.init(props) - result = ListWidget.render(state, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - - # Check first row contains "Apple" - row0 = Enum.filter(cells, fn c -> c.y == 0 end) - chars = Enum.map(row0, & &1.cell.char) - text = chars |> Enum.join() |> String.trim() - assert String.starts_with?(text, "Apple") - end - - test "renders only as many items as height allows" do - props = %{items: @items} - {:ok, state} = ListWidget.init(props) - result = ListWidget.render(state, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - - # Should only render 5 rows (area height) - max_y = Enum.max_by(cells, fn c -> c.y end).y - assert max_y == 4 - end - - test "highlights selected item" do - props = %{items: @items, highlight_style: %{fg: :black, bg: :white}} - {:ok, state} = ListWidget.init(props) - state = %{state | selected_index: 1} - result = ListWidget.render(state, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - - # Row 1 (Banana) should have highlight style - row1 = Enum.filter(cells, fn c -> c.y == 1 end) - first_cell = hd(row1) - assert first_cell.cell.fg == :black - assert first_cell.cell.bg == :white - end - - test "renders multi-select indicators" do - props = %{items: @items, multi_select: true} - {:ok, state} = ListWidget.init(props) - state = %{state | selected_indices: MapSet.new([1])} - result = ListWidget.render(state, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - - # Row 0 should have "[ ] " - row0 = Enum.filter(cells, fn c -> c.y == 0 end) - row0_chars = Enum.map(row0, & &1.cell.char) - row0_text = Enum.join(row0_chars) - assert String.starts_with?(row0_text, "[ ]") - - # Row 1 should have "[x] " - row1 = Enum.filter(cells, fn c -> c.y == 1 end) - row1_chars = Enum.map(row1, & &1.cell.char) - row1_text = Enum.join(row1_chars) - assert String.starts_with?(row1_text, "[x]") - end - - test "truncates long items with ellipsis" do - props = %{items: ["This is a very long item name that should be truncated"]} - {:ok, state} = ListWidget.init(props) - result = ListWidget.render(state, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - row0 = Enum.filter(cells, fn c -> c.y == 0 end) - chars = Enum.map(row0, fn c -> c.cell.char end) - assert List.last(chars) == "…" - end - - test "scrolls to keep selection visible" do - props = %{items: @items} - {:ok, state} = ListWidget.init(props) - # Last item - state = %{state | selected_index: 6} - result = ListWidget.render(state, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - - # Should have scrolled to show Grape - row4 = Enum.filter(cells, fn c -> c.y == 4 end) - chars = Enum.map(row4, & &1.cell.char) - text = chars |> Enum.join() |> String.trim() - assert String.starts_with?(text, "Grape") - end - - test "handles empty list" do - props = %{items: []} - {:ok, state} = ListWidget.init(props) - result = ListWidget.render(state, @area) - - assert %RenderNode{type: :cells, cells: []} = result - end - - test "applies custom style" do - props = %{items: @items, style: %{fg: :green}} - {:ok, state} = ListWidget.init(props) - result = ListWidget.render(state, @area) - - assert %RenderNode{type: :cells, cells: cells} = result - # Non-selected item should have green fg - row1 = Enum.filter(cells, fn c -> c.y == 1 end) - first_cell = hd(row1) - assert first_cell.cell.fg == :green - end - end -end diff --git a/test/term_ui/widget/markdown_viewer_test.exs b/test/term_ui/widget/markdown_viewer_test.exs new file mode 100644 index 00000000..a3e1c195 --- /dev/null +++ b/test/term_ui/widget/markdown_viewer_test.exs @@ -0,0 +1,64 @@ +defmodule TermUI.Widget.MarkdownViewerTest do + use ExUnit.Case, async: true + + alias TermUI.{Event, Frame, Markdown} + alias TermUI.Widget.MarkdownViewer + + @markdown """ + # Heading + + **bold** and *italic* with `code` and [link](https://example.com). + + > quoted + + - [x] done + - [ ] next + + ```elixir + IO.puts(:ok) + ``` + + | Name | Value | + | --- | ---: | + | one | 1 | + """ + + test "MDEx renders the supported block and inline forms as styled rows" do + result = Markdown.render_with_elements(@markdown, 50) + frame = Frame.from_rows(result.lines, 50, result.content_height) + text = Enum.map_join(1..frame.height, "\n", &Frame.row_text(frame, &1)) + + assert text =~ "Heading" + assert text =~ "bold and italic with code and link" + assert text =~ "│ quoted" + assert text =~ "[x] done" + assert text =~ "IO.puts(:ok)" + assert text =~ "│Name" + assert [%{type: :code_block, language: "elixir", content: content}] = result.elements + assert content =~ "IO.puts" + + heading = Frame.cell(frame, 1, 1) + assert :bold in heading.attrs + assert heading.fg == :cyan + end + + test "viewer scrolls, selects code, and emits copy data without side effects" do + state = MarkdownViewer.init(content: @markdown, page_size: 3) + assert %Frame{} = MarkdownViewer.view(state, {30, 5}) + + assert {state, [{:focused, element}]} = MarkdownViewer.update(Event.key(:tab), state) + assert element.type == :code_block + assert {_state, [{:copy, content}]} = MarkdownViewer.update(Event.key(:enter), state) + assert content =~ "IO.puts" + end + + test "raw HTML and control strings do not reach terminal cells" do + rows = Markdown.render("\n\n\e]52;c;payload\a", 30) + frame = Frame.from_rows(rows, 30, length(rows)) + rendered = Enum.map_join(1..frame.height, &Frame.row_text(frame, &1)) + + refute rendered =~ "