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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/DECISIONS.md +1297 -13
- data/README.md +136 -490
- data/TERMINOLOGY.md +11 -2
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +18 -5
- data/book/03-layout.md +11 -10
- data/book/05-focus.md +133 -18
- data/book/06-theming.md +5 -2
- data/book/07-components.md +402 -12
- data/book/08-testing.md +18 -4
- data/book/README.md +7 -5
- data/examples/file_commander.rb +22 -16
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +385 -66
- data/ideas/arrow-key-navigation.md +16 -0
- data/ideas/new-components.md +7 -6
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/combo_box.rb +3 -1
- data/lib/tuile/component/list.rb +22 -0
- data/lib/tuile/component/list_dropdown.rb +86 -3
- data/lib/tuile/component/menu_bar/cascade.rb +255 -0
- data/lib/tuile/component/menu_bar.rb +582 -0
- data/lib/tuile/component/notification.rb +14 -11
- data/lib/tuile/component/picker_window.rb +0 -5
- data/lib/tuile/component/popup.rb +75 -9
- data/lib/tuile/component/select.rb +3 -1
- data/lib/tuile/component/tab_sheet.rb +242 -0
- data/lib/tuile/component/tabs.rb +528 -0
- data/lib/tuile/component/text_area.rb +5 -4
- data/lib/tuile/component/text_field.rb +23 -6
- data/lib/tuile/component/text_view.rb +8 -5
- data/lib/tuile/component.rb +38 -13
- data/lib/tuile/event_queue.rb +25 -1
- data/lib/tuile/fake_screen.rb +14 -0
- data/lib/tuile/keys.rb +65 -0
- data/lib/tuile/screen.rb +94 -77
- data/lib/tuile/screen_pane.rb +109 -27
- data/lib/tuile/styled_string.rb +40 -0
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +1473 -93
- metadata +6 -3
- 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.
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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}, {#
|
|
24
|
-
# {
|
|
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:
|
|
336
|
-
#
|
|
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)
|