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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -36
  3. data/DECISIONS.md +2566 -0
  4. data/README.md +37 -24
  5. data/book/03-layout.md +153 -8
  6. data/book/04-event-loop.md +86 -0
  7. data/book/05-focus.md +84 -51
  8. data/book/06-theming.md +53 -1
  9. data/book/07-components.md +458 -9
  10. data/book/08-testing.md +21 -8
  11. data/book/09-styled-text.md +132 -0
  12. data/book/README.md +22 -14
  13. data/examples/sampler.rb +632 -67
  14. data/ideas/arrow-key-navigation.md +205 -0
  15. data/ideas/new-components.md +118 -0
  16. data/ideas/per-component-buffers.md +55 -0
  17. data/lib/tuile/buffer.rb +52 -51
  18. data/lib/tuile/color.rb +4 -10
  19. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  20. data/lib/tuile/component/big_decimal_field.rb +199 -0
  21. data/lib/tuile/component/button.rb +25 -21
  22. data/lib/tuile/component/checkbox.rb +134 -0
  23. data/lib/tuile/component/checkbox_group.rb +188 -0
  24. data/lib/tuile/component/combo_box.rb +263 -0
  25. data/lib/tuile/component/float_field.rb +161 -0
  26. data/lib/tuile/component/has_caption.rb +44 -0
  27. data/lib/tuile/component/has_content.rb +5 -5
  28. data/lib/tuile/component/has_value.rb +64 -0
  29. data/lib/tuile/component/integer_field.rb +135 -0
  30. data/lib/tuile/component/label.rb +13 -10
  31. data/lib/tuile/component/layout/box.rb +316 -0
  32. data/lib/tuile/component/layout/horizontal.rb +40 -0
  33. data/lib/tuile/component/layout/vertical.rb +41 -0
  34. data/lib/tuile/component/layout.rb +149 -15
  35. data/lib/tuile/component/list.rb +8 -7
  36. data/lib/tuile/component/list_dropdown.rb +157 -0
  37. data/lib/tuile/component/password_field.rb +105 -0
  38. data/lib/tuile/component/popup.rb +10 -14
  39. data/lib/tuile/component/progress_bar.rb +278 -0
  40. data/lib/tuile/component/radio_group.rb +188 -0
  41. data/lib/tuile/component/select.rb +251 -0
  42. data/lib/tuile/component/text_area.rb +189 -65
  43. data/lib/tuile/component/text_field.rb +170 -32
  44. data/lib/tuile/component/text_view.rb +57 -114
  45. data/lib/tuile/component/window.rb +32 -47
  46. data/lib/tuile/component.rb +250 -87
  47. data/lib/tuile/event_queue.rb +14 -17
  48. data/lib/tuile/fake_event_queue.rb +11 -2
  49. data/lib/tuile/fake_screen.rb +4 -5
  50. data/lib/tuile/fraction.rb +6 -10
  51. data/lib/tuile/screen.rb +202 -104
  52. data/lib/tuile/screen_pane.rb +51 -41
  53. data/lib/tuile/styled_string.rb +125 -86
  54. data/lib/tuile/theme.rb +78 -41
  55. data/lib/tuile/version.rb +1 -1
  56. data/lib/tuile.rb +4 -0
  57. data/sig/tuile.rbs +2962 -680
  58. metadata +25 -7
@@ -2,13 +2,33 @@
2
2
 
3
3
  module Tuile
4
4
  class Component
5
- # Abstract base for editable text components ({TextField}, {TextArea}).
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
- # `focusable?`/`tab_stop?` flags.
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 TextInput < Component
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
- # @return [Boolean] true iff {#text} is the empty string.
42
- def empty? = @text.empty?
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
- # @return [Integer] caret index in `0..text.length`.
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. Fires {#on_change}
81
- # only on a real change.
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
- # Sets the caret position. Clamped to `0..text.length`. Fires
95
- # {#on_caret_mutated} hook for subclasses (e.g. {TextArea} scrolls).
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, CTRL+LEFT/CTRL+RIGHT for word jumps. Subclasses
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 - 1
160
- when Keys::RIGHT_ARROW then self.caret = @caret + 1
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!(@caret - 1)
179
- @caret -= 1
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.length + 4`.
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
- # @param caption [String] the button's label.
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 = "", &on_click)
24
+ def initialize(caption = nil, &on_click)
20
25
  super()
21
- @caption = caption.to_s
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 && rect.contains?(event.point)
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 = "[ #{@caption} ]"[0, rect.width]
74
- styled = active? ? StyledString.styled(label, bg: screen.theme.active_bg_color) : StyledString.plain(label)
75
- screen.buffer.set_line(rect.left, rect.top, styled)
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