Class: Charming::Internal::Terminal::TTYBackend

Inherits:
Object
  • Object
show all
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

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

#clearObject

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_pasteObject

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_reportingObject

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_trackingObject

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_pasteObject

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_reportingObject

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_screenObject

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_cursorObject

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.

Returns:

  • (Boolean)


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_handlerObject

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_handlerObject

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_screenObject

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.

Returns:

  • (Boolean)


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_resizeObject

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_handlerObject

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_handlerObject

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

#resumeObject

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_cursorObject

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

#sizeObject

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]

#suspendObject

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_inputObject

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