Class: Tuile::Component::ConfirmWindow
- Defined in:
- lib/tuile/component/confirm_window.rb,
sig/tuile.rbs
Overview
The confirm dialog: a Window asking a question — a caption, a prose #message, a centered row of buttons — plus the one-button degenerate case, the alert. Three factories cover the common shapes and open the dialog as a centered, content-sized Popup:
Component::ConfirmWindow.alert("Export failed", "Contact [email protected].")
Component::ConfirmWindow.confirm("Delete Report Q4?", "This cannot be undone.",
confirm: "Delete") { delete! }
Component::ConfirmWindow.yes_no("Overwrite file?", "target.txt already exists.") { overwrite! }
The component itself is the builder — declare any button set through #button, then #open:
dialog = Component::ConfirmWindow.new("Unsaved changes")
dialog. = "Save your changes before leaving?"
dialog.("Save") { save! }
dialog.("Discard") { discard! }
dialog.("Cancel") # no action: pressing it dismisses
dialog.on_dismiss = -> { stay_put }
dialog.open
Every button closes the dialog. A button with a block then fires it; a
button without one is a Cancel. ESC, q, an outside click and a Cancel
button are all one outcome — #on_dismiss, fired exactly once, and only
when no action button was chosen. There is deliberately no keep-open knob:
a dialog that leads somewhere opens the next window from its callback.
Keys: Left/Right (and Tab) move between the buttons, Enter/Space press the focused one, and each button answers to its underlined mnemonic letter (see #button). The message scrolls without taking focus — BODY_SCROLL_KEYS are handed to it from anywhere in the dialog. Focus opens on the first button; Shift+Tab reaches the message, a tab stop of its own.
The popup sizes itself from what the dialog owns — caption, message, buttons — capped at half the screen (#measured_size), re-measured when any of them changes. Usable tiled too (add it to a Layout): buttons then fire their callbacks with nothing to close.
There is deliberately no content slot: the body is prose (#message=
takes a component for the rare rich body, but the dialog then cannot
measure it). A dialog collecting input is not a confirm dialog — build a
Popup.new(content: your_layout). See design/decisions.md D_confirm_window
for the API rationale.
Constant Summary collapse
- BODY_SCROLL_KEYS =
Keys handed to the message body from anywhere in the dialog, so it scrolls while a button keeps focus. The printables among them (
g/G, the less/vi top/bottom idiom) are RESERVED_MNEMONICS in exchange.Deliberately not
Keys::UP_ARROWS/DOWN_ARROWS: those include the vi aliasesj/k, which stay available as mnemonics ("Keep") — the body still honors them when focused itself. ([Keys::UP_ARROW, Keys::DOWN_ARROW, Keys::PAGE_UP, Keys::PAGE_DOWN, Keys::CTRL_U, Keys::CTRL_D, "g", "G"] + Keys::HOMES + Keys::ENDS_).freeze
- RESERVED_MNEMONICS =
Letters #button refuses as a mnemonic, compared downcased:
qis unconditionally the dismiss key (a Popup claims it below this window), andg/Gscroll the message (BODY_SCROLL_KEYS). %w[q g].freeze
- HEIGHT_CHROME =
Border rows plus the body-to-buttons spacing plus the button row — what #measured_size adds to the wrapped message rows.
4- WIDTH_CHROME =
Border columns plus the body padding — what #measured_size adds to the widest message line, and subtracts to find the wrap width.
4
Instance Attribute Summary collapse
-
#message ⇒ String, ...
@return — whatever #message= was given — set a
String, read thatStringback. -
#on_dismiss ⇒ Proc?
Callback taking no arguments, fired when the dialog is dismissed — ESC,
q, an outside click, or a #button declared without a block.
Attributes inherited from Window
Attributes included from HasContent
Class Method Summary collapse
-
.alert(caption, message, button: "OK") ⇒ Popup
Opens an acknowledgement dialog: a message and one button, whose press is the dismissal.
-
.confirm(caption, message, confirm: "Confirm", cancel: "Cancel", on_dismiss: nil, &action) ⇒ Object
Opens a two-button question: the block is the action, the cancel button (with ESC,
qand an outside click) is the dismissal. -
.yes_no(caption, message, on_dismiss: nil, &action) ⇒ Popup
ConfirmWindow.confirm with Yes/No labels — the other canonical phrasing.
Instance Method Summary collapse
-
#activate(btn) ⇒ void
The chosen-button path, in the settled order: mark chosen, close the popup (whose on_close then skips the dismissal), fire the callback last so it sees the dialog already gone — a callback opening a follow-up popup gets clean focus-repair state.
-
#button(caption, mnemonic: :auto, &action) ⇒ Button
Appends a button and returns it.
-
#button_row_width ⇒ Integer
@return — columns of the button row at its natural width.
-
#caption=(new_caption) ⇒ void
Also re-measures the popup: the caption participates in the width.
-
#close ⇒ void
Closes the popup #open mounted, which counts as a dismissal (#on_dismiss fires).
-
#derive_mnemonic(caption) ⇒ String?
@param
caption. -
#focus_button_step(delta) ⇒ Boolean
@param
delta— -1 or 1. -
#handle_focus ⇒ void
Focus lands on the first button rather than cascading into the message body, which sits before the button row in the tree.
-
#handle_key?(key) ⇒ Boolean
Handles the dialog-wide keys: Left/Right move between the buttons, BODY_SCROLL_KEYS are hand-fed to the message body, and a mnemonic letter presses its button.
-
#initialize(caption = nil) ⇒ ConfirmWindow
constructor
@param
caption— the border title, coerced the same way HasCaption#caption= coerces it. -
#measured_size(reference = screen.size) ⇒ Size
The popup box this dialog wants: wide enough for the caption, the widest message line and the button row, tall enough for the wrapped message plus the button row — each capped at half of
reference. -
#notice_dismissed ⇒ void
The popup's on_close: fires #on_dismiss unless a button was chosen — which makes ESC,
q, an outside click, #close, a direct Screen#remove_popup and teardown all one event, fired exactly once. -
#open ⇒ Popup
Opens the dialog as a centered modal Popup sized by #measured_size and returns that popup.
-
#recenter_button_row ⇒ void
Re-declares the button row's cross extent — the buttons' summed natural width — so the Layout::Vertical centers it.
-
#resize_popup ⇒ void
Re-measures the popup while open; a no-op tiled or before #open.
-
#resolve_mnemonic(caption, mnemonic) ⇒ String?
Validates an explicit mnemonic (raising, as MenuBar#add_item does — none of these has a sane answer at keypress time) or best-effort derives one from the caption's first letter (returning nil where the explicit path would raise).
-
#underline_mnemonic(caption, cue) ⇒ StyledString
StyledString#slice counts columns while a caption search yields a character index, so the prefix is measured, never counted (the MenuBar::Item cue, duplicated per
D_float_field's shallow-shell rule).
Methods inherited from Window
#bottom_border, #caption, #content_rect, #focusable?, #footer, #footer=, #inspect_details, #layout, #layout_footer, #rect=, #repaint, #repaint_border, #scrollbar=, #top_border
Methods included from HasCaption
Methods included from HasContent
Constructor Details
#initialize(caption = nil) ⇒ ConfirmWindow
@param caption — the border title, coerced the same way HasCaption#caption= coerces it.
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
# File 'lib/tuile/component/confirm_window.rb', line 79 def initialize(caption = nil) @popup = nil @chosen = false = nil @on_dismiss = nil # Insertion-ordered; identity-keyed so equal captions stay two buttons. @actions = {}.compare_by_identity @mnemonics = {} super(caption) @body_slot = Slot.new = Layout::Horizontal.new(spacing: 2) @box = Layout::Vertical.new(spacing: 1, padding: Layout::Insets[left: 1, right: 1]) @box.add(@body_slot, Layout::Expand[1]) @box.add(, Layout::Fixed[1], cross: Layout::Fixed[0], align: :center) self.content = @box end |
Instance Attribute Details
#message ⇒ String, ...
@return — whatever #message= was
given — set a String, read that String back. The component
rendering it is derived, never returned.
105 106 107 |
# File 'lib/tuile/component/confirm_window.rb', line 105 def end |
#on_dismiss ⇒ Proc?
100 101 102 |
# File 'lib/tuile/component/confirm_window.rb', line 100 def on_dismiss @on_dismiss end |
Class Method Details
.alert(caption, message, button: "OK") ⇒ Popup
Opens an acknowledgement dialog: a message and one button, whose press
is the dismissal. The button exists for discoverability — ESC and q
close too, but Tuile advertises no keys anywhere, and the button is
clickable.
@param caption — the border title.
@param message — see #message=.
@param button — the button label.
@return — the mounted popup.
266 267 268 269 270 271 |
# File 'lib/tuile/component/confirm_window.rb', line 266 def self.alert(caption, , button: "OK") window = new(caption) window. = window.() window.open end |
.confirm(caption, message, confirm: "Confirm", cancel: "Cancel", on_dismiss: nil, &action) ⇒ Object
Opens a two-button question: the block is the action, the cancel button
(with ESC, q and an outside click) is the dismissal.
Component::ConfirmWindow.confirm("Delete Report Q4?", "This cannot be undone.",
confirm: "Delete") { delete! }
@param caption — the border title.
@param message — see #message=.
@param confirm — the action button's label.
@param cancel — the dismissal button's label.
@param on_dismiss — see #on_dismiss.
@return — the mounted popup.
288 289 290 291 292 293 294 295 296 297 |
# File 'lib/tuile/component/confirm_window.rb', line 288 def self.confirm(caption, , confirm: "Confirm", cancel: "Cancel", on_dismiss: nil, &action) raise ArgumentError, "block required" unless action window = new(caption) window. = window.(confirm, &action) window.(cancel) window.on_dismiss = on_dismiss window.open end |
.yes_no(caption, message, on_dismiss: nil, &action) ⇒ Popup
confirm with Yes/No labels — the other canonical phrasing.
@param caption — the border title.
@param message — see #message=.
@param on_dismiss — see #on_dismiss.
@return — the mounted popup.
306 307 308 |
# File 'lib/tuile/component/confirm_window.rb', line 306 def self.yes_no(caption, , on_dismiss: nil, &action) confirm(caption, , confirm: "Yes", cancel: "No", on_dismiss:, &action) end |
Instance Method Details
#activate(btn) ⇒ void
This method returns an undefined value.
The chosen-button path, in the settled order: mark chosen, close the popup (whose on_close then skips the dismissal), fire the callback last so it sees the dialog already gone — a callback opening a follow-up popup gets clean focus-repair state.
@param btn
338 339 340 341 342 343 |
# File 'lib/tuile/component/confirm_window.rb', line 338 def activate(btn) action = @actions[btn] @chosen = true @popup&.close action.nil? ? @on_dismiss&.call : action.call end |
#button(caption, mnemonic: :auto, &action) ⇒ Button
Appends a button and returns it. A button with a block is an action button: pressing it closes the dialog, then fires the block. A button without one is a Cancel: pressing it closes the dialog, then fires #on_dismiss.
dialog.button("Delete") { delete! } # mnemonic d, underlined
dialog.button("Keep", mnemonic: "e") # explicit letter
dialog.button("Cancel", mnemonic: nil) # no mnemonic
The mnemonic — a printable letter activating the button from anywhere in
the dialog, case-insensitively — is underlined in the caption. Any case
is accepted, and the underline prefers the exact case given, so the case
picks which occurrence is cued ("Save As" with "A" underlines the
As), exactly as on MenuBar#add_item. The default :auto derives the
caption's first letter (cueing it as displayed) and is best-effort:
silently skipped when that letter is reserved, taken, or unusable. An
explicit letter is a promise and raises when it cannot be kept.
Declare buttons before #open: adding one to an open dialog re-measures the popup, but momentarily bounces focus off the button row.
@param caption — the button label.
@param mnemonic — :auto (default), a printable one-column character, or nil for none.
@return — the appended button.
163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 |
# File 'lib/tuile/component/confirm_window.rb', line 163 def (caption, mnemonic: :auto, &action) styled = StyledString.parse(caption) letter = resolve_mnemonic(styled, mnemonic) # The cue keeps the case the caller (or the caption) wrote, so `:auto` # underlines "Discard"'s leading D rather than its trailing d, and an # explicit letter picks its occurrence by case as on MenuBar; `letter` # is the downcased matching key. cue = mnemonic == :auto ? styled.to_s.grapheme_clusters.first : mnemonic btn = Button.new(letter ? underline_mnemonic(styled, cue) : styled) btn.on_click = -> { activate(btn) } @actions[btn] = action @mnemonics[letter] = btn unless letter.nil? .add(btn, Layout::Fixed[btn.caption.display_width + 4]) resize_popup btn end |
#button_row_width ⇒ Integer
@return — columns of the button row at its natural width.
432 433 434 435 |
# File 'lib/tuile/component/confirm_window.rb', line 432 def widths = @actions.keys.map { _1.caption.display_width + 4 } widths.sum + (.spacing * [widths.size - 1, 0].max) end |
#caption=(new_caption) ⇒ void
This method returns an undefined value.
Also re-measures the popup: the caption participates in the width.
@param new_caption
110 111 112 113 |
# File 'lib/tuile/component/confirm_window.rb', line 110 def caption=(new_caption) super resize_popup end |
#close ⇒ void
This method returns an undefined value.
Closes the popup #open mounted, which counts as a dismissal (#on_dismiss fires). No-op when not open, or when used tiled.
201 |
# File 'lib/tuile/component/confirm_window.rb', line 201 def close = @popup&.close |
#derive_mnemonic(caption) ⇒ String?
@param caption
395 396 397 398 399 400 401 402 |
# File 'lib/tuile/component/confirm_window.rb', line 395 def derive_mnemonic(caption) letter = caption.to_s.grapheme_clusters.first&.downcase return nil if letter.nil? || letter == " " return nil unless Keys.printable?(letter) && StyledString.plain(letter).display_width == 1 return nil if RESERVED_MNEMONICS.include?(letter) || @mnemonics.key?(letter) letter end |
#focus_button_step(delta) ⇒ Boolean
@param delta — -1 or 1.
@return — false when focus is not on a button (the key bubbles on).
358 359 360 361 362 363 364 365 |
# File 'lib/tuile/component/confirm_window.rb', line 358 def (delta) = @actions.keys current = .index(screen.focused) return false if current.nil? screen.focused = [(current + delta) % .size] true end |
#handle_focus ⇒ void
This method returns an undefined value.
Focus lands on the first button rather than cascading into the message
body, which sits before the button row in the tree. super is reached
only when there is no button to take it: HasContent#handle_focus is
the cascade this override exists to skip.
228 229 230 231 232 233 234 235 |
# File 'lib/tuile/component/confirm_window.rb', line 228 def handle_focus first = @actions.keys.first if first.nil? super else screen.focused = first end end |
#handle_key?(key) ⇒ Boolean
Handles the dialog-wide keys: Left/Right move between the buttons, BODY_SCROLL_KEYS are hand-fed to the message body, and a mnemonic letter presses its button. Reached by bubbling — the focused button or body sees the key first, so a focused body consumes its own scroll keys before this runs.
@param key
@return — true if the key was handled.
244 245 246 247 248 249 250 251 252 253 254 255 256 |
# File 'lib/tuile/component/confirm_window.rb', line 244 def handle_key?(key) case key when Keys::LEFT_ARROW then return (-1) when Keys::RIGHT_ARROW then return (1) when *BODY_SCROLL_KEYS then return @body_slot.content&.handle_key?(key) || false end target = @mnemonics[key.downcase] return false if target.nil? activate(target) true end |
#measured_size(reference = screen.size) ⇒ Size
The popup box this dialog wants: wide enough for the caption, the widest
message line and the button row, tall enough for the wrapped message
plus the button row — each capped at half of reference. The re-grow
rule's caller-side measure query: the dialog measures only content it
owns, so a Tuile::Component assigned to #message= yields the full
half-screen box.
@param reference — the screen size to cap against.
211 212 213 214 215 216 217 218 219 220 221 |
# File 'lib/tuile/component/confirm_window.rb', line 211 def measured_size(reference = screen.size) cap = Fraction::HALF.resolve(reference) return cap if .is_a?(Component) styled = .nil? ? StyledString::EMPTY : StyledString.parse() widest = styled.lines.map(&:display_width).max || 0 width = [[caption.display_width + 2, + WIDTH_CHROME, widest + WIDTH_CHROME].max, cap.width].min rows = styled.empty? ? 0 : styled.wrap([width - WIDTH_CHROME, 1].max).size Size.new(width, [rows + HEIGHT_CHROME, cap.height].min) end |
#notice_dismissed ⇒ void
This method returns an undefined value.
The popup's on_close: fires #on_dismiss unless a button was chosen —
which makes ESC, q, an outside click, #close, a direct
Screen#remove_popup and teardown all one event, fired exactly once.
349 350 351 352 353 354 |
# File 'lib/tuile/component/confirm_window.rb', line 349 def notice_dismissed return if @chosen @chosen = true @on_dismiss&.call end |
#open ⇒ Popup
Opens the dialog as a centered modal Popup sized by #measured_size
and returns that popup. ESC, q and an outside click dismiss it
(firing #on_dismiss); every button closes it too.
@return — the mounted popup; popup.close closes programmatically,
as #close also does.
187 188 189 190 191 192 193 194 195 196 |
# File 'lib/tuile/component/confirm_window.rb', line 187 def open raise Tuile::Error, "#{inspect} is already open" if @popup&.open? # A previous popup still holds this window as its content; reclaim it. @popup&.content = nil @chosen = false @popup = MeasuredPopup.new(self) @popup.on_close = -> { notice_dismissed } @popup.open end |
#recenter_button_row ⇒ void
This method returns an undefined value.
Re-declares the button row's cross extent — the buttons' summed natural
width — so the Layout::Vertical centers it. Remove-and-re-add is the
only way to change a Layout::Box constraint; the row stays after the
body because both adds append.
426 427 428 429 |
# File 'lib/tuile/component/confirm_window.rb', line 426 def @box.remove() @box.add(, Layout::Fixed[1], cross: Layout::Fixed[], align: :center) end |
#resize_popup ⇒ void
This method returns an undefined value.
Re-measures the popup while open; a no-op tiled or before #open.
439 440 441 |
# File 'lib/tuile/component/confirm_window.rb', line 439 def resize_popup @popup.reposition if @popup&.open? end |
#resolve_mnemonic(caption, mnemonic) ⇒ String?
Validates an explicit mnemonic (raising, as MenuBar#add_item does — none of these has a sane answer at keypress time) or best-effort derives one from the caption's first letter (returning nil where the explicit path would raise).
@param caption
@param mnemonic
@return — the downcased letter, or nil for none.
375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 |
# File 'lib/tuile/component/confirm_window.rb', line 375 def resolve_mnemonic(caption, mnemonic) return nil if mnemonic.nil? return derive_mnemonic(caption) if mnemonic == :auto raise ArgumentError, "mnemonic must not be a space: Space presses the focused button" if mnemonic == " " unless Keys.printable?(mnemonic) && StyledString.plain(mnemonic).display_width == 1 raise ArgumentError, "mnemonic must be a single one-column printable character; got #{mnemonic.inspect}" end down = mnemonic.downcase if RESERVED_MNEMONICS.include?(down) raise ArgumentError, "mnemonic #{down.inspect} is reserved: q dismisses the dialog, g/G scroll the message" end raise ArgumentError, "duplicate mnemonic #{down.inspect}" if @mnemonics.key?(down) down end |
#underline_mnemonic(caption, cue) ⇒ StyledString
StyledString#slice counts columns while a caption search yields a
character index, so the prefix is measured, never counted (the
MenuBar::Item cue, duplicated per D_float_field's shallow-shell rule).
@param caption
@param cue — the letter in the case it was given in — exact case first, so the case picks which occurrence is underlined.
411 412 413 414 415 416 417 418 419 |
# File 'lib/tuile/component/confirm_window.rb', line 411 def underline_mnemonic(caption, cue) text = caption.to_s index = text.index(cue) || text.downcase.index(cue.downcase) return caption if index.nil? start = StyledString.plain(text[0, index]).display_width caption.slice(0, start) + caption.slice(start, 1).with_underline + caption.slice(start + 1, caption.display_width - start - 1) end |