Class: Tuile::ScreenPane

Inherits:
Component show all
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, 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

Attributes inherited from Component

#key_shortcut, #on_theme_changed, #parent, #rect

Instance Method Summary collapse

Methods inherited from Component

#active=, #active?, #attached?, #children_tile_rect?, #clear_background, #cursor_position, #depth, #find_shortcut_component, #focus, #invalidate, #keyboard_hint, #on_focus, #on_tree, #on_width_changed, #root, #screen, #tab_stop?

Constructor Details

#initializeScreenPane

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

#contentComponent?

@return — the tiled content component.

Returns:



30
31
32
# File 'lib/tuile/screen_pane.rb', line 30

def content
  @content
end

#popups::Array[Component] (readonly)

@return — 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.

Returns:



34
35
36
# File 'lib/tuile/screen_pane.rb', line 34

def popups
  @popups
end

#status_barComponent::Label (readonly)

@return — the bottom status bar.

Returns:



36
37
38
# File 'lib/tuile/screen_pane.rb', line 36

def status_bar
  @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.

@param window

Parameters:



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

#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.

Parameters:

Returns:

  • (Boolean)


232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/tuile/screen_pane.rb', line 232

def bubble_key(key, scope)
  chain = []
  cursor = screen.focused
  until cursor.nil?
    chain << cursor
    break if cursor.equal?(scope)

    cursor = cursor.parent
  end
  return false unless chain.last.equal?(scope)

  chain.each { |c| return true if c.handle_key(key) }
  false
end

#children::Array[Component]

Children for tree traversal: content first, popups in stacking order, status bar last.

Returns:



42
# File 'lib/tuile/screen_pane.rb', line 42

def children = [*[@content].compact, *@popups, @status_bar]

#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

Parameters:

Returns:



252
253
254
255
256
257
# File 'lib/tuile/screen_pane.rb', line 252

def first_tab_stop_or_root(root)
  return nil if root.nil?

  root.on_tree { |c| return c if c.tab_stop? }
  root
end

#focusable?Boolean

Returns:

  • (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.

  1. 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.
  2. 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.

@param key

@return — true if the key was handled.

Parameters:

  • key (String)

Returns:

  • (Boolean)


159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/tuile/screen_pane.rb', line 159

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.

@param event

Parameters:



182
183
184
185
186
# File 'lib/tuile/screen_pane.rb', line 182

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

@param window

@return — true if this pane currently hosts the popup.

Parameters:

Returns:

  • (Boolean)


108
# File 'lib/tuile/screen_pane.rb', line 108

def has_popup?(window) = @popups.include?(window) # rubocop:disable Naming/PredicatePrefix

#layoutvoid

This method returns an undefined value.

Lays out content (full pane minus the bottom row) and the status bar (bottom row). Each popup re-resolves its Component::Popup#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).



131
132
133
134
135
136
137
# File 'lib/tuile/screen_pane.rb', line 131

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(&:reposition)
  @status_bar.rect = Rect.new(rect.left, rect.top + rect.height - 1, rect.width, 1)
end

@return — 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.

Returns:



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.

@param child

Parameters:



201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/tuile/screen_pane.rb', line 201

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

Parameters:



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.

@param window

Parameters:



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

#repaintvoid

This method returns an undefined value.

Pane paints nothing itself; its children paint over the entire rect.



141
# File 'lib/tuile/screen_pane.rb', line 141

def repaint; end