Class: Tuile::ScreenPane
- Defined in:
- lib/tuile/screen_pane.rb,
sig/tuile.rbs
Overview
The structural root of the Screen's component tree.
Screen is a singleton runtime owner (event loop, lock, terminal IO, invalidation set). All actual UI lives under a ScreenPane: the tiled #content and the #popups stack. Putting them under a single Component parent gives focus traversal a real root, makes Component#attached? a one-liner, and lets popup-focus repair fall out of the standard Component#on_child_removed hook.
The pane owns no chrome of its own — no status bar, no reserved row.
#content gets the full pane rect, and an app that wants a status line
builds one into its own layout and drives it from
Tuile::Screen#on_focus_changed= (D_status_bar).
The pane is not a Component::Layout: popups deliberately overlap content (Z-ordered, full overdraw, no clipping) and key/mouse dispatch follows modal-popup rules rather than active-child dispatch.
Constant Summary collapse
- FINAL_METHODS =
Instance Attribute Summary collapse
-
#content ⇒ Component?
@return — the tiled content component.
-
#popups ⇒ ::Array[Component::Overlay]
readonly
@return — the open overlays in stacking order; last is topmost.
Attributes inherited from Component
#bg_color, #children, #on_theme_changed, #parent, #rect
Instance Method Summary collapse
-
#add_popup(window) ⇒ void
Adds an overlay and invalidates it for repaint.
-
#bubble_key(key, scope) ⇒ Boolean
Delivers
keyto Tuile::Screen#focused and bubbles it up the ancestor chain, stopping at (and including)scope. -
#detach_all ⇒ void
Unmounts everything: each child is detached — firing Component#on_detached down its subtree — and every slot is emptied.
-
#enclosing_popup(component) ⇒ Component::Overlay?
@param
component. -
#first_tab_stop_or_root(root) ⇒ Component?
First Component#tab_stop? in
root's subtree (pre-order), falling back torootitself when the subtree has no tab stops. -
#focus_chain(scope) ⇒ ::Array[Component]?
Tuile::Screen#focused and its ancestors up to and including
scope. - #focusable? ⇒ Boolean
-
#handle_key(key) ⇒ Boolean
Delivers a key to Tuile::Screen#focused, then bubbles it up the focus chain — the first component whose
handle_keyreturns true wins. -
#handle_mouse(event) ⇒ void
Mouse events check popups in reverse stacking order (topmost first), and fall through to content only when no popup is hit and no modal popup is open.
-
#handle_paste(text) ⇒ Boolean
Delivers pasted text along the same focus chain #handle_key bubbles along, and with the same scoping — first Component#handle_paste returning true wins.
-
#has_popup?(window) ⇒ Boolean
@param
window. -
#initialize ⇒ ScreenPane
constructor
A new instance of ScreenPane.
-
#kept_by(hit) ⇒ ::Array[Component::Overlay]
The overlays a click counts as landing inside: the one it hit, plus every overlay that one belongs to, up the Component::Overlay#owner chain.
-
#layout ⇒ void
Gives #content the whole pane rect — the pane reserves nothing for itself.
-
#modal_popup ⇒ Component::Popup?
@return — the topmost modal overlay, or nil when only bare Component::Overlays (or none) are open.
-
#on_child_removed(child) ⇒ void
Focus repair when a child detaches.
-
#rect=(new_rect) ⇒ void
Re-lays out children whenever the pane's own rect changes.
-
#remove_popup(window) ⇒ void
Removes a popup.
-
#repaint ⇒ void
Pane paints nothing itself; its children paint over the entire rect.
Methods inherited from Component
#active=, #active?, #add_child, #attached?, #children_tile_rect?, #clear_background, #clear_outside_extent, #cursor_position, #depth, #detach_child, #draw_char, #draw_text, #effective_bg_color, #extent, #extent_rect, #fire_lifecycle, #focus, #height, #invalidate, #on_attached, #on_detached, #on_focus, #on_tree, #on_width_changed, #remove_child, #root, #screen, #size, #tab_stop?, verify_final!, #width
Constructor Details
#initialize ⇒ ScreenPane
Returns a new instance of ScreenPane.
22 23 24 25 26 27 28 29 30 |
# File 'lib/tuile/screen_pane.rb', line 22 def initialize super @popups = [] # Per-popup snapshot of {Screen#focused} taken just before the popup was # added. Restored when the popup closes so focus returns to where the # user was, instead of falling through to {#content} and getting # cascaded to the first focusable child. @popup_prior_focus = {} end |
Instance Attribute Details
#content ⇒ Component?
@return — the tiled content component.
33 34 35 |
# File 'lib/tuile/screen_pane.rb', line 33 def content @content end |
#popups ⇒ ::Array[Component::Overlay] (readonly)
@return — the open overlays in stacking order; last is topmost. Holds both Component::Popup modals and bare Component::Overlays (Component::Overlay#modal?). The array must not be mutated by callers.
38 39 40 |
# File 'lib/tuile/screen_pane.rb', line 38 def popups @popups end |
Instance Method Details
#add_popup(window) ⇒ void
This method returns an undefined value.
Adds an overlay and invalidates it for repaint. A Component::Popup is centered and grabs focus; a bare Component::Overlay is left wherever the caller positioned it and does not take focus, so the component that was focused keeps the cursor and keeps receiving keys — the overlay floats above the content, driven from app code.
The whole subtree is invalidated, not just the overlay wrapper (which paints nothing on its own): a reopened popup may land on cells that the tiled content has since overpainted, and if its rect is unchanged from last time its content components won't re-invalidate themselves — so without this the overlay's contents would stay blank on reopen.
@param window — any overlay, modal or not.
71 72 73 74 75 76 77 78 79 80 81 82 83 |
# File 'lib/tuile/screen_pane.rb', line 71 def add_popup(window) raise TypeError, "expected Overlay, got #{window.inspect}" unless window.is_a? Component::Overlay raise ArgumentError, "#{window} already has a parent #{window.parent}" unless window.parent.nil? @popup_prior_focus[window] = screen.focused @popups << window add_child(window) # appended: popups paint over the tiled content if window.modal? window.center screen.focused = window end window.on_tree { |c| screen.invalidate(c) } end |
#bubble_key(key, scope) ⇒ Boolean
Delivers key to Tuile::Screen#focused and bubbles it up the ancestor chain,
stopping at (and including) scope. Delivers to no one — returning false
— when focus is nil or sits outside scope; the latter is what makes an
open popup modal, since focus is always inside it and content beneath
never receives keys.
@param key
@param scope — the modal scope root (topmost popup or content).
@return — true if some component on the chain handled the key.
317 318 319 320 321 322 323 |
# File 'lib/tuile/screen_pane.rb', line 317 def bubble_key(key, scope) chain = focus_chain(scope) return false if chain.nil? chain.each { |c| return true if c.handle_key(key) } false end |
#detach_all ⇒ void
This method returns an undefined value.
Unmounts everything: each child is detached — firing Component#on_detached down its subtree — and every slot is emptied. Terminal; the pane isn't reusable afterwards, and Tuile::Screen#close is its only caller.
Deliberately not named close (Component::Overlay#close already means
"remove me from the pane"), and deliberately not a generic
Component#remove_all_children: a slot container calling that would empty
@children while #content / #footer still pointed at detached
components, which is the desync the tree API exists to prevent.
115 116 117 118 119 120 121 |
# File 'lib/tuile/screen_pane.rb', line 115 def detach_all screen.focused = nil # …so the focus repair in on_child_removed has nothing to do children.dup.each { detach_child(_1) } @content = nil @popups.clear @popup_prior_focus.clear end |
#enclosing_popup(component) ⇒ Component::Overlay?
@param component
@return — component itself when it is an
overlay, else the nearest overlay above it, else nil.
304 305 306 307 |
# File 'lib/tuile/screen_pane.rb', line 304 def enclosing_popup(component) component = component.parent until component.nil? || component.is_a?(Component::Overlay) component end |
#first_tab_stop_or_root(root) ⇒ Component?
First Component#tab_stop? in root's subtree (pre-order), falling
back to root itself when the subtree has no tab stops. Returns nil
if root is nil.
@param root
346 347 348 349 350 351 |
# File 'lib/tuile/screen_pane.rb', line 346 def first_tab_stop_or_root(root) return nil if root.nil? root.on_tree { |c| return c if c.tab_stop? } root end |
#focus_chain(scope) ⇒ ::Array[Component]?
Tuile::Screen#focused and its ancestors up to and including scope.
@param scope — the modal scope root (topmost popup or content).
@return — the chain, innermost first; nil when
focus is nil or sits outside scope.
329 330 331 332 333 334 335 336 337 338 339 |
# File 'lib/tuile/screen_pane.rb', line 329 def focus_chain(scope) chain = [] cursor = screen.focused until cursor.nil? chain << cursor break if cursor.equal?(scope) cursor = cursor.parent end chain.last.equal?(scope) ? chain : nil end |
#focusable? ⇒ Boolean
40 |
# File 'lib/tuile/screen_pane.rb', line 40 def focusable? = false |
#handle_key(key) ⇒ Boolean
Delivers a key to Tuile::Screen#focused, then bubbles it up the focus chain —
the first component whose handle_key returns true wins.
Bubbling stops at the scope root: the topmost modal popup when one is open, else the tiled #content. Focus that is nil or sits outside the scope receives nothing, which is what keeps an open modal popup modal. Non-modal overlays are never the scope: focus stays in the content beneath them, and the overlay is driven by app code (which forwards keys to it explicitly), so it doesn't appear in this path at all.
Because an ancestor sees a key only after every descendant on the chain declined it, the scope root is the natural home for scope-wide fallbacks — a form's default button, or a layout's one-key jumps to its panes (a focused Component::TextField consumes the key first, so typing is never hijacked).
@param key
@return — true if the key was handled.
176 177 178 179 180 181 |
# File 'lib/tuile/screen_pane.rb', line 176 def handle_key(key) scope = modal_popup || @content return false if scope.nil? bubble_key(key, scope) end |
#handle_mouse(event) ⇒ void
This method returns an undefined value.
Mouse events check popups in reverse stacking order (topmost first), and fall through to content only when no popup is hit and no modal popup is open. This preserves modal click-blocking — an open modal eats clicks even outside its rect — while a non-modal overlay blocks nothing: clicks inside it route to it (e.g. click-to-select), clicks elsewhere reach the content beneath.
A left click also dismisses the open popups it landed outside of that asked for it (Component::Overlay#close_on_outside_click?). That is a second thing happening on a click, but not a second dispatch: the click is still delivered exactly once, down one chain, and a dismissed popup is closed rather than told.
"Outside" is measured against the Component::Overlay#owner chain, not against one rect and not against stacking order: the popup the click hit is kept, and so is every popup that one belongs to, transitively. That is what stops a dialog being dismissed by a click on a dropdown its own field opened, and a menu cascade being dismissed by a click on one of its own deeper panels. Order carries no meaning here — between unrelated overlays it is merely the order they opened in — so ownership is declared rather than inferred from the stack.
Two halves of the ordering are load-bearing, and both are specced:
- Snapshot before routing. A popup the delivered click opens must not be in the set (it would immediately dismiss itself — every Component::Select would be unopenable by mouse).
- Close after routing. A widget toggling its own overlay from a click on its face closes it during delivery, and Component::Overlay#close is idempotent, so the dismissal no-ops. Close first and the widget sees a shut overlay and reopens it — a Select's dropdown could then never be dismissed by clicking the Select.
The snapshot is a fresh array for a third reason: a handler may close
further popups, and @popups must not be mutated mid-iteration.
@param event
236 237 238 239 240 241 242 243 244 |
# File 'lib/tuile/screen_pane.rb', line 236 def handle_mouse(event) hit = @popups.reverse_each.find { _1.rect.contains?(event.point) } dismissable = event. == :left ? @popups - kept_by(hit) : [] clicked = hit || (@content if modal_popup.nil?) clicked&.handle_mouse(event) dismissable.each { _1.close if _1.close_on_outside_click? } end |
#handle_paste(text) ⇒ Boolean
Delivers pasted text along the same focus chain #handle_key bubbles along, and with the same scoping — first Component#handle_paste returning true wins.
@param text
@return — true if the text was consumed.
188 189 190 191 192 193 194 195 196 197 |
# File 'lib/tuile/screen_pane.rb', line 188 def handle_paste(text) scope = modal_popup || @content return false if scope.nil? chain = focus_chain(scope) return false if chain.nil? chain.each { |c| return true if c.handle_paste(text) } false end |
#has_popup?(window) ⇒ Boolean
@param window
@return — true if this pane currently hosts the popup.
125 |
# File 'lib/tuile/screen_pane.rb', line 125 def has_popup?(window) = @popups.include?(window) # rubocop:disable Naming/PredicatePrefix |
#kept_by(hit) ⇒ ::Array[Component::Overlay]
The overlays a click counts as landing inside: the one it hit, plus
every overlay that one belongs to, up the Component::Overlay#owner
chain. An owner is any component, so it is resolved to the overlay
enclosing it (an overlay resolves to itself) — which keeps the
relationship a live tree question rather than one frozen when the overlay
opened. The include? guard makes a mis-wired cycle terminate instead of
hanging the UI thread.
@param hit — the overlay the click landed in, if any.
291 292 293 294 295 296 297 298 299 |
# File 'lib/tuile/screen_pane.rb', line 291 def kept_by(hit) kept = [] = hit while && !kept.include?() kept << = enclosing_popup(.owner) end kept end |
#layout ⇒ void
This method returns an undefined value.
Gives #content the whole pane rect — the pane reserves nothing for itself. Each popup re-resolves its Component::Popup#declared_size against the new screen via Component::Popup#reposition — so a Fraction size tracks resize — repositioning itself (modal popups recenter; non-modal overlays keep the top-left their owner assigned).
148 149 150 151 152 153 |
# File 'lib/tuile/screen_pane.rb', line 148 def layout return if rect.empty? @content&.rect = rect @popups.each(&:reposition) end |
#modal_popup ⇒ Component::Popup?
@return — the topmost modal overlay, or nil when only bare Component::Overlays (or none) are open. This is the "modal owner": the popup that scopes key dispatch, blocks mouse clicks, and confines Tab cycling. Bare overlays are excluded — they float above the content without capturing input.
132 |
# File 'lib/tuile/screen_pane.rb', line 132 def modal_popup = @popups.reverse_each.find(&:modal?) |
#on_child_removed(child) ⇒ void
This method returns an undefined value.
Focus repair when a child detaches. Default Component#on_child_removed
would refocus to self (the pane), which isn't a useful focus target.
Instead, route focus to the first interactable widget in the now-topmost
modal popup; falling back to the focus snapshotted when this popup was opened
(if still attached and still focusable); then to the first interactable
widget in #content; then to #content itself; then nil.
"First interactable widget" = first Component#tab_stop? in pre-order;
if a scope has no tab stops at all (a borderless ESC-to-close popup, or
tiled content made entirely of Component::Labels), we focus the scope's root so
q/ESC still has somewhere to dispatch from.
@param child
259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 |
# File 'lib/tuile/screen_pane.rb', line 259 def on_child_removed(child) return unless attached? f = screen.focused return if f.nil? cursor = f while cursor if cursor == child fallback = first_tab_stop_or_root(modal_popup) if fallback.nil? && @removing_popup_prior&.attached? && @removing_popup_prior.focusable? fallback = @removing_popup_prior end fallback ||= first_tab_stop_or_root(@content) screen.focused = fallback return end cursor = cursor.parent end end |
#rect=(new_rect) ⇒ void
This method returns an undefined value.
Re-lays out children whenever the pane's own rect changes.
@param new_rect
137 138 139 140 |
# File 'lib/tuile/screen_pane.rb', line 137 def rect=(new_rect) super layout end |
#remove_popup(window) ⇒ void
This method returns an undefined value.
Removes a popup. If the popup held focus, focus shifts to the now-topmost remaining popup, falling back to the focus snapshotted when the popup was opened (if still attached), then to #content, then to nil.
@param window
90 91 92 93 94 95 96 97 98 99 100 101 102 103 |
# File 'lib/tuile/screen_pane.rb', line 90 def remove_popup(window) raise Tuile::Error, "#{window} is not an open popup on this pane" unless @popups.delete(window) prior = @popup_prior_focus.delete(window) @removing_popup_prior = prior remove_child(window) # Runs after the detach, so a prior pointing *inside* the removed popup is # detectable via `p.root == window`: forward it to *our* prior, so chained # closures climb back to the original owner instead of stopping at a # detached component. @popup_prior_focus.transform_values! { |p| p && p.root == window ? prior : p } ensure @removing_popup_prior = nil end |
#repaint ⇒ void
This method returns an undefined value.
Pane paints nothing itself; its children paint over the entire rect.
157 |
# File 'lib/tuile/screen_pane.rb', line 157 def repaint; end |