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::Popup#open, drawn on top of the content). Popups are not sized from their content — each carries its own top-down Component::Popup#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
-
#buffer ⇒ Buffer
readonly
@return — the back buffer components paint into (Buffer#set_text / Buffer#fill / Buffer#set_char).
-
#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::Popup#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_scheme ⇒ Symbol
Startup color scheme:
:lightwhen TerminalBackground.detect reports a light terminal background,:darkotherwise (including when detection is inconclusive). -
#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::Popup#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_color_scheme(scheme) ⇒ void
An OS appearance flip arrived (mode-2031 report): remember the scheme and apply the matching member of #theme_def.
-
#popups ⇒ ::Array[Component]
@return — currently active popup components (forwarded to ScreenPane).
-
#print(*args) ⇒ void
Writes terminal-housekeeping escapes straight to stdout: #clear, mouse-tracking start/stop, the color-scheme notify toggles, 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::Popup#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 |
# 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 @color_scheme = detect_scheme @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) end |
Instance Attribute Details
#buffer ⇒ Buffer (readonly)
@return — the back buffer components paint into (Buffer#set_text / Buffer#fill / Buffer#set_char).
123 124 125 |
# File 'lib/tuile/screen.rb', line 123 def buffer @buffer end |
#color_scheme ⇒ Symbol (readonly)
@return — :light or :dark
119 120 121 |
# File 'lib/tuile/screen.rb', line 119 def color_scheme @color_scheme end |
#event_queue ⇒ EventQueue (readonly)
@return — the event queue.
223 224 225 |
# File 'lib/tuile/screen.rb', line 223 def event_queue @event_queue end |
#focused ⇒ Component?
@return — currently focused component.
280 281 282 |
# File 'lib/tuile/screen.rb', line 280 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.
143 144 145 |
# File 'lib/tuile/screen.rb', line 143 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.
336 337 338 |
# File 'lib/tuile/screen.rb', line 336 def on_focus_changed @on_focus_changed end |
#pane ⇒ ScreenPane (readonly)
@return — the structural root of the component tree.
116 117 118 |
# File 'lib/tuile/screen.rb', line 116 def pane @pane end |
#size ⇒ Size (readonly)
@return — current screen size.
166 167 168 |
# File 'lib/tuile/screen.rb', line 166 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.
175 176 177 |
# File 'lib/tuile/screen.rb', line 175 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.
184 185 186 |
# File 'lib/tuile/screen.rb', line 184 def theme_def @theme_def end |
Class Method Details
.close ⇒ void
This method returns an undefined value.
544 545 546 |
# File 'lib/tuile/screen.rb', line 544 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.
514 |
# File 'lib/tuile/screen.rb', line 514 def self.fake = FakeScreen.new |
Instance Method Details
#add_popup(window) ⇒ void
This method returns an undefined value.
Internal — use Component::Popup#open instead. Adds the popup to #pane, centers and focuses it.
@param window
343 344 345 346 347 348 |
# File 'lib/tuile/screen.rb', line 343 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.
575 576 577 578 |
# File 'lib/tuile/screen.rb', line 575 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
245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 |
# File 'lib/tuile/screen.rb', line 245 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.
264 265 266 |
# File 'lib/tuile/screen.rb', line 264 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.
524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 |
# File 'lib/tuile/screen.rb', line 524 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).
153 |
# File 'lib/tuile/screen.rb', line 153 def content = @pane.content |
#content=(content) ⇒ void
This method returns an undefined value.
@param content
157 158 159 160 161 162 163 |
# File 'lib/tuile/screen.rb', line 157 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.
653 |
# File 'lib/tuile/screen.rb', line 653 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.
710 711 712 713 |
# File 'lib/tuile/screen.rb', line 710 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.
685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 |
# File 'lib/tuile/screen.rb', line 685 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_scheme ⇒ Symbol
Startup color scheme: :light when TerminalBackground.detect
reports a light terminal background, :dark otherwise (including
when detection is inconclusive). 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 :dark, keeping specs deterministic and off the test
runner's TTY.
@return — :dark or :light.
665 666 667 |
# File 'lib/tuile/screen.rb', line 665 def detect_scheme TerminalBackground.detect == :light ? :light : :dark end |
#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
719 720 721 722 |
# File 'lib/tuile/screen.rb', line 719 def emit(str) $stdout.write(str) $stdout.flush end |
#event_loop ⇒ void
This method returns an undefined value.
789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 |
# File 'lib/tuile/screen.rb', line 789 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::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.
405 |
# File 'lib/tuile/screen.rb', line 405 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.
409 |
# File 'lib/tuile/screen.rb', line 409 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.
753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 |
# File 'lib/tuile/screen.rb', line 753 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
775 |
# File 'lib/tuile/screen.rb', line 775 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.
786 |
# File 'lib/tuile/screen.rb', line 786 def handle_paste(text) = @pane.handle_paste(text) |
#has_popup?(window) ⇒ Boolean
Internal — use Component::Popup#open? instead.
@param window
@return — true if this popup is currently mounted.
504 505 506 507 |
# File 'lib/tuile/screen.rb', line 504 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
272 273 274 275 276 277 |
# File 'lib/tuile/screen.rb', line 272 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=.
729 730 731 732 733 734 735 |
# File 'lib/tuile/screen.rb', line 729 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::Popup#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.
496 497 498 |
# File 'lib/tuile/screen.rb', line 496 def needs_full_repaint @pane&.on_tree { invalidate _1 } end |
#on_color_scheme(scheme) ⇒ void
This method returns an undefined value.
An OS appearance flip arrived (mode-2031 report): remember the scheme and apply the matching member of #theme_def.
@param scheme — :dark or :light.
673 674 675 676 |
# File 'lib/tuile/screen.rb', line 673 def on_color_scheme(scheme) @color_scheme = scheme self.theme = @theme_def.for(@color_scheme) end |
#popups ⇒ ::Array[Component]
@return — currently active popup components (forwarded to Tuile::ScreenPane). The array must not be modified!
220 |
# File 'lib/tuile/screen.rb', line 220 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, 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.
@param args — stuff to print.
556 557 558 |
# File 'lib/tuile/screen.rb', line 556 def print(*args) Kernel.print(*args) 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.
439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 |
# File 'lib/tuile/screen.rb', line 439 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::Popup#close instead. Removes the popup from #pane, repairs focus, and repaints the scene.
Does nothing if the window is not open on this screen.
@param window
479 480 481 482 483 484 485 |
# File 'lib/tuile/screen.rb', line 479 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.
586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 |
# File 'lib/tuile/screen.rb', line 586 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: the whole stack when a tiled repaint may have clobbered # cells they share in the buffer, else just the invalidated popup # components. Overdraw into the buffer is free (only net-visible cell # changes reach the terminal), so reasserting the stack is cheap. popups.each do |p| p.on_tree { |c| repaint << c if tiled_invalidated || @invalidated.include?(c) } 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.
374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 |
# File 'lib/tuile/screen.rb', line 374 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).
230 231 232 233 234 |
# File 'lib/tuile/screen.rb', line 230 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
468 469 470 |
# File 'lib/tuile/screen.rb', line 468 def unregister_global_shortcut(key) @global_shortcuts.delete(key) end |