defmodule Raxol.Terminal.ControlCodes do @moduledoc """ Handles C0 control codes and simple ESC sequences. Extracted from Terminal.Emulator for better organization. Relies on Emulator state and ScreenBuffer for actions. """ require Raxol.Core.Runtime.Log alias Raxol.Terminal.ANSI.CharacterSets alias Raxol.Terminal.Cursor.Movement alias Raxol.Terminal.Emulator alias Raxol.Terminal.ScreenBuffer # Removed unused alias: ModeManager # C0 Constants @nul 0 @bel 7 @bs 8 @ht 9 @lf 10 @vt 11 @ff 12 @cr 13 @so 14 @si 15 @can 24 @sub 26 @esc 27 @del 127 @doc """ Handles a C0 control code (0-31) or DEL (127). Delegates to specific handlers based on the codepoint. """ @spec handle_c0(Emulator.t(), non_neg_integer()) :: Emulator.t() def handle_c0(emulator, char_codepoint) do handler = c0_handler_for(char_codepoint) handler.(emulator) end defp c0_handler_for(@nul), do: fn emulator -> Raxol.Core.Runtime.Log.debug("NUL received, ignoring") emulator end defp c0_handler_for(@bel), do: &handle_bel/1 defp c0_handler_for(@bs), do: &handle_bs/1 defp c0_handler_for(@ht), do: &handle_ht/1 defp c0_handler_for(@lf), do: &handle_lf/1 defp c0_handler_for(@vt), do: &handle_lf/1 defp c0_handler_for(@ff), do: &handle_lf/1 defp c0_handler_for(@cr), do: &handle_cr/1 defp c0_handler_for(@so), do: &handle_so/1 defp c0_handler_for(@si), do: &handle_si/1 defp c0_handler_for(@can), do: &handle_can/1 defp c0_handler_for(@sub), do: &handle_sub/1 defp c0_handler_for(@esc), do: fn emulator -> Raxol.Core.Runtime.Log.debug("ESC received unexpectedly in C0 handler, ignoring") emulator end defp c0_handler_for(@del), do: fn emulator -> Raxol.Core.Runtime.Log.debug("DEL received, ignoring") emulator end defp c0_handler_for(_), do: fn emulator -> Raxol.Core.Runtime.Log.debug("Unhandled C0 control code") emulator end @doc """ Handles bell control code. """ def handle_bel(emulator) do _ = System.cmd("tput", ["bel"]) emulator end @doc "Handle Backspace (BS)" def handle_bs(%Emulator{} = emulator) do # Move cursor left by one, respecting margins # Use alias new_cursor = Movement.move_left(emulator.cursor, 1, 80, 24) %{emulator | cursor: new_cursor} end @doc """ Handles the Horizontal Tab (HT) action. """ def handle_ht(%Emulator{} = emulator) do # Move cursor to the next tab stop {current_col, _} = Raxol.Terminal.Cursor.Manager.get_position(emulator.cursor) active_buffer = Emulator.get_screen_buffer(emulator) width = ScreenBuffer.get_width(active_buffer) # Placeholder: move to next multiple of 8 or end of line next_stop = min(width - 1, div(current_col, 8) * 8 + 8) # Use alias new_cursor = Movement.move_to_column(emulator.cursor, next_stop) %{emulator | cursor: new_cursor} end @doc "Handle Line Feed (LF), New Line (NL), Vertical Tab (VT)" def handle_lf(%Emulator{} = emulator) do active_buffer = Emulator.get_screen_buffer(emulator) buffer_height = ScreenBuffer.get_height(active_buffer) {_, current_row} = Raxol.Terminal.Cursor.Manager.get_position(emulator.cursor) last_row = buffer_height - 1 handle_lf_cursor_movement(current_row == last_row, emulator) end defp move_cursor_down(emulator) do active_buffer = Emulator.get_screen_buffer(emulator) {buffer_width, buffer_height} = ScreenBuffer.get_dimensions(active_buffer) cursor = emulator.cursor {current_col, current_row} = Raxol.Terminal.Cursor.Manager.get_position(cursor) last_row = buffer_height - 1 moved_cursor = move_cursor_down_if_needed( cursor, current_row, last_row, buffer_width, buffer_height ) # Check LNM mode to determine if we should move to column 0 line_feed_mode = Raxol.Terminal.ModeManager.mode_enabled?( emulator.mode_manager, :line_feed_mode ) final_cursor = apply_line_feed_cursor_adjustment( moved_cursor, cursor, current_col, line_feed_mode ) log_cursor_position(final_cursor) %{emulator | cursor: final_cursor} end defp move_cursor_down_if_needed( cursor, _current_row, _last_row, buffer_width, buffer_height ) do # Always move cursor down, even when at last row, to trigger scrolling case cursor do cursor when is_pid(cursor) -> GenServer.call(cursor, {:move_down, 1, buffer_width, buffer_height}) cursor when is_map(cursor) -> Raxol.Terminal.Cursor.Manager.move_down( cursor, 1, buffer_width, buffer_height ) _ -> cursor end end defp log_cursor_position(cursor) do Raxol.Core.Runtime.Log.debug( "[move_cursor_down] Final: cursor=#{inspect(Raxol.Terminal.Cursor.Manager.get_position(cursor))}" ) end defp reset_last_col_exceeded_after_scroll(emulator) do # After a scroll, reset the last_col_exceeded flag so that the next character # is written at the current row (not the next row) %{emulator | last_col_exceeded: false} end @doc "Handle Carriage Return (CR)" def handle_cr(%Emulator{} = emulator) do Raxol.Core.Runtime.Log.debug( "[handle_cr] Input: cursor=#{inspect(Raxol.Terminal.Cursor.Manager.get_position(emulator.cursor))}, last_exceeded=#{emulator.last_col_exceeded}" ) # 1. Check for pending wrap emulator_after_pending_wrap = handle_pending_wrap(emulator.last_col_exceeded, emulator) # 2. Perform CR logic on potentially updated state Raxol.Core.Runtime.Log.debug("[handle_cr] Moving cursor to column 0") # Get current Y coordinate {_cx, cy} = Raxol.Terminal.Cursor.Manager.get_position(emulator.cursor) final_cursor = Raxol.Terminal.Cursor.Manager.move_to( emulator_after_pending_wrap.cursor, 0, cy ) Raxol.Core.Runtime.Log.debug( "[handle_cr] Final cursor: #{inspect(Raxol.Terminal.Cursor.Manager.get_position(final_cursor))}" ) %{emulator_after_pending_wrap | cursor: final_cursor} end @spec handle_so(Emulator.t()) :: Emulator.t() def handle_so(emulator) do # SO: Shift Out. Invoke G1 character set. %{ emulator | charset_state: CharacterSets.invoke_designator(emulator.charset_state, :g1) } end @spec handle_si(Emulator.t()) :: Emulator.t() def handle_si(emulator) do # SI: Shift In. Invoke G0 character set. %{ emulator | charset_state: CharacterSets.invoke_designator(emulator.charset_state, :g0) } end @spec handle_can(Emulator.t()) :: Emulator.t() def handle_can(emulator) do # CAN: Cancel. Parser should handle this within sequences. # If it reaches here, it was outside a sequence. Raxol.Core.Runtime.Log.debug("CAN received outside escape sequence, ignoring") emulator end @doc """ Handles substitute character control code. """ def handle_sub(emulator) do # Print a substitute character (typically displayed as ^Z) _ = System.cmd("echo", ["-n", "^Z"]) emulator end @dialyzer {:nowarn_function, handle_ris: 1} @spec handle_ris(Emulator.t()) :: Emulator.t() # ESC c - Reset to Initial State def handle_ris(emulator) do Raxol.Core.Runtime.Log.info("RIS (Reset to Initial State) received") # Re-initialize most state components, keeping buffer dimensions active_buffer = Emulator.get_screen_buffer(emulator) width = ScreenBuffer.get_width(active_buffer) height = ScreenBuffer.get_height(active_buffer) scrollback_limit = active_buffer.scrollback_limit # Create a completely new default state, preserving only dimensions/limits Emulator.new(width, height, scrollback: scrollback_limit, memorylimit: emulator.memory_limit ) end @spec handle_ind(Emulator.t()) :: Emulator.t() # ESC D - Index def handle_ind(emulator) do # Move cursor down one line, scroll if at bottom margin. Same as LF. handle_lf(emulator) end @spec handle_nel(Emulator.t()) :: Emulator.t() # ESC E - Next Line def handle_nel(emulator) do # Move cursor to start of next line. Like CR + LF. emulator # Move down/scroll |> handle_lf() # Move to col 0 |> handle_cr() end @dialyzer {:nowarn_function, handle_hts: 1} @spec handle_hts(Emulator.t()) :: Emulator.t() # ESC H - Horizontal Tabulation Set def handle_hts(emulator) do # Set a tab stop at the current cursor column. {x, _y} = Raxol.Terminal.Cursor.Manager.get_position(emulator.cursor) new_tab_stops = MapSet.put(emulator.tab_stops, x) %{emulator | tab_stops: new_tab_stops} end @doc "Handle Reverse Index (RI) - ESC M" def handle_ri(%Emulator{} = emulator) do # Move cursor up one line. If at the top margin, scroll down. {_col, row} = Raxol.Terminal.Cursor.Manager.get_position(emulator.cursor) active_buffer = Emulator.get_screen_buffer(emulator) {top_margin, _} = case emulator.scroll_region do # Use scroll_region directly {top, bottom} -> {top, bottom} # Default to full height nil -> {0, ScreenBuffer.get_height(active_buffer) - 1} end handle_ri_cursor_movement(row == top_margin, emulator, active_buffer) end @spec handle_decsc(Emulator.t()) :: Emulator.t() # ESC 7 - Save Cursor State (DEC specific) def handle_decsc(emulator) do # Get cursor state from the PID using CursorManager cursor_position = Raxol.Terminal.Cursor.Manager.get_position(emulator.cursor) cursor_visible = Raxol.Terminal.Cursor.Manager.get_visibility(emulator.cursor) cursor_style = Raxol.Terminal.Cursor.Manager.get_style(emulator.cursor) cursor_blinking = Raxol.Terminal.Cursor.Manager.get_blink(emulator.cursor) saved_state = %{ cursor: %{ position: cursor_position, visible: cursor_visible, style: cursor_style, blink_state: cursor_blinking }, style: emulator.style, charset_state: emulator.charset_state, mode_manager: emulator.mode_manager, scroll_region: emulator.scroll_region, cursor_style: emulator.cursor_style } # Save the state to the stack new_stack = [saved_state | emulator.state_stack] %{emulator | state_stack: new_stack} end @spec handle_decrc(Emulator.t()) :: Emulator.t() # ESC 8 - Restore Cursor State (DEC specific) def handle_decrc(emulator) do case emulator.state_stack do [restored_state_data | new_stack] -> # Apply the restored state components emulator = %{ emulator | state_stack: new_stack, style: restored_state_data.style, charset_state: restored_state_data.charset_state, mode_manager: restored_state_data.mode_manager, scroll_region: restored_state_data.scroll_region, cursor_style: Map.get(restored_state_data, :cursor_style, emulator.cursor_style) } # Restore cursor position and attributes using CursorManager updated_cursor = restore_cursor_state(restored_state_data.cursor, emulator.cursor) %{emulator | cursor: updated_cursor} [] -> # No saved state to restore emulator end end @escape_handlers %{ ?7 => &Raxol.Terminal.ControlCodes.handle_decsc/1, ?8 => &Raxol.Terminal.ControlCodes.handle_decrc/1, ?c => &Raxol.Terminal.ControlCodes.handle_ris/1, ?D => &Raxol.Terminal.ControlCodes.handle_ind/1, ?E => &Raxol.Terminal.ControlCodes.handle_nel/1, ?H => &Raxol.Terminal.ControlCodes.handle_hts/1, ?M => &Raxol.Terminal.ControlCodes.handle_ri/1, ?n => &Raxol.Terminal.ControlCodes.handle_ls2/1, ?o => &Raxol.Terminal.ControlCodes.handle_ls3/1, ?~ => &Raxol.Terminal.ControlCodes.handle_ls1r/1, ?} => &Raxol.Terminal.ControlCodes.handle_ls2r/1, ?| => &Raxol.Terminal.ControlCodes.handle_ls3r/1, ?= => &Raxol.Terminal.Emulator.handle_esc_equals/1, ?> => &Raxol.Terminal.Emulator.handle_esc_greater/1 } @doc """ Handles simple escape sequences (ESC followed by a single byte). """ @spec handle_escape(Emulator.t(), integer()) :: Emulator.t() def handle_escape(emulator, byte) do Raxol.Core.Runtime.Log.debug("ControlCodes.handle_escape called with byte=#{inspect(byte)}") case Map.get(@escape_handlers, byte) do nil -> Raxol.Core.Runtime.Log.debug("Unhandled escape sequence byte: #{inspect(byte)}") emulator handler -> Raxol.Core.Runtime.Log.debug("Found handler for byte #{inspect(byte)}, calling handler") result = handler.(emulator) Raxol.Core.Runtime.Log.debug("Handler returned: #{inspect(result)}") result end end @doc """ Handle Locking Shift 2 (LS2) - ESC n Invokes G2 character set into GL """ def handle_ls2(%Emulator{} = emulator) do %{ emulator | charset_state: %{emulator.charset_state | gl: :g2} } end @doc """ Handle Locking Shift 3 (LS3) - ESC o Invokes G3 character set into GL """ def handle_ls3(%Emulator{} = emulator) do %{ emulator | charset_state: %{emulator.charset_state | gl: :g3} } end @doc """ Handle Locking Shift 1 Right (LS1R) - ESC ~ Invokes G1 character set into GR """ def handle_ls1r(%Emulator{} = emulator) do %{ emulator | charset_state: %{emulator.charset_state | gr: :g1} } end @doc """ Handle Locking Shift 2 Right (LS2R) - ESC } Invokes G2 character set into GR """ def handle_ls2r(%Emulator{} = emulator) do %{ emulator | charset_state: %{emulator.charset_state | gr: :g2} } end @doc """ Handle Locking Shift 3 Right (LS3R) - ESC | Invokes G3 character set into GR """ def handle_ls3r(%Emulator{} = emulator) do %{ emulator | charset_state: %{emulator.charset_state | gr: :g3} } end # Helper function for line feed cursor adjustment defp apply_line_feed_cursor_adjustment( moved_cursor, _cursor, current_col, line_feed_mode ) when is_pid(moved_cursor) do target_col = get_target_column(line_feed_mode, current_col) GenServer.call(moved_cursor, {:move_to_column, target_col}) moved_cursor end defp apply_line_feed_cursor_adjustment( moved_cursor, cursor, current_col, line_feed_mode ) when is_map(moved_cursor) do apply_map_cursor_adjustment( Map.has_key?(moved_cursor, :row) and Map.has_key?(moved_cursor, :col), moved_cursor, cursor, current_col, line_feed_mode ) end defp apply_line_feed_cursor_adjustment( _moved_cursor, cursor, _current_col, _line_feed_mode ) do cursor end # Helper functions for refactored if statements defp handle_lf_cursor_movement(true, emulator) do emulator |> move_cursor_down() |> Emulator.maybe_scroll() |> reset_last_col_exceeded_after_scroll() end defp handle_lf_cursor_movement(_at_last_row, emulator) do emulator |> move_cursor_down() |> reset_last_col_exceeded_after_scroll() end defp handle_pending_wrap(true, emulator) do Raxol.Core.Runtime.Log.debug("[handle_cr] Pending wrap detected") # Perform the deferred wrap: move cursor to col 0, next line {_cx, cy} = Raxol.Terminal.Cursor.Manager.get_position(emulator.cursor) wrapped_cursor = Raxol.Terminal.Cursor.Manager.move_to(emulator.cursor, 0, cy + 1) Raxol.Core.Runtime.Log.debug( "[handle_cr] Cursor after wrap: #{inspect(Raxol.Terminal.Cursor.Manager.get_position(wrapped_cursor))}" ) # Also scroll if needed after wrap (use maybe_scroll on potentially wrapped state) maybe_scrolled_emulator = Emulator.maybe_scroll(%{ emulator | cursor: wrapped_cursor, last_col_exceeded: false }) Raxol.Core.Runtime.Log.debug( "[handle_cr] State after pending wrap + scroll: cursor=#{inspect(Raxol.Terminal.Cursor.Manager.get_position(maybe_scrolled_emulator.cursor))}, last_exceeded=#{maybe_scrolled_emulator.last_col_exceeded}" ) maybe_scrolled_emulator end defp handle_pending_wrap(_has_wrap, emulator) do Raxol.Core.Runtime.Log.debug("[handle_cr] No pending wrap") emulator end defp handle_ri_cursor_movement(true, emulator, _active_buffer) do Raxol.Terminal.Commands.Screen.scroll_down(emulator, 1) end defp handle_ri_cursor_movement(_at_top_margin, emulator, active_buffer) do cursor = emulator.cursor # Use alias cursor = Movement.move_up( cursor, 1, ScreenBuffer.get_width(active_buffer), ScreenBuffer.get_height(active_buffer) ) %{emulator | cursor: cursor} end defp restore_cursor_state(nil, emulator_cursor), do: emulator_cursor defp restore_cursor_state(cursor_data, emulator_cursor) do # Restore cursor position cursor = Raxol.Terminal.Cursor.Manager.set_position( emulator_cursor, cursor_data.position ) # Restore cursor visibility cursor = Raxol.Terminal.Cursor.Manager.set_visibility( cursor, cursor_data.visible ) # Restore cursor style cursor = Raxol.Terminal.Cursor.Manager.set_style( cursor, cursor_data.style ) # Restore cursor blinking state cursor = Raxol.Terminal.Cursor.Manager.set_blink( cursor, cursor_data.blink_state ) cursor end defp get_target_column(true, _current_col), do: 0 defp get_target_column(_line_feed_mode, current_col), do: current_col defp apply_map_cursor_adjustment( true, moved_cursor, _cursor, current_col, line_feed_mode ) do target_col = get_target_column(line_feed_mode, current_col) Raxol.Terminal.Cursor.Manager.move_to_column(moved_cursor, target_col) end defp apply_map_cursor_adjustment( _has_keys, _moved_cursor, cursor, _current_col, _line_feed_mode ) do cursor end end