tuile 0.9.0 → 0.10.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 +32 -0
- data/DECISIONS.md +1961 -0
- data/README.md +31 -24
- data/book/04-event-loop.md +86 -0
- data/book/05-focus.md +82 -51
- data/book/06-theming.md +53 -1
- data/book/07-components.md +363 -9
- data/book/08-testing.md +21 -8
- data/book/09-styled-text.md +132 -0
- data/book/README.md +19 -13
- data/examples/sampler.rb +435 -20
- data/ideas/new-components.md +109 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +52 -51
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/button.rb +25 -21
- data/lib/tuile/component/checkbox.rb +133 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +281 -0
- data/lib/tuile/component/has_caption.rb +44 -0
- data/lib/tuile/component/has_content.rb +5 -5
- data/lib/tuile/component/has_value.rb +64 -0
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +13 -10
- data/lib/tuile/component/layout.rb +3 -17
- data/lib/tuile/component/list.rb +8 -7
- data/lib/tuile/component/list_dropdown.rb +106 -0
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +10 -14
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/text_area.rb +189 -65
- data/lib/tuile/component/text_field.rb +170 -32
- data/lib/tuile/component/text_view.rb +57 -114
- data/lib/tuile/component/window.rb +32 -47
- data/lib/tuile/component.rb +250 -87
- data/lib/tuile/event_queue.rb +14 -17
- data/lib/tuile/fake_event_queue.rb +11 -2
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +6 -10
- data/lib/tuile/screen.rb +202 -104
- data/lib/tuile/screen_pane.rb +51 -41
- data/lib/tuile/styled_string.rb +112 -83
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2043 -614
- metadata +18 -7
|
@@ -10,24 +10,42 @@ module Tuile
|
|
|
10
10
|
# follows the caret so the line being edited stays visible. There is no
|
|
11
11
|
# horizontal scrolling.
|
|
12
12
|
#
|
|
13
|
-
# The caret is a logical index in `0..text.length
|
|
13
|
+
# The caret is a logical index in `0..text.length`, always on a
|
|
14
|
+
# grapheme-cluster boundary ({AbstractStringField}). When the caret falls
|
|
14
15
|
# inside a whitespace run that was absorbed by a soft wrap, it displays
|
|
15
16
|
# at the end of the previous row (which is visually identical to the
|
|
16
17
|
# start of the next row in nearly all cases).
|
|
17
18
|
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
|
|
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.
|
|
24
|
+
#
|
|
25
|
+
# == Implementation details
|
|
26
|
+
#
|
|
27
|
+
# The same two axes {TextField} names apply, and the wrap straddles both: an
|
|
28
|
+
# **index** counts characters into {#text} ({#caret}, a row's `start` and
|
|
29
|
+
# `length`), a **column** counts terminal cells ({#rect}, a row's `columns`,
|
|
30
|
+
# {#cursor_position}, a {MouseEvent}). A row therefore carries *both* counts,
|
|
31
|
+
# and the wrap fills each row to a column budget while recording a character
|
|
32
|
+
# span. Everything crossing between them goes through the inherited
|
|
33
|
+
# `columns_of` and the private `chars_for_column`.
|
|
34
|
+
#
|
|
35
|
+
# The wrap walks **grapheme clusters**, not characters — a combining mark must
|
|
36
|
+
# add no columns and must not be split from its base across a row break. Note
|
|
37
|
+
# `"\r\n"` is a *single* cluster, so a hard break tests `end_with?("\n")`
|
|
38
|
+
# rather than equality.
|
|
39
|
+
class TextArea < AbstractStringField
|
|
22
40
|
def initialize
|
|
23
41
|
super
|
|
24
42
|
@top_display_row = 0
|
|
25
43
|
# Lazy cache of the word-wrapped layout: an
|
|
26
44
|
# `Array<Hash{Symbol=>Integer}>` whose entries are
|
|
27
|
-
# `{start: <text-index>, length: <chars>}`, one per
|
|
28
|
-
# by {#compute_display_rows}. `nil` means "stale,
|
|
29
|
-
# read". Reset to nil whenever {#text} mutates or the
|
|
30
|
-
# see {#on_text_mutated} and {#on_width_changed}.
|
|
45
|
+
# `{start: <text-index>, length: <chars>, columns: <cols>}`, one per
|
|
46
|
+
# display row, built by {#compute_display_rows}. `nil` means "stale,
|
|
47
|
+
# recompute on next read". Reset to nil whenever {#text} mutates or the
|
|
48
|
+
# width changes; see {#on_text_mutated} and {#on_width_changed}.
|
|
31
49
|
@display_rows = nil
|
|
32
50
|
end
|
|
33
51
|
|
|
@@ -62,7 +80,7 @@ module Tuile
|
|
|
62
80
|
self.caret = @text.length
|
|
63
81
|
else
|
|
64
82
|
r = rows[target_row]
|
|
65
|
-
self.caret = r[:start] +
|
|
83
|
+
self.caret = r[:start] + chars_for_column(r, target_col)
|
|
66
84
|
end
|
|
67
85
|
end
|
|
68
86
|
|
|
@@ -73,13 +91,7 @@ module Tuile
|
|
|
73
91
|
rows = display_rows
|
|
74
92
|
(0...rect.height).each do |screen_row|
|
|
75
93
|
row_idx = screen_row + @top_display_row
|
|
76
|
-
line =
|
|
77
|
-
" " * rect.width
|
|
78
|
-
else
|
|
79
|
-
r = rows[row_idx]
|
|
80
|
-
chunk = @text[r[:start], r[:length]] || ""
|
|
81
|
-
chunk + (" " * (rect.width - r[:length]))
|
|
82
|
-
end
|
|
94
|
+
line = row_idx >= rows.size ? " " * rect.width : padded_row(rows[row_idx])
|
|
83
95
|
screen.buffer.set_line(rect.left, rect.top + screen_row, background(line))
|
|
84
96
|
end
|
|
85
97
|
end
|
|
@@ -107,7 +119,7 @@ module Tuile
|
|
|
107
119
|
when *Keys::ENDS_ then move_caret_to_row_end
|
|
108
120
|
when *Keys::BACKSPACES then delete_before_caret
|
|
109
121
|
when Keys::DELETE then delete_at_caret
|
|
110
|
-
when Keys::ENTER then insert_char("\n")
|
|
122
|
+
when Keys::ENTER, Keys::CTRL_J then insert_char("\n")
|
|
111
123
|
else
|
|
112
124
|
return insert_char(key) if Keys.printable?(key)
|
|
113
125
|
|
|
@@ -131,78 +143,149 @@ module Tuile
|
|
|
131
143
|
@display_rows ||= compute_display_rows
|
|
132
144
|
end
|
|
133
145
|
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
|
|
146
|
+
# @return [Array<Hash{Symbol=>Object}>] one entry per grapheme cluster of
|
|
147
|
+
# {#text}: `{offset: <text-index>, text: <cluster>, width: <columns>}`.
|
|
148
|
+
# Rebuilt per wrap and discarded — the wrap is what's cached.
|
|
149
|
+
def cluster_table
|
|
150
|
+
offset = 0
|
|
151
|
+
@text.each_grapheme_cluster.map do |g|
|
|
152
|
+
entry = { offset: offset, text: g, width: Buffer.display_width(g) }
|
|
153
|
+
offset += g.length
|
|
154
|
+
entry
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @param cluster [Hash{Symbol=>Object}]
|
|
159
|
+
# @return [Boolean] true for a space or tab (each exactly one column).
|
|
160
|
+
def blank?(cluster) = cluster[:text].match?(/[ \t]/)
|
|
161
|
+
|
|
162
|
+
# @param cluster [Hash{Symbol=>Object}]
|
|
163
|
+
# @return [Boolean] true for a hard line break. Tests the suffix rather
|
|
164
|
+
# than equality because `"\r\n"` is one grapheme cluster.
|
|
165
|
+
def newline?(cluster) = cluster[:text].end_with?("\n")
|
|
166
|
+
|
|
167
|
+
# Greedy word-wrap, filling each row to a **column** budget while recording
|
|
168
|
+
# the **character** span that produced it. Whitespace at a soft-wrap break
|
|
169
|
+
# point is absorbed (not rendered on either row). A token wider than
|
|
170
|
+
# {Rect#width} hard-wraps inside the token. Newlines force a hard break and
|
|
171
|
+
# the wrap restarts on the next cluster.
|
|
138
172
|
# @return [Array<Hash{Symbol=>Integer}>]
|
|
139
173
|
def compute_display_rows
|
|
140
174
|
width = rect.width
|
|
141
|
-
return [{ start: 0, length: 0 }] if width <= 0 || @text.empty?
|
|
175
|
+
return [{ start: 0, length: 0, columns: 0 }] if width <= 0 || @text.empty?
|
|
142
176
|
|
|
177
|
+
cl = cluster_table
|
|
143
178
|
rows = []
|
|
144
|
-
|
|
145
|
-
n =
|
|
146
|
-
|
|
147
|
-
while
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
179
|
+
i = 0
|
|
180
|
+
n = cl.size
|
|
181
|
+
|
|
182
|
+
while i < n
|
|
183
|
+
start = cl[i][:offset]
|
|
184
|
+
chars = 0
|
|
185
|
+
cols = 0
|
|
186
|
+
|
|
187
|
+
while i < n
|
|
188
|
+
g = cl[i]
|
|
189
|
+
break if newline?(g)
|
|
190
|
+
|
|
191
|
+
if blank?(g)
|
|
192
|
+
if cols < width
|
|
193
|
+
chars += g[:text].length
|
|
194
|
+
cols += g[:width]
|
|
195
|
+
i += 1
|
|
159
196
|
else
|
|
160
|
-
|
|
161
|
-
|
|
197
|
+
chars, cols = trim_trailing_whitespace(start, chars, cols)
|
|
198
|
+
i += 1 while i < n && blank?(cl[i])
|
|
162
199
|
break
|
|
163
200
|
end
|
|
164
201
|
else
|
|
165
|
-
word_end =
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
row_chars = width
|
|
174
|
-
pos += width
|
|
202
|
+
word_chars, word_cols, word_end = measure_word(cl, i)
|
|
203
|
+
|
|
204
|
+
if cols + word_cols <= width
|
|
205
|
+
chars += word_chars
|
|
206
|
+
cols += word_cols
|
|
207
|
+
i = word_end
|
|
208
|
+
elsif cols.zero?
|
|
209
|
+
chars, cols, i = hard_wrap(cl, i, width)
|
|
175
210
|
break
|
|
176
211
|
else
|
|
177
|
-
|
|
212
|
+
chars, cols = trim_trailing_whitespace(start, chars, cols)
|
|
178
213
|
break
|
|
179
214
|
end
|
|
180
215
|
end
|
|
181
216
|
end
|
|
182
217
|
|
|
183
|
-
rows << { start:
|
|
218
|
+
rows << { start: start, length: chars, columns: cols }
|
|
184
219
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
220
|
+
next unless i < n && newline?(cl[i])
|
|
221
|
+
|
|
222
|
+
i += 1
|
|
223
|
+
rows << { start: @text.length, length: 0, columns: 0 } if i >= n
|
|
189
224
|
end
|
|
190
225
|
|
|
191
|
-
rows << { start: 0, length: 0 } if rows.empty?
|
|
226
|
+
rows << { start: 0, length: 0, columns: 0 } if rows.empty?
|
|
192
227
|
rows
|
|
193
228
|
end
|
|
194
229
|
|
|
230
|
+
# @param clusters [Array<Hash{Symbol=>Object}>]
|
|
231
|
+
# @param index [Integer] cluster index of the word's first glyph.
|
|
232
|
+
# @return [Array(Integer, Integer, Integer)] `[chars, columns, next_index]`
|
|
233
|
+
# for the run of non-whitespace starting at `index`.
|
|
234
|
+
def measure_word(clusters, index)
|
|
235
|
+
chars = 0
|
|
236
|
+
cols = 0
|
|
237
|
+
while index < clusters.size && !blank?(clusters[index]) && !newline?(clusters[index])
|
|
238
|
+
chars += clusters[index][:text].length
|
|
239
|
+
cols += clusters[index][:width]
|
|
240
|
+
index += 1
|
|
241
|
+
end
|
|
242
|
+
[chars, cols, index]
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Splits a token too wide for a whole row, taking entire glyphs while they
|
|
246
|
+
# fit. Consumes at least one glyph even when that single glyph is wider than
|
|
247
|
+
# the row — otherwise the wrap would not terminate (the row would stay empty
|
|
248
|
+
# and the same token be reconsidered forever). Such a row reports more
|
|
249
|
+
# columns than the rect holds and {#padded_row} drops the glyph; a
|
|
250
|
+
# 2-column glyph in a 1-column area is unpaintable either way.
|
|
251
|
+
# @param clusters [Array<Hash{Symbol=>Object}>]
|
|
252
|
+
# @param index [Integer]
|
|
253
|
+
# @param width [Integer] column budget.
|
|
254
|
+
# @return [Array(Integer, Integer, Integer)] `[chars, columns, next_index]`
|
|
255
|
+
def hard_wrap(clusters, index, width)
|
|
256
|
+
chars = 0
|
|
257
|
+
cols = 0
|
|
258
|
+
while index < clusters.size && cols + clusters[index][:width] <= width
|
|
259
|
+
chars += clusters[index][:text].length
|
|
260
|
+
cols += clusters[index][:width]
|
|
261
|
+
index += 1
|
|
262
|
+
end
|
|
263
|
+
if chars.zero? && index < clusters.size
|
|
264
|
+
chars = clusters[index][:text].length
|
|
265
|
+
cols = clusters[index][:width]
|
|
266
|
+
index += 1
|
|
267
|
+
end
|
|
268
|
+
[chars, cols, index]
|
|
269
|
+
end
|
|
270
|
+
|
|
195
271
|
# Trims trailing space/tab characters off a row's visible length so the
|
|
196
272
|
# whitespace at a soft-wrap point is absorbed (not rendered) rather than
|
|
197
273
|
# left at the end of the row. Without this, soft-wrapping `"foo bar"`
|
|
198
274
|
# to width 4 would yield row 0 length 4 (`"foo "`) and the natural
|
|
199
275
|
# end-of-row caret position would coincide with row 1's start.
|
|
276
|
+
#
|
|
277
|
+
# Both counts drop by one per trimmed character: a space and a tab each
|
|
278
|
+
# measure exactly one column.
|
|
200
279
|
# @param row_start [Integer]
|
|
201
280
|
# @param row_chars [Integer]
|
|
202
|
-
# @
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
row_chars
|
|
281
|
+
# @param row_cols [Integer]
|
|
282
|
+
# @return [Array(Integer, Integer)] `[row_chars, row_cols]`
|
|
283
|
+
def trim_trailing_whitespace(row_start, row_chars, row_cols)
|
|
284
|
+
while row_chars.positive? && @text[row_start + row_chars - 1].match?(/[ \t]/)
|
|
285
|
+
row_chars -= 1
|
|
286
|
+
row_cols -= 1
|
|
287
|
+
end
|
|
288
|
+
[row_chars, row_cols]
|
|
206
289
|
end
|
|
207
290
|
|
|
208
291
|
# @param caret [Integer]
|
|
@@ -213,10 +296,51 @@ module Tuile
|
|
|
213
296
|
next_start = i + 1 < rows.size ? rows[i + 1][:start] : @text.length + 1
|
|
214
297
|
next unless caret >= r[:start] && caret < next_start
|
|
215
298
|
|
|
216
|
-
return [i, (
|
|
299
|
+
return [i, caret_column_in(r, caret)]
|
|
300
|
+
end
|
|
301
|
+
[rows.size - 1, caret_column_in(rows.last, caret)]
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# @param row [Hash{Symbol=>Integer}]
|
|
305
|
+
# @param caret [Integer]
|
|
306
|
+
# @return [Integer] `caret`'s column offset within `row`.
|
|
307
|
+
def caret_column_in(row, caret)
|
|
308
|
+
chars = (caret - row[:start]).clamp(0, row[:length])
|
|
309
|
+
columns_of(@text[row[:start], chars] || "").clamp(0, row[:columns])
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# @param row [Hash{Symbol=>Integer}]
|
|
313
|
+
# @param column [Integer] a column offset within `row`.
|
|
314
|
+
# @return [Integer] characters from the row's start. A column landing in a
|
|
315
|
+
# wide glyph's right half resolves past it, as a click does in
|
|
316
|
+
# {TextField}.
|
|
317
|
+
def chars_for_column(row, column)
|
|
318
|
+
chars = 0
|
|
319
|
+
col = 0
|
|
320
|
+
(@text[row[:start], row[:length]] || "").each_grapheme_cluster do |g|
|
|
321
|
+
w = Buffer.display_width(g)
|
|
322
|
+
return chars if column < col + ((w + 1) / 2)
|
|
323
|
+
|
|
324
|
+
col += w
|
|
325
|
+
chars += g.length
|
|
326
|
+
end
|
|
327
|
+
chars
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# @param row [Hash{Symbol=>Integer}]
|
|
331
|
+
# @return [String] the row's text padded to `rect.width` columns. A glyph
|
|
332
|
+
# with no room left is dropped rather than half-painted.
|
|
333
|
+
def padded_row(row)
|
|
334
|
+
out = +""
|
|
335
|
+
cols = 0
|
|
336
|
+
(@text[row[:start], row[:length]] || "").each_grapheme_cluster do |g|
|
|
337
|
+
w = Buffer.display_width(g)
|
|
338
|
+
break if cols + w > rect.width
|
|
339
|
+
|
|
340
|
+
out << g
|
|
341
|
+
cols += w
|
|
217
342
|
end
|
|
218
|
-
|
|
219
|
-
[rows.size - 1, (caret - r[:start]).clamp(0, r[:length])]
|
|
343
|
+
out << (" " * (rect.width - cols))
|
|
220
344
|
end
|
|
221
345
|
|
|
222
346
|
# @param delta [Integer] `+1` for down, `-1` for up.
|
|
@@ -233,7 +357,7 @@ module Tuile
|
|
|
233
357
|
end
|
|
234
358
|
|
|
235
359
|
r = rows[new_row]
|
|
236
|
-
self.caret = r[:start] +
|
|
360
|
+
self.caret = r[:start] + chars_for_column(r, cur_col)
|
|
237
361
|
end
|
|
238
362
|
|
|
239
363
|
# @return [void]
|
|
@@ -2,23 +2,76 @@
|
|
|
2
2
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
|
-
# A single-line text input
|
|
5
|
+
# A single-line text input with a real hardware caret, scrolling
|
|
6
|
+
# horizontally to keep that caret in view:
|
|
6
7
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
8
|
+
# f = TextField.new
|
|
9
|
+
# f.rect = Rect.new(0, 0, 6, 1) # six columns wide …
|
|
10
|
+
# f.text = "hello world" # … eleven columns of text, so it scrolls
|
|
11
|
+
# f.caret = 11 # paints "world " — left_column 6, cursor on the last column
|
|
12
|
+
# f.caret = 0 # paints "hello " — left_column 0
|
|
10
13
|
#
|
|
11
|
-
# The
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
|
|
14
|
+
# The field's width never bounds its contents — {#max_text_length} does, and
|
|
15
|
+
# only for typing.
|
|
16
|
+
#
|
|
17
|
+
# == Implementation details
|
|
18
|
+
#
|
|
19
|
+
# Two axes run through this class and are *not* interchangeable:
|
|
20
|
+
#
|
|
21
|
+
# - an **index** counts characters into {#text} — {#caret},
|
|
22
|
+
# {#max_text_length}, `text[i]`, every edit;
|
|
23
|
+
# - a **column** counts terminal cells — {#rect}, {#left_column},
|
|
24
|
+
# {#cursor_position}, a {MouseEvent}.
|
|
25
|
+
#
|
|
26
|
+
# They coincide only while every glyph is one column wide. A fullwidth CJK
|
|
27
|
+
# char is two columns and a combining mark zero, so index 3 of `"日本語"` is
|
|
28
|
+
# column 6. Every crossing goes through the private `column_at` / `index_at`
|
|
29
|
+
# pair; adding an index to a column anywhere else is the bug those two exist
|
|
30
|
+
# to prevent.
|
|
31
|
+
#
|
|
32
|
+
# Indices count characters while widths measure grapheme clusters, but the
|
|
33
|
+
# caret never falls between the two: {AbstractStringField} keeps it on a
|
|
34
|
+
# cluster boundary, so a column derived from it always names a real glyph
|
|
35
|
+
# edge.
|
|
36
|
+
#
|
|
37
|
+
# What gets *painted* is {#display_text}, a third seam that is `text` itself
|
|
38
|
+
# here and the mask in {PasswordField}. Every column measurement reads it, so
|
|
39
|
+
# a subclass showing something else overrides that and never {#repaint} —
|
|
40
|
+
# overriding the paint alone leaves the measurements on the buffer while the
|
|
41
|
+
# cells show the substitute, and the two drift apart by a growing offset.
|
|
42
|
+
class TextField < AbstractStringField
|
|
15
43
|
def initialize
|
|
16
44
|
super
|
|
45
|
+
@left_column = 0
|
|
46
|
+
@max_text_length = nil
|
|
17
47
|
@on_key_up = nil
|
|
18
48
|
@on_key_down = nil
|
|
19
49
|
@on_enter = nil
|
|
20
50
|
end
|
|
21
51
|
|
|
52
|
+
# Optional cap on {#text}'s length **in characters** — a wide glyph counts
|
|
53
|
+
# once. Typing into a field already at the cap does nothing.
|
|
54
|
+
#
|
|
55
|
+
# Deliberately does not police {#text=}: lowering the cap under an existing
|
|
56
|
+
# value leaves that value intact rather than silently trimming it.
|
|
57
|
+
# @return [Integer, nil] maximum characters, or nil for unbounded (default).
|
|
58
|
+
attr_reader :max_text_length
|
|
59
|
+
|
|
60
|
+
# @param max [Integer, nil]
|
|
61
|
+
# @return [void]
|
|
62
|
+
# @raise [TypeError] unless `max` is an Integer or nil.
|
|
63
|
+
# @raise [ArgumentError] if `max` is negative.
|
|
64
|
+
def max_text_length=(max)
|
|
65
|
+
raise TypeError, "expected Integer or nil, got #{max.inspect}" unless max.nil? || max.is_a?(Integer)
|
|
66
|
+
raise ArgumentError, "expected a non-negative max, got #{max}" if max&.negative?
|
|
67
|
+
|
|
68
|
+
@max_text_length = max
|
|
69
|
+
end
|
|
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
|
+
|
|
22
75
|
# Optional callback fired when the UP arrow key is pressed. When set, UP
|
|
23
76
|
# is consumed by the field; when nil, UP falls through to the parent
|
|
24
77
|
# (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
|
|
@@ -43,37 +96,33 @@ module Tuile
|
|
|
43
96
|
def cursor_position
|
|
44
97
|
return nil unless rect.width.positive?
|
|
45
98
|
|
|
46
|
-
|
|
99
|
+
# Scrolling already keeps the caret inside the rect, so the cap is a
|
|
100
|
+
# guard rather than a policy: a cursor parked at rect.left + rect.width
|
|
101
|
+
# reads as column 0 of the next row on an auto-wrapping terminal.
|
|
102
|
+
offset = (column_at(@caret) - @left_column).clamp(0, rect.width - 1)
|
|
103
|
+
Point.new(rect.left + offset, rect.top)
|
|
47
104
|
end
|
|
48
105
|
|
|
106
|
+
# Places the caret at the clicked column. A click on the right half of a
|
|
107
|
+
# wide glyph lands *after* it, as in any editor.
|
|
49
108
|
# @param event [MouseEvent]
|
|
50
109
|
# @return [void]
|
|
51
110
|
def handle_mouse(event)
|
|
52
111
|
super
|
|
53
112
|
return unless event.button == :left && rect.contains?(event.point)
|
|
54
113
|
|
|
55
|
-
self.caret = (event.x - rect.left
|
|
114
|
+
self.caret = index_at(event.x - rect.left + @left_column)
|
|
56
115
|
end
|
|
57
116
|
|
|
58
117
|
# @return [void]
|
|
59
118
|
def repaint
|
|
60
119
|
return if rect.empty?
|
|
61
120
|
|
|
62
|
-
|
|
63
|
-
screen.buffer.set_line(rect.left, rect.top, background(padded))
|
|
121
|
+
screen.buffer.set_line(rect.left, rect.top, background(visible_text))
|
|
64
122
|
end
|
|
65
123
|
|
|
66
124
|
protected
|
|
67
125
|
|
|
68
|
-
# Truncate to fit `rect.width - 1` — single-line fields can't grow past
|
|
69
|
-
# their width.
|
|
70
|
-
# @param new_text [String]
|
|
71
|
-
# @return [String]
|
|
72
|
-
def preprocess_text(new_text)
|
|
73
|
-
new_text = new_text.to_s
|
|
74
|
-
new_text.length > max_text_length ? new_text[0, max_text_length] : new_text
|
|
75
|
-
end
|
|
76
|
-
|
|
77
126
|
# @param key [String]
|
|
78
127
|
# @return [Boolean]
|
|
79
128
|
def handle_text_input_key(key)
|
|
@@ -102,32 +151,121 @@ module Tuile
|
|
|
102
151
|
true
|
|
103
152
|
end
|
|
104
153
|
|
|
154
|
+
# @return [void]
|
|
155
|
+
def on_text_mutated
|
|
156
|
+
adjust_left_column
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @return [void]
|
|
160
|
+
def on_caret_mutated
|
|
161
|
+
adjust_left_column
|
|
162
|
+
end
|
|
163
|
+
|
|
105
164
|
# @return [void]
|
|
106
165
|
def on_width_changed
|
|
107
166
|
super
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
@text = @text[0, [max_text_length, 0].max]
|
|
111
|
-
@caret = @caret.clamp(0, @text.length)
|
|
112
|
-
@on_change&.call(@text)
|
|
167
|
+
adjust_left_column
|
|
113
168
|
end
|
|
114
169
|
|
|
115
|
-
|
|
170
|
+
# What the field paints in place of {#text}: one display character per
|
|
171
|
+
# {#text} character, in order. `column_at` measures `display_text[0, i]` as
|
|
172
|
+
# the rendering of `text[0, i]`, so an override that changes the character
|
|
173
|
+
# count — or reorders — desynchronizes the caret from the display. Nothing
|
|
174
|
+
# enforces it at runtime; a subclass pins it with a spec.
|
|
175
|
+
# @return [String] {#text} itself, unless a subclass substitutes.
|
|
176
|
+
def display_text = @text
|
|
116
177
|
|
|
117
|
-
|
|
118
|
-
# @return [Integer]
|
|
119
|
-
def max_text_length = (rect.width - 1).clamp(0, nil)
|
|
178
|
+
private
|
|
120
179
|
|
|
121
180
|
# @param char [String]
|
|
122
|
-
# @return [Boolean]
|
|
181
|
+
# @return [Boolean] always true — a field at {#max_text_length} swallows the
|
|
182
|
+
# key rather than declining it, so typing can never fall through to a
|
|
183
|
+
# scope-wide binding.
|
|
123
184
|
def insert(char)
|
|
124
|
-
return
|
|
185
|
+
return true if @max_text_length && @text.length >= @max_text_length
|
|
125
186
|
|
|
126
187
|
new_text = @text.dup.insert(@caret, char)
|
|
127
188
|
@caret += 1
|
|
128
189
|
self.text = new_text
|
|
129
190
|
true
|
|
130
191
|
end
|
|
192
|
+
|
|
193
|
+
# @param index [Integer] a {#text} index in `0..text.length`.
|
|
194
|
+
# @return [Integer] the column it sits at. An index landing inside a
|
|
195
|
+
# grapheme cluster measures the whole cluster, putting the caret just
|
|
196
|
+
# past it.
|
|
197
|
+
def column_at(index) = columns_of(display_text[0, index] || "")
|
|
198
|
+
|
|
199
|
+
# @param column [Integer] a text column (0 is the first glyph).
|
|
200
|
+
# @return [Integer] the nearest {#text} index — a column falling in a wide
|
|
201
|
+
# glyph's right half resolves past it.
|
|
202
|
+
def index_at(column)
|
|
203
|
+
col = 0
|
|
204
|
+
i = 0
|
|
205
|
+
display_text.each_grapheme_cluster do |g|
|
|
206
|
+
w = Buffer.display_width(g)
|
|
207
|
+
return i if column < col + ((w + 1) / 2)
|
|
208
|
+
|
|
209
|
+
col += w
|
|
210
|
+
i += g.length
|
|
211
|
+
end
|
|
212
|
+
i
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# @return [Integer] total display width of {#text}.
|
|
216
|
+
def text_columns = column_at(@text.length)
|
|
217
|
+
|
|
218
|
+
# @return [String] the windowed text, padded with spaces to `rect.width`.
|
|
219
|
+
# A wide glyph straddling the right edge is dropped rather than painted
|
|
220
|
+
# as a half glyph.
|
|
221
|
+
def visible_text
|
|
222
|
+
right = @left_column + rect.width
|
|
223
|
+
visible = +""
|
|
224
|
+
width = 0
|
|
225
|
+
col = 0
|
|
226
|
+
display_text.each_grapheme_cluster do |g|
|
|
227
|
+
start = col
|
|
228
|
+
col += Buffer.display_width(g)
|
|
229
|
+
next if start < @left_column
|
|
230
|
+
break if col > right
|
|
231
|
+
|
|
232
|
+
visible << g
|
|
233
|
+
width = col - @left_column
|
|
234
|
+
end
|
|
235
|
+
visible << (" " * (rect.width - width))
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Scrolls the minimum needed to keep the caret's column visible.
|
|
239
|
+
# @return [void]
|
|
240
|
+
def adjust_left_column
|
|
241
|
+
return unless rect.width.positive?
|
|
242
|
+
|
|
243
|
+
col = column_at(@caret)
|
|
244
|
+
@left_column = col if col < @left_column
|
|
245
|
+
@left_column = col - rect.width + 1 if col > @left_column + rect.width - 1
|
|
246
|
+
# The caret may park one column past the last glyph, so the scrollable
|
|
247
|
+
# range runs one column past the text.
|
|
248
|
+
@left_column = snap_to_glyph_start(@left_column.clamp(0, [text_columns - rect.width + 1, 0].max))
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# @param column [Integer]
|
|
252
|
+
# @return [Integer] the smallest glyph-boundary column `>= column`, so the
|
|
253
|
+
# window never opens on a wide glyph's right half.
|
|
254
|
+
#
|
|
255
|
+
# Snapping *right* is the only safe direction, and not because it shows
|
|
256
|
+
# more: the caret's own column is always a glyph boundary, so the next
|
|
257
|
+
# boundary at or after `left_column` can never overshoot it. Snapping left
|
|
258
|
+
# instead pulls the window's right edge inward, which strands the caret
|
|
259
|
+
# outside it whenever wide glyphs exactly fill a narrow field.
|
|
260
|
+
def snap_to_glyph_start(column)
|
|
261
|
+
col = 0
|
|
262
|
+
display_text.each_grapheme_cluster do |g|
|
|
263
|
+
return col if col >= column
|
|
264
|
+
|
|
265
|
+
col += Buffer.display_width(g)
|
|
266
|
+
end
|
|
267
|
+
col
|
|
268
|
+
end
|
|
131
269
|
end
|
|
132
270
|
end
|
|
133
271
|
end
|