tuile 0.12.0 → 0.13.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/DECISIONS.md +1297 -13
  4. data/README.md +136 -490
  5. data/TERMINOLOGY.md +11 -2
  6. data/book/01-first-app.md +22 -17
  7. data/book/02-repaint.md +18 -5
  8. data/book/03-layout.md +11 -10
  9. data/book/05-focus.md +133 -18
  10. data/book/06-theming.md +5 -2
  11. data/book/07-components.md +402 -12
  12. data/book/08-testing.md +18 -4
  13. data/book/README.md +7 -5
  14. data/examples/file_commander.rb +22 -16
  15. data/examples/hello_world.rb +17 -5
  16. data/examples/sampler.rb +385 -66
  17. data/ideas/arrow-key-navigation.md +16 -0
  18. data/ideas/new-components.md +7 -6
  19. data/lib/tuile/ansi.rb +10 -0
  20. data/lib/tuile/component/abstract_string_field.rb +36 -0
  21. data/lib/tuile/component/combo_box.rb +3 -1
  22. data/lib/tuile/component/list.rb +22 -0
  23. data/lib/tuile/component/list_dropdown.rb +86 -3
  24. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  25. data/lib/tuile/component/menu_bar.rb +582 -0
  26. data/lib/tuile/component/notification.rb +14 -11
  27. data/lib/tuile/component/picker_window.rb +0 -5
  28. data/lib/tuile/component/popup.rb +75 -9
  29. data/lib/tuile/component/select.rb +3 -1
  30. data/lib/tuile/component/tab_sheet.rb +242 -0
  31. data/lib/tuile/component/tabs.rb +528 -0
  32. data/lib/tuile/component/text_area.rb +5 -4
  33. data/lib/tuile/component/text_field.rb +23 -6
  34. data/lib/tuile/component/text_view.rb +8 -5
  35. data/lib/tuile/component.rb +38 -13
  36. data/lib/tuile/event_queue.rb +25 -1
  37. data/lib/tuile/fake_screen.rb +14 -0
  38. data/lib/tuile/keys.rb +65 -0
  39. data/lib/tuile/screen.rb +94 -77
  40. data/lib/tuile/screen_pane.rb +109 -27
  41. data/lib/tuile/styled_string.rb +40 -0
  42. data/lib/tuile/version.rb +1 -1
  43. data/sig/tuile.rbs +1473 -93
  44. metadata +6 -3
  45. data/mise.toml +0 -2
@@ -0,0 +1,528 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A one-row strip of captions with exactly one of them selected — the map of
6
+ # where the user is. Knows nothing about content: pair it with
7
+ # {Component::TabSheet} to swap panes, or swap views yourself from
8
+ # {#on_tab_selected}.
9
+ #
10
+ # ␣Details␣│␣Payment␣│␣Shipping␣
11
+ # ^^^^^^^ selected: bold, and highlighted while the strip has focus
12
+ #
13
+ # tabs = Component::Tabs.new
14
+ # tabs.add_tab("Details") # the first tab is selected
15
+ # payment = tabs.add_tab("Payment")
16
+ # tabs.on_tab_selected = ->(index, tab) { show(index) }
17
+ # tabs.selected = payment # fires the listener
18
+ # payment.caption = "Payment ⚠" # repaints the strip
19
+ #
20
+ # LEFT / RIGHT switch tabs immediately — no cursor to move first, no Enter
21
+ # to confirm — clamping at both ends rather than wrapping; a left click
22
+ # selects the tab under the pointer. Everything else bubbles to an ancestor,
23
+ # Enter, Space, Up, Down, Home and End included, so a form's default button
24
+ # and the app's own keys keep working while the strip has focus.
25
+ #
26
+ # One tab stop for the whole strip: Tab moves *past* it, never between its
27
+ # tabs. For a key of your own that switches tabs from elsewhere in the app,
28
+ # bind it yourself and call {#select_next} / {#select_previous}.
29
+ #
30
+ # {Tab} handles are minted by {#add_tab} and owned by the strip. There is no
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`.
33
+ #
34
+ # == Sizing
35
+ # Assign a {#rect} (typically from the surrounding {Layout}). One wider than
36
+ # {#extent}`.width` leaves a dead tail; a narrower one **scrolls**. The strip
37
+ # keeps the selected segment whole in view, moving its window by the minimum
38
+ # needed, so arrowing into an off-screen tab brings that tab on screen — and
39
+ # a click on a half-visible segment at an edge selects it and pulls it into
40
+ # view. A `<` or `>` painted over an edge column says there is more strip
41
+ # that way, as does the cut caption underneath it. The one thing that cannot
42
+ # be shown whole is a caption wider than the entire rect: it shows its head
43
+ # and clips its tail. The scroll offset itself is not API — the invariant is.
44
+ #
45
+ # == Implementation details
46
+ # A segment is one space of padding, the caption, one space of padding, and
47
+ # segments are joined by a single {DEFAULT_SEPARATOR} column. The padding
48
+ # belongs to the segment: the highlight covers it and a click on it selects
49
+ # the tab, while the separator column is chrome and selects nothing, like
50
+ # the blank tail past {#extent}. One private `segments` method is the sole
51
+ # source of that arithmetic — both the paint and the hit test read it, and
52
+ # both offset it by the same scroll column, so a click cannot land on a tab
53
+ # other than the one drawn under it — and it is
54
+ # derived from the captions on each call rather than recorded during the
55
+ # last paint, so a hit test is correct before the first paint.
56
+ #
57
+ # The selected caption is bold *always*, so the strip still says where you
58
+ # are once focus has moved on, and additionally sits on
59
+ # {Theme#active_bg_color} while the strip is on the focus chain. Bold is the
60
+ # selection channel and not strip chrome: bolding every caption would leave
61
+ # selection to the focus-gated background alone, and an unfocused strip
62
+ # would then show no selection at all.
63
+ class Tabs < Component
64
+ # A single tab: a caption, plus its identity on the strip.
65
+ #
66
+ # tab = tabs.add_tab("Payment")
67
+ # tab.caption = "Payment ⚠" # repaints the strip
68
+ # tab.remove # `tab` now raises on every mutator
69
+ #
70
+ # Apps don't construct tabs; {Tabs#add_tab} mints them. A removed handle
71
+ # raises {RuntimeError} on every mutator and on every reader that consults
72
+ # the strip — answering confidently about a tab the strip no longer holds
73
+ # would hide the bug. {#caption} and {#attached?} stay readable (the
74
+ # caption lives here, so an error message can still name it), and
75
+ # {#remove} is a silent no-op so a cleanup path can call it blindly.
76
+ class Tab
77
+ # @param strip [Tabs] the owning strip.
78
+ # @param caption [StyledString] already coerced by the caller.
79
+ def initialize(strip, caption)
80
+ @strip = strip
81
+ @caption = caption
82
+ end
83
+
84
+ private_class_method :new
85
+
86
+ # @return [StyledString] the label painted on the strip. Safe to read on
87
+ # a removed tab.
88
+ attr_reader :caption
89
+
90
+ # Sets the caption and repaints the strip; no-op when unchanged.
91
+ #
92
+ # The caption is app-authored content and may carry its own colors — a
93
+ # badge, a warning marker — over which the strip's own styling composes.
94
+ # @param new_caption [String, StyledString, nil] parsed as
95
+ # {StyledString.parse} parses it; `nil` empties the caption.
96
+ # @raise [RuntimeError] when the tab has been removed.
97
+ # @return [void]
98
+ def caption=(new_caption)
99
+ check_attached
100
+ new_caption = StyledString.parse(new_caption)
101
+ return if @caption == new_caption
102
+
103
+ @caption = new_caption
104
+ @strip.send(:refresh)
105
+ end
106
+
107
+ # @return [Boolean] `true` while the tab is owned by its {Tabs}; `false`
108
+ # permanently once removed.
109
+ def attached? = !@strip.nil?
110
+
111
+ # @return [Boolean] whether this is the strip's selected tab.
112
+ # @raise [RuntimeError] when the tab has been removed.
113
+ def selected?
114
+ check_attached
115
+ @strip.selected.equal?(self)
116
+ end
117
+
118
+ # Removes this tab from its strip and detaches the handle permanently —
119
+ # {Tabs#remove_tab} has what that does to the selection. Idempotent on an
120
+ # already-removed tab, unlike the mutators.
121
+ # @return [void]
122
+ def remove
123
+ return unless attached?
124
+
125
+ @strip.remove_tab(self)
126
+ end
127
+
128
+ # @return [String]
129
+ def inspect = "#<#{self.class.name} #{caption.to_s.inspect}#{attached? ? "" : " (removed)"}>"
130
+
131
+ private
132
+
133
+ # @return [void]
134
+ def detach
135
+ @strip = nil
136
+ end
137
+
138
+ # @raise [RuntimeError] when the tab has been removed.
139
+ # @return [void]
140
+ def check_attached
141
+ raise "tab has been removed" unless attached?
142
+ end
143
+ end
144
+
145
+ # The column between two segments — the glyph {Component::Window} paints
146
+ # its side borders with, so a strip inside a window lines up with it.
147
+ # @return [String]
148
+ DEFAULT_SEPARATOR = "│"
149
+
150
+ # Called on every change of {#selected} with the new selection —
151
+ # `(index, tab)`, or `(nil, nil)` once the last tab has been removed.
152
+ #
153
+ # It reports that the selection *changed*, not that the user pressed
154
+ # something: arrows, a click, {#selected=} / {#selected_index=}, the
155
+ # autoselect of the first {#add_tab} and the re-selection that follows
156
+ # removing the selected tab all fire it. Re-selecting the tab already
157
+ # selected fires nothing.
158
+ # @return [Proc, nil]
159
+ attr_accessor :on_tab_selected
160
+
161
+ # @param separator [String, StyledString] see {#separator=}.
162
+ def initialize(separator: DEFAULT_SEPARATOR)
163
+ super()
164
+ @tabs = []
165
+ @selected_index = nil
166
+ @left_column = 0
167
+ self.separator = separator
168
+ end
169
+
170
+ # @return [Boolean] `true` — the strip takes focus, so its arrows work.
171
+ def focusable? = true
172
+
173
+ # @return [Boolean] `true` — one stop for the whole strip.
174
+ def tab_stop? = true
175
+
176
+ # @return [Array<Tab>] the tabs, in strip order. Read-only by convention
177
+ # (like {Component#children}) — grow and shrink it through {#add_tab} /
178
+ # {#remove_tab}, which keep the selection consistent. Enumerate it to
179
+ # find a tab: `tabs.find { |t| t.caption.to_s == "Payment" }`.
180
+ attr_reader :tabs
181
+
182
+ # @return [StyledString] the column painted between two segments.
183
+ attr_reader :separator
184
+
185
+ # @param new_separator [String, StyledString] e.g. ASCII `"|"` for a
186
+ # terminal that renders `│` badly. Both the paint and the hit test
187
+ # measure it, so a wider one widens the dead column between segments.
188
+ # @raise [ArgumentError] when empty — adjacent captions would run
189
+ # together.
190
+ # @return [void]
191
+ def separator=(new_separator)
192
+ new_separator = StyledString.parse(new_separator)
193
+ raise ArgumentError, "separator must not be empty" if new_separator.empty?
194
+ return if @separator == new_separator
195
+
196
+ @separator = new_separator
197
+ refresh
198
+ end
199
+
200
+ # @return [Tab, nil] the selected tab; `nil` only while there are no tabs.
201
+ def selected = @selected_index && @tabs[@selected_index]
202
+
203
+ # @return [Integer, nil] the selected tab's position; `nil` only while
204
+ # there are no tabs.
205
+ attr_reader :selected_index
206
+
207
+ # @param tab [Tab] one of this strip's tabs.
208
+ # @raise [ArgumentError] when the tab isn't on this strip (a removed one
209
+ # never is).
210
+ # @return [void]
211
+ def selected=(tab)
212
+ select_at(index_of!(tab))
213
+ end
214
+
215
+ # Selects the tab at `index`. There is deliberately no way to select
216
+ # *nothing* while tabs exist — a strip showing captions with none of them
217
+ # selected has no meaning, and a {Component::TabSheet} in that state would
218
+ # show no pane.
219
+ # @param index [Integer] a position in `0...tabs.size`.
220
+ # @raise [TypeError] when `index` isn't an `Integer`.
221
+ # @raise [ArgumentError] when `index` is out of range.
222
+ # @return [void]
223
+ def selected_index=(index)
224
+ raise TypeError, "expected Integer, got #{index.inspect}" unless index.is_a?(Integer)
225
+ unless (0...@tabs.size).cover?(index)
226
+ raise ArgumentError, "index #{index} out of range for #{@tabs.size} tab(s)"
227
+ end
228
+
229
+ select_at(index)
230
+ end
231
+
232
+ # Appends a tab and returns its handle. The first tab added becomes the
233
+ # selection; later ones don't disturb it.
234
+ # @param caption [String, StyledString, nil] parsed as {Tab#caption=}
235
+ # parses it.
236
+ # @return [Tab]
237
+ def add_tab(caption = nil)
238
+ tab = Tab.send(:new, self, StyledString.parse(caption))
239
+ @tabs << tab
240
+ @selected_index.nil? ? select_at(0) : refresh
241
+ tab
242
+ end
243
+
244
+ # Removes `tab` and detaches its handle permanently.
245
+ #
246
+ # The selection is never left dangling: removing the selected tab selects
247
+ # whichever tab slid into its place (the new last tab, if it was the last),
248
+ # and removing the final tab leaves {#selected} `nil`. Either way
249
+ # {#on_tab_selected} fires — the empty case with `(nil, nil)`, since a
250
+ # listener rendering from the selection has to be told to render nothing.
251
+ # @param tab [Tab] one of this strip's tabs.
252
+ # @raise [ArgumentError] when the tab isn't on this strip.
253
+ # @return [void]
254
+ def remove_tab(tab)
255
+ index = index_of!(tab)
256
+ previous = selected
257
+ @tabs.delete_at(index)
258
+ tab.send(:detach)
259
+ apply_selection(selection_after_removing(index), previous)
260
+ end
261
+
262
+ # Selects the next tab, clamping at the last — the strip never wraps.
263
+ # Public because it is the verb an app's own key binding drives.
264
+ # @return [Boolean] `false` only when there are no tabs.
265
+ def select_next = step_selection(1)
266
+
267
+ # Selects the previous tab, clamping at the first.
268
+ # @return [Boolean] `false` only when there are no tabs.
269
+ def select_previous = step_selection(-1)
270
+
271
+ # The cells the strip actually paints: one row, as wide as its segments and
272
+ # separators need, clipped to {#rect}. A layout routinely hands a strip a
273
+ # window's full width for a 32-column strip — the extent is those 32
274
+ # columns.
275
+ #
276
+ # Both the focus highlight and the click hit test use it, so a click on the
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
279
+ # click-to-focus is ungated by geometry.
280
+ # @return [Rect]
281
+ def extent
282
+ return Rect.new(rect.left, rect.top, 0, 1) if rect.empty?
283
+
284
+ Rect.new(rect.left, rect.top, [painted_width - @left_column, rect.width].min, 1)
285
+ end
286
+
287
+ # @return [String]
288
+
289
+ # Switches tabs on LEFT / RIGHT, consuming the key even at the ends of the
290
+ # strip (the selection clamps). Every other key is left unhandled so it
291
+ # bubbles to an ancestor; an empty strip handles nothing at all.
292
+ # @param key [String]
293
+ # @return [Boolean]
294
+ def handle_key(key)
295
+ case key
296
+ when Keys::LEFT_ARROW then select_previous
297
+ when Keys::RIGHT_ARROW then select_next
298
+ else false
299
+ end
300
+ end
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
309
+
310
+ tab = tab_at(event.point)
311
+ self.selected = tab if tab
312
+ end
313
+
314
+ # @return [void]
315
+ def repaint
316
+ super
317
+ return if rect.empty?
318
+
319
+ row = strip_row.slice(@left_column, rect.width)
320
+ draw_text(rect.left, rect.top, row)
321
+ draw_cues(row)
322
+ end
323
+
324
+ private
325
+
326
+ # @return [Integer] the strip column painted in {#rect}'s leftmost cell —
327
+ # the horizontal scroll offset. `0` unless the strip overflows its rect;
328
+ # {#adjust_left_column} is its sole writer.
329
+ attr_reader :left_column
330
+
331
+ # Re-syncs the scroll offset and repaints — what every change to the
332
+ # captions, the separator or the selection ends in.
333
+ # @return [void]
334
+ def refresh
335
+ adjust_left_column
336
+ invalidate
337
+ end
338
+
339
+ # The rect's *width* is the only part of it the offset depends on, so this
340
+ # hook is the whole geometry story; {Component#rect=} invalidates for us.
341
+ # @return [void]
342
+ def on_width_changed
343
+ super
344
+ adjust_left_column
345
+ end
346
+
347
+ # Scrolls the minimum needed to show the selected segment whole, and is the
348
+ # sole writer of {#left_column}. Idempotent, so every mutation site can
349
+ # call it blindly; it returns the offset to `0` on its own once the strip
350
+ # fits again, which is why no mutator owes a scroll-back branch.
351
+ #
352
+ # A segment wider than the whole rect cannot be shown whole: its head wins,
353
+ # being the half of a caption that identifies it.
354
+ # @return [void]
355
+ def adjust_left_column
356
+ if rect.empty? || painted_width <= rect.width || @selected_index.nil?
357
+ @left_column = 0
358
+ return
359
+ end
360
+
361
+ _tab, start, width = segments[@selected_index]
362
+ if width >= rect.width
363
+ @left_column = start
364
+ else
365
+ @left_column = start if start < @left_column
366
+ @left_column = start + width - rect.width if start + width > @left_column + rect.width
367
+ end
368
+ @left_column = snap_to_glyph_start(@left_column.clamp(0, painted_width - rect.width))
369
+ end
370
+
371
+ # {StyledString#slice} *drops* a cluster straddling the window's edge
372
+ # rather than half-painting it, which would leave the painted row a column
373
+ # short and shift everything past the hole one column left — paint and hit
374
+ # test would then disagree, silently and only for wide glyphs. So the
375
+ # offset only ever lands on a cluster boundary. Snapping *forward* is the
376
+ # safe direction: it gives up at most one column of the segment to the left
377
+ # of the window, never of the one being revealed.
378
+ # @param column [Integer]
379
+ # @return [Integer] the smallest cluster-boundary column `>= column`.
380
+ def snap_to_glyph_start(column)
381
+ boundary = 0
382
+ strip_row.to_s.each_grapheme_cluster do |glyph|
383
+ return boundary if boundary >= column
384
+
385
+ boundary += Buffer.display_width(glyph)
386
+ end
387
+ boundary
388
+ end
389
+
390
+ # Paints the overflow cues over the windowed row's edge columns: `<` when
391
+ # segments sit to the left of the window, `>` when more sit to the right.
392
+ # ASCII by convention rather than by constant, as {Checkbox}'s brackets
393
+ # are, and *overlaid* rather than given reserved columns — reserving would
394
+ # make the window width a function of the offset computed from it.
395
+ # @param row [StyledString] the windowed row, as painted.
396
+ # @return [void]
397
+ def draw_cues(row)
398
+ draw_cue(row, 0, "<") if @left_column.positive?
399
+ draw_cue(row, rect.width - 1, ">") if @left_column + rect.width < painted_width
400
+ end
401
+
402
+ # The cue keeps the style of the cell it covers, so one landing on the
403
+ # selected segment doesn't punch a default-background hole in its
404
+ # highlight.
405
+ # @param row [StyledString] the windowed row.
406
+ # @param column [Integer] relative to {#rect}`.left`.
407
+ # @param glyph [String]
408
+ # @return [void]
409
+ def draw_cue(row, column, glyph)
410
+ style = row.slice(column, 1).spans.first&.style || StyledString::Style::DEFAULT
411
+ draw_char(rect.left + column, rect.top, glyph, style)
412
+ end
413
+
414
+ # One `[tab, start_column, width]` triple per tab, in strip order, in
415
+ # columns relative to {#rect}`.left`. A segment's width is its caption plus
416
+ # the two padding columns; the separator columns between segments belong to
417
+ # no segment.
418
+ # @return [Array<Array(Tab, Integer, Integer)>]
419
+ def segments
420
+ column = 0
421
+ @tabs.each_with_index.map do |tab, index|
422
+ column += separator.display_width if index.positive?
423
+ width = tab.caption.display_width + 2
424
+ [tab, column, width].tap { column += width }
425
+ end
426
+ end
427
+
428
+ # @return [Integer] columns the strip would paint given an unlimited rect.
429
+ def painted_width
430
+ _tab, start, width = segments.last
431
+ start.nil? ? 0 : start + width
432
+ end
433
+
434
+ # @param point [Point]
435
+ # @return [Tab, nil] the tab painted at `point`; `nil` for a separator
436
+ # column, the blank tail, or a row the strip doesn't paint.
437
+ def tab_at(point)
438
+ return nil unless extent.contains?(point)
439
+
440
+ column = point.x - rect.left + @left_column
441
+ found = segments.find { |_tab, start, width| column >= start && column < start + width }
442
+ found&.first
443
+ end
444
+
445
+ # @return [StyledString] the whole strip as one row, unclipped: segments
446
+ # left to right, joined by the separator. {#repaint} windows it to the
447
+ # rect; nothing else may, since the window's own arithmetic is
448
+ # {#adjust_left_column}'s.
449
+ def strip_row
450
+ row = StyledString::EMPTY
451
+ @tabs.each_with_index do |tab, index|
452
+ row += separator if index.positive?
453
+ row += segment_text(tab, index)
454
+ end
455
+ row
456
+ end
457
+
458
+ # @param tab [Tab]
459
+ # @param index [Integer]
460
+ # @return [StyledString] the caption between its padding columns, styled
461
+ # for the selection.
462
+ def segment_text(tab, index)
463
+ pad = StyledString.plain(" ")
464
+ segment = pad + tab.caption + pad
465
+ return segment unless index == @selected_index
466
+
467
+ segment = segment.with_bold
468
+ active? ? segment.with_bg(screen.theme.active_bg_color) : segment
469
+ end
470
+
471
+ # @param tab [Tab]
472
+ # @return [Integer] the tab's position on this strip.
473
+ # @raise [ArgumentError] when it isn't on this strip.
474
+ def index_of!(tab)
475
+ index = @tabs.index { |candidate| candidate.equal?(tab) }
476
+ raise ArgumentError, "not a tab of this strip: #{tab.inspect}" if index.nil?
477
+
478
+ index
479
+ end
480
+
481
+ # @param delta [Integer] `+1` / `-1`.
482
+ # @return [Boolean] `false` only when there are no tabs.
483
+ def step_selection(delta)
484
+ return false if @tabs.empty?
485
+
486
+ select_at((@selected_index + delta).clamp(0, @tabs.size - 1))
487
+ true
488
+ end
489
+
490
+ # @param index [Integer, nil]
491
+ # @return [void]
492
+ def select_at(index)
493
+ apply_selection(index, selected)
494
+ end
495
+
496
+ # Stores the selection, repaints, and fires {#on_tab_selected} when the
497
+ # selected *tab* changed.
498
+ #
499
+ # `previous` is passed in rather than read here because a removal can leave
500
+ # the index numerically unchanged while a different tab sits under it —
501
+ # remove the selected middle tab of three and index 1 now holds what used
502
+ # to be index 2. Comparing indices would swallow that notification.
503
+ # @param index [Integer, nil] the new selection.
504
+ # @param previous [Tab, nil] the tab selected before the caller's change.
505
+ # @return [void]
506
+ def apply_selection(index, previous)
507
+ @selected_index = index
508
+ refresh
509
+ tab = index.nil? ? nil : @tabs[index]
510
+ return if tab.equal?(previous)
511
+
512
+ @on_tab_selected&.call(index, tab)
513
+ end
514
+
515
+ # Called with `@tabs` already shortened and `@selected_index` still holding
516
+ # the pre-removal position.
517
+ # @param removed_index [Integer] the position the removed tab held.
518
+ # @return [Integer, nil] where the selection lands.
519
+ def selection_after_removing(removed_index)
520
+ return nil if @tabs.empty?
521
+ return @selected_index if removed_index > @selected_index
522
+ return @selected_index - 1 if removed_index < @selected_index
523
+
524
+ [removed_index, @tabs.size - 1].min
525
+ end
526
+ end
527
+ end
528
+ end
@@ -17,10 +17,11 @@ module Tuile
17
17
  # start of the next row in nearly all cases).
18
18
  #
19
19
  # Enter inserts a newline, as in a plain `<textarea>` or text editor; only
20
- # {#on_change} is wired. A pasted line break arrives as `\n`
21
- # ({Keys::CTRL_J}) rather than the `\r` a typed Enter sends, so both are
22
- # accepted — otherwise a multi-line paste would silently lose its
23
- # newlines.
20
+ # {#on_change} is wired. {Keys::CTRL_J} does the same, since that is the
21
+ # byte a terminal sends for a typed Ctrl+J. A *pasted* line break arrives
22
+ # through {AbstractStringField#handle_paste} instead and never as a key at
23
+ # all — so a subclass rebinding Enter to submit keeps working under a
24
+ # multi-line paste, which lands as one draft.
24
25
  #
25
26
  # Up/Down move the caret between rows and, at the first/last row, snap to
26
27
  # the start/end of the text. A subclass can claim the key at that edge
@@ -20,8 +20,8 @@ module Tuile
20
20
  #
21
21
  # - an **index** counts characters into {#text} — {#caret},
22
22
  # {#max_text_length}, `text[i]`, every edit;
23
- # - a **column** counts terminal cells — {#rect}, {#left_column},
24
- # {#cursor_position}, a {MouseEvent}.
23
+ # - a **column** counts terminal cells — {#rect}, {#cursor_position}, a
24
+ # {MouseEvent}, and the private horizontal scroll offset `left_column`.
25
25
  #
26
26
  # They coincide only while every glyph is one column wide. A fullwidth CJK
27
27
  # char is two columns and a combining mark zero, so index 3 of `"日本語"` is
@@ -68,10 +68,6 @@ module Tuile
68
68
  @max_text_length = max
69
69
  end
70
70
 
71
- # @return [Integer] text column drawn in the field's leftmost cell — the
72
- # horizontal scroll offset. Follows {#caret}, always on a glyph boundary.
73
- attr_reader :left_column
74
-
75
71
  # Optional callback fired when the UP arrow key is pressed. When set, UP
76
72
  # is consumed by the field; when nil, UP falls through to the parent
77
73
  # (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
@@ -151,6 +147,19 @@ module Tuile
151
147
  true
152
148
  end
153
149
 
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.
154
+ # @param text [String]
155
+ # @return [String]
156
+ def preprocess_paste(text)
157
+ flat = super.tr("\n", " ")
158
+ return flat if @max_text_length.nil?
159
+
160
+ flat[0, [@max_text_length - @text.length, 0].max] || ""
161
+ end
162
+
154
163
  # @return [void]
155
164
  def on_text_mutated
156
165
  adjust_left_column
@@ -235,6 +244,14 @@ module Tuile
235
244
  visible << (" " * (rect.width - width))
236
245
  end
237
246
 
247
+ # Internal — the field's own scroll state, with no caller outside this
248
+ # class: the paint, the cursor and the hit test all read the ivar, and
249
+ # nothing above the field has a column to spend it on. Specs assert the
250
+ # scrolling through `send`.
251
+ # @return [Integer] text column drawn in the field's leftmost cell — the
252
+ # horizontal scroll offset. Follows {#caret}, always on a glyph boundary.
253
+ attr_reader :left_column
254
+
238
255
  # Scrolls the minimum needed to keep the caret's column visible.
239
256
  # @return [void]
240
257
  def adjust_left_column
@@ -332,8 +332,10 @@ module Tuile
332
332
 
333
333
  # Scrolls up half a viewport (`rect.height / 2`, at least one row),
334
334
  # clamped at the top — unlike {#scroll_top_row=}, which raises below `0`.
335
- # What `Ctrl+U` does, minus the focus: {#handle_key} ignores every key
336
- # while the view is inactive, this works whoever holds focus.
335
+ # What `Ctrl+U` does, minus the focus: dispatch delivers keys only along
336
+ # the focus chain, so this is what a host with focus elsewhere — a chat
337
+ # transcript under an input field — calls instead of forwarding a
338
+ # synthetic keystroke that would lie about where focus is.
337
339
  # @return [void]
338
340
  def scroll_half_page_up = move_scroll_top_row_by(-half_page_rows)
339
341
 
@@ -346,12 +348,13 @@ module Tuile
346
348
 
347
349
  def tab_stop? = true
348
350
 
351
+ # Claims the scroll ladder: the arrows and `j`/`k`, PageUp/PageDown,
352
+ # `Ctrl+U`/`Ctrl+D`, Home/`g`/End/`G`. Acts on the key alone — a clamped
353
+ # scroll at either edge is still a handled key, and hand-feeding a key to
354
+ # an unfocused view scrolls it (dispatch gates on focus, this doesn't).
349
355
  # @param key [String]
350
356
  # @return [Boolean]
351
357
  def handle_key(key)
352
- return false unless active?
353
- return true if super
354
-
355
358
  case key
356
359
  when *Keys::DOWN_ARROWS then move_scroll_top_row_by(1)
357
360
  when *Keys::UP_ARROWS then move_scroll_top_row_by(-1)