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#handle_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.
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, #children, #id, #parent, #rect
Instance Method Summary collapse
-
#add_popup(window, placement = nil) ⇒ void
Adds an overlay at
placementand invalidates it for repaint; the next settle gives it its rect. -
#bubble_key(key, scope) ⇒ Boolean
Delivers
keyto Tuile::Screen#focused and bubbles it up the ancestor chain, stopping at (and including)scope. -
#check_placement(placement) ⇒ void
Rejects a non-placement where the app named it, rather than mid-pass: the layout is deferred, so the
NoMethodErrorfrom a missingrect_forwould otherwise surface a turn later, under this file's backtrace. -
#constrain(popup, placement) ⇒ void
Moves an open popup; it takes the new rect on the next settle.
-
#detach_all ⇒ void
Unmounts everything: each child is detached — firing Component#handle_detached down its subtree — and every slot is emptied.
-
#dismissing_popups_outside(point, left:) ⇒ void
Runs the press delivery in the block, then dismisses the open popups a left press landed outside of that asked for it (Component::Overlay#close_on_outside_click?).
-
#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_child_removed(child) ⇒ void
Focus repair when a child detaches.
-
#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. -
#handle_paste(text) ⇒ void
Delivers pasted text to Tuile::Screen#focused — and to nobody else.
-
#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.
-
#key_scope ⇒ Component?
The root of the current key scope: the topmost modal popup when one is open, else the tiled #content.
-
#lose_anchor(popup) ⇒ void
Leaves a popup whose anchor was detached or hidden at its last rect.
-
#modal_popup ⇒ Component::Popup?
@return — the topmost modal overlay, or nil when only bare Component::Overlays (or none) are open.
-
#mouse_root_at(point) ⇒ Component?
Where Mouse::Router starts its walk for a pointer at
point: the topmost popup containing it, else #content — unless a modal popup is open, which eats the event even outside its rect. -
#place(popup) ⇒ void
@param
popup. -
#placement(popup) ⇒ Component::Overlay::Placement?
@param
popup— an open popup. -
#popup_at(point) ⇒ Component::Overlay?
@param
point. -
#relayout ⇒ void
Gives #content the whole pane rect — the pane reserves nothing for itself — and each popup the rect its placement asks for, in stacking order, so a submenu is placed after the panel it hangs from.
-
#remark_moved_anchors ⇒ Boolean
Re-marks the pane when an anchored popup's anchor now resolves elsewhere than where its last placement read it.
-
#remove_popup(window) ⇒ void
Removes a popup.
-
#repaint(_canvas) ⇒ void
Pane paints nothing itself; its children paint over the entire rect.
-
#resolve_anchor(anchor) ⇒ Rect?
A placement's Component::Overlay::Placement#anchor in screen coordinates: a
Rectpasses through, a component resolves while it is on screen, andnilmeans either unanchored or gone — #place tells those apart by whether an anchor was declared at all.
Methods inherited from Component
#absolute_extent_rect, #absolute_rect, #active=, #active?, #add_child, #attached?, #bg_color, #bg_color=, #children_tile_rect?, #clear_inside_extent, #clear_outside_extent, #cursor_position, #depth, #detach_child, #error_bg_color, #extent, final, final_methods, #fire_lifecycle, #flush_layout, #focus, #handle_attached, #handle_blur, #handle_detached, #handle_focus, #handle_locale_changed, #handle_mouse_down?, #handle_mouse_drag, #handle_mouse_enter, #handle_mouse_exit, #handle_mouse_move?, #handle_mouse_scroll?, #handle_mouse_up, #handle_rect_changed, #handle_theme_changed, #height, #inspect, #inspect_details, #invalidate, #invalidate_children, #invalidate_layout, #layout_dirty?, listener, #local_extent_rect, #local_rect, #locale, #on_locale_changed, #on_theme_changed, #perform_relayout, #rect_stale?, #remove_child, #repair_focus_after_hiding, #root, #screen, #scroll_to_visible, #size, #stale_layout_ancestor, #tab_stop?, #to_local, #to_screen, verify_final!, #visible=, #visible?, #walk_shown_tree, #walk_tree, #width
Methods included from Final
#final, #final_methods, #verify_final!
Methods included from Listeners::Declare
Constructor Details
#initialize ⇒ ScreenPane
Returns a new instance of ScreenPane.
22 23 24 25 26 27 28 29 30 31 32 33 34 |
# 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 = {} # Where each open popup wants to be, and the anchor rect its last # placement used (`:lost` once the anchor went away) — see #relayout. @placements = {}.compare_by_identity @placed_anchors = {}.compare_by_identity end |
Instance Attribute Details
#content ⇒ Component?
@return — the tiled content component.
37 38 39 |
# File 'lib/tuile/screen_pane.rb', line 37 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.
42 43 44 |
# File 'lib/tuile/screen_pane.rb', line 42 def popups @popups end |
Instance Method Details
#add_popup(window, placement = nil) ⇒ void
This method returns an undefined value.
Adds an overlay at placement and invalidates it for repaint; the next
settle gives it its rect. A Component::Popup grabs focus; a bare
Component::Overlay does not, 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.
@param placement — where it wants to be; nil takes its Component::Overlay#default_placement.
78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/tuile/screen_pane.rb', line 78 def add_popup(window, placement = nil) 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? placement ||= window.default_placement check_placement(placement) @popup_prior_focus[window] = screen.focused @placements[window] = placement @popups << window add_child(window) # appended: popups paint over the tiled content screen.focused = window if window.modal? window.walk_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.
438 439 440 441 442 443 444 |
# File 'lib/tuile/screen_pane.rb', line 438 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 |
#check_placement(placement) ⇒ void
This method returns an undefined value.
Rejects a non-placement where the app named it, rather than mid-pass: the
layout is deferred, so the NoMethodError from a missing rect_for would
otherwise surface a turn later, under this file's backtrace.
@param placement
336 337 338 339 340 |
# File 'lib/tuile/screen_pane.rb', line 336 def check_placement(placement) return if placement.is_a?(Component::Overlay::Placement) raise TypeError, "#{placement.class} must include Tuile::Component::Overlay::Placement" end |
#constrain(popup, placement) ⇒ void
This method returns an undefined value.
Moves an open popup; it takes the new rect on the next settle. Component::Overlay#placement= is the usual way in.
@param popup — an open popup.
@param placement — where it wants to be.
105 106 107 108 109 110 111 112 113 |
# File 'lib/tuile/screen_pane.rb', line 105 def constrain(popup, placement) raise ArgumentError, "#{popup} is not an open popup on this pane" unless has_popup?(popup) check_placement(placement) return if @placements[popup] == placement @placements[popup] = placement invalidate_layout end |
#detach_all ⇒ void
This method returns an undefined value.
Unmounts everything: each child is detached — firing Component#handle_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.
147 148 149 150 151 152 153 154 155 |
# File 'lib/tuile/screen_pane.rb', line 147 def detach_all screen.focused = nil # …so the focus repair in handle_child_removed has nothing to do children.dup.each { detach_child(_1) } @content = nil @popups.clear @popup_prior_focus.clear @placements.clear @placed_anchors.clear end |
#dismissing_popups_outside(point, left:) ⇒ void
This method returns an undefined value.
Runs the press delivery in the block, then dismisses the open popups a left press landed outside of that asked for it (Component::Overlay#close_on_outside_click?). 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 press 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 the block. A popup the delivered press opens must not be in the set (it would immediately dismiss itself — every Component::Select would be unopenable by mouse).
- Close after the block. A widget toggling its own overlay from a press 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 point
@param left — whether the press was the left button; no other button dismisses.
288 289 290 291 292 |
# File 'lib/tuile/screen_pane.rb', line 288 def dismissing_popups_outside(point, left:) dismissable = left ? @popups - kept_by(popup_at(point)) : [] yield dismissable.each { _1.close if _1.close_on_outside_click? } end |
#enclosing_popup(component) ⇒ Component::Overlay?
@param component
@return — component itself when it is an
overlay, else the nearest overlay above it, else nil.
425 426 427 428 |
# File 'lib/tuile/screen_pane.rb', line 425 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, or if root is itself hidden — there is nothing in
there to focus, so the caller falls through to its next candidate.
@param root
468 469 470 471 472 473 |
# File 'lib/tuile/screen_pane.rb', line 468 def first_tab_stop_or_root(root) return nil if root.nil? || !root.visible? root.walk_shown_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.
450 451 452 453 454 455 456 457 458 459 460 |
# File 'lib/tuile/screen_pane.rb', line 450 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
44 |
# File 'lib/tuile/screen_pane.rb', line 44 def focusable? = false |
#handle_child_removed(child) ⇒ void
This method returns an undefined value.
Focus repair when a child detaches. Default Component#handle_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
307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 |
# File 'lib/tuile/screen_pane.rb', line 307 def handle_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 |
#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.
220 221 222 223 224 225 |
# File 'lib/tuile/screen_pane.rb', line 220 def handle_key?(key) scope = key_scope return false if scope.nil? bubble_key(key, scope) end |
#handle_paste(text) ⇒ void
This method returns an undefined value.
Delivers pasted text to Tuile::Screen#focused — and to nobody else.
Scoped exactly like #handle_key? (focus that is nil or sits outside the
modal scope receives nothing, which is what keeps a popup modal) but
not bubbled: an ancestor is never offered a paste its descendant
declined, and unhandled text is dropped. Why keys bubble and pastes
don't: D_bracketed_paste.
@param text
236 237 238 239 240 241 242 243 244 |
# File 'lib/tuile/screen_pane.rb', line 236 def handle_paste(text) scope = key_scope return if scope.nil? chain = focus_chain(scope) return if chain.nil? chain.first.handle_paste(text) end |
#has_popup?(window) ⇒ Boolean
@param window
@return — true if this pane currently hosts the popup.
159 |
# File 'lib/tuile/screen_pane.rb', line 159 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.
408 409 410 411 412 413 414 415 416 |
# File 'lib/tuile/screen_pane.rb', line 408 def kept_by(hit) kept = [] = hit while && !kept.include?() kept << = enclosing_popup(.owner) end kept end |
#key_scope ⇒ Component?
The root of the current key scope: the topmost modal popup when one is open, else the tiled #content. Keys bubble up to it and no further, a paste reaches only a focus chain inside it, and Tab cycles only the stops beneath it — so a component outside it is one the keyboard cannot reach at all. The mouse resolves against a point instead (#mouse_root_at).
@return — nil when the pane holds neither.
175 |
# File 'lib/tuile/screen_pane.rb', line 175 def key_scope = modal_popup || @content |
#lose_anchor(popup) ⇒ void
This method returns an undefined value.
Leaves a popup whose anchor was detached or hidden at its last rect. It warns, once, because closing the popup is its owner's job and an owner that forgot leaves it floating over nothing.
@param popup
373 374 375 376 377 378 |
# File 'lib/tuile/screen_pane.rb', line 373 def lose_anchor(popup) return if @placed_anchors[popup] == :lost @placed_anchors[popup] = :lost Tuile.logger.warn("#{popup} lost its anchor; left where it was — close it when the anchor goes") 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.
166 |
# File 'lib/tuile/screen_pane.rb', line 166 def modal_popup = @popups.reverse_each.find(&:modal?) |
#mouse_root_at(point) ⇒ Component?
Where Mouse::Router starts its walk for a pointer at point: the
topmost popup containing it, else #content — unless a modal popup is
open, which eats the event even outside its rect. A non-modal overlay
blocks nothing: a point outside it reaches the content beneath.
@param point
253 |
# File 'lib/tuile/screen_pane.rb', line 253 def mouse_root_at(point) = popup_at(point) || (@content if modal_popup.nil?) |
#place(popup) ⇒ void
This method returns an undefined value.
@param popup
356 357 358 359 360 361 362 363 364 365 366 |
# File 'lib/tuile/screen_pane.rb', line 356 def place(popup) placement = @placements.fetch(popup) anchor = placement.anchor anchor_rect = resolve_anchor(anchor) unless anchor.nil? return lose_anchor(popup) if anchor_rect.nil? @placed_anchors[popup] = anchor_rect end popup.rect = placement.rect_for(popup, rect.size, anchor_rect) end |
#placement(popup) ⇒ Component::Overlay::Placement?
@param popup — an open popup.
@return — where it wants to be; nil
if it isn't open here.
95 |
# File 'lib/tuile/screen_pane.rb', line 95 def placement(popup) = @placements[popup] |
#popup_at(point) ⇒ Component::Overlay?
@param point
@return — the topmost popup containing point.
420 |
# File 'lib/tuile/screen_pane.rb', line 420 def popup_at(point) = @popups.reverse_each.find { _1.rect.contains?(point) } |
#relayout ⇒ void
This method returns an undefined value.
Gives #content the whole pane rect — the pane reserves nothing for itself — and each popup the rect its placement asks for, in stacking order, so a submenu is placed after the panel it hangs from.
Re-running it moves nothing that stood still: a placement is a rule, so a second popup opening re-derives the first one's rect unchanged.
An empty pane collapses every popup rather than placing it — an At rect
would otherwise stand in a pane with no cells — and has no return if rect.empty? guard, which would strand the content at its last rect
(D_empty_ancestor).
189 190 191 192 193 194 195 196 |
# File 'lib/tuile/screen_pane.rb', line 189 def relayout @content&.rect = local_rect if rect.empty? @popups.each { _1.rect = Rect.new(0, 0, 0, 0) } else @popups.each { place(_1) } end end |
#remark_moved_anchors ⇒ Boolean
Re-marks the pane when an anchored popup's anchor now resolves elsewhere
than where its last placement read it. Tuile::Screen#flush_layout asks once
its drain is empty, because this pane's pass runs before the content an
anchor sits in, so a settle that moved the content handed the pass a
stale anchor. Anchors in lib/ converge in one more round — the content
never moves for a popup, and a cascade anchors to a Rect — but a popup
anchored inside another takes one round per level.
@return — whether it marked, so the screen drains again.
388 389 390 391 392 393 394 395 396 397 |
# File 'lib/tuile/screen_pane.rb', line 388 def remark_moved_anchors return false if rect.empty? # #relayout collapses rather than places then, so nothing records moved = @popups.any? do |popup| anchor = @placements[popup].anchor !anchor.nil? && (resolve_anchor(anchor) || :lost) != @placed_anchors[popup] end invalidate_layout if moved moved 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
120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 |
# File 'lib/tuile/screen_pane.rb', line 120 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) @placements.delete(window) @placed_anchors.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(_canvas) ⇒ void
This method returns an undefined value.
Pane paints nothing itself; its children paint over the entire rect.
@param _canvas — see Component#repaint.
201 |
# File 'lib/tuile/screen_pane.rb', line 201 def repaint(_canvas); end |
#resolve_anchor(anchor) ⇒ Rect?
A placement's Component::Overlay::Placement#anchor in screen
coordinates: a Rect passes through, a component resolves while it is on
screen, and nil means either unanchored or gone — #place tells those
apart by whether an anchor was declared at all.
@param anchor
348 349 350 351 352 |
# File 'lib/tuile/screen_pane.rb', line 348 def resolve_anchor(anchor) return anchor if anchor.nil? || anchor.is_a?(Rect) ComponentUtil.effectively_visible?(anchor) ? anchor.absolute_extent_rect : nil end |