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), and the bottom status bar. Popups are not sized from their content — each carries its own top-down Component::Popup#size — and they deliberately overdraw the content without clipping.
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_line / 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). -
#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
-
#active_window ⇒ Component?
@return — current active tiled component.
-
#add_popup(window) ⇒ void
Internal — use Component::Popup#open instead.
-
#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.
-
#global_shortcut_hints(popup_open:) ⇒ ::Array[String]
Status-bar hints from currently-registered global shortcuts.
-
#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.
-
#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.
-
#refresh_status_bar ⇒ void
Rebuild the status-bar text from the current focus and global-shortcut registry.
-
#register_global_shortcut(key, over_popups: false, hint: nil, &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) ⇒ 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
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
# File 'lib/tuile/screen.rb', line 57 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, popup # stack and status bar. @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_line / Buffer#fill / Buffer#set_char).
118 119 120 |
# File 'lib/tuile/screen.rb', line 118 def buffer @buffer end |
#color_scheme ⇒ Symbol (readonly)
@return — :light or :dark
114 115 116 |
# File 'lib/tuile/screen.rb', line 114 def color_scheme @color_scheme end |
#event_queue ⇒ EventQueue (readonly)
@return — the event queue.
221 222 223 |
# File 'lib/tuile/screen.rb', line 221 def event_queue @event_queue end |
#focused ⇒ Component?
@return — currently focused component.
278 279 280 |
# File 'lib/tuile/screen.rb', line 278 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.
138 139 140 |
# File 'lib/tuile/screen.rb', line 138 def on_error @on_error end |
#pane ⇒ ScreenPane (readonly)
@return — the structural root of the component tree.
111 112 113 |
# File 'lib/tuile/screen.rb', line 111 def pane @pane end |
#size ⇒ Size (readonly)
@return — current screen size.
161 162 163 |
# File 'lib/tuile/screen.rb', line 161 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.
170 171 172 |
# File 'lib/tuile/screen.rb', line 170 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.
179 180 181 |
# File 'lib/tuile/screen.rb', line 179 def theme_def @theme_def end |
Class Method Details
.close ⇒ void
This method returns an undefined value.
559 560 561 |
# File 'lib/tuile/screen.rb', line 559 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.
529 |
# File 'lib/tuile/screen.rb', line 529 def self.fake = FakeScreen.new |
Instance Method Details
#active_window ⇒ Component?
@return — current active tiled component.
480 481 482 483 484 485 |
# File 'lib/tuile/screen.rb', line 480 def active_window check_locked result = nil @pane.content&.on_tree { result = _1 if _1.is_a?(Component::Window) && _1.active? } result end |
#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
351 352 353 354 355 356 |
# File 'lib/tuile/screen.rb', line 351 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 |
#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
243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 |
# File 'lib/tuile/screen.rb', line 243 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.
262 263 264 |
# File 'lib/tuile/screen.rb', line 262 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.
539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 |
# File 'lib/tuile/screen.rb', line 539 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).
148 |
# File 'lib/tuile/screen.rb', line 148 def content = @pane.content |
#content=(content) ⇒ void
This method returns an undefined value.
@param content
152 153 154 155 156 157 158 |
# File 'lib/tuile/screen.rb', line 152 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.
649 |
# File 'lib/tuile/screen.rb', line 649 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.
706 707 708 709 |
# File 'lib/tuile/screen.rb', line 706 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.
681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 |
# File 'lib/tuile/screen.rb', line 681 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.
661 662 663 |
# File 'lib/tuile/screen.rb', line 661 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
715 716 717 718 |
# File 'lib/tuile/screen.rb', line 715 def emit(str) $stdout.write(str) $stdout.flush end |
#event_loop ⇒ void
This method returns an undefined value.
774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 |
# File 'lib/tuile/screen.rb', line 774 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 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.
404 |
# File 'lib/tuile/screen.rb', line 404 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.
408 |
# File 'lib/tuile/screen.rb', line 408 def focus_previous = cycle_focus(forward: false) |
#global_shortcut_hints(popup_open:) ⇒ ::Array[String]
Status-bar hints from currently-registered global shortcuts.
When a popup is open, only over_popups: true shortcuts contribute —
the rest don't fire in that context, so showing them would be a lie.
Insertion order is preserved (Hash iteration order).
@param popup_open
336 337 338 339 340 341 342 343 |
# File 'lib/tuile/screen.rb', line 336 def global_shortcut_hints(popup_open:) @global_shortcuts.each_value.filter_map do |s| next if s.hint.nil? || s.hint.empty? next if popup_open && !s.over_popups s.hint end end |
#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.
749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 |
# File 'lib/tuile/screen.rb', line 749 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
771 |
# File 'lib/tuile/screen.rb', line 771 def handle_mouse(event) = @pane.handle_mouse(event) |
#has_popup?(window) ⇒ Boolean
Internal — use Component::Popup#open? instead.
@param window
@return — true if this popup is currently mounted.
519 520 521 522 |
# File 'lib/tuile/screen.rb', line 519 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
270 271 272 273 274 275 |
# File 'lib/tuile/screen.rb', line 270 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=.
725 726 727 728 729 730 731 |
# File 'lib/tuile/screen.rb', line 725 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.
511 512 513 |
# File 'lib/tuile/screen.rb', line 511 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.
669 670 671 672 |
# File 'lib/tuile/screen.rb', line 669 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!
218 |
# File 'lib/tuile/screen.rb', line 218 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.
571 572 573 |
# File 'lib/tuile/screen.rb', line 571 def print(*args) Kernel.print(*args) end |
#refresh_status_bar ⇒ void
This method returns an undefined value.
Rebuild the status-bar text from the current focus and global-shortcut
registry. Called from #focused= and whenever the global registry
changes. Popups own their own "q Close" prefix in #keyboard_hint;
for the tiled case Screen tacks on the global "q quit" instead.
Global-shortcut hints get spliced in too — see #global_shortcut_hints
for the over_popups filter rule.
317 318 319 320 321 322 323 324 325 326 |
# File 'lib/tuile/screen.rb', line 317 def top_popup = @pane.modal_popup globals = global_shortcut_hints(popup_open: !top_popup.nil?) @pane..text = if top_popup.nil? ["q #{@theme.hint("quit")}", *globals, active_window&.keyboard_hint].compact.reject(&:empty?).join(" ") else [*globals, top_popup.keyboard_hint].reject(&:empty?).join(" ") end end |
#register_global_shortcut(key, over_popups: false, hint: nil, &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, hint: "^L #Tuile::Screen.screenscreen.themescreen.theme.hint("log")") 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.
@param hint — preformatted status-bar hint; nil (default) is silent. Colors are baked in — re-register after a #theme= to recolor.
442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 |
# File 'lib/tuile/screen.rb', line 442 def register_global_shortcut(key, over_popups: false, hint: nil, &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 raise ArgumentError, "hint must be a String or nil, got #{hint.inspect}" unless hint.nil? || hint.is_a?(String) @global_shortcuts[key] = Shortcut.new(block: block, over_popups: over_popups, hint: hint) 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
494 495 496 497 498 499 500 |
# File 'lib/tuile/screen.rb', line 494 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.
581 582 583 584 585 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 |
# File 'lib/tuile/screen.rb', line 581 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 (content subtree + status bar) parent-before-child; the # popups are the top layer and must paint last. The status bar is a # *late* pane child yet sits under the popups, so a single pane.on_tree # walk won't do — we collect the tiled layer first, then append popups. 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) ⇒ 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).
375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 |
# File 'lib/tuile/screen.rb', line 375 def run_event_loop(capture_mouse: 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 # 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 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).
228 229 230 231 232 |
# File 'lib/tuile/screen.rb', line 228 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
474 475 476 477 |
# File 'lib/tuile/screen.rb', line 474 def unregister_global_shortcut(key) @global_shortcuts.delete(key) end |