Class: Tuile::Screen
- Inherits:
-
Object
- Object
- Tuile::Screen
- Defined in:
- lib/tuile/screen.rb,
sig/tuile.rbs
Overview
The process-singleton runtime: one Screen per app, reached through Screen.instance. It owns everything the UI needs to exist — the #event_queue, the UI lock, the invalidation set, the terminal IO, the back #buffer, the #theme/#theme_def, the #focused component, the global-shortcut registry, and the single ScreenPane under which all UI lives. Construct one with Screen.new (or Screen.fake in tests), tear it down with Screen.close.
The component tree
Everything on screen hangs off #pane (a ScreenPane): the tiled #content (set via #content=, filling the whole terminal and laying out its own children), the modal/overlay #popups stack (opened via Component::Overlay#open, drawn on top of the content). Popups are not sized from their content — each carries its own top-down Component::Popup#declared_size — and they deliberately overdraw the content without clipping.
Tuile draws no chrome of its own: there is no status bar and no reserved
row, so #content gets the whole terminal. An app that wants a status line
builds one into its own layout and drives it from #on_focus_changed=
(D_status_bar).
Repaint model
Components never draw to the terminal directly. They call Component#invalidate to mark themselves dirty, and when they do paint they write styled cells into #buffer. Once the event loop drains its queue, #repaint walks the invalidated set in z-order, has each component paint into the buffer, then flushes the buffer's minimal diff (only cells that changed) to the terminal in one synchronized-output batch — which is what keeps repaint flicker-free and coalesces many invalidations into a single frame per tick. See the book (ch. 2) for the why.
Thread-safety
UI-thread-confined, where "the UI thread" changes hands once: it is the
loop's thread while #run_event_loop is in progress, and the thread that
created the screen whenever no loop is running (#state :idle). So an
app builds its tree on its own thread, hands ownership to the loop, and
gets it back for teardown — the loop needn't run on the creating thread.
All UI mutations — #content=, #focused=, #theme=,
Component#invalidate, rect=, … — obey it via #check_locked.
A worker marshals back with screen.event_queue.submit { … }, which runs
the block only while a loop is draining the queue — outside :running it
silently never fires. Terminal resize, key/mouse input and OS color-scheme
flips arrive as events on that same queue.
The singleton slot survives subclassing (FakeScreen < Screen), so
FakeScreen — which captures output in memory — is what
Screen.instance returns under test.
Direct Known Subclasses
Constant Summary collapse
- EDITING_KEYS =
Keys #register_global_shortcut refuses because every editable widget needs them: the registry sits above the component tree, so binding one app-wide would silently break text entry everywhere — a Component::TextArea's newline, a caret move, a deletion.
ENTERis the trap worth naming: it is unprintable, so nothing else stops it, and "bind Enter to submit" is the obvious wrong way to build a default button. The right way is ahandle_keyon the form itself, where a focused field still gets first refusal — see Tuile::ScreenPane#handle_key.Deliberately not reserved:
HOME/END/PAGE_UP/PAGE_DOWN. They move within a widget rather than mutate its value, and binding them app-wide (scroll the log pane) is a real use case. [ Keys::ENTER, Keys::DELETE, *Keys::BACKSPACES, Keys::UP_ARROW, Keys::DOWN_ARROW, Keys::LEFT_ARROW, Keys::RIGHT_ARROW, Keys::CTRL_LEFT_ARROW, Keys::CTRL_RIGHT_ARROW ].freeze
- @@instance =
Class variable (not class instance var) so the singleton survives subclassing —
FakeScreen < ScreenandScreen.instancesee the same slot. nil
Instance Attribute Summary collapse
-
#background_color ⇒ Color?
readonly
The terminal's own background, as it reported it — for deriving a color from the background rather than picking one against it.
-
#buffer ⇒ Buffer
readonly
@return — the back buffer components paint into (Buffer#set_text / Buffer#fill / Buffer#set_char).
-
#color_depth ⇒ Symbol
readonly
How many colors this terminal can show (ColorDepth::DEPTHS), detected at construction.
-
#color_scheme ⇒ Symbol
readonly
@return —
:lightor:dark. -
#event_queue ⇒ EventQueue
readonly
@return — the event queue.
-
#focused ⇒ Component?
@return — currently focused component.
-
#on_error ⇒ Proc
Handler invoked when a StandardError escapes an event handler inside the event loop (e.g. a Component::TextField's
on_changeraises). -
#on_focus_changed ⇒ Proc?
Called after the focused component changes — including to and from
nil, and including the focus repair that runs when a popup closes. -
#pane ⇒ ScreenPane
readonly
@return — the structural root of the component tree.
-
#size ⇒ Size
readonly
@return — current screen size.
-
#theme ⇒ Theme
The color Theme built-in components read at paint time: the member of #theme_def matching the terminal background detected at construction (see TerminalBackground.detect; inconclusive means dark).
- #theme_def ⇒ ThemeDef
Class Method Summary collapse
- .close ⇒ void
-
.fake ⇒ FakeScreen
Testing only — creates new screen, locks the UI, and prevents any redraws, so that test TTY is not painted over.
-
.instance ⇒ Screen
@return — the singleton instance.
Instance Method Summary collapse
-
#add_popup(window) ⇒ void
Internal — use Component::Overlay#open instead.
-
#beep ⇒ void
Rings the terminal bell (Ansi::BEL) — the signal for a keystroke that went nowhere, e.g.
-
#check_locked ⇒ void
Raises unless the calling thread currently owns the UI (see the class-level threading contract).
-
#clear ⇒ void
Clears the TTY screen.
-
#close ⇒ void
Tears the screen down and vacates the singleton slot, moving #state to the terminal
:closed. -
#content ⇒ Component?
@return — tiled content (forwarded to ScreenPane).
-
#content=(content) ⇒ void
@param
content. -
#cursor_position ⇒ Point?
Returns the absolute screen coordinates where the hardware cursor should sit, or nil if it should be hidden.
-
#cursor_sequence ⇒ String
The escape sequence positioning the hardware cursor for the current focus state: hidden when nothing owns it, else moved to the focused component's Component#cursor_position and shown.
-
#cycle_focus(forward:) ⇒ Boolean
Walks the current modal scope in pre-order, collects tab stops, and advances focus by one (wrapping).
-
#detect_background ⇒ TerminalBackground::Result
The startup background probe, seeding #theme and #background_color.
-
#detect_color_depth ⇒ Symbol
The startup color-depth probe, seeding #color_depth.
-
#emit(str) ⇒ void
Writes an assembled frame (escape string) to the terminal.
- #event_loop ⇒ void
-
#focus_next ⇒ Boolean
Advances focus to the next Component#tab_stop? in tree order, wrapping around.
-
#focus_previous ⇒ Boolean
Mirror of #focus_next that walks backwards through the tab order.
-
#handle_key(key) ⇒ Boolean
A key has been pressed on the keyboard.
-
#handle_mouse(event) ⇒ void
Finds target window and calls Component::Window#handle_mouse.
-
#handle_paste(text) ⇒ Boolean
Delivers pasted text down the focus chain (Tuile::ScreenPane#handle_paste).
-
#has_popup?(window) ⇒ Boolean
Internal — use Component::Overlay#open? instead.
-
#initialize ⇒ Screen
constructor
rubocop:disable Style/ClassVars.
-
#invalidate(component) ⇒ void
Invalidates a component: causes the component to be repainted on next call to #repaint.
- #layout ⇒ void
-
#needs_full_repaint ⇒ void
Invalidates the entire attached tree, forcing every component to repaint on the next cycle.
-
#on_background_color(color) ⇒ void
The re-probe answered: adopt the color and restyle, since an app's background-derived tints are now a scheme behind.
-
#on_color_scheme(scheme) ⇒ void
An OS appearance flip arrived (mode-2031 report): remember the scheme, apply the matching member of #theme_def, and re-probe for the new background RGB, which the report does not carry.
-
#popups ⇒ ::Array[Component::Overlay]
@return — the open overlay stack — both Component::Popup modals and bare Component::Overlays — in stacking order (forwarded to ScreenPane).
-
#print(*args) ⇒ void
Writes terminal-housekeeping escapes straight to stdout: #clear, mouse-tracking start/stop, the color-scheme notify toggles, the OSC 11 background re-probe, cursor-show on teardown.
-
#register_global_shortcut(key, over_popups: false, &block) ⇒ void
Registers an app-level keyboard shortcut: when
keyarrives, the block runs on the event-loop thread (free to mutate UI) before the key reaches any component. -
#remove_popup(window) ⇒ void
Internal — use Component::Overlay#close instead.
-
#repaint ⇒ void
Repaints the screen; tries to be as effective as possible, by only considering invalidated components and flushing just the changed cells of #buffer.
-
#run_event_loop(capture_mouse: true, bracketed_paste: true) ⇒ void
Runs the event loop on the calling thread, taking over stdin (raw mode, echo off): keys and mouse events are dispatched via #handle_key / #handle_mouse, and the loop repaints once per drained tick.
-
#state ⇒ Symbol
:idlecovers both ends of the screen's life — before the first #run_event_loop and after it returns — and a screen may cycle:idle→:running→:idlerepeatedly. -
#unregister_global_shortcut(key) ⇒ void
Removes a shortcut previously installed by #register_global_shortcut.
Constructor Details
#initialize ⇒ Screen
rubocop:disable Style/ClassVars
62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
# File 'lib/tuile/screen.rb', line 62 def initialize @@instance = self # rubocop:disable Style/ClassVars @event_queue = EventQueue.new @size = EventQueue::TTYSizeEvent.create.size @invalidated = Set.new # Components being repainted right now. A component may invalidate its # children during its repaint phase; this prevents double-draw. @repainting = Set.new # The thread that owns the UI whenever no event loop is running — i.e. # during :idle, at both ends of the screen's life. See {#check_locked}. @ui_thread = Thread.current @closed = false background = detect_background @color_scheme = background.scheme @background_color = background.color @color_depth = detect_color_depth @theme_def = ThemeDef.default @theme = @theme_def.for(@color_scheme) # Structural root of the component tree: holds tiled content and the # popup stack. @pane = ScreenPane.new @on_error = ->(e) { raise e } # App-level keyboard shortcuts dispatched by {#handle_key} before keys # reach the pane. See {#register_global_shortcut}. @global_shortcuts = {} # The back buffer components paint into. {#repaint} flushes its diff to # the terminal, so only changed cells are emitted (flicker-free on any # terminal). Sized to the current viewport; {#layout} resizes it. @buffer = Buffer.new(@size, color_depth: @color_depth) end |
Instance Attribute Details
#background_color ⇒ Color? (readonly)
The terminal's own background, as it reported it — for deriving a color from the background rather than picking one against it. A pane tinted a few percent off it sits right on any terminal, where a fixed near-neutral only sits right near the one it was tuned on:
bg = screen.background_color
sidebar.bg_color =
bg ? Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }) : FALLBACK_TINT
Nil is the normal case, not an edge — a terminal answering only
COLORFGBG, or neither probe, reports no RGB at all. Keep a fallback.
Kept current across OS appearance flips, a frame behind: the flip report carries light/dark only, so the screen re-probes and this updates when the reply lands. A changed color then fires Component#on_theme_changed across the tree exactly as a theme swap does — a background-derived tint is a theme-derived color.
218 219 220 |
# File 'lib/tuile/screen.rb', line 218 def background_color @background_color end |
#buffer ⇒ Buffer (readonly)
@return — the back buffer components paint into (Buffer#set_text / Buffer#fill / Buffer#set_char).
137 138 139 |
# File 'lib/tuile/screen.rb', line 137 def buffer @buffer end |
#color_depth ⇒ Symbol (readonly)
How many colors this terminal can show (ColorDepth::DEPTHS), detected at construction. Buffer#flush degrades every color it emits to this, so an app may compute an RGB tint — say from #background_color — and paint with it, whatever the terminal turns out to understand.
Deliberately read-only: detection runs once and the answer can't change mid-session. Override a terminal that reports itself wrong through ColorDepth::OVERRIDE_ENV instead.
133 134 135 |
# File 'lib/tuile/screen.rb', line 133 def color_depth @color_depth end |
#color_scheme ⇒ Symbol (readonly)
@return — :light or :dark
122 123 124 |
# File 'lib/tuile/screen.rb', line 122 def color_scheme @color_scheme end |
#event_queue ⇒ EventQueue (readonly)
@return — the event queue.
260 261 262 |
# File 'lib/tuile/screen.rb', line 260 def event_queue @event_queue end |
#focused ⇒ Component?
@return — currently focused component.
317 318 319 |
# File 'lib/tuile/screen.rb', line 317 def focused @focused end |
#on_error ⇒ Proc
Handler invoked when a StandardError escapes an event handler inside
the event loop (e.g. a Component::TextField's on_change raises).
The default re-raises, so the exception propagates out of #run_event_loop and crashes the script with a stacktrace — unhandled exceptions are bugs and should be surfaced loudly.
Replace it when the host has somewhere visible to put errors, e.g. a Component::LogWindow wired to Tuile.logger:
screen.on_error = lambda do |e|
Tuile.logger.error("#{e.class}: #{e.message}\n#{e.backtrace&.join("\n")}")
end
The handler runs on the event-loop thread with the UI lock held. Returning normally keeps the loop alive; raising from within the handler tears the loop down and propagates out of #run_event_loop.
@return — one-arg callable receiving the StandardError instance.
157 158 159 |
# File 'lib/tuile/screen.rb', line 157 def on_error @on_error end |
#on_focus_changed ⇒ Proc?
Called after the focused component changes — including to and from
nil, and including the focus repair that runs when a popup closes.
Takes no arguments; read #focused (and walk its parent chain) for the
new state.
This is the hook an app drives its own status line from. Tuile owns no
status bar and reserves no row: build a Component::Label into your own
layout and fill it here (D_status_bar).
screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
Edge-triggered, like Component#on_attached: re-assigning the
component that already has focus fires nothing, so a callback can be as
expensive as rebuilding a hint string without a did it really change?
guard of its own. That matters more than it looks — ScreenPane#content=
clears focus on every content swap, which on a level-triggered hook would
fire a nil→nil notification during assembly.
It runs after the active-flag cascade and on_focus, so the tree is
settled. Two things a callback must tolerate: #focused being nil, and
firing during #close — teardown clears focus, exactly as it fires
Component#on_detached. A raising callback propagates out of #focused=
and leaves focus assigned; keep it trivial, as with the attach hooks.
373 374 375 |
# File 'lib/tuile/screen.rb', line 373 def on_focus_changed @on_focus_changed end |
#pane ⇒ ScreenPane (readonly)
@return — the structural root of the component tree.
119 120 121 |
# File 'lib/tuile/screen.rb', line 119 def pane @pane end |
#size ⇒ Size (readonly)
@return — current screen size.
180 181 182 |
# File 'lib/tuile/screen.rb', line 180 def size @size end |
#theme ⇒ Theme
The color Theme built-in components read at paint time: the member of #theme_def matching the terminal background detected at construction (see TerminalBackground.detect; inconclusive means dark). While the event loop runs, terminals supporting mode 2031 push OS appearance changes (EventQueue::ColorSchemeEvent) and the screen re-picks from #theme_def.
189 190 191 |
# File 'lib/tuile/screen.rb', line 189 def theme @theme end |
#theme_def ⇒ ThemeDef
The app's ThemeDef — the dark/light Theme pair the screen picks #theme from, at startup and on every OS appearance flip. Starts as ThemeDef.default (ThemeDef::DEFAULT unless reassigned — tests do, see ThemeDef.default=). Assigning a custom definition is the durable way to theme an app: unlike a bare #theme=, it survives the user toggling the OS appearance.
198 199 200 |
# File 'lib/tuile/screen.rb', line 198 def theme_def @theme_def end |
Class Method Details
.close ⇒ void
This method returns an undefined value.
581 582 583 |
# File 'lib/tuile/screen.rb', line 581 def self.close @@instance&.close end |
.fake ⇒ FakeScreen
Testing only — creates new screen, locks the UI, and prevents any redraws, so that test TTY is not painted over. FakeScreen#initialize self-installs as the singleton, so subsequent instance calls return the same object.
551 |
# File 'lib/tuile/screen.rb', line 551 def self.fake = FakeScreen.new |
Instance Method Details
#add_popup(window) ⇒ void
This method returns an undefined value.
Internal — use Component::Overlay#open instead. Adds the overlay to #pane; a Component::Popup is additionally centered and focused.
@param window — any overlay, modal or not.
380 381 382 383 384 385 |
# File 'lib/tuile/screen.rb', line 380 def add_popup(window) check_locked @pane.add_popup(window) # No need to fully repaint the scene: a popup simply paints over the # current screen contents. end |
#beep ⇒ void
This method returns an undefined value.
Rings the terminal bell (Ansi::BEL) — the signal for a keystroke that went nowhere, e.g. a letter matching no menu mnemonic while a menu is open.
return true if activate_mnemonic(key)
screen.beep # no match: the key is swallowed, say so
true
Writes immediately rather than riding the next frame: a beep is not part of a frame, and the keystrokes worth beeping at are precisely the ones that invalidate nothing, so #repaint may never emit at all. Whether the user hears anything is the terminal's setting to make, so there is no Tuile-level enable/disable knob.
617 618 619 620 |
# File 'lib/tuile/screen.rb', line 617 def beep check_locked print(Ansi::BEL) end |
#check_locked ⇒ void
This method returns an undefined value.
Raises unless the calling thread currently owns the UI (see the class-level threading contract).
screen.check_locked # from a worker: raises; wrap the work in
# screen.event_queue.submit { ... } instead
282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 |
# File 'lib/tuile/screen.rb', line 282 def check_locked raise Tuile::Error, "Screen is closed: no UI mutation is possible after Screen#close" if @closed return if @event_queue.running? ? @event_queue.on_loop_thread? : Thread.current.equal?(@ui_thread) # `submit` is the wrong remedy with no loop running — nothing would drain # the queue, so the block silently never fires. = if @event_queue.running? "UI lock not held: UI mutations must run on the event-loop thread; " \ "marshal via screen.event_queue.submit { ... }" else "UI not owned by #{Thread.current}: no event loop is running, so UI mutations must " \ "come from #{@ui_thread}, the thread that created this screen " \ "(or start the event loop first)" end raise Tuile::Error, end |
#clear ⇒ void
This method returns an undefined value.
Clears the TTY screen.
301 302 303 |
# File 'lib/tuile/screen.rb', line 301 def clear print TTY::Cursor.move_to(0, 0), TTY::Cursor.clear_screen end |
#close ⇒ void
This method returns an undefined value.
Tears the screen down and vacates the singleton slot, moving #state to
the terminal :closed. Unmounts the tree first, so every component gets
its Component#on_detached. Idempotent.
561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 |
# File 'lib/tuile/screen.rb', line 561 def close return if @closed raise Tuile::Error, "Screen is running: stop the event loop before closing" if state == :running check_locked begin @pane.detach_all ensure # A raising on_detached propagates — it's a bug to fix, not something the # framework guards — but teardown still has to finish, or one such bug # leaves a half-closed screen behind and every later example fails with it. clear @pane = nil @closed = true @@instance = nil # rubocop:disable Style/ClassVars end end |
#content ⇒ Component?
@return — tiled content (forwarded to Tuile::ScreenPane).
167 |
# File 'lib/tuile/screen.rb', line 167 def content = @pane.content |
#content=(content) ⇒ void
This method returns an undefined value.
@param content
171 172 173 174 175 176 177 |
# File 'lib/tuile/screen.rb', line 171 def content=(content) # Not left to ScreenPane#content='s own checks: after #close there's no # pane to forward to, and NoMethodError-for-nil is a poor error. check_locked @pane.content = content layout end |
#cursor_position ⇒ Point?
Returns the absolute screen coordinates where the hardware cursor should sit, or nil if it should be hidden. Only the #focused component owns the cursor: there can be multiple active components (the focus path), but only one focused.
705 |
# File 'lib/tuile/screen.rb', line 705 def cursor_position = @focused&.cursor_position |
#cursor_sequence ⇒ String
The escape sequence positioning the hardware cursor for the current focus state: hidden when nothing owns it, else moved to the focused component's Component#cursor_position and shown. Appended to each frame's flush.
790 791 792 793 |
# File 'lib/tuile/screen.rb', line 790 def cursor_sequence pos = cursor_position pos.nil? ? TTY::Cursor.hide : "#{TTY::Cursor.move_to(pos.x, pos.y)}#{TTY::Cursor.show}" end |
#cycle_focus(forward:) ⇒ Boolean
Walks the current modal scope in pre-order, collects tab stops, and advances focus by one (wrapping). When the focused component isn't in the tab order (e.g. focus is parked on a popup/window chrome with no interactable widgets), Tab goes to the first stop and Shift+Tab to the last.
@param forward
@return — true if focus moved.
765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 |
# File 'lib/tuile/screen.rb', line 765 def cycle_focus(forward:) check_locked scope = @pane.modal_popup || @pane.content return false if scope.nil? stops = [] scope.on_tree { |c| stops << c if c.tab_stop? } return false if stops.empty? idx = @focused.nil? ? nil : stops.index(@focused) target = if idx.nil? forward ? stops.first : stops.last else stops[(idx + (forward ? 1 : -1)) % stops.size] end return false if target.equal?(@focused) self.focused = target true end |
#detect_background ⇒ TerminalBackground::Result
The startup background probe, seeding #theme and
#background_color. Inconclusive detection means :dark with no
color. Runs in the constructor — the OSC 11 reply arrives on stdin,
which is only safe to read before EventQueue#start_key_thread owns
it. FakeScreen overrides this to pin the result, keeping specs
deterministic and off the test runner's TTY.
716 717 718 |
# File 'lib/tuile/screen.rb', line 716 def detect_background TerminalBackground.detect || TerminalBackground::Result.new(scheme: :dark, color: nil) end |
#detect_color_depth ⇒ Symbol
The startup color-depth probe, seeding #color_depth. Reads the
environment only, so unlike #detect_background it has no timing
constraint. FakeScreen overrides it to pin the result, keeping specs
off whatever COLORTERM the test runner happens to carry.
725 |
# File 'lib/tuile/screen.rb', line 725 def detect_color_depth = ColorDepth.detect |
#emit(str) ⇒ void
This method returns an undefined value.
Writes an assembled frame (escape string) to the terminal. The single sink for repaint output; FakeScreen overrides it to capture instead.
@param str
799 800 801 802 |
# File 'lib/tuile/screen.rb', line 799 def emit(str) $stdout.write(str) $stdout.flush end |
#event_loop ⇒ void
This method returns an undefined value.
869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 |
# File 'lib/tuile/screen.rb', line 869 def event_loop @event_queue.run_loop do |event| case event when EventQueue::KeyEvent key = event.key handled = handle_key(key) @event_queue.stop if !handled && ["q", Keys::ESC].include?(key) when EventQueue::PasteEvent handle_paste(event.text) when MouseEvent handle_mouse(event) when EventQueue::TTYSizeEvent @size = event.size layout when EventQueue::ColorSchemeEvent on_color_scheme(event.scheme) when EventQueue::BackgroundColorEvent on_background_color(event.color) when EventQueue::EmptyQueueEvent repaint when Proc event.call end rescue StandardError => e @on_error.call(e) end end |
#focus_next ⇒ Boolean
Advances focus to the next Component#tab_stop? in tree order, wrapping around. Scope is the topmost popup if one is open, otherwise #content — this keeps Tab confined inside a modal popup. No-op (returns false) if the modal scope has no tab stops or no content at all.
@return — true if focus moved.
442 |
# File 'lib/tuile/screen.rb', line 442 def focus_next = cycle_focus(forward: true) |
#focus_previous ⇒ Boolean
Mirror of #focus_next that walks backwards through the tab order.
@return — true if focus moved.
446 |
# File 'lib/tuile/screen.rb', line 446 def focus_previous = cycle_focus(forward: false) |
#handle_key(key) ⇒ Boolean
A key has been pressed on the keyboard. Handle it, or forward to active window.
Dispatch order:
1. Tab / Shift+Tab — reserved focus navigation, intercepted before
anything else so a focused {Component::TextField} (which swallows
printable keys) can't trap them.
2. App-level shortcuts from {#register_global_shortcut}. An entry
registered with `over_popups: true` always fires; one with the
default `over_popups: false` fires only when no modal popup is open
(otherwise the modal popup receives the key normally). A non-modal
overlay doesn't suppress global shortcuts.
3. {ScreenPane#handle_key} — delivery to {#focused}, bubbling up the
focus chain to the scope root.
@param key
@return — true if the key was handled by some window.
833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 |
# File 'lib/tuile/screen.rb', line 833 def handle_key(key) case key when Keys::TAB focus_next true when Keys::SHIFT_TAB focus_previous true else shortcut = @global_shortcuts[key] if !shortcut.nil? && (shortcut.over_popups || @pane.modal_popup.nil?) shortcut.block.call true else @pane.handle_key(key) end end end |
#handle_mouse(event) ⇒ void
This method returns an undefined value.
Finds target window and calls Component::Window#handle_mouse.
@param event
855 |
# File 'lib/tuile/screen.rb', line 855 def handle_mouse(event) = @pane.handle_mouse(event) |
#handle_paste(text) ⇒ Boolean
Delivers pasted text down the focus chain (Tuile::ScreenPane#handle_paste).
Deliberately not the key ladder: a paste is not a keystroke, so it skips Tab traversal and the global-shortcut registry entirely and goes straight to delivery. Unhandled text is dropped — there is no fallback that replays it as keys, which would put back the very ambiguity mode 2004 exists to remove.
@param text
@return — true if some component consumed it.
866 |
# File 'lib/tuile/screen.rb', line 866 def handle_paste(text) = @pane.handle_paste(text) |
#has_popup?(window) ⇒ Boolean
Internal — use Component::Overlay#open? instead.
@param window — any overlay, modal or not.
@return — true if this overlay is currently mounted.
541 542 543 544 |
# File 'lib/tuile/screen.rb', line 541 def has_popup?(window) # rubocop:disable Naming/PredicatePrefix check_locked @pane.has_popup?(window) end |
#invalidate(component) ⇒ void
This method returns an undefined value.
Invalidates a component: causes the component to be repainted on next call to #repaint.
@param component
309 310 311 312 313 314 |
# File 'lib/tuile/screen.rb', line 309 def invalidate(component) check_locked raise TypeError, "expected Component, got #{component.inspect}" unless component.is_a? Component @invalidated << component unless @repainting.include? component end |
#layout ⇒ void
This method returns an undefined value.
Resizes #buffer and #pane to the current #size, invalidates the whole tree and repaints. Run whenever the terminal size changes (the EventQueue::TTYSizeEvent path) and once at startup via the first #content=.
809 810 811 812 813 814 815 |
# File 'lib/tuile/screen.rb', line 809 def layout check_locked @buffer.resize(size) unless @buffer.size == size needs_full_repaint @pane.rect = Rect.new(0, 0, size.width, size.height) repaint end |
#needs_full_repaint ⇒ void
This method returns an undefined value.
Invalidates the entire attached tree, forcing every component to repaint on the next cycle. Needed whenever something overdraws the scene without clipping and then exposes what was underneath — a closing popup (#remove_popup), or a popup that shrinks or moves so its new #rect no longer covers the cells it previously painted (Component::Overlay#rect=). The popup-only fast path in #repaint can't clear those vacated cells on its own, so we accept the cost of a full repaint.
533 534 535 |
# File 'lib/tuile/screen.rb', line 533 def needs_full_repaint @pane&.on_tree { invalidate _1 } end |
#on_background_color(color) ⇒ void
This method returns an undefined value.
The re-probe answered: adopt the color and restyle, since an app's background-derived tints are now a scheme behind. Deliberately keeps the previous color until the reply lands rather than blanking it on the flip — a terminal that reports mode-2031 flips but not OSC 11 would otherwise lose the color it gave us at startup, permanently.
@param color
750 751 752 753 754 755 756 |
# File 'lib/tuile/screen.rb', line 750 def on_background_color(color) return if @background_color == color @background_color = color @pane&.on_tree { _1.__send__(:on_theme_changed) } needs_full_repaint end |
#on_color_scheme(scheme) ⇒ void
This method returns an undefined value.
An OS appearance flip arrived (mode-2031 report): remember the scheme, apply the matching member of #theme_def, and re-probe for the new background RGB, which the report does not carry.
The query goes out from this thread — the event-loop thread, which also owns #emit — so its bytes can never land inside a frame's synchronized-output batch. The reply comes back through the key thread as an EventQueue::BackgroundColorEvent.
@param scheme — :dark or :light.
737 738 739 740 741 |
# File 'lib/tuile/screen.rb', line 737 def on_color_scheme(scheme) @color_scheme = scheme self.theme = @theme_def.for(@color_scheme) print TerminalBackground::QUERY end |
#popups ⇒ ::Array[Component::Overlay]
@return — the open overlay stack — both Component::Popup modals and bare Component::Overlays — in stacking order (forwarded to Tuile::ScreenPane). The array must not be modified!
257 |
# File 'lib/tuile/screen.rb', line 257 def popups = @pane.popups |
#print(*args) ⇒ void
This method returns an undefined value.
Writes terminal-housekeeping escapes straight to stdout: #clear,
mouse-tracking start/stop, the color-scheme notify toggles, the OSC 11
background re-probe, cursor-show on teardown. Component painting does
not go through here anymore — it writes into #buffer, which
#repaint diffs and #emits. FakeScreen overrides this (and
#emit) to capture into @prints instead of the test runner's stdout.
Flushed, like #emit: none of these escapes ends in a newline, and a buffered stdout would hold a query — one whose reply the app is waiting on — until the next frame went out.
@param args — stuff to print.
597 598 599 600 |
# File 'lib/tuile/screen.rb', line 597 def print(*args) Kernel.print(*args) $stdout.flush end |
#register_global_shortcut(key, over_popups: false, &block) ⇒ void
This method returns an undefined value.
Registers an app-level keyboard shortcut: when key arrives, the block
runs on the event-loop thread (free to mutate UI) before the key reaches
any component. Re-registering a key replaces its binding.
This registry is the only keyboard mechanism above the component tree, and nothing suppresses it — so it accepts only keys no widget can need. Three groups raise at registration rather than misbehaving at runtime:
-
Printable keys — they'd hijack typing into a Component::TextField. A scope-wide one-key binding belongs on the scope root's own
handle_key, where a focused field consumes it first (see Tuile::ScreenPane#handle_key). -
TAB / SHIFT_TAB — #handle_key intercepts them for focus navigation before the registry is consulted, so a binding would never fire.
-
EDITING_KEYS —
ENTER,BACKSPACE,DELETEand the arrows, which every editable widget needs.screen.register_global_shortcut(Keys::CTRL_L, over_popups: true) do log_popup.open end
@param key — unprintable key (e.g. Keys::CTRL_L, Keys::ESC).
@param over_popups — when true, fires even while a modal popup is open (pre-empting the popup); when false (default), suppressed while any popup is open so the popup gets the key.
476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 |
# File 'lib/tuile/screen.rb', line 476 def register_global_shortcut(key, over_popups: false, &block) raise ArgumentError, "block required" if block.nil? raise ArgumentError, "key must be a String, got #{key.inspect}" unless key.is_a?(String) raise ArgumentError, "key cannot be empty" if key.empty? if Keys.printable?(key) raise ArgumentError, "global shortcut key must be unprintable; got #{key.inspect}. " \ "For a one-key binding, override handle_key on the scope root " \ "(your content layout, or the popup) — a focused text field then " \ "consumes the key first, so typing isn't hijacked." end if [Keys::TAB, Keys::SHIFT_TAB].include?(key) raise ArgumentError, "#{key == Keys::TAB ? "TAB" : "SHIFT_TAB"} is reserved for focus navigation" end if EDITING_KEYS.include?(key) raise ArgumentError, "#{key.inspect} is reserved: every editable widget needs it, and this registry " \ "sits above the component tree with nothing to suppress it. For a default " \ "button, handle ENTER in the form's own handle_key instead — a focused " \ "TextArea/TextField gets first refusal there." end @global_shortcuts[key] = Shortcut.new(block: block, over_popups: over_popups) end |
#remove_popup(window) ⇒ void
This method returns an undefined value.
Internal — use Component::Overlay#close instead. Removes the overlay from #pane, repairs focus, and repaints the scene.
Does nothing if the overlay is not open on this screen.
@param window — any overlay, modal or not.
516 517 518 519 520 521 522 |
# File 'lib/tuile/screen.rb', line 516 def remove_popup(window) check_locked return unless @pane.has_popup?(window) @pane.remove_popup(window) needs_full_repaint end |
#repaint ⇒ void
This method returns an undefined value.
Repaints the screen; tries to be as effective as possible, by only considering invalidated components and flushing just the changed cells of #buffer. Called once per event-loop tick (on EventQueue::EmptyQueueEvent); components should Component#invalidate and let the loop coalesce rather than call this directly.
628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 |
# File 'lib/tuile/screen.rb', line 628 def repaint check_locked # This simple TUI framework doesn't support window clipping since tiled # windows are not expected to overlap. If there rarely is a popup, we # just repaint all windows in correct order — sure they will paint over # other windows, but if this is done in the right order, the final # drawing will look okay. Not the most effective algorithm, but very # simple and very fast in common cases. did_paint = false until @invalidated.empty? # Defensive filter: a component can become detached between enqueue # and drain (popup close, sibling removed mid-event-handling, focus # repair). Detached components have no place on the screen and must # never paint, even though Component#invalidate already gates them # out — this catches the case where attachment changed since. @invalidated.delete_if { |c| !c.attached? } break if @invalidated.empty? did_paint = true popups = @pane.popups # Build the repaint list in z-order, leaning on the tree itself rather # than a depth sort. The pane's pre-order traversal already orders the # tiled layer (the content subtree) parent-before-child; the popups are # the top layer and must paint last, so we collect the tiled layer first # and append popups rather than taking a single pane.on_tree walk. popup_members = Set.new popups.each { |p| p.on_tree { popup_members << _1 } } # Tiled layer: invalidated non-popup components, in tree order. repaint = [] tiled_invalidated = false @pane.on_tree do |c| next if popup_members.include?(c) next unless @invalidated.include?(c) repaint << c tiled_invalidated = true end # Popups on top: a layer repaints whole whenever anything *beneath* it — # the tiled tree, or a lower popup — repaints, else just its invalidated # members. Layer-by-layer rather than one tiled_invalidated bool because # the drain loops: a lower popup's repaint cascade re-invalidates # children into the *next* iteration (its gap-clearing Layout wipes and # re-queues a button, say), and that iteration has no tiled repaint — # only the full re-assert of every layer above keeps the stacking order # true across iterations (screen_spec pins it with two overlapping # popups). Overdraw into the buffer is free (only net-visible cell # changes reach the terminal), so reasserting layers is cheap. below_repainted = tiled_invalidated popups.each do |p| layer_invalidated = false p.on_tree { |c| layer_invalidated ||= @invalidated.include?(c) } p.on_tree { |c| repaint << c if below_repainted || @invalidated.include?(c) } below_repainted ||= layer_invalidated end @repainting = repaint.to_set @invalidated.clear repaint.each(&:repaint) @repainting.clear end return unless did_paint # Flush only the changed cells, then reposition the cursor — all inside # one synchronized-output batch so the terminal composites it atomically. emit("#{Ansi::SYNC_BEGIN}#{@buffer.flush}#{cursor_sequence}#{Ansi::SYNC_END}") end |
#run_event_loop(capture_mouse: true, bracketed_paste: true) ⇒ void
This method returns an undefined value.
Runs the event loop on the calling thread, taking over stdin (raw mode,
echo off): keys and mouse events are dispatched via #handle_key /
#handle_mouse, and the loop repaints once per drained tick. Returns
when q or ESC is pressed unhandled. Restores terminal state on exit.
For the duration this thread owns the UI (#state is :running);
ownership reverts to the creating thread once it returns.
@param capture_mouse — when true (default), enables xterm mouse tracking so clicks and scroll wheel arrive as MouseEvents and feed Component#handle_mouse. When false, no tracking escape sequence is written: the terminal keeps its native click handling, which is what you want if the app benefits more from select-to-copy than from click-to-focus. Components' handle_mouse is simply never invoked from the loop in that mode (the terminal stops sending the bytes).
@param bracketed_paste — when true (default), enables DEC private mode 2004 so pasted text arrives whole, as Component#handle_paste, instead of as one keystroke per character — which is the only way a pasted line break can be told from a typed Enter. When false, a paste streams in as keys again and a component that gives ENTER a meaning fires it once per pasted line. Turn it off only for a terminal that mishandles the mode.
411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 |
# File 'lib/tuile/screen.rb', line 411 def run_event_loop(capture_mouse: true, bracketed_paste: true) raise Tuile::Error, "Screen is closed: cannot run the event loop" if @closed # The guard above stays outside the begin: teardown for a setup that never # happened restores echo on a non-TTY stdin, and the ENOTTY masks the # real error. begin $stdin.echo = false print MouseEvent.start_tracking if capture_mouse print Keys::BRACKETED_PASTE_ON if bracketed_paste # Follow OS light/dark flips live: terminals supporting mode 2031 # push color-scheme reports that the key thread turns into # {EventQueue::ColorSchemeEvent}s. print TerminalBackground::NOTIFY_ON $stdin.raw do event_loop end ensure print TerminalBackground::NOTIFY_OFF print Keys::BRACKETED_PASTE_OFF if bracketed_paste print MouseEvent.stop_tracking if capture_mouse print TTY::Cursor.show $stdin.echo = true end end |
#state ⇒ Symbol
:idle covers both ends of the screen's life — before the first
#run_event_loop and after it returns — and a screen may cycle
:idle → :running → :idle repeatedly. :closed is terminal.
@return — :idle (no event loop running), :running (a
#run_event_loop is in progress) or :closed (after #close).
267 268 269 270 271 |
# File 'lib/tuile/screen.rb', line 267 def state return :closed if @closed @event_queue.running? ? :running : :idle end |
#unregister_global_shortcut(key) ⇒ void
This method returns an undefined value.
Removes a shortcut previously installed by #register_global_shortcut.
No-op if key was not registered.
@param key
505 506 507 |
# File 'lib/tuile/screen.rb', line 505 def unregister_global_shortcut(key) @global_shortcuts.delete(key) end |