Class: Tuile::Component::ConfirmWindow

Inherits:
Window
  • Object
show all
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.message = "Save your changes before leaving?"
dialog.button("Save")    { save! }
dialog.button("Discard") { discard! }
dialog.button("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 aliases j/k, which stay available as mnemonics ("Keep") — the body still honors them when focused itself.

Returns:

  • (Array<String>)
([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: q is unconditionally the dismiss key (a Popup claims it below this window), and g/G scroll the message (BODY_SCROLL_KEYS).

Returns:

  • (Array<String>)
%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.

Returns:

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

Returns:

  • (Integer)
4

Instance Attribute Summary collapse

Attributes inherited from Window

#footer_text

Attributes included from HasContent

#content

Class Method Summary collapse

Instance Method Summary collapse

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

#caption, #inspect_details

Methods included from HasContent

#rect=

Constructor Details

#initialize(caption = nil) ⇒ ConfirmWindow

@param caption — the border title, coerced the same way HasCaption#caption= coerces it.

Parameters:

  • caption (?(String | StyledString), nil) (defaults to: nil)


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
  @message = 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
  @button_row = 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(@button_row, Layout::Fixed[1], cross: Layout::Fixed[0], align: :center)
  self.content = @box
end

Instance Attribute Details

#messageString, ...

@return — whatever #message= was given — set a String, read that String back. The component rendering it is derived, never returned.

Returns:



105
106
107
# File 'lib/tuile/component/confirm_window.rb', line 105

def message
  @message
end

#on_dismissProc?

Callback taking no arguments, fired when the dialog is dismissed — ESC, q, an outside click, or a #button declared without a block. Fires exactly once per #open, and never when an action button was chosen.

Returns:

  • (Proc, nil)


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.

Parameters:

Returns:



266
267
268
269
270
271
# File 'lib/tuile/component/confirm_window.rb', line 266

def self.alert(caption, message, button: "OK")
  window = new(caption)
  window.message = message
  window.button(button)
  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, message, confirm: "Confirm", cancel: "Cancel", on_dismiss: nil, &action)
  raise ArgumentError, "block required" unless action

  window = new(caption)
  window.message = message
  window.button(confirm, &action)
  window.button(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.

Parameters:

Returns:



306
307
308
# File 'lib/tuile/component/confirm_window.rb', line 306

def self.yes_no(caption, message, on_dismiss: nil, &action)
  confirm(caption, message, 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

Parameters:



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.

Parameters:

  • caption (String, StyledString)
  • mnemonic: (Symbol, String, nil) (defaults to: :auto)

Returns:



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 button(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?
  @button_row.add(btn, Layout::Fixed[btn.caption.display_width + 4])
  recenter_button_row
  resize_popup
  btn
end

#button_row_widthInteger

@return — columns of the button row at its natural width.

Returns:

  • (Integer)


432
433
434
435
# File 'lib/tuile/component/confirm_window.rb', line 432

def button_row_width
  widths = @actions.keys.map { _1.caption.display_width + 4 }
  widths.sum + (@button_row.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

Parameters:



110
111
112
113
# File 'lib/tuile/component/confirm_window.rb', line 110

def caption=(new_caption)
  super
  resize_popup
end

#closevoid

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

Parameters:

Returns:

  • (String, nil)


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

Parameters:

  • delta (Integer)

Returns:

  • (Boolean)


358
359
360
361
362
363
364
365
# File 'lib/tuile/component/confirm_window.rb', line 358

def focus_button_step(delta)
  buttons = @actions.keys
  current = buttons.index(screen.focused)
  return false if current.nil?

  screen.focused = buttons[(current + delta) % buttons.size]
  true
end

#handle_focusvoid

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.

Parameters:

  • key (String)

Returns:

  • (Boolean)


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 focus_button_step(-1)
  when Keys::RIGHT_ARROW then return focus_button_step(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.

Parameters:

  • reference (Size) (defaults to: screen.size)

Returns:



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 @message.is_a?(Component)

  styled = @message.nil? ? StyledString::EMPTY : StyledString.parse(@message)
  widest = styled.lines.map(&:display_width).max || 0
  width = [[caption.display_width + 2, button_row_width + 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_dismissedvoid

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

#openPopup

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.

Returns:



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_rowvoid

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 recenter_button_row
  @box.remove(@button_row)
  @box.add(@button_row, Layout::Fixed[1], cross: Layout::Fixed[button_row_width], align: :center)
end

#resize_popupvoid

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.

Parameters:

Returns:

  • (String, nil)


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.

Parameters:

Returns:



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