tuile 0.15.0 → 0.17.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 +229 -80
- data/README.md +49 -24
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +17 -16
- data/book/05-focus.md +106 -34
- data/book/06-theming.md +108 -38
- data/book/07-components.md +249 -46
- data/book/08-testing.md +134 -32
- data/book/10-locale.md +3 -3
- data/book/README.md +11 -10
- data/examples/file_commander.rb +52 -32
- data/examples/hello_world.rb +18 -5
- data/examples/sampler.rb +576 -146
- data/lib/tuile/buffer.rb +12 -1
- data/lib/tuile/canvas/backend.rb +46 -0
- data/lib/tuile/canvas.rb +212 -0
- data/lib/tuile/color.rb +38 -9
- data/lib/tuile/component/abstract_string_field.rb +96 -97
- data/lib/tuile/component/abstract_wrapping_field.rb +99 -58
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +27 -19
- data/lib/tuile/component/checkbox.rb +21 -19
- data/lib/tuile/component/checkbox_group.rb +17 -18
- data/lib/tuile/component/combo_box.rb +69 -64
- data/lib/tuile/component/confirm_window.rb +34 -27
- data/lib/tuile/component/date_field.rb +50 -18
- data/lib/tuile/component/date_time_field.rb +319 -0
- data/lib/tuile/component/fill.rb +93 -0
- data/lib/tuile/component/float_field.rb +7 -6
- data/lib/tuile/component/form_item.rb +250 -0
- data/lib/tuile/component/form_layout.rb +206 -0
- data/lib/tuile/component/has_bad_input.rb +99 -28
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +8 -15
- data/lib/tuile/component/has_placeholder.rb +1 -1
- data/lib/tuile/component/has_validation.rb +40 -14
- data/lib/tuile/component/has_value.rb +71 -17
- data/lib/tuile/component/integer_field.rb +7 -6
- data/lib/tuile/component/label.rb +8 -15
- data/lib/tuile/component/layout/absolute.rb +86 -0
- data/lib/tuile/component/layout/box.rb +38 -60
- data/lib/tuile/component/layout.rb +127 -13
- data/lib/tuile/component/list.rb +233 -120
- data/lib/tuile/component/list_dropdown.rb +151 -91
- data/lib/tuile/component/menu_bar/cascade.rb +102 -32
- data/lib/tuile/component/menu_bar.rb +102 -82
- data/lib/tuile/component/notification.rb +76 -49
- data/lib/tuile/component/overlay.rb +217 -58
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +41 -17
- data/lib/tuile/component/popup.rb +15 -26
- data/lib/tuile/component/progress_bar.rb +17 -11
- data/lib/tuile/component/radio_group.rb +16 -17
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +26 -43
- data/lib/tuile/component/slot.rb +4 -5
- data/lib/tuile/component/tab_sheet.rb +27 -34
- data/lib/tuile/component/tabs.rb +49 -34
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +32 -28
- data/lib/tuile/component/text_field.rb +68 -50
- data/lib/tuile/component/text_view.rb +157 -89
- data/lib/tuile/component/time_field.rb +51 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +653 -323
- data/lib/tuile/component_background.rb +177 -0
- data/lib/tuile/component_util.rb +43 -0
- data/lib/tuile/event.rb +29 -0
- data/lib/tuile/event_queue.rb +18 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +120 -7
- data/lib/tuile/keys.rb +15 -6
- data/lib/tuile/layout_pass.rb +180 -0
- data/lib/tuile/listeners.rb +219 -0
- data/lib/tuile/mouse/router.rb +233 -0
- data/lib/tuile/mouse.rb +244 -0
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +510 -138
- data/lib/tuile/screen_pane.rb +185 -67
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +144 -14
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +316 -42
- data/lib/tuile/theme.rb +192 -53
- data/lib/tuile/theme_def.rb +4 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +53 -0
- data/sig/tuile.rbs +6084 -1507
- metadata +19 -17
- 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
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
data/examples/sampler.rb
CHANGED
|
@@ -12,28 +12,40 @@
|
|
|
12
12
|
# Keys: ←→ along the strip, Enter/↓ to open a menu, or a letter for the
|
|
13
13
|
# underlined mnemonic. Tab / Shift+Tab move focus between the strip, the jump
|
|
14
14
|
# box and the demo's widgets. q or ESC quits.
|
|
15
|
+
#
|
|
16
|
+
# Runs at `capture_mouse: :hover` — the Mouse demo needs it, and the level is
|
|
17
|
+
# one app-wide choice made at the event loop.
|
|
15
18
|
|
|
16
19
|
require "rainbow"
|
|
17
20
|
require "tuile"
|
|
18
21
|
|
|
19
22
|
module SamplerExample
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
23
|
+
# `color` with `by` added to each RGB channel, clamped. Tuile ships no color
|
|
24
|
+
# arithmetic; this is the app's.
|
|
25
|
+
STEP = ->(color, by) { Tuile::Color.rgb(*color.rgb.map { (_1 + by).clamp(0, 255) }) }
|
|
26
|
+
|
|
27
|
+
# `hint` is the app's token, not Tuile's: the framework carries accents for
|
|
28
|
+
# the chrome *it* paints, and the status row below is the sampler's own
|
|
29
|
+
# (`D_status_bar` — Tuile draws none). Paired in a ThemeDef so it survives an
|
|
30
|
+
# OS appearance flip, where a bare `theme=` would be replaced. Both greys
|
|
31
|
+
# quantize to :bright_black on a 16-color terminal, so the description stays
|
|
32
|
+
# dimmer than the key beside it even there.
|
|
33
|
+
#
|
|
34
|
+
# `terminal_tint` is derived: the terminal's *own* background stepped 10 per
|
|
35
|
+
# channel away from the ink — the borderless-pane tint, which only sits right
|
|
36
|
+
# when it comes from the real background. The screen re-derives it whenever
|
|
37
|
+
# the background changes; the fixed grey is for a terminal that reported none,
|
|
38
|
+
# which is the branch most users will actually see.
|
|
39
|
+
APP_THEME = Tuile::ThemeDef.new(
|
|
40
|
+
dark: Tuile::Theme::DARK.with(custom: {
|
|
41
|
+
hint: Tuile::Color::GREY54,
|
|
42
|
+
terminal_tint: ->(bg) { bg ? STEP.call(bg, 10) : Tuile::Color::GREY15 }
|
|
43
|
+
}),
|
|
44
|
+
light: Tuile::Theme::LIGHT.with(custom: {
|
|
45
|
+
hint: Tuile::Color::GREY62,
|
|
46
|
+
terminal_tint: ->(bg) { bg ? STEP.call(bg, -10) : Tuile::Color::GREY89 }
|
|
47
|
+
})
|
|
48
|
+
)
|
|
37
49
|
|
|
38
50
|
# A {Tuile::Component::Layout::Vertical} that runs {#on_tick} on every frame
|
|
39
51
|
# while it is on screen. The ticker is started on attach and cancelled on
|
|
@@ -46,21 +58,26 @@ module SamplerExample
|
|
|
46
58
|
@fps = fps
|
|
47
59
|
end
|
|
48
60
|
|
|
49
|
-
#
|
|
50
|
-
|
|
61
|
+
# What `on_tick` fires.
|
|
62
|
+
TickEvent = Data.define(:source) { include Tuile::Event }
|
|
63
|
+
|
|
64
|
+
# @!method on_tick
|
|
65
|
+
# Fired once per frame while this box is attached.
|
|
66
|
+
# @return [Tuile::Listeners]
|
|
67
|
+
listener :on_tick
|
|
51
68
|
|
|
52
|
-
def
|
|
53
|
-
@ticker = screen.event_queue.tick_fps(@fps) {
|
|
69
|
+
def handle_attached
|
|
70
|
+
@ticker = screen.event_queue.tick_fps(@fps) { on_tick.fire(TickEvent.new(source: self)) }
|
|
54
71
|
end
|
|
55
72
|
|
|
56
|
-
def
|
|
73
|
+
def handle_detached
|
|
57
74
|
@ticker&.cancel
|
|
58
75
|
@ticker = nil
|
|
59
76
|
end
|
|
60
77
|
end
|
|
61
78
|
|
|
62
79
|
# A {Tuile::Component::Layout::Vertical} that claims one key for itself. An
|
|
63
|
-
# ancestor's `handle_key
|
|
80
|
+
# ancestor's `handle_key?` is where a scope-wide binding belongs (key-dispatch
|
|
64
81
|
# rung 3); the Select demo uses one to show the letter still arriving while a
|
|
65
82
|
# Select has focus — the capability a ComboBox, which eats every printable
|
|
66
83
|
# unconditionally, cannot offer.
|
|
@@ -70,13 +87,18 @@ module SamplerExample
|
|
|
70
87
|
@shortcut = shortcut
|
|
71
88
|
end
|
|
72
89
|
|
|
73
|
-
#
|
|
74
|
-
|
|
90
|
+
# What `on_shortcut` fires.
|
|
91
|
+
ShortcutEvent = Data.define(:source) { include Tuile::Event }
|
|
75
92
|
|
|
76
|
-
|
|
93
|
+
# @!method on_shortcut
|
|
94
|
+
# Fired when the claimed key arrives.
|
|
95
|
+
# @return [Tuile::Listeners]
|
|
96
|
+
listener :on_shortcut
|
|
97
|
+
|
|
98
|
+
def handle_key?(key)
|
|
77
99
|
return false unless key == @shortcut
|
|
78
100
|
|
|
79
|
-
|
|
101
|
+
on_shortcut.fire(ShortcutEvent.new(source: self))
|
|
80
102
|
true
|
|
81
103
|
end
|
|
82
104
|
end
|
|
@@ -85,26 +107,38 @@ module SamplerExample
|
|
|
85
107
|
# the chat-prompt shape, and the one that made a multi-line paste fire the
|
|
86
108
|
# submit once per pasted line before Tuile drove bracketed paste. It handles
|
|
87
109
|
# no paste of its own: pasted text never arrives as ENTER, so the inherited
|
|
88
|
-
# insert-at-caret is already the wanted behavior, and `
|
|
89
|
-
# feeds the demo's counter.
|
|
110
|
+
# insert-at-caret is already the wanted behavior, and `on_paste_received` here
|
|
111
|
+
# only feeds the demo's counter.
|
|
90
112
|
class PromptTextArea < Tuile::Component::TextArea
|
|
91
|
-
#
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
113
|
+
# What `on_submit` fires.
|
|
114
|
+
SubmitEvent = Data.define(:source, :text) { include Tuile::Event }
|
|
115
|
+
|
|
116
|
+
# What `on_paste_received` fires.
|
|
117
|
+
PasteEvent = Data.define(:source, :text) { include Tuile::Event }
|
|
118
|
+
|
|
119
|
+
# @!method on_submit
|
|
120
|
+
# Fired with the submitted text; the area then clears.
|
|
121
|
+
# @return [Tuile::Listeners]
|
|
122
|
+
listener :on_submit
|
|
123
|
+
|
|
124
|
+
# @!method on_paste_received
|
|
125
|
+
# Fired with the pasted text, before it is inserted. Not `on_paste`:
|
|
126
|
+
# `handle_paste` is the override point, and a slot may not take its name.
|
|
127
|
+
# @return [Tuile::Listeners]
|
|
128
|
+
listener :on_paste_received
|
|
95
129
|
|
|
96
130
|
def handle_paste(text)
|
|
97
|
-
|
|
131
|
+
on_paste_received.fire(PasteEvent.new(source: self, text: text))
|
|
98
132
|
super
|
|
99
133
|
end
|
|
100
134
|
|
|
101
135
|
protected
|
|
102
136
|
|
|
103
|
-
def handle_text_input_key(key)
|
|
137
|
+
def handle_text_input_key?(key)
|
|
104
138
|
return super unless key == Tuile::Keys::ENTER
|
|
105
139
|
|
|
106
|
-
|
|
107
|
-
self.
|
|
140
|
+
on_submit.fire(SubmitEvent.new(source: self, text: text))
|
|
141
|
+
self.value = ""
|
|
108
142
|
true
|
|
109
143
|
end
|
|
110
144
|
end
|
|
@@ -115,8 +149,8 @@ module SamplerExample
|
|
|
115
149
|
# a newline with no menu up — stays the TextArea's own.
|
|
116
150
|
#
|
|
117
151
|
# Subclassing *is* the seam for this. A component receives keys through
|
|
118
|
-
# `handle_key
|
|
119
|
-
# `handle_text_input_key
|
|
152
|
+
# `handle_key?`, so one that wants different keys overrides it (here its
|
|
153
|
+
# `handle_text_input_key?` hook) and calls `super` for the rest, which composes
|
|
120
154
|
# and stacks. None of this is baked into TextArea.
|
|
121
155
|
class SlashCommandTextArea < Tuile::Component::TextArea
|
|
122
156
|
# @param overlay [Tuile::Component::ListDropdown] the menu to steer.
|
|
@@ -127,7 +161,7 @@ module SamplerExample
|
|
|
127
161
|
|
|
128
162
|
protected
|
|
129
163
|
|
|
130
|
-
def handle_text_input_key(key)
|
|
164
|
+
def handle_text_input_key?(key)
|
|
131
165
|
return super unless @overlay.open?
|
|
132
166
|
return true if @overlay.move(key) # Up/Down/PgUp/PgDn/^U/^D
|
|
133
167
|
|
|
@@ -140,6 +174,304 @@ module SamplerExample
|
|
|
140
174
|
end
|
|
141
175
|
end
|
|
142
176
|
|
|
177
|
+
# A drawing surface answering every mouse handler, in two inks that both
|
|
178
|
+
# persist: a left-drag strokes `X`, a plain hover leaves a `.` trail behind
|
|
179
|
+
# the pointer, and a right-drag lifts marks again.
|
|
180
|
+
#
|
|
181
|
+
# X a diagonal drag, then one hover sweep across it
|
|
182
|
+
# X
|
|
183
|
+
# X
|
|
184
|
+
# X
|
|
185
|
+
# .....X........
|
|
186
|
+
# X
|
|
187
|
+
#
|
|
188
|
+
# A trail never overwrites a stroke, so the picture stays a readout of which
|
|
189
|
+
# channel drew which cell — an eraser-on-hover would instead make the drawing
|
|
190
|
+
# unviewable with the pointer over it.
|
|
191
|
+
#
|
|
192
|
+
# canvas = Canvas.new
|
|
193
|
+
# canvas.on_report { |e| log.log(e.line) }
|
|
194
|
+
# canvas.on_move { |e| label.text = "#{e.mouse.x},#{e.mouse.y} (#{e.moves})" }
|
|
195
|
+
#
|
|
196
|
+
# Two slots, because the traffic is two: {#on_report} carries the discrete
|
|
197
|
+
# events to a log, {#on_move} the ~84-a-second moves to one replaced row.
|
|
198
|
+
# Feeding both to a log would drown enter/exit inside 12 ms.
|
|
199
|
+
#
|
|
200
|
+
# Every gesture owes a key (`D_mouse`): the arrows move the caret, space or
|
|
201
|
+
# `x` strokes it, Delete lifts it, `c` clears. The trail, the enter/exit lines
|
|
202
|
+
# and {#on_move} need `capture_mouse: :hover` and arrive as nothing below it;
|
|
203
|
+
# the drag works from `:drag`, a single-cell stroke from `:clicks`.
|
|
204
|
+
#
|
|
205
|
+
# == Implementation details
|
|
206
|
+
# Both glyphs are ASCII on purpose: `·` is East-Asian Ambiguous, so a
|
|
207
|
+
# two-column trail cell would push every painted row past `rect.width`
|
|
208
|
+
# (`D_ambiguous_width`).
|
|
209
|
+
#
|
|
210
|
+
# It paints every cell of its rect itself, one {Tuile::Canvas#set_text}
|
|
211
|
+
# per run of like cells, and so skips `super` in {#repaint} — whose
|
|
212
|
+
# auto-clear blanks the whole rect, which would re-emit every cell this widget
|
|
213
|
+
# is about to paint over anyway. Repainting whole on every move still costs
|
|
214
|
+
# one cell on the wire, since a cell dirties only on a real change.
|
|
215
|
+
#
|
|
216
|
+
# Marks are keyed by rect-local {Tuile::Point}: ink beyond a narrowed rect
|
|
217
|
+
# stops painting and comes back when the rect grows again.
|
|
218
|
+
class Canvas < Tuile::Component
|
|
219
|
+
# @return [String] the drag/keyboard ink.
|
|
220
|
+
STROKE = "X"
|
|
221
|
+
# @return [String] the hover ink.
|
|
222
|
+
TRAIL = "."
|
|
223
|
+
|
|
224
|
+
# Which ink each button drags — `nil` erases. The middle button is absent:
|
|
225
|
+
# a press this component declines bubbles on to the window around it.
|
|
226
|
+
# @return [Hash{Symbol => String, nil}]
|
|
227
|
+
DRAG_INK = { left: STROKE, right: nil }.freeze
|
|
228
|
+
|
|
229
|
+
# What `on_report` fires.
|
|
230
|
+
ReportEvent = Data.define(:source, :line) { include Tuile::Event }
|
|
231
|
+
|
|
232
|
+
# What `on_move` fires.
|
|
233
|
+
MoveEvent = Data.define(:source, :mouse, :moves) { include Tuile::Event }
|
|
234
|
+
|
|
235
|
+
# @!method on_report
|
|
236
|
+
# Fired with one line of commentary per discrete event.
|
|
237
|
+
# @return [Tuile::Listeners]
|
|
238
|
+
listener :on_report
|
|
239
|
+
|
|
240
|
+
# @!method on_move
|
|
241
|
+
# Fired on every move and every drag, with the {Tuile::Mouse::Event} as
|
|
242
|
+
# `mouse` — not `event`, which would read as the event's event — and the
|
|
243
|
+
# number of moves so far.
|
|
244
|
+
# @return [Tuile::Listeners]
|
|
245
|
+
listener :on_move
|
|
246
|
+
|
|
247
|
+
# @return [Tuile::Point] the keyboard caret, in rect-local coordinates.
|
|
248
|
+
attr_reader :caret
|
|
249
|
+
|
|
250
|
+
# The marks, keyed by rect-local {Tuile::Point} — read-only in practice:
|
|
251
|
+
# writing one behind {#paint}'s back skips the `invalidate`.
|
|
252
|
+
# @return [Hash{Tuile::Point => String}]
|
|
253
|
+
attr_reader :ink
|
|
254
|
+
|
|
255
|
+
def initialize
|
|
256
|
+
super
|
|
257
|
+
@ink = {}
|
|
258
|
+
@caret = Tuile::Point.new(0, 0)
|
|
259
|
+
@moves = 0
|
|
260
|
+
@drag_ink = nil
|
|
261
|
+
@drag_outside = false
|
|
262
|
+
bg.default_color = Tuile::ComponentBackground::INPUT_WELL
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def focusable? = true
|
|
266
|
+
|
|
267
|
+
def tab_stop? = true
|
|
268
|
+
|
|
269
|
+
# The caret is already rect-local, and so is what a cursor position means —
|
|
270
|
+
# {Tuile::Screen#cursor_position} puts it on screen.
|
|
271
|
+
# @return [Tuile::Point, nil]
|
|
272
|
+
def cursor_position
|
|
273
|
+
return nil if rect.empty?
|
|
274
|
+
|
|
275
|
+
@caret
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Clamps the caret into the new rect, so a shrink cannot strand it — and
|
|
279
|
+
# with it the hardware cursor — outside what this widget paints.
|
|
280
|
+
# @param old_rect [Tuile::Rect]
|
|
281
|
+
def handle_rect_changed(old_rect)
|
|
282
|
+
super
|
|
283
|
+
@caret = Tuile::Point.new(@caret.x.clamp(0, [rect.width - 1, 0].max),
|
|
284
|
+
@caret.y.clamp(0, [rect.height - 1, 0].max))
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# @return [void]
|
|
288
|
+
def repaint(canvas)
|
|
289
|
+
return if rect.empty?
|
|
290
|
+
|
|
291
|
+
# The clear is what a self-painter opts out of; the cascade never is
|
|
292
|
+
# (`D_repaint_cascade`), leaf or not.
|
|
293
|
+
invalidate_children
|
|
294
|
+
trail_color = screen.theme[:hint]
|
|
295
|
+
rect.height.times { |row| draw_row(canvas, row, trail_color) }
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# @param event [Tuile::Mouse::DownEvent]
|
|
299
|
+
# @return [Boolean]
|
|
300
|
+
def handle_mouse_down?(event)
|
|
301
|
+
unless DRAG_INK.key?(event.button)
|
|
302
|
+
report("down #{event.button} at #{event.x},#{event.y} — declined, bubbles to the window")
|
|
303
|
+
return false
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
report("down #{event.button} at #{event.x},#{event.y} — claimed, grab held")
|
|
307
|
+
@drag_ink = DRAG_INK.fetch(event.button)
|
|
308
|
+
@drag_outside = false
|
|
309
|
+
mark(cell_at(event))
|
|
310
|
+
true
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# @param event [Tuile::Mouse::DragEvent]
|
|
314
|
+
# @return [void]
|
|
315
|
+
def handle_mouse_drag(event)
|
|
316
|
+
super
|
|
317
|
+
cell = cell_at(event)
|
|
318
|
+
if @drag_outside != cell.nil?
|
|
319
|
+
@drag_outside = cell.nil?
|
|
320
|
+
report(@drag_outside ? "drag left the canvas — the grab still delivers" : "drag back inside")
|
|
321
|
+
end
|
|
322
|
+
mark(cell)
|
|
323
|
+
report_move(event)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# @param event [Tuile::Mouse::UpEvent]
|
|
327
|
+
# @return [void]
|
|
328
|
+
def handle_mouse_up(event)
|
|
329
|
+
super
|
|
330
|
+
report("up at #{event.x},#{event.y} — grab released")
|
|
331
|
+
@drag_ink = nil
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# @param event [Tuile::Mouse::MoveEvent]
|
|
335
|
+
# @return [Boolean]
|
|
336
|
+
def handle_mouse_move?(event)
|
|
337
|
+
cell = cell_at(event)
|
|
338
|
+
paint(cell, TRAIL) if cell && @ink[cell].nil?
|
|
339
|
+
report_move(event)
|
|
340
|
+
true
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# @return [void]
|
|
344
|
+
def handle_mouse_enter
|
|
345
|
+
super
|
|
346
|
+
report("enter")
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# @return [void]
|
|
350
|
+
def handle_mouse_exit
|
|
351
|
+
super
|
|
352
|
+
report("exit")
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Declines the notch — nothing here scrolls — which is what lets it bubble
|
|
356
|
+
# on to an ancestor that does.
|
|
357
|
+
# @param event [Tuile::Mouse::ScrollEvent]
|
|
358
|
+
# @return [Boolean] always false.
|
|
359
|
+
def handle_mouse_scroll?(event)
|
|
360
|
+
report("wheel #{event.direction} — declined, bubbles on")
|
|
361
|
+
false
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# @param key [String]
|
|
365
|
+
# @return [Boolean]
|
|
366
|
+
def handle_key?(key)
|
|
367
|
+
case key
|
|
368
|
+
when Tuile::Keys::UP_ARROW then move_caret(0, -1)
|
|
369
|
+
when Tuile::Keys::DOWN_ARROW then move_caret(0, 1)
|
|
370
|
+
when Tuile::Keys::LEFT_ARROW then move_caret(-1, 0)
|
|
371
|
+
when Tuile::Keys::RIGHT_ARROW then move_caret(1, 0)
|
|
372
|
+
when " ", "x" then stroke_caret(STROKE)
|
|
373
|
+
when Tuile::Keys::DELETE, *Tuile::Keys::BACKSPACES then stroke_caret(nil)
|
|
374
|
+
when "c" then clear_marks
|
|
375
|
+
else return super
|
|
376
|
+
end
|
|
377
|
+
true
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
private
|
|
381
|
+
|
|
382
|
+
# One row, as runs of like cells: every cell is painted exactly once, so
|
|
383
|
+
# none is blanked and then painted over (`D_progress_bar`). The canvas
|
|
384
|
+
# paints in the same rect-local coordinates the marks are keyed by, so a
|
|
385
|
+
# run goes straight to `set_text` with no offset.
|
|
386
|
+
# @param row [Integer] rect-local row.
|
|
387
|
+
# @param trail_color [Tuile::Color]
|
|
388
|
+
# @return [void]
|
|
389
|
+
def draw_row(canvas, row, trail_color)
|
|
390
|
+
column = 0
|
|
391
|
+
while column < rect.width
|
|
392
|
+
glyph = @ink[Tuile::Point.new(column, row)]
|
|
393
|
+
run = 1
|
|
394
|
+
run += 1 while column + run < rect.width && @ink[Tuile::Point.new(column + run, row)] == glyph
|
|
395
|
+
text = (glyph || " ") * run
|
|
396
|
+
styled = glyph == TRAIL ? Tuile::StyledString.styled(text, fg: trail_color) : Tuile::StyledString.plain(text)
|
|
397
|
+
canvas.set_text(column, row, styled)
|
|
398
|
+
column += run
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# The event's cell, or nil when it lands outside — which a grabbed
|
|
403
|
+
# {Tuile::Mouse::DragEvent} routinely does. A mouse event already arrives in
|
|
404
|
+
# this component's own coordinates, so there is nothing to subtract; the
|
|
405
|
+
# bounds test is the whole job.
|
|
406
|
+
# @param event [Tuile::Mouse::Event]
|
|
407
|
+
# @return [Tuile::Point, nil]
|
|
408
|
+
def cell_at(event)
|
|
409
|
+
return nil unless (0...rect.width).cover?(event.x) && (0...rect.height).cover?(event.y)
|
|
410
|
+
|
|
411
|
+
Tuile::Point.new(event.x, event.y)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Lays the dragged ink at `cell` and takes the caret with it, so a stroke
|
|
415
|
+
# can be continued from the keyboard. Outside the rect (`nil`) it is a
|
|
416
|
+
# no-op, which is a grabbed drag's normal case.
|
|
417
|
+
# @param cell [Tuile::Point, nil] rect-local.
|
|
418
|
+
# @return [void]
|
|
419
|
+
def mark(cell)
|
|
420
|
+
return if cell.nil?
|
|
421
|
+
|
|
422
|
+
paint(cell, @drag_ink)
|
|
423
|
+
@caret = cell
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# @param cell [Tuile::Point] rect-local.
|
|
427
|
+
# @param ink [String, nil] nil lifts the mark.
|
|
428
|
+
# @return [void]
|
|
429
|
+
def paint(cell, ink)
|
|
430
|
+
return if @ink[cell] == ink
|
|
431
|
+
|
|
432
|
+
ink.nil? ? @ink.delete(cell) : @ink.store(cell, ink)
|
|
433
|
+
invalidate
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# @param ink [String, nil]
|
|
437
|
+
# @return [void]
|
|
438
|
+
def stroke_caret(ink)
|
|
439
|
+
paint(@caret, ink)
|
|
440
|
+
report("#{ink || "lift"} at #{@caret.x},#{@caret.y} — from the keyboard")
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# @param columns [Integer]
|
|
444
|
+
# @param rows [Integer]
|
|
445
|
+
# @return [void]
|
|
446
|
+
def move_caret(columns, rows)
|
|
447
|
+
return if rect.empty?
|
|
448
|
+
|
|
449
|
+
@caret = Tuile::Point.new((@caret.x + columns).clamp(0, rect.width - 1),
|
|
450
|
+
(@caret.y + rows).clamp(0, rect.height - 1))
|
|
451
|
+
invalidate # the hardware cursor is placed from #cursor_position at flush
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# @return [void]
|
|
455
|
+
def clear_marks
|
|
456
|
+
return if @ink.empty?
|
|
457
|
+
|
|
458
|
+
@ink.clear
|
|
459
|
+
report("cleared")
|
|
460
|
+
invalidate
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# @param line [String]
|
|
464
|
+
# @return [void]
|
|
465
|
+
def report(line) = on_report.fire(ReportEvent.new(source: self, line: line))
|
|
466
|
+
|
|
467
|
+
# @param event [Tuile::Mouse::Event]
|
|
468
|
+
# @return [void]
|
|
469
|
+
def report_move(event)
|
|
470
|
+
@moves += 1
|
|
471
|
+
on_move.fire(MoveEvent.new(source: self, mouse: event, moves: @moves))
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
143
475
|
# Top-level sampler component: a shell row across the top — a
|
|
144
476
|
# {Tuile::Component::MenuBar} of the demos, grouped, and a
|
|
145
477
|
# {Tuile::Component::ComboBox} jump box at its right end — over one demo
|
|
@@ -166,13 +498,14 @@ module SamplerExample
|
|
|
166
498
|
|
|
167
499
|
# The bottom row. Tuile draws no status bar and reserves no row
|
|
168
500
|
# (`D_status_bar`) — this one is the sampler's own, kept current by
|
|
169
|
-
# {Tuile::Screen#on_focus_changed
|
|
501
|
+
# {Tuile::Screen#on_focus_changed}. Naming the focused component makes Tab
|
|
170
502
|
# traversal visible as you walk a pane, which no per-pane label shows.
|
|
171
503
|
# @return [void]
|
|
172
504
|
def refresh_status
|
|
173
505
|
focused = screen.focused
|
|
174
506
|
name = focused ? focused.class.name.sub("Tuile::Component::", "") : "(none)"
|
|
175
|
-
|
|
507
|
+
t = screen.theme
|
|
508
|
+
@status.text = "q #{t.fg(:hint, "quit")} ⇥ #{t.fg(:hint, name)}"
|
|
176
509
|
end
|
|
177
510
|
|
|
178
511
|
# Chrome for a demo pane: a blank row top and bottom, two columns either
|
|
@@ -208,9 +541,11 @@ module SamplerExample
|
|
|
208
541
|
# as a live index of the catalogue and any drift between the two is visible.
|
|
209
542
|
#
|
|
210
543
|
# Mnemonics are *hand-picked*: {Tuile::Component::MenuBar#add_item} raises on
|
|
211
|
-
# a duplicate among siblings, and
|
|
544
|
+
# a duplicate among siblings, and seven leaves therefore answer to a letter
|
|
212
545
|
# other than their initial (Past`e`, Checkbox`G`roup, C`o`mboBox,
|
|
213
|
-
# Pic`k`erWindow, S`l`ash menu) — the
|
|
546
|
+
# Pic`k`erWindow, S`l`ash menu, DateTi`m`eField, F`o`rmLayout) — the
|
|
547
|
+
# underline shows which.
|
|
548
|
+
# No item may use
|
|
214
549
|
# `q`: quit is the unhandled-key fallback, so a `q` on the live level would
|
|
215
550
|
# swallow it while the bar has focus.
|
|
216
551
|
MENUS = [
|
|
@@ -233,6 +568,7 @@ module SamplerExample
|
|
|
233
568
|
Entry.new("BigDecimalField", :build_big_decimal_field, "b"),
|
|
234
569
|
Entry.new("DateField", :build_date_field, "d"),
|
|
235
570
|
Entry.new("TimeField", :build_time_field, "t"),
|
|
571
|
+
Entry.new("DateTimeField", :build_date_time_field, "m"),
|
|
236
572
|
Entry.new("Bad input", :build_bad_input, "a"),
|
|
237
573
|
Entry.new("Validation", :build_validation, "v")
|
|
238
574
|
]),
|
|
@@ -246,7 +582,7 @@ module SamplerExample
|
|
|
246
582
|
])
|
|
247
583
|
]),
|
|
248
584
|
# One entry, so it is the item and not a menu — a top-level leaf on the
|
|
249
|
-
# strip is a button
|
|
585
|
+
# strip is a button rather than a drop-down.
|
|
250
586
|
Entry.new("Button", :build_buttons, "b"),
|
|
251
587
|
Menu.new("Overlay", "o", [
|
|
252
588
|
Entry.new("Popup", :build_popup_launcher, "p"),
|
|
@@ -257,6 +593,8 @@ module SamplerExample
|
|
|
257
593
|
Entry.new("LogWindow", :build_log_window, "l")
|
|
258
594
|
]),
|
|
259
595
|
Menu.new("Shell", "h", [
|
|
596
|
+
Entry.new("FormLayout", :build_form_layout, "o"),
|
|
597
|
+
Entry.new("Scroller", :build_scroller, "s"),
|
|
260
598
|
Entry.new("TabSheet", :build_tab_sheet, "t"),
|
|
261
599
|
Entry.new("MenuBar", :build_menu_bar, "m"),
|
|
262
600
|
Entry.new("Narrow strips", :build_narrow_strips, "n"),
|
|
@@ -264,7 +602,10 @@ module SamplerExample
|
|
|
264
602
|
Entry.new("Background", :build_background, "b"),
|
|
265
603
|
Entry.new("Visibility", :build_visibility, "v"),
|
|
266
604
|
Entry.new("Focus & Tab", :build_focus_demo, "f")
|
|
267
|
-
])
|
|
605
|
+
]),
|
|
606
|
+
# The one demo needing a tracking level above the default, which is why
|
|
607
|
+
# the runner asks for `capture_mouse: :hover` for the whole app.
|
|
608
|
+
Entry.new("Mouse", :build_mouse_demo, "m")
|
|
268
609
|
].freeze
|
|
269
610
|
|
|
270
611
|
# Every {Entry} in strip order — what the jump box offers.
|
|
@@ -308,7 +649,7 @@ module SamplerExample
|
|
|
308
649
|
def build_jump_box
|
|
309
650
|
combo = Tuile::Component::ComboBox.new(items: ENTRIES)
|
|
310
651
|
combo.item_label = :caption.to_proc
|
|
311
|
-
combo.on_value_change
|
|
652
|
+
combo.on_value_change { |e| load_entry(e.value) if e.value }
|
|
312
653
|
combo
|
|
313
654
|
end
|
|
314
655
|
|
|
@@ -363,7 +704,7 @@ module SamplerExample
|
|
|
363
704
|
"Home/End jump to row start/end; Up/Down at the first/last row jumps to text start/end.\n" \
|
|
364
705
|
"Overflowing rows scroll vertically to keep the caret visible."
|
|
365
706
|
area = Tuile::Component::TextArea.new
|
|
366
|
-
area.
|
|
707
|
+
area.value = "The quick brown fox jumps over the lazy dog. " \
|
|
367
708
|
"Edit me — the text wraps to the area's width and scrolls vertically " \
|
|
368
709
|
"once the cursor leaves the visible rows."
|
|
369
710
|
form do |f|
|
|
@@ -382,7 +723,7 @@ module SamplerExample
|
|
|
382
723
|
items = %w[Ruby Python JavaScript TypeScript Rust Go Elixir Crystal Haskell Kotlin Swift Zig]
|
|
383
724
|
combo = Tuile::Component::ComboBox.new(items: items)
|
|
384
725
|
status = Tuile::Component::Label.new.tap { _1.text = "(nothing selected)" }
|
|
385
|
-
combo.on_value_change
|
|
726
|
+
combo.on_value_change { |e| status.text = "Selected: #{e.value}" }
|
|
386
727
|
form do |f|
|
|
387
728
|
f.add(prompt, Fixed[3])
|
|
388
729
|
# A cross constraint clamps to the pane, so this is 30 columns or fewer.
|
|
@@ -423,14 +764,14 @@ module SamplerExample
|
|
|
423
764
|
status.text = "level: #{level.value.inspect} endings: #{endings.value&.label.inspect}"
|
|
424
765
|
end
|
|
425
766
|
update.call
|
|
426
|
-
[level, endings].each { _1.on_value_change
|
|
767
|
+
[level, endings].each { _1.on_value_change { update.call } }
|
|
427
768
|
|
|
428
769
|
pane = ShortcutBox.new("r", spacing: 1, padding: FORM_PADDING)
|
|
429
770
|
pane.add(prompt, Fixed[3])
|
|
430
771
|
pane.add(labelled("Log level", level), Fixed[1])
|
|
431
772
|
pane.add(labelled("Line endings", endings), Fixed[1])
|
|
432
773
|
pane.add(status, Fixed[1])
|
|
433
|
-
pane.on_shortcut
|
|
774
|
+
pane.on_shortcut do
|
|
434
775
|
level.value = "warn"
|
|
435
776
|
endings.value = nil
|
|
436
777
|
update.call
|
|
@@ -450,7 +791,7 @@ module SamplerExample
|
|
|
450
791
|
# Set on the composed field, not on the TextField inside it.
|
|
451
792
|
field.placeholder = "1-65535"
|
|
452
793
|
status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
|
|
453
|
-
field.on_value_change
|
|
794
|
+
field.on_value_change { |e| status.text = "e.value: #{e.value.inspect}" }
|
|
454
795
|
form do |f|
|
|
455
796
|
f.add(prompt, Fixed[2])
|
|
456
797
|
f.add(field, Fixed[1], cross: Fixed[20])
|
|
@@ -468,7 +809,7 @@ module SamplerExample
|
|
|
468
809
|
"Up/Down step the value by one. Watch the value while you type '1.5'."
|
|
469
810
|
field = Tuile::Component::FloatField.new
|
|
470
811
|
status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
|
|
471
|
-
field.on_value_change
|
|
812
|
+
field.on_value_change { |e| status.text = "e.value: #{e.value.inspect}" }
|
|
472
813
|
form do |f|
|
|
473
814
|
f.add(prompt, Fixed[2])
|
|
474
815
|
f.add(field, Fixed[1], cross: Fixed[20])
|
|
@@ -487,7 +828,7 @@ module SamplerExample
|
|
|
487
828
|
"what you typed (19.90 keeps its zero)."
|
|
488
829
|
field = Tuile::Component::BigDecimalField.new
|
|
489
830
|
status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
|
|
490
|
-
field.on_value_change
|
|
831
|
+
field.on_value_change { |e| status.text = triple_report(e.value) }
|
|
491
832
|
form do |f|
|
|
492
833
|
f.add(prompt, Fixed[3])
|
|
493
834
|
f.add(field, Fixed[1], cross: Fixed[20])
|
|
@@ -517,7 +858,7 @@ module SamplerExample
|
|
|
517
858
|
# calendar reform, which is noise next to the one fact this row is for.
|
|
518
859
|
report = -> { status.text = "value: #{field.value&.to_s || "nil"} bad_input?: #{field.bad_input?}" }
|
|
519
860
|
report.call
|
|
520
|
-
field.on_value_change
|
|
861
|
+
field.on_value_change { report.call }
|
|
521
862
|
ask = Tuile::Component::Button.new("Ask again") { report.call }
|
|
522
863
|
form do |f|
|
|
523
864
|
f.add(prompt, Fixed[4])
|
|
@@ -550,7 +891,7 @@ module SamplerExample
|
|
|
550
891
|
"seconds: #{seconds.value&.strftime("%H:%M:%S") || "nil"} (#{seconds.formats.first})"
|
|
551
892
|
end
|
|
552
893
|
report.call
|
|
553
|
-
[minutes, seconds].each { _1.on_value_change
|
|
894
|
+
[minutes, seconds].each { _1.on_value_change { report.call } }
|
|
554
895
|
form do |f|
|
|
555
896
|
f.add(prompt, Fixed[4])
|
|
556
897
|
f.add(labelled("Minute stride", minutes, field_width: 12), Fixed[1], cross: Fixed[30])
|
|
@@ -568,6 +909,39 @@ module SamplerExample
|
|
|
568
909
|
end
|
|
569
910
|
end
|
|
570
911
|
|
|
912
|
+
# DateTimeField: the ink rule, which neither half can show alone. Leave the
|
|
913
|
+
# time empty and the *whole* widget reddens — half-filled is a fault no
|
|
914
|
+
# single half committed — where garbage in the date half reddens that half
|
|
915
|
+
# by itself. Both only on the way out, which is why pressing Enter first
|
|
916
|
+
# visibly changes nothing.
|
|
917
|
+
#
|
|
918
|
+
# The echo row carries the value and Save the verdict, the division
|
|
919
|
+
# `D_bad_input` draws: the notice is a push, fired when a half commits;
|
|
920
|
+
# `bad_input?` is a pull, read at the click. The Saved alert spells the
|
|
921
|
+
# value out with the `+00:00` the halves cannot fill in — `D_time_field`'s
|
|
922
|
+
# epoch cost, visible rather than hidden.
|
|
923
|
+
def build_date_time_field
|
|
924
|
+
prompt = Tuile::Component::Label.new
|
|
925
|
+
prompt.text = "Tab here, type 2026-09-14, leave the time empty — then press Enter: nothing reddens.\n" \
|
|
926
|
+
"Tab away and both halves redden at once: half-filled is the composite's own fault.\n" \
|
|
927
|
+
"Now type 2026-99-99 in the date half instead — it reddens alone, the time half stays clean.\n" \
|
|
928
|
+
"Save asks bad_input? at the click and names whichever fault it found."
|
|
929
|
+
field = Tuile::Component::DateTimeField.new
|
|
930
|
+
status = Tuile::Component::Label.new
|
|
931
|
+
# strftime, not inspect: DateTime#inspect spells out the Julian day, noise
|
|
932
|
+
# next to the one fact this row carries.
|
|
933
|
+
report = -> { status.text = "value: #{field.value&.strftime("%Y-%m-%d %H:%M") || "nil"}" }
|
|
934
|
+
report.call
|
|
935
|
+
field.on_value_change { report.call }
|
|
936
|
+
save = Tuile::Component::Button.new("Save") { save_form("Starts at" => field) }
|
|
937
|
+
form do |f|
|
|
938
|
+
f.add(prompt, Fixed[4])
|
|
939
|
+
f.add(labelled("Starts at", field, field_width: 20), Fixed[1], cross: Fixed[36])
|
|
940
|
+
f.add(status, Fixed[1])
|
|
941
|
+
f.add(save, Fixed[1], cross: Fixed[button_width(save)])
|
|
942
|
+
end
|
|
943
|
+
end
|
|
944
|
+
|
|
571
945
|
# HasBadInput: the one fact on_value_change cannot carry. Type a lone "-"
|
|
572
946
|
# and watch the echo row stay silent — the value was nil before and is nil
|
|
573
947
|
# after, so there is no diff to report — while Save, which asks bad_input?
|
|
@@ -582,8 +956,8 @@ module SamplerExample
|
|
|
582
956
|
amount = Tuile::Component::IntegerField.new.tap { _1.id = :amount }
|
|
583
957
|
rate = Tuile::Component::FloatField.new.tap { _1.id = :rate }
|
|
584
958
|
echo = Tuile::Component::Label.new.tap { _1.text = "on_value_change: (nothing yet)" }
|
|
585
|
-
amount.on_value_change
|
|
586
|
-
rate.on_value_change
|
|
959
|
+
amount.on_value_change { |e| echo.text = "on_value_change: amount = #{e.value.inspect}" }
|
|
960
|
+
rate.on_value_change { |e| echo.text = "on_value_change: rate = #{e.value.inspect}" }
|
|
587
961
|
save = Tuile::Component::Button.new("Save") { save_form("Amount" => amount, "Rate" => rate) }
|
|
588
962
|
save.id = :save
|
|
589
963
|
rows = group do |g|
|
|
@@ -629,7 +1003,7 @@ module SamplerExample
|
|
|
629
1003
|
# the field's listener keeps current.
|
|
630
1004
|
def validated_row(caption, field)
|
|
631
1005
|
error = Tuile::Component::Label.new
|
|
632
|
-
field.on_error_message_change
|
|
1006
|
+
field.on_error_message_change { |e| error.text = e.error_message || Tuile::StyledString::EMPTY }
|
|
633
1007
|
row do |r|
|
|
634
1008
|
r.add(Tuile::Component::Label.new(caption), Fixed[14])
|
|
635
1009
|
r.add(field, Fixed[22])
|
|
@@ -696,11 +1070,11 @@ module SamplerExample
|
|
|
696
1070
|
user = Tuile::Component::TextField.new
|
|
697
1071
|
password = Tuile::Component::PasswordField.new
|
|
698
1072
|
reveal = Tuile::Component::Checkbox.new("Show password")
|
|
699
|
-
reveal.on_value_change
|
|
1073
|
+
reveal.on_value_change { |e| password.revealed = e.value }
|
|
700
1074
|
status = Tuile::Component::Label.new
|
|
701
1075
|
refresh = -> { status.text = "user: #{user.text.inspect} password: #{password.value.length} chars" }
|
|
702
1076
|
refresh.call
|
|
703
|
-
[user, password].each { _1.
|
|
1077
|
+
[user, password].each { _1.on_value_change { refresh.call } }
|
|
704
1078
|
form do |f|
|
|
705
1079
|
f.add(prompt, Fixed[4])
|
|
706
1080
|
f.add([user, password], Fixed[1], cross: Fixed[30]) # one constraint, both fields
|
|
@@ -715,7 +1089,7 @@ module SamplerExample
|
|
|
715
1089
|
# A ListDropdown driven from a TextArea — the same shape {ComboBox} and
|
|
716
1090
|
# {Select} use, but wired by app code onto a field that knows nothing about
|
|
717
1091
|
# it. Focus (and the caret) stays in the TextArea the whole time: an
|
|
718
|
-
# `
|
|
1092
|
+
# `on_value_change` listener refills the menu, and {SlashCommandTextArea} hands
|
|
719
1093
|
# movement keys to `#move` and Enter to `#choose` while it is open.
|
|
720
1094
|
def build_slash_demo
|
|
721
1095
|
prompt = Tuile::Component::Label.new
|
|
@@ -733,15 +1107,14 @@ module SamplerExample
|
|
|
733
1107
|
overlay.close if overlay.open?
|
|
734
1108
|
else
|
|
735
1109
|
overlay.items = matches
|
|
736
|
-
overlay.open unless overlay.open?
|
|
737
1110
|
# Width is the driver's call, never the dropdown's: measure the
|
|
738
1111
|
# commands rather than inherit the full-width TextArea's columns.
|
|
739
|
-
overlay.anchor_to(area
|
|
1112
|
+
overlay.anchor_to(area, width: slash_menu_width(matches))
|
|
740
1113
|
end
|
|
741
1114
|
end
|
|
742
1115
|
|
|
743
|
-
area.
|
|
744
|
-
overlay.on_item_chosen
|
|
1116
|
+
area.on_value_change { refill.call }
|
|
1117
|
+
overlay.list.on_item_chosen { |e| accept_slash_command(area, e.item.to_s) }
|
|
745
1118
|
|
|
746
1119
|
form do |f|
|
|
747
1120
|
f.add(prompt, Fixed[4])
|
|
@@ -767,14 +1140,14 @@ module SamplerExample
|
|
|
767
1140
|
refresh = lambda do
|
|
768
1141
|
stats.text = "submits: #{submits} pastes: #{pastes} rows in draft: #{area.row_count}"
|
|
769
1142
|
end
|
|
770
|
-
area.
|
|
771
|
-
area.
|
|
1143
|
+
area.on_value_change { refresh.call }
|
|
1144
|
+
area.on_paste_received do |e|
|
|
772
1145
|
pastes += 1
|
|
773
|
-
log.add_line(Rainbow("pasted #{text.lines.size} line(s), #{text.length} chars").cyan)
|
|
1146
|
+
log.add_line(Rainbow("pasted #{e.text.lines.size} line(s), #{e.text.length} chars").cyan)
|
|
774
1147
|
end
|
|
775
|
-
area.on_submit
|
|
1148
|
+
area.on_submit do |e|
|
|
776
1149
|
submits += 1
|
|
777
|
-
log.add_line(Rainbow("submitted: #{text.inspect}").green)
|
|
1150
|
+
log.add_line(Rainbow("submitted: #{e.text.inspect}").green)
|
|
778
1151
|
end
|
|
779
1152
|
refresh.call
|
|
780
1153
|
|
|
@@ -860,7 +1233,7 @@ module SamplerExample
|
|
|
860
1233
|
status.text = "checked: #{on.empty? ? "(none)" : on.join(", ")}"
|
|
861
1234
|
end
|
|
862
1235
|
refresh.call
|
|
863
|
-
boxes.each { _1.on_value_change
|
|
1236
|
+
boxes.each { _1.on_value_change { refresh.call } }
|
|
864
1237
|
# The boxes sit flush against each other while the form keeps a blank row
|
|
865
1238
|
# around the block: a spacing-0 group nested in the spacing-1 form, rather
|
|
866
1239
|
# than a per-child gap the framework deliberately doesn't offer.
|
|
@@ -891,7 +1264,7 @@ module SamplerExample
|
|
|
891
1264
|
conditional.each { _1.visible = business.checked? }
|
|
892
1265
|
status.text = "visible fields: #{business.checked? ? 4 : 2}"
|
|
893
1266
|
end
|
|
894
|
-
business.on_value_change
|
|
1267
|
+
business.on_value_change { apply.call }
|
|
895
1268
|
# The rows sit flush; the form keeps its blank row around the block.
|
|
896
1269
|
rows = group do |g|
|
|
897
1270
|
g.add(labelled("Name", Tuile::Component::TextField.new), Fixed[1])
|
|
@@ -945,7 +1318,7 @@ module SamplerExample
|
|
|
945
1318
|
# *items* — LogLevel objects, not their labels — so the filter below is plain
|
|
946
1319
|
# set membership, no lookup table. Rows come from `item_label`, which may
|
|
947
1320
|
# return styled text (these colors are inherent to the data, not theme
|
|
948
|
-
# accents, so they need no
|
|
1321
|
+
# accents, so they need no handle_theme_changed hook).
|
|
949
1322
|
def build_checkbox_group
|
|
950
1323
|
prompt = Tuile::Component::Label.new
|
|
951
1324
|
# Kept under 48 columns a line, so an 80-column terminal shows it whole.
|
|
@@ -974,16 +1347,13 @@ module SamplerExample
|
|
|
974
1347
|
status.text = "value: {#{shown.join(", ")}} — #{log.items.size} of #{entries.size} lines"
|
|
975
1348
|
end
|
|
976
1349
|
refresh.call
|
|
977
|
-
group.on_value_change
|
|
1350
|
+
group.on_value_change { refresh.call }
|
|
978
1351
|
|
|
979
|
-
# The
|
|
980
|
-
#
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
group.rect = Tuile::Rect.new(r.left, r.top, group_width, [LOG_LEVELS.size, r.height].min)
|
|
985
|
-
log.rect = Tuile::Rect.new(r.left + group_width + 2, r.top,
|
|
986
|
-
[r.width - group_width - 2, 4].max, r.height)
|
|
1352
|
+
# The sidebar is a third of the row but never over 16 columns — a capped
|
|
1353
|
+
# proportion, which is a Percent with a clamp.
|
|
1354
|
+
body = row do |r|
|
|
1355
|
+
r.add(group, Percent[33].clamp(..16), cross: Fixed[LOG_LEVELS.size])
|
|
1356
|
+
r.add(log, Expand[1])
|
|
987
1357
|
end
|
|
988
1358
|
form do |f|
|
|
989
1359
|
f.add(prompt, Fixed[4])
|
|
@@ -1014,7 +1384,7 @@ module SamplerExample
|
|
|
1014
1384
|
SAMPLE_FILES = [
|
|
1015
1385
|
SampleFile.new("AGENTS.md", 31_402, "2026-07-30"),
|
|
1016
1386
|
SampleFile.new("CHANGELOG.md", 4118, "2026-07-05"),
|
|
1017
|
-
SampleFile.new("
|
|
1387
|
+
SampleFile.new("design/decisions.md", 48_990, "2026-07-31"),
|
|
1018
1388
|
SampleFile.new("Gemfile", 312, "2026-06-18"),
|
|
1019
1389
|
SampleFile.new("README.md", 9674, "2026-07-12"),
|
|
1020
1390
|
SampleFile.new("Rakefile", 2118, "2026-06-18"),
|
|
@@ -1058,20 +1428,17 @@ module SamplerExample
|
|
|
1058
1428
|
update_status.call
|
|
1059
1429
|
end
|
|
1060
1430
|
resort.call
|
|
1061
|
-
group.on_value_change
|
|
1431
|
+
group.on_value_change { resort.call }
|
|
1062
1432
|
# `list` is the composed List, which is where the cursor lives.
|
|
1063
1433
|
# Watching it is what makes the chrome/value split visible above.
|
|
1064
|
-
group.list.on_cursor_changed
|
|
1065
|
-
|
|
1066
|
-
# Side-by-side body
|
|
1067
|
-
#
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
group.rect = Tuile::Rect.new(r.left, r.top, group_width, [SORT_ORDERS.size, r.height].min)
|
|
1073
|
-
files.rect = Tuile::Rect.new(r.left + group_width + 2, r.top,
|
|
1074
|
-
[r.width - group_width - 2, 4].max, r.height)
|
|
1434
|
+
group.list.on_cursor_changed { update_status.call }
|
|
1435
|
+
|
|
1436
|
+
# Side-by-side body with a capped sidebar, as in the CheckboxGroup demo.
|
|
1437
|
+
# List pads a column either side of a row, so a label needs `width - 2`;
|
|
1438
|
+
# the file rows lose one more to their scrollbar.
|
|
1439
|
+
body = row do |r|
|
|
1440
|
+
r.add(group, Percent[33].clamp(..14), cross: Fixed[SORT_ORDERS.size])
|
|
1441
|
+
r.add(files, Expand[1])
|
|
1075
1442
|
end
|
|
1076
1443
|
form do |f|
|
|
1077
1444
|
f.add(prompt, Fixed[4])
|
|
@@ -1138,7 +1505,7 @@ module SamplerExample
|
|
|
1138
1505
|
pane.add(prompt, Fixed[4])
|
|
1139
1506
|
pane.add(determinate, Fixed[2])
|
|
1140
1507
|
pane.add(indeterminate, Fixed[3])
|
|
1141
|
-
pane.on_tick
|
|
1508
|
+
pane.on_tick do
|
|
1142
1509
|
done = done < PROGRESS_TOTAL ? done + 1 : 0
|
|
1143
1510
|
bar.value = done
|
|
1144
1511
|
refresh.call
|
|
@@ -1156,6 +1523,7 @@ module SamplerExample
|
|
|
1156
1523
|
# of hard-coded ANSI / 256-palette / RGB colors that stay put across flips.
|
|
1157
1524
|
BG_CHOICES = [
|
|
1158
1525
|
BgChoice.new("None (terminal default)", nil),
|
|
1526
|
+
BgChoice.new("Terminal background, stepped (derived)", Tuile::Theme.ref(:terminal_tint)),
|
|
1159
1527
|
BgChoice.new("Theme: input well", Tuile::Theme.ref(:input_bg_color)),
|
|
1160
1528
|
BgChoice.new("Theme: active", Tuile::Theme.ref(:active_bg_color)),
|
|
1161
1529
|
BgChoice.new("Theme: active border", Tuile::Theme.ref(:active_border_color)),
|
|
@@ -1169,21 +1537,6 @@ module SamplerExample
|
|
|
1169
1537
|
BgChoice.new("Hot pink (RGB)", Tuile::Color.rgb(120, 20, 70))
|
|
1170
1538
|
].freeze
|
|
1171
1539
|
|
|
1172
|
-
# The one choice that can't be a constant: the terminal's *own* background
|
|
1173
|
-
# stepped +10 per channel — the borderless-pane tint, which only sits right
|
|
1174
|
-
# when it's derived from the real background. Nil on a terminal that
|
|
1175
|
-
# reported none, which is the branch most users will actually see.
|
|
1176
|
-
def terminal_tint_choice
|
|
1177
|
-
bg = Tuile::Screen.instance.background_color
|
|
1178
|
-
return BgChoice.new("Terminal background — none reported", nil) if bg.nil?
|
|
1179
|
-
|
|
1180
|
-
BgChoice.new("Terminal background +10 (derived)",
|
|
1181
|
-
Tuile::Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }))
|
|
1182
|
-
end
|
|
1183
|
-
|
|
1184
|
-
# @param derived [BgChoice] the live terminal-derived tint, offered second.
|
|
1185
|
-
def bg_choices(derived) = [BG_CHOICES.first, derived, *BG_CHOICES[1..]]
|
|
1186
|
-
|
|
1187
1540
|
def build_background
|
|
1188
1541
|
intro = Tuile::Component::Label.new
|
|
1189
1542
|
intro.text = "bg_color tints a component and every descendant that doesn't set its own.\n" \
|
|
@@ -1194,7 +1547,7 @@ module SamplerExample
|
|
|
1194
1547
|
list.cursor = Tuile::Component::List::Cursor.new
|
|
1195
1548
|
list.lines = (1..12).map { |i| "List row #{i}" }
|
|
1196
1549
|
field = Tuile::Component::TextField.new
|
|
1197
|
-
field.
|
|
1550
|
+
field.value = "TextField keeps its own background"
|
|
1198
1551
|
|
|
1199
1552
|
# A borderless sub-box holding the list + field; it inherits the tint too.
|
|
1200
1553
|
box = Tuile::Component::Layout::Vertical.new(spacing: 1)
|
|
@@ -1204,34 +1557,81 @@ module SamplerExample
|
|
|
1204
1557
|
# A ComboBox over BG_CHOICES swaps the whole panel's bg_color on commit, so
|
|
1205
1558
|
# the tint flows down to every descendant without its own background — the
|
|
1206
1559
|
# label and the list — while the input widgets (the combo, the field) keep
|
|
1207
|
-
# their own well. Theme::Ref picks re-resolve on a scheme flip with no hook
|
|
1208
|
-
# the
|
|
1560
|
+
# their own well. Theme::Ref picks re-resolve on a scheme flip with no hook —
|
|
1561
|
+
# the derived terminal tint too, re-derived when the background changes —
|
|
1562
|
+
# and the hard-coded Colors are fixed by design, so no handle_theme_changed.
|
|
1209
1563
|
outer = nil
|
|
1210
|
-
|
|
1211
|
-
combo = Tuile::Component::ComboBox.new(items: bg_choices(derived))
|
|
1564
|
+
combo = Tuile::Component::ComboBox.new(items: BG_CHOICES)
|
|
1212
1565
|
combo.item_label = :label.to_proc
|
|
1213
|
-
combo.on_value_change
|
|
1566
|
+
combo.on_value_change { |e| outer.bg_color = e.value.color }
|
|
1214
1567
|
|
|
1215
1568
|
outer = form do |f|
|
|
1216
1569
|
f.add(intro, Fixed[3])
|
|
1217
1570
|
f.add(combo, Fixed[1], cross: Fixed[40])
|
|
1218
1571
|
f.add(box, Expand[1])
|
|
1219
1572
|
end
|
|
1220
|
-
# The derived tint is the one pick whose *color* moves under it: a flip
|
|
1221
|
-
# re-probes the terminal, so rebuild the choice and re-apply it if it is
|
|
1222
|
-
# the current one. Expect it to correct itself a frame late — the flip
|
|
1223
|
-
# report carries no RGB, so this hook runs once on the old background and
|
|
1224
|
-
# again when the re-probe answers.
|
|
1225
|
-
outer.on_theme_changed = lambda do
|
|
1226
|
-
was_derived = combo.value.equal?(derived)
|
|
1227
|
-
derived = terminal_tint_choice
|
|
1228
|
-
combo.items = bg_choices(derived)
|
|
1229
|
-
combo.value = derived if was_derived
|
|
1230
|
-
end
|
|
1231
1573
|
combo.value = BG_CHOICES.first # show "None" as the resting selection
|
|
1232
1574
|
outer
|
|
1233
1575
|
end
|
|
1234
1576
|
|
|
1577
|
+
# FormLayout: hand it fields and captions, and it stacks the FormItems it
|
|
1578
|
+
# builds. No binder here — the two name fields validate themselves from
|
|
1579
|
+
# their own `on_value_change`, and `error_message=` is what puts the text
|
|
1580
|
+
# in the item's message row. That row is also the gap, so a field going
|
|
1581
|
+
# invalid while you type into the one below it moves nothing.
|
|
1582
|
+
def build_form_layout
|
|
1583
|
+
prompt = Tuile::Component::Label.new
|
|
1584
|
+
prompt.text = "Tab into a name field, type a letter, then erase it: the message row under it\n" \
|
|
1585
|
+
"fills and the well goes red, and nothing below moves. The ∙ beside a caption\n" \
|
|
1586
|
+
"is the required marker — chrome, not a rule; the listener is the rule."
|
|
1587
|
+
fields = Tuile::Component::FormLayout.new
|
|
1588
|
+
first = Tuile::Component::TextField.new
|
|
1589
|
+
surname = Tuile::Component::TextField.new
|
|
1590
|
+
[first, surname].each do |field|
|
|
1591
|
+
field.on_value_change { |e| e.source.error_message = e.value.empty? ? "Must not be blank" : nil }
|
|
1592
|
+
end
|
|
1593
|
+
fields.add(first, caption: "First name", required: true)
|
|
1594
|
+
fields.add(surname, caption: "Surname", required: true)
|
|
1595
|
+
fields.add(Tuile::Component::DateField.new, caption: "Date of birth")
|
|
1596
|
+
form do |f|
|
|
1597
|
+
f.add(prompt, Fixed[3])
|
|
1598
|
+
f.add(fields, Expand[1], cross: Fixed[FORM_WIDTH])
|
|
1599
|
+
end
|
|
1600
|
+
end
|
|
1601
|
+
|
|
1602
|
+
# Wide enough for the longest message row below a field.
|
|
1603
|
+
FORM_WIDTH = 30
|
|
1604
|
+
|
|
1605
|
+
# A form taller than its viewport. Every field starts empty, hence
|
|
1606
|
+
# invalid, so each message row is filled and visibly scrolls in with it.
|
|
1607
|
+
def build_scroller
|
|
1608
|
+
prompt = Tuile::Component::Label.new
|
|
1609
|
+
prompt.text = "Tab down the form: the view follows focus, caption and message included, and\n" \
|
|
1610
|
+
"the wheel scrolls it too. The Scroller claims no keys — ↑↓ stay the fields'."
|
|
1611
|
+
fields = Tuile::Component::FormLayout.new
|
|
1612
|
+
["First name", "Surname", "Email", "Phone", "Street", "City", "Postcode"].each do |caption|
|
|
1613
|
+
fields.add(non_empty(Tuile::Component::TextField.new), caption:, required: true)
|
|
1614
|
+
end
|
|
1615
|
+
fields.add(non_empty(Tuile::Component::DateField.new), caption: "Date of birth", required: true)
|
|
1616
|
+
# Nothing measures (`D_scroller`): eight items, three rows each.
|
|
1617
|
+
scroller = Tuile::Component::Scroller.new(fields, content_rows: 8 * 3)
|
|
1618
|
+
form do |f|
|
|
1619
|
+
f.add(prompt, Fixed[2])
|
|
1620
|
+
f.add(scroller, Fixed[10], cross: Fixed[FORM_WIDTH + 2])
|
|
1621
|
+
end
|
|
1622
|
+
end
|
|
1623
|
+
|
|
1624
|
+
# Validates `field` as non-empty now and on every change, setting *or
|
|
1625
|
+
# clearing* the verdict each time, as {Tuile::Component::HasValidation} asks.
|
|
1626
|
+
# @param field [Tuile::Component]
|
|
1627
|
+
# @return [Tuile::Component] `field`.
|
|
1628
|
+
def non_empty(field)
|
|
1629
|
+
validate = -> { field.error_message = field.empty? ? "Can not be empty" : nil }
|
|
1630
|
+
field.on_value_change(&validate)
|
|
1631
|
+
validate.call
|
|
1632
|
+
field
|
|
1633
|
+
end
|
|
1634
|
+
|
|
1235
1635
|
# Horizontal splitting a row between two equal Expand shares. Resize the
|
|
1236
1636
|
# terminal to watch it recompute: on an odd width the spare column goes to
|
|
1237
1637
|
# the left pane, since the remainder is handed to the earliest Expand first.
|
|
@@ -1279,7 +1679,7 @@ module SamplerExample
|
|
|
1279
1679
|
"so they bubble past the strip."
|
|
1280
1680
|
|
|
1281
1681
|
field = Tuile::Component::TextField.new
|
|
1282
|
-
field.
|
|
1682
|
+
field.value = "type here"
|
|
1283
1683
|
checkbox = Tuile::Component::Checkbox.new("Remember me", value: true)
|
|
1284
1684
|
list = Tuile::Component::List.new
|
|
1285
1685
|
list.cursor = Tuile::Component::List::Cursor.new
|
|
@@ -1301,7 +1701,7 @@ module SamplerExample
|
|
|
1301
1701
|
"List row #{list.cursor.position} · Prose row #{view.scroll_top_row}"
|
|
1302
1702
|
end
|
|
1303
1703
|
report.call
|
|
1304
|
-
sheet.on_tab_selected
|
|
1704
|
+
sheet.on_tab_selected { report.call }
|
|
1305
1705
|
|
|
1306
1706
|
form do |f|
|
|
1307
1707
|
f.add(prompt, Fixed[3])
|
|
@@ -1376,7 +1776,7 @@ module SamplerExample
|
|
|
1376
1776
|
"(#{narrow.selected_index + 1} of #{narrow.tabs.size})"
|
|
1377
1777
|
end
|
|
1378
1778
|
report.call
|
|
1379
|
-
narrow.on_tab_selected
|
|
1779
|
+
narrow.on_tab_selected { report.call }
|
|
1380
1780
|
|
|
1381
1781
|
bar = Tuile::Component::MenuBar.new
|
|
1382
1782
|
%w[File Edit View Window Help].each do |caption|
|
|
@@ -1467,7 +1867,7 @@ module SamplerExample
|
|
|
1467
1867
|
dialog.button("Save") { report.call("saved") }
|
|
1468
1868
|
dialog.button("Discard") { report.call("discarded") }
|
|
1469
1869
|
dialog.button("Cancel")
|
|
1470
|
-
dialog.on_dismiss
|
|
1870
|
+
dialog.on_dismiss { report.call("stayed put") }
|
|
1471
1871
|
dialog.open
|
|
1472
1872
|
end,
|
|
1473
1873
|
Tuile::Component::Button.new("Long") do
|
|
@@ -1475,7 +1875,7 @@ module SamplerExample
|
|
|
1475
1875
|
dialog.message = (1..40).map { "#{_1}. Clause #{_1} of the agreement, spelled out in full." }.join("\n")
|
|
1476
1876
|
dialog.button("Accept") { report.call("accepted the terms") }
|
|
1477
1877
|
dialog.button("Decline") { report.call("declined the terms") }
|
|
1478
|
-
dialog.on_dismiss
|
|
1878
|
+
dialog.on_dismiss { report.call("left the terms unanswered") }
|
|
1479
1879
|
dialog.open
|
|
1480
1880
|
end
|
|
1481
1881
|
]
|
|
@@ -1543,9 +1943,14 @@ module SamplerExample
|
|
|
1543
1943
|
"PickerWindow asks the user to pick one option by a single keystroke.",
|
|
1544
1944
|
"Open PickerWindow"
|
|
1545
1945
|
) do
|
|
1946
|
+
# Captions paint in the terminal's own foreground — the picker
|
|
1947
|
+
# recommends no color of its own. Styling one is the app's call, and
|
|
1948
|
+
# per option: a caption may be a String, an ANSI-coded String (what
|
|
1949
|
+
# `theme.fg` hands back) or a StyledString.
|
|
1546
1950
|
Tuile::Component::PickerWindow.open(
|
|
1547
1951
|
"Pick a fruit",
|
|
1548
|
-
[%w[a Apple], %w[b Banana],
|
|
1952
|
+
[%w[a Apple], %w[b Banana],
|
|
1953
|
+
["c", "Cherry #{screen.theme.fg(:hint, "(in season)")}"]]
|
|
1549
1954
|
) { |key| Tuile.logger.info("Picked: #{key}") }
|
|
1550
1955
|
end
|
|
1551
1956
|
end
|
|
@@ -1578,14 +1983,33 @@ module SamplerExample
|
|
|
1578
1983
|
end
|
|
1579
1984
|
end
|
|
1580
1985
|
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1986
|
+
def build_mouse_demo
|
|
1987
|
+
intro = Tuile::Component::Label.new
|
|
1988
|
+
intro.text = "Drag on the canvas to draw X; right-drag erases; just hovering leaves a dim\n" \
|
|
1989
|
+
"trail. Arrows move the caret, space or x strokes it, Delete lifts it, c clears.\n" \
|
|
1990
|
+
"Discrete events go to the log; the row below is the live pointer."
|
|
1991
|
+
canvas = Canvas.new
|
|
1992
|
+
pointer = Tuile::Component::Label.new
|
|
1993
|
+
pointer.text = "pointer: (move over the canvas)"
|
|
1994
|
+
log = Tuile::Component::LogWindow.new("Events")
|
|
1995
|
+
canvas.on_report { |e| log.log(e.line) }
|
|
1996
|
+
canvas.on_move do |e|
|
|
1997
|
+
kind = e.mouse.is_a?(Tuile::Mouse::DragEvent) ? "drag" : "move"
|
|
1998
|
+
pointer.text = "pointer: #{e.mouse.x},#{e.mouse.y} (#{kind}, #{e.moves} reported so far)"
|
|
1999
|
+
end
|
|
2000
|
+
surface = row do |r|
|
|
2001
|
+
r.add(Tuile::Component::Window.new("Canvas").tap { _1.content = canvas }, Percent[55])
|
|
2002
|
+
r.add(log, Expand[1])
|
|
2003
|
+
end
|
|
2004
|
+
form do |f|
|
|
2005
|
+
f.add(intro, Fixed[3])
|
|
2006
|
+
f.add(surface, Expand[1])
|
|
2007
|
+
f.add(pointer, Fixed[1])
|
|
2008
|
+
end
|
|
1587
2009
|
end
|
|
1588
2010
|
|
|
2011
|
+
# --- Helpers -----------------------------------------------------------
|
|
2012
|
+
|
|
1589
2013
|
# The standard demo shell: children stacked with a blank row between them,
|
|
1590
2014
|
# inset from the window border. Every constraint below reads unqualified —
|
|
1591
2015
|
# `Fixed`, `Expand`, `Insets` all live on {Tuile::Component::Layout}, which
|
|
@@ -1649,19 +2073,19 @@ module SamplerExample
|
|
|
1649
2073
|
end
|
|
1650
2074
|
|
|
1651
2075
|
# Replaces the slash token at the caret with `command` plus a trailing
|
|
1652
|
-
# space, then drops the caret after it (which re-fires
|
|
2076
|
+
# space, then drops the caret after it (which re-fires on_value_change → refill,
|
|
1653
2077
|
# so the now-tokenless text closes the menu).
|
|
1654
2078
|
def accept_slash_command(area, command)
|
|
1655
2079
|
text = area.text
|
|
1656
2080
|
caret = area.caret
|
|
1657
2081
|
start = caret
|
|
1658
2082
|
start -= 1 while start.positive? && !text[start - 1].match?(/\s/)
|
|
1659
|
-
area.
|
|
2083
|
+
area.value = "#{text[0...start]}#{command} #{text[caret..]}"
|
|
1660
2084
|
area.caret = start + command.length + 1
|
|
1661
2085
|
end
|
|
1662
2086
|
|
|
1663
2087
|
# The slash menu's width: the widest command plus List's two row gutters,
|
|
1664
|
-
# clamped to the screen. ListDropdown
|
|
2088
|
+
# clamped to the screen. ListDropdown hangs off its anchor but never measures — the
|
|
1665
2089
|
# width policy stays with the driver, exactly as it does for Select.
|
|
1666
2090
|
# @return [Integer]
|
|
1667
2091
|
def slash_menu_width(matches)
|
|
@@ -1678,13 +2102,19 @@ end
|
|
|
1678
2102
|
# component tree without spinning up the real event loop.
|
|
1679
2103
|
if $PROGRAM_NAME == __FILE__
|
|
1680
2104
|
screen = Tuile::Screen.new
|
|
2105
|
+
screen.theme_def = SamplerExample::APP_THEME
|
|
1681
2106
|
sampler = SamplerExample::Sampler.new
|
|
1682
2107
|
screen.content = sampler
|
|
1683
|
-
screen.on_focus_changed
|
|
2108
|
+
screen.on_focus_changed { sampler.refresh_status }
|
|
1684
2109
|
sampler.refresh_status
|
|
1685
2110
|
sampler.menu_bar.focus
|
|
1686
2111
|
begin
|
|
1687
|
-
|
|
2112
|
+
# `:hover` for the whole app, because the level is set once here and is
|
|
2113
|
+
# all-or-nothing: the Mouse demo's trail and enter/exit lines arrive at no
|
|
2114
|
+
# lower one. The upgrade from the default `:clicks` costs the ~84 motion
|
|
2115
|
+
# reports a second (`R_mouse_reporting`) — not select-to-copy, which mode
|
|
2116
|
+
# 1000 had already taken.
|
|
2117
|
+
screen.run_event_loop(capture_mouse: :hover)
|
|
1688
2118
|
ensure
|
|
1689
2119
|
screen.close
|
|
1690
2120
|
end
|