tuile 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -27
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +2342 -158
- data/README.md +151 -505
- data/TERMINOLOGY.md +15 -5
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +18 -5
- data/book/03-layout.md +28 -20
- data/book/05-focus.md +137 -19
- data/book/06-theming.md +103 -2
- data/book/07-components.md +567 -27
- data/book/08-testing.md +34 -4
- data/book/09-styled-text.md +3 -3
- data/book/README.md +7 -5
- data/examples/file_commander.rb +23 -17
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +527 -108
- data/ideas/arrow-key-navigation.md +17 -1
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +28 -27
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/buffer.rb +51 -3
- data/lib/tuile/color.rb +143 -0
- data/lib/tuile/color_depth.rb +80 -0
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/combo_box.rb +12 -3
- data/lib/tuile/component/confirm_window.rb +442 -0
- data/lib/tuile/component/has_content.rb +22 -9
- data/lib/tuile/component/has_value.rb +1 -1
- data/lib/tuile/component/info_window.rb +64 -16
- data/lib/tuile/component/layout.rb +0 -10
- data/lib/tuile/component/list.rb +22 -0
- data/lib/tuile/component/list_dropdown.rb +102 -11
- data/lib/tuile/component/log_text_view.rb +71 -0
- data/lib/tuile/component/log_window.rb +13 -48
- data/lib/tuile/component/menu_bar/cascade.rb +255 -0
- data/lib/tuile/component/menu_bar.rb +582 -0
- data/lib/tuile/component/notification.rb +24 -39
- data/lib/tuile/component/overlay.rb +192 -0
- data/lib/tuile/component/picker_window.rb +0 -5
- data/lib/tuile/component/popup.rb +61 -123
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/select.rb +17 -7
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +231 -0
- data/lib/tuile/component/tabs.rb +528 -0
- data/lib/tuile/component/text_area.rb +5 -4
- data/lib/tuile/component/text_field.rb +23 -6
- data/lib/tuile/component/text_view.rb +8 -5
- data/lib/tuile/component/window.rb +22 -46
- data/lib/tuile/component.rb +186 -31
- data/lib/tuile/event_queue.rb +45 -1
- data/lib/tuile/fake_screen.rb +40 -2
- data/lib/tuile/keys.rb +72 -0
- data/lib/tuile/screen.rb +212 -113
- data/lib/tuile/screen_pane.rb +125 -41
- data/lib/tuile/styled_string.rb +80 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2527 -358
- metadata +13 -3
- data/mise.toml +0 -2
|
@@ -14,7 +14,7 @@ module Tuile
|
|
|
14
14
|
# combo.value = some_user # selects it; field shows its label
|
|
15
15
|
#
|
|
16
16
|
# It's the assembly you'd otherwise wire by hand — a {TextField} plus a
|
|
17
|
-
#
|
|
17
|
+
# an {Overlay} over a {List} — promoted to one component. Give it a
|
|
18
18
|
# single-row {#rect}; it paints the field across that row with a `▾` in the
|
|
19
19
|
# last column and floats the dropdown above or below.
|
|
20
20
|
#
|
|
@@ -52,6 +52,9 @@ module Tuile
|
|
|
52
52
|
self.content = field
|
|
53
53
|
|
|
54
54
|
@overlay = ListDropdown.new
|
|
55
|
+
# Outside-click dismissal spans the owner chain, so a click on this
|
|
56
|
+
# combo's dropdown must not dismiss a dialog the combo sits in.
|
|
57
|
+
@overlay.owner = self
|
|
55
58
|
@overlay.renderer = ->(item) { @item_label.call(item) }
|
|
56
59
|
@overlay.on_item_chosen = ->(_index, item) { commit(item) }
|
|
57
60
|
end
|
|
@@ -98,7 +101,6 @@ module Tuile
|
|
|
98
101
|
def cursor_position = content.cursor_position
|
|
99
102
|
|
|
100
103
|
# @return [String]
|
|
101
|
-
def keyboard_hint = "↑↓ #{screen.theme.hint("select")} ⏎ #{screen.theme.hint("accept")}"
|
|
102
104
|
|
|
103
105
|
# Re-anchors the (open) dropdown after {HasContent#rect=} has resized the
|
|
104
106
|
# field via {#layout}.
|
|
@@ -145,6 +147,13 @@ module Tuile
|
|
|
145
147
|
draw_char(rect.left + rect.width - 1, rect.top, "▾", StyledString::Style::DEFAULT.with(bg: well))
|
|
146
148
|
end
|
|
147
149
|
|
|
150
|
+
# The one row this combo paints — the full width, at the top of {#rect}.
|
|
151
|
+
# A single-slot container hands its content the whole inner rect, so a
|
|
152
|
+
# ComboBox is routinely assigned more height than it uses; the dropdown
|
|
153
|
+
# hangs under this rather than under the unused space below it.
|
|
154
|
+
# @return [Size]
|
|
155
|
+
def extent = Size.new(rect.width, 1)
|
|
156
|
+
|
|
148
157
|
protected
|
|
149
158
|
|
|
150
159
|
# Field spans the row bar the last column, which the `▾` occupies
|
|
@@ -260,7 +269,7 @@ module Tuile
|
|
|
260
269
|
# labels, which ellipsize a column earlier once the list scrolls. That is
|
|
261
270
|
# the trade a measuring driver ({Select}) makes the other way.
|
|
262
271
|
# @return [void]
|
|
263
|
-
def anchor = @overlay.anchor_to(
|
|
272
|
+
def anchor = @overlay.anchor_to(extent_rect, rows: @filtered.size)
|
|
264
273
|
end
|
|
265
274
|
end
|
|
266
275
|
end
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# The confirm dialog: a {Window} asking a question — a caption, a prose
|
|
6
|
+
# {#message}, a centered row of buttons — plus the one-button degenerate
|
|
7
|
+
# case, the alert. Three factories cover the common shapes and open the
|
|
8
|
+
# dialog as a centered, content-sized {Popup}:
|
|
9
|
+
#
|
|
10
|
+
# Component::ConfirmWindow.alert("Export failed", "Contact support@example.com.")
|
|
11
|
+
# Component::ConfirmWindow.confirm("Delete Report Q4?", "This cannot be undone.",
|
|
12
|
+
# confirm: "Delete") { delete! }
|
|
13
|
+
# Component::ConfirmWindow.yes_no("Overwrite file?", "target.txt already exists.") { overwrite! }
|
|
14
|
+
#
|
|
15
|
+
# The component itself is the builder — declare any button set through
|
|
16
|
+
# {#button}, then {#open}:
|
|
17
|
+
#
|
|
18
|
+
# dialog = Component::ConfirmWindow.new("Unsaved changes")
|
|
19
|
+
# dialog.message = "Save your changes before leaving?"
|
|
20
|
+
# dialog.button("Save") { save! }
|
|
21
|
+
# dialog.button("Discard") { discard! }
|
|
22
|
+
# dialog.button("Cancel") # no action: pressing it dismisses
|
|
23
|
+
# dialog.on_dismiss = -> { stay_put }
|
|
24
|
+
# dialog.open
|
|
25
|
+
#
|
|
26
|
+
# **Every button closes the dialog.** A button with a block then fires it; a
|
|
27
|
+
# button without one is a Cancel. ESC, `q`, an outside click and a Cancel
|
|
28
|
+
# button are all one outcome — {#on_dismiss}, fired exactly once, and only
|
|
29
|
+
# when no action button was chosen. There is deliberately no keep-open knob:
|
|
30
|
+
# a dialog that leads somewhere opens the next window from its callback.
|
|
31
|
+
#
|
|
32
|
+
# Keys: Left/Right (and Tab) move between the buttons, Enter/Space press the
|
|
33
|
+
# focused one, and each button answers to its underlined mnemonic letter
|
|
34
|
+
# (see {#button}). The message scrolls without taking focus —
|
|
35
|
+
# {BODY_SCROLL_KEYS} are handed to it from anywhere in the dialog. Focus
|
|
36
|
+
# opens on the first button; Shift+Tab reaches the message, a tab stop of
|
|
37
|
+
# its own.
|
|
38
|
+
#
|
|
39
|
+
# The popup sizes itself from what the dialog owns — caption, message,
|
|
40
|
+
# buttons — capped at half the screen ({#measured_size}), re-measured when
|
|
41
|
+
# any of them changes. Usable tiled too (add it to a {Layout}): buttons then
|
|
42
|
+
# fire their callbacks with nothing to close.
|
|
43
|
+
#
|
|
44
|
+
# There is deliberately no content slot: the body is prose ({#message=}
|
|
45
|
+
# takes a component for the rare rich body, but the dialog then cannot
|
|
46
|
+
# measure it). A dialog collecting *input* is not a confirm dialog — build a
|
|
47
|
+
# `Popup.new(content: your_layout)`. See `DECISIONS.md` `D_confirm_window`
|
|
48
|
+
# for the API rationale.
|
|
49
|
+
class ConfirmWindow < Window
|
|
50
|
+
# Keys handed to the message body from anywhere in the dialog, so it
|
|
51
|
+
# scrolls while a button keeps focus. The printables among them (`g`/`G`,
|
|
52
|
+
# the less/vi top/bottom idiom) are {RESERVED_MNEMONICS} in exchange.
|
|
53
|
+
#
|
|
54
|
+
# Deliberately *not* `Keys::UP_ARROWS`/`DOWN_ARROWS`: those include the vi
|
|
55
|
+
# aliases `j`/`k`, which stay available as mnemonics ("Keep") — the body
|
|
56
|
+
# still honors them when focused itself.
|
|
57
|
+
# @return [Array<String>]
|
|
58
|
+
BODY_SCROLL_KEYS = ([Keys::UP_ARROW, Keys::DOWN_ARROW, Keys::PAGE_UP, Keys::PAGE_DOWN,
|
|
59
|
+
Keys::CTRL_U, Keys::CTRL_D, "g", "G"] + Keys::HOMES + Keys::ENDS_).freeze
|
|
60
|
+
|
|
61
|
+
# Letters {#button} refuses as a mnemonic, compared downcased: `q` is
|
|
62
|
+
# unconditionally the dismiss key (a {Popup} claims it below this window),
|
|
63
|
+
# and `g`/`G` scroll the message ({BODY_SCROLL_KEYS}).
|
|
64
|
+
# @return [Array<String>]
|
|
65
|
+
RESERVED_MNEMONICS = %w[q g].freeze
|
|
66
|
+
|
|
67
|
+
# Border rows plus the body-to-buttons spacing plus the button row — what
|
|
68
|
+
# {#measured_size} adds to the wrapped message rows.
|
|
69
|
+
# @return [Integer]
|
|
70
|
+
HEIGHT_CHROME = 4
|
|
71
|
+
# Border columns plus the body padding — what {#measured_size} adds to the
|
|
72
|
+
# widest message line, and subtracts to find the wrap width.
|
|
73
|
+
# @return [Integer]
|
|
74
|
+
WIDTH_CHROME = 4
|
|
75
|
+
private_constant :HEIGHT_CHROME, :WIDTH_CHROME
|
|
76
|
+
|
|
77
|
+
# @param caption [String, StyledString, nil] the border title, coerced the
|
|
78
|
+
# same way {HasCaption#caption=} coerces it.
|
|
79
|
+
def initialize(caption = nil)
|
|
80
|
+
@popup = nil
|
|
81
|
+
@chosen = false
|
|
82
|
+
@message = nil
|
|
83
|
+
@on_dismiss = nil
|
|
84
|
+
# Insertion-ordered; identity-keyed so equal captions stay two buttons.
|
|
85
|
+
@actions = {}.compare_by_identity
|
|
86
|
+
@mnemonics = {}
|
|
87
|
+
super(caption)
|
|
88
|
+
@body_slot = Slot.new
|
|
89
|
+
@button_row = Layout::Horizontal.new(spacing: 2)
|
|
90
|
+
@box = Layout::Vertical.new(spacing: 1, padding: Layout::Insets[left: 1, right: 1])
|
|
91
|
+
@box.add(@body_slot, Layout::Expand[1])
|
|
92
|
+
@box.add(@button_row, Layout::Fixed[1], cross: Layout::Fixed[0], align: :center)
|
|
93
|
+
self.content = @box
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Callback taking no arguments, fired when the dialog is dismissed — ESC,
|
|
97
|
+
# `q`, an outside click, or a {#button} declared without a block. Fires
|
|
98
|
+
# exactly once per {#open}, and never when an action button was chosen.
|
|
99
|
+
# @return [Proc, nil]
|
|
100
|
+
attr_accessor :on_dismiss
|
|
101
|
+
|
|
102
|
+
# @return [String, StyledString, Component, nil] whatever {#message=} was
|
|
103
|
+
# given — set a `String`, read that `String` back. The component
|
|
104
|
+
# rendering it is derived, never returned.
|
|
105
|
+
attr_reader :message
|
|
106
|
+
|
|
107
|
+
# Also re-measures the popup: the caption participates in the width.
|
|
108
|
+
# @param new_caption [String, StyledString, nil]
|
|
109
|
+
# @return [void]
|
|
110
|
+
def caption=(new_caption)
|
|
111
|
+
super
|
|
112
|
+
resize_popup
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Sets the dialog body. Text (`String` / {StyledString}) is rendered by a
|
|
116
|
+
# word-wrapping, scrollable {TextView} the dialog owns and measures; a
|
|
117
|
+
# {Component} is mounted as-is, and the dialog — which may measure only
|
|
118
|
+
# content it owns — then takes the full half-screen box. `nil` clears.
|
|
119
|
+
# @param value [String, StyledString, Component, nil]
|
|
120
|
+
# @raise [TypeError] on any other type.
|
|
121
|
+
# @return [void]
|
|
122
|
+
def message=(value)
|
|
123
|
+
occupant =
|
|
124
|
+
case value
|
|
125
|
+
when nil then nil
|
|
126
|
+
when Component then value
|
|
127
|
+
when String, StyledString then TextView.new.tap { _1.text = value }
|
|
128
|
+
else raise TypeError, "expected String, StyledString, Component or nil, got #{value.inspect}"
|
|
129
|
+
end
|
|
130
|
+
@message = value
|
|
131
|
+
@body_slot.content = occupant
|
|
132
|
+
resize_popup
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Appends a button and returns it. A button with a block is an action
|
|
136
|
+
# button: pressing it closes the dialog, then fires the block. A button
|
|
137
|
+
# without one is a Cancel: pressing it closes the dialog, then fires
|
|
138
|
+
# {#on_dismiss}.
|
|
139
|
+
#
|
|
140
|
+
# dialog.button("Delete") { delete! } # mnemonic d, underlined
|
|
141
|
+
# dialog.button("Keep", mnemonic: "e") # explicit letter
|
|
142
|
+
# dialog.button("Cancel", mnemonic: nil) # no mnemonic
|
|
143
|
+
#
|
|
144
|
+
# The mnemonic — a printable letter activating the button from anywhere in
|
|
145
|
+
# the dialog, case-insensitively — is underlined in the caption. Any case
|
|
146
|
+
# is accepted, and the underline prefers the exact case given, so the case
|
|
147
|
+
# picks which occurrence is cued (`"Save As"` with `"A"` underlines the
|
|
148
|
+
# *As*), exactly as on {MenuBar#add_item}. The default `:auto` derives the
|
|
149
|
+
# caption's first letter (cueing it as displayed) and is best-effort:
|
|
150
|
+
# silently skipped when that letter is reserved, taken, or unusable. An
|
|
151
|
+
# explicit letter is a promise and raises when it cannot be kept.
|
|
152
|
+
#
|
|
153
|
+
# Declare buttons before {#open}: adding one to an open dialog re-measures
|
|
154
|
+
# the popup, but momentarily bounces focus off the button row.
|
|
155
|
+
# @param caption [String, StyledString] the button label.
|
|
156
|
+
# @param mnemonic [Symbol, String, nil] `:auto` (default), a printable
|
|
157
|
+
# one-column character, or `nil` for none.
|
|
158
|
+
# @yield optional action, fired after the dialog closes.
|
|
159
|
+
# @raise [ArgumentError] on an explicit mnemonic that is reserved
|
|
160
|
+
# ({RESERVED_MNEMONICS}), a space, already taken, or not a one-column
|
|
161
|
+
# printable.
|
|
162
|
+
# @return [Button] the appended button.
|
|
163
|
+
def button(caption, mnemonic: :auto, &action)
|
|
164
|
+
styled = StyledString.parse(caption)
|
|
165
|
+
letter = resolve_mnemonic(styled, mnemonic)
|
|
166
|
+
# The cue keeps the case the caller (or the caption) wrote, so `:auto`
|
|
167
|
+
# underlines "Discard"'s leading D rather than its trailing d, and an
|
|
168
|
+
# explicit letter picks its occurrence by case as on MenuBar; `letter`
|
|
169
|
+
# is the downcased matching key.
|
|
170
|
+
cue = mnemonic == :auto ? styled.to_s.grapheme_clusters.first : mnemonic
|
|
171
|
+
btn = Button.new(letter ? underline_mnemonic(styled, cue) : styled)
|
|
172
|
+
btn.on_click = -> { activate(btn) }
|
|
173
|
+
@actions[btn] = action
|
|
174
|
+
@mnemonics[letter] = btn unless letter.nil?
|
|
175
|
+
@button_row.add(btn, Layout::Fixed[btn.caption.display_width + 4])
|
|
176
|
+
recenter_button_row
|
|
177
|
+
resize_popup
|
|
178
|
+
btn
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Opens the dialog as a centered modal {Popup} sized by {#measured_size}
|
|
182
|
+
# and returns that popup. ESC, `q` and an outside click dismiss it
|
|
183
|
+
# (firing {#on_dismiss}); every button closes it too.
|
|
184
|
+
# @raise [Tuile::Error] if this dialog is already open.
|
|
185
|
+
# @return [Popup] the mounted popup; `popup.close` closes programmatically,
|
|
186
|
+
# as {#close} also does.
|
|
187
|
+
def open
|
|
188
|
+
raise Tuile::Error, "#{inspect} is already open" if @popup&.open?
|
|
189
|
+
|
|
190
|
+
# A previous popup still holds this window as its content; reclaim it.
|
|
191
|
+
@popup&.content = nil
|
|
192
|
+
@chosen = false
|
|
193
|
+
@popup = MeasuredPopup.new(self)
|
|
194
|
+
@popup.on_close = -> { notice_dismissed }
|
|
195
|
+
@popup.open
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Closes the popup {#open} mounted, which counts as a dismissal
|
|
199
|
+
# ({#on_dismiss} fires). No-op when not open, or when used tiled.
|
|
200
|
+
# @return [void]
|
|
201
|
+
def close = @popup&.close
|
|
202
|
+
|
|
203
|
+
# The popup box this dialog wants: wide enough for the caption, the widest
|
|
204
|
+
# message line and the button row, tall enough for the wrapped message
|
|
205
|
+
# plus the button row — each capped at half of `reference`. The re-grow
|
|
206
|
+
# rule's caller-side measure query: the dialog measures only content it
|
|
207
|
+
# *owns*, so a {Component} assigned to {#message=} yields the full
|
|
208
|
+
# half-screen box.
|
|
209
|
+
# @param reference [Size] the screen size to cap against.
|
|
210
|
+
# @return [Size]
|
|
211
|
+
def measured_size(reference = screen.size)
|
|
212
|
+
cap = Fraction::HALF.resolve(reference)
|
|
213
|
+
return cap if @message.is_a?(Component)
|
|
214
|
+
|
|
215
|
+
styled = @message.nil? ? StyledString::EMPTY : StyledString.parse(@message)
|
|
216
|
+
widest = styled.lines.map(&:display_width).max || 0
|
|
217
|
+
width = [[caption.display_width + 2, button_row_width + WIDTH_CHROME, widest + WIDTH_CHROME].max,
|
|
218
|
+
cap.width].min
|
|
219
|
+
rows = styled.empty? ? 0 : styled.wrap([width - WIDTH_CHROME, 1].max).size
|
|
220
|
+
Size.new(width, [rows + HEIGHT_CHROME, cap.height].min)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Focus lands on the first button rather than cascading into the message
|
|
224
|
+
# body, which sits before the button row in the tree.
|
|
225
|
+
# @return [void]
|
|
226
|
+
def on_focus
|
|
227
|
+
first = @actions.keys.first
|
|
228
|
+
if first.nil?
|
|
229
|
+
super
|
|
230
|
+
else
|
|
231
|
+
screen.focused = first
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Handles the dialog-wide keys: Left/Right move between the buttons,
|
|
236
|
+
# {BODY_SCROLL_KEYS} are hand-fed to the message body, and a mnemonic
|
|
237
|
+
# letter presses its button. Reached by bubbling — the focused button or
|
|
238
|
+
# body sees the key first, so a focused body consumes its own scroll keys
|
|
239
|
+
# before this runs.
|
|
240
|
+
# @param key [String]
|
|
241
|
+
# @return [Boolean] true if the key was handled.
|
|
242
|
+
def handle_key(key)
|
|
243
|
+
case key
|
|
244
|
+
when Keys::LEFT_ARROW then return focus_button_step(-1)
|
|
245
|
+
when Keys::RIGHT_ARROW then return focus_button_step(1)
|
|
246
|
+
when *BODY_SCROLL_KEYS then return @body_slot.content&.handle_key(key) || false
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
target = @mnemonics[key.downcase]
|
|
250
|
+
return false if target.nil?
|
|
251
|
+
|
|
252
|
+
activate(target)
|
|
253
|
+
true
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Opens an acknowledgement dialog: a message and one button, whose press
|
|
257
|
+
# is the dismissal. The button exists for discoverability — ESC and `q`
|
|
258
|
+
# close too, but Tuile advertises no keys anywhere, and the button is
|
|
259
|
+
# clickable.
|
|
260
|
+
# @param caption [String, StyledString, nil] the border title.
|
|
261
|
+
# @param message [String, StyledString, Component, nil] see {#message=}.
|
|
262
|
+
# @param button [String, StyledString] the button label.
|
|
263
|
+
# @return [Popup] the mounted popup.
|
|
264
|
+
def self.alert(caption, message, button: "OK")
|
|
265
|
+
window = new(caption)
|
|
266
|
+
window.message = message
|
|
267
|
+
window.button(button)
|
|
268
|
+
window.open
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Opens a two-button question: the block is the action, the cancel button
|
|
272
|
+
# (with ESC, `q` and an outside click) is the dismissal.
|
|
273
|
+
#
|
|
274
|
+
# Component::ConfirmWindow.confirm("Delete Report Q4?", "This cannot be undone.",
|
|
275
|
+
# confirm: "Delete") { delete! }
|
|
276
|
+
#
|
|
277
|
+
# @param caption [String, StyledString, nil] the border title.
|
|
278
|
+
# @param message [String, StyledString, Component, nil] see {#message=}.
|
|
279
|
+
# @param confirm [String, StyledString] the action button's label.
|
|
280
|
+
# @param cancel [String, StyledString] the dismissal button's label.
|
|
281
|
+
# @param on_dismiss [Proc, nil] see {#on_dismiss}.
|
|
282
|
+
# @yield the action, fired after the dialog closes.
|
|
283
|
+
# @raise [ArgumentError] without a block — a confirm without an action is
|
|
284
|
+
# an {.alert}.
|
|
285
|
+
# @return [Popup] the mounted popup.
|
|
286
|
+
def self.confirm(caption, message, confirm: "Confirm", cancel: "Cancel", on_dismiss: nil, &action)
|
|
287
|
+
raise ArgumentError, "block required" unless action
|
|
288
|
+
|
|
289
|
+
window = new(caption)
|
|
290
|
+
window.message = message
|
|
291
|
+
window.button(confirm, &action)
|
|
292
|
+
window.button(cancel)
|
|
293
|
+
window.on_dismiss = on_dismiss
|
|
294
|
+
window.open
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# {.confirm} with Yes/No labels — the other canonical phrasing.
|
|
298
|
+
# @param caption [String, StyledString, nil] the border title.
|
|
299
|
+
# @param message [String, StyledString, Component, nil] see {#message=}.
|
|
300
|
+
# @param on_dismiss [Proc, nil] see {#on_dismiss}.
|
|
301
|
+
# @yield the action, fired on Yes after the dialog closes.
|
|
302
|
+
# @raise [ArgumentError] without a block.
|
|
303
|
+
# @return [Popup] the mounted popup.
|
|
304
|
+
def self.yes_no(caption, message, on_dismiss: nil, &action)
|
|
305
|
+
confirm(caption, message, confirm: "Yes", cancel: "No", on_dismiss:, &action)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# The {Popup} that {#open} wraps the dialog in: its declared size is
|
|
309
|
+
# derived from the dialog on every {#reposition}, so a message change and
|
|
310
|
+
# a SIGWINCH both re-measure against the current screen.
|
|
311
|
+
class MeasuredPopup < Popup
|
|
312
|
+
# @param window [ConfirmWindow]
|
|
313
|
+
def initialize(window)
|
|
314
|
+
# Before super: Popup#initialize ends in the first #reposition call.
|
|
315
|
+
@window = window
|
|
316
|
+
super(content: window)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# @return [void]
|
|
320
|
+
def reposition
|
|
321
|
+
# The ivar, not #declared_size= — the writer calls reposition itself.
|
|
322
|
+
@declared_size = @window.measured_size(screen.size)
|
|
323
|
+
super
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
private_constant :MeasuredPopup
|
|
327
|
+
|
|
328
|
+
private
|
|
329
|
+
|
|
330
|
+
# The chosen-button path, in the settled order: mark chosen, close the
|
|
331
|
+
# popup (whose on_close then skips the dismissal), fire the callback last
|
|
332
|
+
# so it sees the dialog already gone — a callback opening a follow-up
|
|
333
|
+
# popup gets clean focus-repair state.
|
|
334
|
+
# @param btn [Button]
|
|
335
|
+
# @return [void]
|
|
336
|
+
def activate(btn)
|
|
337
|
+
action = @actions[btn]
|
|
338
|
+
@chosen = true
|
|
339
|
+
@popup&.close
|
|
340
|
+
action.nil? ? @on_dismiss&.call : action.call
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# The popup's on_close: fires {#on_dismiss} unless a button was chosen —
|
|
344
|
+
# which makes ESC, `q`, an outside click, {#close}, a direct
|
|
345
|
+
# {Screen#remove_popup} and teardown all one event, fired exactly once.
|
|
346
|
+
# @return [void]
|
|
347
|
+
def notice_dismissed
|
|
348
|
+
return if @chosen
|
|
349
|
+
|
|
350
|
+
@chosen = true
|
|
351
|
+
@on_dismiss&.call
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# @param delta [Integer] -1 or 1.
|
|
355
|
+
# @return [Boolean] false when focus is not on a button (the key bubbles on).
|
|
356
|
+
def focus_button_step(delta)
|
|
357
|
+
buttons = @actions.keys
|
|
358
|
+
current = buttons.index(screen.focused)
|
|
359
|
+
return false if current.nil?
|
|
360
|
+
|
|
361
|
+
screen.focused = buttons[(current + delta) % buttons.size]
|
|
362
|
+
true
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Validates an explicit mnemonic (raising, as {MenuBar#add_item} does —
|
|
366
|
+
# none of these has a sane answer at keypress time) or best-effort derives
|
|
367
|
+
# one from the caption's first letter (returning nil where the explicit
|
|
368
|
+
# path would raise).
|
|
369
|
+
# @param caption [StyledString]
|
|
370
|
+
# @param mnemonic [Symbol, String, nil]
|
|
371
|
+
# @raise [ArgumentError] see {#button}.
|
|
372
|
+
# @return [String, nil] the downcased letter, or nil for none.
|
|
373
|
+
def resolve_mnemonic(caption, mnemonic)
|
|
374
|
+
return nil if mnemonic.nil?
|
|
375
|
+
return derive_mnemonic(caption) if mnemonic == :auto
|
|
376
|
+
|
|
377
|
+
raise ArgumentError, "mnemonic must not be a space: Space presses the focused button" if mnemonic == " "
|
|
378
|
+
unless Keys.printable?(mnemonic) && StyledString.plain(mnemonic).display_width == 1
|
|
379
|
+
raise ArgumentError, "mnemonic must be a single one-column printable character; got #{mnemonic.inspect}"
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
down = mnemonic.downcase
|
|
383
|
+
if RESERVED_MNEMONICS.include?(down)
|
|
384
|
+
raise ArgumentError, "mnemonic #{down.inspect} is reserved: q dismisses the dialog, g/G scroll the message"
|
|
385
|
+
end
|
|
386
|
+
raise ArgumentError, "duplicate mnemonic #{down.inspect}" if @mnemonics.key?(down)
|
|
387
|
+
|
|
388
|
+
down
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# @param caption [StyledString]
|
|
392
|
+
# @return [String, nil]
|
|
393
|
+
def derive_mnemonic(caption)
|
|
394
|
+
letter = caption.to_s.grapheme_clusters.first&.downcase
|
|
395
|
+
return nil if letter.nil? || letter == " "
|
|
396
|
+
return nil unless Keys.printable?(letter) && StyledString.plain(letter).display_width == 1
|
|
397
|
+
return nil if RESERVED_MNEMONICS.include?(letter) || @mnemonics.key?(letter)
|
|
398
|
+
|
|
399
|
+
letter
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# {StyledString#slice} counts **columns** while a caption search yields a
|
|
403
|
+
# **character** index, so the prefix is measured, never counted (the
|
|
404
|
+
# {MenuBar::Item} cue, duplicated per `D_float_field`'s shallow-shell rule).
|
|
405
|
+
# @param caption [StyledString]
|
|
406
|
+
# @param cue [String] the letter in the case it was given in — exact case
|
|
407
|
+
# first, so the case picks which occurrence is underlined.
|
|
408
|
+
# @return [StyledString]
|
|
409
|
+
def underline_mnemonic(caption, cue)
|
|
410
|
+
text = caption.to_s
|
|
411
|
+
index = text.index(cue) || text.downcase.index(cue.downcase)
|
|
412
|
+
return caption if index.nil?
|
|
413
|
+
|
|
414
|
+
start = StyledString.plain(text[0, index]).display_width
|
|
415
|
+
caption.slice(0, start) + caption.slice(start, 1).with_underline +
|
|
416
|
+
caption.slice(start + 1, caption.display_width - start - 1)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Re-declares the button row's cross extent — the buttons' summed natural
|
|
420
|
+
# width — so the {Layout::Vertical} centers it. Remove-and-re-add is the
|
|
421
|
+
# only way to change a {Layout::Box} constraint; the row stays after the
|
|
422
|
+
# body because both `add`s append.
|
|
423
|
+
# @return [void]
|
|
424
|
+
def recenter_button_row
|
|
425
|
+
@box.remove(@button_row)
|
|
426
|
+
@box.add(@button_row, Layout::Fixed[1], cross: Layout::Fixed[button_row_width], align: :center)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# @return [Integer] columns of the button row at its natural width.
|
|
430
|
+
def button_row_width
|
|
431
|
+
widths = @actions.keys.map { _1.caption.display_width + 4 }
|
|
432
|
+
widths.sum + (@button_row.spacing * [widths.size - 1, 0].max)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Re-measures the popup while open; a no-op tiled or before {#open}.
|
|
436
|
+
# @return [void]
|
|
437
|
+
def resize_popup
|
|
438
|
+
@popup.reposition if @popup&.open?
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
end
|
|
@@ -2,19 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
|
-
# A
|
|
6
|
-
#
|
|
7
|
-
# content
|
|
5
|
+
# A component that owns exactly one child *directly*, under the name
|
|
6
|
+
# `content`. The includer initializes `@content` to nil and provides a
|
|
7
|
+
# protected `layout(content)` positioning the child; the mixin owns the swap:
|
|
8
|
+
#
|
|
9
|
+
# class Slot < Component
|
|
10
|
+
# include Component::HasContent
|
|
11
|
+
#
|
|
12
|
+
# def initialize = (super; @content = nil)
|
|
13
|
+
#
|
|
14
|
+
# protected
|
|
15
|
+
#
|
|
16
|
+
# def layout(content) = content.rect = rect
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# Include it when the child is **permanent and integral** — a typed field's
|
|
20
|
+
# inner {TextField}, an {Overlay}'s body. It does *not* mean "a component
|
|
21
|
+
# with one child": an includer may hold others alongside, as {Window} does
|
|
22
|
+
# with its footer. For a region an app swaps, hold a {Slot} instead.
|
|
23
|
+
#
|
|
24
|
+
# A tree walk finds content generically through `is_a?(HasContent)` plus a
|
|
25
|
+
# `content` compare, which is why this is a mixin rather than a per-class
|
|
26
|
+
# accessor — the same reason {HasCaption} is one.
|
|
8
27
|
module HasContent
|
|
9
28
|
# @return [Component, nil] the current content component.
|
|
10
29
|
attr_reader :content
|
|
11
30
|
|
|
12
|
-
# @param event [MouseEvent]
|
|
13
|
-
# @return [void]
|
|
14
|
-
def handle_mouse(event)
|
|
15
|
-
content.handle_mouse(event) if !content.nil? && content.rect.contains?(event.point)
|
|
16
|
-
end
|
|
17
|
-
|
|
18
31
|
# Sets the new content of this component. Updates `@content` itself;
|
|
19
32
|
# including classes may still override to add behaviour (e.g. a
|
|
20
33
|
# special-cased Array input) but should call `super` to perform the
|
|
@@ -56,7 +56,7 @@ module Tuile
|
|
|
56
56
|
# a read-only display field could override back to `false`. Only
|
|
57
57
|
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
58
58
|
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
59
|
-
# `
|
|
59
|
+
# `D_integer_field`).
|
|
60
60
|
# @return [Boolean]
|
|
61
61
|
def focusable? = true
|
|
62
62
|
end
|
|
@@ -2,30 +2,78 @@
|
|
|
2
2
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
|
-
# A {Window}
|
|
6
|
-
#
|
|
5
|
+
# A {Window} with a read-only body, in one of two presentations:
|
|
6
|
+
# **prose** ({#message=}) wraps in a scrollable {TextView}, **rows**
|
|
7
|
+
# ({#lines=}) stay one per row in a {List}, truncating instead of
|
|
8
|
+
# wrapping — the presentation for columnar output, where a wrap would
|
|
9
|
+
# destroy the alignment. Usable tiled (add it to a {Layout}) or as a
|
|
10
|
+
# popup via {.open}; the constructor and {.open} pick the presentation
|
|
11
|
+
# from the body's type:
|
|
7
12
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
13
|
+
# Component::InfoWindow.open("Help", "A long explanation, wrapped to fit.")
|
|
14
|
+
# Component::InfoWindow.open("Files", ["drwx src/", "-rw- README.md"])
|
|
15
|
+
#
|
|
16
|
+
# Both write the same body slot — the last writer wins.
|
|
10
17
|
class InfoWindow < Window
|
|
11
|
-
# @param caption [String]
|
|
12
|
-
# @param
|
|
13
|
-
#
|
|
14
|
-
def initialize(caption = "",
|
|
18
|
+
# @param caption [String, StyledString, nil] the border title.
|
|
19
|
+
# @param body [String, StyledString, Component, Array, nil] an `Array`
|
|
20
|
+
# is assigned through {#lines=}, anything else through {#message=}.
|
|
21
|
+
def initialize(caption = "", body = nil)
|
|
22
|
+
@message = nil
|
|
15
23
|
super(caption)
|
|
16
|
-
|
|
24
|
+
if body.is_a?(Array)
|
|
25
|
+
self.lines = body
|
|
26
|
+
else
|
|
27
|
+
self.message = body
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [String, StyledString, Component, nil] whatever {#message=}
|
|
32
|
+
# was given — set a `String`, read that `String` back. After
|
|
33
|
+
# {#lines=} it is the {List} that setter mounted.
|
|
34
|
+
attr_reader :message
|
|
35
|
+
|
|
36
|
+
# Sets the prose body. Text (`String` / {StyledString}) is rendered by
|
|
37
|
+
# a word-wrapping, scrollable {TextView} the window owns; a {Component}
|
|
38
|
+
# is mounted as-is. `nil` clears.
|
|
39
|
+
# @param value [String, StyledString, Component, nil]
|
|
40
|
+
# @raise [TypeError] on any other type.
|
|
41
|
+
# @return [void]
|
|
42
|
+
def message=(value)
|
|
43
|
+
occupant =
|
|
44
|
+
case value
|
|
45
|
+
when nil then nil
|
|
46
|
+
when Component then value
|
|
47
|
+
when String, StyledString then TextView.new.tap { _1.text = value }
|
|
48
|
+
else raise TypeError, "expected String, StyledString, Component or nil, got #{value.inspect}"
|
|
49
|
+
end
|
|
50
|
+
@message = value
|
|
51
|
+
self.content = occupant
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Sets the rows body: a {List} populated via {List#lines=} (so entries
|
|
55
|
+
# are coerced, `\n`-split and rstripped exactly as there), one item per
|
|
56
|
+
# row, long rows truncated — never wrapped. For prose use {#message=}.
|
|
57
|
+
# @param lines [Array] entries are `String`, `StyledString`, or anything
|
|
58
|
+
# that responds to `#to_s`.
|
|
59
|
+
# @raise [TypeError] unless an `Array`.
|
|
60
|
+
# @return [void]
|
|
61
|
+
def lines=(lines)
|
|
62
|
+
list = List.new
|
|
17
63
|
list.lines = lines
|
|
18
|
-
self.
|
|
64
|
+
self.message = list
|
|
19
65
|
end
|
|
20
66
|
|
|
21
67
|
# Opens the info window as a popup.
|
|
22
|
-
# @param caption [String]
|
|
23
|
-
# @param
|
|
24
|
-
#
|
|
25
|
-
#
|
|
68
|
+
# @param caption [String, StyledString, nil] the border title.
|
|
69
|
+
# @param body [String, StyledString, Component, Array, nil] dispatched
|
|
70
|
+
# by type as in {#initialize}.
|
|
71
|
+
# @param declared_size [Size, Fraction] the popup's box, applied
|
|
72
|
+
# top-down; the body wraps or scrolls within it. Defaults to
|
|
73
|
+
# {Fraction::HALF}.
|
|
26
74
|
# @return [Popup] the opened popup.
|
|
27
|
-
def self.open(caption,
|
|
28
|
-
Popup.new(content: InfoWindow.new(caption,
|
|
75
|
+
def self.open(caption, body = nil, declared_size: Fraction::HALF)
|
|
76
|
+
Popup.new(content: InfoWindow.new(caption, body), declared_size: declared_size).open
|
|
29
77
|
end
|
|
30
78
|
end
|
|
31
79
|
end
|
|
@@ -190,16 +190,6 @@ module Tuile
|
|
|
190
190
|
invalidate if @children.empty? # nothing left to paint over the gap
|
|
191
191
|
end
|
|
192
192
|
|
|
193
|
-
# Dispatches the event to the child under the mouse cursor.
|
|
194
|
-
# @param event [MouseEvent]
|
|
195
|
-
# @return [void]
|
|
196
|
-
def handle_mouse(event)
|
|
197
|
-
super
|
|
198
|
-
@children.each do |child|
|
|
199
|
-
child.handle_mouse(event) if child.rect.contains?(event.point)
|
|
200
|
-
end
|
|
201
|
-
end
|
|
202
|
-
|
|
203
193
|
# @return [void]
|
|
204
194
|
def on_focus
|
|
205
195
|
super
|