tuile 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd5711addbd65a00c8d471204d50973da93ac9be65ba197114ac04c94cace526
4
- data.tar.gz: 4505d93153dc96fd439e5d69a7b1506e985fc9094f8428fe092ebc6247d62b8a
3
+ metadata.gz: 23a6288632a551240d224975faee1f71d477bb1465126d70915950f67c187650
4
+ data.tar.gz: 33e26892d2a625e85d5d2f2fd058a04080ef12ea38825cb2f9727a346f36c6a2
5
5
  SHA512:
6
- metadata.gz: 23c69343d8a0cc87143b12cd1a4a9a11862c770debf9c5381cabfcb59b55786f8be143ef362581e0a79bb1dc7f9ce512f47d755f603cb486bf4058b3487d4399
7
- data.tar.gz: '0974d322289b63b43bdd498e172f446d7cc56df656645b8e6826fed388154dda4a287110819d305572dbbf1c1ee23011fc4e0cb208f6459d67535e2df5c1d2b1'
6
+ metadata.gz: 233960b007d8c81281e6726e605ab48e9bdc389399b796a4d9a19314e1be2bfab019a787ddc6ab8b421221010cb9402fdc102737fef085aaa326641b76275eed
7
+ data.tar.gz: 68b2425566e2a2586d686699df135a4a7fce835b0f2ca5f090204b3b82f6d3b6d7813375f9129e5256eea627be8df72e667694869d4ba475de55221c0db5ae8e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-05-20
4
+
5
+ - Add `Screen#register_global_shortcut` for app-level hotkeys; registered shortcuts surface in the status bar via `hint:`.
6
+ - Add `Keys::CTRL_A..CTRL_Z` constants and `Keys.printable?` (extracted from `TextField`/`TextArea`/`Screen`).
7
+ - Extract `Component::TextInput` as the shared base of `TextField` and `TextArea`; add `#empty?`.
8
+ - `TextField`/`TextArea`: default `on_escape` to clear focus.
9
+ - `Screen#run_event_loop` accepts `capture_mouse:` (default `true`); pass `false` to skip xterm mouse tracking so the terminal's native select-to-copy keeps working.
10
+ - `StyledString`: add `#with_fg`, mirroring `#with_bg`.
11
+ - `Component::TextView`: add `#<<`, `#add_line`, `#empty?`, and `#remove_last_n_lines` for streaming-tail retraction.
12
+ - `MouseEvent`: map buttons 66/67 to `:scroll_left`/`:scroll_right`.
13
+ - `Component::LogWindow`: extract `#log` helper.
14
+ - `Component::List`: skip `auto_scroll` when rect is empty; re-snap on width change; snap cursor to last line on `auto_scroll`.
15
+ - Document `Component#repaint`'s attached-only call contract.
16
+ - Document keyboard input dispatch order and testing (`FakeScreen`, PTY system tests) in the README.
17
+ - **Breaking:** `Component::TextView#append` is now verbatim — chunks are concatenated onto the current last hard line, embedded `\n` becomes hard breaks, no implicit newline is inserted. Designed for streaming use (e.g. an LLM chat window feeding partial messages straight in). Aliased as `<<` for chainability. The old "add a new entry" behavior is now `Component::TextView#add_line`.
18
+ - **Breaking:** `MouseEvent.parse` raises on malformed input instead of silently truncating.
19
+ - Fix: `Component` gates `invalidate` and `repaint` on `attached?`, dropping the negative-rect relic.
20
+ - Fix: `Popup` recomputes size from content on every `#open`.
21
+ - Fix: `Keys.getkey` reads 5 trailing bytes after ESC, not 6.
22
+ - Fix: `Component::List#add_line` rejects `nil`.
23
+
3
24
  ## [0.3.0] - 2026-05-18
4
25
 
5
26
  - Add `Component::TextView` — read-only scrollable wrapped prose with word wrap, incremental append, and a lazy text reader.
data/README.md CHANGED
@@ -146,7 +146,7 @@ posts a size event, runs layout, and invalidates the entire tree. Components
146
146
  react by reassigning their child rectangles inside `rect=` — do not install
147
147
  your own WINCH handler.
148
148
 
149
- ### Focus and shortcuts
149
+ ### Focus and keyboard input
150
150
 
151
151
  `screen.focused = component` walks parent pointers up to the root, marks the
152
152
  whole chain `active?`, and deactivates everything else. Click-to-focus and
@@ -154,10 +154,86 @@ whole chain `active?`, and deactivates everything else. Click-to-focus and
154
154
  returns true, so clicking a `Label` inside a `Window` does not pull focus
155
155
  away from the window's content.
156
156
 
157
- `key_shortcut` is matched against the focused component's whole subtree
158
- *unless* the focused component owns the hardware cursor (e.g. a `TextField`
159
- the user is typing into) — that suppression is what lets text fields swallow
160
- printable keys without sibling shortcuts hijacking them.
157
+ When a key arrives, the screen dispatches it in this order — the first
158
+ mechanism that handles it wins:
159
+
160
+ 1. **Tab / Shift+Tab** advance focus through `tab_stop?` components in the
161
+ current modal scope (the topmost popup if one is open, otherwise the
162
+ tiled content). They are intercepted at the screen level before anything
163
+ else sees them, so a focused `TextField` cannot swallow them.
164
+
165
+ 2. **Global shortcuts** registered via `Screen#register_global_shortcut`.
166
+ These are app-level hotkeys for actions that don't belong to any
167
+ specific component — opening a log window, toggling help, etc.:
168
+
169
+ ```ruby
170
+ screen.register_global_shortcut(Tuile::Keys::CTRL_L,
171
+ over_popups: true,
172
+ hint: "^L #{Rainbow('log').cadetblue}") do
173
+ log_popup.open
174
+ end
175
+ screen.unregister_global_shortcut(Tuile::Keys::CTRL_L)
176
+ ```
177
+
178
+ Only unprintable keys are accepted (control characters, ESC, BACKSPACE,
179
+ arrows, F-keys); printable keys raise so they can't hijack typing into
180
+ a `TextField`. By default, the shortcut is suppressed while any popup
181
+ is open and the popup receives the key; pass `over_popups: true` to
182
+ pre-empt the popup.
183
+
184
+ Pass `hint:` to surface the shortcut in the status bar. It's a
185
+ preformatted string the caller fully owns (color it however the rest
186
+ of your app does). In the tiled case it appears right after `q quit`
187
+ and before the active window's hint; while a popup is open, only
188
+ `over_popups: true` hints show up, prepended before the popup's
189
+ `q Close`. Omit `hint:` to leave the shortcut silent in the status bar.
190
+
191
+ 3. **`Component#key_shortcut`** — a declarative hotkey attached to a
192
+ component. The framework walks the focused component's subtree for a
193
+ match and focuses the winner. Good fit for "press F to focus the filter
194
+ field" or one-key tab pickers. The lookup is suppressed while the
195
+ focused component owns the hardware cursor (e.g. a `TextField` the user
196
+ is typing into) so editing isn't interrupted:
197
+
198
+ ```ruby
199
+ filter_field.key_shortcut = "f"
200
+ ```
201
+
202
+ 4. **`Component#handle_key`** — override this on your own component when
203
+ it needs to react to keys directly (a list reacting to arrows, a custom
204
+ widget handling Enter, …). Return `true` to mark the key handled,
205
+ `false` to let the dispatcher keep walking. Call `super` to keep the
206
+ default `key_shortcut` subtree lookup; suppress it only when you
207
+ deliberately want this component to swallow everything:
208
+
209
+ ```ruby
210
+ class Toggle < Tuile::Component
211
+ def handle_key(key)
212
+ if key == " "
213
+ @on = !@on
214
+ invalidate
215
+ true
216
+ else
217
+ super
218
+ end
219
+ end
220
+ end
221
+ ```
222
+
223
+ If nothing handles the key and it's `q` or `ESC`, the event loop exits.
224
+
225
+ A component can advertise the keys it responds to by overriding
226
+ `keyboard_hint`. The status bar shows the active window's hint alongside
227
+ the global `q quit` prompt; while a popup is open, the popup's own hint
228
+ replaces it, prefixed with `q Close`:
229
+
230
+ ```ruby
231
+ class FilterWindow < Tuile::Component::Window
232
+ def keyboard_hint
233
+ "f #{Rainbow('filter').cadetblue} Enter #{Rainbow('open').cadetblue}"
234
+ end
235
+ end
236
+ ```
161
237
 
162
238
  ## Components
163
239
 
@@ -352,6 +428,62 @@ Tuile.logger = TTY::Logger.new # duck-typed, works directly
352
428
  Tuile.logger = Logger.new(Tuile::Component::LogWindow::IO.new(window))
353
429
  ```
354
430
 
431
+ ## Testing
432
+
433
+ Tuile ships with a `Tuile::FakeScreen` that you install in place of the real
434
+ screen for unit tests. It fixes the viewport at 160×50, disables the UI lock,
435
+ collects every string the framework "would have printed" into an array, and
436
+ uses a synchronous `FakeEventQueue` (submitted blocks run inline; posted
437
+ events are discarded). No terminal IO happens, so the TTY running the tests
438
+ is never painted over.
439
+
440
+ The standard setup is `Screen.fake` / `Screen.close` as a before/after pair —
441
+ this resets the singleton between examples, so state can't leak across
442
+ tests:
443
+
444
+ ```ruby
445
+ require "tuile"
446
+
447
+ module Tuile
448
+ describe Component::Label do
449
+ before { Screen.fake }
450
+ after { Screen.close }
451
+
452
+ it "renders text into its rect" do
453
+ label = Component::Label.new
454
+ label.rect = Rect.new(0, 0, 5, 1)
455
+ label.text = "hi"
456
+ label.repaint
457
+ assert_equal [TTY::Cursor.move_to(0, 0), "hi "], Screen.instance.prints
458
+ end
459
+ end
460
+ end
461
+ ```
462
+
463
+ Key hooks:
464
+
465
+ - `Screen.instance.prints` — array of strings the screen would have written
466
+ to the terminal. Assert against it (or `.join`) for repaint output.
467
+ - `Screen.instance.repaint` — drive a repaint synchronously; production code
468
+ must not call this, but specs use it to flush the invalidated set after a
469
+ mutation.
470
+ - `Screen.instance.invalidated?(component)` / `invalidated_clear` — verify
471
+ that a mutation did (or did not) invalidate something. Setting a property
472
+ to its current value should typically *not* invalidate.
473
+ - `Screen.instance.clear` — drops accumulated `prints` without resetting
474
+ invalidation.
475
+
476
+ Because `FakeEventQueue#submit` runs the block immediately on the calling
477
+ thread, code paths that marshal work back via `screen.event_queue.submit { … }`
478
+ just work in tests. Posted events (`#post`) are dropped — if your test needs
479
+ to drive a real event loop, you are in system-test territory.
480
+
481
+ For end-to-end tests of a runnable script, spawn it in a pseudo-TTY with
482
+ `PTY.spawn`, wait for a known glyph to confirm the first paint landed, send
483
+ a key, and assert the exit status. `spec/examples/hello_world_spec.rb` is the
484
+ canonical template; PTY-based tests are Linux/macOS only since Ruby's stdlib
485
+ `PTY` isn't on Windows.
486
+
355
487
  ## Development
356
488
 
357
489
  After checking out the repo, run `bin/setup` to install dependencies. Then,
@@ -57,7 +57,7 @@ module Tuile
57
57
  # wipe.
58
58
  # @return [void]
59
59
  def repaint
60
- return if rect.empty? || rect.left.negative? || rect.top.negative?
60
+ return if rect.empty?
61
61
 
62
62
  (0...rect.height).each do |row|
63
63
  line = @clipped_lines[row] || @blank_line
@@ -167,6 +167,7 @@ module Tuile
167
167
  # @param line [String, StyledString, #to_s]
168
168
  # @return [void]
169
169
  def add_line(line)
170
+ raise ArgumentError, "line is nil" if line.nil?
170
171
  add_lines [line]
171
172
  end
172
173
 
@@ -326,6 +327,16 @@ module Tuile
326
327
  def candidate_positions(_line_count)
327
328
  []
328
329
  end
330
+
331
+ # Overridden so all movement funnels — base {Cursor#go_to_last},
332
+ # {Cursor#go_to_first}, etc., which all call {#go} — become safe
333
+ # no-ops on a disabled cursor. The instance is frozen, so a default
334
+ # mutating {#go} would raise.
335
+ # @param _new_position [Integer]
336
+ # @return [Boolean] always false.
337
+ def go(_new_position)
338
+ false
339
+ end
329
340
  end
330
341
 
331
342
  # @return [Integer] 0-based line index of the current cursor position.
@@ -384,6 +395,15 @@ module Tuile
384
395
  true
385
396
  end
386
397
 
398
+ # Moves the cursor to the last reachable position. For base {Cursor},
399
+ # the last line; {Limited} clamps to the last allowed position; {None}
400
+ # is a no-op.
401
+ # @param line_count [Integer] number of lines in the list.
402
+ # @return [Boolean] true if the position changed.
403
+ def go_to_last(line_count)
404
+ go(line_count - 1)
405
+ end
406
+
387
407
  protected
388
408
 
389
409
  # @param lines [Integer]
@@ -404,12 +424,6 @@ module Tuile
404
424
  go(0)
405
425
  end
406
426
 
407
- # @param line_count [Integer]
408
- # @return [Boolean]
409
- def go_to_last(line_count)
410
- go(line_count - 1)
411
- end
412
-
413
427
  # Cursor which can only land on specific allowed lines.
414
428
  class Limited < Cursor
415
429
  # @param positions [Array<Integer>] allowed positions. Must not be
@@ -442,6 +456,12 @@ module Tuile
442
456
  @positions.select { it < line_count }
443
457
  end
444
458
 
459
+ # @param _line_count [Integer]
460
+ # @return [Boolean]
461
+ def go_to_last(_line_count)
462
+ go(@positions.last)
463
+ end
464
+
445
465
  protected
446
466
 
447
467
  # @param lines [Integer]
@@ -467,12 +487,6 @@ module Tuile
467
487
  def go_to_first
468
488
  go(@positions.first)
469
489
  end
470
-
471
- # @param _line_count [Integer]
472
- # @return [Boolean]
473
- def go_to_last(_line_count)
474
- go(@positions.last)
475
- end
476
490
  end
477
491
  end
478
492
 
@@ -480,11 +494,16 @@ module Tuile
480
494
 
481
495
  # Rebuilds pre-padded lines when the wrap width changes. The wrap width
482
496
  # depends on {#rect}`.width` and the scrollbar gutter, both of which
483
- # trigger this hook.
497
+ # trigger this hook. Also re-evaluates {#auto_scroll}: if items were
498
+ # appended while the rect was empty (e.g. a {Popup}-wrapped list got
499
+ # `add_line` calls before the popup was opened), the auto-scroll update
500
+ # was skipped because there was no viewport — re-run it now that there
501
+ # is one, so the list snaps to the bottom on first paint.
484
502
  # @return [void]
485
503
  def on_width_changed
486
504
  super
487
505
  rebuild_padded_lines
506
+ update_top_line_if_auto_scroll
488
507
  end
489
508
 
490
509
  private
@@ -638,10 +657,20 @@ module Tuile
638
657
  invalidate
639
658
  end
640
659
 
641
- # If auto-scrolling, recalculate the top line.
660
+ # If auto-scrolling, recalculate the top line and snap the cursor to the
661
+ # last reachable position. Without the cursor snap the viewport gets
662
+ # yanked back to wherever the cursor sat on the next arrow press,
663
+ # negating the auto-scroll. Skipped when {#rect} is empty: without a
664
+ # viewport the "lines minus viewport" formula yields `@lines.size`,
665
+ # which would leave `top_line` past the last item once a real rect
666
+ # arrives. {#on_width_changed} re-runs this hook when the rect grows so
667
+ # the snap-to-bottom intent is preserved.
642
668
  # @return [void]
643
669
  def update_top_line_if_auto_scroll
644
670
  return unless @auto_scroll
671
+ return if rect.empty?
672
+
673
+ notify_cursor_changed if @cursor.go_to_last(@lines.size)
645
674
 
646
675
  new_top_line = (@lines.size - viewport_lines).clamp(0, nil)
647
676
  return unless @top_line != new_top_line
@@ -23,6 +23,16 @@ module Tuile
23
23
  self.scrollbar = true
24
24
  end
25
25
 
26
+ # Appends given line to the log. Can be called from any thread. Does nothing if nil is passed in.
27
+ # @param string [String, nil] the line (or multiple lines) to log.
28
+ # @return [void]
29
+ def log(string)
30
+ return if string.nil?
31
+ screen.event_queue.submit do
32
+ content.add_line(string)
33
+ end
34
+ end
35
+
26
36
  # IO-shaped adapter that forwards each log line to the owning {LogWindow}.
27
37
  # Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
28
38
  # call `output.puts`, e.g. `TTY::Logger`).
@@ -35,17 +45,13 @@ module Tuile
35
45
  # @param string [String]
36
46
  # @return [void]
37
47
  def write(string)
38
- @window.screen.event_queue.submit do
39
- @window.content.add_line(string.chomp)
40
- end
48
+ @window.log(string.chomp)
41
49
  end
42
50
 
43
51
  # @param string [String]
44
52
  # @return [void]
45
53
  def puts(string)
46
- @window.screen.event_queue.submit do
47
- @window.content.add_line(string)
48
- end
54
+ @window.log(string)
49
55
  end
50
56
 
51
57
  # Stdlib `Logger` only treats an object as an IO target when it
@@ -30,17 +30,17 @@ module Tuile
30
30
  def initialize(content: nil)
31
31
  super()
32
32
  @content = nil
33
- # Off-screen sentinel until the content sets a real size and the popup
34
- # is centered on open.
35
- @rect = Rect.new(-1, -1, 0, 0)
36
33
  self.content = content unless content.nil?
37
34
  end
38
35
 
39
36
  def focusable? = true
40
37
 
41
- # Mounts this popup on the {Screen}.
38
+ # Mounts this popup on the {Screen}. Recomputes the popup's size from
39
+ # the current content first, so reopening a popup whose content has
40
+ # grown or shrunk while closed picks up the new size.
42
41
  # @return [void]
43
42
  def open
43
+ update_rect unless @content.nil?
44
44
  screen.add_popup(self)
45
45
  end
46
46
 
@@ -119,7 +119,7 @@ module Tuile
119
119
  def update_rect
120
120
  size = @content.content_size.clamp_height(max_height)
121
121
  size = size.clamp(Size.new(screen.size.width * 4 / 5, screen.size.height * 4 / 5))
122
- self.rect = Rect.new(-1, -1, size.width, size.height)
122
+ self.rect = Rect.new(0, 0, size.width, size.height)
123
123
  center if open?
124
124
  end
125
125
  end
@@ -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