Class: Tuile::Screen
- Inherits:
-
Object
- Object
- Tuile::Screen
- Extended by:
- Listeners::Declare
- 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
Defined Under Namespace
Classes: FocusChangedEvent
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_key?on 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).
-
#canvas ⇒ Canvas
readonly
The untinted root canvas over #buffer, at the buffer's own origin, which #canvas_for derives every component's from.
-
#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.
-
#locale ⇒ Locale
The formatting conventions this session renders and parses by — date formats, the calendar, month and weekday names, the decimal separator.
-
#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(width: 160, height: 50) ⇒ 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? ⇒ Boolean
Whether Screen.instance would answer rather than raise — for code that must work with no screen in the process at all, which a detached component tree legitimately is (
Component#localeis the one caller in the gem). -
.listener ⇒ void
Defines the slot's reader, which returns the Listeners — or, given a block, registers it and returns the
Proc.
Instance Method Summary collapse
-
#add_popup(window, placement = nil) ⇒ 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.
-
#canvas_for(component, backend: @buffer, root: nil) ⇒ Canvas
The canvas
componentpaints onto: one over #buffer carrying that component's resolved background, positioned where it sits on screen and bounded by what its ancestors allow — so it writes at(0, 0), an inherited tint shows through and a scrolled-away row goes nowhere, with the component doing none of it. -
#check_locked ⇒ void
Raises unless the calling thread currently owns the UI (see the class-level threading contract).
-
#clear ⇒ void
Clears the TTY screen.
-
#clip_for(component, root: nil) ⇒ Rect
The cells
componentmay write, in its own coordinates: its Component#local_rect intersected with every ancestor's, each folded in as the walk climbs. -
#clipped?(component, root) ⇒ Boolean
Whether anything on
component's ancestor chain actually cuts it — the test that lets #clip_for answer Component#local_rect outright. -
#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?
Where the hardware cursor should sit in screen coordinates, 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.
-
#detect_locale ⇒ Locale
The startup locale probe, seeding #locale.
-
#dispatch(event) ⇒ Object
Routes one event to its handler, then #settles.
-
#emit(str) ⇒ void
Writes an assembled frame (escape string) to the terminal.
- #event_loop ⇒ void
-
#fire_focus_hooks(previous, focused) ⇒ void
The tail of #focused=: blur, then focus, then the scroll-into-view request, then the app notice.
-
#flush_layout ⇒ void
Runs every pending Component#relayout, so rects are current again.
-
#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.
-
#follow_up_focus(previous) ⇒ void
The geometry half of #focused=: scroll the target into view, warn if it still shows nothing, then fire the notice.
-
#grabbed ⇒ Component?
@return — the component holding the mouse grab — the one whose Component#handle_mouse_down? claimed the press still held.
-
#handle_background_color(color) ⇒ void
The re-probe answered: adopt the color, re-derive the theme from it and restyle, since an app's background-derived tints are now a scheme behind.
-
#handle_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.
-
#handle_key?(key) ⇒ Boolean
A key has been pressed on the keyboard.
-
#handle_mouse(event) ⇒ void
Routes one mouse event into the tree (Mouse::Router) — what the event loop does with every report the terminal sends, and how a spec drives the mouse:.
-
#handle_paste(text) ⇒ void
Delivers pasted text to #focused (Tuile::ScreenPane#handle_paste).
-
#has_popup?(window) ⇒ Boolean
Internal — use Component::Overlay#open? instead.
-
#hovered ⇒ Component?
@return — the innermost component the pointer is over, as of the last move.
-
#initialize ⇒ Screen
constructor
A new instance of Screen.
-
#invalidate(component) ⇒ void
Invalidates a component: causes the component to be repainted on next call to #repaint.
-
#invalidate_layout(component) ⇒ void
Marks a container as owing a Component#relayout, run by the next #flush_layout.
-
#needs_full_repaint ⇒ void
Invalidates the entire attached tree, forcing every component to repaint on the next cycle.
-
#on_error ⇒ Listeners
Fired with an EventQueue::ErrorEvent when a StandardError escapes an event handler inside the event loop (e.g. a Component::TextField's
on_value_changelistener raises). -
#on_focus_changed ⇒ Listeners
Fired with a FocusChangedEvent after #focused changes.
-
#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.
- #resize ⇒ void
-
#restyle ⇒ void
Fires Component#handle_theme_changed across the attached tree and repaints everything, after #theme or #background_color changed.
-
#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.
-
#settle ⇒ void
Brings deferred work up to date, once per #dispatched event.
-
#size_pane ⇒ void
Gives the pane the whole screen — the one rect no parent's pass assigns, so the screen places it itself.
-
#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.
-
#warn_if_unseen(component) ⇒ void
Unguarded on purpose: a container forwarding focus re-enters #focused=, so a target may be reported twice.
Methods included from Listeners::Declare
Constructor Details
#initialize ⇒ Screen
Returns a new instance of Screen.
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 |
# File 'lib/tuile/screen.rb', line 71 def initialize @@instance = self # rubocop:disable Style/ClassVars @event_queue = EventQueue.new @size = EventQueue::TTYSizeEvent.create.size @invalidated = Set.new # Whether some container may owe a {Component#relayout} — the O(1) gate # that lets a no-op {#flush_layout} skip the walk; the containers' own # flags are the queue. @layout_pending = false # A {#focused=} made inside a pass whose scroll and notice wait for the # drain, and what was focused before the first such call. @focus_deferred = false @focus_deferred_from = nil # 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 @locale = detect_locale @theme_def = ThemeDef.default # The theme as assigned, derivation Procs and all; {#theme} is this # resolved against {#background_color}, re-resolved when either changes. @theme_source = @theme_def.for(@color_scheme) @theme = @theme_source.resolve(@background_color) # Structural root of the component tree: holds tiled content and the # popup stack. Sized here rather than waiting for the first {#resize}, # for the same reason {#size} is seeded from {EventQueue::TTYSizeEvent}: # an empty pane rect is an *ancestor* empty rect, and {#repaint}'s drain # filter would take the whole tree with it. @pane = ScreenPane.new size_pane @mouse_router = Mouse::Router.new(self) # 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; {#resize} resizes it. @buffer = Buffer.new(@size, color_depth: @color_depth) @canvas = Canvas.new(@buffer) 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. Declare such a color as a derived theme token (see Theme) and point a slot at it:
pane_bg: ->(bg) { bg ? Color.rgb(*bg.rgb.map { (_1 + 10).clamp(0, 255) }) : FALLBACK_TINT }
sidebar.bg_color = Theme.ref(:pane_bg)
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 re-resolves #theme and then fires Component#handle_theme_changed across the tree once, as a theme swap does — even when no token is derived, since a component may read this directly.
341 342 343 |
# File 'lib/tuile/screen.rb', line 341 def background_color @background_color end |
#buffer ⇒ Buffer (readonly)
@return — the back buffer components paint into (Buffer#set_text / Buffer#fill / Buffer#set_char).
163 164 165 |
# File 'lib/tuile/screen.rb', line 163 def buffer @buffer end |
#canvas ⇒ Canvas (readonly)
The untinted root canvas over #buffer, at the buffer's own origin,
which #canvas_for derives every component's from. Nothing in lib/
paints through it: a component paints onto the canvas it is given,
never this one by name (D_canvas).
170 171 172 |
# File 'lib/tuile/screen.rb', line 170 def canvas @canvas 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.
159 160 161 |
# File 'lib/tuile/screen.rb', line 159 def color_depth @color_depth end |
#color_scheme ⇒ Symbol (readonly)
@return — :light or :dark
148 149 150 |
# File 'lib/tuile/screen.rb', line 148 def color_scheme @color_scheme end |
#event_queue ⇒ EventQueue (readonly)
@return — the event queue.
418 419 420 |
# File 'lib/tuile/screen.rb', line 418 def event_queue @event_queue end |
#focused ⇒ Component?
@return — currently focused component.
539 540 541 |
# File 'lib/tuile/screen.rb', line 539 def focused @focused end |
#locale ⇒ Locale
The formatting conventions this session renders and parses by — date formats, the calendar, month and weekday names, the decimal separator. Detected once at construction from Locale.system; Locale::ISO when the environment says nothing.
Read it at paint or parse time and never cache it in an ivar, for the same reason #theme says so: #locale= can replace it mid-session.
385 386 387 |
# File 'lib/tuile/screen.rb', line 385 def locale @locale end |
#pane ⇒ ScreenPane (readonly)
@return — the structural root of the component tree.
145 146 147 |
# File 'lib/tuile/screen.rb', line 145 def pane @pane end |
#size ⇒ Size (readonly)
@return — current screen size.
299 300 301 |
# File 'lib/tuile/screen.rb', line 299 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.
Always resolved (Theme#resolve): a derived token is recomputed from #background_color whenever that changes, so no token here is a Proc.
311 312 313 |
# File 'lib/tuile/screen.rb', line 311 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.
320 321 322 |
# File 'lib/tuile/screen.rb', line 320 def theme_def @theme_def end |
Class Method Details
.close ⇒ void
This method returns an undefined value.
856 857 858 |
# File 'lib/tuile/screen.rb', line 856 def self.close @@instance&.close end |
.fake(width: 160, height: 50) ⇒ 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.
before { Screen.fake(width: 40, height: 12) } # a narrow, short terminal
@param width — the terminal's columns.
@param height — the terminal's rows.
825 |
# File 'lib/tuile/screen.rb', line 825 def self.fake(width: 160, height: 50) = FakeScreen.new(width: width, height: height) |
.instance ⇒ Screen
@return — the singleton instance.
273 274 275 276 277 |
# File 'lib/tuile/screen.rb', line 273 def self.instance raise Tuile::Error, "Screen not initialized; call Screen.new first" if @@instance.nil? @@instance end |
.instance? ⇒ Boolean
Whether instance would answer rather than raise — for code that must
work with no screen in the process at all, which a detached component
tree legitimately is (Component#locale is the one caller in the gem).
283 |
# File 'lib/tuile/screen.rb', line 283 def self.instance? = !@@instance.nil? |
.listener ⇒ void
This method returns an undefined value.
Defines the slot's reader, which returns the Listeners — or, given a
block, registers it and returns the Proc.
An optional block is the slot's transition block, run on the owner whenever the list becomes non-empty or empty again. It is what lets a widget install a bridge only while somebody is listening:
listener :on_enter do |claimed|
claimed ? editor.on_enter << @bridge : editor.on_enter.remove(@bridge)
end
The list is built on first read, so no mixin has to remember a
constructor line — which means in-class code goes through the reader and
never touches @on_foo, nil until somebody asks.
@param name — the slot's full name, on_-prefixed. Spelling it out is what keeps on_value_change greppable from its declaration.
@return — name.
3208 |
# File 'sig/tuile.rbs', line 3208 def self.listener: (Symbol name) ?{ (bool claimed) -> void } -> Symbol |
Instance Method Details
#add_popup(window, placement = nil) ⇒ void
This method returns an undefined value.
Internal — use Component::Overlay#open instead. Adds the overlay to
#pane at placement; a Component::Popup is additionally focused.
@param window — any overlay, modal or not.
@param placement — where it wants to be; nil takes the overlay's default.
639 640 641 642 643 644 |
# File 'lib/tuile/screen.rb', line 639 def add_popup(window, placement = nil) check_locked @pane.add_popup(window, placement) # 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.
892 893 894 895 |
# File 'lib/tuile/screen.rb', line 892 def beep check_locked print(Ansi::BEL) end |
#canvas_for(component, backend: @buffer, root: nil) ⇒ Canvas
The canvas component paints onto: one over #buffer carrying that
component's resolved background, positioned where it sits on screen and
bounded by what its ancestors allow — so it writes at (0, 0), an
inherited tint shows through and a scrolled-away row goes nowhere, with
the component doing none of it. Summing the offsets is this method's job,
which is what leaves every Component#rect parent-relative
(D_relative_rect).
label.repaint(screen.canvas_for(label)) # paint one component, as a spec does
With root: it is positioned and bounded within that ancestor instead of
the screen, so a subtree paints into a buffer of its own:
screen.canvas_for(list, backend: Buffer.new(window.rect.size), root: window)
The sole converter into backend coordinates: #clip_for answers in
the component's own space and the one moved_by here puts it where
Canvas keeps it, beside the origin (D_clip).
@param component
@param backend — where the cells land; the screen's #buffer unless given.
@param root — component itself or one of its ancestors; nil is the screen.
196 197 198 199 200 201 202 203 204 205 206 207 208 |
# File 'lib/tuile/screen.rb', line 196 def canvas_for(component, backend: @buffer, root: nil) # Built rather than derived from #canvas, since {Canvas#with} is # block-only. __send__ because Component#bg is protected: the # framework paints with it, an app never asks (`D_bg_surface`). origin = component.to_screen(Point::ZERO) unless root.nil? base = root.to_screen(Point::ZERO) origin = Point.new(origin.x - base.x, origin.y - base.y) end Canvas.new(backend, bg_color: component.__send__(:bg).effective, origin:, clip: clip_for(component, root:).moved_by(origin)) 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
Both halves of the test below are load-bearing — is a loop running anywhere, and is it mine: the loop need not run on the thread that created the screen, and the gem's own specs rely on that.
444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 |
# File 'lib/tuile/screen.rb', line 444 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.in_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.
463 464 465 |
# File 'lib/tuile/screen.rb', line 463 def clear print TTY::Cursor.move_to(0, 0), TTY::Cursor.clear_screen end |
#clip_for(component, root: nil) ⇒ Rect
The cells component may write, in its own coordinates: its
Component#local_rect intersected with every ancestor's, each folded in
as the walk climbs.
Every component is bounded, and a component cannot widen its own bounds —
which is why this lives here rather than as a hook on Component. A
container that wants to allow its children less than its own box has no
way to say so yet; clip_rect_for(child) is the shape that would, and it
is deferred with no caller (D_clip).
Resolved per call and never cached, like the origin it rides beside: an ancestor may scroll between two frames. #clipped? skips the fold entirely in the common case, which is what keeps that affordable.
@param component
@param root — the last ancestor folded in — component itself or one of its ancestors; nil folds in every one.
@return — never nil; empty exactly when the component
can show nothing — scrolled clean out of its viewport, collapsed, or
under an ancestor allowing no cell. The own rect being folded in, that
one predicate answers is any of this visible.
230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 |
# File 'lib/tuile/screen.rb', line 230 def clip_for(component, root: nil) return component.local_rect unless clipped?(component, root) clip = component.local_rect x = 0 y = 0 node = component while !node.equal?(root) && (up = node.parent) # Intersection only ever shrinks, so the rest of the chain cannot change # an empty answer. Worth the test: a scroller's off-screen children land # here on every scroll, one per row it is not showing (`D_clip`). return clip if clip.empty? x -= node.rect.left y -= node.rect.top clip = clip.intersect(up.local_rect.moved_by(Point.new(x, y))) node = up end clip end |
#clipped?(component, root) ⇒ Boolean
Whether anything on component's ancestor chain actually cuts it — the
test that lets #clip_for answer Component#local_rect outright.
If every node sits inside the box its parent gave it, then by induction
every ancestor's box contains component's rect, the fold can remove
nothing, and the answer is its own local_rect — which still bounds the
component, so the short-circuit gives up no part of the guarantee.
Allocation-free on purpose, and that is the whole optimization.
local_rect, moved_by and intersect each build a Rect; comparing
node.rect against the ancestor's stored dimensions builds nothing.
Spelled the obvious way, up.local_rect.contains_rect?(node.rect), it
reads better and measures as no gain at all (D_clip).
@param component
@param root — see #clip_for.
1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 |
# File 'lib/tuile/screen.rb', line 1071 def clipped?(component, root) node = component while !node.equal?(root) && (up = node.parent) r = node.rect # An empty rect covers no cells, so it is trivially inside — matching # {Rect#contains_rect?}, whose place this takes. unless r.empty? || (r.left >= 0 && r.top >= 0 && r.left + r.width <= up.rect.width && r.top + r.height <= up.rect.height) return true end node = up end false 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#handle_detached. Idempotent.
835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 |
# File 'lib/tuile/screen.rb', line 835 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 handle_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 @mouse_router = nil @closed = true @@instance = nil # rubocop:disable Style/ClassVars end end |
#content ⇒ Component?
@return — tiled content (forwarded to Tuile::ScreenPane).
286 |
# File 'lib/tuile/screen.rb', line 286 def content = @pane.content |
#content=(content) ⇒ void
This method returns an undefined value.
@param content
290 291 292 293 294 295 296 |
# File 'lib/tuile/screen.rb', line 290 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 resize end |
#cursor_position ⇒ Point?
Where the hardware cursor should sit in screen coordinates, 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.
A component answers Component#cursor_position in its own coordinates and
this converts — the same split as painting, so a caret is a column and a
row and nothing more (D_relative_rect).
A caret an ancestor clips away is hidden, not parked at a coordinate
nobody can see: the cursor is the one thing on screen that no clip can
reach, since the terminal draws it itself (D_clip).
1014 1015 1016 1017 1018 1019 1020 1021 1022 |
# File 'lib/tuile/screen.rb', line 1014 def cursor_position focused = @focused local = focused&.cursor_position return nil if local.nil? return nil unless clip_for(focused).contains?(local) focused.to_screen(local) end |
#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.
1238 1239 1240 1241 |
# File 'lib/tuile/screen.rb', line 1238 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.
1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 |
# File 'lib/tuile/screen.rb', line 1213 def cycle_focus(forward:) check_locked scope = @pane.key_scope return false if scope.nil? stops = [] scope.walk_shown_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.
1145 1146 1147 |
# File 'lib/tuile/screen.rb', line 1145 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.
1154 |
# File 'lib/tuile/screen.rb', line 1154 def detect_color_depth = ColorDepth.detect |
#detect_locale ⇒ Locale
The startup locale probe, seeding #locale. Shells out (see
Locale.system), so unlike #detect_background it touches no terminal
stream and has no timing constraint. FakeScreen overrides it to pin
Locale::ISO, keeping specs off whatever LC_TIME the test runner
happens to carry — and out of a subprocess per example.
1162 |
# File 'lib/tuile/screen.rb', line 1162 def detect_locale = Locale.system |
#dispatch(event) ⇒ Object
Routes one event to its handler, then #settles.
The single seam every event passes through, which is why FakeScreen's
gesture helpers drive it rather than calling #handle_mouse themselves: a
spec's click then reaches components exactly as the loop's own report
does, settle included.
The rescue stays in #event_loop: only a loop has an on_error slot
to divert into, and a spec driving one event wants the raise.
@param event — a EventQueue::KeyEvent, Mouse::Event, EventQueue::PasteEvent, EventQueue::TTYSizeEvent, EventQueue::ColorSchemeEvent, EventQueue::BackgroundColorEvent, EventQueue::EmptyQueueEvent, or a Proc from EventQueue#submit.
@return — what the handler returned — a paste's is the Boolean Tuile::ScreenPane#handle_paste answers with; most are unspecified.
1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 |
# File 'lib/tuile/screen.rb', line 1331 def dispatch(event) result = case event when EventQueue::KeyEvent key = event.key handled = handle_key?(key) @event_queue.stop if !handled && ["q", Keys::ESC].include?(key) handled when EventQueue::PasteEvent handle_paste(event.text) when Mouse::Event handle_mouse(event) when EventQueue::TTYSizeEvent @size = event.size resize when EventQueue::ColorSchemeEvent handle_color_scheme(event.scheme) when EventQueue::BackgroundColorEvent handle_background_color(event.color) when EventQueue::EmptyQueueEvent repaint when Proc event.call end settle result 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
1247 1248 1249 1250 |
# File 'lib/tuile/screen.rb', line 1247 def emit(str) $stdout.write(str) $stdout.flush end |
#event_loop ⇒ void
This method returns an undefined value.
1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 |
# File 'lib/tuile/screen.rb', line 1371 def event_loop @event_queue.run_loop do |event| dispatch(event) rescue StandardError => e # Empty means re-raise: the generic fire cannot know that, so the one # slot with an executable empty branch carries it here. raise e if on_error.empty? on_error.fire(EventQueue::ErrorEvent.new(error: e)) end end |
#fire_focus_hooks(previous, focused) ⇒ void
This method returns an undefined value.
The tail of #focused=: blur, then focus, then the scroll-into-view request, then the app notice.
A hook may reassign #focused; that nested call has already run this whole sequence for the target it chose, so this one stops rather than announcing a focus that no longer holds.
@param previous — what held focus before the assignment.
@param focused — what the assignment asked for.
1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 |
# File 'lib/tuile/screen.rb', line 1097 def fire_focus_hooks(previous, focused) deferred = LayoutPass.running? # Recorded before the hooks, which may reassign focus themselves: the # first deferral in a drain keeps its `previous`, so the one notice fired # later compares against where focus stood before any of them. if deferred && !@focus_deferred @focus_deferred = true @focus_deferred_from = previous end unless focused.equal?(previous) previous&.__send__(:handle_blur) return unless @focused.equal?(focused) end @focused&.__send__(:handle_focus) follow_up_focus(previous) unless deferred end |
#flush_layout ⇒ void
This method returns an undefined value.
Runs every pending Component#relayout, so rects are current again.
form.add(field)
screen.flush_layout
field.rect # assigned, rather than whatever it had before
The loop calls this after every event (#dispatch), which is enough for
app code that mutates in one handler and reads in the next. Call it by
hand only to read a rect in the same turn that dirtied it — that is what
Swing's validate() and Tk's update idletasks are for.
Implementation details
LayoutPass.drain over the pane, the same fixpoint a detached tree's Component#flush_layout runs. Then two follow-ups the pane's tree owes once it has settled: the pane places anchored popups before the content they hang from, so a moved anchor re-marks it and the drain runs again (Tuile::ScreenPane#remark_moved_anchors); and the half of a #focused= that was deferred because it happened inside a pass — see there.
513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 |
# File 'lib/tuile/screen.rb', line 513 def flush_layout check_locked LayoutPass.refuse_nested return if @pane.nil? rounds = 0 loop do if @layout_pending rounds = LayoutPass.drain(@pane, rounds) # Only once the drain returns: a raise leaves the gate open, so the # next settle reports the same cycle rather than skipping the walk. @layout_pending = false end # One round shared with the drain: an anchor chain that never settles # hits the same cap. next if @pane.__send__(:remark_moved_anchors) break unless @focus_deferred # Last, so the scroll sees popups at their final place; and looped, so # what an on_focus_changed callback marks is settled before we return. @focus_deferred = false follow_up_focus(@focus_deferred_from) 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.
712 |
# File 'lib/tuile/screen.rb', line 712 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.
716 |
# File 'lib/tuile/screen.rb', line 716 def focus_previous = cycle_focus(forward: false) |
#follow_up_focus(previous) ⇒ void
This method returns an undefined value.
The geometry half of #focused=: scroll the target into view, warn if it still shows nothing, then fire the notice.
@param previous — what was focused before the change.
1118 1119 1120 1121 1122 1123 1124 1125 |
# File 'lib/tuile/screen.rb', line 1118 def follow_up_focus(previous) # Level-triggered like `handle_focus`, not edge-triggered like the notice # below: re-focusing what already has focus is how an app says "bring it # back into view", and an already-satisfied request scrolls by zero. @focused&.scroll_to_visible warn_if_unseen(@focused) unless @focused.nil? on_focus_changed.fire(FocusChangedEvent.new(source: self)) unless @focused.equal?(previous) end |
#grabbed ⇒ Component?
@return — the component holding the mouse grab — the one whose Component#handle_mouse_down? claimed the press still held.
1044 |
# File 'lib/tuile/screen.rb', line 1044 def grabbed = @mouse_router&.grabbed |
#handle_background_color(color) ⇒ void
This method returns an undefined value.
The re-probe answered: adopt the color, re-derive the theme from it 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
1188 1189 1190 1191 1192 1193 1194 |
# File 'lib/tuile/screen.rb', line 1188 def handle_background_color(color) return if @background_color == color @background_color = color @theme = @theme_source.resolve(color) restyle end |
#handle_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.
1174 1175 1176 1177 1178 |
# File 'lib/tuile/screen.rb', line 1174 def handle_color_scheme(scheme) @color_scheme = scheme self.theme = @theme_def.for(@color_scheme) print TerminalBackground::QUERY 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.
1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 |
# File 'lib/tuile/screen.rb', line 1282 def handle_key?(key) # A key is one of the grab's releases: a terminal loses a release over ssh # and tmux, and a stuck grab would swallow every later drag. @mouse_router.release_grab 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.
Routes one mouse event into the tree (Mouse::Router) — what the event loop does with every report the terminal sends, and how a spec drives the mouse:
screen.handle_mouse(Mouse::DownEvent.new(:left, 5, 2))
@param event
1032 1033 1034 1035 |
# File 'lib/tuile/screen.rb', line 1032 def handle_mouse(event) check_locked @mouse_router.dispatch(event) end |
#handle_paste(text) ⇒ void
This method returns an undefined value.
Delivers pasted text to #focused (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, goes straight to delivery, and does not bubble to ancestors the way a key does. 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 — and with no alternative delivery, no verdict to carry either.
@param text
1314 |
# File 'lib/tuile/screen.rb', line 1314 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.
811 812 813 814 |
# File 'lib/tuile/screen.rb', line 811 def has_popup?(window) # rubocop:disable Naming/PredicatePrefix check_locked @pane.has_popup?(window) end |
#hovered ⇒ Component?
@return — the innermost component the pointer is over, as
of the last move. Always nil below capture_mouse: :hover, and frozen
at its last value while a press is grabbed.
1040 |
# File 'lib/tuile/screen.rb', line 1040 def hovered = @mouse_router&.hovered |
#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
471 472 473 474 475 476 |
# File 'lib/tuile/screen.rb', line 471 def invalidate(component) check_locked raise TypeError, "expected Component, got #{component.inspect}" unless component.is_a? Component @invalidated << component unless @repainting.include? component end |
#invalidate_layout(component) ⇒ void
This method returns an undefined value.
Marks a container as owing a Component#relayout, run by the next #flush_layout.
@param component
482 483 484 485 486 487 |
# File 'lib/tuile/screen.rb', line 482 def invalidate_layout(component) check_locked raise TypeError, "expected Component, got #{component.inspect}" unless component.is_a? Component @layout_pending = true 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.
803 804 805 |
# File 'lib/tuile/screen.rb', line 803 def needs_full_repaint @pane&.walk_tree { invalidate _1 } end |
#on_error ⇒ Listeners
Fired with an EventQueue::ErrorEvent when a StandardError escapes an
event handler inside the event loop (e.g. a Component::TextField's
on_value_change listener raises). The one slot whose event carries no
source: its listener wants error.
Empty means re-raise, 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. Registering anything at all takes that over:
screen.on_error do |e|
Tuile.logger.error("#{e.error.class}: #{e.error.message}")
end
A listener runs on the event-loop thread with the UI lock held. Returning normally keeps the loop alive; raising tears the loop down and propagates out of #run_event_loop.
270 |
# File 'lib/tuile/screen.rb', line 270 listener :on_error |
#on_focus_changed ⇒ Listeners
Fired with a FocusChangedEvent after #focused changes.
630 |
# File 'lib/tuile/screen.rb', line 630 listener :on_focus_changed |
#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!
415 |
# File 'lib/tuile/screen.rb', line 415 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.
872 873 874 875 |
# File 'lib/tuile/screen.rb', line 872 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.
746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 |
# File 'lib/tuile/screen.rb', line 746 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.
786 787 788 789 790 791 792 |
# File 'lib/tuile/screen.rb', line 786 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.
Settles the layout first: painting is the heaviest reader of rects there is, and a pending #flush_layout would have it paint children where they no longer are.
907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 |
# File 'lib/tuile/screen.rb', line 907 def repaint check_locked flush_layout # The one site that runs after every mutation, so a component hidden, # detached or reparented while hovered gets its exit exactly once. @mouse_router.sync_hover # 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, two ways a queued component has no place on the # screen by the time we drain it. It became *detached* since (popup # close, sibling removed mid-event-handling, focus repair) — which # Component#invalidate gates at enqueue, so this catches only a change # since. Or it sits under an *empty rect*, its own or any ancestor's, # which is how a subtree is collapsed. # # The self half is belt-and-braces — every #repaint opens with the same # `return if rect.empty?`, and that stays: it is each component's own # contract, local to the method an app subclass calls `super` on. The # ancestor half is the one that does work no component can do for # itself, so a container that forgets to zero its children leaves them # inert rather than painting them at stale coordinates # (`D_empty_ancestor`). # # {Component#visible?} rides the same walk, one more term in the same # AND — a different question (geometry says *where*, the flag says # *whether*) with the same answer for this frame (`D_visibility`). @invalidated.delete_if do |c| next true unless c.attached? blocked = c blocked = blocked.parent while blocked && !blocked.rect.empty? && blocked.visible? !blocked.nil? end 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.walk_tree walk. popup_members = Set.new popups.each { |p| p.walk_tree { popup_members << _1 } } # Tiled layer: invalidated non-popup components, in tree order. repaint = [] tiled_invalidated = false @pane.walk_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.walk_tree { |c| layer_invalidated ||= @invalidated.include?(c) } p.walk_tree { |c| repaint << c if below_repainted || @invalidated.include?(c) } below_repainted ||= layer_invalidated end @repainting = repaint.to_set @invalidated.clear repaint.each { _1.repaint(canvas_for(_1)) } @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 |
#resize ⇒ 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=. Not layout in the Component#relayout sense: assigning the pane a rect only marks it, and the #repaint below settles the pass.
1258 1259 1260 1261 1262 1263 1264 |
# File 'lib/tuile/screen.rb', line 1258 def resize check_locked @buffer.resize(size) unless @buffer.size == size needs_full_repaint size_pane repaint end |
#restyle ⇒ void
This method returns an undefined value.
Fires Component#handle_theme_changed across the attached tree and repaints everything, after #theme or #background_color changed.
1199 1200 1201 1202 1203 1204 |
# File 'lib/tuile/screen.rb', line 1199 def restyle # `__send__`, not `&:handle_theme_changed`: the hook is protected, and an app # subclass may narrow it further (`D_hook_visibility`). @pane&.walk_tree { _1.__send__(:handle_theme_changed) } needs_full_repaint 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 — how much mouse tracking to ask the terminal for — each level unlocking one tier of Mouse's events, and costing what that tier reports: - false — none. The terminal keeps its native click handling, which is what you want if the app benefits more from select-to-copy. - true (default), :clicks — presses, releases and the wheel. - :drag — plus motion while a button is held, as Component#handle_mouse_drag. - :hover — plus motion with no button, as Component#handle_mouse_move? and the enter/exit hooks. ~84 reports a second (R_mouse_reporting), so ask for it only if something uses it.
@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.
676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 |
# File 'lib/tuile/screen.rb', line 676 def run_event_loop(capture_mouse: true, bracketed_paste: true) raise Tuile::Error, "Screen is closed: cannot run the event loop" if @closed level = Mouse.level(capture_mouse) # 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 @mouse_router.level = level print Mouse.start_tracking(level) if level 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 Mouse.stop_tracking(level) if level # Back to delivering everything a spec posts once no loop owns the wire. @mouse_router.level = :hover print TTY::Cursor.show $stdin.echo = true end end |
#settle ⇒ void
This method returns an undefined value.
Brings deferred work up to date, once per #dispatched event.
The sequence point between "a handler mutated the tree" and "the next event reads it": whatever a handler marks, this is where it is done, so a queued mouse press never routes against what the key before it left half-finished.
1366 1367 1368 |
# File 'lib/tuile/screen.rb', line 1366 def settle flush_layout end |
#size_pane ⇒ void
This method returns an undefined value.
Gives the pane the whole screen — the one rect no parent's pass assigns, so the screen places it itself.
1051 1052 1053 |
# File 'lib/tuile/screen.rb', line 1051 def size_pane LayoutPass.run(self) { @pane.__send__(:rect=, Rect.new(0, 0, @size.width, @size.height)) } 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).
425 426 427 428 429 |
# File 'lib/tuile/screen.rb', line 425 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
775 776 777 |
# File 'lib/tuile/screen.rb', line 775 def unregister_global_shortcut(key) @global_shortcuts.delete(key) end |
#warn_if_unseen(component) ⇒ void
This method returns an undefined value.
Unguarded on purpose: a container forwarding focus re-enters #focused=, so a target may be reported twice.
@param component
1131 1132 1133 1134 1135 1136 |
# File 'lib/tuile/screen.rb', line 1131 def warn_if_unseen(component) return unless clip_for(component).empty? Tuile.logger.warn("Screen: focused #{component} shows nothing, even after scroll_to_visible " \ "(a stale Scroller#content_rows? Fixed[0] meant as visible = false?)") end |