diff --git a/lib/term_ui/backend/ssh.ex b/lib/term_ui/backend/ssh.ex new file mode 100644 index 0000000..91f90db --- /dev/null +++ b/lib/term_ui/backend/ssh.ex @@ -0,0 +1,515 @@ +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 + alias TermUI.Renderer.DisplayWidth + + # 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" + @autowrap_off "\e[?7l" + @autowrap_on "\e[?7h" + @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 + + # Disable autowrap to prevent bottom-right writes from scrolling the screen. + # This is important for diff renderers that frequently touch the right edge. + device_write(device, @autowrap_off) + + # 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) + + # Restore terminal autowrap + device_write(device, @autowrap_on) + + # 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 and row-based rendering: + - Clears each touched row once (`EL2`) + - Streams row content left-to-right + - Minimizes cursor movement within the row + + This avoids stale-cell artifacts and reduces SSH output volume versus + absolute cursor movement per cell. + """ + @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 + {max_rows, max_cols} = state.size + + # Sort cells by position for sequential rendering + sorted = Enum.sort_by(cells, fn {{row, col}, _cell} -> {row, col} end) + + # Render with style delta tracking. + # Track row-local cursor progression so adjacent cells don't need CUP. + {iodata, new_style, last_pos, _last_row, _next_col} = + Enum.reduce(sorted, {[], state.current_style, state.cursor_position, nil, 1}, fn + {{row, col}, {char, fg, bg, attrs}}, {acc, prev_style, _prev_pos, last_row, next_col} -> + {row_prefix, row_col} = + if last_row != row do + {["\e[#{row};1H", "\e[2K"], 1} + else + {[], next_col} + end + + move_seq = + if col == row_col do + [] + else + "\e[#{row};#{col}H" + end + + # Avoid emitting the bottom-right cell directly. Some terminals can + # still scroll or mis-handle this edge under latency/reflow. + if row == max_rows and col == max_cols do + new_acc = [acc, row_prefix] + {new_acc, prev_style, {row, col}, row, col} + else + {style_seq, new_style} = style_delta_sequence(prev_style, fg, bg, attrs) + safe_char = sanitize_char(char) + new_acc = [acc, row_prefix, move_seq, style_seq, safe_char] + + width = max(1, DisplayWidth.width(safe_char)) + next_col = col + width + + {new_acc, new_style, {row, next_col}, row, next_col} + end + 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 — 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) when is_binary(char) do + char + |> String.graphemes() + |> List.first() + |> case do + nil -> + " " + + grapheme -> + if Regex.match?(~r/[\x00-\x1F\x7F]/u, grapheme), do: " ", else: grapheme + end + end +end diff --git a/lib/term_ui/renderer/buffer.ex b/lib/term_ui/renderer/buffer.ex index 483f26f..457634c 100644 --- a/lib/term_ui/renderer/buffer.ex +++ b/lib/term_ui/renderer/buffer.ex @@ -416,6 +416,24 @@ defmodule TermUI.Renderer.Buffer do defp build_cell(grapheme, nil), do: Cell.new(grapheme) defp build_cell(grapheme, style), do: Style.to_cell(style, grapheme) + @doc """ + Fills the entire buffer with the given cell. + + This is used to set a background before rendering content on top, + ensuring the diff algorithm detects all cells as changed on the + first frame (producing a full-screen draw). + """ + @spec fill(t(), Cell.t()) :: :ok + def fill(%__MODULE__{} = buffer, %Cell{} = cell) do + entries = + for row <- 1..buffer.rows, col <- 1..buffer.cols do + {{row, col}, cell} + end + + :ets.insert(buffer.table, entries) + :ok + end + # Private helpers defp initialize_cells(%__MODULE__{} = buffer) do diff --git a/lib/term_ui/runtime.ex b/lib/term_ui/runtime.ex index 0a7d641..ada1853 100644 --- a/lib/term_ui/runtime.ex +++ b/lib/term_ui/runtime.ex @@ -57,7 +57,7 @@ defmodule TermUI.Runtime do cleanup_shutdown: 1, cleanup_terminal_restore: 1, cleanup_persistent_terms: 0, - ensure_echo_enabled: 0, + ensure_echo_enabled: 1, render_with_buffer_manager: 2, render_to_tty_backend: 2, extract_all_cells: 1} @@ -69,6 +69,7 @@ defmodule TermUI.Runtime do | {:backend, :auto | :raw | :tty} | {:skip_terminal, boolean()} | {:use_input_handler, boolean()} + | {:background, Cell.t()} # Default render interval in milliseconds (~60 FPS) @default_render_interval 16 @@ -302,21 +303,25 @@ defmodule TermUI.Runtime do 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) + {backend_mode, backend, backend_state, capabilities, terminal_started, buffer_manager, + dimensions} = + init_backend(opts, unique_buffer_manager_name()) + + # Suppress default Logger handler only for local terminal backends. + # Custom backends (e.g. SSH) should not mutate global logger handlers. logger_handler_config = - if Keyword.get(opts, :skip_terminal, false) do - nil - else + if backend_mode in [:raw, :tty] and not Keyword.get(opts, :skip_terminal, false) do suppress_logger() + else + nil 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) + # Store backend info in persistent_term only for local terminal runtimes. + # Custom runtimes (SSH) must not override global terminal context used by + # the local UI runtime. + if backend_mode in [:raw, :tty, :skip] do + PersistentTerms.store_backend_context(backend_mode, capabilities) + end # Initialize root component state root_state = root_module.init(opts) @@ -343,7 +348,8 @@ defmodule TermUI.Runtime do capabilities: capabilities, input_handler: input_handler, input_state: input_state, - logger_handler_config: logger_handler_config + logger_handler_config: logger_handler_config, + background: Keyword.get(opts, :background) }) # Schedule first render @@ -363,14 +369,14 @@ defmodule TermUI.Runtime do {:ok, state} end - defp init_backend(opts) do + defp init_backend(opts, buffer_manager_name) 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) + select_backend(backend_opt, buffer_manager_name) end end @@ -435,22 +441,32 @@ defmodule TermUI.Runtime do capabilities: params.capabilities, input_handler: params.input_handler, input_state: params.input_state, - logger_handler_config: params[:logger_handler_config] + logger_handler_config: params[:logger_handler_config], + background: params[:background] } end - defp select_backend(backend_opt) do + defp select_backend(backend_opt, buffer_manager_name) 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) + {:raw, _raw_state} -> + attempt_raw_backend(fallback_to_tty: true, buffer_manager_name: buffer_manager_name) + + {:tty, capabilities} -> + init_tty_backend(capabilities) + + {:explicit, :raw, _opts} -> + attempt_raw_backend(fallback_to_tty: false, buffer_manager_name: buffer_manager_name) + + {:explicit, :tty, _opts} -> + init_tty_backend(Selector.detect_capabilities()) + + {:explicit, module, opts} -> + init_explicit_backend(module, opts, buffer_manager_name) end end defp attempt_raw_backend(opts) do - case setup_terminal_and_buffers() do + case setup_terminal_and_buffers(Keyword.fetch!(opts, :buffer_manager_name)) do {true, buffer_manager, dimensions} -> init_raw_backend(buffer_manager, dimensions) @@ -489,15 +505,29 @@ defmodule TermUI.Runtime do {:tty, backend, backend_state, capabilities, false, nil, nil} end - defp init_explicit_backend(TermUI.Backend.Raw) do - attempt_raw_backend(fallback_to_tty: false) + defp init_explicit_backend(TermUI.Backend.Raw, _opts, buffer_manager_name) do + attempt_raw_backend(fallback_to_tty: false, buffer_manager_name: buffer_manager_name) end - defp init_explicit_backend(TermUI.Backend.TTY) do + defp init_explicit_backend(TermUI.Backend.TTY, _opts, _buffer_manager_name) do init_tty_backend(Selector.detect_capabilities()) end - defp setup_terminal_and_buffers do + defp init_explicit_backend(module, opts, buffer_manager_name) when is_atom(module) do + {:ok, backend_state} = module.init(opts) + {:ok, {rows, cols}} = module.size(backend_state) + + # Start BufferManager for the custom backend + buffer_pid = + case BufferManager.start_link(rows: rows, cols: cols, name: buffer_manager_name) do + {:ok, pid} -> pid + {:error, {:already_started, pid}} -> pid + end + + {:custom, module, backend_state, nil, false, buffer_pid, {cols, rows}} + end + + defp setup_terminal_and_buffers(buffer_manager_name) do # Start Terminal GenServer (or reuse if already running) case Terminal.start_link() do {:ok, _pid} -> :ok @@ -514,7 +544,7 @@ defmodule TermUI.Runtime do # Start BufferManager (or reuse if already running) buffer_pid = - case BufferManager.start_link(rows: rows, cols: cols) do + case BufferManager.start_link(rows: rows, cols: cols, name: buffer_manager_name) do {:ok, pid} -> pid {:error, {:already_started, pid}} -> pid end @@ -533,6 +563,11 @@ defmodule TermUI.Runtime do end end + @spec unique_buffer_manager_name() :: {:global, {:term_ui_buffer_manager, pid(), integer()}} + defp unique_buffer_manager_name do + {:global, {:term_ui_buffer_manager, self(), System.unique_integer([:positive])}} + end + @impl true def handle_cast({:event, event}, state) do if state.shutting_down do @@ -631,6 +666,37 @@ defmodule TermUI.Runtime do end end + @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 + + {:noreply, state} + 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 + @impl true def handle_info({:DOWN, _ref, :process, _pid, _reason}, state) do # Command task completed (handled via command_result) @@ -722,12 +788,17 @@ defmodule TermUI.Runtime do cleanup_shutdown(state) cleanup_terminal_restore(state) - # Step 5: Defensive cleanup (catches anything missed above) - terminate_defensive_cleanup() + # Step 5: Defensive cleanup (local terminals only) + if state.backend_mode in [:raw, :tty, :skip] do + terminate_defensive_cleanup() + end + + # Step 6: Persistent terms (local terminals only) and echo + if state.backend_mode in [:raw, :tty, :skip] do + cleanup_persistent_terms() + end - # Step 6: Persistent terms and echo - cleanup_persistent_terms() - ensure_echo_enabled() + ensure_echo_enabled(state) :ok end @@ -780,7 +851,9 @@ defmodule TermUI.Runtime do end defp cleanup_terminal_restore(state) do - if state.terminal_started 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() end rescue @@ -793,8 +866,11 @@ defmodule TermUI.Runtime do _ -> :ok end - defp ensure_echo_enabled do - :io.setopts(echo: true) + 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) + end rescue _ -> :ok end @@ -815,14 +891,19 @@ defmodule TermUI.Runtime do # Cleanup ONLCR persistent_term TerminalOutput.disable_onlcr() - # Safety net stty restore (skip on WSL where stty always fails) - unless TerminalOutput.needs_hard_reset?() do + # Safety net stty restore (skip if stdin is not a TTY, and on WSL). + unless TerminalOutput.needs_hard_reset?() or not stdin_tty?() do TermUI.TermUtils.safe_stty(["sane"]) end rescue _ -> :ok end + @spec stdin_tty?() :: boolean() + defp stdin_tty? do + match?({:ok, _}, :io.rows()) and match?({:ok, _}, :io.columns()) + end + defp suppress_logger do case :logger.get_handler_config(:default) do {:ok, config} -> @@ -1130,8 +1211,15 @@ defmodule TermUI.Runtime do # 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) + if state.backend_mode == :custom do + # Custom backends (SSH): redraw only changed rows. + # Each changed row is emitted as a full logical row to guarantee + # stale text is cleared without forcing full-screen repaints. + render_custom_with_buffer_manager(render_tree, state) + else + # Local raw backend: cell-level diffing for maximal efficiency. + render_with_buffer_manager(render_tree, state) + end else # TTY backend: create temporary buffer, render all cells render_to_tty_backend(render_tree, state) @@ -1151,8 +1239,15 @@ defmodule TermUI.Runtime do # 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) + # Fill current buffer: background cell if set, otherwise clear to empty. + # A non-default background ensures the diff draws all cells on the first + # frame, producing a full-screen render instead of content-only. + buffer = BufferManager.get_current_buffer(state.buffer_manager) + + case state.background do + %Cell{} = bg -> Buffer.fill(buffer, bg) + _ -> BufferManager.clear_current(state.buffer_manager) + end # Render tree to buffer NodeRenderer.render_to_buffer(render_tree, state.buffer_manager) @@ -1170,6 +1265,30 @@ defmodule TermUI.Runtime do {cells, state.backend_state} end + # Renders with BufferManager for custom backends (SSH). + # Emits complete changed rows (including spaces) so backends can safely + # clear and redraw touched rows without full-screen flicker. + defp render_custom_with_buffer_manager(render_tree, state) do + buffer = BufferManager.get_current_buffer(state.buffer_manager) + + case state.background do + %Cell{} = bg -> Buffer.fill(buffer, bg) + _ -> BufferManager.clear_current(state.buffer_manager) + end + + NodeRenderer.render_to_buffer(render_tree, state.buffer_manager) + + current = BufferManager.get_current_buffer(state.buffer_manager) + previous = BufferManager.get_previous_buffer(state.buffer_manager) + + changed_rows = changed_rows(current, previous) + cells = extract_rows_cells(current, changed_rows) + + 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 @@ -1220,6 +1339,31 @@ defmodule TermUI.Runtime do end end + @spec extract_rows_cells(reference(), [pos_integer()]) :: list() + defp extract_rows_cells(_buffer, []), do: [] + + defp extract_rows_cells(buffer, rows) do + rows + |> Enum.reduce([], fn row, acc -> + row_cells = + buffer + |> Buffer.get_row(row) + |> Enum.with_index(1) + |> Enum.flat_map(fn {cell, col} -> cell_to_backend_tuple(cell, row, col) end) + + row_cells ++ acc + end) + end + + @spec changed_rows(reference(), reference()) :: [pos_integer()] + defp changed_rows(current, previous) do + {rows, _cols} = Buffer.dimensions(current) + + for row <- 1..rows, + Buffer.get_row(current, row) != Buffer.get_row(previous, row), + do: row + 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} @@ -1321,28 +1465,58 @@ defmodule TermUI.Runtime do # --- Resize Handling --- defp handle_resize(rows, cols, state) do - # Skip if terminal not available - if state.terminal_started do - # Update dimensions in state - new_dimensions = {cols, rows} + cond do + state.terminal_started -> + # Local terminal (Raw/TTY) with Terminal singleton + new_dimensions = {cols, rows} - # Resize buffer manager - if state.buffer_manager do - BufferManager.resize(state.buffer_manager, rows, cols) - end + if state.buffer_manager do + BufferManager.resize(state.buffer_manager, rows, cols) + end - # Clear screen to avoid artifacts - IO.write("\e[2J") + # Clear screen through backend to avoid direct IO.write + state = + if state.backend do + {:ok, new_backend_state} = state.backend.clear(state.backend_state) + %{state | backend_state: new_backend_state} + else + state + end - # Create resize event and broadcast to all components - resize_event = Event.Resize.new(cols, rows) - state = broadcast_event(resize_event, %{state | dimensions: new_dimensions}) + resize_event = Event.Resize.new(cols, rows) + state = broadcast_event(resize_event, %{state | dimensions: new_dimensions}) - # Mark dirty and force immediate render - state = %{state | dirty: true} - do_render(state) - else - state + state = %{state | dirty: true} + do_render(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 and clear + 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 + + {:ok, backend_state} = state.backend.clear(backend_state) + state = %{state | backend_state: backend_state} + + resize_event = Event.Resize.new(cols, rows) + state = broadcast_event(resize_event, %{state | dimensions: new_dimensions}) + + state = %{state | dirty: true} + do_render(state) + + true -> + state end end diff --git a/test/integration/ssh_runtime_rendering_test.exs b/test/integration/ssh_runtime_rendering_test.exs new file mode 100644 index 0000000..4bb415d --- /dev/null +++ b/test/integration/ssh_runtime_rendering_test.exs @@ -0,0 +1,133 @@ +defmodule TermUI.Integration.SSHRuntimeRenderingTest do + use ExUnit.Case, async: false + + alias TermUI.Runtime + + defmodule RowToggleRoot do + use TermUI.Elm + + @impl true + def init(_opts), do: %{mode: :long} + + @impl true + def event_to_msg(_event, _state), do: :ignore + + @impl true + def update(:short, state), do: {%{state | mode: :short}, []} + def update(:long, state), do: {%{state | mode: :long}, []} + def update(_msg, state), do: {state, []} + + @impl true + def view(%{mode: :long}) do + [ + {:text, "ABCDEFGHIJ"}, + {:text, "1234567890"} + ] + end + + def view(%{mode: :short}) do + [ + {:text, "AB"}, + {:text, "12"} + ] + end + end + + defmodule FullFrame2x2Root do + use TermUI.Elm + + @impl true + def init(_opts), do: %{} + + @impl true + def event_to_msg(_event, _state), do: :ignore + + @impl true + def update(_msg, state), do: {state, []} + + @impl true + def view(_state) do + [ + {:text, "AB"}, + {:text, "CD"} + ] + end + end + + defp start_ssh_runtime(root, device, opts \\ []) do + size = Keyword.get(opts, :size, {4, 10}) + + {:ok, runtime} = + Runtime.start_link( + [ + root: root, + backend: {TermUI.Backend.SSH, device: device, size: size}, + render_interval: 60_000 + ] ++ + Keyword.drop(opts, [:size]) + ) + + on_exit(fn -> + if Process.alive?(runtime), do: Runtime.shutdown(runtime) + end) + + runtime + end + + defp force_render_sync(runtime) do + Runtime.force_render(runtime) + _ = :sys.get_state(runtime) + :ok + end + + defp output_snapshot(device) do + {_input, output} = StringIO.contents(device) + output + end + + defp output_since(device, snapshot) do + current = output_snapshot(device) + + if String.starts_with?(current, snapshot) do + String.replace_prefix(current, snapshot, "") + else + current + end + end + + describe "custom SSH runtime diff rendering" do + test "redraws changed rows without full-screen clear on shrink updates" do + {:ok, device} = StringIO.open("") + runtime = start_ssh_runtime(RowToggleRoot, device) + + force_render_sync(runtime) + snapshot = output_snapshot(device) + + Runtime.send_message(runtime, :root, :short) + :ok = Runtime.sync(runtime) + force_render_sync(runtime) + + output = output_since(device, snapshot) + + assert output =~ "\e[1;1H\e[2K" + assert output =~ "\e[2;1H\e[2K" + assert output =~ "AB" + assert output =~ "12" + refute output =~ "\e[2J" + refute output =~ "CDEFG" + end + + test "does not emit bottom-right character for 2x2 full-frame content" do + {:ok, device} = StringIO.open("") + runtime = start_ssh_runtime(FullFrame2x2Root, device, size: {2, 2}) + + force_render_sync(runtime) + output = output_snapshot(device) + + assert output =~ "A" + assert output =~ "B" + assert output =~ "C" + refute output =~ "D" + end + end +end diff --git a/test/term_ui/backend/ssh_test.exs b/test/term_ui/backend/ssh_test.exs new file mode 100644 index 0000000..16231da --- /dev/null +++ b/test/term_ui/backend/ssh_test.exs @@ -0,0 +1,591 @@ +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 "disables autowrap on init" do + {_state, device} = init_ssh() + assert device_output(device) =~ "\e[?7l" + 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 "restores autowrap on shutdown" do + {state, device} = init_ssh() + SSH.shutdown(state) + {_input, output} = StringIO.contents(device) + assert output =~ "\e[?7h" + 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 "clears each touched row before drawing" do + {state, _device} = init_ssh() + {:ok, device} = StringIO.open("") + state = %{state | device: device} + + cells = [ + {{1, 1}, {"A", :default, :default, []}}, + {{2, 1}, {"B", :default, :default, []}} + ] + + {:ok, _state} = SSH.draw_cells(state, cells) + + output = device_output(device) + assert String.contains?(output, "\e[1;1H\e[2K") + assert String.contains?(output, "\e[2;1H\e[2K") + end + + test "does not emit per-cell cursor moves for contiguous 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) + refute String.contains?(output, "\e[1;2H") + 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 + + test "skips drawing the bottom-right cell to avoid terminal scroll edge cases" do + {state, _device} = init_ssh(size: {2, 2}) + {:ok, device} = StringIO.open("") + state = %{state | device: device} + + cells = [ + {{2, 1}, {"C", :default, :default, []}}, + {{2, 2}, {"D", :default, :default, []}} + ] + + {:ok, new_state} = SSH.draw_cells(state, cells) + + output = device_output(device) + assert output =~ "C" + refute output =~ "D" + assert new_state.cursor_position == {2, 2} + 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