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

Constant Summary collapse

BG_STATES =

Returns:

  • (::Array[Symbol])
BG_INHERIT =

Returns:

  • (Symbol)

Instance Attribute Summary collapse

Attributes inherited from Component

#bg_color, #children, #id, #on_locale_changed, #on_theme_changed, #parent, #rect

Instance Method Summary collapse

Methods inherited from Component

#active=, #active?, #add_child, #ambient_bg_color, #attached?, #children_tile_rect?, #clear_background, #clear_inside_extent, #clear_outside_extent, #coerce_bg_color, #cursor_position, #default_bg_color, #depth, #detach_child, #draw_char, #draw_text, #effective_bg_color, #error_bg_color, #extent, #extent_rect, final, final_methods, #fire_lifecycle, #focus, #handle_attached, #handle_blur, #handle_child_visibility_changed, #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_theme_changed, #handle_width_changed, #height, #inspect, #inspect_details, #invalidate, #invalidate_children, #locale, #remove_child, #repair_focus_after_hiding, #resolve_bg_color, #root, #screen, #size, #tab_stop?, verify_final!, #visible=, #visible?, #walk_shown_tree, #walk_tree, #width

Methods included from Final

#final, #final_methods, #verify_final!

Constructor Details

#initializeScreenPane

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

#contentComponent?

@return — the tiled content component.

Returns:



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.

Returns:



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.

Parameters:



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

Parameters:

Returns:

  • (Boolean)


325
326
327
328
329
330
331
# File 'lib/tuile/screen_pane.rb', line 325

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_allvoid

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.



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 handle_child_removed has nothing to do
  children.dup.each { detach_child(_1) }
  @content = nil
  @popups.clear
  @popup_prior_focus.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.

Parameters:

  • point (Point)
  • left: (Boolean)


244
245
246
247
248
# File 'lib/tuile/screen_pane.rb', line 244

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

@returncomponent itself when it is an overlay, else the nearest overlay above it, else nil.

Parameters:

Returns:



312
313
314
315
# File 'lib/tuile/screen_pane.rb', line 312

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

Parameters:

Returns:



355
356
357
358
359
360
# File 'lib/tuile/screen_pane.rb', line 355

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.

Parameters:

Returns:



337
338
339
340
341
342
343
344
345
346
347
# File 'lib/tuile/screen_pane.rb', line 337

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

Returns:

  • (Boolean)


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

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

Parameters:



263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
# File 'lib/tuile/screen_pane.rb', line 263

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.

Parameters:

  • key (String)

Returns:

  • (Boolean)


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_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

Parameters:

  • text (String)


192
193
194
195
196
197
198
199
200
# File 'lib/tuile/screen_pane.rb', line 192

def handle_paste(text)
  scope = modal_popup || @content
  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.

Parameters:

Returns:

  • (Boolean)


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.

Parameters:

Returns:



295
296
297
298
299
300
301
302
303
# File 'lib/tuile/screen_pane.rb', line 295

def kept_by(hit)
  kept = []
  overlay = hit
  while overlay && !kept.include?(overlay)
    kept << overlay
    overlay = enclosing_popup(overlay.owner)
  end
  kept
end

#layoutvoid

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

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

Returns:



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

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

Parameters:

Returns:



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

def mouse_root_at(point) = popup_at(point) || (@content if modal_popup.nil?)

@param point

@return — the topmost popup containing point.

Parameters:

Returns:



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

def popup_at(point) = @popups.reverse_each.find { _1.rect.contains?(point) }

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



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

Parameters:



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

#repaintvoid

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