tuile 0.16.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 +108 -0
- data/README.md +21 -12
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +5 -4
- data/book/05-focus.md +12 -9
- data/book/06-theming.md +55 -17
- data/book/07-components.md +188 -40
- data/book/08-testing.md +115 -15
- data/book/10-locale.md +1 -1
- data/book/README.md +5 -5
- data/examples/file_commander.rb +38 -27
- data/examples/hello_world.rb +1 -1
- data/examples/sampler.rb +225 -169
- 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 +81 -80
- data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +19 -11
- data/lib/tuile/component/checkbox.rb +12 -10
- data/lib/tuile/component/checkbox_group.rb +11 -13
- data/lib/tuile/component/combo_box.rb +30 -40
- data/lib/tuile/component/confirm_window.rb +27 -22
- data/lib/tuile/component/date_field.rb +27 -20
- data/lib/tuile/component/date_time_field.rb +75 -31
- 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 +98 -27
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +5 -12
- data/lib/tuile/component/has_validation.rb +39 -13
- data/lib/tuile/component/has_value.rb +70 -16
- 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 -63
- data/lib/tuile/component/layout.rb +124 -10
- data/lib/tuile/component/list.rb +197 -94
- data/lib/tuile/component/list_dropdown.rb +148 -88
- data/lib/tuile/component/menu_bar/cascade.rb +97 -27
- data/lib/tuile/component/menu_bar.rb +84 -64
- data/lib/tuile/component/notification.rb +44 -31
- data/lib/tuile/component/overlay.rb +210 -52
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +15 -10
- data/lib/tuile/component/popup.rb +13 -24
- data/lib/tuile/component/progress_bar.rb +7 -7
- data/lib/tuile/component/radio_group.rb +10 -12
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +15 -31
- data/lib/tuile/component/slot.rb +1 -2
- data/lib/tuile/component/tab_sheet.rb +21 -28
- data/lib/tuile/component/tabs.rb +39 -24
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +21 -19
- data/lib/tuile/component/text_field.rb +55 -39
- data/lib/tuile/component/text_view.rb +143 -79
- data/lib/tuile/component/time_field.rb +26 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +481 -259
- 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 +14 -0
- data/lib/tuile/fake_screen.rb +41 -10
- 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 +51 -35
- data/lib/tuile/mouse.rb +96 -29
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +419 -84
- data/lib/tuile/screen_pane.rb +144 -31
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +139 -9
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +310 -36
- data/lib/tuile/theme.rb +170 -19
- 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 +4951 -1158
- metadata +16 -2
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
data/examples/sampler.rb
CHANGED
|
@@ -20,35 +20,33 @@ require "rainbow"
|
|
|
20
20
|
require "tuile"
|
|
21
21
|
|
|
22
22
|
module SamplerExample
|
|
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
|
+
|
|
23
27
|
# `hint` is the app's token, not Tuile's: the framework carries accents for
|
|
24
28
|
# the chrome *it* paints, and the status row below is the sampler's own
|
|
25
29
|
# (`D_status_bar` — Tuile draws none). Paired in a ThemeDef so it survives an
|
|
26
30
|
# OS appearance flip, where a bare `theme=` would be replaced. Both greys
|
|
27
31
|
# quantize to :bright_black on a 16-color terminal, so the description stays
|
|
28
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.
|
|
29
39
|
APP_THEME = Tuile::ThemeDef.new(
|
|
30
|
-
dark: Tuile::Theme::DARK.with(custom: {
|
|
31
|
-
|
|
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
|
+
})
|
|
32
48
|
)
|
|
33
49
|
|
|
34
|
-
# Sampler-local container: a {Tuile::Component::Layout::Absolute} that runs a
|
|
35
|
-
# caller-supplied block on `rect=` to position its children. Most demos are
|
|
36
|
-
# plain stacks and use the box layouts instead; this is what's left for the
|
|
37
|
-
# two that aren't — a sidebar whose width is `min(16, width / 3)`, which is a
|
|
38
|
-
# cap on a proportion and so outside {Tuile::Component::Layout::Box}'s
|
|
39
|
-
# Fixed/Percent/Expand vocabulary by design.
|
|
40
|
-
class Panel < Tuile::Component::Layout::Absolute
|
|
41
|
-
def initialize(&layout_block)
|
|
42
|
-
super()
|
|
43
|
-
@layout_block = layout_block
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def rect=(new_rect)
|
|
47
|
-
super
|
|
48
|
-
@layout_block&.call(rect) unless rect.empty?
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
|
|
52
50
|
# A {Tuile::Component::Layout::Vertical} that runs {#on_tick} on every frame
|
|
53
51
|
# while it is on screen. The ticker is started on attach and cancelled on
|
|
54
52
|
# detach, so selecting another demo — which detaches this pane — cannot leave
|
|
@@ -60,11 +58,16 @@ module SamplerExample
|
|
|
60
58
|
@fps = fps
|
|
61
59
|
end
|
|
62
60
|
|
|
63
|
-
#
|
|
64
|
-
|
|
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
|
|
65
68
|
|
|
66
69
|
def handle_attached
|
|
67
|
-
@ticker = screen.event_queue.tick_fps(@fps) {
|
|
70
|
+
@ticker = screen.event_queue.tick_fps(@fps) { on_tick.fire(TickEvent.new(source: self)) }
|
|
68
71
|
end
|
|
69
72
|
|
|
70
73
|
def handle_detached
|
|
@@ -84,13 +87,18 @@ module SamplerExample
|
|
|
84
87
|
@shortcut = shortcut
|
|
85
88
|
end
|
|
86
89
|
|
|
87
|
-
#
|
|
88
|
-
|
|
90
|
+
# What `on_shortcut` fires.
|
|
91
|
+
ShortcutEvent = Data.define(:source) { include Tuile::Event }
|
|
92
|
+
|
|
93
|
+
# @!method on_shortcut
|
|
94
|
+
# Fired when the claimed key arrives.
|
|
95
|
+
# @return [Tuile::Listeners]
|
|
96
|
+
listener :on_shortcut
|
|
89
97
|
|
|
90
98
|
def handle_key?(key)
|
|
91
99
|
return false unless key == @shortcut
|
|
92
100
|
|
|
93
|
-
|
|
101
|
+
on_shortcut.fire(ShortcutEvent.new(source: self))
|
|
94
102
|
true
|
|
95
103
|
end
|
|
96
104
|
end
|
|
@@ -99,16 +107,28 @@ module SamplerExample
|
|
|
99
107
|
# the chat-prompt shape, and the one that made a multi-line paste fire the
|
|
100
108
|
# submit once per pasted line before Tuile drove bracketed paste. It handles
|
|
101
109
|
# no paste of its own: pasted text never arrives as ENTER, so the inherited
|
|
102
|
-
# insert-at-caret is already the wanted behavior, and `
|
|
103
|
-
# 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.
|
|
104
112
|
class PromptTextArea < Tuile::Component::TextArea
|
|
105
|
-
#
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
109
129
|
|
|
110
130
|
def handle_paste(text)
|
|
111
|
-
|
|
131
|
+
on_paste_received.fire(PasteEvent.new(source: self, text: text))
|
|
112
132
|
super
|
|
113
133
|
end
|
|
114
134
|
|
|
@@ -117,8 +137,8 @@ module SamplerExample
|
|
|
117
137
|
def handle_text_input_key?(key)
|
|
118
138
|
return super unless key == Tuile::Keys::ENTER
|
|
119
139
|
|
|
120
|
-
|
|
121
|
-
self.
|
|
140
|
+
on_submit.fire(SubmitEvent.new(source: self, text: text))
|
|
141
|
+
self.value = ""
|
|
122
142
|
true
|
|
123
143
|
end
|
|
124
144
|
end
|
|
@@ -170,10 +190,10 @@ module SamplerExample
|
|
|
170
190
|
# unviewable with the pointer over it.
|
|
171
191
|
#
|
|
172
192
|
# canvas = Canvas.new
|
|
173
|
-
# canvas.
|
|
174
|
-
# canvas.on_move
|
|
193
|
+
# canvas.on_report { |e| log.log(e.line) }
|
|
194
|
+
# canvas.on_move { |e| label.text = "#{e.mouse.x},#{e.mouse.y} (#{e.moves})" }
|
|
175
195
|
#
|
|
176
|
-
# Two slots, because the traffic is two: {#
|
|
196
|
+
# Two slots, because the traffic is two: {#on_report} carries the discrete
|
|
177
197
|
# events to a log, {#on_move} the ~84-a-second moves to one replaced row.
|
|
178
198
|
# Feeding both to a log would drown enter/exit inside 12 ms.
|
|
179
199
|
#
|
|
@@ -187,7 +207,7 @@ module SamplerExample
|
|
|
187
207
|
# two-column trail cell would push every painted row past `rect.width`
|
|
188
208
|
# (`D_ambiguous_width`).
|
|
189
209
|
#
|
|
190
|
-
# It paints every cell of its rect itself, one {Tuile::
|
|
210
|
+
# It paints every cell of its rect itself, one {Tuile::Canvas#set_text}
|
|
191
211
|
# per run of like cells, and so skips `super` in {#repaint} — whose
|
|
192
212
|
# auto-clear blanks the whole rect, which would re-emit every cell this widget
|
|
193
213
|
# is about to paint over anyway. Repainting whole on every move still costs
|
|
@@ -206,12 +226,23 @@ module SamplerExample
|
|
|
206
226
|
# @return [Hash{Symbol => String, nil}]
|
|
207
227
|
DRAG_INK = { left: STROKE, right: nil }.freeze
|
|
208
228
|
|
|
209
|
-
#
|
|
210
|
-
|
|
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
|
|
211
239
|
|
|
212
|
-
#
|
|
213
|
-
#
|
|
214
|
-
|
|
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
|
|
215
246
|
|
|
216
247
|
# @return [Tuile::Point] the keyboard caret, in rect-local coordinates.
|
|
217
248
|
attr_reader :caret
|
|
@@ -228,40 +259,40 @@ module SamplerExample
|
|
|
228
259
|
@moves = 0
|
|
229
260
|
@drag_ink = nil
|
|
230
261
|
@drag_outside = false
|
|
262
|
+
bg.default_color = Tuile::ComponentBackground::INPUT_WELL
|
|
231
263
|
end
|
|
232
264
|
|
|
233
265
|
def focusable? = true
|
|
234
266
|
|
|
235
267
|
def tab_stop? = true
|
|
236
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.
|
|
237
271
|
# @return [Tuile::Point, nil]
|
|
238
272
|
def cursor_position
|
|
239
273
|
return nil if rect.empty?
|
|
240
274
|
|
|
241
|
-
|
|
275
|
+
@caret
|
|
242
276
|
end
|
|
243
277
|
|
|
244
|
-
# @return [Tuile::Color]
|
|
245
|
-
def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color
|
|
246
|
-
|
|
247
278
|
# Clamps the caret into the new rect, so a shrink cannot strand it — and
|
|
248
279
|
# with it the hardware cursor — outside what this widget paints.
|
|
249
|
-
# @param
|
|
250
|
-
def
|
|
280
|
+
# @param old_rect [Tuile::Rect]
|
|
281
|
+
def handle_rect_changed(old_rect)
|
|
251
282
|
super
|
|
252
|
-
@caret = Tuile::Point.new(@caret.x.clamp(0, [
|
|
253
|
-
@caret.y.clamp(0, [
|
|
283
|
+
@caret = Tuile::Point.new(@caret.x.clamp(0, [rect.width - 1, 0].max),
|
|
284
|
+
@caret.y.clamp(0, [rect.height - 1, 0].max))
|
|
254
285
|
end
|
|
255
286
|
|
|
256
287
|
# @return [void]
|
|
257
|
-
def repaint
|
|
288
|
+
def repaint(canvas)
|
|
258
289
|
return if rect.empty?
|
|
259
290
|
|
|
260
291
|
# The clear is what a self-painter opts out of; the cascade never is
|
|
261
292
|
# (`D_repaint_cascade`), leaf or not.
|
|
262
293
|
invalidate_children
|
|
263
294
|
trail_color = screen.theme[:hint]
|
|
264
|
-
rect.height.times { |row| draw_row(row, trail_color) }
|
|
295
|
+
rect.height.times { |row| draw_row(canvas, row, trail_color) }
|
|
265
296
|
end
|
|
266
297
|
|
|
267
298
|
# @param event [Tuile::Mouse::DownEvent]
|
|
@@ -349,11 +380,13 @@ module SamplerExample
|
|
|
349
380
|
private
|
|
350
381
|
|
|
351
382
|
# 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`).
|
|
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.
|
|
353
386
|
# @param row [Integer] rect-local row.
|
|
354
387
|
# @param trail_color [Tuile::Color]
|
|
355
388
|
# @return [void]
|
|
356
|
-
def draw_row(row, trail_color)
|
|
389
|
+
def draw_row(canvas, row, trail_color)
|
|
357
390
|
column = 0
|
|
358
391
|
while column < rect.width
|
|
359
392
|
glyph = @ink[Tuile::Point.new(column, row)]
|
|
@@ -361,21 +394,21 @@ module SamplerExample
|
|
|
361
394
|
run += 1 while column + run < rect.width && @ink[Tuile::Point.new(column + run, row)] == glyph
|
|
362
395
|
text = (glyph || " ") * run
|
|
363
396
|
styled = glyph == TRAIL ? Tuile::StyledString.styled(text, fg: trail_color) : Tuile::StyledString.plain(text)
|
|
364
|
-
|
|
397
|
+
canvas.set_text(column, row, styled)
|
|
365
398
|
column += run
|
|
366
399
|
end
|
|
367
400
|
end
|
|
368
401
|
|
|
369
|
-
# The event's cell
|
|
370
|
-
#
|
|
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.
|
|
371
406
|
# @param event [Tuile::Mouse::Event]
|
|
372
407
|
# @return [Tuile::Point, nil]
|
|
373
408
|
def cell_at(event)
|
|
374
|
-
|
|
375
|
-
row = event.y - rect.top
|
|
376
|
-
return nil unless (0...rect.width).cover?(column) && (0...rect.height).cover?(row)
|
|
409
|
+
return nil unless (0...rect.width).cover?(event.x) && (0...rect.height).cover?(event.y)
|
|
377
410
|
|
|
378
|
-
Tuile::Point.new(
|
|
411
|
+
Tuile::Point.new(event.x, event.y)
|
|
379
412
|
end
|
|
380
413
|
|
|
381
414
|
# Lays the dragged ink at `cell` and takes the caret with it, so a stroke
|
|
@@ -429,13 +462,13 @@ module SamplerExample
|
|
|
429
462
|
|
|
430
463
|
# @param line [String]
|
|
431
464
|
# @return [void]
|
|
432
|
-
def report(line) =
|
|
465
|
+
def report(line) = on_report.fire(ReportEvent.new(source: self, line: line))
|
|
433
466
|
|
|
434
467
|
# @param event [Tuile::Mouse::Event]
|
|
435
468
|
# @return [void]
|
|
436
469
|
def report_move(event)
|
|
437
470
|
@moves += 1
|
|
438
|
-
|
|
471
|
+
on_move.fire(MoveEvent.new(source: self, mouse: event, moves: @moves))
|
|
439
472
|
end
|
|
440
473
|
end
|
|
441
474
|
|
|
@@ -465,7 +498,7 @@ module SamplerExample
|
|
|
465
498
|
|
|
466
499
|
# The bottom row. Tuile draws no status bar and reserves no row
|
|
467
500
|
# (`D_status_bar`) — this one is the sampler's own, kept current by
|
|
468
|
-
# {Tuile::Screen#on_focus_changed
|
|
501
|
+
# {Tuile::Screen#on_focus_changed}. Naming the focused component makes Tab
|
|
469
502
|
# traversal visible as you walk a pane, which no per-pane label shows.
|
|
470
503
|
# @return [void]
|
|
471
504
|
def refresh_status
|
|
@@ -508,9 +541,10 @@ module SamplerExample
|
|
|
508
541
|
# as a live index of the catalogue and any drift between the two is visible.
|
|
509
542
|
#
|
|
510
543
|
# Mnemonics are *hand-picked*: {Tuile::Component::MenuBar#add_item} raises on
|
|
511
|
-
# a duplicate among siblings, and
|
|
544
|
+
# a duplicate among siblings, and seven leaves therefore answer to a letter
|
|
512
545
|
# other than their initial (Past`e`, Checkbox`G`roup, C`o`mboBox,
|
|
513
|
-
# Pic`k`erWindow, S`l`ash menu, DateTi`m`eField) — the
|
|
546
|
+
# Pic`k`erWindow, S`l`ash menu, DateTi`m`eField, F`o`rmLayout) — the
|
|
547
|
+
# underline shows which.
|
|
514
548
|
# No item may use
|
|
515
549
|
# `q`: quit is the unhandled-key fallback, so a `q` on the live level would
|
|
516
550
|
# swallow it while the bar has focus.
|
|
@@ -559,6 +593,8 @@ module SamplerExample
|
|
|
559
593
|
Entry.new("LogWindow", :build_log_window, "l")
|
|
560
594
|
]),
|
|
561
595
|
Menu.new("Shell", "h", [
|
|
596
|
+
Entry.new("FormLayout", :build_form_layout, "o"),
|
|
597
|
+
Entry.new("Scroller", :build_scroller, "s"),
|
|
562
598
|
Entry.new("TabSheet", :build_tab_sheet, "t"),
|
|
563
599
|
Entry.new("MenuBar", :build_menu_bar, "m"),
|
|
564
600
|
Entry.new("Narrow strips", :build_narrow_strips, "n"),
|
|
@@ -613,7 +649,7 @@ module SamplerExample
|
|
|
613
649
|
def build_jump_box
|
|
614
650
|
combo = Tuile::Component::ComboBox.new(items: ENTRIES)
|
|
615
651
|
combo.item_label = :caption.to_proc
|
|
616
|
-
combo.on_value_change
|
|
652
|
+
combo.on_value_change { |e| load_entry(e.value) if e.value }
|
|
617
653
|
combo
|
|
618
654
|
end
|
|
619
655
|
|
|
@@ -668,7 +704,7 @@ module SamplerExample
|
|
|
668
704
|
"Home/End jump to row start/end; Up/Down at the first/last row jumps to text start/end.\n" \
|
|
669
705
|
"Overflowing rows scroll vertically to keep the caret visible."
|
|
670
706
|
area = Tuile::Component::TextArea.new
|
|
671
|
-
area.
|
|
707
|
+
area.value = "The quick brown fox jumps over the lazy dog. " \
|
|
672
708
|
"Edit me — the text wraps to the area's width and scrolls vertically " \
|
|
673
709
|
"once the cursor leaves the visible rows."
|
|
674
710
|
form do |f|
|
|
@@ -687,7 +723,7 @@ module SamplerExample
|
|
|
687
723
|
items = %w[Ruby Python JavaScript TypeScript Rust Go Elixir Crystal Haskell Kotlin Swift Zig]
|
|
688
724
|
combo = Tuile::Component::ComboBox.new(items: items)
|
|
689
725
|
status = Tuile::Component::Label.new.tap { _1.text = "(nothing selected)" }
|
|
690
|
-
combo.on_value_change
|
|
726
|
+
combo.on_value_change { |e| status.text = "Selected: #{e.value}" }
|
|
691
727
|
form do |f|
|
|
692
728
|
f.add(prompt, Fixed[3])
|
|
693
729
|
# A cross constraint clamps to the pane, so this is 30 columns or fewer.
|
|
@@ -728,14 +764,14 @@ module SamplerExample
|
|
|
728
764
|
status.text = "level: #{level.value.inspect} endings: #{endings.value&.label.inspect}"
|
|
729
765
|
end
|
|
730
766
|
update.call
|
|
731
|
-
[level, endings].each { _1.on_value_change
|
|
767
|
+
[level, endings].each { _1.on_value_change { update.call } }
|
|
732
768
|
|
|
733
769
|
pane = ShortcutBox.new("r", spacing: 1, padding: FORM_PADDING)
|
|
734
770
|
pane.add(prompt, Fixed[3])
|
|
735
771
|
pane.add(labelled("Log level", level), Fixed[1])
|
|
736
772
|
pane.add(labelled("Line endings", endings), Fixed[1])
|
|
737
773
|
pane.add(status, Fixed[1])
|
|
738
|
-
pane.on_shortcut
|
|
774
|
+
pane.on_shortcut do
|
|
739
775
|
level.value = "warn"
|
|
740
776
|
endings.value = nil
|
|
741
777
|
update.call
|
|
@@ -755,7 +791,7 @@ module SamplerExample
|
|
|
755
791
|
# Set on the composed field, not on the TextField inside it.
|
|
756
792
|
field.placeholder = "1-65535"
|
|
757
793
|
status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
|
|
758
|
-
field.on_value_change
|
|
794
|
+
field.on_value_change { |e| status.text = "e.value: #{e.value.inspect}" }
|
|
759
795
|
form do |f|
|
|
760
796
|
f.add(prompt, Fixed[2])
|
|
761
797
|
f.add(field, Fixed[1], cross: Fixed[20])
|
|
@@ -773,7 +809,7 @@ module SamplerExample
|
|
|
773
809
|
"Up/Down step the value by one. Watch the value while you type '1.5'."
|
|
774
810
|
field = Tuile::Component::FloatField.new
|
|
775
811
|
status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
|
|
776
|
-
field.on_value_change
|
|
812
|
+
field.on_value_change { |e| status.text = "e.value: #{e.value.inspect}" }
|
|
777
813
|
form do |f|
|
|
778
814
|
f.add(prompt, Fixed[2])
|
|
779
815
|
f.add(field, Fixed[1], cross: Fixed[20])
|
|
@@ -792,7 +828,7 @@ module SamplerExample
|
|
|
792
828
|
"what you typed (19.90 keeps its zero)."
|
|
793
829
|
field = Tuile::Component::BigDecimalField.new
|
|
794
830
|
status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
|
|
795
|
-
field.on_value_change
|
|
831
|
+
field.on_value_change { |e| status.text = triple_report(e.value) }
|
|
796
832
|
form do |f|
|
|
797
833
|
f.add(prompt, Fixed[3])
|
|
798
834
|
f.add(field, Fixed[1], cross: Fixed[20])
|
|
@@ -822,7 +858,7 @@ module SamplerExample
|
|
|
822
858
|
# calendar reform, which is noise next to the one fact this row is for.
|
|
823
859
|
report = -> { status.text = "value: #{field.value&.to_s || "nil"} bad_input?: #{field.bad_input?}" }
|
|
824
860
|
report.call
|
|
825
|
-
field.on_value_change
|
|
861
|
+
field.on_value_change { report.call }
|
|
826
862
|
ask = Tuile::Component::Button.new("Ask again") { report.call }
|
|
827
863
|
form do |f|
|
|
828
864
|
f.add(prompt, Fixed[4])
|
|
@@ -855,7 +891,7 @@ module SamplerExample
|
|
|
855
891
|
"seconds: #{seconds.value&.strftime("%H:%M:%S") || "nil"} (#{seconds.formats.first})"
|
|
856
892
|
end
|
|
857
893
|
report.call
|
|
858
|
-
[minutes, seconds].each { _1.on_value_change
|
|
894
|
+
[minutes, seconds].each { _1.on_value_change { report.call } }
|
|
859
895
|
form do |f|
|
|
860
896
|
f.add(prompt, Fixed[4])
|
|
861
897
|
f.add(labelled("Minute stride", minutes, field_width: 12), Fixed[1], cross: Fixed[30])
|
|
@@ -896,7 +932,7 @@ module SamplerExample
|
|
|
896
932
|
# next to the one fact this row carries.
|
|
897
933
|
report = -> { status.text = "value: #{field.value&.strftime("%Y-%m-%d %H:%M") || "nil"}" }
|
|
898
934
|
report.call
|
|
899
|
-
field.on_value_change
|
|
935
|
+
field.on_value_change { report.call }
|
|
900
936
|
save = Tuile::Component::Button.new("Save") { save_form("Starts at" => field) }
|
|
901
937
|
form do |f|
|
|
902
938
|
f.add(prompt, Fixed[4])
|
|
@@ -920,8 +956,8 @@ module SamplerExample
|
|
|
920
956
|
amount = Tuile::Component::IntegerField.new.tap { _1.id = :amount }
|
|
921
957
|
rate = Tuile::Component::FloatField.new.tap { _1.id = :rate }
|
|
922
958
|
echo = Tuile::Component::Label.new.tap { _1.text = "on_value_change: (nothing yet)" }
|
|
923
|
-
amount.on_value_change
|
|
924
|
-
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}" }
|
|
925
961
|
save = Tuile::Component::Button.new("Save") { save_form("Amount" => amount, "Rate" => rate) }
|
|
926
962
|
save.id = :save
|
|
927
963
|
rows = group do |g|
|
|
@@ -967,7 +1003,7 @@ module SamplerExample
|
|
|
967
1003
|
# the field's listener keeps current.
|
|
968
1004
|
def validated_row(caption, field)
|
|
969
1005
|
error = Tuile::Component::Label.new
|
|
970
|
-
field.on_error_message_change
|
|
1006
|
+
field.on_error_message_change { |e| error.text = e.error_message || Tuile::StyledString::EMPTY }
|
|
971
1007
|
row do |r|
|
|
972
1008
|
r.add(Tuile::Component::Label.new(caption), Fixed[14])
|
|
973
1009
|
r.add(field, Fixed[22])
|
|
@@ -1034,11 +1070,11 @@ module SamplerExample
|
|
|
1034
1070
|
user = Tuile::Component::TextField.new
|
|
1035
1071
|
password = Tuile::Component::PasswordField.new
|
|
1036
1072
|
reveal = Tuile::Component::Checkbox.new("Show password")
|
|
1037
|
-
reveal.on_value_change
|
|
1073
|
+
reveal.on_value_change { |e| password.revealed = e.value }
|
|
1038
1074
|
status = Tuile::Component::Label.new
|
|
1039
1075
|
refresh = -> { status.text = "user: #{user.text.inspect} password: #{password.value.length} chars" }
|
|
1040
1076
|
refresh.call
|
|
1041
|
-
[user, password].each { _1.
|
|
1077
|
+
[user, password].each { _1.on_value_change { refresh.call } }
|
|
1042
1078
|
form do |f|
|
|
1043
1079
|
f.add(prompt, Fixed[4])
|
|
1044
1080
|
f.add([user, password], Fixed[1], cross: Fixed[30]) # one constraint, both fields
|
|
@@ -1053,7 +1089,7 @@ module SamplerExample
|
|
|
1053
1089
|
# A ListDropdown driven from a TextArea — the same shape {ComboBox} and
|
|
1054
1090
|
# {Select} use, but wired by app code onto a field that knows nothing about
|
|
1055
1091
|
# it. Focus (and the caret) stays in the TextArea the whole time: an
|
|
1056
|
-
# `
|
|
1092
|
+
# `on_value_change` listener refills the menu, and {SlashCommandTextArea} hands
|
|
1057
1093
|
# movement keys to `#move` and Enter to `#choose` while it is open.
|
|
1058
1094
|
def build_slash_demo
|
|
1059
1095
|
prompt = Tuile::Component::Label.new
|
|
@@ -1071,15 +1107,14 @@ module SamplerExample
|
|
|
1071
1107
|
overlay.close if overlay.open?
|
|
1072
1108
|
else
|
|
1073
1109
|
overlay.items = matches
|
|
1074
|
-
overlay.open unless overlay.open?
|
|
1075
1110
|
# Width is the driver's call, never the dropdown's: measure the
|
|
1076
1111
|
# commands rather than inherit the full-width TextArea's columns.
|
|
1077
|
-
overlay.anchor_to(area
|
|
1112
|
+
overlay.anchor_to(area, width: slash_menu_width(matches))
|
|
1078
1113
|
end
|
|
1079
1114
|
end
|
|
1080
1115
|
|
|
1081
|
-
area.
|
|
1082
|
-
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) }
|
|
1083
1118
|
|
|
1084
1119
|
form do |f|
|
|
1085
1120
|
f.add(prompt, Fixed[4])
|
|
@@ -1105,14 +1140,14 @@ module SamplerExample
|
|
|
1105
1140
|
refresh = lambda do
|
|
1106
1141
|
stats.text = "submits: #{submits} pastes: #{pastes} rows in draft: #{area.row_count}"
|
|
1107
1142
|
end
|
|
1108
|
-
area.
|
|
1109
|
-
area.
|
|
1143
|
+
area.on_value_change { refresh.call }
|
|
1144
|
+
area.on_paste_received do |e|
|
|
1110
1145
|
pastes += 1
|
|
1111
|
-
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)
|
|
1112
1147
|
end
|
|
1113
|
-
area.on_submit
|
|
1148
|
+
area.on_submit do |e|
|
|
1114
1149
|
submits += 1
|
|
1115
|
-
log.add_line(Rainbow("submitted: #{text.inspect}").green)
|
|
1150
|
+
log.add_line(Rainbow("submitted: #{e.text.inspect}").green)
|
|
1116
1151
|
end
|
|
1117
1152
|
refresh.call
|
|
1118
1153
|
|
|
@@ -1198,7 +1233,7 @@ module SamplerExample
|
|
|
1198
1233
|
status.text = "checked: #{on.empty? ? "(none)" : on.join(", ")}"
|
|
1199
1234
|
end
|
|
1200
1235
|
refresh.call
|
|
1201
|
-
boxes.each { _1.on_value_change
|
|
1236
|
+
boxes.each { _1.on_value_change { refresh.call } }
|
|
1202
1237
|
# The boxes sit flush against each other while the form keeps a blank row
|
|
1203
1238
|
# around the block: a spacing-0 group nested in the spacing-1 form, rather
|
|
1204
1239
|
# than a per-child gap the framework deliberately doesn't offer.
|
|
@@ -1229,7 +1264,7 @@ module SamplerExample
|
|
|
1229
1264
|
conditional.each { _1.visible = business.checked? }
|
|
1230
1265
|
status.text = "visible fields: #{business.checked? ? 4 : 2}"
|
|
1231
1266
|
end
|
|
1232
|
-
business.on_value_change
|
|
1267
|
+
business.on_value_change { apply.call }
|
|
1233
1268
|
# The rows sit flush; the form keeps its blank row around the block.
|
|
1234
1269
|
rows = group do |g|
|
|
1235
1270
|
g.add(labelled("Name", Tuile::Component::TextField.new), Fixed[1])
|
|
@@ -1312,16 +1347,13 @@ module SamplerExample
|
|
|
1312
1347
|
status.text = "value: {#{shown.join(", ")}} — #{log.items.size} of #{entries.size} lines"
|
|
1313
1348
|
end
|
|
1314
1349
|
refresh.call
|
|
1315
|
-
group.on_value_change
|
|
1350
|
+
group.on_value_change { refresh.call }
|
|
1316
1351
|
|
|
1317
|
-
# The
|
|
1318
|
-
#
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
group.rect = Tuile::Rect.new(r.left, r.top, group_width, [LOG_LEVELS.size, r.height].min)
|
|
1323
|
-
log.rect = Tuile::Rect.new(r.left + group_width + 2, r.top,
|
|
1324
|
-
[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])
|
|
1325
1357
|
end
|
|
1326
1358
|
form do |f|
|
|
1327
1359
|
f.add(prompt, Fixed[4])
|
|
@@ -1396,20 +1428,17 @@ module SamplerExample
|
|
|
1396
1428
|
update_status.call
|
|
1397
1429
|
end
|
|
1398
1430
|
resort.call
|
|
1399
|
-
group.on_value_change
|
|
1431
|
+
group.on_value_change { resort.call }
|
|
1400
1432
|
# `list` is the composed List, which is where the cursor lives.
|
|
1401
1433
|
# Watching it is what makes the chrome/value split visible above.
|
|
1402
|
-
group.list.on_cursor_changed
|
|
1403
|
-
|
|
1404
|
-
# Side-by-side body
|
|
1405
|
-
#
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
group.rect = Tuile::Rect.new(r.left, r.top, group_width, [SORT_ORDERS.size, r.height].min)
|
|
1411
|
-
files.rect = Tuile::Rect.new(r.left + group_width + 2, r.top,
|
|
1412
|
-
[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])
|
|
1413
1442
|
end
|
|
1414
1443
|
form do |f|
|
|
1415
1444
|
f.add(prompt, Fixed[4])
|
|
@@ -1476,7 +1505,7 @@ module SamplerExample
|
|
|
1476
1505
|
pane.add(prompt, Fixed[4])
|
|
1477
1506
|
pane.add(determinate, Fixed[2])
|
|
1478
1507
|
pane.add(indeterminate, Fixed[3])
|
|
1479
|
-
pane.on_tick
|
|
1508
|
+
pane.on_tick do
|
|
1480
1509
|
done = done < PROGRESS_TOTAL ? done + 1 : 0
|
|
1481
1510
|
bar.value = done
|
|
1482
1511
|
refresh.call
|
|
@@ -1494,6 +1523,7 @@ module SamplerExample
|
|
|
1494
1523
|
# of hard-coded ANSI / 256-palette / RGB colors that stay put across flips.
|
|
1495
1524
|
BG_CHOICES = [
|
|
1496
1525
|
BgChoice.new("None (terminal default)", nil),
|
|
1526
|
+
BgChoice.new("Terminal background, stepped (derived)", Tuile::Theme.ref(:terminal_tint)),
|
|
1497
1527
|
BgChoice.new("Theme: input well", Tuile::Theme.ref(:input_bg_color)),
|
|
1498
1528
|
BgChoice.new("Theme: active", Tuile::Theme.ref(:active_bg_color)),
|
|
1499
1529
|
BgChoice.new("Theme: active border", Tuile::Theme.ref(:active_border_color)),
|
|
@@ -1507,21 +1537,6 @@ module SamplerExample
|
|
|
1507
1537
|
BgChoice.new("Hot pink (RGB)", Tuile::Color.rgb(120, 20, 70))
|
|
1508
1538
|
].freeze
|
|
1509
1539
|
|
|
1510
|
-
# The one choice that can't be a constant: the terminal's *own* background
|
|
1511
|
-
# stepped +10 per channel — the borderless-pane tint, which only sits right
|
|
1512
|
-
# when it's derived from the real background. Nil on a terminal that
|
|
1513
|
-
# reported none, which is the branch most users will actually see.
|
|
1514
|
-
def terminal_tint_choice
|
|
1515
|
-
bg = Tuile::Screen.instance.background_color
|
|
1516
|
-
return BgChoice.new("Terminal background — none reported", nil) if bg.nil?
|
|
1517
|
-
|
|
1518
|
-
BgChoice.new("Terminal background +10 (derived)",
|
|
1519
|
-
Tuile::Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }))
|
|
1520
|
-
end
|
|
1521
|
-
|
|
1522
|
-
# @param derived [BgChoice] the live terminal-derived tint, offered second.
|
|
1523
|
-
def bg_choices(derived) = [BG_CHOICES.first, derived, *BG_CHOICES[1..]]
|
|
1524
|
-
|
|
1525
1540
|
def build_background
|
|
1526
1541
|
intro = Tuile::Component::Label.new
|
|
1527
1542
|
intro.text = "bg_color tints a component and every descendant that doesn't set its own.\n" \
|
|
@@ -1532,7 +1547,7 @@ module SamplerExample
|
|
|
1532
1547
|
list.cursor = Tuile::Component::List::Cursor.new
|
|
1533
1548
|
list.lines = (1..12).map { |i| "List row #{i}" }
|
|
1534
1549
|
field = Tuile::Component::TextField.new
|
|
1535
|
-
field.
|
|
1550
|
+
field.value = "TextField keeps its own background"
|
|
1536
1551
|
|
|
1537
1552
|
# A borderless sub-box holding the list + field; it inherits the tint too.
|
|
1538
1553
|
box = Tuile::Component::Layout::Vertical.new(spacing: 1)
|
|
@@ -1542,34 +1557,81 @@ module SamplerExample
|
|
|
1542
1557
|
# A ComboBox over BG_CHOICES swaps the whole panel's bg_color on commit, so
|
|
1543
1558
|
# the tint flows down to every descendant without its own background — the
|
|
1544
1559
|
# label and the list — while the input widgets (the combo, the field) keep
|
|
1545
|
-
# their own well. Theme::Ref picks re-resolve on a scheme flip with no hook
|
|
1546
|
-
# 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.
|
|
1547
1563
|
outer = nil
|
|
1548
|
-
|
|
1549
|
-
combo = Tuile::Component::ComboBox.new(items: bg_choices(derived))
|
|
1564
|
+
combo = Tuile::Component::ComboBox.new(items: BG_CHOICES)
|
|
1550
1565
|
combo.item_label = :label.to_proc
|
|
1551
|
-
combo.on_value_change
|
|
1566
|
+
combo.on_value_change { |e| outer.bg_color = e.value.color }
|
|
1552
1567
|
|
|
1553
1568
|
outer = form do |f|
|
|
1554
1569
|
f.add(intro, Fixed[3])
|
|
1555
1570
|
f.add(combo, Fixed[1], cross: Fixed[40])
|
|
1556
1571
|
f.add(box, Expand[1])
|
|
1557
1572
|
end
|
|
1558
|
-
# The derived tint is the one pick whose *color* moves under it: a flip
|
|
1559
|
-
# re-probes the terminal, so rebuild the choice and re-apply it if it is
|
|
1560
|
-
# the current one. Expect it to correct itself a frame late — the flip
|
|
1561
|
-
# report carries no RGB, so this hook runs once on the old background and
|
|
1562
|
-
# again when the re-probe answers.
|
|
1563
|
-
outer.on_theme_changed = lambda do
|
|
1564
|
-
was_derived = combo.value.equal?(derived)
|
|
1565
|
-
derived = terminal_tint_choice
|
|
1566
|
-
combo.items = bg_choices(derived)
|
|
1567
|
-
combo.value = derived if was_derived
|
|
1568
|
-
end
|
|
1569
1573
|
combo.value = BG_CHOICES.first # show "None" as the resting selection
|
|
1570
1574
|
outer
|
|
1571
1575
|
end
|
|
1572
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
|
+
|
|
1573
1635
|
# Horizontal splitting a row between two equal Expand shares. Resize the
|
|
1574
1636
|
# terminal to watch it recompute: on an odd width the spare column goes to
|
|
1575
1637
|
# the left pane, since the remainder is handed to the earliest Expand first.
|
|
@@ -1617,7 +1679,7 @@ module SamplerExample
|
|
|
1617
1679
|
"so they bubble past the strip."
|
|
1618
1680
|
|
|
1619
1681
|
field = Tuile::Component::TextField.new
|
|
1620
|
-
field.
|
|
1682
|
+
field.value = "type here"
|
|
1621
1683
|
checkbox = Tuile::Component::Checkbox.new("Remember me", value: true)
|
|
1622
1684
|
list = Tuile::Component::List.new
|
|
1623
1685
|
list.cursor = Tuile::Component::List::Cursor.new
|
|
@@ -1639,7 +1701,7 @@ module SamplerExample
|
|
|
1639
1701
|
"List row #{list.cursor.position} · Prose row #{view.scroll_top_row}"
|
|
1640
1702
|
end
|
|
1641
1703
|
report.call
|
|
1642
|
-
sheet.on_tab_selected
|
|
1704
|
+
sheet.on_tab_selected { report.call }
|
|
1643
1705
|
|
|
1644
1706
|
form do |f|
|
|
1645
1707
|
f.add(prompt, Fixed[3])
|
|
@@ -1714,7 +1776,7 @@ module SamplerExample
|
|
|
1714
1776
|
"(#{narrow.selected_index + 1} of #{narrow.tabs.size})"
|
|
1715
1777
|
end
|
|
1716
1778
|
report.call
|
|
1717
|
-
narrow.on_tab_selected
|
|
1779
|
+
narrow.on_tab_selected { report.call }
|
|
1718
1780
|
|
|
1719
1781
|
bar = Tuile::Component::MenuBar.new
|
|
1720
1782
|
%w[File Edit View Window Help].each do |caption|
|
|
@@ -1805,7 +1867,7 @@ module SamplerExample
|
|
|
1805
1867
|
dialog.button("Save") { report.call("saved") }
|
|
1806
1868
|
dialog.button("Discard") { report.call("discarded") }
|
|
1807
1869
|
dialog.button("Cancel")
|
|
1808
|
-
dialog.on_dismiss
|
|
1870
|
+
dialog.on_dismiss { report.call("stayed put") }
|
|
1809
1871
|
dialog.open
|
|
1810
1872
|
end,
|
|
1811
1873
|
Tuile::Component::Button.new("Long") do
|
|
@@ -1813,7 +1875,7 @@ module SamplerExample
|
|
|
1813
1875
|
dialog.message = (1..40).map { "#{_1}. Clause #{_1} of the agreement, spelled out in full." }.join("\n")
|
|
1814
1876
|
dialog.button("Accept") { report.call("accepted the terms") }
|
|
1815
1877
|
dialog.button("Decline") { report.call("declined the terms") }
|
|
1816
|
-
dialog.on_dismiss
|
|
1878
|
+
dialog.on_dismiss { report.call("left the terms unanswered") }
|
|
1817
1879
|
dialog.open
|
|
1818
1880
|
end
|
|
1819
1881
|
]
|
|
@@ -1930,10 +1992,10 @@ module SamplerExample
|
|
|
1930
1992
|
pointer = Tuile::Component::Label.new
|
|
1931
1993
|
pointer.text = "pointer: (move over the canvas)"
|
|
1932
1994
|
log = Tuile::Component::LogWindow.new("Events")
|
|
1933
|
-
canvas.
|
|
1934
|
-
canvas.on_move
|
|
1935
|
-
kind =
|
|
1936
|
-
pointer.text = "pointer: #{
|
|
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)"
|
|
1937
1999
|
end
|
|
1938
2000
|
surface = row do |r|
|
|
1939
2001
|
r.add(Tuile::Component::Window.new("Canvas").tap { _1.content = canvas }, Percent[55])
|
|
@@ -1948,12 +2010,6 @@ module SamplerExample
|
|
|
1948
2010
|
|
|
1949
2011
|
# --- Helpers -----------------------------------------------------------
|
|
1950
2012
|
|
|
1951
|
-
def panel(*children, &layout_block)
|
|
1952
|
-
p = Panel.new(&layout_block)
|
|
1953
|
-
p.add(children)
|
|
1954
|
-
p
|
|
1955
|
-
end
|
|
1956
|
-
|
|
1957
2013
|
# The standard demo shell: children stacked with a blank row between them,
|
|
1958
2014
|
# inset from the window border. Every constraint below reads unqualified —
|
|
1959
2015
|
# `Fixed`, `Expand`, `Insets` all live on {Tuile::Component::Layout}, which
|
|
@@ -2017,19 +2073,19 @@ module SamplerExample
|
|
|
2017
2073
|
end
|
|
2018
2074
|
|
|
2019
2075
|
# Replaces the slash token at the caret with `command` plus a trailing
|
|
2020
|
-
# 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,
|
|
2021
2077
|
# so the now-tokenless text closes the menu).
|
|
2022
2078
|
def accept_slash_command(area, command)
|
|
2023
2079
|
text = area.text
|
|
2024
2080
|
caret = area.caret
|
|
2025
2081
|
start = caret
|
|
2026
2082
|
start -= 1 while start.positive? && !text[start - 1].match?(/\s/)
|
|
2027
|
-
area.
|
|
2083
|
+
area.value = "#{text[0...start]}#{command} #{text[caret..]}"
|
|
2028
2084
|
area.caret = start + command.length + 1
|
|
2029
2085
|
end
|
|
2030
2086
|
|
|
2031
2087
|
# The slash menu's width: the widest command plus List's two row gutters,
|
|
2032
|
-
# clamped to the screen. ListDropdown
|
|
2088
|
+
# clamped to the screen. ListDropdown hangs off its anchor but never measures — the
|
|
2033
2089
|
# width policy stays with the driver, exactly as it does for Select.
|
|
2034
2090
|
# @return [Integer]
|
|
2035
2091
|
def slash_menu_width(matches)
|
|
@@ -2049,7 +2105,7 @@ if $PROGRAM_NAME == __FILE__
|
|
|
2049
2105
|
screen.theme_def = SamplerExample::APP_THEME
|
|
2050
2106
|
sampler = SamplerExample::Sampler.new
|
|
2051
2107
|
screen.content = sampler
|
|
2052
|
-
screen.on_focus_changed
|
|
2108
|
+
screen.on_focus_changed { sampler.refresh_status }
|
|
2053
2109
|
sampler.refresh_status
|
|
2054
2110
|
sampler.menu_bar.focus
|
|
2055
2111
|
begin
|