tuile 0.14.0 → 0.16.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +159 -49
  3. data/README.md +53 -17
  4. data/book/04-event-loop.md +12 -12
  5. data/book/05-focus.md +152 -22
  6. data/book/06-theming.md +105 -25
  7. data/book/07-components.md +531 -50
  8. data/book/08-testing.md +100 -20
  9. data/book/10-locale.md +216 -0
  10. data/book/README.md +19 -9
  11. data/examples/file_commander.rb +14 -5
  12. data/examples/hello_world.rb +17 -4
  13. data/examples/sampler.rb +654 -40
  14. data/lib/tuile/component/abstract_string_field.rb +114 -68
  15. data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
  16. data/lib/tuile/component/big_decimal_field.rb +52 -79
  17. data/lib/tuile/component/button.rb +8 -8
  18. data/lib/tuile/component/checkbox.rb +9 -9
  19. data/lib/tuile/component/checkbox_group.rb +38 -21
  20. data/lib/tuile/component/combo_box.rb +102 -59
  21. data/lib/tuile/component/confirm_window.rb +7 -5
  22. data/lib/tuile/component/date_field.rb +347 -0
  23. data/lib/tuile/component/date_time_field.rb +275 -0
  24. data/lib/tuile/component/float_field.rb +57 -82
  25. data/lib/tuile/component/has_bad_input.rb +88 -0
  26. data/lib/tuile/component/has_caption.rb +8 -0
  27. data/lib/tuile/component/has_content.rb +32 -13
  28. data/lib/tuile/component/has_placeholder.rb +62 -0
  29. data/lib/tuile/component/has_validation.rb +115 -0
  30. data/lib/tuile/component/has_value.rb +28 -1
  31. data/lib/tuile/component/integer_field.rb +51 -78
  32. data/lib/tuile/component/label.rb +7 -39
  33. data/lib/tuile/component/layout/box.rb +90 -19
  34. data/lib/tuile/component/layout.rb +15 -5
  35. data/lib/tuile/component/list.rb +53 -38
  36. data/lib/tuile/component/list_dropdown.rb +7 -3
  37. data/lib/tuile/component/menu_bar/cascade.rb +5 -5
  38. data/lib/tuile/component/menu_bar.rb +18 -18
  39. data/lib/tuile/component/notification.rb +32 -18
  40. data/lib/tuile/component/overlay.rb +26 -8
  41. data/lib/tuile/component/picker_window.rb +27 -8
  42. data/lib/tuile/component/popup.rb +2 -2
  43. data/lib/tuile/component/progress_bar.rb +10 -4
  44. data/lib/tuile/component/radio_group.rb +41 -23
  45. data/lib/tuile/component/select.rb +23 -16
  46. data/lib/tuile/component/slot.rb +3 -3
  47. data/lib/tuile/component/tab_sheet.rb +6 -6
  48. data/lib/tuile/component/tabs.rb +11 -11
  49. data/lib/tuile/component/text_area.rb +26 -18
  50. data/lib/tuile/component/text_field.rb +55 -26
  51. data/lib/tuile/component/text_view.rb +40 -19
  52. data/lib/tuile/component/time_field.rb +479 -0
  53. data/lib/tuile/component/window.rb +26 -13
  54. data/lib/tuile/component.rb +635 -131
  55. data/lib/tuile/event_queue.rb +4 -4
  56. data/lib/tuile/fake_event_queue.rb +1 -1
  57. data/lib/tuile/fake_screen.rb +95 -3
  58. data/lib/tuile/final.rb +75 -0
  59. data/lib/tuile/locale.rb +851 -0
  60. data/lib/tuile/mouse/router.rb +217 -0
  61. data/lib/tuile/mouse.rb +177 -0
  62. data/lib/tuile/screen.rb +219 -68
  63. data/lib/tuile/screen_pane.rb +51 -42
  64. data/lib/tuile/styled_string.rb +5 -5
  65. data/lib/tuile/testing.rb +198 -0
  66. data/lib/tuile/theme.rb +110 -32
  67. data/lib/tuile/version.rb +1 -1
  68. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  69. data/lib/tuile.rb +1 -0
  70. data/sig/tuile.rbs +4595 -825
  71. metadata +14 -9
  72. data/COMPARISON.md +0 -101
  73. data/DECISIONS.md +0 -5422
  74. data/TERMINOLOGY.md +0 -71
  75. data/ideas/arrow-key-navigation.md +0 -221
  76. data/ideas/modal-backdrop.md +0 -24
  77. data/ideas/new-components.md +0 -124
  78. data/ideas/per-component-buffers.md +0 -55
  79. data/lib/tuile/mouse_event.rb +0 -68
@@ -22,10 +22,12 @@ module Tuile
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
  #
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.
25
+ # The buffer only ever holds `0`–`9`, one leading `-` and one `.`: a key
26
+ # that would break that is dropped without moving the caret, and so is a
27
+ # *paste* that would (`"$19.99"` lands nothing, rather than sieving through
28
+ # as a price the user never copied). Up/Down step by one. Range checks
29
+ # (`min`/`max`) and a display scale (`19.9` → `19.90`) belong to a forms
30
+ # layer, not here — nothing rounds or pads what you typed.
29
31
  #
30
32
  # Requires the `bigdecimal` gem, which Tuile does *not* depend on: it is a
31
33
  # bundled gem from Ruby 3.4 on, so a `Gemfile` naming it is what puts it on
@@ -48,14 +50,18 @@ module Tuile
48
50
  # `Float` is refused on both, and display goes through `to_s("F")` — plain
49
51
  # notation, never `BigDecimal#to_s`'s `"0.1999e2"`.
50
52
  #
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`.
53
+ # It *wraps* a {TextField} rather than subclassing one, so its face carries
54
+ # only the typed {HasValue} seam and never the widget's `String`-typed
55
+ # `text`; {AbstractWrappingField} supplies the wrapping.
54
56
  #
55
57
  # UI-thread-confined, like every component (see {Screen}).
56
- class BigDecimalField < Component
57
- include HasContent
58
- include HasValue
58
+ class BigDecimalField < AbstractWrappingField
59
+ include HasBadInput
60
+
61
+ # @return [String] what {#bad_input_message} reports for a buffer that is
62
+ # typeable but not a decimal.
63
+ BAD_INPUT_MESSAGE = "not a decimal number"
64
+ private_constant :BAD_INPUT_MESSAGE
59
65
 
60
66
  # A buffer {#value} parses: an optional sign and digits with an optional
61
67
  # fractional part (either side may be empty, but not both). No exponent —
@@ -64,19 +70,42 @@ module Tuile
64
70
  NUMERIC = /\A-?(?:\d+(?:\.\d*)?|\.\d+)\z/
65
71
  private_constant :NUMERIC
66
72
 
73
+ # The face: a {TextField} that admits only the buffers a decimal can be
74
+ # typed through, however the characters arrive.
75
+ class Field < TextField
76
+ # Buffers reachable by typing a decimal: an optional leading `-` and
77
+ # digits with at most one `.`. Looser than {NUMERIC} on purpose —
78
+ # `""`, `"-"` and `"1."` are members, or the values past them could not
79
+ # be typed at all.
80
+ # @return [Regexp]
81
+ TYPEABLE = /\A-?\d*(?:\.\d*)?\z/
82
+ private_constant :TYPEABLE
83
+
84
+ protected
85
+
86
+ # Accepts the insertion only if the whole resulting buffer is still
87
+ # typeable, so a pasted `"$19.99"` is dropped rather than sieved into
88
+ # `"19.99"` — a price the user never copied.
89
+ # @param str [String]
90
+ # @return [Boolean] true if the text changed.
91
+ def insert_text(str)
92
+ return false unless TYPEABLE.match?(@text.dup.insert(@caret, str))
93
+
94
+ super
95
+ end
96
+ end
97
+
67
98
  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
99
+ super(Field.new)
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) }
74
103
  end
75
104
 
76
105
  # @return [::BigDecimal, nil] the parsed buffer; `nil` when empty or not a
77
106
  # number (e.g. a lone `"-"`).
78
107
  def value
79
- text = content.text
108
+ text = editor.text
80
109
  text.match?(NUMERIC) ? BigDecimal(normalize(text)) : nil
81
110
  end
82
111
 
@@ -91,34 +120,18 @@ module Tuile
91
120
  # @raise [TypeError] on a value `BigDecimal()` won't take at all.
92
121
  # @return [void]
93
122
  def value=(new_value)
94
- content.text = new_value.nil? ? "" : coerce(new_value).to_s("F")
95
- content.caret = content.text.length
123
+ editor.text = new_value.nil? ? "" : coerce(new_value).to_s("F")
124
+ editor.caret = editor.text.length
96
125
  end
97
126
 
98
127
  # `nil`, not `""`: a numeric field with no parseable number is empty.
99
128
  # @return [nil]
100
129
  def empty_value = nil
101
130
 
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)
131
+ # `"-"`, `"."` and `"-."` are typeable and parse to nothing; an *empty*
132
+ # buffer is empty, not bad ({HasBadInput}).
133
+ # @return [String, nil]
134
+ def bad_input_message = value.nil? && !editor.text.empty? ? BAD_INPUT_MESSAGE : nil
122
135
 
123
136
  private
124
137
 
@@ -149,51 +162,11 @@ module Tuile
149
162
  big
150
163
  end
151
164
 
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
165
  # Nudges {#value} by `delta`, treating an empty/un-parseable field as
167
166
  # zero.
168
167
  # @param delta [Integer]
169
168
  # @return [void]
170
169
  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
170
  end
198
171
  end
199
172
  end
@@ -9,7 +9,7 @@ module Tuile
9
9
  #
10
10
  # Buttons are tab stops — Tab and Shift+Tab will land on them as part of
11
11
  # the standard focus cycle. Click-to-focus also works via the inherited
12
- # {Component#handle_mouse}.
12
+ # {Component#handle_mouse_down?}.
13
13
  #
14
14
  # Assign a {#rect} (typically by the surrounding {Layout}) wide enough to
15
15
  # show `[ caption ]` — that natural width is `caption.display_width + 4`.
@@ -38,7 +38,7 @@ module Tuile
38
38
 
39
39
  # @param key [String]
40
40
  # @return [Boolean]
41
- def handle_key(key)
41
+ def handle_key?(key)
42
42
  case key
43
43
  when Keys::ENTER, " "
44
44
  @on_click&.call
@@ -52,7 +52,7 @@ module Tuile
52
52
  # columns, clipped to {#rect}. Both the focus highlight and the click hit
53
53
  # test use it, so a click on the blank tail of an over-wide rect — or on a
54
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
55
+ # It still *focuses*: {Mouse::Router}'s click-to-focus is ungated
56
56
  # by geometry. Same rule as {Checkbox#extent}, which documents the two
57
57
  # traps behind it.
58
58
  # @return [Size]
@@ -60,13 +60,13 @@ module Tuile
60
60
 
61
61
  # Fires {#on_click} on a left click within {#extent}; `super` runs first, so
62
62
  # a click anywhere in {#rect} still focuses.
63
- # @param event [MouseEvent]
64
- # @return [void]
65
- def handle_mouse(event)
66
- super
67
- return unless event.button == :left && extent_rect.contains?(event.point)
63
+ # @param event [Mouse::DownEvent]
64
+ # @return [Boolean]
65
+ def handle_mouse_down?(event)
66
+ return false unless event.button == :left
68
67
 
69
68
  @on_click&.call
69
+ true
70
70
  end
71
71
 
72
72
  # @return [void]
@@ -89,7 +89,7 @@ module Tuile
89
89
  #
90
90
  # Both the focus highlight and the click hit test use it, so a click on the
91
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
92
+ # not toggle. It still *focuses*: {Mouse::Router}'s click-to-focus
93
93
  # is ungated by geometry, and the tail is the field's own row.
94
94
  #
95
95
  # The extent ignores {Component#bg_color}: an inherited tint paints the dead
@@ -102,22 +102,22 @@ module Tuile
102
102
  # to an ancestor.
103
103
  # @param key [String]
104
104
  # @return [Boolean]
105
- def handle_key(key)
105
+ def handle_key?(key)
106
106
  return false unless [" ", Keys::ENTER].include?(key)
107
107
 
108
108
  toggle
109
109
  true
110
110
  end
111
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_rect.contains?(event.point)
112
+ # Toggles on a left press; a press on the dead tail past {#extent} focuses
113
+ # the checkbox without reaching here.
114
+ # @param event [Mouse::DownEvent]
115
+ # @return [Boolean]
116
+ def handle_mouse_down?(event)
117
+ return false unless event.button == :left
119
118
 
120
119
  toggle
120
+ true
121
121
  end
122
122
 
123
123
  # @return [void]
@@ -24,12 +24,12 @@ module Tuile
24
24
  # `cg.items & cg.value.to_a` when you need {#items} order.
25
25
  #
26
26
  # Composes rather than subclasses, like {ComboBox}: a {List} of the items is
27
- # its single {HasContent} child, which is where the cursor, scrolling, the
28
- # scrollbar and per-row mouse hit-testing come from — the group only supplies
29
- # the {List#renderer} that puts the box in front of the label. `content` is
30
- # that list, so an app can tune it (`scrollbar_visibility`,
31
- # `show_cursor_when_inactive`, …). Rows beyond {#rect}'s height scroll; the
32
- # inner list is the tab stop, not the group.
27
+ # its single child, which is where the cursor, scrolling, the scrollbar and
28
+ # per-row mouse hit-testing come from — the group only supplies the
29
+ # {List#renderer} that puts the box in front of the label. {#list} is that
30
+ # list, exposed read-only so an app can tune it (`scrollbar_visibility`,
31
+ # `show_cursor_when_inactive`, …) but never swap it out. Rows beyond
32
+ # {#rect}'s height scroll; the inner list is the tab stop, not the group.
33
33
  #
34
34
  # == +items+ is chrome; +value+ is authoritative
35
35
  # {#items=} changes only what is *presented*. It never touches {#value} and
@@ -54,7 +54,6 @@ module Tuile
54
54
  #
55
55
  # UI-thread-confined, like every component (see {Screen}).
56
56
  class CheckboxGroup < Component
57
- include HasContent
58
57
  include HasValue
59
58
 
60
59
  # @return [Set]
@@ -78,11 +77,36 @@ module Tuile
78
77
  list.renderer = method(:render_row)
79
78
  list.on_item_chosen = ->(_index, item) { toggle(item) }
80
79
  list.items = items.to_a
81
- self.content = list
80
+ @list = list
81
+ add_child(list, at: 0)
82
+ end
83
+
84
+ # The composed {List}: an app may *tune* it — its scrollbar, its cursor,
85
+ # `show_cursor_when_inactive` — but never replace it, since this group's
86
+ # renderer and selection are wired into this one (`design/decisions.md`
87
+ # `D_has_content`). Those knobs are {List} concepts rather than group
88
+ # concepts, which is why they are reached here instead of forwarded
89
+ # (`D_wrapping_field`).
90
+ # @return [List]
91
+ attr_reader :list
92
+
93
+ # @param new_rect [Rect]
94
+ # @return [void]
95
+ def rect=(new_rect)
96
+ super
97
+ list.rect = rect
98
+ end
99
+
100
+ # @return [void]
101
+ def handle_focus
102
+ super
103
+ # The list is what the arrows drive, so it takes the focus this group
104
+ # was given; the group itself claims only Space.
105
+ screen.focused = list if list.focusable?
82
106
  end
83
107
 
84
108
  # @return [Array] the presented items.
85
- def items = content.items
109
+ def items = list.items
86
110
 
87
111
  # @return [Proc, Method] item -> row label (a `String`, {StyledString}, or
88
112
  # anything with `#to_s`); `:to_s` by default.
@@ -93,14 +117,14 @@ module Tuile
93
117
  # @raise [TypeError] unless `new_items` is an `Array`.
94
118
  # @return [void]
95
119
  def items=(new_items)
96
- content.items = new_items
120
+ list.items = new_items
97
121
  end
98
122
 
99
123
  # @param proc [Proc, Method] item -> row label.
100
124
  # @return [void]
101
125
  def item_label=(proc)
102
126
  @item_label = proc
103
- content.refresh_rows
127
+ list.refresh_rows
104
128
  end
105
129
 
106
130
  # @return [Set] the frozen empty set — {HasValue#empty?} means nothing is
@@ -120,7 +144,7 @@ module Tuile
120
144
  return if value == selected
121
145
 
122
146
  super(selected)
123
- content.refresh_rows
147
+ list.refresh_rows
124
148
  end
125
149
 
126
150
  # Toggles the cursor row on Space. Nothing else is claimed: the composed
@@ -129,20 +153,13 @@ module Tuile
129
153
  # neither of us wants bubbles on to an ancestor.
130
154
  # @param key [String]
131
155
  # @return [Boolean]
132
- def handle_key(key)
156
+ def handle_key?(key)
133
157
  return false unless key == " "
134
158
 
135
- toggle_at(content.cursor.position)
159
+ toggle_at(list.cursor.position)
136
160
  true
137
161
  end
138
162
 
139
- protected
140
-
141
- # Places the composed list across the whole rect ({HasContent} hook).
142
- # @param list [Component]
143
- # @return [void]
144
- def layout(list) = (list.rect = rect)
145
-
146
163
  private
147
164
 
148
165
  # Flips membership of the item on row `index`; an index outside {#items} is
@@ -30,10 +30,18 @@ module Tuile
30
30
  # The dropdown is a {ListDropdown}, tinted to read as a floating panel; see
31
31
  # it for the theming knob.
32
32
  #
33
+ # == The inner field is private machinery
34
+ # It has no public accessor: its buffer is the *query*, so swapping the field
35
+ # would break the filtering. What is worth reaching is re-exposed here
36
+ # ({#placeholder}, {#cursor_position}); a **spec** reaches the field itself:
37
+ #
38
+ # field = Testing.get(Component::TextField, in: combo)
39
+ # field.text = "ap" # type a query without a real loop
40
+ #
33
41
  # UI-thread-confined, like every component (see {Screen}).
34
42
  class ComboBox < Component
35
- include HasContent
36
43
  include HasValue
44
+ include HasPlaceholder
37
45
 
38
46
  # @param items [Array] the candidate items (any type); also settable via
39
47
  # {#items=}.
@@ -46,10 +54,16 @@ module Tuile
46
54
  @filtered = []
47
55
  @suppressing_filter = false
48
56
 
49
- field = TextField.new
50
- field.on_change = ->(_text) { refill unless @suppressing_filter }
51
- field.on_key = method(:field_key)
52
- self.content = field
57
+ @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 }
62
+ # ESC is the one key this combo wants that the field consumes itself, so
63
+ # it cannot arrive by bubbling the way {#handle_key?}'s do. With no menu
64
+ # 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)
53
67
 
54
68
  @overlay = ListDropdown.new
55
69
  # Outside-click dismissal spans the owner chain, so a click on this
@@ -98,19 +112,39 @@ module Tuile
98
112
 
99
113
  # @return [Point, nil] the field's caret position (the combo delegates the
100
114
  # hardware cursor to its field).
101
- def cursor_position = content.cursor_position
115
+ def cursor_position = field.cursor_position
116
+
117
+ # The hint the inner field paints while empty ({HasPlaceholder}) — for a
118
+ # combo that means while nothing is selected *and* nothing is typed, so it
119
+ # reads as a prompt for the query: `"type to filter"`.
120
+ # @return [String, nil]
121
+ def placeholder = field.placeholder
102
122
 
103
- # @return [String]
123
+ # @param text [String, nil]
124
+ # @return [void]
125
+ # @raise [TypeError] unless `text` is a String or nil.
126
+ def placeholder=(text)
127
+ field.placeholder = text
128
+ end
104
129
 
105
- # Re-anchors the (open) dropdown after {HasContent#rect=} has resized the
106
- # field via {#layout}.
130
+ # Resizes the field and re-anchors the dropdown if it is open.
107
131
  # @param new_rect [Rect]
108
132
  # @return [void]
109
133
  def rect=(new_rect)
110
134
  super
135
+ # One row, or none at all when the combo itself was given none — a
136
+ # 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)
111
138
  anchor if @overlay.open?
112
139
  end
113
140
 
141
+ # @return [void]
142
+ def handle_focus
143
+ super
144
+ # The field is what edits, so it takes the focus the combo was given.
145
+ screen.focused = field if field.focusable?
146
+ end
147
+
114
148
  # Closes the dropdown and reverts an uncommitted query when the combo
115
149
  # leaves the focus chain — so tabbing away doesn't strand an open menu or
116
150
  # a half-typed filter. Safe against re-entrancy: focus never sits inside
@@ -127,15 +161,41 @@ module Tuile
127
161
  revert_query
128
162
  end
129
163
 
130
- # @param event [MouseEvent]
131
- # @return [void]
132
- def handle_mouse(event)
133
- if content.rect.contains?(event.point)
134
- content.handle_mouse(event)
135
- elsif event.button == :left && rect.contains?(event.point) # the ▾ cell
136
- content.focus
137
- @overlay.open? ? close_menu : open_menu
164
+ # Moves the dropdown's highlight ({ListDropdown::MOVE_KEYS}) and commits on
165
+ # Enter while it is open; opens it on Down or Enter while it is closed.
166
+ #
167
+ # These arrive by **bubbling**: the inner field is what holds focus, and it
168
+ # declines every one of them (it claims no Up/Down and no Enter of its
169
+ # own), so they reach this ancestor untouched while printable keys and the
170
+ # editing keys are consumed below and never get here. ESC is the exception
171
+ # — the field consumes it, so the combo takes it through
172
+ # {AbstractStringField#on_escape} instead.
173
+ # @param key [String]
174
+ # @return [Boolean] true if consumed.
175
+ def handle_key?(key)
176
+ if @overlay.open?
177
+ return true if @overlay.move(key)
178
+ return false unless key == Keys::ENTER
179
+
180
+ @overlay.choose
181
+ else
182
+ return false unless [Keys::DOWN_ARROW, Keys::ENTER].include?(key)
183
+
184
+ open_menu
138
185
  end
186
+ true
187
+ end
188
+
189
+ # Toggles the dropdown on a left press on the ▾ cell — the only cell of
190
+ # this component's own that is not the field's.
191
+ # @param event [Mouse::DownEvent]
192
+ # @return [Boolean]
193
+ def handle_mouse_down?(event)
194
+ return false unless event.button == :left
195
+
196
+ field.focus
197
+ @overlay.open? ? close_menu : open_menu
198
+ true
139
199
  end
140
200
 
141
201
  # @return [void]
@@ -143,10 +203,16 @@ module Tuile
143
203
  super
144
204
  return if rect.empty?
145
205
 
146
- well = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color
147
- draw_char(rect.left + rect.width - 1, rect.top, "▾", StyledString::Style::DEFAULT.with(bg: well))
206
+ draw_char(rect.left + rect.width - 1, rect.top, "▾")
148
207
  end
149
208
 
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
+
150
216
  # The one row this combo paints — the full width, at the top of {#rect}.
151
217
  # A single-slot container hands its content the whole inner rect, so a
152
218
  # ComboBox is routinely assigned more height than it uses; the dropdown
@@ -154,46 +220,23 @@ module Tuile
154
220
  # @return [Size]
155
221
  def extent = Size.new(rect.width, 1)
156
222
 
157
- protected
158
-
159
- # Field spans the row bar the last column, which the `▾` occupies
160
- # ({HasContent} layout hook). One row, or none at all when the combo itself
161
- # was given none — a starved parent must not hand out a rect it doesn't own.
162
- # @param field [Component]
223
+ # Declines the default's blank: `field` covers every column of the face but
224
+ # the last, and this combo paints the `▾` into that one, so blanking would
225
+ # only dirty a cell it is about to repaint (`D_progress_bar`).
163
226
  # @return [void]
164
- def layout(field)
165
- field.rect = Rect.new(rect.left, rect.top, [rect.width - 1, 0].max, [rect.height, 1].min)
166
- end
227
+ def clear_inside_extent = nil
167
228
 
168
229
  private
169
230
 
170
- # The field's key interceptor: while the dropdown is open forwards movement
171
- # to it ({ListDropdown#move}), commits on Enter ({ListDropdown#choose}),
172
- # and dismisses on ESC (reverting the query); opens it on Down or Enter
173
- # when closed. Everything else (printable keys, editing) falls through to
174
- # the field, whose {TextField#on_change} refilters.
175
- # @param key [String]
176
- # @return [Boolean] true if consumed.
177
- def field_key(key)
178
- if @overlay.open?
179
- if @overlay.move(key)
180
- true
181
- elsif key == Keys::ENTER
182
- @overlay.choose
183
- true
184
- elsif key == Keys::ESC
185
- close_menu
186
- revert_query
187
- true
188
- else
189
- false
190
- end
191
- elsif [Keys::DOWN_ARROW, Keys::ENTER].include?(key)
192
- open_menu
193
- true
194
- else
195
- false
196
- end
231
+ # @return [TextField] the inner field, holding the query.
232
+ attr_reader :field
233
+
234
+ # Dismisses the dropdown and puts the current value's label back in the
235
+ # field, undoing an uncommitted query.
236
+ # @return [void]
237
+ def dismiss_menu
238
+ close_menu
239
+ revert_query
197
240
  end
198
241
 
199
242
  # Recomputes the matches for the current query, opening the dropdown when
@@ -201,7 +244,7 @@ module Tuile
201
244
  # when there are none.
202
245
  # @return [void]
203
246
  def refill
204
- @filtered = matching(content.text)
247
+ @filtered = matching(field.text)
205
248
  if @filtered.empty?
206
249
  close_menu
207
250
  else
@@ -245,7 +288,7 @@ module Tuile
245
288
  # Sets the field's text without triggering a refilter — for programmatic
246
289
  # value changes and query reverts, which must not spring the dropdown.
247
290
  # Every programmatic write to the field goes through here; a direct
248
- # `content.text =` reaches the field's `on_change` and pops the dropdown
291
+ # `field.text =` reaches the field's `on_change` and pops the dropdown
249
292
  # open on a {#value=} the user never asked to browse.
250
293
  # Parks the caret at the end: `text=` only *clamps* the caret, so a
251
294
  # shorter query replaced by a longer label would otherwise strand it
@@ -254,8 +297,8 @@ module Tuile
254
297
  # @return [void]
255
298
  def sync_field(text)
256
299
  @suppressing_filter = true
257
- content.text = text
258
- content.caret = content.text.length
300
+ field.text = text
301
+ field.caret = field.text.length
259
302
  ensure
260
303
  @suppressing_filter = false
261
304
  end
@@ -44,7 +44,7 @@ module Tuile
44
44
  # There is deliberately no content slot: the body is prose ({#message=}
45
45
  # takes a component for the rare rich body, but the dialog then cannot
46
46
  # measure it). A dialog collecting *input* is not a confirm dialog — build a
47
- # `Popup.new(content: your_layout)`. See `DECISIONS.md` `D_confirm_window`
47
+ # `Popup.new(content: your_layout)`. See `design/decisions.md` `D_confirm_window`
48
48
  # for the API rationale.
49
49
  class ConfirmWindow < Window
50
50
  # Keys handed to the message body from anywhere in the dialog, so it
@@ -221,9 +221,11 @@ module Tuile
221
221
  end
222
222
 
223
223
  # Focus lands on the first button rather than cascading into the message
224
- # body, which sits before the button row in the tree.
224
+ # body, which sits before the button row in the tree. `super` is reached
225
+ # only when there is no button to take it: {HasContent#handle_focus} *is*
226
+ # the cascade this override exists to skip.
225
227
  # @return [void]
226
- def on_focus
228
+ def handle_focus
227
229
  first = @actions.keys.first
228
230
  if first.nil?
229
231
  super
@@ -239,11 +241,11 @@ module Tuile
239
241
  # before this runs.
240
242
  # @param key [String]
241
243
  # @return [Boolean] true if the key was handled.
242
- def handle_key(key)
244
+ def handle_key?(key)
243
245
  case key
244
246
  when Keys::LEFT_ARROW then return focus_button_step(-1)
245
247
  when Keys::RIGHT_ARROW then return focus_button_step(1)
246
- when *BODY_SCROLL_KEYS then return @body_slot.content&.handle_key(key) || false
248
+ when *BODY_SCROLL_KEYS then return @body_slot.content&.handle_key?(key) || false
247
249
  end
248
250
 
249
251
  target = @mnemonics[key.downcase]