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.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +21 -12
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +5 -4
  7. data/book/05-focus.md +12 -9
  8. data/book/06-theming.md +55 -17
  9. data/book/07-components.md +188 -40
  10. data/book/08-testing.md +115 -15
  11. data/book/10-locale.md +1 -1
  12. data/book/README.md +5 -5
  13. data/examples/file_commander.rb +38 -27
  14. data/examples/hello_world.rb +1 -1
  15. data/examples/sampler.rb +225 -169
  16. data/lib/tuile/buffer.rb +12 -1
  17. data/lib/tuile/canvas/backend.rb +46 -0
  18. data/lib/tuile/canvas.rb +212 -0
  19. data/lib/tuile/color.rb +38 -9
  20. data/lib/tuile/component/abstract_string_field.rb +81 -80
  21. data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +19 -11
  24. data/lib/tuile/component/checkbox.rb +12 -10
  25. data/lib/tuile/component/checkbox_group.rb +11 -13
  26. data/lib/tuile/component/combo_box.rb +30 -40
  27. data/lib/tuile/component/confirm_window.rb +27 -22
  28. data/lib/tuile/component/date_field.rb +27 -20
  29. data/lib/tuile/component/date_time_field.rb +75 -31
  30. data/lib/tuile/component/fill.rb +93 -0
  31. data/lib/tuile/component/float_field.rb +7 -6
  32. data/lib/tuile/component/form_item.rb +250 -0
  33. data/lib/tuile/component/form_layout.rb +206 -0
  34. data/lib/tuile/component/has_bad_input.rb +98 -27
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +5 -12
  37. data/lib/tuile/component/has_validation.rb +39 -13
  38. data/lib/tuile/component/has_value.rb +70 -16
  39. data/lib/tuile/component/integer_field.rb +7 -6
  40. data/lib/tuile/component/label.rb +8 -15
  41. data/lib/tuile/component/layout/absolute.rb +86 -0
  42. data/lib/tuile/component/layout/box.rb +38 -63
  43. data/lib/tuile/component/layout.rb +124 -10
  44. data/lib/tuile/component/list.rb +197 -94
  45. data/lib/tuile/component/list_dropdown.rb +148 -88
  46. data/lib/tuile/component/menu_bar/cascade.rb +97 -27
  47. data/lib/tuile/component/menu_bar.rb +84 -64
  48. data/lib/tuile/component/notification.rb +44 -31
  49. data/lib/tuile/component/overlay.rb +210 -52
  50. data/lib/tuile/component/password_field.rb +1 -8
  51. data/lib/tuile/component/picker_window.rb +15 -10
  52. data/lib/tuile/component/popup.rb +13 -24
  53. data/lib/tuile/component/progress_bar.rb +7 -7
  54. data/lib/tuile/component/radio_group.rb +10 -12
  55. data/lib/tuile/component/scroller.rb +266 -0
  56. data/lib/tuile/component/select.rb +15 -31
  57. data/lib/tuile/component/slot.rb +1 -2
  58. data/lib/tuile/component/tab_sheet.rb +21 -28
  59. data/lib/tuile/component/tabs.rb +39 -24
  60. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  61. data/lib/tuile/component/text_area.rb +21 -19
  62. data/lib/tuile/component/text_field.rb +55 -39
  63. data/lib/tuile/component/text_view.rb +143 -79
  64. data/lib/tuile/component/time_field.rb +26 -21
  65. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  66. data/lib/tuile/component/window.rb +27 -26
  67. data/lib/tuile/component.rb +481 -259
  68. data/lib/tuile/component_background.rb +177 -0
  69. data/lib/tuile/component_util.rb +43 -0
  70. data/lib/tuile/event.rb +29 -0
  71. data/lib/tuile/event_queue.rb +14 -0
  72. data/lib/tuile/fake_screen.rb +41 -10
  73. data/lib/tuile/keys.rb +15 -6
  74. data/lib/tuile/layout_pass.rb +180 -0
  75. data/lib/tuile/listeners.rb +219 -0
  76. data/lib/tuile/mouse/router.rb +51 -35
  77. data/lib/tuile/mouse.rb +96 -29
  78. data/lib/tuile/point.rb +6 -0
  79. data/lib/tuile/rect.rb +33 -0
  80. data/lib/tuile/screen.rb +419 -84
  81. data/lib/tuile/screen_pane.rb +144 -31
  82. data/lib/tuile/strict_layout.rb +127 -0
  83. data/lib/tuile/styled_string.rb +139 -9
  84. data/lib/tuile/testing/gestures.rb +35 -0
  85. data/lib/tuile/testing.rb +310 -36
  86. data/lib/tuile/theme.rb +170 -19
  87. data/lib/tuile/theme_def.rb +4 -0
  88. data/lib/tuile/version.rb +1 -1
  89. data/lib/tuile.rb +53 -0
  90. data/sig/tuile.rbs +4951 -1158
  91. metadata +16 -2
  92. data/lib/tuile/vertical_scroll_bar.rb +0 -122
@@ -12,8 +12,8 @@ module Tuile
12
12
  #
13
13
  # def value = Integer(editor.text, 10) rescue nil
14
14
  #
15
- # def value=(new_value)
16
- # editor.text = new_value.nil? ? "" : new_value.to_s
15
+ # def set_value(new_value, from_user:)
16
+ # editor.set_value(new_value.nil? ? "" : new_value.to_s, from_user:)
17
17
  # editor.caret = editor.text.length
18
18
  # end
19
19
  #
@@ -37,7 +37,7 @@ module Tuile
37
37
  # editor is how a test reaches a state no public setter produces:
38
38
  #
39
39
  # editor = Testing.get(Component::TextField, in: field)
40
- # editor.text = "-" # bad input; field.value still reads nil
40
+ # editor.value = "-" # bad input; field.value still reads nil
41
41
  #
42
42
  # A knob that is *editor-shaped* rather than a concept of this field's own
43
43
  # domain is **not** forwarded, and the subclass sets it on its editor
@@ -55,7 +55,7 @@ module Tuile
55
55
  # by ENTER never moves focus at all. Override it to canonicalize a buffer
56
56
  # the user typed loosely:
57
57
  #
58
- # def commit = (self.value = value unless value.nil?) # rewrite in the canonical form
58
+ # def commit = (set_value(value, from_user: true) unless value.nil?) # rewrite canonically
59
59
  #
60
60
  # ENTER is committed and then **left to keep bubbling**, so a scope's
61
61
  # default button still sees it; only an {#on_enter} of this field's own
@@ -67,18 +67,26 @@ module Tuile
67
67
  # two gestures, so a form is never handed a half-typed date that happens to
68
68
  # parse ({DateField}, {TimeField}).
69
69
  #
70
+ # == Who made the change
71
+ # The editor's own event says whether the user moved the buffer, and this
72
+ # field's notice passes that on: a subclass writes the editor through
73
+ # {AbstractStringField#set_value} with the `from_user:` it was handed, and
74
+ # the edit route relays it. The commit gestures announce as the user's —
75
+ # an approximation for the one commit code can cause, a programmatic
76
+ # `screen.focused =` moving focus off this field.
77
+ #
70
78
  # == Implementation details
71
- # - **{HasValue#value} and {#value=} raise until overridden.** The inherited
79
+ # - **{HasValue#value} and {#set_value} raise until overridden.** The inherited
72
80
  # pair stores into `@value` and never touches the editor, so a subclass
73
81
  # that defined only one would silently half-work.
74
82
  # - **{HasValue#empty_value} is called during construction**, to seed the
75
83
  # change guard, so it must not depend on subclass state that `super` has
76
84
  # not set yet. In practice it is a constant per class.
77
- # - **The editor's `on_change` and `on_enter` slots are claimed** — for that
78
- # guard, and to commit before an app's ENTER handler runs. A slot cannot
79
- # be shared, so a subclass reacting to buffer edits overrides
80
- # {#handle_editor_change} (every edit), {#value=} or {#commit} rather than
81
- # reassigning either.
85
+ # - **The editor's `on_value_change` and `on_enter` slots carry this field's own
86
+ # listeners** — for that guard, and to commit before an app's ENTER handler
87
+ # runs. They are lists, so nothing an app adds displaces them; a subclass
88
+ # reacting to buffer edits still overrides {#handle_editor_change} (every
89
+ # edit), {#set_value} or {#commit}, which run in a defined order.
82
90
  # - **Not for a field whose editor is a *filter*.** This base assumes the
83
91
  # buffer is a rendering of the value, so an edit may change the value.
84
92
  # {ComboBox} breaks both halves — its text is a transient query and only a
@@ -98,13 +106,19 @@ module Tuile
98
106
 
99
107
  @editor = editor
100
108
  @last_value = empty_value
101
- @on_enter = nil
109
+ # Held so the transition block can unsubscribe the same object it added:
110
+ # a lambda is only equal to itself.
111
+ @enter_bridge = lambda do
112
+ commit_and_notify
113
+ on_enter.fire(EnterEvent.new(source: self))
114
+ end
102
115
  # One widget, one surface: the editor paints no well of its own, so this
103
- # field's bg_color reaches the cells the editor paints.
104
- editor.bg_color = BG_INHERIT
105
- editor.on_change = lambda do |_text|
116
+ # field's well covers it and its bg_color reaches the cells the editor paints.
117
+ editor.bg_color = ComponentBackground::INHERIT
118
+ bg.default_color = ComponentBackground::INPUT_WELL
119
+ editor.on_value_change do |e|
106
120
  handle_editor_change
107
- fire_if_changed if notify_on_edit?
121
+ fire_if_changed(from_user: e.from_user?) if notify_on_edit?
108
122
  end
109
123
  add_child(editor, at: 0)
110
124
  end
@@ -113,12 +127,14 @@ module Tuile
113
127
  # @raise [NotImplementedError] unless the subclass overrides it.
114
128
  def value = raise(NotImplementedError, "#{self.class} must implement value")
115
129
 
116
- # Writes `new_value` into the editor's buffer.
130
+ # Writes `new_value` into the editor's buffer, passing `from_user` on to
131
+ # {AbstractStringField#set_value}.
117
132
  # @param new_value [Object]
133
+ # @param from_user [Boolean] see {HasValue#set_value}.
118
134
  # @return [void]
119
135
  # @raise [NotImplementedError] unless the subclass overrides it.
120
- def value=(new_value)
121
- raise(NotImplementedError, "#{self.class} must implement value=")
136
+ def set_value(new_value, from_user:)
137
+ raise(NotImplementedError, "#{self.class} must implement set_value")
122
138
  end
123
139
 
124
140
  # Empties the *input*, not just the value — a field holding bad input
@@ -130,7 +146,7 @@ module Tuile
130
146
  # Announced here rather than through the editor's change, so a field
131
147
  # holding its notice ({#notify_on_edit?}) still reports an emptying as
132
148
  # it happens: emptying is not a half-typed prefix.
133
- fire_if_changed
149
+ fire_if_changed(from_user: false)
134
150
  end
135
151
 
136
152
  # @return [String, nil] the hint the editor paints while empty
@@ -144,26 +160,28 @@ module Tuile
144
160
  editor.placeholder = text
145
161
  end
146
162
 
147
- # @return [Proc, Method, nil] fired when ENTER is pressed, *after*
148
- # {#commit}; see {TextField#on_enter}.
149
- attr_reader :on_enter
163
+ # What {#on_enter} fires.
164
+ #
165
+ # @!attribute [r] source
166
+ # @return [AbstractWrappingField] the field ENTER reached.
167
+ EnterEvent = Data.define(:source) { include Tuile::Event }
150
168
 
151
- # @param callback [Proc, Method, nil]
152
- # @return [void]
153
- def on_enter=(callback)
154
- @on_enter = callback
155
- # Wrapped rather than forwarded, so an app's ENTER handler reads a
156
- # committed buffer. A nil callback leaves the editor's own slot nil,
157
- # which is what keeps ENTER *bubbling* — see {#handle_key?}.
158
- editor.on_enter = callback && lambda do
159
- commit_and_notify
160
- callback.call
161
- end
169
+ # @!method on_enter
170
+ # Fired with an {EnterEvent} when ENTER is pressed, *after* {#commit};
171
+ # see {TextField#on_enter}.
172
+ #
173
+ # **Empty means the field declines ENTER**, which keeps it bubbling to
174
+ # the scope's default button — see {#handle_key?}. The editor's own slot
175
+ # is claimed only while this one is non-empty, by a bridge that commits
176
+ # first, so a listener here always reads a committed buffer.
177
+ # @return [Listeners]
178
+ listener :on_enter do |claimed|
179
+ claimed ? editor.on_enter << @enter_bridge : editor.on_enter.remove(@enter_bridge)
162
180
  end
163
181
 
164
182
  # Commits on ENTER, and leaves the key unconsumed so it keeps bubbling.
165
183
  #
166
- # The editor declines ENTER whenever {#on_enter} is nil, so the key
184
+ # The editor declines ENTER whenever {#on_enter} is empty, so the key
167
185
  # reaches this field instead — and it must be committed on the way past,
168
186
  # or the form default button it is bubbling towards acts on an
169
187
  # uncommitted buffer.
@@ -198,13 +216,6 @@ module Tuile
198
216
  screen.focused = editor if editor.focusable?
199
217
  end
200
218
 
201
- # @param new_rect [Rect]
202
- # @return [void]
203
- def rect=(new_rect)
204
- super
205
- layout(editor)
206
- end
207
-
208
219
  protected
209
220
 
210
221
  # @return [AbstractStringField] the wrapped editor.
@@ -225,14 +236,14 @@ module Tuile
225
236
  #
226
237
  # Only the *push* settles: {HasValue#value} stays a live parse of the
227
238
  # buffer either way. And overriding this is half the job — {#commit} is
228
- # covered here, but the field must fire from its own `value=` too, or a
239
+ # covered here, but the field must fire from its own `set_value` too, or a
229
240
  # programmatic write and an Up/Down step go unannounced until the next
230
241
  # commit.
231
242
  # @return [Boolean]
232
243
  def notify_on_edit? = true
233
244
 
234
245
  # Called whenever the editor's buffer changes, however the characters
235
- # arrived — a typed key, a paste, or a {#value=} of this field's own. It
246
+ # arrived — a typed key, a paste, or a {#set_value} of this field's own. It
236
247
  # is named for the *editor*, not for the user, because those last two are
237
248
  # not input. No-op by default; override it to drop state that describes
238
249
  # the *previous* buffer, as a field latching whether its input has settled
@@ -242,15 +253,8 @@ module Tuile
242
253
 
243
254
  # Places the editor across the whole rect; override to reserve cells for a
244
255
  # face of your own.
245
- # @param editor [Component]
246
256
  # @return [void]
247
- def layout(editor) = (editor.rect = rect)
248
-
249
- # The field well the face sits on — the editor is marked
250
- # {Component::BG_INHERIT}, so this one covers it (exactly one well per
251
- # widget) and {Component#bg_color} set here reaches the cells it paints.
252
- # @return [Color]
253
- def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color
257
+ def relayout = (editor.rect = local_rect)
254
258
 
255
259
  private
256
260
 
@@ -260,19 +264,20 @@ module Tuile
260
264
  # @return [void]
261
265
  def commit_and_notify
262
266
  commit
263
- fire_if_changed
267
+ fire_if_changed(from_user: true)
264
268
  end
265
269
 
266
270
  # Re-emits {HasValue#on_value_change}, but only when {#value} differs from
267
271
  # the last one fired — so a buffer edit that leaves the value alone
268
272
  # (`"7"`→`"07"`) stays silent.
273
+ # @param from_user [Boolean] what the write that moved the buffer declared.
269
274
  # @return [void]
270
- def fire_if_changed
275
+ def fire_if_changed(from_user:)
271
276
  v = value
272
277
  return if v == @last_value
273
278
 
274
279
  @last_value = v
275
- on_value_change&.call(v)
280
+ on_value_change.fire(HasValue::ValueChangeEvent.new(source: self, value: v, from_user:))
276
281
  end
277
282
  end
278
283
  end
@@ -18,7 +18,7 @@ module Tuile
18
18
  # would round. Give it a single-row {#rect}:
19
19
  #
20
20
  # price = Component::BigDecimalField.new
21
- # price.on_value_change = ->(d) { total.value = d } # BigDecimal or nil
21
+ # price.on_value_change { |e| total.value = e.value } # BigDecimal or nil
22
22
  # price.value = BigDecimal("19.99") # field shows "19.99"
23
23
  # price.value = 19.99 # ArgumentError: a Float can't be exact
24
24
  #
@@ -98,8 +98,8 @@ module Tuile
98
98
  def initialize
99
99
  super(Field.new)
100
100
  # Not the general on_key interceptor: that slot stays free for the app.
101
- editor.on_key_up = -> { step(1) }
102
- editor.on_key_down = -> { step(-1) }
101
+ editor.on_key_up { step(1) }
102
+ editor.on_key_down { step(-1) }
103
103
  end
104
104
 
105
105
  # @return [::BigDecimal, nil] the parsed buffer; `nil` when empty or not a
@@ -118,9 +118,10 @@ module Tuile
118
118
  # decimal you wrote, which is the whole reason to use this field), a
119
119
  # non-numeric `String`, a NaN or an infinity.
120
120
  # @raise [TypeError] on a value `BigDecimal()` won't take at all.
121
+ # @param from_user [Boolean] see {HasValue#set_value}.
121
122
  # @return [void]
122
- def value=(new_value)
123
- editor.text = new_value.nil? ? "" : coerce(new_value).to_s("F")
123
+ def set_value(new_value, from_user:)
124
+ editor.set_value(new_value.nil? ? "" : coerce(new_value).to_s("F"), from_user:)
124
125
  editor.caret = editor.text.length
125
126
  end
126
127
 
@@ -166,7 +167,7 @@ module Tuile
166
167
  # zero.
167
168
  # @param delta [Integer]
168
169
  # @return [void]
169
- def step(delta) = (self.value = (value || BigDecimal(0)) + delta)
170
+ def step(delta) = set_value((value || BigDecimal(0)) + delta, from_user: true)
170
171
  end
171
172
  end
172
173
  end
@@ -20,17 +20,24 @@ module Tuile
20
20
 
21
21
  # @param caption [String, StyledString, nil] the button's label, coerced
22
22
  # the same way {HasCaption#caption=} coerces it.
23
- # @yield optional `on_click` callback; same as assigning {#on_click=}.
24
- def initialize(caption = nil, &on_click)
23
+ # @yield optional `on_click` listener; same as registering one on {#on_click}.
24
+ def initialize(caption = nil, &listener)
25
25
  super()
26
26
  self.caption = caption
27
- @on_click = on_click
27
+ on_click << listener if listener
28
28
  end
29
29
 
30
- # Callback fired when the button is activated (Enter, Space, or
31
- # left-click). The callable receives no arguments.
32
- # @return [Proc, Method, nil] no-arg callable, or nil.
33
- attr_accessor :on_click
30
+ # What {#on_click} fires.
31
+ #
32
+ # @!attribute [r] source
33
+ # @return [Button] the button that was activated.
34
+ ClickEvent = Data.define(:source) { include Tuile::Event }
35
+
36
+ # @!method on_click
37
+ # Fired with a {ClickEvent} when the button is activated — Enter, Space
38
+ # or a left click within {#extent}.
39
+ # @return [Listeners]
40
+ listener :on_click
34
41
 
35
42
  def focusable? = true
36
43
 
@@ -41,7 +48,7 @@ module Tuile
41
48
  def handle_key?(key)
42
49
  case key
43
50
  when Keys::ENTER, " "
44
- @on_click&.call
51
+ on_click.fire(ClickEvent.new(source: self))
45
52
  true
46
53
  else
47
54
  false
@@ -65,18 +72,19 @@ module Tuile
65
72
  def handle_mouse_down?(event)
66
73
  return false unless event.button == :left
67
74
 
68
- @on_click&.call
75
+ on_click.fire(ClickEvent.new(source: self))
69
76
  true
70
77
  end
71
78
 
79
+ # @param canvas [Canvas] see {Component#repaint}.
72
80
  # @return [void]
73
- def repaint
81
+ def repaint(canvas)
74
82
  super
75
83
  return if rect.empty?
76
84
 
77
85
  label = (StyledString.plain("[ ") + caption + StyledString.plain(" ]")).ellipsize(rect.width)
78
86
  label = label.with_bg(screen.theme.active_bg_color) if active?
79
- draw_text(rect.left, rect.top, label)
87
+ canvas.set_text(0, 0, label)
80
88
  end
81
89
  end
82
90
  end
@@ -8,7 +8,7 @@ module Tuile
8
8
  # [ ] Enable syslog forwarding
9
9
  #
10
10
  # cb = Component::Checkbox.new("Enable syslog forwarding", value: true)
11
- # cb.on_value_change = ->(on) { config.syslog = on }
11
+ # cb.on_value_change { |e| config.syslog = e.value }
12
12
  # cb.toggle # unchecks it, firing the listener with false
13
13
  # cb.checked? # => false
14
14
  #
@@ -60,9 +60,10 @@ module Tuile
60
60
  # whatever a caller assigns — and `cb.value = nil` on a fresh checkbox is
61
61
  # the no-op it looks like rather than a spurious change event.
62
62
  # @param new_value [Object] anything; truthiness decides.
63
+ # @param from_user [Boolean] see {HasValue#set_value}.
63
64
  # @return [void]
64
- def value=(new_value)
65
- super(new_value ? true : false)
65
+ def set_value(new_value, from_user:)
66
+ super(new_value ? true : false, from_user:)
66
67
  end
67
68
 
68
69
  # @return [Boolean] {#value} under its domain word — `license.checked?`
@@ -70,15 +71,15 @@ module Tuile
70
71
  def checked? = value
71
72
 
72
73
  # {#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).
74
+ # keeps routing through the one write path, {#set_value}.
75
75
  # @param new_value [Object] anything; truthiness decides.
76
76
  # @return [void]
77
77
  def checked=(new_value)
78
78
  self.value = new_value
79
79
  end
80
80
 
81
- # Flips {#value}.
81
+ # Flips {#value} — programmatically; Space, Enter and a click flip it as
82
+ # the user's ({HasValue::ValueChangeEvent#from_user?}).
82
83
  # @return [void]
83
84
  def toggle = (self.value = !value)
84
85
 
@@ -105,7 +106,7 @@ module Tuile
105
106
  def handle_key?(key)
106
107
  return false unless [" ", Keys::ENTER].include?(key)
107
108
 
108
- toggle
109
+ set_value(!value, from_user: true)
109
110
  true
110
111
  end
111
112
 
@@ -116,18 +117,19 @@ module Tuile
116
117
  def handle_mouse_down?(event)
117
118
  return false unless event.button == :left
118
119
 
119
- toggle
120
+ set_value(!value, from_user: true)
120
121
  true
121
122
  end
122
123
 
124
+ # @param canvas [Canvas] see {Component#repaint}.
123
125
  # @return [void]
124
- def repaint
126
+ def repaint(canvas)
125
127
  super
126
128
  return if rect.empty?
127
129
 
128
130
  label = (StyledString.plain(value ? "[x] " : "[ ] ") + caption).ellipsize(rect.width)
129
131
  label = label.with_bg(screen.theme.active_bg_color) if active?
130
- draw_text(rect.left, rect.top, label)
132
+ canvas.set_text(0, 0, label)
131
133
  end
132
134
  end
133
135
  end
@@ -12,7 +12,7 @@ module Tuile
12
12
  #
13
13
  # cg = Component::CheckboxGroup.new(items: %w[Errors Warnings Info])
14
14
  # cg.value = %w[Errors Info] # any Enumerable, stored as a Set
15
- # cg.on_value_change = ->(set) { filter(set) } # once per toggle
15
+ # cg.on_value_change { |e| filter(e.value) } # once per toggle
16
16
  # cg.value # => #<Set: {"Errors", "Info"}>
17
17
  # cg.item_label = ->(level) { level.name } # default :to_s
18
18
  #
@@ -69,13 +69,12 @@ module Tuile
69
69
  super()
70
70
  @item_label = :to_s.to_proc
71
71
  @value = coerce(value)
72
- @on_value_change = nil
73
72
 
74
73
  list = List.new
75
74
  # A List has no cursor at all by default (Cursor::None, position -1).
76
75
  list.cursor = List::Cursor.new
77
76
  list.renderer = method(:render_row)
78
- list.on_item_chosen = ->(_index, item) { toggle(item) }
77
+ list.on_item_chosen { |e| toggle(e.item) }
79
78
  list.items = items.to_a
80
79
  @list = list
81
80
  add_child(list, at: 0)
@@ -90,12 +89,8 @@ module Tuile
90
89
  # @return [List]
91
90
  attr_reader :list
92
91
 
93
- # @param new_rect [Rect]
94
92
  # @return [void]
95
- def rect=(new_rect)
96
- super
97
- list.rect = rect
98
- end
93
+ def relayout = list.rect = local_rect
99
94
 
100
95
  # @return [void]
101
96
  def handle_focus
@@ -135,15 +130,16 @@ module Tuile
135
130
  # changed. Stores a frozen `Set` *copy*, so a set the caller goes on
136
131
  # mutating can't reach in.
137
132
  # @param new_value [Enumerable, nil] `nil` selects nothing.
133
+ # @param from_user [Boolean] see {HasValue#set_value}.
138
134
  # @raise [TypeError] unless `new_value` is an `Enumerable` or `nil`.
139
135
  # @return [void]
140
- def value=(new_value)
136
+ def set_value(new_value, from_user:)
141
137
  selected = coerce(new_value)
142
- # HasValue#value= no-ops on an unchanged value; this guard is what also
138
+ # HasValue#set_value no-ops on an unchanged value; this guard is what also
143
139
  # skips the row rebuild.
144
140
  return if value == selected
145
141
 
146
- super(selected)
142
+ super(selected, from_user:)
147
143
  list.refresh_rows
148
144
  end
149
145
 
@@ -177,14 +173,16 @@ module Tuile
177
173
  # @param item [Object]
178
174
  # @return [void]
179
175
  def toggle(item)
180
- self.value = value.include?(item) ? value - [item] : value + [item]
176
+ set_value(value.include?(item) ? value - [item] : value + [item], from_user: true)
181
177
  end
182
178
 
183
179
  # @param item [Object]
180
+ # @param _text_width [Integer] unused: a box plus a label is as wide as it
181
+ # is, and {List} ellipsizes what will not fit.
184
182
  # @return [StyledString] the item's row: its label behind a checkmark box.
185
183
  # The {List} calls this at paint time, so the boxes track {#value}
186
184
  # without re-rendering anything but the visible rows.
187
- def render_row(item)
185
+ def render_row(item, _text_width)
188
186
  StyledString.plain(value.include?(item) ? "[x] " : "[ ] ") + label_for(item)
189
187
  end
190
188
 
@@ -10,7 +10,7 @@ module Tuile
10
10
  # combo = Component::ComboBox.new
11
11
  # combo.items = User.all # Array of any type
12
12
  # combo.item_label = ->(u) { u.full_name } # item -> shown text; default :to_s
13
- # combo.on_value_change = ->(u) { open(u) } # fires on commit, with the item
13
+ # combo.on_value_change { |e| open(e.value) } # fires on commit, with the item
14
14
  # combo.value = some_user # selects it; field shows its label
15
15
  #
16
16
  # It's the assembly you'd otherwise wire by hand — a {TextField} plus a
@@ -36,7 +36,7 @@ module Tuile
36
36
  # ({#placeholder}, {#cursor_position}); a **spec** reaches the field itself:
37
37
  #
38
38
  # field = Testing.get(Component::TextField, in: combo)
39
- # field.text = "ap" # type a query without a real loop
39
+ # field.value = "ap" # type a query without a real loop
40
40
  #
41
41
  # UI-thread-confined, like every component (see {Screen}).
42
42
  class ComboBox < Component
@@ -48,29 +48,32 @@ module Tuile
48
48
  def initialize(items: [])
49
49
  super()
50
50
  @value = nil
51
- @on_value_change = nil
52
51
  @items = items.to_a
53
52
  @item_label = :to_s.to_proc
54
53
  @filtered = []
55
- @suppressing_filter = false
56
54
 
57
55
  @field = TextField.new
58
- # One widget, one surface: this field paints no well of its own, so the
59
- # composed field's own bg_color reaches the cells the field paints.
60
- @field.bg_color = BG_INHERIT
61
- @field.on_change = ->(_text) { refill unless @suppressing_filter }
56
+ # One widget, one surface: the inner field paints no well of its own, so
57
+ # this combo's well covers it and the `▾` alike, and the combo's own
58
+ # bg_color reaches the cells the field paints.
59
+ @field.bg_color = ComponentBackground::INHERIT
60
+ bg.default_color = ComponentBackground::INPUT_WELL
61
+ # Only the user's typing filters: a programmatic write to the field
62
+ # ({#sync_field}) must not spring the dropdown open.
63
+ @field.on_value_change { |e| refill if e.from_user? }
62
64
  # ESC is the one key this combo wants that the field consumes itself, so
63
65
  # it cannot arrive by bubbling the way {#handle_key?}'s do. With no menu
64
66
  # open it keeps the field's own meaning: cancel text entry.
65
- @field.on_escape = -> { @overlay.open? ? dismiss_menu : screen.focused = nil }
66
- add_child(@field, at: 0)
67
+ @field.escape_clears_focus = false
68
+ @field.on_escape { @overlay.open? ? dismiss_menu : screen.focused = nil }
67
69
 
68
70
  @overlay = ListDropdown.new
69
71
  # Outside-click dismissal spans the owner chain, so a click on this
70
72
  # combo's dropdown must not dismiss a dialog the combo sits in.
71
73
  @overlay.owner = self
72
- @overlay.renderer = ->(item) { @item_label.call(item) }
73
- @overlay.on_item_chosen = ->(_index, item) { commit(item) }
74
+ @overlay.renderer = ->(item, _text_width) { @item_label.call(item) }
75
+ @overlay.list.on_item_chosen { |e| commit(e.item) }
76
+ add_child(@field, at: 0)
74
77
  end
75
78
 
76
79
  # @return [Array] the candidate items.
@@ -102,8 +105,9 @@ module Tuile
102
105
  # *without* opening the dropdown, then fires {#on_value_change}. `nil`
103
106
  # clears the selection (blank field). The value need not be in {#items}.
104
107
  # @param new_value [Object]
108
+ # @param from_user [Boolean] see {HasValue#set_value}.
105
109
  # @return [void]
106
- def value=(new_value)
110
+ def set_value(new_value, from_user:)
107
111
  return if value == new_value
108
112
 
109
113
  sync_field(display_for(new_value))
@@ -127,15 +131,12 @@ module Tuile
127
131
  field.placeholder = text
128
132
  end
129
133
 
130
- # Resizes the field and re-anchors the dropdown if it is open.
131
- # @param new_rect [Rect]
134
+ # Resizes the field; the open dropdown follows the combo by itself.
132
135
  # @return [void]
133
- def rect=(new_rect)
134
- super
136
+ def relayout
135
137
  # One row, or none at all when the combo itself was given none — a
136
138
  # starved parent must not hand out a rect it doesn't own.
137
- field.rect = Rect.new(rect.left, rect.top, [rect.width - 1, 0].max, [rect.height, 1].min)
138
- anchor if @overlay.open?
139
+ field.rect = Rect.new(0, 0, [rect.width - 1, 0].max, [rect.height, 1].min)
139
140
  end
140
141
 
141
142
  # @return [void]
@@ -198,21 +199,15 @@ module Tuile
198
199
  true
199
200
  end
200
201
 
202
+ # @param canvas [Canvas] see {Component#repaint}.
201
203
  # @return [void]
202
- def repaint
204
+ def repaint(canvas)
203
205
  super
204
206
  return if rect.empty?
205
207
 
206
- draw_char(rect.left + rect.width - 1, rect.top, "▾")
208
+ canvas.set_char(rect.width - 1, 0, "▾")
207
209
  end
208
210
 
209
- # The field well the whole face sits on — the inner {Component::TextField}
210
- # is marked {Component::BG_INHERIT} so this one covers both it and the `▾`
211
- # (exactly one well per widget), which is what makes {Component#bg_color}
212
- # on the ComboBox reach the cells the field paints.
213
- # @return [Color]
214
- def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color
215
-
216
211
  # The one row this combo paints — the full width, at the top of {#rect}.
217
212
  # A single-slot container hands its content the whole inner rect, so a
218
213
  # ComboBox is routinely assigned more height than it uses; the dropdown
@@ -223,8 +218,9 @@ module Tuile
223
218
  # Declines the default's blank: `field` covers every column of the face but
224
219
  # the last, and this combo paints the `▾` into that one, so blanking would
225
220
  # only dirty a cell it is about to repaint (`D_progress_bar`).
221
+ # @param _canvas [Canvas] the surface to paint onto.
226
222
  # @return [void]
227
- def clear_inside_extent = nil
223
+ def clear_inside_extent(_canvas) = nil
228
224
 
229
225
  private
230
226
 
@@ -250,7 +246,6 @@ module Tuile
250
246
  else
251
247
  @overlay.items = @filtered
252
248
  @overlay.cursor = List::Cursor.new(position: @filtered.index(value) || 0)
253
- @overlay.open unless @overlay.open?
254
249
  anchor
255
250
  end
256
251
  end
@@ -273,7 +268,7 @@ module Tuile
273
268
  # @return [void]
274
269
  def commit(item)
275
270
  close_menu
276
- self.value = item
271
+ set_value(item, from_user: true)
277
272
  end
278
273
 
279
274
  # @return [void]
@@ -287,20 +282,15 @@ module Tuile
287
282
 
288
283
  # Sets the field's text without triggering a refilter — for programmatic
289
284
  # value changes and query reverts, which must not spring the dropdown.
290
- # Every programmatic write to the field goes through here; a direct
291
- # `field.text =` reaches the field's `on_change` and pops the dropdown
292
- # open on a {#value=} the user never asked to browse.
293
- # Parks the caret at the end: `text=` only *clamps* the caret, so a
285
+ # The write is `from_user: false`, which is all the field's listener
286
+ # checks. Parks the caret at the end: `value=` only *clamps* the caret, so a
294
287
  # shorter query replaced by a longer label would otherwise strand it
295
288
  # mid-word (commit "Go", then pick "Kotlin" → caret after "Ko").
296
289
  # @param text [String]
297
290
  # @return [void]
298
291
  def sync_field(text)
299
- @suppressing_filter = true
300
- field.text = text
292
+ field.value = text
301
293
  field.caret = field.text.length
302
- ensure
303
- @suppressing_filter = false
304
294
  end
305
295
 
306
296
  # @param item [Object]
@@ -312,7 +302,7 @@ module Tuile
312
302
  # labels, which ellipsize a column earlier once the list scrolls. That is
313
303
  # the trade a measuring driver ({Select}) makes the other way.
314
304
  # @return [void]
315
- def anchor = @overlay.anchor_to(extent_rect, rows: @filtered.size)
305
+ def anchor = @overlay.anchor_to(self)
316
306
  end
317
307
  end
318
308
  end