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.

Defined Under Namespace

Classes: DismissEvent

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_footer, #relayout, #repaint, #repaint_border, #scrollbar=, #top_border

Methods included from HasCaption

#caption, #inspect_details

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
# File 'lib/tuile/component/confirm_window.rb', line 79

def initialize(caption = nil)
  @popup = nil
  @chosen = false
  @message = 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

#message ⇒ String, ...

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

Returns:



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

def message
  @message
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:



272
273
274
275
276
277
# File 'lib/tuile/component/confirm_window.rb', line 272

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.



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

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



312
313
314
# File 'lib/tuile/component/confirm_window.rb', line 312

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:



340
341
342
343
344
345
# File 'lib/tuile/component/confirm_window.rb', line 340

def activate(btn)
  action = @actions[btn]
  @chosen = true
  @popup&.close
  action.nil? ? fire_dismissed : 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:



169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/tuile/component/confirm_window.rb', line 169

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_width ⇒ Integer

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

Returns:

  • (Integer)


437
438
439
440
# File 'lib/tuile/component/confirm_window.rb', line 437

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:



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

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.



207
# File 'lib/tuile/component/confirm_window.rb', line 207

def close = @popup&.close

#derive_mnemonic(caption) ⇒ String?

@param caption

Parameters:

Returns:

  • (String, nil)


400
401
402
403
404
405
406
407
# File 'lib/tuile/component/confirm_window.rb', line 400

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

#fire_dismissed ⇒ void

This method returns an undefined value.



359
# File 'lib/tuile/component/confirm_window.rb', line 359

def fire_dismissed = on_dismiss.fire(DismissEvent.new(source: self))

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


363
364
365
366
367
368
369
370
# File 'lib/tuile/component/confirm_window.rb', line 363

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



234
235
236
237
238
239
240
241
# File 'lib/tuile/component/confirm_window.rb', line 234

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)


250
251
252
253
254
255
256
257
258
259
260
261
262
# File 'lib/tuile/component/confirm_window.rb', line 250

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:



217
218
219
220
221
222
223
224
225
226
227
# File 'lib/tuile/component/confirm_window.rb', line 217

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



351
352
353
354
355
356
# File 'lib/tuile/component/confirm_window.rb', line 351

def notice_dismissed
  return if @chosen

  @chosen = true
  fire_dismissed
end

#on_dismiss ⇒ Listeners

Fired with a DismissEvent 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:



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

listener :on_dismiss

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

Returns:



193
194
195
196
197
198
199
200
201
202
# File 'lib/tuile/component/confirm_window.rb', line 193

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.



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

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_popup ⇒ void

This method returns an undefined value.

Re-measures the popup while open; a no-op tiled or before #open.



444
445
446
# File 'lib/tuile/component/confirm_window.rb', line 444

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)


380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
# File 'lib/tuile/component/confirm_window.rb', line 380

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:



416
417
418
419
420
421
422
423
424
# File 'lib/tuile/component/confirm_window.rb', line 416

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