tuile 0.1.0 → 0.2.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 +17 -0
- data/README.md +6 -9
- data/examples/file_commander.rb +0 -14
- data/examples/sampler.rb +287 -0
- data/lib/tuile/component/button.rb +86 -0
- data/lib/tuile/component/label.rb +2 -2
- data/lib/tuile/component/layout.rb +29 -12
- data/lib/tuile/component/list.rb +47 -4
- data/lib/tuile/component/text_area.rb +378 -0
- data/lib/tuile/component/text_field.rb +49 -4
- data/lib/tuile/component/window.rb +11 -3
- 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/truncate.rb +83 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +1 -1
- data/sig/tuile.rbs +363 -13
- metadata +7 -17
data/lib/tuile/component/list.rb
CHANGED
|
@@ -22,6 +22,8 @@ module Tuile
|
|
|
22
22
|
@scrollbar_visibility = :gone
|
|
23
23
|
@show_cursor_when_inactive = false
|
|
24
24
|
@on_item_chosen = nil
|
|
25
|
+
@on_cursor_changed = nil
|
|
26
|
+
@last_cursor_state = cursor_state
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
# @return [Proc, nil] callback fired when an item is chosen — by pressing
|
|
@@ -31,6 +33,16 @@ module Tuile
|
|
|
31
33
|
# {Cursor::None}, or empty content).
|
|
32
34
|
attr_accessor :on_item_chosen
|
|
33
35
|
|
|
36
|
+
# @return [Proc, nil] callback fired when the `(index, line)` tuple under
|
|
37
|
+
# the cursor changes. Called as `proc.call(index, line)` where `line`
|
|
38
|
+
# is `nil` when the cursor is off-content ({Cursor::None}, empty list,
|
|
39
|
+
# or `index` past the last line). Fires on cursor moves (key, mouse,
|
|
40
|
+
# search), on {#cursor=}, and on {#lines=}/{#add_lines} when the line
|
|
41
|
+
# at the cursor's index changes (or its in-range/out-of-range status
|
|
42
|
+
# flips). Useful for keeping a details pane in sync with the
|
|
43
|
+
# highlighted row.
|
|
44
|
+
attr_accessor :on_cursor_changed
|
|
45
|
+
|
|
34
46
|
# @return [Boolean] if true and a line is added or new content is set,
|
|
35
47
|
# auto-scrolls to the bottom.
|
|
36
48
|
attr_reader :auto_scroll
|
|
@@ -83,6 +95,7 @@ module Tuile
|
|
|
83
95
|
old_position = @cursor.position
|
|
84
96
|
@cursor = cursor
|
|
85
97
|
invalidate if old_position != cursor.position
|
|
98
|
+
notify_cursor_changed
|
|
86
99
|
end
|
|
87
100
|
|
|
88
101
|
# Sets the top line.
|
|
@@ -107,6 +120,7 @@ module Tuile
|
|
|
107
120
|
@lines = lines.flat_map { it.to_s.split("\n") }.map(&:rstrip)
|
|
108
121
|
@content_size = nil
|
|
109
122
|
update_top_line_if_auto_scroll
|
|
123
|
+
notify_cursor_changed
|
|
110
124
|
invalidate
|
|
111
125
|
end
|
|
112
126
|
|
|
@@ -146,6 +160,7 @@ module Tuile
|
|
|
146
160
|
@lines += lines.flat_map { it.to_s.split("\n") }.map(&:rstrip)
|
|
147
161
|
@content_size = nil
|
|
148
162
|
update_top_line_if_auto_scroll
|
|
163
|
+
notify_cursor_changed
|
|
149
164
|
invalidate
|
|
150
165
|
end
|
|
151
166
|
|
|
@@ -160,6 +175,8 @@ module Tuile
|
|
|
160
175
|
|
|
161
176
|
def focusable? = true
|
|
162
177
|
|
|
178
|
+
def tab_stop? = true
|
|
179
|
+
|
|
163
180
|
# @param key [String] a key.
|
|
164
181
|
# @return [Boolean] true if the key was handled.
|
|
165
182
|
def handle_key(key)
|
|
@@ -178,6 +195,7 @@ module Tuile
|
|
|
178
195
|
true
|
|
179
196
|
elsif @cursor.handle_key(key, @lines.size, viewport_lines)
|
|
180
197
|
move_viewport_to_cursor
|
|
198
|
+
notify_cursor_changed
|
|
181
199
|
invalidate
|
|
182
200
|
true
|
|
183
201
|
else
|
|
@@ -222,6 +240,7 @@ module Tuile
|
|
|
222
240
|
line = event.y - rect.top + top_line
|
|
223
241
|
if @cursor.handle_mouse(line, event, @lines.size)
|
|
224
242
|
move_viewport_to_cursor
|
|
243
|
+
notify_cursor_changed
|
|
225
244
|
invalidate
|
|
226
245
|
end
|
|
227
246
|
fire_item_chosen if event.button == :left && line >= 0 && line < @lines.size && cursor_on_item?
|
|
@@ -229,9 +248,13 @@ module Tuile
|
|
|
229
248
|
end
|
|
230
249
|
|
|
231
250
|
# Paints the list items into {#rect}.
|
|
251
|
+
#
|
|
252
|
+
# Skips the {Component#repaint} default's auto-clear: every row of
|
|
253
|
+
# {#rect} is painted below (with padded content past the last item),
|
|
254
|
+
# so the parent contract — "fully draw over your rect" — is met
|
|
255
|
+
# without an upfront wipe.
|
|
232
256
|
# @return [void]
|
|
233
257
|
def repaint
|
|
234
|
-
super
|
|
235
258
|
return if rect.empty?
|
|
236
259
|
|
|
237
260
|
width = rect.width
|
|
@@ -302,9 +325,9 @@ module Tuile
|
|
|
302
325
|
go_down_by(1, line_count)
|
|
303
326
|
when *Keys::UP_ARROWS
|
|
304
327
|
go_up_by(1)
|
|
305
|
-
when Keys::
|
|
328
|
+
when *Keys::HOMES
|
|
306
329
|
go_to_first
|
|
307
|
-
when Keys::
|
|
330
|
+
when *Keys::ENDS_
|
|
308
331
|
go_to_last(line_count)
|
|
309
332
|
when Keys::CTRL_U
|
|
310
333
|
go_up_by(viewport_lines / 2)
|
|
@@ -446,6 +469,25 @@ module Tuile
|
|
|
446
469
|
@on_item_chosen&.call(pos, @lines[pos])
|
|
447
470
|
end
|
|
448
471
|
|
|
472
|
+
# @return [Array((Integer, String, nil))] `[position, line_at_position]`,
|
|
473
|
+
# with `line` nil when the cursor is off-content.
|
|
474
|
+
def cursor_state
|
|
475
|
+
pos = @cursor.position
|
|
476
|
+
line = pos >= 0 && pos < @lines.size ? @lines[pos] : nil
|
|
477
|
+
[pos, line]
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Fires {#on_cursor_changed} if {#cursor_state} differs from the last
|
|
481
|
+
# fired state. Idempotent — safe to call after any mutation.
|
|
482
|
+
# @return [void]
|
|
483
|
+
def notify_cursor_changed
|
|
484
|
+
state = cursor_state
|
|
485
|
+
return if state == @last_cursor_state
|
|
486
|
+
|
|
487
|
+
@last_cursor_state = state
|
|
488
|
+
@on_cursor_changed&.call(*state)
|
|
489
|
+
end
|
|
490
|
+
|
|
449
491
|
# @param query [String]
|
|
450
492
|
# @param include_current [Boolean]
|
|
451
493
|
# @param reverse [Boolean]
|
|
@@ -463,6 +505,7 @@ module Tuile
|
|
|
463
505
|
|
|
464
506
|
@cursor.go(match)
|
|
465
507
|
move_viewport_to_cursor
|
|
508
|
+
notify_cursor_changed
|
|
466
509
|
invalidate
|
|
467
510
|
true
|
|
468
511
|
end
|
|
@@ -548,7 +591,7 @@ module Tuile
|
|
|
548
591
|
def trim_to(str, width)
|
|
549
592
|
return " " * width if str.empty?
|
|
550
593
|
|
|
551
|
-
truncated_line =
|
|
594
|
+
truncated_line = Truncate.truncate(str, length: width)
|
|
552
595
|
return truncated_line unless truncated_line == str
|
|
553
596
|
|
|
554
597
|
length = Unicode::DisplayWidth.of(Rainbow.uncolor(str))
|
|
@@ -0,0 +1,378 @@
|
|
|
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
|
+
# @return [String]
|
|
142
|
+
SGR_RESET = TextField::SGR_RESET
|
|
143
|
+
|
|
144
|
+
# @return [void]
|
|
145
|
+
def repaint
|
|
146
|
+
return if rect.empty?
|
|
147
|
+
|
|
148
|
+
bg = active? ? ACTIVE_BG_SGR : INACTIVE_BG_SGR
|
|
149
|
+
rows = display_rows
|
|
150
|
+
(0...rect.height).each do |screen_row|
|
|
151
|
+
row_idx = screen_row + @top_display_row
|
|
152
|
+
line = if row_idx >= rows.size
|
|
153
|
+
" " * rect.width
|
|
154
|
+
else
|
|
155
|
+
r = rows[row_idx]
|
|
156
|
+
chunk = @text[r[:start], r[:length]] || ""
|
|
157
|
+
chunk + (" " * (rect.width - r[:length]))
|
|
158
|
+
end
|
|
159
|
+
screen.print TTY::Cursor.move_to(rect.left, rect.top + screen_row), bg, line, SGR_RESET
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
protected
|
|
164
|
+
|
|
165
|
+
# @return [void]
|
|
166
|
+
def on_width_changed
|
|
167
|
+
super
|
|
168
|
+
@display_rows = nil
|
|
169
|
+
adjust_top_display_row
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
# @return [Array<Hash{Symbol=>Integer}>] cached wrap of {#text} for the
|
|
175
|
+
# current {Rect#width}. Each entry is `{start:, length:}`.
|
|
176
|
+
def display_rows
|
|
177
|
+
@display_rows ||= compute_display_rows
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Greedy word-wrap. Whitespace at a soft-wrap break point is absorbed
|
|
181
|
+
# (not rendered on either row). A token longer than {Rect#width} hard-
|
|
182
|
+
# wraps inside the token. Newlines force a hard break and the wrap
|
|
183
|
+
# restarts on the next character.
|
|
184
|
+
# @return [Array<Hash{Symbol=>Integer}>]
|
|
185
|
+
def compute_display_rows
|
|
186
|
+
width = rect.width
|
|
187
|
+
return [{ start: 0, length: 0 }] if width <= 0 || @text.empty?
|
|
188
|
+
|
|
189
|
+
rows = []
|
|
190
|
+
pos = 0
|
|
191
|
+
n = @text.length
|
|
192
|
+
|
|
193
|
+
while pos < n
|
|
194
|
+
row_start = pos
|
|
195
|
+
row_chars = 0
|
|
196
|
+
|
|
197
|
+
while pos < n
|
|
198
|
+
c = @text[pos]
|
|
199
|
+
break if c == "\n"
|
|
200
|
+
|
|
201
|
+
if c.match?(/[ \t]/)
|
|
202
|
+
if row_chars < width
|
|
203
|
+
row_chars += 1
|
|
204
|
+
pos += 1
|
|
205
|
+
else
|
|
206
|
+
row_chars = trim_trailing_whitespace(row_start, row_chars)
|
|
207
|
+
pos += 1 while pos < n && @text[pos].match?(/[ \t]/)
|
|
208
|
+
break
|
|
209
|
+
end
|
|
210
|
+
else
|
|
211
|
+
word_end = pos
|
|
212
|
+
word_end += 1 while word_end < n && !@text[word_end].match?(/\s/)
|
|
213
|
+
word_len = word_end - pos
|
|
214
|
+
|
|
215
|
+
if row_chars + word_len <= width
|
|
216
|
+
row_chars += word_len
|
|
217
|
+
pos = word_end
|
|
218
|
+
elsif row_chars.zero?
|
|
219
|
+
row_chars = width
|
|
220
|
+
pos += width
|
|
221
|
+
break
|
|
222
|
+
else
|
|
223
|
+
row_chars = trim_trailing_whitespace(row_start, row_chars)
|
|
224
|
+
break
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
rows << { start: row_start, length: row_chars }
|
|
230
|
+
|
|
231
|
+
if pos < n && @text[pos] == "\n"
|
|
232
|
+
pos += 1
|
|
233
|
+
rows << { start: pos, length: 0 } if pos == n
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
rows << { start: 0, length: 0 } if rows.empty?
|
|
238
|
+
rows
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Trims trailing space/tab characters off a row's visible length so the
|
|
242
|
+
# whitespace at a soft-wrap point is absorbed (not rendered) rather than
|
|
243
|
+
# left at the end of the row. Without this, soft-wrapping `"foo bar"`
|
|
244
|
+
# to width 4 would yield row 0 length 4 (`"foo "`) and the natural
|
|
245
|
+
# end-of-row caret position would coincide with row 1's start.
|
|
246
|
+
# @param row_start [Integer]
|
|
247
|
+
# @param row_chars [Integer]
|
|
248
|
+
# @return [Integer] new row_chars.
|
|
249
|
+
def trim_trailing_whitespace(row_start, row_chars)
|
|
250
|
+
row_chars -= 1 while row_chars.positive? && @text[row_start + row_chars - 1].match?(/[ \t]/)
|
|
251
|
+
row_chars
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# @param caret [Integer]
|
|
255
|
+
# @return [Array(Integer, Integer)] `[row_index, column]` for `caret`.
|
|
256
|
+
def caret_to_display(caret)
|
|
257
|
+
rows = display_rows
|
|
258
|
+
rows.each_with_index do |r, i|
|
|
259
|
+
next_start = i + 1 < rows.size ? rows[i + 1][:start] : @text.length + 1
|
|
260
|
+
next unless caret >= r[:start] && caret < next_start
|
|
261
|
+
|
|
262
|
+
return [i, (caret - r[:start]).clamp(0, r[:length])]
|
|
263
|
+
end
|
|
264
|
+
r = rows.last
|
|
265
|
+
[rows.size - 1, (caret - r[:start]).clamp(0, r[:length])]
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# @param delta [Integer] `+1` for down, `-1` for up.
|
|
269
|
+
# @return [void]
|
|
270
|
+
def move_caret_vertical(delta)
|
|
271
|
+
rows = display_rows
|
|
272
|
+
cur_row, cur_col = caret_to_display(@caret)
|
|
273
|
+
new_row = (cur_row + delta).clamp(0, rows.size - 1)
|
|
274
|
+
if new_row == cur_row
|
|
275
|
+
# Already at the top/bottom display row. Snap to the absolute
|
|
276
|
+
# start/end of the text so the user has a quick way to reach it.
|
|
277
|
+
self.caret = delta.positive? ? @text.length : 0
|
|
278
|
+
return
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
r = rows[new_row]
|
|
282
|
+
self.caret = r[:start] + cur_col.clamp(0, r[:length])
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# @return [void]
|
|
286
|
+
def move_caret_to_row_start
|
|
287
|
+
rows = display_rows
|
|
288
|
+
cur_row, = caret_to_display(@caret)
|
|
289
|
+
self.caret = rows[cur_row][:start]
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# @return [void]
|
|
293
|
+
def move_caret_to_row_end
|
|
294
|
+
rows = display_rows
|
|
295
|
+
cur_row, = caret_to_display(@caret)
|
|
296
|
+
r = rows[cur_row]
|
|
297
|
+
self.caret = r[:start] + r[:length]
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# @param char [String]
|
|
301
|
+
# @return [Boolean] always true.
|
|
302
|
+
def insert_char(char)
|
|
303
|
+
@text = @text.dup.insert(@caret, char)
|
|
304
|
+
@caret += char.length
|
|
305
|
+
@display_rows = nil
|
|
306
|
+
adjust_top_display_row
|
|
307
|
+
invalidate
|
|
308
|
+
@on_change&.call(@text)
|
|
309
|
+
true
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# @return [void]
|
|
313
|
+
def delete_before_caret
|
|
314
|
+
return if @caret.zero?
|
|
315
|
+
|
|
316
|
+
@text = @text.dup
|
|
317
|
+
@text.slice!(@caret - 1)
|
|
318
|
+
@caret -= 1
|
|
319
|
+
@display_rows = nil
|
|
320
|
+
adjust_top_display_row
|
|
321
|
+
invalidate
|
|
322
|
+
@on_change&.call(@text)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# @return [void]
|
|
326
|
+
def delete_at_caret
|
|
327
|
+
return if @caret >= @text.length
|
|
328
|
+
|
|
329
|
+
@text = @text.dup
|
|
330
|
+
@text.slice!(@caret)
|
|
331
|
+
@display_rows = nil
|
|
332
|
+
adjust_top_display_row
|
|
333
|
+
invalidate
|
|
334
|
+
@on_change&.call(@text)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Keeps the caret visible by scrolling vertically.
|
|
338
|
+
# @return [void]
|
|
339
|
+
def adjust_top_display_row
|
|
340
|
+
return if rect.empty?
|
|
341
|
+
|
|
342
|
+
rows = display_rows
|
|
343
|
+
cur_row, = caret_to_display(@caret)
|
|
344
|
+
if cur_row < @top_display_row
|
|
345
|
+
@top_display_row = cur_row
|
|
346
|
+
elsif cur_row >= @top_display_row + rect.height
|
|
347
|
+
@top_display_row = cur_row - rect.height + 1
|
|
348
|
+
end
|
|
349
|
+
max_top = (rows.size - rect.height).clamp(0, nil)
|
|
350
|
+
@top_display_row = @top_display_row.clamp(0, max_top)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# @param key [String]
|
|
354
|
+
# @return [Boolean]
|
|
355
|
+
def printable?(key)
|
|
356
|
+
key.length == 1 && key.ord >= 0x20 && key.ord < 0x7f
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Same semantics as {TextField}'s ctrl+left.
|
|
360
|
+
# @return [Integer]
|
|
361
|
+
def word_left
|
|
362
|
+
c = @caret
|
|
363
|
+
c -= 1 while c.positive? && @text[c - 1].match?(/\s/)
|
|
364
|
+
c -= 1 while c.positive? && !@text[c - 1].match?(/\s/)
|
|
365
|
+
c
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Same semantics as {TextField}'s ctrl+right.
|
|
369
|
+
# @return [Integer]
|
|
370
|
+
def word_right
|
|
371
|
+
c = @caret
|
|
372
|
+
c += 1 while c < @text.length && !@text[c].match?(/\s/)
|
|
373
|
+
c += 1 while c < @text.length && @text[c].match?(/\s/)
|
|
374
|
+
c
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
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,31 @@ 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
|
+
# SGR reset.
|
|
162
|
+
# @return [String]
|
|
163
|
+
SGR_RESET = "\e[0m"
|
|
164
|
+
|
|
143
165
|
# @return [void]
|
|
144
166
|
def repaint
|
|
145
|
-
clear_background
|
|
146
167
|
return if rect.empty?
|
|
147
168
|
|
|
148
|
-
|
|
169
|
+
bg = active? ? ACTIVE_BG_SGR : INACTIVE_BG_SGR
|
|
170
|
+
padded = @text + (" " * (rect.width - @text.length))
|
|
171
|
+
screen.print TTY::Cursor.move_to(rect.left, rect.top), bg, padded, SGR_RESET
|
|
149
172
|
end
|
|
150
173
|
|
|
151
174
|
protected
|
|
@@ -204,6 +227,28 @@ module Tuile
|
|
|
204
227
|
def printable?(key)
|
|
205
228
|
key.length == 1 && key.ord >= 0x20 && key.ord < 0x7f
|
|
206
229
|
end
|
|
230
|
+
|
|
231
|
+
# Caret target for ctrl+left: skip whitespace going left, then a run of
|
|
232
|
+
# non-whitespace. Lands at the beginning of the current word, or the
|
|
233
|
+
# beginning of the previous word if already there.
|
|
234
|
+
# @return [Integer]
|
|
235
|
+
def word_left
|
|
236
|
+
c = @caret
|
|
237
|
+
c -= 1 while c.positive? && @text[c - 1].match?(/\s/)
|
|
238
|
+
c -= 1 while c.positive? && !@text[c - 1].match?(/\s/)
|
|
239
|
+
c
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Caret target for ctrl+right: skip non-whitespace going right, then a
|
|
243
|
+
# run of whitespace. Lands at the beginning of the next word, or at the
|
|
244
|
+
# end of the text if no further word exists.
|
|
245
|
+
# @return [Integer]
|
|
246
|
+
def word_right
|
|
247
|
+
c = @caret
|
|
248
|
+
c += 1 while c < @text.length && !@text[c].match?(/\s/)
|
|
249
|
+
c += 1 while c < @text.length && @text[c].match?(/\s/)
|
|
250
|
+
c
|
|
251
|
+
end
|
|
207
252
|
end
|
|
208
253
|
end
|
|
209
254
|
end
|
|
@@ -132,13 +132,21 @@ module Tuile
|
|
|
132
132
|
end
|
|
133
133
|
|
|
134
134
|
# Fully repaints the window: both frame and contents.
|
|
135
|
+
#
|
|
136
|
+
# Window deliberately paints over its entire rect (border around the
|
|
137
|
+
# edge, content/footer over the interior), so we don't need the
|
|
138
|
+
# {Component#repaint} default's auto-clear — but we do still want its
|
|
139
|
+
# "re-invalidate children" effect, since the border overpaints
|
|
140
|
+
# whatever the content/footer drew on the perimeter. Calling super
|
|
141
|
+
# handles both: the auto-clear is harmless (we re-paint over it), and
|
|
142
|
+
# the invalidation queues content + footer for repaint in the same
|
|
143
|
+
# cycle.
|
|
135
144
|
# @return [void]
|
|
136
145
|
def repaint
|
|
146
|
+
return unless visible?
|
|
147
|
+
|
|
137
148
|
super
|
|
138
149
|
repaint_border
|
|
139
|
-
# Border paints over content: invalidate the content to have it
|
|
140
|
-
# repainted.
|
|
141
|
-
content&.invalidate
|
|
142
150
|
end
|
|
143
151
|
|
|
144
152
|
# @param key [String, nil]
|