tuile 0.9.0 → 0.11.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 +81 -36
- data/DECISIONS.md +2566 -0
- data/README.md +37 -24
- data/book/03-layout.md +153 -8
- data/book/04-event-loop.md +86 -0
- data/book/05-focus.md +84 -51
- data/book/06-theming.md +53 -1
- data/book/07-components.md +458 -9
- data/book/08-testing.md +21 -8
- data/book/09-styled-text.md +132 -0
- data/book/README.md +22 -14
- data/examples/sampler.rb +632 -67
- data/ideas/arrow-key-navigation.md +205 -0
- data/ideas/new-components.md +118 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +52 -51
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/big_decimal_field.rb +199 -0
- data/lib/tuile/component/button.rb +25 -21
- data/lib/tuile/component/checkbox.rb +134 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +263 -0
- data/lib/tuile/component/float_field.rb +161 -0
- data/lib/tuile/component/has_caption.rb +44 -0
- data/lib/tuile/component/has_content.rb +5 -5
- data/lib/tuile/component/has_value.rb +64 -0
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +13 -10
- data/lib/tuile/component/layout/box.rb +316 -0
- data/lib/tuile/component/layout/horizontal.rb +40 -0
- data/lib/tuile/component/layout/vertical.rb +41 -0
- data/lib/tuile/component/layout.rb +149 -15
- data/lib/tuile/component/list.rb +8 -7
- data/lib/tuile/component/list_dropdown.rb +157 -0
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +10 -14
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/select.rb +251 -0
- data/lib/tuile/component/text_area.rb +189 -65
- data/lib/tuile/component/text_field.rb +170 -32
- data/lib/tuile/component/text_view.rb +57 -114
- data/lib/tuile/component/window.rb +32 -47
- data/lib/tuile/component.rb +250 -87
- data/lib/tuile/event_queue.rb +14 -17
- data/lib/tuile/fake_event_queue.rb +11 -2
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +6 -10
- data/lib/tuile/screen.rb +202 -104
- data/lib/tuile/screen_pane.rb +51 -41
- data/lib/tuile/styled_string.rb +125 -86
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +4 -0
- data/sig/tuile.rbs +2962 -680
- metadata +25 -7
|
@@ -2,13 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
|
-
# Abstract base for editable text components
|
|
5
|
+
# Abstract base for the **String-valued** editable text components
|
|
6
|
+
# ({TextField}, {TextArea}): a field whose {HasValue#value} *is* its text.
|
|
7
|
+
# A field whose value is a different type (an `Integer`, a domain object)
|
|
8
|
+
# *composes* one of these rather than subclassing it — subclassing would
|
|
9
|
+
# drag this String-typed `text`/`value` seam onto its face alongside the
|
|
10
|
+
# real typed one.
|
|
6
11
|
#
|
|
7
12
|
# Holds the shared state — a mutable {#text} buffer, a {#caret} index,
|
|
8
13
|
# {#on_change} and {#on_escape} callbacks — and the keyboard machinery
|
|
9
14
|
# that single-line and multi-line inputs both need: ESC handling,
|
|
10
15
|
# LEFT/RIGHT caret movement, CTRL+LEFT/CTRL+RIGHT word jumps, and the
|
|
11
|
-
# `
|
|
16
|
+
# `tab_stop?` flag (`focusable?` comes from {HasValue}).
|
|
17
|
+
#
|
|
18
|
+
# {#caret} counts *characters* into {#text} but may only sit *between*
|
|
19
|
+
# grapheme clusters — the glyphs a terminal draws. Both write sites snap it
|
|
20
|
+
# forward onto the enclosing cluster's end, and every edit steps by a whole
|
|
21
|
+
# cluster:
|
|
22
|
+
#
|
|
23
|
+
# f.text = "e\u{0301}x" # a decomposed e-acute then "x": 3 chars, 2 glyphs
|
|
24
|
+
# f.caret = 1 # into the middle of the e-acute …
|
|
25
|
+
# f.caret # => 2, its end — where the caret already drew
|
|
26
|
+
# f.handle_key(Keys::BACKSPACE)
|
|
27
|
+
# f.text # => "x": the whole glyph went, not its accent
|
|
28
|
+
#
|
|
29
|
+
# Insertion stays character-native, so `String#insert` merges a typed
|
|
30
|
+
# combining mark into its base; {#text=}'s snap covers the case where that
|
|
31
|
+
# re-segments the text around the caret.
|
|
12
32
|
#
|
|
13
33
|
# Subclasses implement the layout-specific pieces ({#cursor_position},
|
|
14
34
|
# {#repaint}) and add their own keys (HOME/END, ENTER, UP/DOWN,
|
|
@@ -25,12 +45,15 @@ module Tuile
|
|
|
25
45
|
# - {#on_text_mutated} / {#on_caret_mutated} — post-mutation side
|
|
26
46
|
# effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
|
|
27
47
|
# keep the caret visible).
|
|
28
|
-
class
|
|
48
|
+
class AbstractStringField < Component
|
|
49
|
+
include HasValue
|
|
50
|
+
|
|
29
51
|
def initialize
|
|
30
52
|
super
|
|
31
53
|
@text = +""
|
|
32
54
|
@caret = 0
|
|
33
55
|
@on_change = nil
|
|
56
|
+
@on_value_change = nil
|
|
34
57
|
@on_key = nil
|
|
35
58
|
@on_escape = method(:default_on_escape)
|
|
36
59
|
end
|
|
@@ -38,10 +61,24 @@ module Tuile
|
|
|
38
61
|
# @return [String] current text contents.
|
|
39
62
|
attr_reader :text
|
|
40
63
|
|
|
41
|
-
#
|
|
42
|
-
|
|
64
|
+
# A text component's value *is* its text: {#value}/{#value=} are the
|
|
65
|
+
# {HasValue} seam over the same buffer as {#text}/{#text=}, so a form can
|
|
66
|
+
# drive it alongside typed fields. `text` stays the text-native name.
|
|
67
|
+
# @return [String]
|
|
68
|
+
def value = text
|
|
43
69
|
|
|
44
|
-
# @
|
|
70
|
+
# @param new_value [String, #to_s]
|
|
71
|
+
# @return [void]
|
|
72
|
+
def value=(new_value)
|
|
73
|
+
self.text = new_value.to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# `""` (not `nil`): a text field is empty when its buffer is blank.
|
|
77
|
+
# @return [String]
|
|
78
|
+
def empty_value = ""
|
|
79
|
+
|
|
80
|
+
# @return [Integer] caret index in `0..text.length`, counting characters
|
|
81
|
+
# and always on a grapheme-cluster boundary (see the class doc).
|
|
45
82
|
attr_reader :caret
|
|
46
83
|
|
|
47
84
|
# Optional callback fired whenever {#text} changes. Receives the new text
|
|
@@ -72,30 +109,32 @@ module Tuile
|
|
|
72
109
|
# @return [Proc, Method, nil] no-arg callable, or nil.
|
|
73
110
|
attr_accessor :on_escape
|
|
74
111
|
|
|
75
|
-
def focusable? = true
|
|
76
|
-
|
|
77
112
|
def tab_stop? = true
|
|
78
113
|
|
|
79
114
|
# Sets the text. Runs {#preprocess_text} first (subclasses may filter or
|
|
80
|
-
# truncate). Caret is clamped to the new text length
|
|
81
|
-
# only on a
|
|
115
|
+
# truncate). Caret is clamped to the new text length, then snapped back
|
|
116
|
+
# onto a cluster boundary of the *new* text. Fires {#on_change} only on a
|
|
117
|
+
# real change.
|
|
82
118
|
# @param new_text [String]
|
|
83
119
|
def text=(new_text)
|
|
84
120
|
new_text = preprocess_text(new_text)
|
|
85
121
|
return if @text == new_text
|
|
86
122
|
|
|
87
123
|
@text = +new_text
|
|
88
|
-
@caret = @caret.clamp(0, @text.length)
|
|
124
|
+
@caret = snap_to_cluster(@caret.clamp(0, @text.length))
|
|
89
125
|
on_text_mutated
|
|
90
126
|
invalidate
|
|
91
127
|
@on_change&.call(@text)
|
|
128
|
+
on_value_change&.call(@text)
|
|
92
129
|
end
|
|
93
130
|
|
|
94
|
-
#
|
|
95
|
-
#
|
|
131
|
+
# Clamps to `0..text.length`, then snaps forward onto a grapheme-cluster
|
|
132
|
+
# boundary, so an index that fell inside a cluster reads back as that
|
|
133
|
+
# cluster's end. Fires the {#on_caret_mutated} hook for subclasses (e.g.
|
|
134
|
+
# {TextArea} scrolls).
|
|
96
135
|
# @param new_caret [Integer]
|
|
97
136
|
def caret=(new_caret)
|
|
98
|
-
new_caret = new_caret.clamp(0, @text.length)
|
|
137
|
+
new_caret = snap_to_cluster(new_caret.clamp(0, @text.length))
|
|
99
138
|
return if @caret == new_caret
|
|
100
139
|
|
|
101
140
|
@caret = new_caret
|
|
@@ -134,6 +173,15 @@ module Tuile
|
|
|
134
173
|
# @return [String] possibly transformed text.
|
|
135
174
|
def preprocess_text(new_text) = new_text.to_s
|
|
136
175
|
|
|
176
|
+
# The one measurement primitive both inputs share: a caret index counts
|
|
177
|
+
# characters, but every rect, cursor and click counts columns, and only
|
|
178
|
+
# this converts between them.
|
|
179
|
+
# @param str [String]
|
|
180
|
+
# @return [Integer] `str`'s width in terminal columns, measured per
|
|
181
|
+
# grapheme cluster — so a combining mark adds nothing and a fullwidth
|
|
182
|
+
# glyph adds two.
|
|
183
|
+
def columns_of(str) = str.each_grapheme_cluster.sum { |g| Buffer.display_width(g) }
|
|
184
|
+
|
|
137
185
|
# Hook called after {#text} has been mutated, before invalidation /
|
|
138
186
|
# {#on_change}. Default no-op. Subclasses use this to invalidate caches
|
|
139
187
|
# ({TextArea}'s wrap cache) and update derived state.
|
|
@@ -148,7 +196,8 @@ module Tuile
|
|
|
148
196
|
|
|
149
197
|
# Dispatch hook for {#handle_key}. Handles ESC and the navigation keys
|
|
150
198
|
# that have identical semantics in single-line and multi-line inputs:
|
|
151
|
-
# LEFT/RIGHT arrows,
|
|
199
|
+
# LEFT/RIGHT arrows (one grapheme cluster per press, so a press always
|
|
200
|
+
# moves), CTRL+LEFT/CTRL+RIGHT for word jumps. Subclasses
|
|
152
201
|
# override to add their own keys (HOME/END, UP/DOWN, ENTER, BACKSPACE/
|
|
153
202
|
# DELETE, printable insertion) and call `super` to fall back to the
|
|
154
203
|
# common navigation handling.
|
|
@@ -156,8 +205,8 @@ module Tuile
|
|
|
156
205
|
# @return [Boolean] true if the key was handled.
|
|
157
206
|
def handle_text_input_key(key)
|
|
158
207
|
case key
|
|
159
|
-
when Keys::LEFT_ARROW then self.caret = @caret
|
|
160
|
-
when Keys::RIGHT_ARROW then self.caret = @caret
|
|
208
|
+
when Keys::LEFT_ARROW then self.caret = cluster_boundary_before(@caret)
|
|
209
|
+
when Keys::RIGHT_ARROW then self.caret = cluster_boundary_after(@caret)
|
|
161
210
|
when Keys::CTRL_LEFT_ARROW then self.caret = word_left
|
|
162
211
|
when Keys::CTRL_RIGHT_ARROW then self.caret = word_right
|
|
163
212
|
when Keys::ESC
|
|
@@ -170,27 +219,71 @@ module Tuile
|
|
|
170
219
|
true
|
|
171
220
|
end
|
|
172
221
|
|
|
222
|
+
# Removes the whole grapheme cluster before the caret — one press, one
|
|
223
|
+
# glyph, whatever it is built from (a ZWJ emoji family and a three-jamo
|
|
224
|
+
# Hangul syllable each go whole).
|
|
173
225
|
# @return [void]
|
|
174
226
|
def delete_before_caret
|
|
175
227
|
return if @caret.zero?
|
|
176
228
|
|
|
229
|
+
start = cluster_boundary_before(@caret)
|
|
177
230
|
new_text = @text.dup
|
|
178
|
-
new_text.slice!(
|
|
179
|
-
@caret
|
|
231
|
+
new_text.slice!(start...@caret)
|
|
232
|
+
@caret = start
|
|
180
233
|
self.text = new_text
|
|
181
234
|
end
|
|
182
235
|
|
|
236
|
+
# Removes the whole grapheme cluster at the caret.
|
|
183
237
|
# @return [void]
|
|
184
238
|
def delete_at_caret
|
|
185
239
|
return if @caret >= @text.length
|
|
186
240
|
|
|
187
241
|
new_text = @text.dup
|
|
188
|
-
new_text.slice!(@caret)
|
|
242
|
+
new_text.slice!(@caret...cluster_boundary_after(@caret))
|
|
189
243
|
self.text = new_text
|
|
190
244
|
end
|
|
191
245
|
|
|
192
246
|
private
|
|
193
247
|
|
|
248
|
+
# @param index [Integer] a {#text} index in `0..text.length`.
|
|
249
|
+
# @return [Integer] the smallest grapheme-cluster boundary `>= index`.
|
|
250
|
+
def snap_to_cluster(index)
|
|
251
|
+
offset = 0
|
|
252
|
+
@text.each_grapheme_cluster do |g|
|
|
253
|
+
return offset if offset >= index
|
|
254
|
+
|
|
255
|
+
offset += g.length
|
|
256
|
+
end
|
|
257
|
+
offset
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# @param index [Integer]
|
|
261
|
+
# @return [Integer] the greatest grapheme-cluster boundary `< index`, or
|
|
262
|
+
# 0 at the start of the text.
|
|
263
|
+
def cluster_boundary_before(index)
|
|
264
|
+
last = 0
|
|
265
|
+
offset = 0
|
|
266
|
+
@text.each_grapheme_cluster do |g|
|
|
267
|
+
offset += g.length
|
|
268
|
+
return last if offset >= index
|
|
269
|
+
|
|
270
|
+
last = offset
|
|
271
|
+
end
|
|
272
|
+
last
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# @param index [Integer]
|
|
276
|
+
# @return [Integer] the smallest grapheme-cluster boundary `> index`, or
|
|
277
|
+
# `text.length` at the end of the text.
|
|
278
|
+
def cluster_boundary_after(index)
|
|
279
|
+
offset = 0
|
|
280
|
+
@text.each_grapheme_cluster do |g|
|
|
281
|
+
offset += g.length
|
|
282
|
+
return offset if offset > index
|
|
283
|
+
end
|
|
284
|
+
offset
|
|
285
|
+
end
|
|
286
|
+
|
|
194
287
|
# Default {#on_escape} action: clear focus. Component deactivates; user
|
|
195
288
|
# can re-focus by clicking or tabbing back in.
|
|
196
289
|
# @return [void]
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The gem's one *optional* dependency, deliberately absent from the gemspec so
|
|
4
|
+
# that only an app naming this component pays for it. Zeitwerk loads this file
|
|
5
|
+
# on the first reference to {Tuile::Component::BigDecimalField} and not before.
|
|
6
|
+
begin
|
|
7
|
+
require "bigdecimal"
|
|
8
|
+
rescue LoadError
|
|
9
|
+
raise LoadError, "Tuile::Component::BigDecimalField needs the bigdecimal gem. Add `gem \"bigdecimal\"` " \
|
|
10
|
+
"to your Gemfile — since Ruby 3.4 it is a bundled gem, so Bundler no longer puts it " \
|
|
11
|
+
"on the load path for free."
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module Tuile
|
|
15
|
+
class Component
|
|
16
|
+
# A single-line field whose {#value} is a `BigDecimal` (or `nil` when
|
|
17
|
+
# empty) — the numeric field for money, where {FloatField}'s binary double
|
|
18
|
+
# would round. Give it a single-row {#rect}:
|
|
19
|
+
#
|
|
20
|
+
# price = Component::BigDecimalField.new
|
|
21
|
+
# price.on_value_change = ->(d) { total.value = d } # BigDecimal or nil
|
|
22
|
+
# price.value = BigDecimal("19.99") # field shows "19.99"
|
|
23
|
+
# price.value = 19.99 # ArgumentError: a Float can't be exact
|
|
24
|
+
#
|
|
25
|
+
# Only `0`–`9`, one leading `-` and one `.` can be typed; any other
|
|
26
|
+
# printable key is dropped without moving the caret. Up/Down step by one.
|
|
27
|
+
# Range checks (`min`/`max`) and a display scale (`19.9` → `19.90`) belong
|
|
28
|
+
# to a forms layer, not here — nothing rounds or pads what you typed.
|
|
29
|
+
#
|
|
30
|
+
# Requires the `bigdecimal` gem, which Tuile does *not* depend on: it is a
|
|
31
|
+
# bundled gem from Ruby 3.4 on, so a `Gemfile` naming it is what puts it on
|
|
32
|
+
# the load path. Referencing this class without it raises `LoadError`.
|
|
33
|
+
#
|
|
34
|
+
# == Implementation details
|
|
35
|
+
# {#value} is a *derived parse*: the buffer is the single source of truth,
|
|
36
|
+
# recomputed on read and left exactly as typed (`"19.90"` keeps its zero,
|
|
37
|
+
# which `BigDecimal#to_s` would not). It reads `nil` for a buffer that
|
|
38
|
+
# isn't a number (`""`, a lone `"-"`) but `1` / `0.5` for a half-typed
|
|
39
|
+
# `"1."` / `".5"`, so reaching for the decimal point doesn't blink the
|
|
40
|
+
# value to `nil` and back through {#on_value_change} — which fires per
|
|
41
|
+
# keystroke, but only on a real *value* change (`"1.0"`→`"1.00"` is silent,
|
|
42
|
+
# since the two compare equal).
|
|
43
|
+
#
|
|
44
|
+
# Both ends of that round-trip are written here rather than left to the
|
|
45
|
+
# library, because `bigdecimal` 3.1 (Ruby 3.3's default gem) and 4.x
|
|
46
|
+
# disagree about them: 3.1 rejects `BigDecimal("1.")` and `BigDecimal(0.1)`
|
|
47
|
+
# where 4.x accepts both. So the buffer is normalized before parsing, a
|
|
48
|
+
# `Float` is refused on both, and display goes through `to_s("F")` — plain
|
|
49
|
+
# notation, never `BigDecimal#to_s`'s `"0.1999e2"`.
|
|
50
|
+
#
|
|
51
|
+
# It *composes* a {TextField} (its single {HasContent} child) rather than
|
|
52
|
+
# subclassing one, so its face carries only the typed {HasValue} seam,
|
|
53
|
+
# never the widget's `String`-typed `text`.
|
|
54
|
+
#
|
|
55
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
56
|
+
class BigDecimalField < Component
|
|
57
|
+
include HasContent
|
|
58
|
+
include HasValue
|
|
59
|
+
|
|
60
|
+
# A buffer {#value} parses: an optional sign and digits with an optional
|
|
61
|
+
# fractional part (either side may be empty, but not both). No exponent —
|
|
62
|
+
# `to_s("F")` never writes one and no key types an `e`.
|
|
63
|
+
# @return [Regexp]
|
|
64
|
+
NUMERIC = /\A-?(?:\d+(?:\.\d*)?|\.\d+)\z/
|
|
65
|
+
private_constant :NUMERIC
|
|
66
|
+
|
|
67
|
+
def initialize
|
|
68
|
+
super()
|
|
69
|
+
@last_value = nil
|
|
70
|
+
field = TextField.new
|
|
71
|
+
field.on_change = ->(_text) { fire_if_changed }
|
|
72
|
+
field.on_key = method(:field_key)
|
|
73
|
+
self.content = field
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [::BigDecimal, nil] the parsed buffer; `nil` when empty or not a
|
|
77
|
+
# number (e.g. a lone `"-"`).
|
|
78
|
+
def value
|
|
79
|
+
text = content.text
|
|
80
|
+
text.match?(NUMERIC) ? BigDecimal(normalize(text)) : nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Writes `new_value` into the buffer in plain notation and parks the
|
|
84
|
+
# caret at its end; fires {#on_value_change} only if the value actually
|
|
85
|
+
# changed.
|
|
86
|
+
# @param new_value [::BigDecimal, Integer, String, nil] `nil` empties the
|
|
87
|
+
# field. A `Float` is refused, not converted — see the raise.
|
|
88
|
+
# @raise [ArgumentError] on a `Float` (its binary value is not the
|
|
89
|
+
# decimal you wrote, which is the whole reason to use this field), a
|
|
90
|
+
# non-numeric `String`, a NaN or an infinity.
|
|
91
|
+
# @raise [TypeError] on a value `BigDecimal()` won't take at all.
|
|
92
|
+
# @return [void]
|
|
93
|
+
def value=(new_value)
|
|
94
|
+
content.text = new_value.nil? ? "" : coerce(new_value).to_s("F")
|
|
95
|
+
content.caret = content.text.length
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# `nil`, not `""`: a numeric field with no parseable number is empty.
|
|
99
|
+
# @return [nil]
|
|
100
|
+
def empty_value = nil
|
|
101
|
+
|
|
102
|
+
# @return [Point, nil] the field's caret (the hardware cursor is delegated
|
|
103
|
+
# to the inner field).
|
|
104
|
+
def cursor_position = content.cursor_position
|
|
105
|
+
|
|
106
|
+
# Fired when ENTER is pressed in the field; see {TextField#on_enter}.
|
|
107
|
+
# @return [Proc, Method, nil] no-arg callable, or nil.
|
|
108
|
+
def on_enter = content.on_enter
|
|
109
|
+
|
|
110
|
+
# @param callback [Proc, Method, nil]
|
|
111
|
+
# @return [void]
|
|
112
|
+
def on_enter=(callback)
|
|
113
|
+
content.on_enter = callback
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
protected
|
|
117
|
+
|
|
118
|
+
# Places the wrapped field across the whole rect ({HasContent} hook).
|
|
119
|
+
# @param field [Component]
|
|
120
|
+
# @return [void]
|
|
121
|
+
def layout(field) = (field.rect = rect)
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
# Rewrites the half-typed shapes {NUMERIC} admits into ones every
|
|
126
|
+
# `bigdecimal` version parses: `".5"` → `"0.5"`, `"1."` → `"1"`.
|
|
127
|
+
# @param text [String] a buffer matching {NUMERIC}.
|
|
128
|
+
# @return [String]
|
|
129
|
+
def normalize(text)
|
|
130
|
+
text = text.sub(".", "0.") if text.start_with?(".", "-.")
|
|
131
|
+
text.chomp(".")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @param new_value [::BigDecimal, Integer, String]
|
|
135
|
+
# @return [::BigDecimal]
|
|
136
|
+
# @raise [ArgumentError] on a `Float` — `bigdecimal` 4.x would take it
|
|
137
|
+
# and 3.1 would not, and neither answer is the one a money field wants
|
|
138
|
+
# to give silently. Also on a NaN or an infinity: `to_s("F")` writes
|
|
139
|
+
# `"NaN"`, which no parse reads back, so writing one would silently
|
|
140
|
+
# turn the value `nil`.
|
|
141
|
+
def coerce(new_value)
|
|
142
|
+
if new_value.is_a?(Float)
|
|
143
|
+
raise ArgumentError, "a Float is not exact — pass BigDecimal(#{new_value.to_s.inspect}) or the String"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
big = BigDecimal(new_value)
|
|
147
|
+
raise ArgumentError, "value must be finite, got #{big}" unless big.finite?
|
|
148
|
+
|
|
149
|
+
big
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# The field's key interceptor, consulted *before* the field acts on the
|
|
153
|
+
# key — which is what lets a rejected character be swallowed without the
|
|
154
|
+
# caret ever moving.
|
|
155
|
+
# @param key [String]
|
|
156
|
+
# @return [Boolean] true to consume the key.
|
|
157
|
+
def field_key(key)
|
|
158
|
+
case key
|
|
159
|
+
when Keys::UP_ARROW then step(1)
|
|
160
|
+
when Keys::DOWN_ARROW then step(-1)
|
|
161
|
+
else return Keys.printable?(key) && !accepts?(key)
|
|
162
|
+
end
|
|
163
|
+
true
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Nudges {#value} by `delta`, treating an empty/un-parseable field as
|
|
167
|
+
# zero.
|
|
168
|
+
# @param delta [Integer]
|
|
169
|
+
# @return [void]
|
|
170
|
+
def step(delta) = (self.value = (value || BigDecimal(0)) + delta)
|
|
171
|
+
|
|
172
|
+
# Whether `char` may be inserted. Deliberately shallow: it keeps the
|
|
173
|
+
# buffer *typeable* rather than always-valid — a transient `"-"` or
|
|
174
|
+
# `"1."` has to be reachable — and {#value} decides what parses.
|
|
175
|
+
# @param char [String] a single printable character.
|
|
176
|
+
# @return [Boolean]
|
|
177
|
+
def accepts?(char)
|
|
178
|
+
case char
|
|
179
|
+
when /\A[0-9]\z/ then true
|
|
180
|
+
when "-" then content.caret.zero? && !content.text.start_with?("-")
|
|
181
|
+
when "." then !content.text.include?(".")
|
|
182
|
+
else false
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Re-emits {#on_value_change} with the freshly-parsed {#value}, but only
|
|
187
|
+
# when it differs from the last one fired — so a buffer edit that leaves
|
|
188
|
+
# the value unchanged (`"1.0"`→`"1.00"`) stays silent.
|
|
189
|
+
# @return [void]
|
|
190
|
+
def fire_if_changed
|
|
191
|
+
v = value
|
|
192
|
+
return if v == @last_value
|
|
193
|
+
|
|
194
|
+
@last_value = v
|
|
195
|
+
on_value_change&.call(v)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -12,34 +12,26 @@ module Tuile
|
|
|
12
12
|
# {Component#handle_mouse}.
|
|
13
13
|
#
|
|
14
14
|
# Assign a {#rect} (typically by the surrounding {Layout}) wide enough to
|
|
15
|
-
# show `[ caption ]` — that natural width is `caption.
|
|
15
|
+
# show `[ caption ]` — that natural width is `caption.display_width + 4`.
|
|
16
|
+
# A narrower {#rect} truncates the label with an ellipsis; a wider one leaves
|
|
17
|
+
# a tail that focuses but doesn't activate (see {#extent}).
|
|
16
18
|
class Button < Component
|
|
17
|
-
|
|
19
|
+
include Component::HasCaption
|
|
20
|
+
|
|
21
|
+
# @param caption [String, StyledString, nil] the button's label, coerced
|
|
22
|
+
# the same way {HasCaption#caption=} coerces it.
|
|
18
23
|
# @yield optional `on_click` callback; same as assigning {#on_click=}.
|
|
19
|
-
def initialize(caption =
|
|
24
|
+
def initialize(caption = nil, &on_click)
|
|
20
25
|
super()
|
|
21
|
-
|
|
26
|
+
self.caption = caption
|
|
22
27
|
@on_click = on_click
|
|
23
28
|
end
|
|
24
29
|
|
|
25
|
-
# @return [String] the button's label.
|
|
26
|
-
attr_reader :caption
|
|
27
|
-
|
|
28
30
|
# Callback fired when the button is activated (Enter, Space, or
|
|
29
31
|
# left-click). The callable receives no arguments.
|
|
30
32
|
# @return [Proc, Method, nil] no-arg callable, or nil.
|
|
31
33
|
attr_accessor :on_click
|
|
32
34
|
|
|
33
|
-
# Sets a new caption and invalidates the button. No-op if unchanged.
|
|
34
|
-
# @param new_caption [String]
|
|
35
|
-
def caption=(new_caption)
|
|
36
|
-
new_caption = new_caption.to_s
|
|
37
|
-
return if @caption == new_caption
|
|
38
|
-
|
|
39
|
-
@caption = new_caption
|
|
40
|
-
invalidate
|
|
41
|
-
end
|
|
42
|
-
|
|
43
35
|
def focusable? = true
|
|
44
36
|
|
|
45
37
|
def tab_stop? = true
|
|
@@ -56,11 +48,23 @@ module Tuile
|
|
|
56
48
|
end
|
|
57
49
|
end
|
|
58
50
|
|
|
51
|
+
# The cells the button actually paints: one row, `caption.display_width + 4`
|
|
52
|
+
# columns, clipped to {#rect}. Both the focus highlight and the click hit
|
|
53
|
+
# test use it, so a click on the blank tail of an over-wide rect — or on a
|
|
54
|
+
# lower row, when the rect is taller than one — does not fire {#on_click}.
|
|
55
|
+
# It still *focuses*: {Component#handle_mouse}'s click-to-focus is ungated
|
|
56
|
+
# by geometry. Same rule as {Checkbox#extent}, which documents the two
|
|
57
|
+
# traps behind it.
|
|
58
|
+
# @return [Rect]
|
|
59
|
+
def extent = Rect.new(rect.left, rect.top, [caption.display_width + 4, rect.width].min, 1)
|
|
60
|
+
|
|
61
|
+
# Fires {#on_click} on a left click within {#extent}; `super` runs first, so
|
|
62
|
+
# a click anywhere in {#rect} still focuses.
|
|
59
63
|
# @param event [MouseEvent]
|
|
60
64
|
# @return [void]
|
|
61
65
|
def handle_mouse(event)
|
|
62
66
|
super
|
|
63
|
-
return unless event.button == :left &&
|
|
67
|
+
return unless event.button == :left && extent.contains?(event.point)
|
|
64
68
|
|
|
65
69
|
@on_click&.call
|
|
66
70
|
end
|
|
@@ -70,9 +74,9 @@ module Tuile
|
|
|
70
74
|
super
|
|
71
75
|
return if rect.empty?
|
|
72
76
|
|
|
73
|
-
label = "[
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
label = (StyledString.plain("[ ") + caption + StyledString.plain(" ]")).ellipsize(rect.width)
|
|
78
|
+
label = label.with_bg(screen.theme.active_bg_color) if active?
|
|
79
|
+
draw_line(rect.left, rect.top, label)
|
|
76
80
|
end
|
|
77
81
|
end
|
|
78
82
|
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A boolean input on one row. Space, Enter or a left click toggles it:
|
|
6
|
+
#
|
|
7
|
+
# [x] Enable syslog forwarding
|
|
8
|
+
# [ ] Enable syslog forwarding
|
|
9
|
+
#
|
|
10
|
+
# cb = Component::Checkbox.new("Enable syslog forwarding", value: true)
|
|
11
|
+
# cb.on_value_change = ->(on) { config.syslog = on }
|
|
12
|
+
# cb.toggle # unchecks it, firing the listener with false
|
|
13
|
+
# cb.checked? # => false
|
|
14
|
+
#
|
|
15
|
+
# {#value} is the canonical seam ({HasValue}), always `true`/`false` and
|
|
16
|
+
# never `nil`; {#checked?} / {#checked=} / {#toggle} are the domain-word face
|
|
17
|
+
# over it — one piece of state, four names. Unchecked is the
|
|
18
|
+
# {#empty_value}, so a fresh checkbox is {HasValue#empty? empty} and
|
|
19
|
+
# {HasValue#clear} unchecks.
|
|
20
|
+
#
|
|
21
|
+
# Space and Enter both toggle — same as a checkable row in a
|
|
22
|
+
# {Component::List} ({CheckboxGroup}, {RadioGroup}), so the gesture reads the
|
|
23
|
+
# same standalone and grouped. A focused checkbox therefore *consumes* Enter:
|
|
24
|
+
# a form's Enter-to-submit on an ancestor won't see it, exactly as with a
|
|
25
|
+
# focused {Button} or {TextArea}. Which widget lets Enter through is per
|
|
26
|
+
# widget, never a framework guarantee — book ch5's Enter table is the list.
|
|
27
|
+
#
|
|
28
|
+
# A tab stop, so Tab lands on it, and the widget highlights while on the focus
|
|
29
|
+
# chain. Assign a {#rect} (typically from the surrounding {Layout}) at least
|
|
30
|
+
# `caption.display_width + 4` wide; a narrower one ellipsizes the caption, a
|
|
31
|
+
# wider one leaves a dead tail — see {#extent}.
|
|
32
|
+
#
|
|
33
|
+
# == Implementation details
|
|
34
|
+
# The glyphs are a house convention rather than constants: three columns plus
|
|
35
|
+
# a trailing space (`[x] `, `[ ] `), ASCII because `☑`/`☐` are absent from
|
|
36
|
+
# most monospace fonts and the fallback glyph bleeds over its cell. A widget
|
|
37
|
+
# painting checkbox-like rows without instantiating a Checkbox — checkable
|
|
38
|
+
# rows in a {Component::List} — repeats those literals to match.
|
|
39
|
+
class Checkbox < Component
|
|
40
|
+
include Component::HasValue
|
|
41
|
+
include Component::HasCaption
|
|
42
|
+
|
|
43
|
+
# @param caption [String, StyledString, nil] the label, coerced as
|
|
44
|
+
# {HasCaption#caption=} coerces it.
|
|
45
|
+
# @param value [Boolean] initial state. Assigned through {#value=}, which
|
|
46
|
+
# also seeds the backing ivar — an unseeded checkbox would read `nil` and
|
|
47
|
+
# so report itself non-{HasValue#empty? empty} while fresh.
|
|
48
|
+
def initialize(caption = nil, value: false)
|
|
49
|
+
super()
|
|
50
|
+
self.caption = caption
|
|
51
|
+
self.value = value
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def tab_stop? = true
|
|
55
|
+
|
|
56
|
+
# @return [Boolean] `false` — {HasValue#empty?} means unchecked.
|
|
57
|
+
def empty_value = false
|
|
58
|
+
|
|
59
|
+
# Coerces to `true`/`false` before storing, so the two-state invariant holds
|
|
60
|
+
# whatever a caller assigns — and `cb.value = nil` on a fresh checkbox is
|
|
61
|
+
# the no-op it looks like rather than a spurious change event.
|
|
62
|
+
# @param new_value [Object] anything; truthiness decides.
|
|
63
|
+
# @return [void]
|
|
64
|
+
def value=(new_value)
|
|
65
|
+
super(new_value ? true : false)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Boolean] {#value} under its domain word — `license.checked?`
|
|
69
|
+
# reads better than `license.value`. Not a second piece of state.
|
|
70
|
+
def checked? = value
|
|
71
|
+
|
|
72
|
+
# {#value=} under its domain word. A delegator rather than an `alias`, so it
|
|
73
|
+
# keeps routing through the one write path even if a subclass overrides
|
|
74
|
+
# {#value=} (an `alias` would freeze this onto the body defined here).
|
|
75
|
+
# @param new_value [Object] anything; truthiness decides.
|
|
76
|
+
# @return [void]
|
|
77
|
+
def checked=(new_value)
|
|
78
|
+
self.value = new_value
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Flips {#value}.
|
|
82
|
+
# @return [void]
|
|
83
|
+
def toggle = (self.value = !value)
|
|
84
|
+
|
|
85
|
+
# The cells the widget actually paints: one row, `caption.display_width + 4`
|
|
86
|
+
# columns, clipped to {#rect}. A form column routinely hands a checkbox a
|
|
87
|
+
# 40-column rect for a 22-column `[ ] Enable syslog forwarding` — the extent
|
|
88
|
+
# is those 22 columns.
|
|
89
|
+
#
|
|
90
|
+
# Both the focus highlight and the click hit test use it, so a click on the
|
|
91
|
+
# blank tail — or on a lower row, when the rect is taller than one — does
|
|
92
|
+
# not toggle. It still *focuses*: {Component#handle_mouse}'s click-to-focus
|
|
93
|
+
# is ungated by geometry, and the tail is the field's own row.
|
|
94
|
+
#
|
|
95
|
+
# The extent ignores {Component#bg_color}: an inherited tint paints the dead
|
|
96
|
+
# tail, but a hit test that silently widened with a background would be a
|
|
97
|
+
# mode switch invisible in the code and untestable by inspection.
|
|
98
|
+
# @return [Rect]
|
|
99
|
+
def extent = Rect.new(rect.left, rect.top, [caption.display_width + 4, rect.width].min, 1)
|
|
100
|
+
|
|
101
|
+
# Toggles on Space or Enter. Every other key is left unhandled so it bubbles
|
|
102
|
+
# to an ancestor.
|
|
103
|
+
# @param key [String]
|
|
104
|
+
# @return [Boolean]
|
|
105
|
+
def handle_key(key)
|
|
106
|
+
return false unless [" ", Keys::ENTER].include?(key)
|
|
107
|
+
|
|
108
|
+
toggle
|
|
109
|
+
true
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Toggles on a left click within {#extent}; `super` runs first, so a click
|
|
113
|
+
# anywhere in {#rect} still focuses.
|
|
114
|
+
# @param event [MouseEvent]
|
|
115
|
+
# @return [void]
|
|
116
|
+
def handle_mouse(event)
|
|
117
|
+
super
|
|
118
|
+
return unless event.button == :left && extent.contains?(event.point)
|
|
119
|
+
|
|
120
|
+
toggle
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @return [void]
|
|
124
|
+
def repaint
|
|
125
|
+
super
|
|
126
|
+
return if rect.empty?
|
|
127
|
+
|
|
128
|
+
label = (StyledString.plain(value ? "[x] " : "[ ] ") + caption).ellipsize(rect.width)
|
|
129
|
+
label = label.with_bg(screen.theme.active_bg_color) if active?
|
|
130
|
+
draw_line(rect.left, rect.top, label)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|