Class: Tuile::ScreenPane
- Defined in:
- lib/tuile/screen_pane.rb
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, the modal #popups stack, and the bottom #status_bar. 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 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?
The tiled content component.
-
#popups ⇒ Array<Component>
readonly
Overlay popups in stacking order; last is topmost.
-
#status_bar ⇒ Component::Label
readonly
The bottom status bar.
Attributes inherited from Component
#content_size, #key_shortcut, #on_theme_changed, #parent, #rect
Instance Method Summary collapse
-
#add_popup(window) ⇒ void
Adds a popup and invalidates it for repaint.
-
#children ⇒ Object
Children for tree traversal: content first, popups in stacking order, status bar last.
- #focusable? ⇒ Boolean
-
#handle_key(key) ⇒ Boolean
Dispatches a key in two phases, both scoped to the topmost modal popup (when one is open) or else the tiled #content.
-
#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.
-
#has_popup?(window) ⇒ Boolean
True if this pane currently hosts the popup.
-
#initialize ⇒ ScreenPane
constructor
A new instance of ScreenPane.
-
#layout ⇒ void
Lays out content (full pane minus the bottom row) and the status bar (bottom row).
-
#modal_popup ⇒ Component::Popup?
The topmost modal popup, or nil when only non-modal overlays (or no popups) 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?, #attached?, #cursor_position, #depth, #find_shortcut_component, #focus, #keyboard_hint, #on_child_content_size_changed, #on_focus, #on_tree, #popup_max_height, #popup_min_height, #root, #screen, #tab_stop?
Constructor Details
#initialize ⇒ ScreenPane
Returns a new instance of ScreenPane.
17 18 19 20 21 22 23 24 25 26 27 |
# File 'lib/tuile/screen_pane.rb', line 17 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 = {} @status_bar = Component::Label.new @status_bar.parent = self end |
Instance Attribute Details
#content ⇒ Component?
Returns the tiled content component.
30 31 32 |
# File 'lib/tuile/screen_pane.rb', line 30 def content @content end |
#popups ⇒ Array<Component> (readonly)
Returns overlay popups in stacking order; last is topmost. Holds both modal popups and non-modal overlays (Component::Popup#modal?). The array must not be mutated by callers.
34 35 36 |
# File 'lib/tuile/screen_pane.rb', line 34 def popups @popups end |
#status_bar ⇒ Component::Label (readonly)
Returns the bottom status bar.
36 37 38 |
# File 'lib/tuile/screen_pane.rb', line 36 def @status_bar end |
Instance Method Details
#add_popup(window) ⇒ void
This method returns an undefined value.
Adds a popup and invalidates it for repaint. A modal popup is centered and grabs focus; a non-modal overlay (Component::Popup#modal? false) is left wherever the caller positions 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.
68 69 70 71 72 73 74 75 76 77 78 79 80 |
# File 'lib/tuile/screen_pane.rb', line 68 def add_popup(window) raise TypeError, "expected Popup, got #{window.inspect}" unless window.is_a? Component::Popup raise ArgumentError, "#{window} already has a parent #{window.parent}" unless window.parent.nil? @popup_prior_focus[window] = screen.focused @popups << window window.parent = self if window.modal? window.center screen.focused = window end screen.invalidate(window) end |
#children ⇒ Object
Children for tree traversal: content first, popups in stacking order, status bar last.
42 |
# File 'lib/tuile/screen_pane.rb', line 42 def children = [*[@content].compact, *@popups, @status_bar] |
#focusable? ⇒ Boolean
38 |
# File 'lib/tuile/screen_pane.rb', line 38 def focusable? = false |
#handle_key(key) ⇒ Boolean
Dispatches a key in two phases, both scoped to the topmost modal popup (when one is open) or else the tiled #content. 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.
-
Capture — a Component#key_shortcut match anywhere in the scope focuses that component and consumes the key. Suppressed while a cursor-owner (Tuile::Screen#cursor_position) is mid-edit, so typing into a Component::TextField isn’t hijacked by a sibling’s shortcut.
-
Delivery — the key is handed to Tuile::Screen#focused and bubbles up its ancestor chain to the scope root; the first component to return true wins. Focus that is nil or sits outside the scope receives nothing, which is what keeps an open modal popup modal.
157 158 159 160 161 162 163 164 165 166 167 168 169 170 |
# File 'lib/tuile/screen_pane.rb', line 157 def handle_key(key) scope = modal_popup || @content return false if scope.nil? if screen.cursor_position.nil? target = scope.find_shortcut_component(key) unless target.nil? screen.focused = target return true end end 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.
180 181 182 183 184 |
# File 'lib/tuile/screen_pane.rb', line 180 def handle_mouse(event) clicked = @popups.reverse_each.find { _1.rect.contains?(event.point) } clicked = @content if clicked.nil? && modal_popup.nil? clicked&.handle_mouse(event) end |
#has_popup?(window) ⇒ Boolean
Returns true if this pane currently hosts the popup.
108 |
# File 'lib/tuile/screen_pane.rb', line 108 def has_popup?(window) = @popups.include?(window) # rubocop:disable Naming/PredicatePrefix |
#layout ⇒ void
This method returns an undefined value.
Lays out content (full pane minus the bottom row) and the status bar (bottom row). Modal popups self-recenter via Component::Popup#center; non-modal overlays keep the position their owner assigned.
129 130 131 132 133 134 135 |
# File 'lib/tuile/screen_pane.rb', line 129 def layout return if rect.empty? @content.rect = Rect.new(rect.left, rect.top, rect.width, [rect.height - 1, 0].max) unless @content.nil? @popups.each { |p| p.center if p.modal? } @status_bar.rect = Rect.new(rect.left, rect.top + rect.height - 1, rect.width, 1) end |
#modal_popup ⇒ Component::Popup?
Returns the topmost modal popup, or nil when only non-modal overlays (or no popups) are open. This is the “modal owner”: the popup that scopes key dispatch, blocks mouse clicks, owns the status bar, and confines Tab cycling. Non-modal overlays are excluded — they float above the content without capturing input.
115 |
# File 'lib/tuile/screen_pane.rb', line 115 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.
199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 |
# File 'lib/tuile/screen_pane.rb', line 199 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.
120 121 122 123 |
# File 'lib/tuile/screen_pane.rb', line 120 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.
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 |
# File 'lib/tuile/screen_pane.rb', line 87 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) # Detach first so the popup becomes its own root; then any prior # pointing *inside* that popup is detectable via `p.root == window`. window.parent = nil # If any other popup recorded its prior focus inside the popup we're # removing, forward it to *our* prior so chained closures still 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 } @removing_popup_prior = prior on_child_removed(window) 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.
139 |
# File 'lib/tuile/screen_pane.rb', line 139 def repaint; end |