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
@@ -38,7 +38,7 @@ module Tuile
38
38
  # The `█`/`░` pair is the same one {VerticalScrollBar} uses — East-Asian
39
39
  # Ambiguous and Neutral respectively, so under an ambiguous-as-wide terminal
40
40
  # the rendered length would vary with the fill level. Shipped anyway, per
41
- # `DECISIONS.md` `D_ambiguous_width`: a bar that rhymes with the scrollbar
41
+ # `design/decisions.md` `D_ambiguous_width`: a bar that rhymes with the scrollbar
42
42
  # beats a third convention, and if that bet is ever reversed both swap
43
43
  # together.
44
44
  class ProgressBar < Component
@@ -148,7 +148,7 @@ module Tuile
148
148
 
149
149
  # Sets the color of both glyphs, live-resolved at paint time when given a
150
150
  # {Theme::Ref} (so it follows a {Screen#theme=} with no
151
- # {Component#on_theme_changed} hook).
151
+ # {Component#handle_theme_changed} hook).
152
152
  #
153
153
  # bar.bar_color = Color::GREEN
154
154
  # bar.bar_color = Theme.ref(:brand_ok) # an app #custom token
@@ -192,10 +192,16 @@ module Tuile
192
192
  end
193
193
 
194
194
  # @return [void]
195
- def on_attached = sync_ticker
195
+ def handle_attached
196
+ super
197
+ sync_ticker
198
+ end
196
199
 
197
200
  # @return [void]
198
- def on_detached = sync_ticker
201
+ def handle_detached
202
+ super
203
+ sync_ticker
204
+ end
199
205
 
200
206
  # Paints the bar on the first row of {#rect} and blanks the rest.
201
207
  #
@@ -22,12 +22,13 @@ module Tuile
22
22
  # already-selected row is a no-op rather than a deselect.
23
23
  #
24
24
  # Composes rather than subclasses, like {ComboBox}: a {List} of the items
25
- # is its single {HasContent} child, which is where the cursor, scrolling,
26
- # the scrollbar and per-row mouse hit-testing come from — the group only
27
- # supplies the {List#renderer} that puts the marker in front of the label.
28
- # `content` is that list, so an app can tune it (`scrollbar_visibility`,
29
- # `show_cursor_when_inactive`, …). Rows beyond {#rect}'s height scroll; the
30
- # inner list is the tab stop, not the group.
25
+ # is its single child, which is where the cursor, scrolling, the scrollbar
26
+ # and per-row mouse hit-testing come from — the group only supplies the
27
+ # {List#renderer} that puts the marker in front of the label. {#list} is
28
+ # that list, exposed read-only so an app can tune it
29
+ # (`scrollbar_visibility`, `show_cursor_when_inactive`, …) but never swap it
30
+ # out. Rows beyond {#rect}'s height scroll; the inner list is the tab stop,
31
+ # not the group.
31
32
  #
32
33
  # == The cursor is chrome
33
34
  # The cursor and the selection are two independent things, as in
@@ -36,7 +37,7 @@ module Tuile
36
37
  # {#value=} therefore does *not* move the cursor. An app that wants it
37
38
  # parked on the selection parks it:
38
39
  #
39
- # rg.content.cursor = List::Cursor.new(position: rg.items.index(rg.value))
40
+ # rg.list.cursor = List::Cursor.new(position: rg.items.index(rg.value))
40
41
  #
41
42
  # {#items=} is the one thing that moves it, clamping it back into range.
42
43
  #
@@ -60,7 +61,6 @@ module Tuile
60
61
  #
61
62
  # UI-thread-confined, like every component (see {Screen}).
62
63
  class RadioGroup < Component
63
- include HasContent
64
64
  include HasValue
65
65
 
66
66
  # @param items [Array] the items to present, one row each; also settable
@@ -80,11 +80,36 @@ module Tuile
80
80
  list.renderer = method(:render_row)
81
81
  list.on_item_chosen = ->(_index, item) { self.value = item }
82
82
  list.items = items.to_a
83
- self.content = list
83
+ @list = list
84
+ add_child(list, at: 0)
85
+ end
86
+
87
+ # The composed {List}: an app may *tune* it — its scrollbar, its cursor,
88
+ # `show_cursor_when_inactive` — but never replace it, since this group's
89
+ # renderer and selection are wired into this one (`design/decisions.md`
90
+ # `D_has_content`). Those knobs are {List} concepts rather than group
91
+ # concepts, which is why they are reached here instead of forwarded
92
+ # (`D_wrapping_field`).
93
+ # @return [List]
94
+ attr_reader :list
95
+
96
+ # @param new_rect [Rect]
97
+ # @return [void]
98
+ def rect=(new_rect)
99
+ super
100
+ list.rect = rect
101
+ end
102
+
103
+ # @return [void]
104
+ def handle_focus
105
+ super
106
+ # The list is what the arrows drive, so it takes the focus this group
107
+ # was given; the group itself claims only Space.
108
+ screen.focused = list if list.focusable?
84
109
  end
85
110
 
86
111
  # @return [Array] the presented items.
87
- def items = content.items
112
+ def items = list.items
88
113
 
89
114
  # @return [Proc, Method] item -> row label (a `String`, {StyledString}, or
90
115
  # anything with `#to_s`); `:to_s` by default.
@@ -101,14 +126,14 @@ module Tuile
101
126
  # Before the items land, so the single {List#on_cursor_changed} that
102
127
  # {List#items=} fires reports the final row rather than a stale one.
103
128
  clamp_cursor(new_items.size)
104
- content.items = new_items
129
+ list.items = new_items
105
130
  end
106
131
 
107
132
  # @param proc [Proc, Method] item -> row label.
108
133
  # @return [void]
109
134
  def item_label=(proc)
110
135
  @item_label = proc
111
- content.refresh_rows
136
+ list.refresh_rows
112
137
  end
113
138
 
114
139
  # Selects `new_value`, firing {HasValue#on_value_change} when it really
@@ -122,7 +147,7 @@ module Tuile
122
147
  return if value == new_value
123
148
 
124
149
  super
125
- content.refresh_rows
150
+ list.refresh_rows
126
151
  end
127
152
 
128
153
  # Selects the cursor row on Space. Nothing else is claimed: the composed
@@ -131,20 +156,13 @@ module Tuile
131
156
  # neither of us wants bubbles on to an ancestor.
132
157
  # @param key [String]
133
158
  # @return [Boolean]
134
- def handle_key(key)
159
+ def handle_key?(key)
135
160
  return false unless key == " "
136
161
 
137
- select_at(content.cursor.position)
162
+ select_at(list.cursor.position)
138
163
  true
139
164
  end
140
165
 
141
- protected
142
-
143
- # Places the composed list across the whole rect ({HasContent} hook).
144
- # @param list [Component]
145
- # @return [void]
146
- def layout(list) = (list.rect = rect)
147
-
148
166
  private
149
167
 
150
168
  # Selects the item on row `index`; an index outside {#items} is ignored —
@@ -172,7 +190,7 @@ module Tuile
172
190
  # @param item_count [Integer] size of the incoming item list.
173
191
  # @return [void]
174
192
  def clamp_cursor(item_count)
175
- cursor = content.cursor
193
+ cursor = list.cursor
176
194
  # go_to_last funnels through Cursor#go's clamp(0, nil), so an empty
177
195
  # items list floors at 0 instead of going negative.
178
196
  cursor.go_to_last(item_count) if cursor.position >= item_count
@@ -42,7 +42,7 @@ module Tuile
42
42
  # Home/End are declined too, so they stay available app-wide.
43
43
  #
44
44
  # There is no type-ahead: a hidden prefix buffer *is* the ComboBox query with
45
- # the feedback removed (`DECISIONS.md` `D_select`). Which is also why labels
45
+ # the feedback removed (`design/decisions.md` `D_select`). Which is also why labels
46
46
  # need no prefix-disambiguation.
47
47
  #
48
48
  # == Implementation details
@@ -138,7 +138,7 @@ module Tuile
138
138
  # is left unhandled so it bubbles to an ancestor.
139
139
  # @param key [String]
140
140
  # @return [Boolean]
141
- def handle_key(key)
141
+ def handle_key?(key)
142
142
  if @overlay.open?
143
143
  return true if @overlay.move(key)
144
144
 
@@ -159,22 +159,21 @@ module Tuile
159
159
  # The one row this Select paints — the full width, at the top of {#rect}.
160
160
  # A single-slot container ({Component::Window}, {Component::Popup}) hands
161
161
  # its content the whole inner rect, so a Select is routinely assigned more
162
- # height than it uses; {#repaint} clears that tail, {#handle_mouse} refuses
163
- # clicks in it, and the dropdown hangs under this rather than under the
162
+ # height than it uses; {#repaint} clears that tail, a press in it never reaches
163
+ # {#handle_mouse_down?}, and the dropdown hangs under this rather than under the
164
164
  # unused space.
165
165
  # @return [Size]
166
166
  def extent = Size.new(rect.width, 1)
167
167
 
168
- # Toggles the dropdown on a left click anywhere in {#extent} — a field's
169
- # affordance is its whole row, as the well advertises; `super` runs first,
170
- # so the click also focuses.
171
- # @param event [MouseEvent]
172
- # @return [void]
173
- def handle_mouse(event)
174
- super
175
- return unless event.button == :left && extent_rect.contains?(event.point)
168
+ # Toggles the dropdown on a left press anywhere in {#extent} — a field's
169
+ # affordance is its whole row, as the well advertises.
170
+ # @param event [Mouse::DownEvent]
171
+ # @return [Boolean]
172
+ def handle_mouse_down?(event)
173
+ return false unless event.button == :left
176
174
 
177
175
  @overlay.open? ? close_menu : open_menu
176
+ true
178
177
  end
179
178
 
180
179
  # @return [void]
@@ -185,17 +184,25 @@ module Tuile
185
184
  draw_text(rect.left, rect.top, face_row)
186
185
  end
187
186
 
187
+ # The field well this Select's face sits on — {Theme#active_bg_color}
188
+ # while on the focus chain, {Theme#input_bg_color} otherwise. A Select has
189
+ # no caret, so the focus shade is its only indicator: an app that flattens
190
+ # it with a plain {Component#bg_color} is choosing that, and can keep the
191
+ # pair with `bg_color = { normal: …, active: … }`.
192
+ # @return [Color]
193
+ def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color
194
+
188
195
  private
189
196
 
190
197
  # The painted row: the value's label padded across all but the last column,
191
- # then the `▾`, all on the field well — {Theme#active_bg_color} while on the
192
- # focus chain, {Theme#input_bg_color} otherwise.
198
+ # then the `▾`. The well underneath is {#default_bg_color}, applied by
199
+ # {Component#draw_text} — so a label span carrying its own background keeps
200
+ # it, where the old override-all fill flattened it.
193
201
  # @return [StyledString]
194
202
  def face_row
195
203
  width = [rect.width - 1, 0].max
196
204
  label = label_for(value).ellipsize(width)
197
- row = label + StyledString.plain("#{" " * (width - label.display_width)}▾")
198
- row.with_bg(active? ? screen.theme.active_bg_color : screen.theme.input_bg_color)
205
+ label + StyledString.plain("#{" " * (width - label.display_width)}▾")
199
206
  end
200
207
 
201
208
  # Rebuilds the dropdown's rows, highlight and geometry, opening it if
@@ -23,7 +23,7 @@ module Tuile
23
23
  # (what {Window} does with an absent footer); never detach it.
24
24
  #
25
25
  # Transparent to input: not {Component#focusable?},
26
- # {Component#handle_mouse} descends through it, and a departing occupant's
26
+ # the mouse routes straight through it, and a departing occupant's
27
27
  # focus repair is handed to the container.
28
28
  class Slot < Component
29
29
  include Component::HasContent
@@ -40,8 +40,8 @@ module Tuile
40
40
  # nothing to bubble from.
41
41
  # @param child [Component] the just-detached occupant.
42
42
  # @return [void]
43
- def on_child_removed(child)
44
- parent&.on_child_removed(child)
43
+ def handle_child_removed(child)
44
+ parent&.handle_child_removed(child)
45
45
  end
46
46
 
47
47
  protected
@@ -26,11 +26,11 @@ module Tuile
26
26
  # designing around:
27
27
  #
28
28
  # - A hidden pane is invisible to *everything*: the Tab cycle, focus
29
- # cascades, repaint, the cursor, `on_tree` walks. No gates anywhere.
29
+ # cascades, repaint, the cursor, `walk_tree` walks. No gates anywhere.
30
30
  # - Its state survives, because state is ivars — scroll position, caret,
31
31
  # list cursor, text are all exactly as the user left them, and mutating a
32
32
  # hidden pane is safe (`invalidate` while detached is a silent no-op).
33
- # - {Component#on_detached} / {Component#on_attached} fire on every switch,
33
+ # - {Component#handle_detached} / {Component#handle_attached} fire on every switch,
34
34
  # so a pane holding a mounted-lifetime resource — a {Component::ProgressBar}'s
35
35
  # ticker — releases it while hidden and re-acquires it on return. A pane
36
36
  # that must keep something alive while hidden can't; that something
@@ -40,7 +40,7 @@ module Tuile
40
40
  # `children` is `[strip, pane]`, the strip pinned at index 0, so pre-order
41
41
  # traversal gives the strip-then-pane Tab order for free. The swap follows
42
42
  # the slot-swap recipe {Component#detach_child} documents — detach, rewire,
43
- # `on_child_removed` last, so the focus repair sees the new occupant.
43
+ # `handle_child_removed` last, so the focus repair sees the new occupant.
44
44
  #
45
45
  # Panes live in an identity-keyed `Tab => Component` map here rather than in
46
46
  # a slot on {Tabs::Tab}: the strip's tab array stays the sole ordering
@@ -169,7 +169,7 @@ module Tuile
169
169
  # Sends focus to the strip: a sheet is a container, and the strip is where
170
170
  # a tab switch is driven from. The pane is a Tab press away.
171
171
  # @return [void]
172
- def on_focus
172
+ def handle_focus
173
173
  super
174
174
  screen.focused = @strip
175
175
  end
@@ -179,7 +179,7 @@ module Tuile
179
179
  # action was a tab switch.
180
180
  # @param child [Component]
181
181
  # @return [void]
182
- def on_child_removed(child)
182
+ def handle_child_removed(child)
183
183
  super
184
184
  screen.focused = @strip if attached? && screen.focused.equal?(self)
185
185
  end
@@ -205,7 +205,7 @@ module Tuile
205
205
  layout_pane
206
206
  end
207
207
  invalidate
208
- on_child_removed(old) unless old.nil?
208
+ handle_child_removed(old) unless old.nil?
209
209
  end
210
210
 
211
211
  # Drops entries whose tab is gone. {Tabs::Tab#remove} takes a tab off the
@@ -29,7 +29,7 @@ module Tuile
29
29
  #
30
30
  # {Tab} handles are minted by {#add_tab} and owned by the strip. There is no
31
31
  # `items=`: a tab is identity plus its own state, so the set grows and
32
- # shrinks one tab at a time. See book ch7 and `DECISIONS.md` `D_tabs`.
32
+ # shrinks one tab at a time. See book ch7 and `design/decisions.md` `D_tabs`.
33
33
  #
34
34
  # == Sizing
35
35
  # Assign a {#rect} (typically from the surrounding {Layout}). One wider than
@@ -275,7 +275,7 @@ module Tuile
275
275
  #
276
276
  # Both the focus highlight and the click hit test use it, so a click on the
277
277
  # blank tail — or on a lower row, when the rect is taller than one —
278
- # selects nothing. It still *focuses*: {Component#handle_mouse}'s
278
+ # selects nothing. It still *focuses*: {Mouse::Router}'s
279
279
  # click-to-focus is ungated by geometry.
280
280
  # @return [Size]
281
281
  def extent
@@ -291,7 +291,7 @@ module Tuile
291
291
  # bubbles to an ancestor; an empty strip handles nothing at all.
292
292
  # @param key [String]
293
293
  # @return [Boolean]
294
- def handle_key(key)
294
+ def handle_key?(key)
295
295
  case key
296
296
  when Keys::LEFT_ARROW then select_previous
297
297
  when Keys::RIGHT_ARROW then select_next
@@ -299,16 +299,16 @@ module Tuile
299
299
  end
300
300
  end
301
301
 
302
- # Selects the tab under a left click; `super` runs first, so a click
303
- # anywhere in {#rect} still focuses.
304
- # @param event [MouseEvent]
305
- # @return [void]
306
- def handle_mouse(event)
307
- super
308
- return unless event.button == :left
302
+ # Selects the tab under a left press; a press between tabs selects
303
+ # nothing and still claims the strip.
304
+ # @param event [Mouse::DownEvent]
305
+ # @return [Boolean]
306
+ def handle_mouse_down?(event)
307
+ return false unless event.button == :left
309
308
 
310
309
  tab = tab_at(event.point)
311
310
  self.selected = tab if tab
311
+ true
312
312
  end
313
313
 
314
314
  # @return [void]
@@ -339,7 +339,7 @@ module Tuile
339
339
  # The rect's *width* is the only part of it the offset depends on, so this
340
340
  # hook is the whole geometry story; {Component#rect=} invalidates for us.
341
341
  # @return [void]
342
- def on_width_changed
342
+ def handle_width_changed
343
343
  super
344
344
  adjust_left_column
345
345
  end
@@ -31,7 +31,7 @@ module Tuile
31
31
  # class PromptArea < Component::TextArea
32
32
  # protected
33
33
  #
34
- # def handle_text_input_key(key)
34
+ # def handle_text_input_key?(key)
35
35
  # return recall_previous if key == Keys::UP_ARROW && caret_row.zero?
36
36
  # return recall_next if key == Keys::DOWN_ARROW && caret_row == row_count - 1
37
37
  #
@@ -39,8 +39,7 @@ module Tuile
39
39
  # end
40
40
  # end
41
41
  #
42
- # Both recalls return `true` to consume the key; app code that would rather
43
- # not subclass claims the same keys through {AbstractStringField#on_key}.
42
+ # Both recalls return `true` to consume the key.
44
43
  #
45
44
  # == Implementation details
46
45
  #
@@ -88,11 +87,10 @@ module Tuile
88
87
  Point.new(rect.left + col.clamp(0, rect.width - 1), rect.top + row_in_viewport)
89
88
  end
90
89
 
91
- # @param event [MouseEvent]
92
- # @return [void]
93
- def handle_mouse(event)
94
- super
95
- return unless event.button == :left && rect.contains?(event.point)
90
+ # @param event [Mouse::DownEvent]
91
+ # @return [Boolean]
92
+ def handle_mouse_down?(event)
93
+ return false unless event.button == :left
96
94
 
97
95
  target_row = (event.y - rect.top) + @scroll_top_row
98
96
  self.caret = if target_row >= wrap.row_count
@@ -100,6 +98,7 @@ module Tuile
100
98
  else
101
99
  wrap.index_at(target_row, event.x - rect.left)
102
100
  end
101
+ true
103
102
  end
104
103
 
105
104
  # @return [void]
@@ -108,31 +107,36 @@ module Tuile
108
107
 
109
108
  (0...rect.height).each do |row_in_viewport|
110
109
  line = wrap.row_text(row_in_viewport + @scroll_top_row)
111
- screen.buffer.set_text(rect.left, rect.top + row_in_viewport, background(line))
110
+ draw_text(rect.left, rect.top + row_in_viewport, StyledString.plain(line))
112
111
  end
113
112
  end
114
113
 
115
114
  protected
116
115
 
117
116
  # @return [void]
118
- def on_text_mutated
117
+ def handle_text_mutated
118
+ super
119
119
  @wrap = nil
120
120
  adjust_scroll_top_row
121
121
  end
122
122
 
123
123
  # @return [void]
124
- def on_caret_mutated
124
+ def handle_caret_mutated
125
+ super
125
126
  adjust_scroll_top_row
126
127
  end
127
128
 
129
+ # HOME/END and CTRL+U act on the caret's **row** — the wrapped one, not
130
+ # the `\n` line — so CTRL+U kills back to exactly where HOME would go.
128
131
  # @param key [String]
129
132
  # @return [Boolean]
130
- def handle_text_input_key(key)
133
+ def handle_text_input_key?(key)
131
134
  case key
132
135
  when Keys::UP_ARROW then move_caret_vertical(-1)
133
136
  when Keys::DOWN_ARROW then move_caret_vertical(1)
134
137
  when *Keys::HOMES then move_caret_to_row_start
135
138
  when *Keys::ENDS_ then move_caret_to_row_end
139
+ when Keys::CTRL_U then delete_back_to(caret_row_start)
136
140
  when *Keys::BACKSPACES then delete_before_caret
137
141
  when Keys::DELETE then delete_at_caret
138
142
  when Keys::ENTER, Keys::CTRL_J then insert_char("\n")
@@ -145,7 +149,7 @@ module Tuile
145
149
  end
146
150
 
147
151
  # @return [void]
148
- def on_width_changed
152
+ def handle_width_changed
149
153
  super
150
154
  @wrap = nil
151
155
  adjust_scroll_top_row
@@ -173,9 +177,12 @@ module Tuile
173
177
  self.caret = wrap.index_at(new_row, cur_col)
174
178
  end
175
179
 
180
+ # @return [Integer] index of the first character of the caret's row.
181
+ def caret_row_start = wrap.row_start(wrap.row_at(@caret))
182
+
176
183
  # @return [void]
177
184
  def move_caret_to_row_start
178
- self.caret = wrap.row_start(wrap.row_at(@caret))
185
+ self.caret = caret_row_start
179
186
  end
180
187
 
181
188
  # @return [void]
@@ -183,12 +190,13 @@ module Tuile
183
190
  self.caret = wrap.row_end(wrap.row_at(@caret))
184
191
  end
185
192
 
193
+ # Routes a typed character (or the ENTER newline) through
194
+ # {AbstractStringField#insert_text}, the same mutation a paste lands on.
186
195
  # @param char [String]
187
- # @return [Boolean] always true.
196
+ # @return [Boolean] always true — a rejected character is swallowed rather
197
+ # than declined, so typing never falls through to a scope-wide binding.
188
198
  def insert_char(char)
189
- new_text = @text.dup.insert(@caret, char)
190
- @caret += char.length
191
- self.text = new_text
199
+ insert_text(char)
192
200
  true
193
201
  end
194
202
 
@@ -12,7 +12,8 @@ module Tuile
12
12
  # f.caret = 0 # paints "hello " — left_column 0
13
13
  #
14
14
  # The field's width never bounds its contents — {#max_text_length} does, and
15
- # only for typing.
15
+ # only for typing. An empty field paints its {HasPlaceholder#placeholder}
16
+ # instead, when it has one.
16
17
  #
17
18
  # == Implementation details
18
19
  #
@@ -21,7 +22,7 @@ module Tuile
21
22
  # - an **index** counts characters into {#text} — {#caret},
22
23
  # {#max_text_length}, `text[i]`, every edit;
23
24
  # - a **column** counts terminal cells — {#rect}, {#cursor_position}, a
24
- # {MouseEvent}, and the private horizontal scroll offset `left_column`.
25
+ # {Mouse::DownEvent}, and the private horizontal scroll offset `left_column`.
25
26
  #
26
27
  # They coincide only while every glyph is one column wide. A fullwidth CJK
27
28
  # char is two columns and a combining mark zero, so index 3 of `"日本語"` is
@@ -40,8 +41,11 @@ module Tuile
40
41
  # overriding the paint alone leaves the measurements on the buffer while the
41
42
  # cells show the substitute, and the two drift apart by a growing offset.
42
43
  class TextField < AbstractStringField
44
+ include HasPlaceholder
45
+
43
46
  def initialize
44
47
  super
48
+ @placeholder = nil
45
49
  @left_column = 0
46
50
  @max_text_length = nil
47
51
  @on_key_up = nil
@@ -99,32 +103,37 @@ module Tuile
99
103
  Point.new(rect.left + offset, rect.top)
100
104
  end
101
105
 
102
- # Places the caret at the clicked column. A click on the right half of a
106
+ # Places the caret at the pressed column. A press on the right half of a
103
107
  # wide glyph lands *after* it, as in any editor.
104
- # @param event [MouseEvent]
105
- # @return [void]
106
- def handle_mouse(event)
107
- super
108
- return unless event.button == :left && rect.contains?(event.point)
108
+ # @param event [Mouse::DownEvent]
109
+ # @return [Boolean]
110
+ def handle_mouse_down?(event)
111
+ return false unless event.button == :left
109
112
 
110
113
  self.caret = index_at(event.x - rect.left + @left_column)
114
+ true
111
115
  end
112
116
 
113
117
  # @return [void]
114
118
  def repaint
115
119
  return if rect.empty?
116
120
 
117
- screen.buffer.set_text(rect.left, rect.top, background(visible_text))
121
+ return draw_text(rect.left, rect.top, placeholder_row) if show_placeholder?
122
+
123
+ draw_text(rect.left, rect.top, StyledString.plain(visible_text))
118
124
  end
119
125
 
120
126
  protected
121
127
 
128
+ # CTRL+U kills back to the start of the field — with the caret at the end,
129
+ # "clear what I typed".
122
130
  # @param key [String]
123
131
  # @return [Boolean]
124
- def handle_text_input_key(key)
132
+ def handle_text_input_key?(key)
125
133
  case key
126
134
  when *Keys::HOMES then self.caret = 0
127
135
  when *Keys::ENDS_ then self.caret = @text.length
136
+ when Keys::CTRL_U then delete_back_to(0)
128
137
  when *Keys::BACKSPACES then delete_before_caret
129
138
  when Keys::DELETE then delete_at_caret
130
139
  when Keys::UP_ARROW
@@ -147,31 +156,38 @@ module Tuile
147
156
  true
148
157
  end
149
158
 
150
- # Flattens the paste onto the field's one row — newlines become spaces —
151
- # and trims it to what {#max_text_length} still allows. Trimming rather
152
- # than rejecting: a paste that overshoots the cap fills the field, which
153
- # is what typing the same characters would have done.
159
+ # Keeps the paste's **first line** and drops the rest, then trims what's
160
+ # left to what {#max_text_length} still allows:
161
+ #
162
+ # f.handle_paste("name\nstreet\ncity")
163
+ # f.text # => "name"
164
+ #
165
+ # Overshooting the cap trims rather than rejects, which is what typing the
166
+ # same characters would have done. Why the first line, and not a
167
+ # newline-to-space flattening: `D_paste_newlines`.
154
168
  # @param text [String]
155
169
  # @return [String]
156
170
  def preprocess_paste(text)
157
- flat = super.tr("\n", " ")
158
- return flat if @max_text_length.nil?
171
+ first_line = super[/\A[^\n]*/]
172
+ return first_line if @max_text_length.nil?
159
173
 
160
- flat[0, [@max_text_length - @text.length, 0].max] || ""
174
+ first_line[0, [@max_text_length - @text.length, 0].max] || ""
161
175
  end
162
176
 
163
177
  # @return [void]
164
- def on_text_mutated
178
+ def handle_text_mutated
179
+ super
165
180
  adjust_left_column
166
181
  end
167
182
 
168
183
  # @return [void]
169
- def on_caret_mutated
184
+ def handle_caret_mutated
185
+ super
170
186
  adjust_left_column
171
187
  end
172
188
 
173
189
  # @return [void]
174
- def on_width_changed
190
+ def handle_width_changed
175
191
  super
176
192
  adjust_left_column
177
193
  end
@@ -186,16 +202,16 @@ module Tuile
186
202
 
187
203
  private
188
204
 
205
+ # Routes a typed character through {AbstractStringField#insert_text}, the
206
+ # same mutation a paste lands on.
189
207
  # @param char [String]
190
- # @return [Boolean] always true — a field at {#max_text_length} swallows the
191
- # key rather than declining it, so typing can never fall through to a
192
- # scope-wide binding.
208
+ # @return [Boolean] always true — a field that is at {#max_text_length},
209
+ # or that rejected the character, swallows the key rather than declining
210
+ # it, so typing can never fall through to a scope-wide binding.
193
211
  def insert(char)
194
212
  return true if @max_text_length && @text.length >= @max_text_length
195
213
 
196
- new_text = @text.dup.insert(@caret, char)
197
- @caret += 1
198
- self.text = new_text
214
+ insert_text(char)
199
215
  true
200
216
  end
201
217
 
@@ -221,6 +237,19 @@ module Tuile
221
237
  i
222
238
  end
223
239
 
240
+ # @return [Boolean] true when the empty field should paint its
241
+ # {HasPlaceholder#placeholder} instead of its (blank) contents.
242
+ def show_placeholder? = @text.empty? && !placeholder.to_s.empty?
243
+
244
+ # {#repaint} does not call `super`, so this padded row is the only thing
245
+ # that clears the rect: it *is* the well.
246
+ # @return [StyledString] the hint, ellipsized to `rect.width` and padded
247
+ # back out to it.
248
+ def placeholder_row
249
+ hint = StyledString.styled(placeholder, fg: screen.theme.placeholder_color).ellipsize(rect.width)
250
+ hint + StyledString.plain(" " * [rect.width - hint.display_width, 0].max)
251
+ end
252
+
224
253
  # @return [Integer] total display width of {#text}.
225
254
  def text_columns = column_at(@text.length)
226
255