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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +137 -5
- data/lib/tuile/color.rb +127 -0
- data/lib/tuile/component/label.rb +34 -4
- data/lib/tuile/component/list.rb +43 -14
- data/lib/tuile/component/log_window.rb +12 -6
- data/lib/tuile/component/popup.rb +5 -5
- data/lib/tuile/component/text_area.rb +39 -134
- data/lib/tuile/component/text_field.rb +31 -148
- data/lib/tuile/component/text_input.rb +213 -0
- data/lib/tuile/component/text_view.rb +792 -48
- data/lib/tuile/component/window.rb +5 -10
- data/lib/tuile/component.rb +15 -3
- data/lib/tuile/event_queue.rb +104 -9
- data/lib/tuile/fake_event_queue.rb +69 -0
- data/lib/tuile/keys.rb +91 -8
- data/lib/tuile/mouse_event.rb +23 -4
- data/lib/tuile/screen.rb +156 -12
- data/lib/tuile/styled_string.rb +38 -58
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +932 -154
- metadata +3 -1
|
@@ -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 <
|
|
21
|
+
class TextArea < TextInput
|
|
22
22
|
def initialize
|
|
23
23
|
super
|
|
24
|
-
@text = +""
|
|
25
|
-
@caret = 0
|
|
26
24
|
@top_display_row = 0
|
|
27
|
-
|
|
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
|
-
|
|
258
|
+
new_text = @text.dup.insert(@caret, char)
|
|
302
259
|
@caret += char.length
|
|
303
|
-
|
|
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 <
|
|
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
|
-
#
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
@
|
|
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
|
-
#
|
|
79
|
-
# @
|
|
80
|
-
def
|
|
81
|
-
|
|
82
|
-
return
|
|
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
|
-
|
|
85
|
-
invalidate
|
|
55
|
+
self.caret = (event.x - rect.left).clamp(0, @text.length)
|
|
86
56
|
end
|
|
87
57
|
|
|
88
|
-
|
|
58
|
+
# @return [void]
|
|
59
|
+
def repaint
|
|
60
|
+
return if rect.empty?
|
|
89
61
|
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
def cursor_position
|
|
94
|
-
return nil unless rect.width.positive?
|
|
67
|
+
protected
|
|
95
68
|
|
|
96
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
127
|
+
new_text = @text.dup.insert(@caret, char)
|
|
195
128
|
@caret += 1
|
|
196
|
-
|
|
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
|