tuile 0.3.0 → 0.5.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.
@@ -18,63 +18,22 @@ module Tuile
18
18
  # Currently only {#on_change} is wired; Enter inserts a newline as in any
19
19
  # plain `<textarea>` or text editor. A future `on_enter`/`on_submit`
20
20
  # callback may opt out of that by consuming Enter instead.
21
- class TextArea < Component
21
+ class TextArea < TextInput
22
22
  def initialize
23
23
  super
24
- @text = +""
25
- @caret = 0
26
24
  @top_display_row = 0
27
- @on_change = nil
25
+ # Lazy cache of the word-wrapped layout: an
26
+ # `Array<Hash{Symbol=>Integer}>` whose entries are
27
+ # `{start: <text-index>, length: <chars>}`, one per display row, built
28
+ # by {#compute_display_rows}. `nil` means "stale, recompute on next
29
+ # read". Reset to nil whenever {#text} mutates or the width changes;
30
+ # see {#on_text_mutated} and {#on_width_changed}.
28
31
  @display_rows = nil
29
32
  end
30
33
 
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
34
  # @return [Integer] index of the topmost display row currently visible.
38
35
  attr_reader :top_display_row
39
36
 
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
37
  # @return [Point, nil]
79
38
  def cursor_position
80
39
  return nil if rect.empty?
@@ -90,32 +49,6 @@ module Tuile
90
49
  Point.new(rect.left + col.clamp(0, rect.width - 1), rect.top + screen_row)
91
50
  end
92
51
 
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
52
  # @param event [MouseEvent]
120
53
  # @return [void]
121
54
  def handle_mouse(event)
@@ -133,12 +66,6 @@ module Tuile
133
66
  end
134
67
  end
135
68
 
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
69
  # @return [void]
143
70
  def repaint
144
71
  return if rect.empty?
@@ -160,6 +87,36 @@ module Tuile
160
87
 
161
88
  protected
162
89
 
90
+ # @return [void]
91
+ def on_text_mutated
92
+ @display_rows = nil
93
+ adjust_top_display_row
94
+ end
95
+
96
+ # @return [void]
97
+ def on_caret_mutated
98
+ adjust_top_display_row
99
+ end
100
+
101
+ # @param key [String]
102
+ # @return [Boolean]
103
+ def handle_text_input_key(key)
104
+ case key
105
+ when Keys::UP_ARROW then move_caret_vertical(-1)
106
+ when Keys::DOWN_ARROW then move_caret_vertical(1)
107
+ when *Keys::HOMES then move_caret_to_row_start
108
+ when *Keys::ENDS_ then move_caret_to_row_end
109
+ when *Keys::BACKSPACES then delete_before_caret
110
+ when Keys::DELETE then delete_at_caret
111
+ when Keys::ENTER then insert_char("\n")
112
+ else
113
+ return insert_char(key) if Keys.printable?(key)
114
+
115
+ return super
116
+ end
117
+ true
118
+ end
119
+
163
120
  # @return [void]
164
121
  def on_width_changed
165
122
  super
@@ -298,40 +255,12 @@ module Tuile
298
255
  # @param char [String]
299
256
  # @return [Boolean] always true.
300
257
  def insert_char(char)
301
- @text = @text.dup.insert(@caret, char)
258
+ new_text = @text.dup.insert(@caret, char)
302
259
  @caret += char.length
303
- @display_rows = nil
304
- adjust_top_display_row
305
- invalidate
306
- @on_change&.call(@text)
260
+ self.text = new_text
307
261
  true
308
262
  end
309
263
 
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
264
  # Keeps the caret visible by scrolling vertically.
336
265
  # @return [void]
337
266
  def adjust_top_display_row
@@ -347,30 +276,6 @@ module Tuile
347
276
  max_top = (rows.size - rect.height).clamp(0, nil)
348
277
  @top_display_row = @top_display_row.clamp(0, max_top)
349
278
  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
279
  end
375
280
  end
376
281
  end
@@ -11,36 +11,14 @@ module Tuile
11
11
  # The caret is a logical index in `0..text.length`. The hardware cursor is
12
12
  # positioned by {Screen} after each repaint cycle when this component is
13
13
  # focused; see {Component#cursor_position}.
14
- class TextField < Component
14
+ class TextField < TextInput
15
15
  def initialize
16
16
  super
17
- @text = +""
18
- @caret = 0
19
- @on_escape = nil
20
- @on_change = nil
21
17
  @on_key_up = nil
22
18
  @on_key_down = nil
23
19
  @on_enter = nil
24
20
  end
25
21
 
26
- # @return [String] current text contents.
27
- attr_reader :text
28
-
29
- # @return [Integer] caret index in `0..text.length`.
30
- attr_reader :caret
31
-
32
- # Optional callback fired when ESC is pressed. When set, ESC is consumed
33
- # by the field; when nil, ESC falls through to the parent (default
34
- # behavior).
35
- # @return [Proc, Method, nil] no-arg callable, or nil.
36
- attr_accessor :on_escape
37
-
38
- # Optional callback fired whenever {#text} changes. Receives the new text
39
- # as a single argument. Not fired by {#caret=} (text unchanged) and not
40
- # fired when a setter is a no-op.
41
- # @return [Proc, Method, nil] one-arg callable, or nil.
42
- attr_accessor :on_change
43
-
44
22
  # Optional callback fired when the UP arrow key is pressed. When set, UP
45
23
  # is consumed by the field; when nil, UP falls through to the parent
46
24
  # (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
@@ -61,60 +39,50 @@ module Tuile
61
39
  # @return [Proc, Method, nil] no-arg callable, or nil.
62
40
  attr_accessor :on_enter
63
41
 
64
- # Sets the text. Truncates to fit if longer than `rect.width - 1`. Caret
65
- # is clamped to the new text length.
66
- # @param new_text [String]
67
- def text=(new_text)
68
- new_text = new_text.to_s
69
- new_text = new_text[0, max_text_length] if new_text.length > max_text_length
70
- return if @text == new_text
42
+ # @return [Point, nil]
43
+ def cursor_position
44
+ return nil unless rect.width.positive?
71
45
 
72
- @text = +new_text
73
- @caret = @caret.clamp(0, @text.length)
74
- invalidate
75
- @on_change&.call(@text)
46
+ Point.new(rect.left + @caret, rect.top)
76
47
  end
77
48
 
78
- # Sets the caret position. Clamped to `0..text.length`.
79
- # @param new_caret [Integer]
80
- def caret=(new_caret)
81
- new_caret = new_caret.clamp(0, @text.length)
82
- return if @caret == new_caret
49
+ # @param event [MouseEvent]
50
+ # @return [void]
51
+ def handle_mouse(event)
52
+ super
53
+ return unless event.button == :left && rect.contains?(event.point)
83
54
 
84
- @caret = new_caret
85
- invalidate
55
+ self.caret = (event.x - rect.left).clamp(0, @text.length)
86
56
  end
87
57
 
88
- def focusable? = true
58
+ # @return [void]
59
+ def repaint
60
+ return if rect.empty?
89
61
 
90
- def tab_stop? = true
62
+ bg = active? ? ACTIVE_BG_SGR : INACTIVE_BG_SGR
63
+ padded = @text + (" " * (rect.width - @text.length))
64
+ screen.print TTY::Cursor.move_to(rect.left, rect.top), bg, padded, Ansi::RESET
65
+ end
91
66
 
92
- # @return [Point, nil]
93
- def cursor_position
94
- return nil unless rect.width.positive?
67
+ protected
95
68
 
96
- Point.new(rect.left + @caret, rect.top)
69
+ # Truncate to fit `rect.width - 1` — single-line fields can't grow past
70
+ # their width.
71
+ # @param new_text [String]
72
+ # @return [String]
73
+ def preprocess_text(new_text)
74
+ new_text = new_text.to_s
75
+ new_text.length > max_text_length ? new_text[0, max_text_length] : new_text
97
76
  end
98
77
 
99
78
  # @param key [String]
100
79
  # @return [Boolean]
101
- def handle_key(key)
102
- return false unless active?
103
- return true if super
104
-
80
+ def handle_text_input_key(key)
105
81
  case key
106
- when Keys::LEFT_ARROW then self.caret = @caret - 1
107
- when Keys::RIGHT_ARROW then self.caret = @caret + 1
108
- when Keys::CTRL_LEFT_ARROW then self.caret = word_left
109
- when Keys::CTRL_RIGHT_ARROW then self.caret = word_right
110
82
  when *Keys::HOMES then self.caret = 0
111
83
  when *Keys::ENDS_ then self.caret = @text.length
112
84
  when *Keys::BACKSPACES then delete_before_caret
113
85
  when Keys::DELETE then delete_at_caret
114
- when Keys::ESC
115
- return false if @on_escape.nil?
116
-
117
- @on_escape.call
118
86
  when Keys::UP_ARROW
119
87
  return false if @on_key_up.nil?
120
88
 
@@ -128,48 +96,13 @@ module Tuile
128
96
 
129
97
  @on_enter.call
130
98
  else
131
- return insert(key) if printable?(key)
99
+ return insert(key) if Keys.printable?(key)
132
100
 
133
- return false
101
+ return super
134
102
  end
135
103
  true
136
104
  end
137
105
 
138
- # @param event [MouseEvent]
139
- # @return [void]
140
- def handle_mouse(event)
141
- super
142
- return unless event.button == :left && rect.contains?(event.point)
143
-
144
- self.caret = (event.x - rect.left).clamp(0, @text.length)
145
- end
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
-
162
- # @return [void]
163
- def repaint
164
- return if rect.empty?
165
-
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
169
- end
170
-
171
- protected
172
-
173
106
  # @return [void]
174
107
  def on_width_changed
175
108
  super
@@ -191,61 +124,11 @@ module Tuile
191
124
  def insert(char)
192
125
  return false if @text.length >= max_text_length
193
126
 
194
- @text = @text.dup.insert(@caret, char)
127
+ new_text = @text.dup.insert(@caret, char)
195
128
  @caret += 1
196
- invalidate
197
- @on_change&.call(@text)
129
+ self.text = new_text
198
130
  true
199
131
  end
200
-
201
- # @return [void]
202
- def delete_before_caret
203
- return if @caret.zero?
204
-
205
- @text = @text.dup
206
- @text.slice!(@caret - 1)
207
- @caret -= 1
208
- invalidate
209
- @on_change&.call(@text)
210
- end
211
-
212
- # @return [void]
213
- def delete_at_caret
214
- return if @caret >= @text.length
215
-
216
- @text = @text.dup
217
- @text.slice!(@caret)
218
- invalidate
219
- @on_change&.call(@text)
220
- end
221
-
222
- # @param key [String]
223
- # @return [Boolean]
224
- def printable?(key)
225
- key.length == 1 && key.ord >= 0x20 && key.ord < 0x7f
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
249
132
  end
250
133
  end
251
134
  end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # Abstract base for editable text components ({TextField}, {TextArea}).
6
+ #
7
+ # Holds the shared state — a mutable {#text} buffer, a {#caret} index,
8
+ # {#on_change} and {#on_escape} callbacks — and the keyboard machinery
9
+ # that single-line and multi-line inputs both need: ESC handling,
10
+ # LEFT/RIGHT caret movement, CTRL+LEFT/CTRL+RIGHT word jumps, and the
11
+ # `focusable?`/`tab_stop?` flags.
12
+ #
13
+ # Subclasses implement the layout-specific pieces ({#cursor_position},
14
+ # {#repaint}) and add their own keys (HOME/END, ENTER, UP/DOWN,
15
+ # printable insertion) by overriding the protected
16
+ # {#handle_text_input_key} hook — `super` falls through to the common
17
+ # navigation handling.
18
+ #
19
+ # The mutation pipeline is a template method: {#text=} and {#caret=}
20
+ # detect no-ops, mutate state, fire {#on_change}, and invalidate.
21
+ # Subclasses inject their own behavior via two protected hooks:
22
+ #
23
+ # - {#preprocess_text} — input filter (e.g. {TextField} truncates to
24
+ # fit `rect.width - 1`).
25
+ # - {#on_text_mutated} / {#on_caret_mutated} — post-mutation side
26
+ # effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
27
+ # keep the caret visible).
28
+ class TextInput < Component
29
+ def initialize
30
+ super
31
+ @text = +""
32
+ @caret = 0
33
+ @on_change = nil
34
+ @on_escape = method(:default_on_escape)
35
+ end
36
+
37
+ # @return [String] current text contents.
38
+ attr_reader :text
39
+
40
+ # @return [Boolean] true iff {#text} is the empty string.
41
+ def empty? = @text.empty?
42
+
43
+ # @return [Integer] caret index in `0..text.length`.
44
+ attr_reader :caret
45
+
46
+ # Optional callback fired whenever {#text} changes. Receives the new text
47
+ # as a single argument. Not fired by {#caret=} (text unchanged) and not
48
+ # fired when a setter is a no-op.
49
+ # @return [Proc, Method, nil] one-arg callable, or nil.
50
+ attr_accessor :on_change
51
+
52
+ # Callback fired when ESC is pressed. Defaults to a closure that clears
53
+ # focus (`screen.focused = nil`) so ESC visibly cancels text entry instead
54
+ # of bubbling to the parent — and, in particular, instead of reaching the
55
+ # screen's default ESC-to-quit handler. Set to nil to let ESC fall through
56
+ # to the parent again; set to any other callable to replace the default.
57
+ # @return [Proc, Method, nil] no-arg callable, or nil.
58
+ attr_accessor :on_escape
59
+
60
+ def focusable? = true
61
+
62
+ def tab_stop? = true
63
+
64
+ # Sets the text. Runs {#preprocess_text} first (subclasses may filter or
65
+ # truncate). Caret is clamped to the new text length. Fires {#on_change}
66
+ # only on a real change.
67
+ # @param new_text [String]
68
+ def text=(new_text)
69
+ new_text = preprocess_text(new_text)
70
+ return if @text == new_text
71
+
72
+ @text = +new_text
73
+ @caret = @caret.clamp(0, @text.length)
74
+ on_text_mutated
75
+ invalidate
76
+ @on_change&.call(@text)
77
+ end
78
+
79
+ # Sets the caret position. Clamped to `0..text.length`. Fires
80
+ # {#on_caret_mutated} hook for subclasses (e.g. {TextArea} scrolls).
81
+ # @param new_caret [Integer]
82
+ def caret=(new_caret)
83
+ new_caret = new_caret.clamp(0, @text.length)
84
+ return if @caret == new_caret
85
+
86
+ @caret = new_caret
87
+ on_caret_mutated
88
+ invalidate
89
+ end
90
+
91
+ # 256-color SGR for the focused-button highlight (matches what
92
+ # `Rainbow(...).bg(:darkslategray)` emits, which is what
93
+ # {Component::Button#repaint} uses for its focused state).
94
+ # @return [String]
95
+ ACTIVE_BG_SGR = "\e[48;5;59m"
96
+ # 256-color SGR for the unfocused field's "well": index 238 sits in
97
+ # the grayscale ramp (~#444444), bright enough to stand out against
98
+ # non-pure-black terminal themes (Gruvbox/Solarized/OneDark base
99
+ # backgrounds sit in the #1d–#2d range), and still distinctly darker
100
+ # than the active highlight at index 59 (~#5f5f5f). Rainbow's
101
+ # RGB-to-256 mapping snaps everything dark to palette index 16
102
+ # (terminal black), so we emit the escape directly to reach the ramp.
103
+ # @return [String]
104
+ INACTIVE_BG_SGR = "\e[48;5;238m"
105
+
106
+ # Handles a key. Returns false when the component is inactive. Otherwise
107
+ # first runs the {Component#handle_key} shortcut search via `super`, then
108
+ # delegates to {#handle_text_input_key}.
109
+ # @param key [String]
110
+ # @return [Boolean]
111
+ def handle_key(key)
112
+ return false unless active?
113
+ return true if super
114
+
115
+ handle_text_input_key(key)
116
+ end
117
+
118
+ protected
119
+
120
+ # Input filter for {#text=}. Subclasses override to truncate or reject
121
+ # invalid input. Default coerces to String.
122
+ # @param new_text [String]
123
+ # @return [String] possibly transformed text.
124
+ def preprocess_text(new_text) = new_text.to_s
125
+
126
+ # Hook called after {#text} has been mutated, before invalidation /
127
+ # {#on_change}. Default no-op. Subclasses use this to invalidate caches
128
+ # ({TextArea}'s wrap cache) and update derived state.
129
+ # @return [void]
130
+ def on_text_mutated; end
131
+
132
+ # Hook called after {#caret} has been mutated, before invalidation.
133
+ # Default no-op. Subclasses use this to keep the caret visible
134
+ # ({TextArea}'s vertical scroll).
135
+ # @return [void]
136
+ def on_caret_mutated; end
137
+
138
+ # Dispatch hook for {#handle_key}. Handles ESC and the navigation keys
139
+ # that have identical semantics in single-line and multi-line inputs:
140
+ # LEFT/RIGHT arrows, CTRL+LEFT/CTRL+RIGHT for word jumps. Subclasses
141
+ # override to add their own keys (HOME/END, UP/DOWN, ENTER, BACKSPACE/
142
+ # DELETE, printable insertion) and call `super` to fall back to the
143
+ # common navigation handling.
144
+ # @param key [String]
145
+ # @return [Boolean] true if the key was handled.
146
+ def handle_text_input_key(key)
147
+ case key
148
+ when Keys::LEFT_ARROW then self.caret = @caret - 1
149
+ when Keys::RIGHT_ARROW then self.caret = @caret + 1
150
+ when Keys::CTRL_LEFT_ARROW then self.caret = word_left
151
+ when Keys::CTRL_RIGHT_ARROW then self.caret = word_right
152
+ when Keys::ESC
153
+ return false if @on_escape.nil?
154
+
155
+ @on_escape.call
156
+ else
157
+ return false
158
+ end
159
+ true
160
+ end
161
+
162
+ # @return [void]
163
+ def delete_before_caret
164
+ return if @caret.zero?
165
+
166
+ new_text = @text.dup
167
+ new_text.slice!(@caret - 1)
168
+ @caret -= 1
169
+ self.text = new_text
170
+ end
171
+
172
+ # @return [void]
173
+ def delete_at_caret
174
+ return if @caret >= @text.length
175
+
176
+ new_text = @text.dup
177
+ new_text.slice!(@caret)
178
+ self.text = new_text
179
+ end
180
+
181
+ private
182
+
183
+ # Default {#on_escape} action: clear focus. Component deactivates; user
184
+ # can re-focus by clicking or tabbing back in.
185
+ # @return [void]
186
+ def default_on_escape
187
+ screen.focused = nil
188
+ end
189
+
190
+ # Caret target for ctrl+left: skip whitespace going left, then a run of
191
+ # non-whitespace. Lands at the beginning of the current word, or the
192
+ # beginning of the previous word if already there.
193
+ # @return [Integer]
194
+ def word_left
195
+ c = @caret
196
+ c -= 1 while c.positive? && @text[c - 1].match?(/\s/)
197
+ c -= 1 while c.positive? && !@text[c - 1].match?(/\s/)
198
+ c
199
+ end
200
+
201
+ # Caret target for ctrl+right: skip non-whitespace going right, then a
202
+ # run of whitespace. Lands at the beginning of the next word, or at the
203
+ # end of the text if no further word exists.
204
+ # @return [Integer]
205
+ def word_right
206
+ c = @caret
207
+ c += 1 while c < @text.length && !@text[c].match?(/\s/)
208
+ c += 1 while c < @text.length && @text[c].match?(/\s/)
209
+ c
210
+ end
211
+ end
212
+ end
213
+ end