tuile 0.15.0 → 0.16.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 +121 -80
- data/README.md +28 -12
- data/book/04-event-loop.md +12 -12
- data/book/05-focus.md +95 -26
- data/book/06-theming.md +58 -26
- data/book/07-components.md +61 -6
- data/book/08-testing.md +24 -22
- data/book/10-locale.md +2 -2
- data/book/README.md +6 -5
- data/examples/file_commander.rb +14 -5
- data/examples/hello_world.rb +17 -4
- data/examples/sampler.rb +392 -18
- data/lib/tuile/component/abstract_string_field.rb +16 -18
- data/lib/tuile/component/abstract_wrapping_field.rb +47 -11
- data/lib/tuile/component/button.rb +8 -8
- data/lib/tuile/component/checkbox.rb +9 -9
- data/lib/tuile/component/checkbox_group.rb +6 -5
- data/lib/tuile/component/combo_box.rb +50 -35
- data/lib/tuile/component/confirm_window.rb +7 -5
- data/lib/tuile/component/date_field.rb +28 -3
- data/lib/tuile/component/date_time_field.rb +275 -0
- data/lib/tuile/component/has_bad_input.rb +2 -2
- data/lib/tuile/component/has_content.rb +3 -3
- data/lib/tuile/component/has_placeholder.rb +1 -1
- data/lib/tuile/component/has_validation.rb +2 -2
- data/lib/tuile/component/has_value.rb +1 -1
- data/lib/tuile/component/label.rb +1 -1
- data/lib/tuile/component/layout/box.rb +4 -1
- data/lib/tuile/component/layout.rb +3 -3
- data/lib/tuile/component/list.rb +42 -32
- data/lib/tuile/component/list_dropdown.rb +3 -3
- data/lib/tuile/component/menu_bar/cascade.rb +5 -5
- data/lib/tuile/component/menu_bar.rb +18 -18
- data/lib/tuile/component/notification.rb +32 -18
- data/lib/tuile/component/overlay.rb +9 -8
- data/lib/tuile/component/picker_window.rb +27 -8
- data/lib/tuile/component/popup.rb +2 -2
- data/lib/tuile/component/progress_bar.rb +10 -4
- data/lib/tuile/component/radio_group.rb +6 -5
- data/lib/tuile/component/select.rb +11 -12
- data/lib/tuile/component/slot.rb +3 -3
- data/lib/tuile/component/tab_sheet.rb +6 -6
- data/lib/tuile/component/tabs.rb +11 -11
- data/lib/tuile/component/text_area.rb +12 -10
- data/lib/tuile/component/text_field.rb +14 -12
- data/lib/tuile/component/text_view.rb +15 -11
- data/lib/tuile/component/time_field.rb +29 -4
- data/lib/tuile/component.rb +201 -93
- data/lib/tuile/event_queue.rb +4 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +84 -2
- data/lib/tuile/mouse/router.rb +217 -0
- data/lib/tuile/mouse.rb +177 -0
- data/lib/tuile/screen.rb +98 -61
- data/lib/tuile/screen_pane.rb +41 -36
- data/lib/tuile/styled_string.rb +5 -5
- data/lib/tuile/testing.rb +8 -8
- data/lib/tuile/theme.rb +22 -34
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +1 -1
- data/sig/tuile.rbs +1211 -427
- metadata +4 -16
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -8562
- data/TERMINOLOGY.md +0 -85
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/binder.md +0 -177
- data/ideas/composite-field.md +0 -77
- data/ideas/focus-accent.md +0 -116
- data/ideas/form-layout.md +0 -151
- data/ideas/hover/probe.rb +0 -241
- data/ideas/hover/probe_spec.rb +0 -82
- data/ideas/hover.md +0 -909
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -144
- data/ideas/per-component-buffers.md +0 -55
- data/lib/tuile/mouse_event.rb +0 -68
|
@@ -42,7 +42,7 @@ module Tuile
|
|
|
42
42
|
#
|
|
43
43
|
# {Item} handles are minted by {#add_item} and nest via the *same* method, so
|
|
44
44
|
# depth is unlimited. There is no removal, no reordering and no dynamic
|
|
45
|
-
# rebuilding: a menu is built once, at construction. See `
|
|
45
|
+
# rebuilding: a menu is built once, at construction. See `design/decisions.md`
|
|
46
46
|
# `D_menu_bar`.
|
|
47
47
|
#
|
|
48
48
|
# == Sizing
|
|
@@ -242,7 +242,7 @@ module Tuile
|
|
|
242
242
|
#
|
|
243
243
|
# Both the highlight and the click hit test use it, so a click on the blank
|
|
244
244
|
# tail — or on a lower row, when the rect is taller than one — opens
|
|
245
|
-
# nothing. It still *focuses*: {
|
|
245
|
+
# nothing. It still *focuses*: {Mouse::Router}'s click-to-focus is
|
|
246
246
|
# ungated by geometry.
|
|
247
247
|
# @return [Size]
|
|
248
248
|
def extent
|
|
@@ -275,7 +275,7 @@ module Tuile
|
|
|
275
275
|
# panels on the pane — they are the {ScreenPane}'s children, not the bar's,
|
|
276
276
|
# so nothing else would take them down.
|
|
277
277
|
# @return [void]
|
|
278
|
-
def
|
|
278
|
+
def handle_detached
|
|
279
279
|
super
|
|
280
280
|
@cascade.close
|
|
281
281
|
end
|
|
@@ -288,11 +288,11 @@ module Tuile
|
|
|
288
288
|
# submenu — and both step to the sibling menu.
|
|
289
289
|
# @param key [String]
|
|
290
290
|
# @return [Boolean]
|
|
291
|
-
def handle_key(key)
|
|
291
|
+
def handle_key?(key)
|
|
292
292
|
# Ahead of the cascade: an open one swallows every printable it doesn't
|
|
293
293
|
# recognize, so a letter would never reach the strip otherwise.
|
|
294
|
-
return true if handle_mnemonic(key)
|
|
295
|
-
return true if @cascade.handle_key(key)
|
|
294
|
+
return true if handle_mnemonic?(key)
|
|
295
|
+
return true if @cascade.handle_key?(key)
|
|
296
296
|
|
|
297
297
|
if @cascade.open?
|
|
298
298
|
case key
|
|
@@ -310,17 +310,16 @@ module Tuile
|
|
|
310
310
|
end
|
|
311
311
|
end
|
|
312
312
|
|
|
313
|
-
# Opens the menu under a left
|
|
314
|
-
# open one;
|
|
315
|
-
#
|
|
316
|
-
# @param event [
|
|
317
|
-
# @return [
|
|
318
|
-
def
|
|
319
|
-
|
|
320
|
-
return unless event.button == :left
|
|
313
|
+
# Opens the menu under a left press, or closes it when it is already the
|
|
314
|
+
# open one; a press on the strip's padding claims the press and does
|
|
315
|
+
# nothing else.
|
|
316
|
+
# @param event [Mouse::DownEvent]
|
|
317
|
+
# @return [Boolean]
|
|
318
|
+
def handle_mouse_down?(event)
|
|
319
|
+
return false unless event.button == :left
|
|
321
320
|
|
|
322
321
|
index = index_at(event.point)
|
|
323
|
-
return if index.nil?
|
|
322
|
+
return true if index.nil?
|
|
324
323
|
|
|
325
324
|
if @cascade.open? && index == @highlighted_index
|
|
326
325
|
@cascade.close
|
|
@@ -328,6 +327,7 @@ module Tuile
|
|
|
328
327
|
self.highlight = index
|
|
329
328
|
open_highlighted
|
|
330
329
|
end
|
|
330
|
+
true
|
|
331
331
|
end
|
|
332
332
|
|
|
333
333
|
# @return [void]
|
|
@@ -372,7 +372,7 @@ module Tuile
|
|
|
372
372
|
# hook is the whole geometry story; {Component#rect=} invalidates for us,
|
|
373
373
|
# and {#rect=} closes the cascade rather than re-anchoring it.
|
|
374
374
|
# @return [void]
|
|
375
|
-
def
|
|
375
|
+
def handle_width_changed
|
|
376
376
|
super
|
|
377
377
|
adjust_left_column
|
|
378
378
|
end
|
|
@@ -512,11 +512,11 @@ module Tuile
|
|
|
512
512
|
# not offered to any other level.
|
|
513
513
|
# @param key [String]
|
|
514
514
|
# @return [Boolean] whether a mnemonic claimed the key.
|
|
515
|
-
def handle_mnemonic(key)
|
|
515
|
+
def handle_mnemonic?(key)
|
|
516
516
|
return false unless Keys.printable?(key)
|
|
517
517
|
|
|
518
518
|
down = key.downcase
|
|
519
|
-
return @cascade.handle_mnemonic(down) if @cascade.open?
|
|
519
|
+
return @cascade.handle_mnemonic?(down) if @cascade.open?
|
|
520
520
|
|
|
521
521
|
index = items.index { |item| item.mnemonic == down }
|
|
522
522
|
return false if index.nil?
|
|
@@ -28,20 +28,20 @@ module Tuile
|
|
|
28
28
|
# (floor {MIN_CAP_WIDTH}) and {HEIGHT_FRACTION} tall, and **grows but never
|
|
29
29
|
# shrinks** while it lives; a long message wraps to {MAX_ROWS_PER_MESSAGE}
|
|
30
30
|
# rows and is then ellipsized, and entries past the height cap wait unpainted.
|
|
31
|
-
# `
|
|
31
|
+
# `design/decisions.md` `D_notification` has why each of those is what it is.
|
|
32
32
|
#
|
|
33
33
|
# Three things it deliberately doesn't do:
|
|
34
34
|
#
|
|
35
35
|
# - **Take focus, or receive keys.** An {Overlay} sits off the
|
|
36
|
-
# key-dispatch scope ({ScreenPane#handle_key}), so no key arrives here at
|
|
37
|
-
# all. A left click *on the box* dismisses ({#
|
|
36
|
+
# key-dispatch scope ({ScreenPane#handle_key?}), so no key arrives here at
|
|
37
|
+
# all. A left click *on the box* dismisses ({#handle_mouse_down?}); an app
|
|
38
38
|
# wanting a key registers a global shortcut and calls {Overlay#close}. A
|
|
39
39
|
# click *elsewhere* does not — this is the one overlay with
|
|
40
40
|
# {Overlay#close_on_outside_click?} false, since a toast is timed and an
|
|
41
41
|
# unrelated click is not about it.
|
|
42
42
|
# - **Follow a theme flip.** A `Theme::Ref` `color:` is resolved once, when
|
|
43
43
|
# the message is added — a toast lives seconds, so there is no
|
|
44
|
-
# {Component#
|
|
44
|
+
# {Component#handle_theme_changed} rebuild.
|
|
45
45
|
# - **Take a size.** An {Overlay} has no declared box; the messages decide
|
|
46
46
|
# this one's, in {#reposition}.
|
|
47
47
|
class Notification < Overlay
|
|
@@ -122,7 +122,7 @@ module Tuile
|
|
|
122
122
|
@messages = []
|
|
123
123
|
@high_water = 0
|
|
124
124
|
@ticker = nil
|
|
125
|
-
@view =
|
|
125
|
+
@view = View.new
|
|
126
126
|
@window = Window.new
|
|
127
127
|
@window.content = @view
|
|
128
128
|
super(content: @window, close_on_outside_click: false)
|
|
@@ -179,27 +179,41 @@ module Tuile
|
|
|
179
179
|
self.rect = Rect.new([screen.size.width - width, 0].max, 0, width, height)
|
|
180
180
|
end
|
|
181
181
|
|
|
182
|
-
# A left
|
|
183
|
-
#
|
|
184
|
-
#
|
|
185
|
-
#
|
|
186
|
-
|
|
187
|
-
# {HasContent#handle_mouse} may run, since both end at a
|
|
188
|
-
# `screen.focused = …` inside this subtree (see {Overlay#focusable?}).
|
|
189
|
-
# @param event [MouseEvent]
|
|
190
|
-
# @return [void]
|
|
191
|
-
def handle_mouse(event)
|
|
182
|
+
# A left press dismisses the whole box, every message with it. Every other
|
|
183
|
+
# press is claimed and inert, so nothing beneath the toast acts on it.
|
|
184
|
+
# @param event [Mouse::DownEvent]
|
|
185
|
+
# @return [Boolean]
|
|
186
|
+
def handle_mouse_down?(event)
|
|
192
187
|
close if event.button == :left
|
|
188
|
+
true
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Claimed and inert: a stray wheel spin over the toast must neither nuke it
|
|
192
|
+
# nor reach whatever it covers.
|
|
193
|
+
# @param _event [Mouse::ScrollEvent]
|
|
194
|
+
# @return [Boolean]
|
|
195
|
+
def handle_mouse_scroll?(_event) = true
|
|
196
|
+
|
|
197
|
+
# The message rows, minus the wheel — the one thing a plain
|
|
198
|
+
# {Component::TextView} would add here. On a short terminal the queued
|
|
199
|
+
# messages are taller than the box, and the box is unfocusable, so
|
|
200
|
+
# scrolling them would be a capability the keyboard cannot reach
|
|
201
|
+
# (`D_mouse`); they are meant to *wait* until the ticker retires the ones
|
|
202
|
+
# above.
|
|
203
|
+
class View < TextView
|
|
204
|
+
# @param _event [Mouse::ScrollEvent]
|
|
205
|
+
# @return [Boolean] true — claimed on the box's behalf, and inert.
|
|
206
|
+
def handle_mouse_scroll?(_event) = true
|
|
193
207
|
end
|
|
194
208
|
|
|
195
209
|
# @return [void]
|
|
196
|
-
def
|
|
210
|
+
def handle_attached
|
|
197
211
|
super
|
|
198
212
|
sync_ticker
|
|
199
213
|
end
|
|
200
214
|
|
|
201
215
|
# @return [void]
|
|
202
|
-
def
|
|
216
|
+
def handle_detached
|
|
203
217
|
super
|
|
204
218
|
sync_ticker
|
|
205
219
|
end
|
|
@@ -218,7 +232,7 @@ module Tuile
|
|
|
218
232
|
# Syncs the retirement clock from the invariant "something to retire, and on
|
|
219
233
|
# screen" — the sole writer of `@ticker`. Four sites change whether it is
|
|
220
234
|
# wanted (append, a retirement that empties the queue, {#close}, detach),
|
|
221
|
-
# which is the 2×2 a start-in-{#
|
|
235
|
+
# which is the 2×2 a start-in-{#handle_attached} / cancel-in-{#handle_detached} pair
|
|
222
236
|
# gets half wrong. The early return is also what keeps an append from
|
|
223
237
|
# *restarting* the clock and extending the oldest message's life.
|
|
224
238
|
# @return [void]
|
|
@@ -23,7 +23,7 @@ module Tuile
|
|
|
23
23
|
# **{#focusable?} and {#modal?} move together — flip both or neither.** The
|
|
24
24
|
# defaults here are inert (`false`, `false`): a bare overlay floats without
|
|
25
25
|
# disturbing focus or key dispatch. {Component::Popup} flips both. What must
|
|
26
|
-
# not appear is a *focusable non-modal* overlay: {ScreenPane#handle_key}
|
|
26
|
+
# not appear is a *focusable non-modal* overlay: {ScreenPane#handle_key?}
|
|
27
27
|
# scopes delivery to the topmost modal popup or else the tiled content, so
|
|
28
28
|
# such an overlay would hold focus outside the key scope, where delivery
|
|
29
29
|
# reaches nobody and *every* keystroke goes dead until Tab recovers. A
|
|
@@ -85,9 +85,9 @@ module Tuile
|
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
# Whether a left click outside this overlay closes it (default true). The
|
|
88
|
-
# pane does the closing — {ScreenPane#
|
|
89
|
-
# overlays *before*
|
|
90
|
-
# *after*, so a widget that toggles its own overlay from a click on its
|
|
88
|
+
# pane does the closing — {ScreenPane#dismissing_popups_outside} snapshots
|
|
89
|
+
# the open overlays *before* the press is routed and closes the
|
|
90
|
+
# dismissable ones *after*, so a widget that toggles its own overlay from a click on its
|
|
91
91
|
# face (a {Component::Select}, a {Component::MenuBar} title) still toggles
|
|
92
92
|
# correctly: the delivered click closes the overlay and the dismissal then
|
|
93
93
|
# no-ops on it, rather than closing and reopening it. Only `:left`
|
|
@@ -130,12 +130,12 @@ module Tuile
|
|
|
130
130
|
# A callback taking no arguments, fired once this overlay has left the
|
|
131
131
|
# screen — **however it left**: {#close}, a direct {Screen#remove_popup},
|
|
132
132
|
# an outside click, or teardown via {Screen#close}. That unconditionality
|
|
133
|
-
# is the point, so it hangs off {#
|
|
133
|
+
# is the point, so it hangs off {#handle_detached} rather than {#close}; a
|
|
134
134
|
# driver keeping its own record of open overlays reconciles it here and
|
|
135
135
|
# cannot drift ({Component::MenuBar::Cascade} is the worked example).
|
|
136
136
|
#
|
|
137
137
|
# It fires *after* the overlay is detached, so {#open?} is already false
|
|
138
|
-
# and the usual {Component#
|
|
138
|
+
# and the usual {Component#handle_detached} caveats apply: release state, don't
|
|
139
139
|
# inspect the tree, keep it trivial (it may run while the pane is mid-way
|
|
140
140
|
# through closing a batch of overlays, and a raise propagates).
|
|
141
141
|
# @return [Proc, nil]
|
|
@@ -161,7 +161,7 @@ module Tuile
|
|
|
161
161
|
# overlay = Component::Overlay.new(content: label).open # construct and mount
|
|
162
162
|
#
|
|
163
163
|
# There is deliberately no class-level `Overlay.open` factory — see
|
|
164
|
-
# `
|
|
164
|
+
# `design/decisions.md` `D_popup_open`; returning `self` is what keeps the
|
|
165
165
|
# one-liner above available without one.
|
|
166
166
|
# @return [self]
|
|
167
167
|
def open
|
|
@@ -191,7 +191,8 @@ module Tuile
|
|
|
191
191
|
# Fires {#on_close}. A subclass overriding this **must** call `super`, or
|
|
192
192
|
# the overlay's driver never hears that it closed.
|
|
193
193
|
# @return [void]
|
|
194
|
-
def
|
|
194
|
+
def handle_detached
|
|
195
|
+
super
|
|
195
196
|
@on_close&.call
|
|
196
197
|
end
|
|
197
198
|
|
|
@@ -3,11 +3,26 @@
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
5
|
# A {Window} that lists options identified by single keyboard keys, asks
|
|
6
|
-
# the user to pick one, and fires a callback with the picked key.
|
|
6
|
+
# the user to pick one, and fires a callback with the picked key. Each row
|
|
7
|
+
# is `"<key> <caption>"`:
|
|
8
|
+
#
|
|
9
|
+
# PickerWindow.open("Sort by", [%w[n name], %w[s size]]) { sort_by(_1) }
|
|
10
|
+
#
|
|
11
|
+
# ┌Sort by───────┐
|
|
12
|
+
# │n name │
|
|
13
|
+
# │s size │
|
|
14
|
+
# └──────────────┘
|
|
7
15
|
#
|
|
8
16
|
# Usable tiled (just add to a {Layout} and read picks via the block) or
|
|
9
17
|
# as a popup via {.open}, which wraps it in a {Popup} that closes itself
|
|
10
18
|
# after a pick. ESC / `q` close without firing the callback.
|
|
19
|
+
#
|
|
20
|
+
# Captions paint in the terminal's own foreground. To color them, hand in
|
|
21
|
+
# {StyledString} captions — the picker styles nothing itself, so an app's
|
|
22
|
+
# own token applies per option:
|
|
23
|
+
#
|
|
24
|
+
# PickerWindow.open("File", [["o", StyledString.plain("Open")],
|
|
25
|
+
# ["d", theme.fg(:danger, "Delete")]]) { … }
|
|
11
26
|
class PickerWindow < Window
|
|
12
27
|
# Scrolls the window when more items.
|
|
13
28
|
# @return [Integer]
|
|
@@ -18,26 +33,30 @@ module Tuile
|
|
|
18
33
|
# @!attribute [r] key
|
|
19
34
|
# @return [String] the keyboard key that picks this option.
|
|
20
35
|
# @!attribute [r] caption
|
|
21
|
-
# @return [
|
|
36
|
+
# @return [StyledString] the option caption, coerced from whatever the
|
|
37
|
+
# constructor was handed.
|
|
22
38
|
class Option < Data.define(:key, :caption)
|
|
23
39
|
end
|
|
24
40
|
|
|
25
41
|
# @param caption [String] the window caption.
|
|
26
|
-
# @param options [Array<Array(String,
|
|
27
|
-
# option caption.
|
|
42
|
+
# @param options [Array<Array(String, StyledString)>] pairs of
|
|
43
|
+
# keyboard key and option caption. A caption goes through
|
|
44
|
+
# {StyledString.parse}, so a plain String, an ANSI-coded one (what
|
|
45
|
+
# {Theme#fg} returns) and a {StyledString} are all accepted.
|
|
28
46
|
# @yield [key] called with the option key once one is selected by the
|
|
29
47
|
# user. Not called if the picker is dismissed without picking.
|
|
30
48
|
# @yieldparam key [String] the picked option key.
|
|
31
49
|
# @yieldreturn [void]
|
|
50
|
+
# @raise [ArgumentError] when `block` is missing or `options` is empty.
|
|
32
51
|
def initialize(caption, options, &block)
|
|
33
52
|
raise ArgumentError, "block required" unless block
|
|
34
53
|
raise ArgumentError, "options must not be empty" if options.empty?
|
|
35
54
|
|
|
36
55
|
super(caption)
|
|
37
|
-
@options = options.map { Option.new(_1[0], _1[1]) }
|
|
56
|
+
@options = options.map { Option.new(_1[0], StyledString.parse(_1[1])) }
|
|
38
57
|
@block = block
|
|
39
58
|
list = Component::List.new
|
|
40
|
-
list.renderer = ->(option) { "#{option.key}
|
|
59
|
+
list.renderer = ->(option) { StyledString.plain("#{option.key} ") + option.caption }
|
|
41
60
|
list.items = @options
|
|
42
61
|
list.cursor = Component::List::Cursor.new
|
|
43
62
|
list.on_item_chosen = ->(_index, option) { select_option(option.key) }
|
|
@@ -57,7 +76,7 @@ module Tuile
|
|
|
57
76
|
# option's `key` picks that option.
|
|
58
77
|
# @param key [String]
|
|
59
78
|
# @return [Boolean]
|
|
60
|
-
def handle_key(key)
|
|
79
|
+
def handle_key?(key)
|
|
61
80
|
if @options.any? { _1.key == key }
|
|
62
81
|
select_option(key)
|
|
63
82
|
true
|
|
@@ -69,7 +88,7 @@ module Tuile
|
|
|
69
88
|
# Opens a picker as a popup. Picking an option fires `block`, then
|
|
70
89
|
# closes the popup; ESC / `q` close without firing `block`.
|
|
71
90
|
# @param caption [String]
|
|
72
|
-
# @param options [Array<Array(String,
|
|
91
|
+
# @param options [Array<Array(String, StyledString)>]
|
|
73
92
|
# @yield [key]
|
|
74
93
|
# @yieldparam key [String]
|
|
75
94
|
# @yieldreturn [void]
|
|
@@ -28,7 +28,7 @@ module Tuile
|
|
|
28
28
|
#
|
|
29
29
|
# `q` and ESC close the popup — handled here, at the top of the popup's own
|
|
30
30
|
# subtree, so the key only arrives after every component on the focus chain
|
|
31
|
-
# declined it (see {ScreenPane#handle_key}). That's why typing `q` into a
|
|
31
|
+
# declined it (see {ScreenPane#handle_key?}). That's why typing `q` into a
|
|
32
32
|
# nested {Component::TextField} doesn't dismiss the popup: the field consumes
|
|
33
33
|
# it first.
|
|
34
34
|
#
|
|
@@ -107,7 +107,7 @@ module Tuile
|
|
|
107
107
|
# focused content after that content declined to handle it.
|
|
108
108
|
# @param key [String]
|
|
109
109
|
# @return [Boolean] true if the key was handled.
|
|
110
|
-
def handle_key(key)
|
|
110
|
+
def handle_key?(key)
|
|
111
111
|
if [Keys::ESC, "q"].include?(key)
|
|
112
112
|
close
|
|
113
113
|
true
|
|
@@ -38,7 +38,7 @@ module Tuile
|
|
|
38
38
|
# The `█`/`░` pair is the same one {VerticalScrollBar} uses — East-Asian
|
|
39
39
|
# Ambiguous and Neutral respectively, so under an ambiguous-as-wide terminal
|
|
40
40
|
# the rendered length would vary with the fill level. Shipped anyway, per
|
|
41
|
-
# `
|
|
41
|
+
# `design/decisions.md` `D_ambiguous_width`: a bar that rhymes with the scrollbar
|
|
42
42
|
# beats a third convention, and if that bet is ever reversed both swap
|
|
43
43
|
# together.
|
|
44
44
|
class ProgressBar < Component
|
|
@@ -148,7 +148,7 @@ module Tuile
|
|
|
148
148
|
|
|
149
149
|
# Sets the color of both glyphs, live-resolved at paint time when given a
|
|
150
150
|
# {Theme::Ref} (so it follows a {Screen#theme=} with no
|
|
151
|
-
# {Component#
|
|
151
|
+
# {Component#handle_theme_changed} hook).
|
|
152
152
|
#
|
|
153
153
|
# bar.bar_color = Color::GREEN
|
|
154
154
|
# bar.bar_color = Theme.ref(:brand_ok) # an app #custom token
|
|
@@ -192,10 +192,16 @@ module Tuile
|
|
|
192
192
|
end
|
|
193
193
|
|
|
194
194
|
# @return [void]
|
|
195
|
-
def
|
|
195
|
+
def handle_attached
|
|
196
|
+
super
|
|
197
|
+
sync_ticker
|
|
198
|
+
end
|
|
196
199
|
|
|
197
200
|
# @return [void]
|
|
198
|
-
def
|
|
201
|
+
def handle_detached
|
|
202
|
+
super
|
|
203
|
+
sync_ticker
|
|
204
|
+
end
|
|
199
205
|
|
|
200
206
|
# Paints the bar on the first row of {#rect} and blanks the rest.
|
|
201
207
|
#
|
|
@@ -86,9 +86,10 @@ module Tuile
|
|
|
86
86
|
|
|
87
87
|
# The composed {List}: an app may *tune* it — its scrollbar, its cursor,
|
|
88
88
|
# `show_cursor_when_inactive` — but never replace it, since this group's
|
|
89
|
-
# renderer and selection are wired into this one.
|
|
90
|
-
#
|
|
91
|
-
# instead of forwarded
|
|
89
|
+
# renderer and selection are wired into this one (`design/decisions.md`
|
|
90
|
+
# `D_has_content`). Those knobs are {List} concepts rather than group
|
|
91
|
+
# concepts, which is why they are reached here instead of forwarded
|
|
92
|
+
# (`D_wrapping_field`).
|
|
92
93
|
# @return [List]
|
|
93
94
|
attr_reader :list
|
|
94
95
|
|
|
@@ -100,7 +101,7 @@ module Tuile
|
|
|
100
101
|
end
|
|
101
102
|
|
|
102
103
|
# @return [void]
|
|
103
|
-
def
|
|
104
|
+
def handle_focus
|
|
104
105
|
super
|
|
105
106
|
# The list is what the arrows drive, so it takes the focus this group
|
|
106
107
|
# was given; the group itself claims only Space.
|
|
@@ -155,7 +156,7 @@ module Tuile
|
|
|
155
156
|
# neither of us wants bubbles on to an ancestor.
|
|
156
157
|
# @param key [String]
|
|
157
158
|
# @return [Boolean]
|
|
158
|
-
def handle_key(key)
|
|
159
|
+
def handle_key?(key)
|
|
159
160
|
return false unless key == " "
|
|
160
161
|
|
|
161
162
|
select_at(list.cursor.position)
|
|
@@ -42,7 +42,7 @@ module Tuile
|
|
|
42
42
|
# Home/End are declined too, so they stay available app-wide.
|
|
43
43
|
#
|
|
44
44
|
# There is no type-ahead: a hidden prefix buffer *is* the ComboBox query with
|
|
45
|
-
# the feedback removed (`
|
|
45
|
+
# the feedback removed (`design/decisions.md` `D_select`). Which is also why labels
|
|
46
46
|
# need no prefix-disambiguation.
|
|
47
47
|
#
|
|
48
48
|
# == Implementation details
|
|
@@ -138,7 +138,7 @@ module Tuile
|
|
|
138
138
|
# is left unhandled so it bubbles to an ancestor.
|
|
139
139
|
# @param key [String]
|
|
140
140
|
# @return [Boolean]
|
|
141
|
-
def handle_key(key)
|
|
141
|
+
def handle_key?(key)
|
|
142
142
|
if @overlay.open?
|
|
143
143
|
return true if @overlay.move(key)
|
|
144
144
|
|
|
@@ -159,22 +159,21 @@ module Tuile
|
|
|
159
159
|
# The one row this Select paints — the full width, at the top of {#rect}.
|
|
160
160
|
# A single-slot container ({Component::Window}, {Component::Popup}) hands
|
|
161
161
|
# its content the whole inner rect, so a Select is routinely assigned more
|
|
162
|
-
# height than it uses; {#repaint} clears that tail,
|
|
163
|
-
#
|
|
162
|
+
# height than it uses; {#repaint} clears that tail, a press in it never reaches
|
|
163
|
+
# {#handle_mouse_down?}, and the dropdown hangs under this rather than under the
|
|
164
164
|
# unused space.
|
|
165
165
|
# @return [Size]
|
|
166
166
|
def extent = Size.new(rect.width, 1)
|
|
167
167
|
|
|
168
|
-
# Toggles the dropdown on a left
|
|
169
|
-
# affordance is its whole row, as the well advertises
|
|
170
|
-
#
|
|
171
|
-
# @
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
super
|
|
175
|
-
return unless event.button == :left && extent_rect.contains?(event.point)
|
|
168
|
+
# Toggles the dropdown on a left press anywhere in {#extent} — a field's
|
|
169
|
+
# affordance is its whole row, as the well advertises.
|
|
170
|
+
# @param event [Mouse::DownEvent]
|
|
171
|
+
# @return [Boolean]
|
|
172
|
+
def handle_mouse_down?(event)
|
|
173
|
+
return false unless event.button == :left
|
|
176
174
|
|
|
177
175
|
@overlay.open? ? close_menu : open_menu
|
|
176
|
+
true
|
|
178
177
|
end
|
|
179
178
|
|
|
180
179
|
# @return [void]
|
data/lib/tuile/component/slot.rb
CHANGED
|
@@ -23,7 +23,7 @@ module Tuile
|
|
|
23
23
|
# (what {Window} does with an absent footer); never detach it.
|
|
24
24
|
#
|
|
25
25
|
# Transparent to input: not {Component#focusable?},
|
|
26
|
-
#
|
|
26
|
+
# the mouse routes straight through it, and a departing occupant's
|
|
27
27
|
# focus repair is handed to the container.
|
|
28
28
|
class Slot < Component
|
|
29
29
|
include Component::HasContent
|
|
@@ -40,8 +40,8 @@ module Tuile
|
|
|
40
40
|
# nothing to bubble from.
|
|
41
41
|
# @param child [Component] the just-detached occupant.
|
|
42
42
|
# @return [void]
|
|
43
|
-
def
|
|
44
|
-
parent&.
|
|
43
|
+
def handle_child_removed(child)
|
|
44
|
+
parent&.handle_child_removed(child)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
protected
|
|
@@ -26,11 +26,11 @@ module Tuile
|
|
|
26
26
|
# designing around:
|
|
27
27
|
#
|
|
28
28
|
# - A hidden pane is invisible to *everything*: the Tab cycle, focus
|
|
29
|
-
# cascades, repaint, the cursor, `
|
|
29
|
+
# cascades, repaint, the cursor, `walk_tree` walks. No gates anywhere.
|
|
30
30
|
# - Its state survives, because state is ivars — scroll position, caret,
|
|
31
31
|
# list cursor, text are all exactly as the user left them, and mutating a
|
|
32
32
|
# hidden pane is safe (`invalidate` while detached is a silent no-op).
|
|
33
|
-
# - {Component#
|
|
33
|
+
# - {Component#handle_detached} / {Component#handle_attached} fire on every switch,
|
|
34
34
|
# so a pane holding a mounted-lifetime resource — a {Component::ProgressBar}'s
|
|
35
35
|
# ticker — releases it while hidden and re-acquires it on return. A pane
|
|
36
36
|
# that must keep something alive while hidden can't; that something
|
|
@@ -40,7 +40,7 @@ module Tuile
|
|
|
40
40
|
# `children` is `[strip, pane]`, the strip pinned at index 0, so pre-order
|
|
41
41
|
# traversal gives the strip-then-pane Tab order for free. The swap follows
|
|
42
42
|
# the slot-swap recipe {Component#detach_child} documents — detach, rewire,
|
|
43
|
-
# `
|
|
43
|
+
# `handle_child_removed` last, so the focus repair sees the new occupant.
|
|
44
44
|
#
|
|
45
45
|
# Panes live in an identity-keyed `Tab => Component` map here rather than in
|
|
46
46
|
# a slot on {Tabs::Tab}: the strip's tab array stays the sole ordering
|
|
@@ -169,7 +169,7 @@ module Tuile
|
|
|
169
169
|
# Sends focus to the strip: a sheet is a container, and the strip is where
|
|
170
170
|
# a tab switch is driven from. The pane is a Tab press away.
|
|
171
171
|
# @return [void]
|
|
172
|
-
def
|
|
172
|
+
def handle_focus
|
|
173
173
|
super
|
|
174
174
|
screen.focused = @strip
|
|
175
175
|
end
|
|
@@ -179,7 +179,7 @@ module Tuile
|
|
|
179
179
|
# action was a tab switch.
|
|
180
180
|
# @param child [Component]
|
|
181
181
|
# @return [void]
|
|
182
|
-
def
|
|
182
|
+
def handle_child_removed(child)
|
|
183
183
|
super
|
|
184
184
|
screen.focused = @strip if attached? && screen.focused.equal?(self)
|
|
185
185
|
end
|
|
@@ -205,7 +205,7 @@ module Tuile
|
|
|
205
205
|
layout_pane
|
|
206
206
|
end
|
|
207
207
|
invalidate
|
|
208
|
-
|
|
208
|
+
handle_child_removed(old) unless old.nil?
|
|
209
209
|
end
|
|
210
210
|
|
|
211
211
|
# Drops entries whose tab is gone. {Tabs::Tab#remove} takes a tab off the
|
data/lib/tuile/component/tabs.rb
CHANGED
|
@@ -29,7 +29,7 @@ module Tuile
|
|
|
29
29
|
#
|
|
30
30
|
# {Tab} handles are minted by {#add_tab} and owned by the strip. There is no
|
|
31
31
|
# `items=`: a tab is identity plus its own state, so the set grows and
|
|
32
|
-
# shrinks one tab at a time. See book ch7 and `
|
|
32
|
+
# shrinks one tab at a time. See book ch7 and `design/decisions.md` `D_tabs`.
|
|
33
33
|
#
|
|
34
34
|
# == Sizing
|
|
35
35
|
# Assign a {#rect} (typically from the surrounding {Layout}). One wider than
|
|
@@ -275,7 +275,7 @@ module Tuile
|
|
|
275
275
|
#
|
|
276
276
|
# Both the focus highlight and the click hit test use it, so a click on the
|
|
277
277
|
# blank tail — or on a lower row, when the rect is taller than one —
|
|
278
|
-
# selects nothing. It still *focuses*: {
|
|
278
|
+
# selects nothing. It still *focuses*: {Mouse::Router}'s
|
|
279
279
|
# click-to-focus is ungated by geometry.
|
|
280
280
|
# @return [Size]
|
|
281
281
|
def extent
|
|
@@ -291,7 +291,7 @@ module Tuile
|
|
|
291
291
|
# bubbles to an ancestor; an empty strip handles nothing at all.
|
|
292
292
|
# @param key [String]
|
|
293
293
|
# @return [Boolean]
|
|
294
|
-
def handle_key(key)
|
|
294
|
+
def handle_key?(key)
|
|
295
295
|
case key
|
|
296
296
|
when Keys::LEFT_ARROW then select_previous
|
|
297
297
|
when Keys::RIGHT_ARROW then select_next
|
|
@@ -299,16 +299,16 @@ module Tuile
|
|
|
299
299
|
end
|
|
300
300
|
end
|
|
301
301
|
|
|
302
|
-
# Selects the tab under a left
|
|
303
|
-
#
|
|
304
|
-
# @param event [
|
|
305
|
-
# @return [
|
|
306
|
-
def
|
|
307
|
-
|
|
308
|
-
return unless event.button == :left
|
|
302
|
+
# Selects the tab under a left press; a press between tabs selects
|
|
303
|
+
# nothing and still claims the strip.
|
|
304
|
+
# @param event [Mouse::DownEvent]
|
|
305
|
+
# @return [Boolean]
|
|
306
|
+
def handle_mouse_down?(event)
|
|
307
|
+
return false unless event.button == :left
|
|
309
308
|
|
|
310
309
|
tab = tab_at(event.point)
|
|
311
310
|
self.selected = tab if tab
|
|
311
|
+
true
|
|
312
312
|
end
|
|
313
313
|
|
|
314
314
|
# @return [void]
|
|
@@ -339,7 +339,7 @@ module Tuile
|
|
|
339
339
|
# The rect's *width* is the only part of it the offset depends on, so this
|
|
340
340
|
# hook is the whole geometry story; {Component#rect=} invalidates for us.
|
|
341
341
|
# @return [void]
|
|
342
|
-
def
|
|
342
|
+
def handle_width_changed
|
|
343
343
|
super
|
|
344
344
|
adjust_left_column
|
|
345
345
|
end
|
|
@@ -31,7 +31,7 @@ module Tuile
|
|
|
31
31
|
# class PromptArea < Component::TextArea
|
|
32
32
|
# protected
|
|
33
33
|
#
|
|
34
|
-
# def handle_text_input_key(key)
|
|
34
|
+
# def handle_text_input_key?(key)
|
|
35
35
|
# return recall_previous if key == Keys::UP_ARROW && caret_row.zero?
|
|
36
36
|
# return recall_next if key == Keys::DOWN_ARROW && caret_row == row_count - 1
|
|
37
37
|
#
|
|
@@ -87,11 +87,10 @@ module Tuile
|
|
|
87
87
|
Point.new(rect.left + col.clamp(0, rect.width - 1), rect.top + row_in_viewport)
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
# @param event [
|
|
91
|
-
# @return [
|
|
92
|
-
def
|
|
93
|
-
|
|
94
|
-
return unless event.button == :left && rect.contains?(event.point)
|
|
90
|
+
# @param event [Mouse::DownEvent]
|
|
91
|
+
# @return [Boolean]
|
|
92
|
+
def handle_mouse_down?(event)
|
|
93
|
+
return false unless event.button == :left
|
|
95
94
|
|
|
96
95
|
target_row = (event.y - rect.top) + @scroll_top_row
|
|
97
96
|
self.caret = if target_row >= wrap.row_count
|
|
@@ -99,6 +98,7 @@ module Tuile
|
|
|
99
98
|
else
|
|
100
99
|
wrap.index_at(target_row, event.x - rect.left)
|
|
101
100
|
end
|
|
101
|
+
true
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
# @return [void]
|
|
@@ -114,13 +114,15 @@ module Tuile
|
|
|
114
114
|
protected
|
|
115
115
|
|
|
116
116
|
# @return [void]
|
|
117
|
-
def
|
|
117
|
+
def handle_text_mutated
|
|
118
|
+
super
|
|
118
119
|
@wrap = nil
|
|
119
120
|
adjust_scroll_top_row
|
|
120
121
|
end
|
|
121
122
|
|
|
122
123
|
# @return [void]
|
|
123
|
-
def
|
|
124
|
+
def handle_caret_mutated
|
|
125
|
+
super
|
|
124
126
|
adjust_scroll_top_row
|
|
125
127
|
end
|
|
126
128
|
|
|
@@ -128,7 +130,7 @@ module Tuile
|
|
|
128
130
|
# the `\n` line — so CTRL+U kills back to exactly where HOME would go.
|
|
129
131
|
# @param key [String]
|
|
130
132
|
# @return [Boolean]
|
|
131
|
-
def handle_text_input_key(key)
|
|
133
|
+
def handle_text_input_key?(key)
|
|
132
134
|
case key
|
|
133
135
|
when Keys::UP_ARROW then move_caret_vertical(-1)
|
|
134
136
|
when Keys::DOWN_ARROW then move_caret_vertical(1)
|
|
@@ -147,7 +149,7 @@ module Tuile
|
|
|
147
149
|
end
|
|
148
150
|
|
|
149
151
|
# @return [void]
|
|
150
|
-
def
|
|
152
|
+
def handle_width_changed
|
|
151
153
|
super
|
|
152
154
|
@wrap = nil
|
|
153
155
|
adjust_scroll_top_row
|