Class: Tuile::Component::Popup
- Inherits:
-
Component
- Object
- Component
- Tuile::Component::Popup
- Includes:
- HasContent
- Defined in:
- lib/tuile/component/popup.rb,
sig/tuile.rbs
Overview
An overlay that wraps any Tuile::Component as its content. Popup itself paints nothing — it's a transparent host that handles its lifecycle (#open / #close / #open?, ESC/q to close) and holds a top-down #size the Screen applies.
The popup does not size itself to its content. Its box is declared by #size — a Fraction (resolved against the screen every layout pass, so it tracks resize) or an absolute Size (clamped to the screen). The default is Fraction::HALF: half the screen, centered. The wrapped content then fills that box and handles its own overflow by wrapping and scrolling, so use content that can — a TextView or TextArea — for anything longer than fits. A Label only truncates.
Modal by default: it centers on the screen, grabs focus, eats keys, and
blocks clicks beneath it. Pass modal: false for a non-modal overlay
that floats above the content without taking focus or capturing input —
the caller positions it (via #rect=), sizes it, and drives it from app
code. That's the building block for an autocomplete/slash-command list
anchored to a text field's caret: typing keeps focus in the input while
the caller refills and drives the overlay.
The wrapped content fills the popup's full #rect; if you want a frame and caption, wrap a Window (or any subclass — including LogWindow) and let it draw its own border:
window = Component::Window.new("Help")
window.content = Component::List.new.tap { _1.lines = lines }
Component::Popup.new(content: window).open
Bare content also works (a Label, a List…), in which case the popup is borderless.
q and ESC close the popup — handled here, at the top of the popup's own
subtree, so the key only arrives after every component on the focus chain
declined it (see ScreenPane#handle_key). That's why typing q into a
nested TextField doesn't dismiss the popup: the field
consumes it first.
A left click outside the popup closes it too, modal or not — see #close_on_outside_click? for the exact contract and #on_close= for the notice a driver hears when it happens.
Direct Known Subclasses
Instance Attribute Summary collapse
-
#close_on_outside_click ⇒ Object
writeonly
@return — see #close_on_outside_click?.
-
#on_close ⇒ Proc?
A callback taking no arguments, fired once this popup has left the screen — however it left: #close, a direct Screen#remove_popup, an outside click, or teardown via Screen#close.
-
#owner ⇒ Component?
The component this overlay is part of, or
nil(the default) when it is an overlay in its own right. -
#size ⇒ Size, Fraction
@return — the popup's declared size.
Attributes included from HasContent
Instance Method Summary collapse
-
#center ⇒ void
Recenters the popup on the screen, preserving its current width/height.
-
#close ⇒ void
Removes this popup from the Screen.
-
#close_on_outside_click? ⇒ Boolean
Whether a left click outside this popup closes it (default true, modal or not).
- #focusable? ⇒ Boolean
-
#handle_key(key) ⇒ Boolean
qand ESC close the popup. -
#handle_mouse ⇒ void
@param
event. -
#initialize(content: nil, modal: true, size: Fraction::HALF, close_on_outside_click: true) ⇒ Object
constructor
@param
content— initial content; can be set later via HasContent#content=. -
#layout(content) ⇒ void
Content fills the popup's full rect — Popup has no border to subtract.
-
#modal? ⇒ Boolean
@return — whether this popup is modal.
-
#on_detached ⇒ void
Fires #on_close.
- #on_focus ⇒ void
- #open ⇒ self
-
#open? ⇒ Boolean
@return — true if this popup is currently mounted on the screen.
-
#rect=(new_rect) ⇒ void
Reassigns the popup's rect, escalating to a full scene repaint when an open popup shrinks or moves so its new rect no longer covers the cells it previously painted.
-
#reposition ⇒ void
Re-resolves #size against the current screen and repositions the popup itself (this is not laying out content — the popup's own rect): a modal popup recenters; a non-modal overlay keeps its caller-assigned top-left (only its size follows the screen).
Constructor Details
#initialize(content: nil, modal: true, size: Fraction::HALF, close_on_outside_click: true) ⇒ Object
@param content — initial content; can be set later via HasContent#content=. The content fills the popup's Tuile::Component#rect; it does not determine the popup's size.
@param modal — true (default) for a centered, focus-grabbing, input-capturing modal; false for a non-modal overlay the caller positions and drives (see the class docs).
@param size — the popup's size, applied top-down. A Fraction is resolved against the screen each layout pass; a Size is clamped to the screen. Defaults to Fraction::HALF.
@param close_on_outside_click — true (default) to dismiss on a left click that misses this popup. See #close_on_outside_click?.
61 62 63 64 65 66 67 68 69 70 71 72 |
# File 'lib/tuile/component/popup.rb', line 61 def initialize(content: nil, modal: true, size: Fraction::HALF, close_on_outside_click: true) super() @modal = modal @size = size @close_on_outside_click = close_on_outside_click @owner = nil @on_close = nil @content = nil self.content = content unless content.nil? reposition end |
Instance Attribute Details
#close_on_outside_click=(value) ⇒ Object (writeonly)
@return — see #close_on_outside_click?.
105 106 107 |
# File 'lib/tuile/component/popup.rb', line 105 def close_on_outside_click=(value) @close_on_outside_click = value end |
#on_close ⇒ Proc?
A callback taking no arguments, fired once this popup has left the screen — however it left: #close, a direct Screen#remove_popup, an outside click, or teardown via Screen#close. That unconditionality is the point, so it hangs off #on_detached rather than #close; a driver keeping its own record of open popups reconciles it here and cannot drift (MenuBar::Cascade is the worked example).
It fires after the popup is detached, so #open? is already false and the usual Tuile::Component#on_detached caveats apply: release state, don't inspect the tree, keep it trivial (it may run while the pane is mid-way through closing a batch of popups, and a raise propagates).
135 136 137 |
# File 'lib/tuile/component/popup.rb', line 135 def on_close @on_close end |
#owner ⇒ Component?
The component this overlay is part of, or nil (the default) when it
is an overlay in its own right. It exists for outside-click dismissal:
a click inside this popup also counts as inside whatever popup encloses
its owner, so the host is not dismissed by a click on a panel it put
there. See #close_on_outside_click?.
Set it to the driver — ComboBox hands its dropdown
self — rather than to the enclosing popup: the driver knows what it
is, while the popup above it is a tree relationship the pane resolves
at click time (so it cannot go stale). Any Tuile::Component is accepted, and
a Popup resolves to itself, which is how a
MenuBar::Cascade chains each panel to the one it dropped
out of.
121 122 123 |
# File 'lib/tuile/component/popup.rb', line 121 def owner @owner end |
Instance Method Details
#center ⇒ void
This method returns an undefined value.
Recenters the popup on the screen, preserving its current width/height.
213 214 215 |
# File 'lib/tuile/component/popup.rb', line 213 def center self.rect = rect.centered(screen.size) end |
#close ⇒ void
This method returns an undefined value.
Removes this popup from the Screen. No-op if not currently open.
183 184 185 |
# File 'lib/tuile/component/popup.rb', line 183 def close screen.remove_popup(self) end |
#close_on_outside_click? ⇒ Boolean
Whether a left click outside this popup closes it (default true, modal or
not). The pane does the closing — ScreenPane#handle_mouse snapshots
the open popups before routing the click and closes the dismissable
ones after, so a widget that toggles its own overlay from a click on
its face (a Select, a MenuBar title) still
toggles correctly: the delivered click closes the overlay and the
dismissal then no-ops on it, rather than closing and reopening it. Only
:left dismisses; scroll and right clicks never do.
"Outside" spans the #owner chain, not just this rect. A click counts as inside this popup when it lands in its rect or in any popup that belongs to it — so a dialog is not dismissed by a click on a dropdown its own field opened, and a menu cascade is not dismissed by a click on one of its deeper panels. Popups with no owner relationship are independent: clicking one dismisses the other, which is what a window-like overlay should do. A popup that must survive unrelated clicks entirely (Notification) sets this false.
Every dismissable popup closes, not just the topmost, and stacking order plays no part: a MenuBar cascade must vanish whole on one click on the background, not peel one panel per click.
102 |
# File 'lib/tuile/component/popup.rb', line 102 def close_on_outside_click? = @close_on_outside_click |
#focusable? ⇒ Boolean
137 |
# File 'lib/tuile/component/popup.rb', line 137 def focusable? = true |
#handle_key(key) ⇒ Boolean
q and ESC close the popup. The popup sits on the focus chain of
whatever it wraps, so the key reaches here by bubbling up from the
focused content after that content declined to handle it.
@param key
@return — true if the key was handled.
222 223 224 225 226 227 228 229 |
# File 'lib/tuile/component/popup.rb', line 222 def handle_key(key) if [Keys::ESC, "q"].include?(key) close true else false end end |
#handle_mouse ⇒ void
This method returns an undefined value.
@param event
2669 |
# File 'sig/tuile.rbs', line 2669
def handle_mouse: (MouseEvent event) -> void
|
#layout(content) ⇒ void
This method returns an undefined value.
Content fills the popup's full rect — Popup has no border to subtract.
@param content
243 244 245 |
# File 'lib/tuile/component/popup.rb', line 243 def layout(content) content.rect = rect end |
#modal? ⇒ Boolean
@return — whether this popup is modal. See #initialize.
78 |
# File 'lib/tuile/component/popup.rb', line 78 def modal? = @modal |
#on_detached ⇒ void
This method returns an undefined value.
Fires #on_close. A subclass overriding this must call super, or
the popup's driver never hears that it closed.
234 235 236 |
# File 'lib/tuile/component/popup.rb', line 234 def on_detached @on_close&.call end |
#on_focus ⇒ void
This method returns an undefined value.
2671 |
# File 'sig/tuile.rbs', line 2671
def on_focus: () -> void
|
#open ⇒ self
Mounts this popup on the Screen, re-resolving its #size against the current screen first.
popup = Component::Popup.new(content: window).open # construct and mount
There is deliberately no class-level Popup.open factory — see
DECISIONS.md D-popup-open; returning self is what keeps the
one-liner above available without one.
175 176 177 178 179 |
# File 'lib/tuile/component/popup.rb', line 175 def open reposition screen.add_popup(self) self end |
#open? ⇒ Boolean
@return — true if this popup is currently mounted on the screen.
188 189 190 |
# File 'lib/tuile/component/popup.rb', line 188 def open? screen.has_popup?(self) end |
#rect=(new_rect) ⇒ void
This method returns an undefined value.
Reassigns the popup's rect, escalating to a full scene repaint when an open popup shrinks or moves so its new rect no longer covers the cells it previously painted. A popup overdraws the scene without clipping and nothing clears underneath it, so Screen#repaint's popup-only fast path would repaint into the new rect and leave the vacated cells showing stale content. When the new rect fully covers the old one (the popup only grew), the fast path is correct and the full repaint is skipped.
@param new_rect
160 161 162 163 164 |
# File 'lib/tuile/component/popup.rb', line 160 def rect=(new_rect) old_rect = rect super screen.needs_full_repaint if open? && !new_rect.contains_rect?(old_rect) end |
#reposition ⇒ void
This method returns an undefined value.
Re-resolves #size against the current screen and repositions the popup itself (this is not laying out content — the popup's own rect): a modal popup recenters; a non-modal overlay keeps its caller-assigned top-left (only its size follows the screen). Called on #open, on #size=, and by the screen's layout pass (so a Fraction size tracks SIGWINCH).
The final rect is computed and assigned in one step rather than sizing at the origin and then centering: the intermediate origin rect rarely covers the previous one, which would make #rect='s shrink/move detection fire a full repaint on every resize.
204 205 206 207 208 209 |
# File 'lib/tuile/component/popup.rb', line 204 def reposition size = @size.is_a?(Fraction) ? @size.resolve(screen.size) : @size.clamp(screen.size) r = Rect.new(rect.left, rect.top, size.width, size.height) r = r.centered(screen.size) if modal? self.rect = r end |