tuile 0.1.0 → 0.3.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 +28 -0
- data/README.md +10 -10
- data/examples/file_commander.rb +0 -14
- data/examples/sampler.rb +320 -0
- data/lib/tuile/ansi.rb +14 -0
- data/lib/tuile/component/button.rb +86 -0
- data/lib/tuile/component/label.rb +64 -26
- data/lib/tuile/component/layout.rb +29 -12
- data/lib/tuile/component/list.rb +192 -63
- data/lib/tuile/component/text_area.rb +376 -0
- data/lib/tuile/component/text_field.rb +46 -4
- data/lib/tuile/component/text_view.rb +351 -0
- data/lib/tuile/component/window.rb +13 -5
- data/lib/tuile/component.rb +53 -5
- data/lib/tuile/event_queue.rb +14 -1
- data/lib/tuile/keys.rb +24 -4
- data/lib/tuile/screen.rb +127 -39
- data/lib/tuile/screen_pane.rb +29 -7
- data/lib/tuile/styled_string.rb +761 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +1 -1
- data/sig/tuile.rbs +958 -53
- metadata +9 -17
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A multi-line, word-wrapping text input.
|
|
6
|
+
#
|
|
7
|
+
# Sized by the caller — {#rect} is fixed; the area does not grow with
|
|
8
|
+
# content. Text is wrapped to {Rect#width} columns and any text that
|
|
9
|
+
# doesn't fit vertically is reached by scrolling: {#top_display_row}
|
|
10
|
+
# follows the caret so the line being edited stays visible. There is no
|
|
11
|
+
# horizontal scrolling.
|
|
12
|
+
#
|
|
13
|
+
# The caret is a logical index in `0..text.length`. When the caret falls
|
|
14
|
+
# inside a whitespace run that was absorbed by a soft wrap, it displays
|
|
15
|
+
# at the end of the previous row (which is visually identical to the
|
|
16
|
+
# start of the next row in nearly all cases).
|
|
17
|
+
#
|
|
18
|
+
# Currently only {#on_change} is wired; Enter inserts a newline as in any
|
|
19
|
+
# plain `<textarea>` or text editor. A future `on_enter`/`on_submit`
|
|
20
|
+
# callback may opt out of that by consuming Enter instead.
|
|
21
|
+
class TextArea < Component
|
|
22
|
+
def initialize
|
|
23
|
+
super
|
|
24
|
+
@text = +""
|
|
25
|
+
@caret = 0
|
|
26
|
+
@top_display_row = 0
|
|
27
|
+
@on_change = nil
|
|
28
|
+
@display_rows = nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [String] current text contents (may contain embedded `\n`).
|
|
32
|
+
attr_reader :text
|
|
33
|
+
|
|
34
|
+
# @return [Integer] caret index in `0..text.length`.
|
|
35
|
+
attr_reader :caret
|
|
36
|
+
|
|
37
|
+
# @return [Integer] index of the topmost display row currently visible.
|
|
38
|
+
attr_reader :top_display_row
|
|
39
|
+
|
|
40
|
+
# Optional callback fired whenever {#text} changes. Receives the new text
|
|
41
|
+
# as a single argument. Not fired by {#caret=} (text unchanged), not
|
|
42
|
+
# fired by a no-op setter, and not fired by a re-wrap caused by a width
|
|
43
|
+
# change ({#text} itself is unchanged).
|
|
44
|
+
# @return [Proc, Method, nil] one-arg callable, or nil.
|
|
45
|
+
attr_accessor :on_change
|
|
46
|
+
|
|
47
|
+
# Sets the text. Caret is clamped to the new text length; vertical scroll
|
|
48
|
+
# is adjusted to keep the caret visible.
|
|
49
|
+
# @param new_text [String]
|
|
50
|
+
def text=(new_text)
|
|
51
|
+
new_text = new_text.to_s
|
|
52
|
+
return if @text == new_text
|
|
53
|
+
|
|
54
|
+
@text = +new_text
|
|
55
|
+
@caret = @caret.clamp(0, @text.length)
|
|
56
|
+
@display_rows = nil
|
|
57
|
+
adjust_top_display_row
|
|
58
|
+
invalidate
|
|
59
|
+
@on_change&.call(@text)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Sets the caret position. Clamped to `0..text.length`; vertical scroll
|
|
63
|
+
# is adjusted to keep the caret visible.
|
|
64
|
+
# @param new_caret [Integer]
|
|
65
|
+
def caret=(new_caret)
|
|
66
|
+
new_caret = new_caret.clamp(0, @text.length)
|
|
67
|
+
return if @caret == new_caret
|
|
68
|
+
|
|
69
|
+
@caret = new_caret
|
|
70
|
+
adjust_top_display_row
|
|
71
|
+
invalidate
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def focusable? = true
|
|
75
|
+
|
|
76
|
+
def tab_stop? = true
|
|
77
|
+
|
|
78
|
+
# @return [Point, nil]
|
|
79
|
+
def cursor_position
|
|
80
|
+
return nil if rect.empty?
|
|
81
|
+
|
|
82
|
+
row, col = caret_to_display(@caret)
|
|
83
|
+
screen_row = row - @top_display_row
|
|
84
|
+
return nil if screen_row.negative? || screen_row >= rect.height
|
|
85
|
+
|
|
86
|
+
# Cap so the hardware cursor never lands at rect.left+rect.width
|
|
87
|
+
# (one past the rect). Terminals with auto-wrap interpret that as
|
|
88
|
+
# column 0 of the row below; capping pins the cursor on the last
|
|
89
|
+
# visible cell instead.
|
|
90
|
+
Point.new(rect.left + col.clamp(0, rect.width - 1), rect.top + screen_row)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @param key [String]
|
|
94
|
+
# @return [Boolean]
|
|
95
|
+
def handle_key(key)
|
|
96
|
+
return false unless active?
|
|
97
|
+
return true if super
|
|
98
|
+
|
|
99
|
+
case key
|
|
100
|
+
when Keys::LEFT_ARROW then self.caret = @caret - 1
|
|
101
|
+
when Keys::RIGHT_ARROW then self.caret = @caret + 1
|
|
102
|
+
when Keys::CTRL_LEFT_ARROW then self.caret = word_left
|
|
103
|
+
when Keys::CTRL_RIGHT_ARROW then self.caret = word_right
|
|
104
|
+
when Keys::UP_ARROW then move_caret_vertical(-1)
|
|
105
|
+
when Keys::DOWN_ARROW then move_caret_vertical(1)
|
|
106
|
+
when *Keys::HOMES then move_caret_to_row_start
|
|
107
|
+
when *Keys::ENDS_ then move_caret_to_row_end
|
|
108
|
+
when *Keys::BACKSPACES then delete_before_caret
|
|
109
|
+
when Keys::DELETE then delete_at_caret
|
|
110
|
+
when Keys::ENTER then insert_char("\n")
|
|
111
|
+
else
|
|
112
|
+
return insert_char(key) if printable?(key)
|
|
113
|
+
|
|
114
|
+
return false
|
|
115
|
+
end
|
|
116
|
+
true
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @param event [MouseEvent]
|
|
120
|
+
# @return [void]
|
|
121
|
+
def handle_mouse(event)
|
|
122
|
+
super
|
|
123
|
+
return unless event.button == :left && rect.contains?(event.point)
|
|
124
|
+
|
|
125
|
+
target_row = (event.y - rect.top) + @top_display_row
|
|
126
|
+
target_col = event.x - rect.left
|
|
127
|
+
rows = display_rows
|
|
128
|
+
if target_row >= rows.size
|
|
129
|
+
self.caret = @text.length
|
|
130
|
+
else
|
|
131
|
+
r = rows[target_row]
|
|
132
|
+
self.caret = r[:start] + target_col.clamp(0, r[:length])
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Same SGR palette as {Component::TextField} for visual consistency.
|
|
137
|
+
# @return [String]
|
|
138
|
+
ACTIVE_BG_SGR = TextField::ACTIVE_BG_SGR
|
|
139
|
+
# @return [String]
|
|
140
|
+
INACTIVE_BG_SGR = TextField::INACTIVE_BG_SGR
|
|
141
|
+
|
|
142
|
+
# @return [void]
|
|
143
|
+
def repaint
|
|
144
|
+
return if rect.empty?
|
|
145
|
+
|
|
146
|
+
bg = active? ? ACTIVE_BG_SGR : INACTIVE_BG_SGR
|
|
147
|
+
rows = display_rows
|
|
148
|
+
(0...rect.height).each do |screen_row|
|
|
149
|
+
row_idx = screen_row + @top_display_row
|
|
150
|
+
line = if row_idx >= rows.size
|
|
151
|
+
" " * rect.width
|
|
152
|
+
else
|
|
153
|
+
r = rows[row_idx]
|
|
154
|
+
chunk = @text[r[:start], r[:length]] || ""
|
|
155
|
+
chunk + (" " * (rect.width - r[:length]))
|
|
156
|
+
end
|
|
157
|
+
screen.print TTY::Cursor.move_to(rect.left, rect.top + screen_row), bg, line, Ansi::RESET
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
protected
|
|
162
|
+
|
|
163
|
+
# @return [void]
|
|
164
|
+
def on_width_changed
|
|
165
|
+
super
|
|
166
|
+
@display_rows = nil
|
|
167
|
+
adjust_top_display_row
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
# @return [Array<Hash{Symbol=>Integer}>] cached wrap of {#text} for the
|
|
173
|
+
# current {Rect#width}. Each entry is `{start:, length:}`.
|
|
174
|
+
def display_rows
|
|
175
|
+
@display_rows ||= compute_display_rows
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Greedy word-wrap. Whitespace at a soft-wrap break point is absorbed
|
|
179
|
+
# (not rendered on either row). A token longer than {Rect#width} hard-
|
|
180
|
+
# wraps inside the token. Newlines force a hard break and the wrap
|
|
181
|
+
# restarts on the next character.
|
|
182
|
+
# @return [Array<Hash{Symbol=>Integer}>]
|
|
183
|
+
def compute_display_rows
|
|
184
|
+
width = rect.width
|
|
185
|
+
return [{ start: 0, length: 0 }] if width <= 0 || @text.empty?
|
|
186
|
+
|
|
187
|
+
rows = []
|
|
188
|
+
pos = 0
|
|
189
|
+
n = @text.length
|
|
190
|
+
|
|
191
|
+
while pos < n
|
|
192
|
+
row_start = pos
|
|
193
|
+
row_chars = 0
|
|
194
|
+
|
|
195
|
+
while pos < n
|
|
196
|
+
c = @text[pos]
|
|
197
|
+
break if c == "\n"
|
|
198
|
+
|
|
199
|
+
if c.match?(/[ \t]/)
|
|
200
|
+
if row_chars < width
|
|
201
|
+
row_chars += 1
|
|
202
|
+
pos += 1
|
|
203
|
+
else
|
|
204
|
+
row_chars = trim_trailing_whitespace(row_start, row_chars)
|
|
205
|
+
pos += 1 while pos < n && @text[pos].match?(/[ \t]/)
|
|
206
|
+
break
|
|
207
|
+
end
|
|
208
|
+
else
|
|
209
|
+
word_end = pos
|
|
210
|
+
word_end += 1 while word_end < n && !@text[word_end].match?(/\s/)
|
|
211
|
+
word_len = word_end - pos
|
|
212
|
+
|
|
213
|
+
if row_chars + word_len <= width
|
|
214
|
+
row_chars += word_len
|
|
215
|
+
pos = word_end
|
|
216
|
+
elsif row_chars.zero?
|
|
217
|
+
row_chars = width
|
|
218
|
+
pos += width
|
|
219
|
+
break
|
|
220
|
+
else
|
|
221
|
+
row_chars = trim_trailing_whitespace(row_start, row_chars)
|
|
222
|
+
break
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
rows << { start: row_start, length: row_chars }
|
|
228
|
+
|
|
229
|
+
if pos < n && @text[pos] == "\n"
|
|
230
|
+
pos += 1
|
|
231
|
+
rows << { start: pos, length: 0 } if pos == n
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
rows << { start: 0, length: 0 } if rows.empty?
|
|
236
|
+
rows
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Trims trailing space/tab characters off a row's visible length so the
|
|
240
|
+
# whitespace at a soft-wrap point is absorbed (not rendered) rather than
|
|
241
|
+
# left at the end of the row. Without this, soft-wrapping `"foo bar"`
|
|
242
|
+
# to width 4 would yield row 0 length 4 (`"foo "`) and the natural
|
|
243
|
+
# end-of-row caret position would coincide with row 1's start.
|
|
244
|
+
# @param row_start [Integer]
|
|
245
|
+
# @param row_chars [Integer]
|
|
246
|
+
# @return [Integer] new row_chars.
|
|
247
|
+
def trim_trailing_whitespace(row_start, row_chars)
|
|
248
|
+
row_chars -= 1 while row_chars.positive? && @text[row_start + row_chars - 1].match?(/[ \t]/)
|
|
249
|
+
row_chars
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# @param caret [Integer]
|
|
253
|
+
# @return [Array(Integer, Integer)] `[row_index, column]` for `caret`.
|
|
254
|
+
def caret_to_display(caret)
|
|
255
|
+
rows = display_rows
|
|
256
|
+
rows.each_with_index do |r, i|
|
|
257
|
+
next_start = i + 1 < rows.size ? rows[i + 1][:start] : @text.length + 1
|
|
258
|
+
next unless caret >= r[:start] && caret < next_start
|
|
259
|
+
|
|
260
|
+
return [i, (caret - r[:start]).clamp(0, r[:length])]
|
|
261
|
+
end
|
|
262
|
+
r = rows.last
|
|
263
|
+
[rows.size - 1, (caret - r[:start]).clamp(0, r[:length])]
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# @param delta [Integer] `+1` for down, `-1` for up.
|
|
267
|
+
# @return [void]
|
|
268
|
+
def move_caret_vertical(delta)
|
|
269
|
+
rows = display_rows
|
|
270
|
+
cur_row, cur_col = caret_to_display(@caret)
|
|
271
|
+
new_row = (cur_row + delta).clamp(0, rows.size - 1)
|
|
272
|
+
if new_row == cur_row
|
|
273
|
+
# Already at the top/bottom display row. Snap to the absolute
|
|
274
|
+
# start/end of the text so the user has a quick way to reach it.
|
|
275
|
+
self.caret = delta.positive? ? @text.length : 0
|
|
276
|
+
return
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
r = rows[new_row]
|
|
280
|
+
self.caret = r[:start] + cur_col.clamp(0, r[:length])
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# @return [void]
|
|
284
|
+
def move_caret_to_row_start
|
|
285
|
+
rows = display_rows
|
|
286
|
+
cur_row, = caret_to_display(@caret)
|
|
287
|
+
self.caret = rows[cur_row][:start]
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# @return [void]
|
|
291
|
+
def move_caret_to_row_end
|
|
292
|
+
rows = display_rows
|
|
293
|
+
cur_row, = caret_to_display(@caret)
|
|
294
|
+
r = rows[cur_row]
|
|
295
|
+
self.caret = r[:start] + r[:length]
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# @param char [String]
|
|
299
|
+
# @return [Boolean] always true.
|
|
300
|
+
def insert_char(char)
|
|
301
|
+
@text = @text.dup.insert(@caret, char)
|
|
302
|
+
@caret += char.length
|
|
303
|
+
@display_rows = nil
|
|
304
|
+
adjust_top_display_row
|
|
305
|
+
invalidate
|
|
306
|
+
@on_change&.call(@text)
|
|
307
|
+
true
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# @return [void]
|
|
311
|
+
def delete_before_caret
|
|
312
|
+
return if @caret.zero?
|
|
313
|
+
|
|
314
|
+
@text = @text.dup
|
|
315
|
+
@text.slice!(@caret - 1)
|
|
316
|
+
@caret -= 1
|
|
317
|
+
@display_rows = nil
|
|
318
|
+
adjust_top_display_row
|
|
319
|
+
invalidate
|
|
320
|
+
@on_change&.call(@text)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# @return [void]
|
|
324
|
+
def delete_at_caret
|
|
325
|
+
return if @caret >= @text.length
|
|
326
|
+
|
|
327
|
+
@text = @text.dup
|
|
328
|
+
@text.slice!(@caret)
|
|
329
|
+
@display_rows = nil
|
|
330
|
+
adjust_top_display_row
|
|
331
|
+
invalidate
|
|
332
|
+
@on_change&.call(@text)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Keeps the caret visible by scrolling vertically.
|
|
336
|
+
# @return [void]
|
|
337
|
+
def adjust_top_display_row
|
|
338
|
+
return if rect.empty?
|
|
339
|
+
|
|
340
|
+
rows = display_rows
|
|
341
|
+
cur_row, = caret_to_display(@caret)
|
|
342
|
+
if cur_row < @top_display_row
|
|
343
|
+
@top_display_row = cur_row
|
|
344
|
+
elsif cur_row >= @top_display_row + rect.height
|
|
345
|
+
@top_display_row = cur_row - rect.height + 1
|
|
346
|
+
end
|
|
347
|
+
max_top = (rows.size - rect.height).clamp(0, nil)
|
|
348
|
+
@top_display_row = @top_display_row.clamp(0, max_top)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# @param key [String]
|
|
352
|
+
# @return [Boolean]
|
|
353
|
+
def printable?(key)
|
|
354
|
+
key.length == 1 && key.ord >= 0x20 && key.ord < 0x7f
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Same semantics as {TextField}'s ctrl+left.
|
|
358
|
+
# @return [Integer]
|
|
359
|
+
def word_left
|
|
360
|
+
c = @caret
|
|
361
|
+
c -= 1 while c.positive? && @text[c - 1].match?(/\s/)
|
|
362
|
+
c -= 1 while c.positive? && !@text[c - 1].match?(/\s/)
|
|
363
|
+
c
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Same semantics as {TextField}'s ctrl+right.
|
|
367
|
+
# @return [Integer]
|
|
368
|
+
def word_right
|
|
369
|
+
c = @caret
|
|
370
|
+
c += 1 while c < @text.length && !@text[c].match?(/\s/)
|
|
371
|
+
c += 1 while c < @text.length && @text[c].match?(/\s/)
|
|
372
|
+
c
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
@@ -87,6 +87,8 @@ module Tuile
|
|
|
87
87
|
|
|
88
88
|
def focusable? = true
|
|
89
89
|
|
|
90
|
+
def tab_stop? = true
|
|
91
|
+
|
|
90
92
|
# @return [Point, nil]
|
|
91
93
|
def cursor_position
|
|
92
94
|
return nil unless rect.width.positive?
|
|
@@ -103,8 +105,10 @@ module Tuile
|
|
|
103
105
|
case key
|
|
104
106
|
when Keys::LEFT_ARROW then self.caret = @caret - 1
|
|
105
107
|
when Keys::RIGHT_ARROW then self.caret = @caret + 1
|
|
106
|
-
when Keys::
|
|
107
|
-
when Keys::
|
|
108
|
+
when Keys::CTRL_LEFT_ARROW then self.caret = word_left
|
|
109
|
+
when Keys::CTRL_RIGHT_ARROW then self.caret = word_right
|
|
110
|
+
when *Keys::HOMES then self.caret = 0
|
|
111
|
+
when *Keys::ENDS_ then self.caret = @text.length
|
|
108
112
|
when *Keys::BACKSPACES then delete_before_caret
|
|
109
113
|
when Keys::DELETE then delete_at_caret
|
|
110
114
|
when Keys::ESC
|
|
@@ -140,12 +144,28 @@ module Tuile
|
|
|
140
144
|
self.caret = (event.x - rect.left).clamp(0, @text.length)
|
|
141
145
|
end
|
|
142
146
|
|
|
147
|
+
# 256-color SGR for the focused-button highlight (matches what
|
|
148
|
+
# `Rainbow(...).bg(:darkslategray)` emits, which is what
|
|
149
|
+
# {Component::Button#repaint} uses for its focused state).
|
|
150
|
+
# @return [String]
|
|
151
|
+
ACTIVE_BG_SGR = "\e[48;5;59m"
|
|
152
|
+
# 256-color SGR for the unfocused field's "well": index 238 sits in
|
|
153
|
+
# the grayscale ramp (~#444444), bright enough to stand out against
|
|
154
|
+
# non-pure-black terminal themes (Gruvbox/Solarized/OneDark base
|
|
155
|
+
# backgrounds sit in the #1d–#2d range), and still distinctly darker
|
|
156
|
+
# than the active highlight at index 59 (~#5f5f5f). Rainbow's
|
|
157
|
+
# RGB-to-256 mapping snaps everything dark to palette index 16
|
|
158
|
+
# (terminal black), so we emit the escape directly to reach the ramp.
|
|
159
|
+
# @return [String]
|
|
160
|
+
INACTIVE_BG_SGR = "\e[48;5;238m"
|
|
161
|
+
|
|
143
162
|
# @return [void]
|
|
144
163
|
def repaint
|
|
145
|
-
clear_background
|
|
146
164
|
return if rect.empty?
|
|
147
165
|
|
|
148
|
-
|
|
166
|
+
bg = active? ? ACTIVE_BG_SGR : INACTIVE_BG_SGR
|
|
167
|
+
padded = @text + (" " * (rect.width - @text.length))
|
|
168
|
+
screen.print TTY::Cursor.move_to(rect.left, rect.top), bg, padded, Ansi::RESET
|
|
149
169
|
end
|
|
150
170
|
|
|
151
171
|
protected
|
|
@@ -204,6 +224,28 @@ module Tuile
|
|
|
204
224
|
def printable?(key)
|
|
205
225
|
key.length == 1 && key.ord >= 0x20 && key.ord < 0x7f
|
|
206
226
|
end
|
|
227
|
+
|
|
228
|
+
# Caret target for ctrl+left: skip whitespace going left, then a run of
|
|
229
|
+
# non-whitespace. Lands at the beginning of the current word, or the
|
|
230
|
+
# beginning of the previous word if already there.
|
|
231
|
+
# @return [Integer]
|
|
232
|
+
def word_left
|
|
233
|
+
c = @caret
|
|
234
|
+
c -= 1 while c.positive? && @text[c - 1].match?(/\s/)
|
|
235
|
+
c -= 1 while c.positive? && !@text[c - 1].match?(/\s/)
|
|
236
|
+
c
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Caret target for ctrl+right: skip non-whitespace going right, then a
|
|
240
|
+
# run of whitespace. Lands at the beginning of the next word, or at the
|
|
241
|
+
# end of the text if no further word exists.
|
|
242
|
+
# @return [Integer]
|
|
243
|
+
def word_right
|
|
244
|
+
c = @caret
|
|
245
|
+
c += 1 while c < @text.length && !@text[c].match?(/\s/)
|
|
246
|
+
c += 1 while c < @text.length && @text[c].match?(/\s/)
|
|
247
|
+
c
|
|
248
|
+
end
|
|
207
249
|
end
|
|
208
250
|
end
|
|
209
251
|
end
|