tuile 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +121 -80
- data/README.md +28 -12
- data/book/04-event-loop.md +12 -12
- data/book/05-focus.md +95 -26
- data/book/06-theming.md +58 -26
- data/book/07-components.md +61 -6
- data/book/08-testing.md +24 -22
- data/book/10-locale.md +2 -2
- data/book/README.md +6 -5
- data/examples/file_commander.rb +14 -5
- data/examples/hello_world.rb +17 -4
- data/examples/sampler.rb +392 -18
- data/lib/tuile/component/abstract_string_field.rb +16 -18
- data/lib/tuile/component/abstract_wrapping_field.rb +47 -11
- data/lib/tuile/component/button.rb +8 -8
- data/lib/tuile/component/checkbox.rb +9 -9
- data/lib/tuile/component/checkbox_group.rb +6 -5
- data/lib/tuile/component/combo_box.rb +50 -35
- data/lib/tuile/component/confirm_window.rb +7 -5
- data/lib/tuile/component/date_field.rb +28 -3
- data/lib/tuile/component/date_time_field.rb +275 -0
- data/lib/tuile/component/has_bad_input.rb +2 -2
- data/lib/tuile/component/has_content.rb +3 -3
- data/lib/tuile/component/has_placeholder.rb +1 -1
- data/lib/tuile/component/has_validation.rb +2 -2
- data/lib/tuile/component/has_value.rb +1 -1
- data/lib/tuile/component/label.rb +1 -1
- data/lib/tuile/component/layout/box.rb +4 -1
- data/lib/tuile/component/layout.rb +3 -3
- data/lib/tuile/component/list.rb +42 -32
- data/lib/tuile/component/list_dropdown.rb +3 -3
- data/lib/tuile/component/menu_bar/cascade.rb +5 -5
- data/lib/tuile/component/menu_bar.rb +18 -18
- data/lib/tuile/component/notification.rb +32 -18
- data/lib/tuile/component/overlay.rb +9 -8
- data/lib/tuile/component/picker_window.rb +27 -8
- data/lib/tuile/component/popup.rb +2 -2
- data/lib/tuile/component/progress_bar.rb +10 -4
- data/lib/tuile/component/radio_group.rb +6 -5
- data/lib/tuile/component/select.rb +11 -12
- data/lib/tuile/component/slot.rb +3 -3
- data/lib/tuile/component/tab_sheet.rb +6 -6
- data/lib/tuile/component/tabs.rb +11 -11
- data/lib/tuile/component/text_area.rb +12 -10
- data/lib/tuile/component/text_field.rb +14 -12
- data/lib/tuile/component/text_view.rb +15 -11
- data/lib/tuile/component/time_field.rb +29 -4
- data/lib/tuile/component.rb +201 -93
- data/lib/tuile/event_queue.rb +4 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +84 -2
- data/lib/tuile/mouse/router.rb +217 -0
- data/lib/tuile/mouse.rb +177 -0
- data/lib/tuile/screen.rb +98 -61
- data/lib/tuile/screen_pane.rb +41 -36
- data/lib/tuile/styled_string.rb +5 -5
- data/lib/tuile/testing.rb +8 -8
- data/lib/tuile/theme.rb +22 -34
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +1 -1
- data/sig/tuile.rbs +1211 -427
- metadata +4 -16
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -8562
- data/TERMINOLOGY.md +0 -85
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/binder.md +0 -177
- data/ideas/composite-field.md +0 -77
- data/ideas/focus-accent.md +0 -116
- data/ideas/form-layout.md +0 -151
- data/ideas/hover/probe.rb +0 -241
- data/ideas/hover/probe_spec.rb +0 -82
- data/ideas/hover.md +0 -909
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -144
- data/ideas/per-component-buffers.md +0 -55
- data/lib/tuile/mouse_event.rb +0 -68
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)
|
|
@@ -115,8 +129,8 @@ module SamplerExample
|
|
|
115
129
|
# a newline with no menu up — stays the TextArea's own.
|
|
116
130
|
#
|
|
117
131
|
# Subclassing *is* the seam for this. A component receives keys through
|
|
118
|
-
# `handle_key
|
|
119
|
-
# `handle_text_input_key
|
|
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
|
|
120
134
|
# and stacks. None of this is baked into TextArea.
|
|
121
135
|
class SlashCommandTextArea < Tuile::Component::TextArea
|
|
122
136
|
# @param overlay [Tuile::Component::ListDropdown] the menu to steer.
|
|
@@ -127,7 +141,7 @@ module SamplerExample
|
|
|
127
141
|
|
|
128
142
|
protected
|
|
129
143
|
|
|
130
|
-
def handle_text_input_key(key)
|
|
144
|
+
def handle_text_input_key?(key)
|
|
131
145
|
return super unless @overlay.open?
|
|
132
146
|
return true if @overlay.move(key) # Up/Down/PgUp/PgDn/^U/^D
|
|
133
147
|
|
|
@@ -140,6 +154,291 @@ module SamplerExample
|
|
|
140
154
|
end
|
|
141
155
|
end
|
|
142
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
|
+
|
|
143
442
|
# Top-level sampler component: a shell row across the top — a
|
|
144
443
|
# {Tuile::Component::MenuBar} of the demos, grouped, and a
|
|
145
444
|
# {Tuile::Component::ComboBox} jump box at its right end — over one demo
|
|
@@ -172,7 +471,8 @@ module SamplerExample
|
|
|
172
471
|
def refresh_status
|
|
173
472
|
focused = screen.focused
|
|
174
473
|
name = focused ? focused.class.name.sub("Tuile::Component::", "") : "(none)"
|
|
175
|
-
|
|
474
|
+
t = screen.theme
|
|
475
|
+
@status.text = "q #{t.fg(:hint, "quit")} ⇥ #{t.fg(:hint, name)}"
|
|
176
476
|
end
|
|
177
477
|
|
|
178
478
|
# Chrome for a demo pane: a blank row top and bottom, two columns either
|
|
@@ -208,9 +508,10 @@ module SamplerExample
|
|
|
208
508
|
# as a live index of the catalogue and any drift between the two is visible.
|
|
209
509
|
#
|
|
210
510
|
# Mnemonics are *hand-picked*: {Tuile::Component::MenuBar#add_item} raises on
|
|
211
|
-
# a duplicate among siblings, and
|
|
511
|
+
# a duplicate among siblings, and six leaves therefore answer to a letter
|
|
212
512
|
# other than their initial (Past`e`, Checkbox`G`roup, C`o`mboBox,
|
|
213
|
-
# 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
|
|
214
515
|
# `q`: quit is the unhandled-key fallback, so a `q` on the live level would
|
|
215
516
|
# swallow it while the bar has focus.
|
|
216
517
|
MENUS = [
|
|
@@ -233,6 +534,7 @@ module SamplerExample
|
|
|
233
534
|
Entry.new("BigDecimalField", :build_big_decimal_field, "b"),
|
|
234
535
|
Entry.new("DateField", :build_date_field, "d"),
|
|
235
536
|
Entry.new("TimeField", :build_time_field, "t"),
|
|
537
|
+
Entry.new("DateTimeField", :build_date_time_field, "m"),
|
|
236
538
|
Entry.new("Bad input", :build_bad_input, "a"),
|
|
237
539
|
Entry.new("Validation", :build_validation, "v")
|
|
238
540
|
]),
|
|
@@ -246,7 +548,7 @@ module SamplerExample
|
|
|
246
548
|
])
|
|
247
549
|
]),
|
|
248
550
|
# One entry, so it is the item and not a menu — a top-level leaf on the
|
|
249
|
-
# strip is a button
|
|
551
|
+
# strip is a button rather than a drop-down.
|
|
250
552
|
Entry.new("Button", :build_buttons, "b"),
|
|
251
553
|
Menu.new("Overlay", "o", [
|
|
252
554
|
Entry.new("Popup", :build_popup_launcher, "p"),
|
|
@@ -264,7 +566,10 @@ module SamplerExample
|
|
|
264
566
|
Entry.new("Background", :build_background, "b"),
|
|
265
567
|
Entry.new("Visibility", :build_visibility, "v"),
|
|
266
568
|
Entry.new("Focus & Tab", :build_focus_demo, "f")
|
|
267
|
-
])
|
|
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")
|
|
268
573
|
].freeze
|
|
269
574
|
|
|
270
575
|
# Every {Entry} in strip order — what the jump box offers.
|
|
@@ -568,6 +873,39 @@ module SamplerExample
|
|
|
568
873
|
end
|
|
569
874
|
end
|
|
570
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
|
+
|
|
571
909
|
# HasBadInput: the one fact on_value_change cannot carry. Type a lone "-"
|
|
572
910
|
# and watch the echo row stay silent — the value was nil before and is nil
|
|
573
911
|
# after, so there is no diff to report — while Save, which asks bad_input?
|
|
@@ -945,7 +1283,7 @@ module SamplerExample
|
|
|
945
1283
|
# *items* — LogLevel objects, not their labels — so the filter below is plain
|
|
946
1284
|
# set membership, no lookup table. Rows come from `item_label`, which may
|
|
947
1285
|
# return styled text (these colors are inherent to the data, not theme
|
|
948
|
-
# accents, so they need no
|
|
1286
|
+
# accents, so they need no handle_theme_changed hook).
|
|
949
1287
|
def build_checkbox_group
|
|
950
1288
|
prompt = Tuile::Component::Label.new
|
|
951
1289
|
# Kept under 48 columns a line, so an 80-column terminal shows it whole.
|
|
@@ -1014,7 +1352,7 @@ module SamplerExample
|
|
|
1014
1352
|
SAMPLE_FILES = [
|
|
1015
1353
|
SampleFile.new("AGENTS.md", 31_402, "2026-07-30"),
|
|
1016
1354
|
SampleFile.new("CHANGELOG.md", 4118, "2026-07-05"),
|
|
1017
|
-
SampleFile.new("
|
|
1355
|
+
SampleFile.new("design/decisions.md", 48_990, "2026-07-31"),
|
|
1018
1356
|
SampleFile.new("Gemfile", 312, "2026-06-18"),
|
|
1019
1357
|
SampleFile.new("README.md", 9674, "2026-07-12"),
|
|
1020
1358
|
SampleFile.new("Rakefile", 2118, "2026-06-18"),
|
|
@@ -1205,7 +1543,7 @@ module SamplerExample
|
|
|
1205
1543
|
# the tint flows down to every descendant without its own background — the
|
|
1206
1544
|
# label and the list — while the input widgets (the combo, the field) keep
|
|
1207
1545
|
# their own well. Theme::Ref picks re-resolve on a scheme flip with no hook;
|
|
1208
|
-
# 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.
|
|
1209
1547
|
outer = nil
|
|
1210
1548
|
derived = terminal_tint_choice
|
|
1211
1549
|
combo = Tuile::Component::ComboBox.new(items: bg_choices(derived))
|
|
@@ -1543,9 +1881,14 @@ module SamplerExample
|
|
|
1543
1881
|
"PickerWindow asks the user to pick one option by a single keystroke.",
|
|
1544
1882
|
"Open PickerWindow"
|
|
1545
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.
|
|
1546
1888
|
Tuile::Component::PickerWindow.open(
|
|
1547
1889
|
"Pick a fruit",
|
|
1548
|
-
[%w[a Apple], %w[b Banana],
|
|
1890
|
+
[%w[a Apple], %w[b Banana],
|
|
1891
|
+
["c", "Cherry #{screen.theme.fg(:hint, "(in season)")}"]]
|
|
1549
1892
|
) { |key| Tuile.logger.info("Picked: #{key}") }
|
|
1550
1893
|
end
|
|
1551
1894
|
end
|
|
@@ -1578,6 +1921,31 @@ module SamplerExample
|
|
|
1578
1921
|
end
|
|
1579
1922
|
end
|
|
1580
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
|
+
|
|
1581
1949
|
# --- Helpers -----------------------------------------------------------
|
|
1582
1950
|
|
|
1583
1951
|
def panel(*children, &layout_block)
|
|
@@ -1678,13 +2046,19 @@ end
|
|
|
1678
2046
|
# component tree without spinning up the real event loop.
|
|
1679
2047
|
if $PROGRAM_NAME == __FILE__
|
|
1680
2048
|
screen = Tuile::Screen.new
|
|
2049
|
+
screen.theme_def = SamplerExample::APP_THEME
|
|
1681
2050
|
sampler = SamplerExample::Sampler.new
|
|
1682
2051
|
screen.content = sampler
|
|
1683
2052
|
screen.on_focus_changed = -> { sampler.refresh_status }
|
|
1684
2053
|
sampler.refresh_status
|
|
1685
2054
|
sampler.menu_bar.focus
|
|
1686
2055
|
begin
|
|
1687
|
-
|
|
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)
|
|
1688
2062
|
ensure
|
|
1689
2063
|
screen.close
|
|
1690
2064
|
end
|
|
@@ -24,7 +24,7 @@ module Tuile
|
|
|
24
24
|
# f.text = "e\u{0301}x" # a decomposed e-acute then "x": 3 chars, 2 glyphs
|
|
25
25
|
# f.caret = 1 # into the middle of the e-acute …
|
|
26
26
|
# f.caret # => 2, its end — where the caret already drew
|
|
27
|
-
# f.handle_key(Keys::BACKSPACE)
|
|
27
|
+
# f.handle_key?(Keys::BACKSPACE)
|
|
28
28
|
# f.text # => "x": the whole glyph went, not its accent
|
|
29
29
|
#
|
|
30
30
|
# Insertion stays character-native, so `String#insert` merges a typed
|
|
@@ -34,11 +34,11 @@ module Tuile
|
|
|
34
34
|
# Subclasses implement the layout-specific pieces ({#cursor_position},
|
|
35
35
|
# {#repaint}) and add their own keys (HOME/END, ENTER, UP/DOWN,
|
|
36
36
|
# printable insertion) by overriding the protected
|
|
37
|
-
# {#handle_text_input_key} hook — `super` falls through to the common
|
|
37
|
+
# {#handle_text_input_key?} hook — `super` falls through to the common
|
|
38
38
|
# navigation handling.
|
|
39
39
|
#
|
|
40
40
|
# == Customizing a field is subclassing it, and there are two seams
|
|
41
|
-
# To change what **keys** do, override {#handle_text_input_key}; to
|
|
41
|
+
# To change what **keys** do, override {#handle_text_input_key?}; to
|
|
42
42
|
# constrain what the buffer may **hold**, override {#insert_text}, which
|
|
43
43
|
# every insertion runs through — typed, pasted, or the ENTER newline:
|
|
44
44
|
#
|
|
@@ -46,7 +46,7 @@ module Tuile
|
|
|
46
46
|
# protected
|
|
47
47
|
#
|
|
48
48
|
# # ENTER submits instead of falling through to the parent.
|
|
49
|
-
# def handle_text_input_key(key)
|
|
49
|
+
# def handle_text_input_key?(key)
|
|
50
50
|
# return super unless key == Keys::ENTER
|
|
51
51
|
#
|
|
52
52
|
# submit(text)
|
|
@@ -76,7 +76,7 @@ module Tuile
|
|
|
76
76
|
# which insertion does *not* pass through.
|
|
77
77
|
# - {#preprocess_paste} — sanitizer for {#handle_paste}, run before the
|
|
78
78
|
# clipboard reaches {#insert_text} ({TextField} keeps its first line).
|
|
79
|
-
# - {#
|
|
79
|
+
# - {#handle_text_mutated} / {#handle_caret_mutated} — post-mutation side
|
|
80
80
|
# effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
|
|
81
81
|
# keep the caret visible).
|
|
82
82
|
class AbstractStringField < Component
|
|
@@ -141,7 +141,7 @@ module Tuile
|
|
|
141
141
|
|
|
142
142
|
@text = +new_text
|
|
143
143
|
@caret = snap_to_cluster(@caret.clamp(0, @text.length))
|
|
144
|
-
|
|
144
|
+
handle_text_mutated
|
|
145
145
|
invalidate
|
|
146
146
|
@on_change&.call(@text)
|
|
147
147
|
on_value_change&.call(@text)
|
|
@@ -149,7 +149,7 @@ module Tuile
|
|
|
149
149
|
|
|
150
150
|
# Clamps to `0..text.length`, then snaps forward onto a grapheme-cluster
|
|
151
151
|
# boundary, so an index that fell inside a cluster reads back as that
|
|
152
|
-
# cluster's end. Fires the {#
|
|
152
|
+
# cluster's end. Fires the {#handle_caret_mutated} hook for subclasses (e.g.
|
|
153
153
|
# {TextArea} scrolls).
|
|
154
154
|
# @param new_caret [Integer]
|
|
155
155
|
def caret=(new_caret)
|
|
@@ -157,27 +157,25 @@ module Tuile
|
|
|
157
157
|
return if @caret == new_caret
|
|
158
158
|
|
|
159
159
|
@caret = new_caret
|
|
160
|
-
|
|
160
|
+
handle_caret_mutated
|
|
161
161
|
invalidate
|
|
162
162
|
end
|
|
163
163
|
|
|
164
|
-
# Handles a key, by delegating to the {#handle_text_input_key} hook a
|
|
165
|
-
# subclass overrides. Dispatch ({ScreenPane#handle_key}) only routes keys
|
|
164
|
+
# Handles a key, by delegating to the {#handle_text_input_key?} hook a
|
|
165
|
+
# subclass overrides. Dispatch ({ScreenPane#handle_key?}) only routes keys
|
|
166
166
|
# here when this input is on the focus chain, so there is no {#active?}
|
|
167
167
|
# gate.
|
|
168
168
|
# @param key [String]
|
|
169
169
|
# @return [Boolean]
|
|
170
|
-
def handle_key(key) = handle_text_input_key(key)
|
|
170
|
+
def handle_key?(key) = handle_text_input_key?(key)
|
|
171
171
|
|
|
172
172
|
# Inserts pasted text at the caret as **one** mutation, so {#on_change}
|
|
173
173
|
# fires once for the whole paste rather than once per character.
|
|
174
174
|
# {#preprocess_paste} filters it first.
|
|
175
175
|
# @param text [String]
|
|
176
|
-
# @return [
|
|
177
|
-
# one, and one its {#insert_text} rejects wholesale.
|
|
176
|
+
# @return [void]
|
|
178
177
|
def handle_paste(text)
|
|
179
178
|
insert_text(preprocess_paste(text))
|
|
180
|
-
true
|
|
181
179
|
end
|
|
182
180
|
|
|
183
181
|
protected
|
|
@@ -259,15 +257,15 @@ module Tuile
|
|
|
259
257
|
# {#on_change}. Default no-op. Subclasses use this to invalidate caches
|
|
260
258
|
# ({TextArea}'s wrap cache) and update derived state.
|
|
261
259
|
# @return [void]
|
|
262
|
-
def
|
|
260
|
+
def handle_text_mutated; end
|
|
263
261
|
|
|
264
262
|
# Hook called after {#caret} has been mutated, before invalidation.
|
|
265
263
|
# Default no-op. Subclasses use this to keep the caret visible
|
|
266
264
|
# ({TextArea}'s vertical scroll).
|
|
267
265
|
# @return [void]
|
|
268
|
-
def
|
|
266
|
+
def handle_caret_mutated; end
|
|
269
267
|
|
|
270
|
-
# Dispatch hook for {#handle_key}. Handles ESC and the editing keys that
|
|
268
|
+
# Dispatch hook for {#handle_key?}. Handles ESC and the editing keys that
|
|
271
269
|
# have identical semantics in single-line and multi-line inputs:
|
|
272
270
|
# LEFT/RIGHT arrows (one grapheme cluster per press, so a press always
|
|
273
271
|
# moves), CTRL+LEFT/CTRL+RIGHT for word jumps, and CTRL+W, which deletes
|
|
@@ -277,7 +275,7 @@ module Tuile
|
|
|
277
275
|
# `super` to fall back to the common handling.
|
|
278
276
|
# @param key [String]
|
|
279
277
|
# @return [Boolean] true if the key was handled.
|
|
280
|
-
def handle_text_input_key(key)
|
|
278
|
+
def handle_text_input_key?(key)
|
|
281
279
|
case key
|
|
282
280
|
when Keys::LEFT_ARROW then self.caret = cluster_boundary_before(@caret)
|
|
283
281
|
when Keys::RIGHT_ARROW then self.caret = cluster_boundary_after(@caret)
|