tuile 0.11.0 → 0.12.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 +680 -8
- data/README.md +12 -13
- data/TERMINOLOGY.md +61 -0
- data/book/02-repaint.md +1 -1
- data/book/03-layout.md +1 -1
- data/book/06-theming.md +1 -1
- data/book/07-components.md +97 -27
- data/examples/file_commander.rb +5 -4
- data/examples/sampler.rb +38 -1
- data/ideas/new-components.md +9 -4
- data/lib/tuile/buffer.rb +7 -7
- data/lib/tuile/component/button.rb +1 -1
- data/lib/tuile/component/checkbox.rb +1 -1
- data/lib/tuile/component/checkbox_group.rb +31 -26
- data/lib/tuile/component/combo_box.rb +10 -7
- data/lib/tuile/component/info_window.rb +1 -1
- data/lib/tuile/component/label.rb +14 -14
- data/lib/tuile/component/list.rb +291 -216
- data/lib/tuile/component/list_dropdown.rb +14 -7
- data/lib/tuile/component/notification.rb +317 -0
- data/lib/tuile/component/picker_window.rb +3 -3
- data/lib/tuile/component/popup.rb +8 -10
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/radio_group.rb +32 -30
- data/lib/tuile/component/select.rb +7 -7
- data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
- data/lib/tuile/component/text_area.rb +79 -273
- data/lib/tuile/component/text_field.rb +1 -1
- data/lib/tuile/component/text_view.rb +191 -177
- data/lib/tuile/component/window.rb +8 -8
- data/lib/tuile/component.rb +5 -5
- data/lib/tuile/screen.rb +1 -1
- data/lib/tuile/styled_string.rb +12 -12
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +6 -6
- data/sig/tuile.rbs +788 -377
- metadata +4 -1
|
@@ -6,7 +6,7 @@ module Tuile
|
|
|
6
6
|
#
|
|
7
7
|
# Sized by the caller — {#rect} is fixed; the area does not grow with
|
|
8
8
|
# content. Text is wrapped to {Rect#width} columns and any text that
|
|
9
|
-
# doesn't fit vertically is reached by scrolling: {#
|
|
9
|
+
# doesn't fit vertically is reached by scrolling: {#scroll_top_row}
|
|
10
10
|
# follows the caret so the line being edited stays visible. There is no
|
|
11
11
|
# horizontal scrolling.
|
|
12
12
|
#
|
|
@@ -22,49 +22,69 @@ module Tuile
|
|
|
22
22
|
# accepted — otherwise a multi-line paste would silently lose its
|
|
23
23
|
# newlines.
|
|
24
24
|
#
|
|
25
|
-
#
|
|
25
|
+
# Up/Down move the caret between rows and, at the first/last row, snap to
|
|
26
|
+
# the start/end of the text. A subclass can claim the key at that edge
|
|
27
|
+
# instead — shell-style history recall is the motivating case — by asking
|
|
28
|
+
# {#caret_row} and {#row_count} before delegating:
|
|
29
|
+
#
|
|
30
|
+
# class PromptArea < Component::TextArea
|
|
31
|
+
# protected
|
|
32
|
+
#
|
|
33
|
+
# def handle_text_input_key(key)
|
|
34
|
+
# return recall_previous if key == Keys::UP_ARROW && caret_row.zero?
|
|
35
|
+
# return recall_next if key == Keys::DOWN_ARROW && caret_row == row_count - 1
|
|
36
|
+
#
|
|
37
|
+
# super # anywhere else: the caret moves, and the edge still snaps
|
|
38
|
+
# end
|
|
39
|
+
# end
|
|
26
40
|
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
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`.
|
|
41
|
+
# Both recalls return `true` to consume the key; app code that would rather
|
|
42
|
+
# not subclass claims the same keys through {AbstractStringField#on_key}.
|
|
34
43
|
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
44
|
+
# == Implementation details
|
|
45
|
+
#
|
|
46
|
+
# The wrap itself — and with it every conversion between a character
|
|
47
|
+
# **index** and a **row/column** — lives in {WrappedText}, a
|
|
48
|
+
# snapshot of `(text, rect.width)` this class caches and drops whenever
|
|
49
|
+
# either changes. What stays here is the widget: keys, mouse, painting, and
|
|
50
|
+
# the {#scroll_top_row} viewport, which {WrappedText} deliberately knows
|
|
51
|
+
# nothing about (it is a pure function of text and width; the viewport is
|
|
52
|
+
# stateful and needs {Rect#height}).
|
|
39
53
|
class TextArea < AbstractStringField
|
|
40
54
|
def initialize
|
|
41
55
|
super
|
|
42
|
-
@
|
|
43
|
-
# Lazy cache
|
|
44
|
-
#
|
|
45
|
-
|
|
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}.
|
|
49
|
-
@display_rows = nil
|
|
56
|
+
@scroll_top_row = 0
|
|
57
|
+
# Lazy cache; nil means "stale, rebuild on next read". Reset whenever
|
|
58
|
+
# {#text} mutates or the width changes.
|
|
59
|
+
@wrap = nil
|
|
50
60
|
end
|
|
51
61
|
|
|
52
|
-
# @return [Integer] index of the topmost
|
|
53
|
-
attr_reader :
|
|
62
|
+
# @return [Integer] index of the topmost row currently visible.
|
|
63
|
+
attr_reader :scroll_top_row
|
|
64
|
+
|
|
65
|
+
# The caret's row, counted from the text's first row — *not* from the top
|
|
66
|
+
# of the viewport (subtract {#scroll_top_row} for that).
|
|
67
|
+
# @return [Integer] a row index in `0...row_count`.
|
|
68
|
+
def caret_row = wrap.row_at(@caret)
|
|
69
|
+
|
|
70
|
+
# @return [Integer] rows the wrapped text occupies at the current
|
|
71
|
+
# {Rect#width}; always `>= 1`, since empty text still wraps to one
|
|
72
|
+
# (empty) row.
|
|
73
|
+
def row_count = wrap.row_count
|
|
54
74
|
|
|
55
75
|
# @return [Point, nil]
|
|
56
76
|
def cursor_position
|
|
57
77
|
return nil if rect.empty?
|
|
58
78
|
|
|
59
|
-
row, col =
|
|
60
|
-
|
|
61
|
-
return nil if
|
|
79
|
+
row, col = wrap.position_at(@caret)
|
|
80
|
+
row_in_viewport = row - @scroll_top_row
|
|
81
|
+
return nil if row_in_viewport.negative? || row_in_viewport >= rect.height
|
|
62
82
|
|
|
63
83
|
# Cap so the hardware cursor never lands at rect.left+rect.width
|
|
64
84
|
# (one past the rect). Terminals with auto-wrap interpret that as
|
|
65
85
|
# column 0 of the row below; capping pins the cursor on the last
|
|
66
86
|
# visible cell instead.
|
|
67
|
-
Point.new(rect.left + col.clamp(0, rect.width - 1), rect.top +
|
|
87
|
+
Point.new(rect.left + col.clamp(0, rect.width - 1), rect.top + row_in_viewport)
|
|
68
88
|
end
|
|
69
89
|
|
|
70
90
|
# @param event [MouseEvent]
|
|
@@ -73,26 +93,21 @@ module Tuile
|
|
|
73
93
|
super
|
|
74
94
|
return unless event.button == :left && rect.contains?(event.point)
|
|
75
95
|
|
|
76
|
-
target_row = (event.y - rect.top) + @
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
r = rows[target_row]
|
|
83
|
-
self.caret = r[:start] + chars_for_column(r, target_col)
|
|
84
|
-
end
|
|
96
|
+
target_row = (event.y - rect.top) + @scroll_top_row
|
|
97
|
+
self.caret = if target_row >= wrap.row_count
|
|
98
|
+
@text.length
|
|
99
|
+
else
|
|
100
|
+
wrap.index_at(target_row, event.x - rect.left)
|
|
101
|
+
end
|
|
85
102
|
end
|
|
86
103
|
|
|
87
104
|
# @return [void]
|
|
88
105
|
def repaint
|
|
89
106
|
return if rect.empty?
|
|
90
107
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
line = row_idx >= rows.size ? " " * rect.width : padded_row(rows[row_idx])
|
|
95
|
-
screen.buffer.set_line(rect.left, rect.top + screen_row, background(line))
|
|
108
|
+
(0...rect.height).each do |row_in_viewport|
|
|
109
|
+
line = wrap.row_text(row_in_viewport + @scroll_top_row)
|
|
110
|
+
screen.buffer.set_text(rect.left, rect.top + row_in_viewport, background(line))
|
|
96
111
|
end
|
|
97
112
|
end
|
|
98
113
|
|
|
@@ -100,13 +115,13 @@ module Tuile
|
|
|
100
115
|
|
|
101
116
|
# @return [void]
|
|
102
117
|
def on_text_mutated
|
|
103
|
-
@
|
|
104
|
-
|
|
118
|
+
@wrap = nil
|
|
119
|
+
adjust_scroll_top_row
|
|
105
120
|
end
|
|
106
121
|
|
|
107
122
|
# @return [void]
|
|
108
123
|
def on_caret_mutated
|
|
109
|
-
|
|
124
|
+
adjust_scroll_top_row
|
|
110
125
|
end
|
|
111
126
|
|
|
112
127
|
# @param key [String]
|
|
@@ -131,248 +146,40 @@ module Tuile
|
|
|
131
146
|
# @return [void]
|
|
132
147
|
def on_width_changed
|
|
133
148
|
super
|
|
134
|
-
@
|
|
135
|
-
|
|
149
|
+
@wrap = nil
|
|
150
|
+
adjust_scroll_top_row
|
|
136
151
|
end
|
|
137
152
|
|
|
138
153
|
private
|
|
139
154
|
|
|
140
|
-
# @return [
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
@display_rows ||= compute_display_rows
|
|
144
|
-
end
|
|
145
|
-
|
|
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.
|
|
172
|
-
# @return [Array<Hash{Symbol=>Integer}>]
|
|
173
|
-
def compute_display_rows
|
|
174
|
-
width = rect.width
|
|
175
|
-
return [{ start: 0, length: 0, columns: 0 }] if width <= 0 || @text.empty?
|
|
176
|
-
|
|
177
|
-
cl = cluster_table
|
|
178
|
-
rows = []
|
|
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
|
|
196
|
-
else
|
|
197
|
-
chars, cols = trim_trailing_whitespace(start, chars, cols)
|
|
198
|
-
i += 1 while i < n && blank?(cl[i])
|
|
199
|
-
break
|
|
200
|
-
end
|
|
201
|
-
else
|
|
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)
|
|
210
|
-
break
|
|
211
|
-
else
|
|
212
|
-
chars, cols = trim_trailing_whitespace(start, chars, cols)
|
|
213
|
-
break
|
|
214
|
-
end
|
|
215
|
-
end
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
rows << { start: start, length: chars, columns: cols }
|
|
219
|
-
|
|
220
|
-
next unless i < n && newline?(cl[i])
|
|
221
|
-
|
|
222
|
-
i += 1
|
|
223
|
-
rows << { start: @text.length, length: 0, columns: 0 } if i >= n
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
rows << { start: 0, length: 0, columns: 0 } if rows.empty?
|
|
227
|
-
rows
|
|
228
|
-
end
|
|
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
|
-
|
|
271
|
-
# Trims trailing space/tab characters off a row's visible length so the
|
|
272
|
-
# whitespace at a soft-wrap point is absorbed (not rendered) rather than
|
|
273
|
-
# left at the end of the row. Without this, soft-wrapping `"foo bar"`
|
|
274
|
-
# to width 4 would yield row 0 length 4 (`"foo "`) and the natural
|
|
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.
|
|
279
|
-
# @param row_start [Integer]
|
|
280
|
-
# @param row_chars [Integer]
|
|
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]
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
# @param caret [Integer]
|
|
292
|
-
# @return [Array(Integer, Integer)] `[row_index, column]` for `caret`.
|
|
293
|
-
def caret_to_display(caret)
|
|
294
|
-
rows = display_rows
|
|
295
|
-
rows.each_with_index do |r, i|
|
|
296
|
-
next_start = i + 1 < rows.size ? rows[i + 1][:start] : @text.length + 1
|
|
297
|
-
next unless caret >= r[:start] && caret < next_start
|
|
298
|
-
|
|
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
|
|
342
|
-
end
|
|
343
|
-
out << (" " * (rect.width - cols))
|
|
155
|
+
# @return [WrappedText] the current wrap of {#text} at {Rect#width}.
|
|
156
|
+
def wrap
|
|
157
|
+
@wrap ||= WrappedText.new(@text, rect.width)
|
|
344
158
|
end
|
|
345
159
|
|
|
346
160
|
# @param delta [Integer] `+1` for down, `-1` for up.
|
|
347
161
|
# @return [void]
|
|
348
162
|
def move_caret_vertical(delta)
|
|
349
|
-
|
|
350
|
-
cur_row,
|
|
351
|
-
new_row = (cur_row + delta).clamp(0, rows.size - 1)
|
|
163
|
+
cur_row, cur_col = wrap.position_at(@caret)
|
|
164
|
+
new_row = (cur_row + delta).clamp(0, wrap.row_count - 1)
|
|
352
165
|
if new_row == cur_row
|
|
353
|
-
# Already at the top/bottom
|
|
354
|
-
#
|
|
166
|
+
# Already at the top/bottom row. Snap to the absolute start/end of the
|
|
167
|
+
# text so the user has a quick way to reach it.
|
|
355
168
|
self.caret = delta.positive? ? @text.length : 0
|
|
356
169
|
return
|
|
357
170
|
end
|
|
358
171
|
|
|
359
|
-
|
|
360
|
-
self.caret = r[:start] + chars_for_column(r, cur_col)
|
|
172
|
+
self.caret = wrap.index_at(new_row, cur_col)
|
|
361
173
|
end
|
|
362
174
|
|
|
363
175
|
# @return [void]
|
|
364
176
|
def move_caret_to_row_start
|
|
365
|
-
|
|
366
|
-
cur_row, = caret_to_display(@caret)
|
|
367
|
-
self.caret = rows[cur_row][:start]
|
|
177
|
+
self.caret = wrap.row_start(wrap.row_at(@caret))
|
|
368
178
|
end
|
|
369
179
|
|
|
370
180
|
# @return [void]
|
|
371
181
|
def move_caret_to_row_end
|
|
372
|
-
|
|
373
|
-
cur_row, = caret_to_display(@caret)
|
|
374
|
-
r = rows[cur_row]
|
|
375
|
-
self.caret = r[:start] + r[:length]
|
|
182
|
+
self.caret = wrap.row_end(wrap.row_at(@caret))
|
|
376
183
|
end
|
|
377
184
|
|
|
378
185
|
# @param char [String]
|
|
@@ -386,18 +193,17 @@ module Tuile
|
|
|
386
193
|
|
|
387
194
|
# Keeps the caret visible by scrolling vertically.
|
|
388
195
|
# @return [void]
|
|
389
|
-
def
|
|
196
|
+
def adjust_scroll_top_row
|
|
390
197
|
return if rect.empty?
|
|
391
198
|
|
|
392
|
-
|
|
393
|
-
cur_row
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
@top_display_row = cur_row - rect.height + 1
|
|
199
|
+
cur_row = wrap.row_at(@caret)
|
|
200
|
+
if cur_row < @scroll_top_row
|
|
201
|
+
@scroll_top_row = cur_row
|
|
202
|
+
elsif cur_row >= @scroll_top_row + rect.height
|
|
203
|
+
@scroll_top_row = cur_row - rect.height + 1
|
|
398
204
|
end
|
|
399
|
-
max_top = (
|
|
400
|
-
@
|
|
205
|
+
max_top = (wrap.row_count - rect.height).clamp(0, nil)
|
|
206
|
+
@scroll_top_row = @scroll_top_row.clamp(0, max_top)
|
|
401
207
|
end
|
|
402
208
|
end
|
|
403
209
|
end
|