Class: Tuile::Component::Popup

Inherits:
Component
  • Object
show all
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 (still painted on top) without taking focus or capturing input — the caller positions it (via #rect=) and drives it from app code. That is the building block for an autocomplete/slash-command list anchored to a TextField or TextArea caret: typing keeps focus (and the cursor) in the input, an TextInput#on_change listener refills the list, and an TextInput#on_key interceptor forwards Up/Down/Enter to it. Such a caller owns the list data, so it sizes the overlay itself (overlay.size = Size.new(longest, [items.size, 8].min)) — still caller-decides, top-down.

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. Any nested TextField that owns the hardware cursor swallows printable keys first via the standard cursor-owner suppression in #handle_key, so typing q into a text field doesn't dismiss the popup.

Instance Attribute Summary collapse

Attributes included from HasContent

#content

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(content: nil, modal: true, size: Fraction::HALF) ⇒ Popup

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

Parameters:

  • content: (Component, nil) (defaults to: nil)
  • modal: (Boolean) (defaults to: true)
  • size: (Size, Fraction) (defaults to: Fraction::HALF)


59
60
61
62
63
64
65
66
# File 'lib/tuile/component/popup.rb', line 59

def initialize(content: nil, modal: true, size: Fraction::HALF)
  super()
  @modal = modal
  @size = size
  @content = nil
  self.content = content unless content.nil?
  reposition
end

Instance Attribute Details

#sizeSize, Fraction

@return — the popup's declared size. See #size=.

Returns:



69
70
71
# File 'lib/tuile/component/popup.rb', line 69

def size
  @size
end

Class Method Details

.open(content: nil, modal: true, size: Fraction::HALF) ⇒ Popup

Constructs and opens a popup in one call.

@param content

@param modal — see #initialize.

@param size — see #initialize.

@return — the opened popup.

Parameters:

  • content: (Component, nil) (defaults to: nil)
  • modal: (Boolean) (defaults to: true)
  • size: (Size, Fraction) (defaults to: Fraction::HALF)

Returns:



116
117
118
# File 'lib/tuile/component/popup.rb', line 116

def self.open(content: nil, modal: true, size: Fraction::HALF)
  Popup.new(content: content, modal: modal, size: size).tap(&:open)
end

Instance Method Details

#centervoid

This method returns an undefined value.

Recenters the popup on the screen, preserving its current width/height.



152
153
154
# File 'lib/tuile/component/popup.rb', line 152

def center
  self.rect = rect.centered(screen.size)
end

#children::Array[Component]

Returns:



2019
# File 'sig/tuile.rbs', line 2019

def children: () -> ::Array[Component]

#closevoid

This method returns an undefined value.

Removes this popup from the Screen. No-op if not currently open.



122
123
124
# File 'lib/tuile/component/popup.rb', line 122

def close
  screen.remove_popup(self)
end

#focusable?Boolean

Returns:

  • (Boolean)


74
# File 'lib/tuile/component/popup.rb', line 74

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.

Parameters:

  • key (String)

Returns:

  • (Boolean)


169
170
171
172
173
174
175
176
# File 'lib/tuile/component/popup.rb', line 169

def handle_key(key)
  if [Keys::ESC, "q"].include?(key)
    close
    true
  else
    false
  end
end

#handle_mousevoid

This method returns an undefined value.

@param event

Parameters:



2017
# File 'sig/tuile.rbs', line 2017

def handle_mouse: (MouseEvent event) -> void

#keyboard_hintString

Hint for the status bar: own "q Close" plus the wrapped content's hint.

Returns:

  • (String)


158
159
160
161
162
# File 'lib/tuile/component/popup.rb', line 158

def keyboard_hint
  prefix = "q #{screen.theme.hint("Close")}"
  child_hint = @content&.keyboard_hint.to_s
  child_hint.empty? ? prefix : "#{prefix}  #{child_hint}"
end

#layout(content) ⇒ void

This method returns an undefined value.

Content fills the popup's full rect — Popup has no border to subtract.

@param content

Parameters:



183
184
185
# File 'lib/tuile/component/popup.rb', line 183

def layout(content)
  content.rect = rect
end

#modal?Boolean

@return — whether this popup is modal. See #initialize.

Returns:

  • (Boolean)


72
# File 'lib/tuile/component/popup.rb', line 72

def modal? = @modal

#on_focusvoid

This method returns an undefined value.



2021
# File 'sig/tuile.rbs', line 2021

def on_focus: () -> void

#openvoid

This method returns an undefined value.

Mounts this popup on the Screen, re-resolving its #size against the current screen first.



106
107
108
109
# File 'lib/tuile/component/popup.rb', line 106

def open
  reposition
  screen.add_popup(self)
end

#open?Boolean

@return — true if this popup is currently mounted on the screen.

Returns:

  • (Boolean)


127
128
129
# File 'lib/tuile/component/popup.rb', line 127

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

Parameters:



97
98
99
100
101
# File 'lib/tuile/component/popup.rb', line 97

def rect=(new_rect)
  old_rect = rect
  super
  screen.needs_full_repaint if open? && !new_rect.contains_rect?(old_rect)
end

#repositionvoid

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.



143
144
145
146
147
148
# File 'lib/tuile/component/popup.rb', line 143

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