Class: Charming::Internal::Terminal::TTYBackend
- Inherits:
-
Object
- Object
- Charming::Internal::Terminal::TTYBackend
- Includes:
- Adapter
- Defined in:
- lib/charming/internal/terminal/tty_backend.rb
Overview
TTYBackend is the production terminal backend. It reads key and mouse events from a TTY::Reader, normalizes them via KeyNormalizer and MouseParser, and writes output frames using TTY::Cursor and TTY::Screen. It also installs SIGWINCH and SIGINFO handlers so the runtime can react to terminal resize and focus changes.
Constant Summary collapse
- ALT_SCREEN_ON =
Escape sequences for entering/leaving the alternate screen buffer.
"\e[?1049h"- ALT_SCREEN_OFF =
"\e[?1049l"- AUTO_WRAP_OFF =
Escape sequences for disabling/enabling automatic line wrapping during frame writes.
"\e[?7l"- AUTO_WRAP_ON =
"\e[?7h"- BRACKETED_PASTE_ON =
Escape sequences for enabling/disabling bracketed-paste mode, and the markers the terminal wraps around pasted text.
"\e[?2004h"- BRACKETED_PASTE_OFF =
"\e[?2004l"- PASTE_START =
"\e[200~"- PASTE_END =
"\e[201~"- FOCUS_REPORTING_ON =
Escape sequences for terminal focus reporting and the focus-in/out markers.
"\e[?1004h"- FOCUS_REPORTING_OFF =
"\e[?1004l"- FOCUS_IN =
"\e[I"- FOCUS_OUT =
"\e[O"- BACKGROUND_QUERY =
The OSC 11 background-color query and the terminators a reply may end with.
"\e]11;?\e\\"- OSC_TERMINATORS =
["\a", "\e\\"].freeze
Instance Method Summary collapse
-
#clear ⇒ Object
Clears the terminal screen and moves the cursor to (1, 1).
-
#disable_bracketed_paste ⇒ Object
Emits the ANSI sequence disabling bracketed-paste mode.
-
#disable_focus_reporting ⇒ Object
Emits the ANSI sequence disabling terminal focus reporting.
-
#disable_mouse_tracking ⇒ Object
Emits the ANSI sequences that disable terminal mouse reporting.
-
#enable_bracketed_paste ⇒ Object
Emits the ANSI sequence enabling bracketed-paste mode.
-
#enable_focus_reporting ⇒ Object
Emits the ANSI sequence enabling terminal focus reporting.
-
#enable_mouse_tracking(motion: :drag) ⇒ Object
Emits the ANSI sequences that enable terminal mouse reporting (press, motion, SGR).
-
#enter_alt_screen ⇒ Object
Enters the alternate screen buffer.
-
#hide_cursor ⇒ Object
Hides the terminal cursor.
-
#initialize(input: $stdin, output: $stdout, reader: nil, cursor: TTY::Cursor) ⇒ TTYBackend
constructor
input and output default to
$stdin/$stdoutfor normal terminal use; tests can inject IO objects. -
#input_pending? ⇒ Boolean
True when stdin has bytes ready to read right now — a genuine nonblocking check (0s wait), unlike read_event whose tty-reader nonblock path waits up to 0.1s.
-
#install_focus_handler ⇒ Object
Installs a SIGINFO handler that marks the terminal as having received focus.
-
#install_resize_handler ⇒ Object
Installs a SIGWINCH handler that sets the internal
@resizedflag, returning the previous handler so it can be restored on teardown. -
#leave_alt_screen ⇒ Object
Leaves the alternate screen buffer.
-
#mouse_enabled? ⇒ Boolean
Returns whether mouse tracking is currently enabled on this backend.
-
#move_cursor(row, column) ⇒ Object
Moves the terminal cursor to the given 1-based (row, column).
-
#notify_resize ⇒ Object
Manually flags the backend as resized (used by tests or external integrations).
-
#query_background_color(timeout: 0.15) ⇒ Object
Queries the terminal's background color via OSC 11 and classifies the reply as :dark or :light.
-
#read_event(timeout: nil) ⇒ Object
Reads the next event.
-
#restore_focus_handler ⇒ Object
Restores the previous SIGINFO handler.
-
#restore_resize_handler ⇒ Object
Restores the previous SIGWINCH handler captured by
install_resize_handler. -
#resume ⇒ Object
Re-enters the TUI after a resume (SIGCONT): raw/no-echo input, alt screen, hidden cursor, and the reporting modes that were active before the suspend.
-
#show_cursor ⇒ Object
Shows the terminal cursor.
-
#size ⇒ Object
Returns the current terminal dimensions as [width, height] via TTY::Screen.
-
#suspend ⇒ Object
Returns the terminal to its normal state for a shell suspend (Ctrl+Z): reporting modes off, cursor visible, primary screen, cooked input.
-
#with_raw_input ⇒ Object
Keeps terminal input in raw/no-echo mode for the duration of a TUI run.
-
#write_escape(sequence) ⇒ Object
Writes an out-of-band escape sequence (image transmission, clipboard, notification, title) raw — no cursor positioning, line-clearing, or auto-wrap handling that would corrupt it.
-
#write_frame(frame) ⇒ Object
Writes a full multi-line frame to the terminal, disabling auto-wrap during the write so overlong lines don't disturb the screen layout.
-
#write_lines(line_changes) ⇒ Object
Writes a partial frame composed of [row, line] tuples (1-based rows).
Constructor Details
#initialize(input: $stdin, output: $stdout, reader: nil, cursor: TTY::Cursor) ⇒ TTYBackend
input and output default to $stdin/$stdout for normal terminal use;
tests can inject IO objects. reader is a TTY::Reader instance (created from
input/output when nil). cursor is the TTY::Cursor class used for cursor control.
41 42 43 44 45 46 47 48 49 50 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 41 def initialize(input: $stdin, output: $stdout, reader: nil, cursor: TTY::Cursor) @input = input @output = output @reader = reader || TTY::Reader.new(input: input, output: output) @cursor = cursor @key_normalizer = KeyNormalizer.new(@reader) @resized = false @previous_winch_handler = nil @mouse_enabled = false end |
Instance Method Details
#clear ⇒ Object
Clears the terminal screen and moves the cursor to (1, 1).
268 269 270 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 268 def clear write_control(@cursor.clear_screen) end |
#disable_bracketed_paste ⇒ Object
Emits the ANSI sequence disabling bracketed-paste mode. Idempotent.
103 104 105 106 107 108 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 103 def disable_bracketed_paste return unless @bracketed_paste write_control(BRACKETED_PASTE_OFF) @bracketed_paste = false end |
#disable_focus_reporting ⇒ Object
Emits the ANSI sequence disabling terminal focus reporting. Idempotent.
87 88 89 90 91 92 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 87 def disable_focus_reporting return unless @focus_reporting write_control(FOCUS_REPORTING_OFF) @focus_reporting = false end |
#disable_mouse_tracking ⇒ Object
Emits the ANSI sequences that disable terminal mouse reporting. Idempotent.
206 207 208 209 210 211 212 213 214 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 206 def disable_mouse_tracking return unless @mouse_enabled write_control("\e[?1000l") write_control("\e[?1002l") write_control("\e[?1003l") if @mouse_motion == :all write_control("\e[?1006l") @mouse_enabled = false end |
#enable_bracketed_paste ⇒ Object
Emits the ANSI sequence enabling bracketed-paste mode. Idempotent.
95 96 97 98 99 100 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 95 def enable_bracketed_paste return if @bracketed_paste write_control(BRACKETED_PASTE_ON) @bracketed_paste = true end |
#enable_focus_reporting ⇒ Object
Emits the ANSI sequence enabling terminal focus reporting. Idempotent.
79 80 81 82 83 84 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 79 def enable_focus_reporting return if @focus_reporting write_control(FOCUS_REPORTING_ON) @focus_reporting = true end |
#enable_mouse_tracking(motion: :drag) ⇒ Object
Emits the ANSI sequences that enable terminal mouse reporting (press, motion, SGR). Idempotent: skipped when mouse tracking is already enabled.
194 195 196 197 198 199 200 201 202 203 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 194 def enable_mouse_tracking(motion: :drag) return if @mouse_enabled write_control("\e[?1000h") write_control("\e[?1002h") write_control("\e[?1003h") if motion == :all write_control("\e[?1006h") @mouse_enabled = true @mouse_motion = motion end |
#enter_alt_screen ⇒ Object
Enters the alternate screen buffer.
248 249 250 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 248 def enter_alt_screen write_control(ALT_SCREEN_ON) end |
#hide_cursor ⇒ Object
Hides the terminal cursor.
263 264 265 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 263 def hide_cursor write_control(@cursor.hide) end |
#input_pending? ⇒ Boolean
True when stdin has bytes ready to read right now — a genuine nonblocking check (0s wait), unlike read_event whose tty-reader nonblock path waits up to 0.1s. Lets the runtime drain buffered key auto-repeat and stop the instant the buffer empties.
74 75 76 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 74 def input_pending? resized? || !@input.wait_readable(0).nil? end |
#install_focus_handler ⇒ Object
Installs a SIGINFO handler that marks the terminal as having received focus. SIGINFO is sent by some terminals (notably macOS Terminal.app) on focus changes.
174 175 176 177 178 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 174 def install_focus_handler # Terminal focus change: some terminals send a special sequence # when focus changes. We use this to throttle rendering. @previous_focus_handler = Signal.trap("INFO") { @focused = true } end |
#install_resize_handler ⇒ Object
Installs a SIGWINCH handler that sets the internal @resized flag, returning
the previous handler so it can be restored on teardown.
168 169 170 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 168 def install_resize_handler @previous_winch_handler = Signal.trap("WINCH") { @resized = true } end |
#leave_alt_screen ⇒ Object
Leaves the alternate screen buffer.
253 254 255 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 253 def leave_alt_screen write_control(ALT_SCREEN_OFF) end |
#mouse_enabled? ⇒ Boolean
Returns whether mouse tracking is currently enabled on this backend.
217 218 219 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 217 def mouse_enabled? @mouse_enabled end |
#move_cursor(row, column) ⇒ Object
Moves the terminal cursor to the given 1-based (row, column).
273 274 275 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 273 def move_cursor(row, column) write_control(@cursor.move_to(column - 1, row - 1)) end |
#notify_resize ⇒ Object
Manually flags the backend as resized (used by tests or external integrations).
222 223 224 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 222 def notify_resize @resized = true end |
#query_background_color(timeout: 0.15) ⇒ Object
Queries the terminal's background color via OSC 11 and classifies the reply as :dark or :light. Returns nil when the input cannot be polled, the terminal stays silent past timeout, or the reply is unparseable. Call before the event loop starts — a reply arriving later would be read as keyboard input.
145 146 147 148 149 150 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 145 def query_background_color(timeout: 0.15) return nil unless @input.respond_to?(:wait_readable) write_control(BACKGROUND_QUERY) UI::Background.parse_osc11(read_reply(timeout)) end |
#read_event(timeout: nil) ⇒ Object
Reads the next event. If a SIGWINCH was received, returns a ResizeEvent with the current terminal dimensions. Bracketed pastes return a PasteEvent; mouse escape sequences are parsed by MouseParser; other input is normalized via KeyNormalizer. Returns nil on timeout.
56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 56 def read_event(timeout: nil) return resize_event if resized? raw = @reader.read_keypress(echo: false, raw: true, nonblock: timeout) return nil unless raw return Events::FocusEvent.new(focused: true) if raw == FOCUS_IN return Events::FocusEvent.new(focused: false) if raw == FOCUS_OUT return paste_event(raw) if raw.start_with?(PASTE_START) return MouseParser.parse(raw) if MouseParser.sequence?(raw) @key_normalizer.normalize(raw) rescue Errno::EAGAIN, IO::WaitReadable nil end |
#restore_focus_handler ⇒ Object
Restores the previous SIGINFO handler.
181 182 183 184 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 181 def restore_focus_handler Signal.trap("INFO", @previous_focus_handler) if @previous_focus_handler @previous_focus_handler = nil end |
#restore_resize_handler ⇒ Object
Restores the previous SIGWINCH handler captured by install_resize_handler.
187 188 189 190 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 187 def restore_resize_handler Signal.trap("WINCH", @previous_winch_handler) if @previous_winch_handler @previous_winch_handler = nil end |
#resume ⇒ Object
Re-enters the TUI after a resume (SIGCONT): raw/no-echo input, alt screen, hidden cursor, and the reporting modes that were active before the suspend. The caller is responsible for triggering a repaint.
125 126 127 128 129 130 131 132 133 134 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 125 def resume @input.raw! if @input.respond_to?(:raw!) @input.echo = false if @input.respond_to?(:echo=) enter_alt_screen hide_cursor enable_mouse_tracking(motion: @mouse_motion || :drag) if @mouse_was_enabled enable_bracketed_paste enable_focus_reporting clear end |
#show_cursor ⇒ Object
Shows the terminal cursor.
258 259 260 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 258 def show_cursor write_control(@cursor.show) end |
#size ⇒ Object
Returns the current terminal dimensions as [width, height] via TTY::Screen.
278 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 278 def size = [TTY::Screen.width, TTY::Screen.height] |
#suspend ⇒ Object
Returns the terminal to its normal state for a shell suspend (Ctrl+Z): reporting modes off, cursor visible, primary screen, cooked input.
112 113 114 115 116 117 118 119 120 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 112 def suspend @mouse_was_enabled = @mouse_enabled disable_mouse_tracking disable_bracketed_paste disable_focus_reporting show_cursor leave_alt_screen @input.cooked! if @input.respond_to?(:cooked!) end |
#with_raw_input ⇒ Object
Keeps terminal input in raw/no-echo mode for the duration of a TUI run. Reading a single keypress in raw mode is not enough: keys pressed while rendering or dispatching events can otherwise be echoed into the alternate screen before the next read.
155 156 157 158 159 160 161 162 163 164 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 155 def with_raw_input return yield unless @input.respond_to?(:tty?) && @input.tty? return yield unless @input.respond_to?(:raw) && @input.respond_to?(:noecho) @input.raw do @input.noecho do yield end end end |
#write_escape(sequence) ⇒ Object
Writes an out-of-band escape sequence (image transmission, clipboard, notification, title) raw — no cursor positioning, line-clearing, or auto-wrap handling that would corrupt it.
243 244 245 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 243 def write_escape(sequence) write_control(sequence.payload) end |
#write_frame(frame) ⇒ Object
Writes a full multi-line frame to the terminal, disabling auto-wrap during the write so overlong lines don't disturb the screen layout.
228 229 230 231 232 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 228 def write_frame(frame) without_auto_wrap do write_positioned_lines(frame.to_s.lines(chomp: true)) end end |
#write_lines(line_changes) ⇒ Object
Writes a partial frame composed of [row, line] tuples (1-based rows).
235 236 237 238 239 |
# File 'lib/charming/internal/terminal/tty_backend.rb', line 235 def write_lines(line_changes, **) without_auto_wrap do write_control(line_changes.map { |row, line| positioned_line(row, line) }.join) end end |