tuile 0.14.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 +159 -49
- data/README.md +53 -17
- data/book/04-event-loop.md +12 -12
- data/book/05-focus.md +152 -22
- data/book/06-theming.md +105 -25
- data/book/07-components.md +531 -50
- data/book/08-testing.md +100 -20
- data/book/10-locale.md +216 -0
- data/book/README.md +19 -9
- data/examples/file_commander.rb +14 -5
- data/examples/hello_world.rb +17 -4
- data/examples/sampler.rb +654 -40
- data/lib/tuile/component/abstract_string_field.rb +114 -68
- data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
- data/lib/tuile/component/big_decimal_field.rb +52 -79
- data/lib/tuile/component/button.rb +8 -8
- data/lib/tuile/component/checkbox.rb +9 -9
- data/lib/tuile/component/checkbox_group.rb +38 -21
- data/lib/tuile/component/combo_box.rb +102 -59
- data/lib/tuile/component/confirm_window.rb +7 -5
- data/lib/tuile/component/date_field.rb +347 -0
- data/lib/tuile/component/date_time_field.rb +275 -0
- data/lib/tuile/component/float_field.rb +57 -82
- data/lib/tuile/component/has_bad_input.rb +88 -0
- data/lib/tuile/component/has_caption.rb +8 -0
- data/lib/tuile/component/has_content.rb +32 -13
- data/lib/tuile/component/has_placeholder.rb +62 -0
- data/lib/tuile/component/has_validation.rb +115 -0
- data/lib/tuile/component/has_value.rb +28 -1
- data/lib/tuile/component/integer_field.rb +51 -78
- data/lib/tuile/component/label.rb +7 -39
- data/lib/tuile/component/layout/box.rb +90 -19
- data/lib/tuile/component/layout.rb +15 -5
- data/lib/tuile/component/list.rb +53 -38
- data/lib/tuile/component/list_dropdown.rb +7 -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 +26 -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 +41 -23
- data/lib/tuile/component/select.rb +23 -16
- 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 +26 -18
- data/lib/tuile/component/text_field.rb +55 -26
- data/lib/tuile/component/text_view.rb +40 -19
- data/lib/tuile/component/time_field.rb +479 -0
- data/lib/tuile/component/window.rb +26 -13
- data/lib/tuile/component.rb +635 -131
- data/lib/tuile/event_queue.rb +4 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +95 -3
- data/lib/tuile/final.rb +75 -0
- data/lib/tuile/locale.rb +851 -0
- data/lib/tuile/mouse/router.rb +217 -0
- data/lib/tuile/mouse.rb +177 -0
- data/lib/tuile/screen.rb +219 -68
- data/lib/tuile/screen_pane.rb +51 -42
- data/lib/tuile/styled_string.rb +5 -5
- data/lib/tuile/testing.rb +198 -0
- data/lib/tuile/theme.rb +110 -32
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +88 -12
- data/lib/tuile.rb +1 -0
- data/sig/tuile.rbs +4595 -825
- metadata +14 -9
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -5422
- data/TERMINOLOGY.md +0 -71
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -124
- data/ideas/per-component-buffers.md +0 -55
- data/lib/tuile/mouse_event.rb +0 -68
data/examples/sampler.rb
CHANGED
|
@@ -12,11 +12,25 @@
|
|
|
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
|
|
23
|
+
# `hint` is the app's token, not Tuile's: the framework carries accents for
|
|
24
|
+
# the chrome *it* paints, and the status row below is the sampler's own
|
|
25
|
+
# (`D_status_bar` — Tuile draws none). Paired in a ThemeDef so it survives an
|
|
26
|
+
# OS appearance flip, where a bare `theme=` would be replaced. Both greys
|
|
27
|
+
# quantize to :bright_black on a 16-color terminal, so the description stays
|
|
28
|
+
# dimmer than the key beside it even there.
|
|
29
|
+
APP_THEME = Tuile::ThemeDef.new(
|
|
30
|
+
dark: Tuile::Theme::DARK.with(custom: { hint: Tuile::Color::GREY54 }),
|
|
31
|
+
light: Tuile::Theme::LIGHT.with(custom: { hint: Tuile::Color::GREY62 })
|
|
32
|
+
)
|
|
33
|
+
|
|
20
34
|
# Sampler-local container: a {Tuile::Component::Layout::Absolute} that runs a
|
|
21
35
|
# caller-supplied block on `rect=` to position its children. Most demos are
|
|
22
36
|
# plain stacks and use the box layouts instead; this is what's left for the
|
|
@@ -49,18 +63,18 @@ module SamplerExample
|
|
|
49
63
|
# @return [Proc, nil] called with no arguments on each frame.
|
|
50
64
|
attr_writer :on_tick
|
|
51
65
|
|
|
52
|
-
def
|
|
66
|
+
def handle_attached
|
|
53
67
|
@ticker = screen.event_queue.tick_fps(@fps) { @on_tick&.call }
|
|
54
68
|
end
|
|
55
69
|
|
|
56
|
-
def
|
|
70
|
+
def handle_detached
|
|
57
71
|
@ticker&.cancel
|
|
58
72
|
@ticker = nil
|
|
59
73
|
end
|
|
60
74
|
end
|
|
61
75
|
|
|
62
76
|
# A {Tuile::Component::Layout::Vertical} that claims one key for itself. An
|
|
63
|
-
# ancestor's `handle_key
|
|
77
|
+
# ancestor's `handle_key?` is where a scope-wide binding belongs (key-dispatch
|
|
64
78
|
# rung 3); the Select demo uses one to show the letter still arriving while a
|
|
65
79
|
# Select has focus — the capability a ComboBox, which eats every printable
|
|
66
80
|
# unconditionally, cannot offer.
|
|
@@ -73,7 +87,7 @@ module SamplerExample
|
|
|
73
87
|
# @return [Proc, nil] called with no arguments when the shortcut arrives.
|
|
74
88
|
attr_writer :on_shortcut
|
|
75
89
|
|
|
76
|
-
def handle_key(key)
|
|
90
|
+
def handle_key?(key)
|
|
77
91
|
return false unless key == @shortcut
|
|
78
92
|
|
|
79
93
|
@on_shortcut&.call
|
|
@@ -100,7 +114,7 @@ module SamplerExample
|
|
|
100
114
|
|
|
101
115
|
protected
|
|
102
116
|
|
|
103
|
-
def handle_text_input_key(key)
|
|
117
|
+
def handle_text_input_key?(key)
|
|
104
118
|
return super unless key == Tuile::Keys::ENTER
|
|
105
119
|
|
|
106
120
|
@on_submit&.call(text)
|
|
@@ -109,6 +123,322 @@ module SamplerExample
|
|
|
109
123
|
end
|
|
110
124
|
end
|
|
111
125
|
|
|
126
|
+
# A {Tuile::Component::TextArea} that steers a {Tuile::Component::ListDropdown}
|
|
127
|
+
# while it is open: movement keys move the highlight, ENTER accepts and ESC
|
|
128
|
+
# dismisses, and everything else — printables, editing, the ENTER that inserts
|
|
129
|
+
# a newline with no menu up — stays the TextArea's own.
|
|
130
|
+
#
|
|
131
|
+
# Subclassing *is* the seam for this. A component receives keys through
|
|
132
|
+
# `handle_key?`, so one that wants different keys overrides it (here its
|
|
133
|
+
# `handle_text_input_key?` hook) and calls `super` for the rest, which composes
|
|
134
|
+
# and stacks. None of this is baked into TextArea.
|
|
135
|
+
class SlashCommandTextArea < Tuile::Component::TextArea
|
|
136
|
+
# @param overlay [Tuile::Component::ListDropdown] the menu to steer.
|
|
137
|
+
def initialize(overlay)
|
|
138
|
+
super()
|
|
139
|
+
@overlay = overlay
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
protected
|
|
143
|
+
|
|
144
|
+
def handle_text_input_key?(key)
|
|
145
|
+
return super unless @overlay.open?
|
|
146
|
+
return true if @overlay.move(key) # Up/Down/PgUp/PgDn/^U/^D
|
|
147
|
+
|
|
148
|
+
case key
|
|
149
|
+
when Tuile::Keys::ENTER then @overlay.choose
|
|
150
|
+
when Tuile::Keys::ESC then @overlay.close
|
|
151
|
+
else return super
|
|
152
|
+
end
|
|
153
|
+
true
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# A drawing surface answering every mouse handler, in two inks that both
|
|
158
|
+
# persist: a left-drag strokes `X`, a plain hover leaves a `.` trail behind
|
|
159
|
+
# the pointer, and a right-drag lifts marks again.
|
|
160
|
+
#
|
|
161
|
+
# X a diagonal drag, then one hover sweep across it
|
|
162
|
+
# X
|
|
163
|
+
# X
|
|
164
|
+
# X
|
|
165
|
+
# .....X........
|
|
166
|
+
# X
|
|
167
|
+
#
|
|
168
|
+
# A trail never overwrites a stroke, so the picture stays a readout of which
|
|
169
|
+
# channel drew which cell — an eraser-on-hover would instead make the drawing
|
|
170
|
+
# unviewable with the pointer over it.
|
|
171
|
+
#
|
|
172
|
+
# canvas = Canvas.new
|
|
173
|
+
# canvas.on_event = ->(line) { log.log(line) }
|
|
174
|
+
# canvas.on_move = ->(event, count) { label.text = "#{event.x},#{event.y} (#{count})" }
|
|
175
|
+
#
|
|
176
|
+
# Two slots, because the traffic is two: {#on_event} carries the discrete
|
|
177
|
+
# events to a log, {#on_move} the ~84-a-second moves to one replaced row.
|
|
178
|
+
# Feeding both to a log would drown enter/exit inside 12 ms.
|
|
179
|
+
#
|
|
180
|
+
# Every gesture owes a key (`D_mouse`): the arrows move the caret, space or
|
|
181
|
+
# `x` strokes it, Delete lifts it, `c` clears. The trail, the enter/exit lines
|
|
182
|
+
# and {#on_move} need `capture_mouse: :hover` and arrive as nothing below it;
|
|
183
|
+
# the drag works from `:drag`, a single-cell stroke from `:clicks`.
|
|
184
|
+
#
|
|
185
|
+
# == Implementation details
|
|
186
|
+
# Both glyphs are ASCII on purpose: `·` is East-Asian Ambiguous, so a
|
|
187
|
+
# two-column trail cell would push every painted row past `rect.width`
|
|
188
|
+
# (`D_ambiguous_width`).
|
|
189
|
+
#
|
|
190
|
+
# It paints every cell of its rect itself, one {Tuile::Component#draw_text}
|
|
191
|
+
# per run of like cells, and so skips `super` in {#repaint} — whose
|
|
192
|
+
# auto-clear blanks the whole rect, which would re-emit every cell this widget
|
|
193
|
+
# is about to paint over anyway. Repainting whole on every move still costs
|
|
194
|
+
# one cell on the wire, since a cell dirties only on a real change.
|
|
195
|
+
#
|
|
196
|
+
# Marks are keyed by rect-local {Tuile::Point}: ink beyond a narrowed rect
|
|
197
|
+
# stops painting and comes back when the rect grows again.
|
|
198
|
+
class Canvas < Tuile::Component
|
|
199
|
+
# @return [String] the drag/keyboard ink.
|
|
200
|
+
STROKE = "X"
|
|
201
|
+
# @return [String] the hover ink.
|
|
202
|
+
TRAIL = "."
|
|
203
|
+
|
|
204
|
+
# Which ink each button drags — `nil` erases. The middle button is absent:
|
|
205
|
+
# a press this component declines bubbles on to the window around it.
|
|
206
|
+
# @return [Hash{Symbol => String, nil}]
|
|
207
|
+
DRAG_INK = { left: STROKE, right: nil }.freeze
|
|
208
|
+
|
|
209
|
+
# @return [Proc, nil] called with one line (String) per discrete event.
|
|
210
|
+
attr_accessor :on_event
|
|
211
|
+
|
|
212
|
+
# @return [Proc, nil] called with the {Tuile::Mouse::Event} and the number
|
|
213
|
+
# of moves so far (Integer), on every move and every drag.
|
|
214
|
+
attr_accessor :on_move
|
|
215
|
+
|
|
216
|
+
# @return [Tuile::Point] the keyboard caret, in rect-local coordinates.
|
|
217
|
+
attr_reader :caret
|
|
218
|
+
|
|
219
|
+
# The marks, keyed by rect-local {Tuile::Point} — read-only in practice:
|
|
220
|
+
# writing one behind {#paint}'s back skips the `invalidate`.
|
|
221
|
+
# @return [Hash{Tuile::Point => String}]
|
|
222
|
+
attr_reader :ink
|
|
223
|
+
|
|
224
|
+
def initialize
|
|
225
|
+
super
|
|
226
|
+
@ink = {}
|
|
227
|
+
@caret = Tuile::Point.new(0, 0)
|
|
228
|
+
@moves = 0
|
|
229
|
+
@drag_ink = nil
|
|
230
|
+
@drag_outside = false
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def focusable? = true
|
|
234
|
+
|
|
235
|
+
def tab_stop? = true
|
|
236
|
+
|
|
237
|
+
# @return [Tuile::Point, nil]
|
|
238
|
+
def cursor_position
|
|
239
|
+
return nil if rect.empty?
|
|
240
|
+
|
|
241
|
+
Tuile::Point.new(rect.left + @caret.x, rect.top + @caret.y)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# @return [Tuile::Color]
|
|
245
|
+
def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color
|
|
246
|
+
|
|
247
|
+
# Clamps the caret into the new rect, so a shrink cannot strand it — and
|
|
248
|
+
# with it the hardware cursor — outside what this widget paints.
|
|
249
|
+
# @param new_rect [Tuile::Rect]
|
|
250
|
+
def rect=(new_rect)
|
|
251
|
+
super
|
|
252
|
+
@caret = Tuile::Point.new(@caret.x.clamp(0, [new_rect.width - 1, 0].max),
|
|
253
|
+
@caret.y.clamp(0, [new_rect.height - 1, 0].max))
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# @return [void]
|
|
257
|
+
def repaint
|
|
258
|
+
return if rect.empty?
|
|
259
|
+
|
|
260
|
+
# The clear is what a self-painter opts out of; the cascade never is
|
|
261
|
+
# (`D_repaint_cascade`), leaf or not.
|
|
262
|
+
invalidate_children
|
|
263
|
+
trail_color = screen.theme[:hint]
|
|
264
|
+
rect.height.times { |row| draw_row(row, trail_color) }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# @param event [Tuile::Mouse::DownEvent]
|
|
268
|
+
# @return [Boolean]
|
|
269
|
+
def handle_mouse_down?(event)
|
|
270
|
+
unless DRAG_INK.key?(event.button)
|
|
271
|
+
report("down #{event.button} at #{event.x},#{event.y} — declined, bubbles to the window")
|
|
272
|
+
return false
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
report("down #{event.button} at #{event.x},#{event.y} — claimed, grab held")
|
|
276
|
+
@drag_ink = DRAG_INK.fetch(event.button)
|
|
277
|
+
@drag_outside = false
|
|
278
|
+
mark(cell_at(event))
|
|
279
|
+
true
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# @param event [Tuile::Mouse::DragEvent]
|
|
283
|
+
# @return [void]
|
|
284
|
+
def handle_mouse_drag(event)
|
|
285
|
+
super
|
|
286
|
+
cell = cell_at(event)
|
|
287
|
+
if @drag_outside != cell.nil?
|
|
288
|
+
@drag_outside = cell.nil?
|
|
289
|
+
report(@drag_outside ? "drag left the canvas — the grab still delivers" : "drag back inside")
|
|
290
|
+
end
|
|
291
|
+
mark(cell)
|
|
292
|
+
report_move(event)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# @param event [Tuile::Mouse::UpEvent]
|
|
296
|
+
# @return [void]
|
|
297
|
+
def handle_mouse_up(event)
|
|
298
|
+
super
|
|
299
|
+
report("up at #{event.x},#{event.y} — grab released")
|
|
300
|
+
@drag_ink = nil
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# @param event [Tuile::Mouse::MoveEvent]
|
|
304
|
+
# @return [Boolean]
|
|
305
|
+
def handle_mouse_move?(event)
|
|
306
|
+
cell = cell_at(event)
|
|
307
|
+
paint(cell, TRAIL) if cell && @ink[cell].nil?
|
|
308
|
+
report_move(event)
|
|
309
|
+
true
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# @return [void]
|
|
313
|
+
def handle_mouse_enter
|
|
314
|
+
super
|
|
315
|
+
report("enter")
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# @return [void]
|
|
319
|
+
def handle_mouse_exit
|
|
320
|
+
super
|
|
321
|
+
report("exit")
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Declines the notch — nothing here scrolls — which is what lets it bubble
|
|
325
|
+
# on to an ancestor that does.
|
|
326
|
+
# @param event [Tuile::Mouse::ScrollEvent]
|
|
327
|
+
# @return [Boolean] always false.
|
|
328
|
+
def handle_mouse_scroll?(event)
|
|
329
|
+
report("wheel #{event.direction} — declined, bubbles on")
|
|
330
|
+
false
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# @param key [String]
|
|
334
|
+
# @return [Boolean]
|
|
335
|
+
def handle_key?(key)
|
|
336
|
+
case key
|
|
337
|
+
when Tuile::Keys::UP_ARROW then move_caret(0, -1)
|
|
338
|
+
when Tuile::Keys::DOWN_ARROW then move_caret(0, 1)
|
|
339
|
+
when Tuile::Keys::LEFT_ARROW then move_caret(-1, 0)
|
|
340
|
+
when Tuile::Keys::RIGHT_ARROW then move_caret(1, 0)
|
|
341
|
+
when " ", "x" then stroke_caret(STROKE)
|
|
342
|
+
when Tuile::Keys::DELETE, *Tuile::Keys::BACKSPACES then stroke_caret(nil)
|
|
343
|
+
when "c" then clear_marks
|
|
344
|
+
else return super
|
|
345
|
+
end
|
|
346
|
+
true
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
private
|
|
350
|
+
|
|
351
|
+
# One row, as runs of like cells: every cell is painted exactly once, so
|
|
352
|
+
# none is blanked and then painted over (`D_progress_bar`).
|
|
353
|
+
# @param row [Integer] rect-local row.
|
|
354
|
+
# @param trail_color [Tuile::Color]
|
|
355
|
+
# @return [void]
|
|
356
|
+
def draw_row(row, trail_color)
|
|
357
|
+
column = 0
|
|
358
|
+
while column < rect.width
|
|
359
|
+
glyph = @ink[Tuile::Point.new(column, row)]
|
|
360
|
+
run = 1
|
|
361
|
+
run += 1 while column + run < rect.width && @ink[Tuile::Point.new(column + run, row)] == glyph
|
|
362
|
+
text = (glyph || " ") * run
|
|
363
|
+
styled = glyph == TRAIL ? Tuile::StyledString.styled(text, fg: trail_color) : Tuile::StyledString.plain(text)
|
|
364
|
+
draw_text(rect.left + column, rect.top + row, styled)
|
|
365
|
+
column += run
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# The event's cell in rect-local coordinates, or nil when it lands outside
|
|
370
|
+
# — which a grabbed {Tuile::Mouse::DragEvent} routinely does.
|
|
371
|
+
# @param event [Tuile::Mouse::Event]
|
|
372
|
+
# @return [Tuile::Point, nil]
|
|
373
|
+
def cell_at(event)
|
|
374
|
+
column = event.x - rect.left
|
|
375
|
+
row = event.y - rect.top
|
|
376
|
+
return nil unless (0...rect.width).cover?(column) && (0...rect.height).cover?(row)
|
|
377
|
+
|
|
378
|
+
Tuile::Point.new(column, row)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Lays the dragged ink at `cell` and takes the caret with it, so a stroke
|
|
382
|
+
# can be continued from the keyboard. Outside the rect (`nil`) it is a
|
|
383
|
+
# no-op, which is a grabbed drag's normal case.
|
|
384
|
+
# @param cell [Tuile::Point, nil] rect-local.
|
|
385
|
+
# @return [void]
|
|
386
|
+
def mark(cell)
|
|
387
|
+
return if cell.nil?
|
|
388
|
+
|
|
389
|
+
paint(cell, @drag_ink)
|
|
390
|
+
@caret = cell
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# @param cell [Tuile::Point] rect-local.
|
|
394
|
+
# @param ink [String, nil] nil lifts the mark.
|
|
395
|
+
# @return [void]
|
|
396
|
+
def paint(cell, ink)
|
|
397
|
+
return if @ink[cell] == ink
|
|
398
|
+
|
|
399
|
+
ink.nil? ? @ink.delete(cell) : @ink.store(cell, ink)
|
|
400
|
+
invalidate
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# @param ink [String, nil]
|
|
404
|
+
# @return [void]
|
|
405
|
+
def stroke_caret(ink)
|
|
406
|
+
paint(@caret, ink)
|
|
407
|
+
report("#{ink || "lift"} at #{@caret.x},#{@caret.y} — from the keyboard")
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# @param columns [Integer]
|
|
411
|
+
# @param rows [Integer]
|
|
412
|
+
# @return [void]
|
|
413
|
+
def move_caret(columns, rows)
|
|
414
|
+
return if rect.empty?
|
|
415
|
+
|
|
416
|
+
@caret = Tuile::Point.new((@caret.x + columns).clamp(0, rect.width - 1),
|
|
417
|
+
(@caret.y + rows).clamp(0, rect.height - 1))
|
|
418
|
+
invalidate # the hardware cursor is placed from #cursor_position at flush
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# @return [void]
|
|
422
|
+
def clear_marks
|
|
423
|
+
return if @ink.empty?
|
|
424
|
+
|
|
425
|
+
@ink.clear
|
|
426
|
+
report("cleared")
|
|
427
|
+
invalidate
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# @param line [String]
|
|
431
|
+
# @return [void]
|
|
432
|
+
def report(line) = @on_event&.call(line)
|
|
433
|
+
|
|
434
|
+
# @param event [Tuile::Mouse::Event]
|
|
435
|
+
# @return [void]
|
|
436
|
+
def report_move(event)
|
|
437
|
+
@moves += 1
|
|
438
|
+
@on_move&.call(event, @moves)
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
112
442
|
# Top-level sampler component: a shell row across the top — a
|
|
113
443
|
# {Tuile::Component::MenuBar} of the demos, grouped, and a
|
|
114
444
|
# {Tuile::Component::ComboBox} jump box at its right end — over one demo
|
|
@@ -141,7 +471,8 @@ module SamplerExample
|
|
|
141
471
|
def refresh_status
|
|
142
472
|
focused = screen.focused
|
|
143
473
|
name = focused ? focused.class.name.sub("Tuile::Component::", "") : "(none)"
|
|
144
|
-
|
|
474
|
+
t = screen.theme
|
|
475
|
+
@status.text = "q #{t.fg(:hint, "quit")} ⇥ #{t.fg(:hint, name)}"
|
|
145
476
|
end
|
|
146
477
|
|
|
147
478
|
# Chrome for a demo pane: a blank row top and bottom, two columns either
|
|
@@ -177,9 +508,10 @@ module SamplerExample
|
|
|
177
508
|
# as a live index of the catalogue and any drift between the two is visible.
|
|
178
509
|
#
|
|
179
510
|
# Mnemonics are *hand-picked*: {Tuile::Component::MenuBar#add_item} raises on
|
|
180
|
-
# a duplicate among siblings, and
|
|
511
|
+
# a duplicate among siblings, and six leaves therefore answer to a letter
|
|
181
512
|
# other than their initial (Past`e`, Checkbox`G`roup, C`o`mboBox,
|
|
182
|
-
# Pic`k`erWindow, S`l`ash menu) — the underline shows which.
|
|
513
|
+
# Pic`k`erWindow, S`l`ash menu, DateTi`m`eField) — the underline shows which.
|
|
514
|
+
# No item may use
|
|
183
515
|
# `q`: quit is the unhandled-key fallback, so a `q` on the live level would
|
|
184
516
|
# swallow it while the bar has focus.
|
|
185
517
|
MENUS = [
|
|
@@ -199,7 +531,12 @@ module SamplerExample
|
|
|
199
531
|
Menu.new("Typed", "y", [
|
|
200
532
|
Entry.new("IntegerField", :build_integer_field, "i"),
|
|
201
533
|
Entry.new("FloatField", :build_float_field, "f"),
|
|
202
|
-
Entry.new("BigDecimalField", :build_big_decimal_field, "b")
|
|
534
|
+
Entry.new("BigDecimalField", :build_big_decimal_field, "b"),
|
|
535
|
+
Entry.new("DateField", :build_date_field, "d"),
|
|
536
|
+
Entry.new("TimeField", :build_time_field, "t"),
|
|
537
|
+
Entry.new("DateTimeField", :build_date_time_field, "m"),
|
|
538
|
+
Entry.new("Bad input", :build_bad_input, "a"),
|
|
539
|
+
Entry.new("Validation", :build_validation, "v")
|
|
203
540
|
]),
|
|
204
541
|
Menu.new("Choose", "c", [
|
|
205
542
|
Entry.new("Checkbox", :build_checkboxes, "c"),
|
|
@@ -211,7 +548,7 @@ module SamplerExample
|
|
|
211
548
|
])
|
|
212
549
|
]),
|
|
213
550
|
# One entry, so it is the item and not a menu — a top-level leaf on the
|
|
214
|
-
# strip is a button
|
|
551
|
+
# strip is a button rather than a drop-down.
|
|
215
552
|
Entry.new("Button", :build_buttons, "b"),
|
|
216
553
|
Menu.new("Overlay", "o", [
|
|
217
554
|
Entry.new("Popup", :build_popup_launcher, "p"),
|
|
@@ -227,8 +564,12 @@ module SamplerExample
|
|
|
227
564
|
Entry.new("Narrow strips", :build_narrow_strips, "n"),
|
|
228
565
|
Entry.new("Layout", :build_layout, "l"),
|
|
229
566
|
Entry.new("Background", :build_background, "b"),
|
|
567
|
+
Entry.new("Visibility", :build_visibility, "v"),
|
|
230
568
|
Entry.new("Focus & Tab", :build_focus_demo, "f")
|
|
231
|
-
])
|
|
569
|
+
]),
|
|
570
|
+
# The one demo needing a tracking level above the default, which is why
|
|
571
|
+
# the runner asks for `capture_mouse: :hover` for the whole app.
|
|
572
|
+
Entry.new("Mouse", :build_mouse_demo, "m")
|
|
232
573
|
].freeze
|
|
233
574
|
|
|
234
575
|
# Every {Entry} in strip order — what the jump box offers.
|
|
@@ -309,10 +650,13 @@ module SamplerExample
|
|
|
309
650
|
|
|
310
651
|
def build_text_field
|
|
311
652
|
prompt = Tuile::Component::Label.new
|
|
312
|
-
prompt.text = "Tab here, then type. Arrows, Home/End, Backspace, Delete all work
|
|
653
|
+
prompt.text = "Tab here, then type. Arrows, Home/End, Backspace, Delete all work.\n" \
|
|
654
|
+
"While it is empty it shows a placeholder — a hint in ink faint " \
|
|
655
|
+
"enough to miss, which is the point."
|
|
313
656
|
field = Tuile::Component::TextField.new
|
|
657
|
+
field.placeholder = "dd.mm.yyyy"
|
|
314
658
|
form do |f|
|
|
315
|
-
f.add(prompt, Fixed[
|
|
659
|
+
f.add(prompt, Fixed[2])
|
|
316
660
|
f.add(field, Fixed[1])
|
|
317
661
|
end
|
|
318
662
|
end
|
|
@@ -408,6 +752,8 @@ module SamplerExample
|
|
|
408
752
|
prompt.text = "Tab here, then type digits (and a leading -). Non-digits are ignored.\n" \
|
|
409
753
|
"Up/Down step the value by one; an empty field counts as 0."
|
|
410
754
|
field = Tuile::Component::IntegerField.new
|
|
755
|
+
# Set on the composed field, not on the TextField inside it.
|
|
756
|
+
field.placeholder = "1-65535"
|
|
411
757
|
status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
|
|
412
758
|
field.on_value_change = ->(value) { status.text = "value: #{value.inspect}" }
|
|
413
759
|
form do |f|
|
|
@@ -454,6 +800,218 @@ module SamplerExample
|
|
|
454
800
|
end
|
|
455
801
|
end
|
|
456
802
|
|
|
803
|
+
# DateField: several formats in, one format out. Type a date in any of the
|
|
804
|
+
# three spellings this pane accepts and Tab away — the field rewrites it in
|
|
805
|
+
# the *first* one, which is how the user sees that it understood them. Both
|
|
806
|
+
# halves of the settling rule are visible here too: nothing reddens while a
|
|
807
|
+
# date is being typed, and a buffer that never parsed reddens on the way
|
|
808
|
+
# out. The echo row is deliberately driven by `on_value_change` alone, so
|
|
809
|
+
# garbage leaves it stale: bad_input? is a *pull*, and the button is the "a
|
|
810
|
+
# form asks once, at the click" gesture that consults it (it also gives Tab
|
|
811
|
+
# somewhere to go, which is what makes both behaviours visible at all).
|
|
812
|
+
def build_date_field
|
|
813
|
+
prompt = Tuile::Component::Label.new
|
|
814
|
+
prompt.text = "Tab here, then type 4.9.2026 — or 09/04/2026, or 2026-09-04.\n" \
|
|
815
|
+
"Tab away or press Enter and a buffer that parses is rewritten as yyyy-mm-dd;\n" \
|
|
816
|
+
"one that doesn't is left as you typed it, and *then* the well goes red.\n" \
|
|
817
|
+
"Up/Down step a day, and an empty field steps to today."
|
|
818
|
+
field = Tuile::Component::DateField.new
|
|
819
|
+
field.formats = ["%Y-%m-%d", "%d.%m.%Y", "%m/%d/%Y"]
|
|
820
|
+
status = Tuile::Component::Label.new
|
|
821
|
+
# to_s, not inspect: Date#inspect spells out the Julian day and the
|
|
822
|
+
# calendar reform, which is noise next to the one fact this row is for.
|
|
823
|
+
report = -> { status.text = "value: #{field.value&.to_s || "nil"} bad_input?: #{field.bad_input?}" }
|
|
824
|
+
report.call
|
|
825
|
+
field.on_value_change = ->(_value) { report.call }
|
|
826
|
+
ask = Tuile::Component::Button.new("Ask again") { report.call }
|
|
827
|
+
form do |f|
|
|
828
|
+
f.add(prompt, Fixed[4])
|
|
829
|
+
f.add(field, Fixed[1], cross: Fixed[20])
|
|
830
|
+
f.add(status, Fixed[1])
|
|
831
|
+
f.add(ask, Fixed[1], cross: Fixed[button_width(ask)])
|
|
832
|
+
end
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
# TimeField: one knob for two things, and the reason it is one. Both fields
|
|
836
|
+
# below sit under a *Finnish* spelling that this pane installs on the
|
|
837
|
+
# screen, because the whole point is invisible under ISO: switching
|
|
838
|
+
# precision must not cost you the locale's separator. So the left field
|
|
839
|
+
# shows 13.45 and the right 13.45.00 — a dot either way, which a per-field
|
|
840
|
+
# format override could not have managed.
|
|
841
|
+
def build_time_field
|
|
842
|
+
# Assigned here rather than detected, so the pane demonstrates the same
|
|
843
|
+
# thing on an American box as on a Finnish one.
|
|
844
|
+
Tuile::Screen.instance.locale = Tuile::Locale::ISO.with(time_formats: ["%H.%M.%S", "%H:%M:%S"])
|
|
845
|
+
prompt = Tuile::Component::Label.new
|
|
846
|
+
prompt.text = "The session spells times the Finnish way (13.45). Tab into either field.\n" \
|
|
847
|
+
"Up/Down step a minute on the left, a second on the right (PageUp/PageDown an hour in both) —\n" \
|
|
848
|
+
"one knob, because precision is a property of the format the buffer holds.\n" \
|
|
849
|
+
"Note the dot survives the switch: that is what a per-field format would cost."
|
|
850
|
+
minutes = time_field_with(step: 60)
|
|
851
|
+
seconds = time_field_with(step: 1)
|
|
852
|
+
status = Tuile::Component::Label.new
|
|
853
|
+
report = lambda do
|
|
854
|
+
status.text = "minutes: #{minutes.value&.strftime("%H:%M:%S") || "nil"} (#{minutes.formats.first}) " \
|
|
855
|
+
"seconds: #{seconds.value&.strftime("%H:%M:%S") || "nil"} (#{seconds.formats.first})"
|
|
856
|
+
end
|
|
857
|
+
report.call
|
|
858
|
+
[minutes, seconds].each { _1.on_value_change = ->(_value) { report.call } }
|
|
859
|
+
form do |f|
|
|
860
|
+
f.add(prompt, Fixed[4])
|
|
861
|
+
f.add(labelled("Minute stride", minutes, field_width: 12), Fixed[1], cross: Fixed[30])
|
|
862
|
+
f.add(labelled("Second stride", seconds, field_width: 12), Fixed[1], cross: Fixed[30])
|
|
863
|
+
f.add(status, Fixed[1])
|
|
864
|
+
end
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
# @param step [Integer]
|
|
868
|
+
# @return [Tuile::Component::TimeField]
|
|
869
|
+
def time_field_with(step:)
|
|
870
|
+
Tuile::Component::TimeField.new.tap do |field|
|
|
871
|
+
field.step = step
|
|
872
|
+
field.set_to(13, 45)
|
|
873
|
+
end
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
# DateTimeField: the ink rule, which neither half can show alone. Leave the
|
|
877
|
+
# time empty and the *whole* widget reddens — half-filled is a fault no
|
|
878
|
+
# single half committed — where garbage in the date half reddens that half
|
|
879
|
+
# by itself. Both only on the way out, which is why pressing Enter first
|
|
880
|
+
# visibly changes nothing.
|
|
881
|
+
#
|
|
882
|
+
# The echo row carries the value and Save the verdict, the division
|
|
883
|
+
# `D_bad_input` draws: the notice is a push, fired when a half commits;
|
|
884
|
+
# `bad_input?` is a pull, read at the click. The Saved alert spells the
|
|
885
|
+
# value out with the `+00:00` the halves cannot fill in — `D_time_field`'s
|
|
886
|
+
# epoch cost, visible rather than hidden.
|
|
887
|
+
def build_date_time_field
|
|
888
|
+
prompt = Tuile::Component::Label.new
|
|
889
|
+
prompt.text = "Tab here, type 2026-09-14, leave the time empty — then press Enter: nothing reddens.\n" \
|
|
890
|
+
"Tab away and both halves redden at once: half-filled is the composite's own fault.\n" \
|
|
891
|
+
"Now type 2026-99-99 in the date half instead — it reddens alone, the time half stays clean.\n" \
|
|
892
|
+
"Save asks bad_input? at the click and names whichever fault it found."
|
|
893
|
+
field = Tuile::Component::DateTimeField.new
|
|
894
|
+
status = Tuile::Component::Label.new
|
|
895
|
+
# strftime, not inspect: DateTime#inspect spells out the Julian day, noise
|
|
896
|
+
# next to the one fact this row carries.
|
|
897
|
+
report = -> { status.text = "value: #{field.value&.strftime("%Y-%m-%d %H:%M") || "nil"}" }
|
|
898
|
+
report.call
|
|
899
|
+
field.on_value_change = ->(_value) { report.call }
|
|
900
|
+
save = Tuile::Component::Button.new("Save") { save_form("Starts at" => field) }
|
|
901
|
+
form do |f|
|
|
902
|
+
f.add(prompt, Fixed[4])
|
|
903
|
+
f.add(labelled("Starts at", field, field_width: 20), Fixed[1], cross: Fixed[36])
|
|
904
|
+
f.add(status, Fixed[1])
|
|
905
|
+
f.add(save, Fixed[1], cross: Fixed[button_width(save)])
|
|
906
|
+
end
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
# HasBadInput: the one fact on_value_change cannot carry. Type a lone "-"
|
|
910
|
+
# and watch the echo row stay silent — the value was nil before and is nil
|
|
911
|
+
# after, so there is no diff to report — while Save, which asks bad_input?
|
|
912
|
+
# instead of empty?, refuses and names the field.
|
|
913
|
+
def build_bad_input
|
|
914
|
+
prompt = Tuile::Component::Label.new
|
|
915
|
+
prompt.text = "Type a lone '-' into Amount (or '1e' into Rate), then press Save.\n" \
|
|
916
|
+
"Both read value: nil and empty?: true, exactly like an untouched field —\n" \
|
|
917
|
+
"which is why a form must ask bad_input? before it saves a nil over your work."
|
|
918
|
+
# The one pane that tags its widgets with `id`, so the sampler's own spec
|
|
919
|
+
# can drive them through Tuile::Testing.get by name.
|
|
920
|
+
amount = Tuile::Component::IntegerField.new.tap { _1.id = :amount }
|
|
921
|
+
rate = Tuile::Component::FloatField.new.tap { _1.id = :rate }
|
|
922
|
+
echo = Tuile::Component::Label.new.tap { _1.text = "on_value_change: (nothing yet)" }
|
|
923
|
+
amount.on_value_change = ->(v) { echo.text = "on_value_change: amount = #{v.inspect}" }
|
|
924
|
+
rate.on_value_change = ->(v) { echo.text = "on_value_change: rate = #{v.inspect}" }
|
|
925
|
+
save = Tuile::Component::Button.new("Save") { save_form("Amount" => amount, "Rate" => rate) }
|
|
926
|
+
save.id = :save
|
|
927
|
+
rows = group do |g|
|
|
928
|
+
g.add(labelled("Amount", amount), Fixed[1])
|
|
929
|
+
g.add(labelled("Rate", rate), Fixed[1])
|
|
930
|
+
end
|
|
931
|
+
form do |f|
|
|
932
|
+
f.add(prompt, Fixed[3])
|
|
933
|
+
f.add(rows, Fixed[2])
|
|
934
|
+
f.add(echo, Fixed[1])
|
|
935
|
+
f.add(save, Fixed[1], cross: Fixed[button_width(save)])
|
|
936
|
+
end
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
# The login form of `D_has_validation`: the click writes a verdict onto
|
|
940
|
+
# each field and the field paints itself red, while *this* pane owns the
|
|
941
|
+
# cells the messages go in — one Label per field, refilled from
|
|
942
|
+
# `on_error_message_change`. Note the handler sets *or clears* on every
|
|
943
|
+
# pass, which is the whole writer discipline; and that no field computes
|
|
944
|
+
# anything, so nothing here fights the fields' own `bad_input?`.
|
|
945
|
+
def build_validation
|
|
946
|
+
prompt = Tuile::Component::Label.new
|
|
947
|
+
prompt.text = "Press Log in empty, then with a 2-letter username: the message lands in\n" \
|
|
948
|
+
"this pane's cells (via a listener) and the field's well turns red.\n" \
|
|
949
|
+
"Tab between them — an invalid field still shows which one has focus."
|
|
950
|
+
username = Tuile::Component::TextField.new
|
|
951
|
+
password = Tuile::Component::PasswordField.new
|
|
952
|
+
fields = { "Username" => username, "Password" => password }
|
|
953
|
+
rows = group do |g|
|
|
954
|
+
fields.each { |caption, field| g.add(validated_row(caption, field), Fixed[1]) }
|
|
955
|
+
end
|
|
956
|
+
login = Tuile::Component::Button.new("Log in") { validate_login(fields) }
|
|
957
|
+
form do |f|
|
|
958
|
+
f.add(prompt, Fixed[3])
|
|
959
|
+
f.add(rows, Fixed[2])
|
|
960
|
+
f.add(login, Fixed[1], cross: Fixed[button_width(login)])
|
|
961
|
+
end
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
# @param caption [String]
|
|
965
|
+
# @param field [Tuile::Component] a field carrying {Tuile::Component::HasValidation}.
|
|
966
|
+
# @return [Tuile::Component] a row of caption, field, and the error Label
|
|
967
|
+
# the field's listener keeps current.
|
|
968
|
+
def validated_row(caption, field)
|
|
969
|
+
error = Tuile::Component::Label.new
|
|
970
|
+
field.on_error_message_change = ->(msg) { error.text = msg || Tuile::StyledString::EMPTY }
|
|
971
|
+
row do |r|
|
|
972
|
+
r.add(Tuile::Component::Label.new(caption), Fixed[14])
|
|
973
|
+
r.add(field, Fixed[22])
|
|
974
|
+
r.add(error, Expand[1])
|
|
975
|
+
end
|
|
976
|
+
end
|
|
977
|
+
|
|
978
|
+
# Two rules, so the pane shows both halves: "required" has nothing to tint,
|
|
979
|
+
# "too short" does.
|
|
980
|
+
# @param fields [Hash{String => Tuile::Component}] caption => field.
|
|
981
|
+
def validate_login(fields)
|
|
982
|
+
fields.each { |caption, field| field.error_message = login_problem(caption, field) }
|
|
983
|
+
return if fields.each_value.any?(&:error_message)
|
|
984
|
+
|
|
985
|
+
Tuile::Component::ConfirmWindow.alert("Logged in", "Welcome, #{fields["Username"].value}.")
|
|
986
|
+
end
|
|
987
|
+
|
|
988
|
+
# @param caption [String]
|
|
989
|
+
# @param field [Tuile::Component]
|
|
990
|
+
# @return [String, nil] the verdict, or `nil` — the *clear* half of
|
|
991
|
+
# set-or-clear-on-every-pass, without which a fixed field stays red.
|
|
992
|
+
def login_problem(caption, field)
|
|
993
|
+
return "#{caption} is required" if field.empty?
|
|
994
|
+
return "at least 3 characters" if field.value.length < 3
|
|
995
|
+
|
|
996
|
+
nil
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
# The Save gate of `D_bad_input`: asked once, at the click, so the
|
|
1000
|
+
# continuously-true fact ("2" is a bad date on the way to "2026") is only
|
|
1001
|
+
# ever read in a settled state.
|
|
1002
|
+
# @param fields [Hash{String => Tuile::Component}] caption => field.
|
|
1003
|
+
def save_form(fields)
|
|
1004
|
+
bad = fields.filter_map do |caption, field|
|
|
1005
|
+
"#{caption}: #{field.bad_input_message}" if field.respond_to?(:bad_input?) && field.bad_input?
|
|
1006
|
+
end
|
|
1007
|
+
if bad.empty?
|
|
1008
|
+
values = fields.map { |caption, field| "#{caption}: #{field.value.inspect}" }
|
|
1009
|
+
Tuile::Component::ConfirmWindow.alert("Saved", values.join("\n"))
|
|
1010
|
+
else
|
|
1011
|
+
Tuile::Component::ConfirmWindow.alert("Cannot save", "#{bad.size} problem(s):\n#{bad.join("\n")}")
|
|
1012
|
+
end
|
|
1013
|
+
end
|
|
1014
|
+
|
|
457
1015
|
# @param value [BigDecimal, nil]
|
|
458
1016
|
# @return [String] the value tripled exactly, next to the same sum in Float.
|
|
459
1017
|
def triple_report(value)
|
|
@@ -495,19 +1053,17 @@ module SamplerExample
|
|
|
495
1053
|
# A ListDropdown driven from a TextArea — the same shape {ComboBox} and
|
|
496
1054
|
# {Select} use, but wired by app code onto a field that knows nothing about
|
|
497
1055
|
# it. Focus (and the caret) stays in the TextArea the whole time: an
|
|
498
|
-
# `on_change` listener refills the menu, and
|
|
499
|
-
# movement keys to `#move` and Enter to `#choose` while it is open.
|
|
500
|
-
# this is baked into TextArea.
|
|
1056
|
+
# `on_change` listener refills the menu, and {SlashCommandTextArea} hands
|
|
1057
|
+
# movement keys to `#move` and Enter to `#choose` while it is open.
|
|
501
1058
|
def build_slash_demo
|
|
502
1059
|
prompt = Tuile::Component::Label.new
|
|
503
1060
|
prompt.text = "A ListDropdown driven from a TextArea. Type a slash command\n" \
|
|
504
1061
|
"(try \"/\" or \"/s\"). The menu floats over the field without taking\n" \
|
|
505
1062
|
"focus: Down/Up move the selection, Enter accepts, ESC dismisses, and\n" \
|
|
506
1063
|
"ordinary typing keeps editing the field and refilters the menu."
|
|
507
|
-
area = Tuile::Component::TextArea.new
|
|
508
|
-
|
|
509
1064
|
overlay = Tuile::Component::ListDropdown.new
|
|
510
1065
|
@slash_overlay = overlay
|
|
1066
|
+
area = SlashCommandTextArea.new(overlay)
|
|
511
1067
|
|
|
512
1068
|
refill = lambda do
|
|
513
1069
|
matches = slash_matches(area)
|
|
@@ -524,20 +1080,6 @@ module SamplerExample
|
|
|
524
1080
|
|
|
525
1081
|
area.on_change = ->(_text) { refill.call }
|
|
526
1082
|
overlay.on_item_chosen = ->(_idx, item) { accept_slash_command(area, item.to_s) }
|
|
527
|
-
area.on_key = lambda do |key|
|
|
528
|
-
next false unless overlay.open?
|
|
529
|
-
next true if overlay.move(key) # Up/Down/PgUp/PgDn/^U/^D
|
|
530
|
-
|
|
531
|
-
case key
|
|
532
|
-
when Tuile::Keys::ENTER
|
|
533
|
-
overlay.choose
|
|
534
|
-
when Tuile::Keys::ESC
|
|
535
|
-
overlay.close
|
|
536
|
-
true
|
|
537
|
-
else
|
|
538
|
-
false
|
|
539
|
-
end
|
|
540
|
-
end
|
|
541
1083
|
|
|
542
1084
|
form do |f|
|
|
543
1085
|
f.add(prompt, Fixed[4])
|
|
@@ -668,6 +1210,42 @@ module SamplerExample
|
|
|
668
1210
|
end
|
|
669
1211
|
end
|
|
670
1212
|
|
|
1213
|
+
# `visible=` on a conditional form: the fields a checkbox above them
|
|
1214
|
+
# governs. The rows close up completely when they go — a hidden child costs
|
|
1215
|
+
# neither its slot nor the box's `spacing` gap, which is what separates it
|
|
1216
|
+
# from a `Fixed[0]` collapse — and come back with their constraints and
|
|
1217
|
+
# their typed text intact, since nothing was ever detached.
|
|
1218
|
+
def build_visibility
|
|
1219
|
+
prompt = Tuile::Component::Label.new
|
|
1220
|
+
prompt.text = "Tick 'Business customer' to reveal two more fields.\n" \
|
|
1221
|
+
"The rows close up with no double gap, Tab skips what is hidden,\n" \
|
|
1222
|
+
"and text typed into a field survives being hidden and shown."
|
|
1223
|
+
status = Tuile::Component::Label.new
|
|
1224
|
+
company = labelled("Company", Tuile::Component::TextField.new)
|
|
1225
|
+
vat = labelled("VAT id", Tuile::Component::TextField.new)
|
|
1226
|
+
conditional = [company, vat]
|
|
1227
|
+
business = Tuile::Component::Checkbox.new("Business customer")
|
|
1228
|
+
apply = lambda do
|
|
1229
|
+
conditional.each { _1.visible = business.checked? }
|
|
1230
|
+
status.text = "visible fields: #{business.checked? ? 4 : 2}"
|
|
1231
|
+
end
|
|
1232
|
+
business.on_value_change = ->(_) { apply.call }
|
|
1233
|
+
# The rows sit flush; the form keeps its blank row around the block.
|
|
1234
|
+
rows = group do |g|
|
|
1235
|
+
g.add(labelled("Name", Tuile::Component::TextField.new), Fixed[1])
|
|
1236
|
+
g.add(labelled("Email", Tuile::Component::TextField.new), Fixed[1])
|
|
1237
|
+
g.add(company, Fixed[1])
|
|
1238
|
+
g.add(vat, Fixed[1])
|
|
1239
|
+
end
|
|
1240
|
+
apply.call
|
|
1241
|
+
form do |f|
|
|
1242
|
+
f.add(prompt, Fixed[3])
|
|
1243
|
+
f.add(business, Fixed[1])
|
|
1244
|
+
f.add(rows, Fixed[4])
|
|
1245
|
+
f.add(status, Fixed[1])
|
|
1246
|
+
end
|
|
1247
|
+
end
|
|
1248
|
+
|
|
671
1249
|
# One filterable log level: the item type a CheckboxGroup holds. Its `value`
|
|
672
1250
|
# is a Set of *these*, never of the labels shown on the rows.
|
|
673
1251
|
LogLevel = Data.define(:label, :tag, :color)
|
|
@@ -705,7 +1283,7 @@ module SamplerExample
|
|
|
705
1283
|
# *items* — LogLevel objects, not their labels — so the filter below is plain
|
|
706
1284
|
# set membership, no lookup table. Rows come from `item_label`, which may
|
|
707
1285
|
# return styled text (these colors are inherent to the data, not theme
|
|
708
|
-
# accents, so they need no
|
|
1286
|
+
# accents, so they need no handle_theme_changed hook).
|
|
709
1287
|
def build_checkbox_group
|
|
710
1288
|
prompt = Tuile::Component::Label.new
|
|
711
1289
|
# Kept under 48 columns a line, so an 80-column terminal shows it whole.
|
|
@@ -774,7 +1352,7 @@ module SamplerExample
|
|
|
774
1352
|
SAMPLE_FILES = [
|
|
775
1353
|
SampleFile.new("AGENTS.md", 31_402, "2026-07-30"),
|
|
776
1354
|
SampleFile.new("CHANGELOG.md", 4118, "2026-07-05"),
|
|
777
|
-
SampleFile.new("
|
|
1355
|
+
SampleFile.new("design/decisions.md", 48_990, "2026-07-31"),
|
|
778
1356
|
SampleFile.new("Gemfile", 312, "2026-06-18"),
|
|
779
1357
|
SampleFile.new("README.md", 9674, "2026-07-12"),
|
|
780
1358
|
SampleFile.new("Rakefile", 2118, "2026-06-18"),
|
|
@@ -808,7 +1386,7 @@ module SamplerExample
|
|
|
808
1386
|
short_size = ->(bytes) { bytes < 1024 ? bytes.to_s : "#{(bytes / 1024.0).round}k" }
|
|
809
1387
|
|
|
810
1388
|
update_status = lambda do
|
|
811
|
-
under_cursor = SORT_ORDERS[group.
|
|
1389
|
+
under_cursor = SORT_ORDERS[group.list.cursor.position]
|
|
812
1390
|
status.text = "value: #{group.value.label} — cursor: #{under_cursor&.label}"
|
|
813
1391
|
end
|
|
814
1392
|
resort = lambda do
|
|
@@ -819,9 +1397,9 @@ module SamplerExample
|
|
|
819
1397
|
end
|
|
820
1398
|
resort.call
|
|
821
1399
|
group.on_value_change = ->(_order) { resort.call }
|
|
822
|
-
# `
|
|
1400
|
+
# `list` is the composed List, which is where the cursor lives.
|
|
823
1401
|
# Watching it is what makes the chrome/value split visible above.
|
|
824
|
-
group.
|
|
1402
|
+
group.list.on_cursor_changed = ->(_idx, _line) { update_status.call }
|
|
825
1403
|
|
|
826
1404
|
# Side-by-side body on a rect-callback {Panel}, as in the CheckboxGroup
|
|
827
1405
|
# demo — the sidebar width is a capped proportion, not a constraint.
|
|
@@ -965,7 +1543,7 @@ module SamplerExample
|
|
|
965
1543
|
# the tint flows down to every descendant without its own background — the
|
|
966
1544
|
# label and the list — while the input widgets (the combo, the field) keep
|
|
967
1545
|
# their own well. Theme::Ref picks re-resolve on a scheme flip with no hook;
|
|
968
|
-
# the hard-coded Colors are fixed by design, so no
|
|
1546
|
+
# the hard-coded Colors are fixed by design, so no handle_theme_changed here.
|
|
969
1547
|
outer = nil
|
|
970
1548
|
derived = terminal_tint_choice
|
|
971
1549
|
combo = Tuile::Component::ComboBox.new(items: bg_choices(derived))
|
|
@@ -1303,9 +1881,14 @@ module SamplerExample
|
|
|
1303
1881
|
"PickerWindow asks the user to pick one option by a single keystroke.",
|
|
1304
1882
|
"Open PickerWindow"
|
|
1305
1883
|
) do
|
|
1884
|
+
# Captions paint in the terminal's own foreground — the picker
|
|
1885
|
+
# recommends no color of its own. Styling one is the app's call, and
|
|
1886
|
+
# per option: a caption may be a String, an ANSI-coded String (what
|
|
1887
|
+
# `theme.fg` hands back) or a StyledString.
|
|
1306
1888
|
Tuile::Component::PickerWindow.open(
|
|
1307
1889
|
"Pick a fruit",
|
|
1308
|
-
[%w[a Apple], %w[b Banana],
|
|
1890
|
+
[%w[a Apple], %w[b Banana],
|
|
1891
|
+
["c", "Cherry #{screen.theme.fg(:hint, "(in season)")}"]]
|
|
1309
1892
|
) { |key| Tuile.logger.info("Picked: #{key}") }
|
|
1310
1893
|
end
|
|
1311
1894
|
end
|
|
@@ -1338,6 +1921,31 @@ module SamplerExample
|
|
|
1338
1921
|
end
|
|
1339
1922
|
end
|
|
1340
1923
|
|
|
1924
|
+
def build_mouse_demo
|
|
1925
|
+
intro = Tuile::Component::Label.new
|
|
1926
|
+
intro.text = "Drag on the canvas to draw X; right-drag erases; just hovering leaves a dim\n" \
|
|
1927
|
+
"trail. Arrows move the caret, space or x strokes it, Delete lifts it, c clears.\n" \
|
|
1928
|
+
"Discrete events go to the log; the row below is the live pointer."
|
|
1929
|
+
canvas = Canvas.new
|
|
1930
|
+
pointer = Tuile::Component::Label.new
|
|
1931
|
+
pointer.text = "pointer: (move over the canvas)"
|
|
1932
|
+
log = Tuile::Component::LogWindow.new("Events")
|
|
1933
|
+
canvas.on_event = ->(line) { log.log(line) }
|
|
1934
|
+
canvas.on_move = lambda do |event, count|
|
|
1935
|
+
kind = event.is_a?(Tuile::Mouse::DragEvent) ? "drag" : "move"
|
|
1936
|
+
pointer.text = "pointer: #{event.x},#{event.y} (#{kind}, #{count} reported so far)"
|
|
1937
|
+
end
|
|
1938
|
+
surface = row do |r|
|
|
1939
|
+
r.add(Tuile::Component::Window.new("Canvas").tap { _1.content = canvas }, Percent[55])
|
|
1940
|
+
r.add(log, Expand[1])
|
|
1941
|
+
end
|
|
1942
|
+
form do |f|
|
|
1943
|
+
f.add(intro, Fixed[3])
|
|
1944
|
+
f.add(surface, Expand[1])
|
|
1945
|
+
f.add(pointer, Fixed[1])
|
|
1946
|
+
end
|
|
1947
|
+
end
|
|
1948
|
+
|
|
1341
1949
|
# --- Helpers -----------------------------------------------------------
|
|
1342
1950
|
|
|
1343
1951
|
def panel(*children, &layout_block)
|
|
@@ -1438,13 +2046,19 @@ end
|
|
|
1438
2046
|
# component tree without spinning up the real event loop.
|
|
1439
2047
|
if $PROGRAM_NAME == __FILE__
|
|
1440
2048
|
screen = Tuile::Screen.new
|
|
2049
|
+
screen.theme_def = SamplerExample::APP_THEME
|
|
1441
2050
|
sampler = SamplerExample::Sampler.new
|
|
1442
2051
|
screen.content = sampler
|
|
1443
2052
|
screen.on_focus_changed = -> { sampler.refresh_status }
|
|
1444
2053
|
sampler.refresh_status
|
|
1445
2054
|
sampler.menu_bar.focus
|
|
1446
2055
|
begin
|
|
1447
|
-
|
|
2056
|
+
# `:hover` for the whole app, because the level is set once here and is
|
|
2057
|
+
# all-or-nothing: the Mouse demo's trail and enter/exit lines arrive at no
|
|
2058
|
+
# lower one. The upgrade from the default `:clicks` costs the ~84 motion
|
|
2059
|
+
# reports a second (`R_mouse_reporting`) — not select-to-copy, which mode
|
|
2060
|
+
# 1000 had already taken.
|
|
2061
|
+
screen.run_event_loop(capture_mouse: :hover)
|
|
1448
2062
|
ensure
|
|
1449
2063
|
screen.close
|
|
1450
2064
|
end
|