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
|
@@ -20,7 +20,7 @@ module Tuile
|
|
|
20
20
|
# dialog.button("Save") { save! }
|
|
21
21
|
# dialog.button("Discard") { discard! }
|
|
22
22
|
# dialog.button("Cancel") # no action: pressing it dismisses
|
|
23
|
-
# dialog.on_dismiss
|
|
23
|
+
# dialog.on_dismiss { stay_put }
|
|
24
24
|
# dialog.open
|
|
25
25
|
#
|
|
26
26
|
# **Every button closes the dialog.** A button with a block then fires it; a
|
|
@@ -80,7 +80,6 @@ module Tuile
|
|
|
80
80
|
@popup = nil
|
|
81
81
|
@chosen = false
|
|
82
82
|
@message = nil
|
|
83
|
-
@on_dismiss = nil
|
|
84
83
|
# Insertion-ordered; identity-keyed so equal captions stay two buttons.
|
|
85
84
|
@actions = {}.compare_by_identity
|
|
86
85
|
@mnemonics = {}
|
|
@@ -93,11 +92,18 @@ module Tuile
|
|
|
93
92
|
self.content = @box
|
|
94
93
|
end
|
|
95
94
|
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
|
|
95
|
+
# What {#on_dismiss} fires.
|
|
96
|
+
#
|
|
97
|
+
# @!attribute [r] source
|
|
98
|
+
# @return [ConfirmWindow] the dialog that was dismissed.
|
|
99
|
+
DismissEvent = Data.define(:source) { include Tuile::Event }
|
|
100
|
+
|
|
101
|
+
# @!method on_dismiss
|
|
102
|
+
# Fired with a {DismissEvent} when the dialog is dismissed — ESC, `q`, an
|
|
103
|
+
# outside click, or a {#button} declared without a block. Fires exactly
|
|
104
|
+
# once per {#open}, and never when an action button was chosen.
|
|
105
|
+
# @return [Listeners]
|
|
106
|
+
listener :on_dismiss
|
|
101
107
|
|
|
102
108
|
# @return [String, StyledString, Component, nil] whatever {#message=} was
|
|
103
109
|
# given — set a `String`, read that `String` back. The component
|
|
@@ -169,7 +175,7 @@ module Tuile
|
|
|
169
175
|
# is the downcased matching key.
|
|
170
176
|
cue = mnemonic == :auto ? styled.to_s.grapheme_clusters.first : mnemonic
|
|
171
177
|
btn = Button.new(letter ? underline_mnemonic(styled, cue) : styled)
|
|
172
|
-
btn.on_click
|
|
178
|
+
btn.on_click { activate(btn) }
|
|
173
179
|
@actions[btn] = action
|
|
174
180
|
@mnemonics[letter] = btn unless letter.nil?
|
|
175
181
|
@button_row.add(btn, Layout::Fixed[btn.caption.display_width + 4])
|
|
@@ -191,7 +197,7 @@ module Tuile
|
|
|
191
197
|
@popup&.content = nil
|
|
192
198
|
@chosen = false
|
|
193
199
|
@popup = MeasuredPopup.new(self)
|
|
194
|
-
@popup.on_close
|
|
200
|
+
@popup.on_close { notice_dismissed }
|
|
195
201
|
@popup.open
|
|
196
202
|
end
|
|
197
203
|
|
|
@@ -292,7 +298,7 @@ module Tuile
|
|
|
292
298
|
window.message = message
|
|
293
299
|
window.button(confirm, &action)
|
|
294
300
|
window.button(cancel)
|
|
295
|
-
window.on_dismiss
|
|
301
|
+
window.on_dismiss << on_dismiss if on_dismiss
|
|
296
302
|
window.open
|
|
297
303
|
end
|
|
298
304
|
|
|
@@ -307,23 +313,19 @@ module Tuile
|
|
|
307
313
|
confirm(caption, message, confirm: "Yes", cancel: "No", on_dismiss:, &action)
|
|
308
314
|
end
|
|
309
315
|
|
|
310
|
-
# The {Popup} that {#open} wraps the dialog in: its
|
|
311
|
-
#
|
|
312
|
-
#
|
|
316
|
+
# The {Popup} that {#open} wraps the dialog in: its size is measured from
|
|
317
|
+
# the dialog on every layout pass, so a message change and a SIGWINCH both
|
|
318
|
+
# re-measure against the current screen.
|
|
313
319
|
class MeasuredPopup < Popup
|
|
314
320
|
# @param window [ConfirmWindow]
|
|
315
321
|
def initialize(window)
|
|
316
|
-
# Before super: Popup#initialize ends in the first #reposition call.
|
|
317
322
|
@window = window
|
|
318
323
|
super(content: window)
|
|
319
324
|
end
|
|
320
325
|
|
|
321
|
-
# @
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
@declared_size = @window.measured_size(screen.size)
|
|
325
|
-
super
|
|
326
|
-
end
|
|
326
|
+
# @param screen_size [Size]
|
|
327
|
+
# @return [Size] {ConfirmWindow#measured_size}.
|
|
328
|
+
def declared_size_in(screen_size) = @window.measured_size(screen_size)
|
|
327
329
|
end
|
|
328
330
|
private_constant :MeasuredPopup
|
|
329
331
|
|
|
@@ -339,7 +341,7 @@ module Tuile
|
|
|
339
341
|
action = @actions[btn]
|
|
340
342
|
@chosen = true
|
|
341
343
|
@popup&.close
|
|
342
|
-
action.nil? ?
|
|
344
|
+
action.nil? ? fire_dismissed : action.call
|
|
343
345
|
end
|
|
344
346
|
|
|
345
347
|
# The popup's on_close: fires {#on_dismiss} unless a button was chosen —
|
|
@@ -350,9 +352,12 @@ module Tuile
|
|
|
350
352
|
return if @chosen
|
|
351
353
|
|
|
352
354
|
@chosen = true
|
|
353
|
-
|
|
355
|
+
fire_dismissed
|
|
354
356
|
end
|
|
355
357
|
|
|
358
|
+
# @return [void]
|
|
359
|
+
def fire_dismissed = on_dismiss.fire(DismissEvent.new(source: self))
|
|
360
|
+
|
|
356
361
|
# @param delta [Integer] -1 or 1.
|
|
357
362
|
# @return [Boolean] false when focus is not on a button (the key bubbles on).
|
|
358
363
|
def focus_button_step(delta)
|
|
@@ -6,7 +6,7 @@ module Tuile
|
|
|
6
6
|
# empty). Give it a single-row {#rect}:
|
|
7
7
|
#
|
|
8
8
|
# field = Component::DateField.new
|
|
9
|
-
# field.on_value_change
|
|
9
|
+
# field.on_value_change { |e| puts e.value.inspect } # Date or nil, per commit
|
|
10
10
|
# field.value = Date.new(2026, 9, 4) # field shows "2026-09-04"
|
|
11
11
|
# field.placeholder # => "yyyy-mm-dd"
|
|
12
12
|
# field.clear # empties it; value => nil
|
|
@@ -110,8 +110,8 @@ module Tuile
|
|
|
110
110
|
editor.max_text_length = MAX_TEXT_LENGTH
|
|
111
111
|
# Claiming the editor's two arrow slots, not the general interceptor:
|
|
112
112
|
# that one stays free for the app.
|
|
113
|
-
editor.on_key_up
|
|
114
|
-
editor.on_key_down
|
|
113
|
+
editor.on_key_up { step(1) }
|
|
114
|
+
editor.on_key_down { step(-1) }
|
|
115
115
|
@settled = false
|
|
116
116
|
@placeholder_override = nil
|
|
117
117
|
# Both nil: follow the screen's locale until an app overrides them.
|
|
@@ -144,13 +144,14 @@ module Tuile
|
|
|
144
144
|
# field.value = Time.now # shows today; reads back a Date, time dropped
|
|
145
145
|
#
|
|
146
146
|
# @param new_value [Date, nil] `nil` empties the field.
|
|
147
|
+
# @param from_user [Boolean] see {HasValue#set_value}.
|
|
147
148
|
# @return [void]
|
|
148
|
-
def
|
|
149
|
-
editor.
|
|
149
|
+
def set_value(new_value, from_user:)
|
|
150
|
+
editor.set_value(new_value.nil? ? "" : new_value.strftime(formats.first), from_user:)
|
|
150
151
|
editor.caret = editor.text.length
|
|
151
152
|
# The edit above announced nothing ({#notify_on_edit?}); a date written
|
|
152
153
|
# rather than typed has no prefix to be mistaken for a value.
|
|
153
|
-
fire_if_changed
|
|
154
|
+
fire_if_changed(from_user:)
|
|
154
155
|
end
|
|
155
156
|
|
|
156
157
|
# `nil`, not `""`: a date field with no parseable date is empty.
|
|
@@ -189,7 +190,7 @@ module Tuile
|
|
|
189
190
|
def formats=(list)
|
|
190
191
|
@formats = list.nil? ? nil : Locale::DateFormats.validate(list)
|
|
191
192
|
sync_placeholder
|
|
192
|
-
fire_if_changed
|
|
193
|
+
fire_if_changed(from_user: false)
|
|
193
194
|
end
|
|
194
195
|
|
|
195
196
|
# When the Gregorian calendar takes over from the Julian one, as a Julian
|
|
@@ -222,7 +223,7 @@ module Tuile
|
|
|
222
223
|
end
|
|
223
224
|
|
|
224
225
|
@calendar_start = start
|
|
225
|
-
fire_if_changed
|
|
226
|
+
fire_if_changed(from_user: false)
|
|
226
227
|
end
|
|
227
228
|
|
|
228
229
|
# Overrides the hint derived from the primary format.
|
|
@@ -248,6 +249,14 @@ module Tuile
|
|
|
248
249
|
# @return [String, nil]
|
|
249
250
|
def bad_input_message = value.nil? && !editor.text.empty? ? BAD_INPUT_MESSAGE : nil
|
|
250
251
|
|
|
252
|
+
# Every prefix of a date is bad input, so the report is latched to the
|
|
253
|
+
# commit gestures instead of shown per keystroke: `2`, `20`, `202` on the
|
|
254
|
+
# way to `2026-09-04` never redden and announce nothing, and a date the
|
|
255
|
+
# field cannot parse reddens the moment the user leaves the field or
|
|
256
|
+
# presses ENTER ({HasBadInput}).
|
|
257
|
+
# @return [Boolean]
|
|
258
|
+
def bad_input_settled? = @settled
|
|
259
|
+
|
|
251
260
|
protected
|
|
252
261
|
|
|
253
262
|
# Rewrites a buffer that parses in the primary format, leaving one that
|
|
@@ -256,7 +265,8 @@ module Tuile
|
|
|
256
265
|
# @return [void]
|
|
257
266
|
def commit
|
|
258
267
|
date = value
|
|
259
|
-
|
|
268
|
+
# The user's: a commit gesture, and a held notice announces here.
|
|
269
|
+
set_value(date, from_user: true) unless date.nil? # …which unsettles, hence the order
|
|
260
270
|
settle(true)
|
|
261
271
|
end
|
|
262
272
|
|
|
@@ -266,20 +276,14 @@ module Tuile
|
|
|
266
276
|
# @return [Boolean]
|
|
267
277
|
def notify_on_edit? = false
|
|
268
278
|
|
|
269
|
-
# Every prefix of a date is bad input, so the well is latched to the
|
|
270
|
-
# commit gestures instead of painted per keystroke: `2`, `20`, `202` on
|
|
271
|
-
# the way to `2026-09-04` never redden, and a date the field cannot parse
|
|
272
|
-
# reddens the moment the user leaves the field or presses ENTER
|
|
273
|
-
# ({HasBadInput}).
|
|
274
|
-
# @return [Boolean]
|
|
275
|
-
def bad_input_settled? = @settled
|
|
276
|
-
|
|
277
279
|
# An edit is the user having another go, so the well goes quiet again
|
|
278
280
|
# until the next commit gesture.
|
|
279
281
|
# @return [void]
|
|
280
282
|
def handle_editor_change
|
|
281
|
-
super
|
|
283
|
+
# Unsettled before the sync riding `super`, which would otherwise read
|
|
284
|
+
# the new buffer against the old latch.
|
|
282
285
|
settle(false)
|
|
286
|
+
super
|
|
283
287
|
end
|
|
284
288
|
|
|
285
289
|
# Re-derives the hint (which was *pushed* into the editor, so a repaint
|
|
@@ -293,7 +297,7 @@ module Tuile
|
|
|
293
297
|
sync_placeholder
|
|
294
298
|
date = value
|
|
295
299
|
self.value = date unless date.nil?
|
|
296
|
-
fire_if_changed # for the buffer that just *stopped* parsing: nothing above touched it
|
|
300
|
+
fire_if_changed(from_user: false) # for the buffer that just *stopped* parsing: nothing above touched it
|
|
297
301
|
end
|
|
298
302
|
|
|
299
303
|
private
|
|
@@ -307,6 +311,9 @@ module Tuile
|
|
|
307
311
|
# Nothing else painted: an ENTER on an untouched buffer writes no cells,
|
|
308
312
|
# and neither does leaving the field with bad input in it.
|
|
309
313
|
invalidate
|
|
314
|
+
# The latch moves without the buffer, so this is the one input to the
|
|
315
|
+
# showable report that {HasBadInput}'s edit funnel cannot see.
|
|
316
|
+
sync_bad_input
|
|
310
317
|
end
|
|
311
318
|
|
|
312
319
|
# @param text [String]
|
|
@@ -330,7 +337,7 @@ module Tuile
|
|
|
330
337
|
# @return [void]
|
|
331
338
|
def step(delta)
|
|
332
339
|
date = value
|
|
333
|
-
|
|
340
|
+
set_value(date.nil? ? Date.today : date + delta, from_user: true)
|
|
334
341
|
end
|
|
335
342
|
|
|
336
343
|
# @return [void]
|
|
@@ -9,7 +9,7 @@ module Tuile
|
|
|
9
9
|
# ↑ the blank column is {Layout::Box#spacing}, not a component
|
|
10
10
|
#
|
|
11
11
|
# f = Component::DateTimeField.new
|
|
12
|
-
# f.on_value_change
|
|
12
|
+
# f.on_value_change { |e| puts e.value.inspect } # DateTime or nil, per commit
|
|
13
13
|
# f.value = DateTime.new(2026, 9, 14, 13, 45) # "2026-09-14" / "13:45"
|
|
14
14
|
# f.clear # empties both halves
|
|
15
15
|
#
|
|
@@ -27,10 +27,10 @@ module Tuile
|
|
|
27
27
|
# f.date_field.calendar_start = Date::ITALY
|
|
28
28
|
# f.time_field.step = 900 # Up/Down walk a quarter hour
|
|
29
29
|
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
30
|
+
# What this field owns on them: each half's {Component#bg_color} (the well
|
|
31
|
+
# rule below), and one listener on each half's {HasValue#on_value_change}
|
|
32
|
+
# and {HasBadInput#on_bad_input_change} — that is how the composite hears
|
|
33
|
+
# them, so append your own and never remove those.
|
|
34
34
|
#
|
|
35
35
|
# == The value is a `DateTime` at +00:00
|
|
36
36
|
# Both halves feed it with no adapter, and the offset is a placeholder
|
|
@@ -56,8 +56,11 @@ module Tuile
|
|
|
56
56
|
# 2026-09-14 (empty) nil "needs both a date and a time" this field
|
|
57
57
|
#
|
|
58
58
|
# A half's bad input is the half's to paint, on its own latch, and this
|
|
59
|
-
# field paints nothing
|
|
60
|
-
#
|
|
59
|
+
# field paints nothing — but it does *report*: {HasBadInput#bad_input_message}
|
|
60
|
+
# relays the guilty half's message and {HasBadInput#on_bad_input_change}
|
|
61
|
+
# fires on that half's latch, so whoever has cells beside the pair prints the
|
|
62
|
+
# words the half has nowhere to put. Half-filled is nobody else's, so this
|
|
63
|
+
# field reddens whole — but **only while it is not active**: it judges you when you leave
|
|
61
64
|
# and goes quiet when you come back to fix it. A validator's verdict
|
|
62
65
|
# ({HasValidation#error_message=}) is by definition not attributable either,
|
|
63
66
|
# and reddens whole with no latch at all.
|
|
@@ -70,10 +73,10 @@ module Tuile
|
|
|
70
73
|
# == Implementation details
|
|
71
74
|
# - **The halves keep their own wells, and this field's ink is *synced* onto
|
|
72
75
|
# them.** `error_bg_color` sits at the top of the background chain, so a
|
|
73
|
-
# child answering {
|
|
76
|
+
# child answering {ComponentBackground#default_color} — every field does — never
|
|
74
77
|
# inherits an ancestor's error level, so marking only this field would
|
|
75
78
|
# leave the halves untouched and reach no cell at all. So the halves are
|
|
76
|
-
# marked {
|
|
79
|
+
# marked {ComponentBackground::INHERIT} exactly while this field inks, and `nil`
|
|
77
80
|
# otherwise. A guilty half's *own* error well still beats the mark, which
|
|
78
81
|
# is what keeps the ink rule free of arithmetic.
|
|
79
82
|
# - **The spacing column is nobody's surface** — {Component#clear_inside_extent}
|
|
@@ -129,7 +132,14 @@ module Tuile
|
|
|
129
132
|
# one handed a three-row rect paints a three-row well.
|
|
130
133
|
add(@date_field, Expand[DATE_WEIGHT], cross: Fixed[1])
|
|
131
134
|
add(@time_field, Expand[TIME_WEIGHT], cross: Fixed[1])
|
|
132
|
-
[@date_field, @time_field].each
|
|
135
|
+
[@date_field, @time_field].each do |half|
|
|
136
|
+
half.on_value_change { |e| handle_half_change(from_user: e.from_user?) }
|
|
137
|
+
# A half's report moves without its value — garbage and an empty
|
|
138
|
+
# buffer both read `nil` — and this field relays it. The report
|
|
139
|
+
# only: that event carries no origin to announce a value with, and
|
|
140
|
+
# every change of a half's value reaches the slot above anyway.
|
|
141
|
+
half.on_bad_input_change { handle_half_report_change }
|
|
142
|
+
end
|
|
133
143
|
end
|
|
134
144
|
|
|
135
145
|
# @return [DateField] the left half; tune it, never replace it.
|
|
@@ -149,7 +159,13 @@ module Tuile
|
|
|
149
159
|
end
|
|
150
160
|
|
|
151
161
|
# Writes the date into one half and the time of day into the other, firing
|
|
152
|
-
# {HasValue#on_value_change} once if the value actually changed.
|
|
162
|
+
# {HasValue#on_value_change} once if the value actually changed. The
|
|
163
|
+
# halves are written programmatically whatever `from_user` says — their
|
|
164
|
+
# notices are muted — and this field's own event carries it.
|
|
165
|
+
#
|
|
166
|
+
# An app never needs this with `from_user: true` to reach a half: each
|
|
167
|
+
# half is a {DateField} / {TimeField} with its own {#set_value}, and a
|
|
168
|
+
# user's edit there already arrives here as the user's.
|
|
153
169
|
#
|
|
154
170
|
# @param new_value [DateTime, Time, nil] anything carrying both a civil
|
|
155
171
|
# date and a time of day; `nil` empties both halves.
|
|
@@ -157,7 +173,8 @@ module Tuile
|
|
|
157
173
|
# @raise [TypeError] on a `Date` (it has no hour, and midnight would be
|
|
158
174
|
# invented) or anything else missing one of the two — checked before
|
|
159
175
|
# either half is written, so a rejected value leaves the field as it was.
|
|
160
|
-
|
|
176
|
+
# @param from_user [Boolean] see {HasValue#set_value}.
|
|
177
|
+
def set_value(new_value, from_user:)
|
|
161
178
|
unless new_value.nil? || CIVIL_PARTS.all? { new_value.respond_to?(_1) }
|
|
162
179
|
raise TypeError,
|
|
163
180
|
"expected a date and time of day answering #{CIVIL_PARTS.join("/")}, got #{new_value.inspect}"
|
|
@@ -167,7 +184,8 @@ module Tuile
|
|
|
167
184
|
date_field.value = new_value
|
|
168
185
|
time_field.value = new_value
|
|
169
186
|
end
|
|
170
|
-
|
|
187
|
+
sync_bad_input
|
|
188
|
+
fire_if_changed(from_user:)
|
|
171
189
|
end
|
|
172
190
|
|
|
173
191
|
# `nil`, not a pair of nils: a field with no parseable date *and* time is
|
|
@@ -182,19 +200,32 @@ module Tuile
|
|
|
182
200
|
applying { [date_field, time_field].each(&:clear) }
|
|
183
201
|
# Announced even though the halves hold their own notice: emptying is
|
|
184
202
|
# not a half-typed prefix.
|
|
185
|
-
|
|
203
|
+
sync_bad_input
|
|
204
|
+
fire_if_changed(from_user: false)
|
|
186
205
|
end
|
|
187
206
|
|
|
188
207
|
# The guilty half's own report, the date's first when both are bad; else
|
|
189
208
|
# the one fault no half can wear, a half-filled pair.
|
|
190
209
|
# @return [String, nil]
|
|
191
210
|
def bad_input_message
|
|
192
|
-
attributed =
|
|
211
|
+
attributed = guilty_half&.bad_input_message
|
|
193
212
|
return attributed unless attributed.nil?
|
|
194
213
|
|
|
195
214
|
date_field.empty? ^ time_field.empty? ? HALF_FILLED_MESSAGE : nil
|
|
196
215
|
end
|
|
197
216
|
|
|
217
|
+
# Relayed from the half whose message this field is carrying, so the report
|
|
218
|
+
# reaches a listener at the instant that half reddens rather than a focus
|
|
219
|
+
# edge later; the fault no half can wear settles on leaving this field.
|
|
220
|
+
#
|
|
221
|
+
# No latch ivar, deliberately: every input here is a fact something
|
|
222
|
+
# announces, which is what lets the sync sites be a complete list.
|
|
223
|
+
# @return [Boolean]
|
|
224
|
+
def bad_input_settled?
|
|
225
|
+
half = guilty_half
|
|
226
|
+
half ? half.bad_input_settled? : !active?
|
|
227
|
+
end
|
|
228
|
+
|
|
198
229
|
# Sets the verdict and syncs the halves' wells onto it.
|
|
199
230
|
# @param new_message [String, StyledString, nil]
|
|
200
231
|
# @return [void]
|
|
@@ -210,7 +241,10 @@ module Tuile
|
|
|
210
241
|
def active=(flag)
|
|
211
242
|
was = active?
|
|
212
243
|
super
|
|
213
|
-
|
|
244
|
+
return if was == active?
|
|
245
|
+
|
|
246
|
+
sync_half_wells
|
|
247
|
+
sync_bad_input
|
|
214
248
|
end
|
|
215
249
|
|
|
216
250
|
# @return [Size] the full width, one row — so a taller rect gets the
|
|
@@ -219,20 +253,19 @@ module Tuile
|
|
|
219
253
|
|
|
220
254
|
protected
|
|
221
255
|
|
|
222
|
-
#
|
|
223
|
-
#
|
|
224
|
-
#
|
|
225
|
-
#
|
|
226
|
-
# half's `bad_input?` moves with every keystroke and announces nothing at
|
|
227
|
-
# all by design, so a latch of this field's own could not follow it.
|
|
256
|
+
# `false` while a half is guilty: that half paints the fault where it
|
|
257
|
+
# happened, and a well over the pair would say the other half was wrong
|
|
258
|
+
# too. This field reddens for the half-filled fault and for a verdict, the
|
|
259
|
+
# two nobody else can wear.
|
|
228
260
|
# @return [Boolean]
|
|
229
|
-
def
|
|
261
|
+
def wears_bad_input_ink? = guilty_half.nil?
|
|
230
262
|
|
|
231
263
|
private
|
|
232
264
|
|
|
233
|
-
# @return [
|
|
234
|
-
# represent,
|
|
235
|
-
|
|
265
|
+
# @return [AbstractWrappingField, nil] the half holding input its own
|
|
266
|
+
# value cannot represent, the date's first — it owns the message this
|
|
267
|
+
# field relays, and the latch relayed with it.
|
|
268
|
+
def guilty_half = [date_field, time_field].find(&:bad_input?)
|
|
236
269
|
|
|
237
270
|
# One idempotent sync over one condition, this field the sole writer of
|
|
238
271
|
# its halves' {Component#bg_color} — the shape a hook-owned resource takes.
|
|
@@ -243,13 +276,23 @@ module Tuile
|
|
|
243
276
|
# @return [void]
|
|
244
277
|
def sync_half_wells
|
|
245
278
|
ink = error_ink?
|
|
246
|
-
[date_field, time_field].each { _1.bg_color = ink ?
|
|
279
|
+
[date_field, time_field].each { _1.bg_color = ink ? ComponentBackground::INHERIT : nil }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# @param from_user [Boolean] what the half's write declared.
|
|
283
|
+
# @return [void]
|
|
284
|
+
def handle_half_change(from_user:)
|
|
285
|
+
sync_half_wells
|
|
286
|
+
return if @applying
|
|
287
|
+
|
|
288
|
+
sync_bad_input
|
|
289
|
+
fire_if_changed(from_user:)
|
|
247
290
|
end
|
|
248
291
|
|
|
249
292
|
# @return [void]
|
|
250
|
-
def
|
|
293
|
+
def handle_half_report_change
|
|
251
294
|
sync_half_wells
|
|
252
|
-
|
|
295
|
+
sync_bad_input unless @applying
|
|
253
296
|
end
|
|
254
297
|
|
|
255
298
|
# Runs `block` with the halves' notices suppressed, so a value written
|
|
@@ -262,13 +305,14 @@ module Tuile
|
|
|
262
305
|
@applying = false
|
|
263
306
|
end
|
|
264
307
|
|
|
308
|
+
# @param from_user [Boolean]
|
|
265
309
|
# @return [void]
|
|
266
|
-
def fire_if_changed
|
|
310
|
+
def fire_if_changed(from_user:)
|
|
267
311
|
v = value
|
|
268
312
|
return if v == @last_value
|
|
269
313
|
|
|
270
314
|
@last_value = v
|
|
271
|
-
on_value_change
|
|
315
|
+
on_value_change.fire(HasValue::ValueChangeEvent.new(source: self, value: v, from_user:))
|
|
272
316
|
end
|
|
273
317
|
end
|
|
274
318
|
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# Fills every cell of its {#rect} with one glyph. Give it a one-column rect
|
|
6
|
+
# and `│` for a vertical rule between two borderless panes, a one-row rect
|
|
7
|
+
# and `─` for a horizontal one — the glyph decides the direction, the parent
|
|
8
|
+
# decides the length:
|
|
9
|
+
#
|
|
10
|
+
# rule = Component::Fill.new("│", color: Theme.ref(:pane_frame))
|
|
11
|
+
# bottom = Component::Layout::Horizontal.new
|
|
12
|
+
# bottom.add(system_pane, Layout::Percent[40])
|
|
13
|
+
# bottom.add(rule, Layout::Fixed[1])
|
|
14
|
+
# bottom.add(log_pane, Layout::Expand[1])
|
|
15
|
+
#
|
|
16
|
+
# Display-only — not focusable, no keys, no mouse. The background behind
|
|
17
|
+
# the glyph is its own {Component#bg_color}, else inherited, as for any
|
|
18
|
+
# component.
|
|
19
|
+
class Fill < Component
|
|
20
|
+
# @param char [String] initial {#char=}.
|
|
21
|
+
# @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil]
|
|
22
|
+
# initial {#color=}.
|
|
23
|
+
# @raise [ArgumentError, TypeError] see {#char=} and {#color=}.
|
|
24
|
+
def initialize(char, color: nil)
|
|
25
|
+
super()
|
|
26
|
+
@char = StyledString.validate_glyph(char, :char)
|
|
27
|
+
@color = nil
|
|
28
|
+
self.color = color
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [String] the glyph painted into every cell; one grapheme
|
|
32
|
+
# cluster, one column wide.
|
|
33
|
+
attr_reader :char
|
|
34
|
+
|
|
35
|
+
# @return [Color, Theme::Ref, nil] the value as set, so a {Theme::Ref}
|
|
36
|
+
# comes back unresolved; `nil` (the default) is the terminal's default
|
|
37
|
+
# foreground.
|
|
38
|
+
attr_reader :color
|
|
39
|
+
|
|
40
|
+
# @param char [String]
|
|
41
|
+
# @return [void]
|
|
42
|
+
# @raise [TypeError] when `char` is not a String.
|
|
43
|
+
# @raise [ArgumentError] when `char` is not exactly one grapheme cluster
|
|
44
|
+
# one column wide.
|
|
45
|
+
def char=(char)
|
|
46
|
+
char = StyledString.validate_glyph(char, :char)
|
|
47
|
+
return if @char == char
|
|
48
|
+
|
|
49
|
+
@char = char
|
|
50
|
+
invalidate
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Sets the glyph's color, live-resolved at paint time when given a
|
|
54
|
+
# {Theme::Ref} (so it follows a {Screen#theme=} with no
|
|
55
|
+
# {Component#handle_theme_changed} hook).
|
|
56
|
+
#
|
|
57
|
+
# rule.color = :bright_black
|
|
58
|
+
# rule.color = Theme.ref(:pane_frame) # an app #custom token
|
|
59
|
+
#
|
|
60
|
+
# @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil]
|
|
61
|
+
# coerced via {Color.coerce} unless it is a {Theme::Ref}; `nil` is the
|
|
62
|
+
# terminal default.
|
|
63
|
+
# @return [void]
|
|
64
|
+
# @raise [KeyError] when a {Theme::Ref} names a token the current theme
|
|
65
|
+
# lacks.
|
|
66
|
+
def color=(color)
|
|
67
|
+
color = Color.coerce(color) unless color.is_a?(Theme::Ref)
|
|
68
|
+
return if @color == color
|
|
69
|
+
|
|
70
|
+
color.resolve(screen.theme) if color.is_a?(Theme::Ref) # fail fast on a bad token
|
|
71
|
+
|
|
72
|
+
@color = color
|
|
73
|
+
invalidate
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Paints {#char} into every cell. Not `super`: the default's clear would
|
|
77
|
+
# dirty every cell just before it is painted over.
|
|
78
|
+
# @param canvas [Canvas] see {Component#repaint}.
|
|
79
|
+
# @return [void]
|
|
80
|
+
def repaint(canvas)
|
|
81
|
+
return if rect.empty?
|
|
82
|
+
|
|
83
|
+
row = StyledString.styled(@char * rect.width, fg: resolved_color)
|
|
84
|
+
rect.height.times { canvas.set_text(0, _1, row) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# @return [Color, nil]
|
|
90
|
+
def resolved_color = @color.is_a?(Theme::Ref) ? @color.resolve(screen.theme) : @color
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -6,7 +6,7 @@ module Tuile
|
|
|
6
6
|
# the {IntegerField} twin, one Ruby type over. Give it a single-row {#rect}:
|
|
7
7
|
#
|
|
8
8
|
# field = Component::FloatField.new
|
|
9
|
-
# field.on_value_change
|
|
9
|
+
# field.on_value_change { |e| puts e.value.inspect } # Float or nil, per change
|
|
10
10
|
# field.value = 19.99 # field shows "19.99"
|
|
11
11
|
# field.clear # empties it; value => nil
|
|
12
12
|
#
|
|
@@ -79,8 +79,8 @@ module Tuile
|
|
|
79
79
|
def initialize
|
|
80
80
|
super(Field.new)
|
|
81
81
|
# Not the general on_key interceptor: that slot stays free for the app.
|
|
82
|
-
editor.on_key_up
|
|
83
|
-
editor.on_key_down
|
|
82
|
+
editor.on_key_up { step(1.0) }
|
|
83
|
+
editor.on_key_down { step(-1.0) }
|
|
84
84
|
end
|
|
85
85
|
|
|
86
86
|
# @return [Float, nil] the parsed buffer; `nil` when empty or not a
|
|
@@ -96,9 +96,10 @@ module Tuile
|
|
|
96
96
|
# is coerced with `Float()`, so an `Integer` `3` shows as `"3.0"`.
|
|
97
97
|
# @raise [ArgumentError] on a non-numeric `String`, a NaN or an infinity.
|
|
98
98
|
# @raise [TypeError] on a value `Float()` won't take at all (an `Array`).
|
|
99
|
+
# @param from_user [Boolean] see {HasValue#set_value}.
|
|
99
100
|
# @return [void]
|
|
100
|
-
def
|
|
101
|
-
editor.
|
|
101
|
+
def set_value(new_value, from_user:)
|
|
102
|
+
editor.set_value(new_value.nil? ? "" : coerce(new_value).to_s, from_user:)
|
|
102
103
|
editor.caret = editor.text.length
|
|
103
104
|
end
|
|
104
105
|
|
|
@@ -130,7 +131,7 @@ module Tuile
|
|
|
130
131
|
# `0.0`.
|
|
131
132
|
# @param delta [Float]
|
|
132
133
|
# @return [void]
|
|
133
|
-
def step(delta) = (
|
|
134
|
+
def step(delta) = set_value((value || 0.0) + delta, from_user: true)
|
|
134
135
|
end
|
|
135
136
|
end
|
|
136
137
|
end
|