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 +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +137 -5
- data/lib/tuile/component/label.rb +1 -1
- 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 +127 -22
- data/lib/tuile/component/window.rb +5 -10
- data/lib/tuile/component.rb +15 -3
- data/lib/tuile/keys.rb +91 -8
- data/lib/tuile/mouse_event.rb +23 -4
- data/lib/tuile/screen.rb +154 -12
- data/lib/tuile/styled_string.rb +13 -0
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +407 -110
- metadata +2 -1
|
@@ -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
|
|
@@ -12,9 +12,19 @@ module Tuile
|
|
|
12
12
|
# ANSI-as-bytes wrapping, color does *not* get dropped on continuation
|
|
13
13
|
# rows). {#text=} accepts a {String} (parsed via {StyledString.parse},
|
|
14
14
|
# so embedded ANSI is honored) or a {StyledString} directly; {#text}
|
|
15
|
-
# always returns the {StyledString}.
|
|
16
|
-
#
|
|
17
|
-
#
|
|
15
|
+
# always returns the {StyledString}.
|
|
16
|
+
#
|
|
17
|
+
# For incremental updates pick the right primitive: {#append} (aliased
|
|
18
|
+
# as `<<`) is verbatim and stream-friendly — chunks are concatenated
|
|
19
|
+
# straight onto the buffer, with embedded `\n` becoming hard breaks.
|
|
20
|
+
# {#add_line} is the "log entry" convenience — it starts the content on
|
|
21
|
+
# a fresh line by inserting a leading `\n` when the buffer is non-empty.
|
|
22
|
+
# {#remove_last_n_lines} pops hard lines back off the tail — the
|
|
23
|
+
# inverse of building up a region with {#append} / {#add_line}, so a
|
|
24
|
+
# caller streaming reformattable content (e.g. partially-rendered
|
|
25
|
+
# Markdown that may need to retract its last paragraph) can replace
|
|
26
|
+
# the tail without rewriting the whole text. Turn on {#auto_scroll}
|
|
27
|
+
# to keep the latest content in view.
|
|
18
28
|
#
|
|
19
29
|
# TextView is meant to be the content of a {Window} — focus indication and
|
|
20
30
|
# keyboard-hint surfacing rely on the surrounding window chrome.
|
|
@@ -76,35 +86,117 @@ module Tuile
|
|
|
76
86
|
invalidate
|
|
77
87
|
end
|
|
78
88
|
|
|
79
|
-
#
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
#
|
|
89
|
+
# @return [Boolean] true iff {#text} is empty (no hard lines).
|
|
90
|
+
def empty? = @hard_lines.empty?
|
|
91
|
+
|
|
92
|
+
# Appends `str` verbatim. Embedded `\n` characters become hard line
|
|
93
|
+
# breaks; otherwise the text is concatenated onto the current last
|
|
94
|
+
# hard line. Designed for streaming use (e.g. an LLM chat window
|
|
95
|
+
# receiving partial messages — feed each chunk straight in). Accepts
|
|
96
|
+
# the same input forms as {#text=}; empty/`nil` input is a no-op.
|
|
97
|
+
#
|
|
98
|
+
# For the "add an entry on a new line" pattern use {#add_line}.
|
|
83
99
|
#
|
|
84
|
-
# Cost is O(appended
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
# {#text} is invalidated
|
|
100
|
+
# Cost is O(appended + width-of-current-last-hard-line) — the
|
|
101
|
+
# previously last hard line is re-wrapped (because the extension may
|
|
102
|
+
# cause it to wrap differently), any additional hard lines created by
|
|
103
|
+
# embedded `\n` are wrapped fresh. The cached {#text} is invalidated
|
|
104
|
+
# and rebuilt on demand.
|
|
88
105
|
# @param str [String, StyledString, nil]
|
|
89
106
|
# @return [void]
|
|
90
107
|
def append(str)
|
|
91
108
|
screen.check_locked
|
|
92
109
|
appended = StyledString.parse(str)
|
|
93
|
-
if
|
|
94
|
-
|
|
95
|
-
|
|
110
|
+
return if appended.empty?
|
|
111
|
+
|
|
112
|
+
new_segments = appended.lines
|
|
113
|
+
width = wrap_width
|
|
114
|
+
|
|
115
|
+
if empty?
|
|
116
|
+
new_segments.each do |hl|
|
|
117
|
+
@hard_lines << hl
|
|
118
|
+
append_physical_lines(hl, width)
|
|
119
|
+
end
|
|
120
|
+
else
|
|
121
|
+
extension = new_segments.first
|
|
122
|
+
unless extension.empty?
|
|
123
|
+
old_last = @hard_lines.pop
|
|
124
|
+
drop_physical_rows_for(old_last, width)
|
|
125
|
+
extended = old_last + extension
|
|
126
|
+
@hard_lines << extended
|
|
127
|
+
append_physical_lines(extended, width)
|
|
128
|
+
end
|
|
129
|
+
new_segments[1..].each do |hl|
|
|
130
|
+
@hard_lines << hl
|
|
131
|
+
append_physical_lines(hl, width)
|
|
132
|
+
end
|
|
96
133
|
end
|
|
97
134
|
|
|
98
|
-
new_hard_lines = appended.lines
|
|
99
135
|
@text = nil
|
|
100
|
-
@
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
136
|
+
@content_size = compute_content_size
|
|
137
|
+
update_top_line_if_auto_scroll
|
|
138
|
+
invalidate
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Verbatim append, returning `self` for chainability (`view << a << b`).
|
|
142
|
+
# @param str [String, StyledString, nil]
|
|
143
|
+
# @return [self]
|
|
144
|
+
def <<(str)
|
|
145
|
+
append(str)
|
|
146
|
+
self
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Appends `str` as a new entry: starts a fresh hard line first (when
|
|
150
|
+
# the buffer is non-empty) and then appends `str`. Equivalent to
|
|
151
|
+
# `append("\n" + str)` on a non-empty buffer, or `append(str)` on an
|
|
152
|
+
# empty one. `nil` and `""` produce a blank entry on a non-empty
|
|
153
|
+
# buffer and a no-op on an empty buffer (matches the old `append`
|
|
154
|
+
# semantics for "log line" callers).
|
|
155
|
+
# @param str [String, StyledString, nil]
|
|
156
|
+
# @return [void]
|
|
157
|
+
def add_line(str)
|
|
158
|
+
parsed = StyledString.parse(str)
|
|
159
|
+
if empty?
|
|
160
|
+
append(parsed)
|
|
161
|
+
else
|
|
162
|
+
append(StyledString.plain("\n") + parsed)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Drops the last `n` hard lines from the buffer. The inverse of
|
|
167
|
+
# building up a tail region with {#append} / {#add_line}: a caller
|
|
168
|
+
# streaming partially-rendered content whose tail must occasionally
|
|
169
|
+
# be retracted (e.g. Markdown-to-ANSI where a new token reformats
|
|
170
|
+
# the table being built) can call `remove_last_n_lines(k)` followed
|
|
171
|
+
# by `append(new_tail)` to replace the damaged region in place.
|
|
172
|
+
#
|
|
173
|
+
# `n == 0` and the empty-buffer case are no-ops (no invalidation).
|
|
174
|
+
# `n >= hard-line count` empties the buffer.
|
|
175
|
+
#
|
|
176
|
+
# Operates on **hard lines** (the `\n`-delimited entries the
|
|
177
|
+
# buffer stores), not on wrapped physical rows — same granularity
|
|
178
|
+
# as {#add_line}. Cost is O(rendered-rows of the popped lines).
|
|
179
|
+
# @param n [Integer] number of hard lines to drop; must be >= 0.
|
|
180
|
+
# @raise [TypeError] if `n` isn't an `Integer`.
|
|
181
|
+
# @raise [ArgumentError] if `n` is negative.
|
|
182
|
+
# @return [void]
|
|
183
|
+
def remove_last_n_lines(n)
|
|
184
|
+
raise TypeError, "expected Integer, got #{n.inspect}" unless n.is_a?(Integer)
|
|
185
|
+
raise ArgumentError, "n must not be negative, got #{n}" if n.negative?
|
|
186
|
+
|
|
187
|
+
screen.check_locked
|
|
188
|
+
return if n.zero? || empty?
|
|
189
|
+
|
|
106
190
|
width = wrap_width
|
|
107
|
-
|
|
191
|
+
to_drop = [n, @hard_lines.size].min
|
|
192
|
+
to_drop.times do
|
|
193
|
+
popped = @hard_lines.pop
|
|
194
|
+
drop_physical_rows_for(popped, width)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
@text = nil
|
|
198
|
+
@content_size = compute_content_size
|
|
199
|
+
@top_line = top_line_max if @top_line > top_line_max
|
|
108
200
|
update_top_line_if_auto_scroll
|
|
109
201
|
invalidate
|
|
110
202
|
end
|
|
@@ -255,6 +347,19 @@ module Tuile
|
|
|
255
347
|
end
|
|
256
348
|
end
|
|
257
349
|
|
|
350
|
+
# Pops from {@physical_lines} the rows that `hard_line` previously
|
|
351
|
+
# contributed (the inverse of {#append_physical_lines} for the same
|
|
352
|
+
# input). Used by {#append} when extending the last hard line: its
|
|
353
|
+
# old wrapped rows are dropped, then the extended hard line is
|
|
354
|
+
# re-wrapped and appended.
|
|
355
|
+
# @param hard_line [StyledString]
|
|
356
|
+
# @param width [Integer]
|
|
357
|
+
# @return [void]
|
|
358
|
+
def drop_physical_rows_for(hard_line, width)
|
|
359
|
+
count = hard_line.empty? || width <= 0 ? 1 : hard_line.wrap(width).size
|
|
360
|
+
count.times { @physical_lines.pop }
|
|
361
|
+
end
|
|
362
|
+
|
|
258
363
|
# Rebuilds the joined {StyledString} from {@hard_lines}, inserting a
|
|
259
364
|
# default-styled `"\n"` between hard lines. Called from the {#text}
|
|
260
365
|
# reader when the cache is cold. Cost is O(total spans).
|
|
@@ -8,8 +8,9 @@ module Tuile
|
|
|
8
8
|
#
|
|
9
9
|
# The window's `content` is unset by default; assign one via {#content=}.
|
|
10
10
|
#
|
|
11
|
-
# Window is considered invisible if {#rect} is empty
|
|
12
|
-
#
|
|
11
|
+
# Window is considered invisible if {#rect} is empty. The window won't
|
|
12
|
+
# draw when invisible. (Repaint of detached windows is short-circuited
|
|
13
|
+
# by {Component#invalidate}; subclasses don't need to re-check.)
|
|
13
14
|
class Window < Component
|
|
14
15
|
include Component::HasContent
|
|
15
16
|
|
|
@@ -125,12 +126,6 @@ module Tuile
|
|
|
125
126
|
Size.new(inner_w + 2, inner_h + 2)
|
|
126
127
|
end
|
|
127
128
|
|
|
128
|
-
# @return [Boolean] true if {#rect} is off screen and the window won't
|
|
129
|
-
# paint.
|
|
130
|
-
def visible?
|
|
131
|
-
!@rect.empty? && !@rect.top.negative? && !@rect.left.negative?
|
|
132
|
-
end
|
|
133
|
-
|
|
134
129
|
# Fully repaints the window: both frame and contents.
|
|
135
130
|
#
|
|
136
131
|
# Window deliberately paints over its entire rect (border around the
|
|
@@ -143,7 +138,7 @@ module Tuile
|
|
|
143
138
|
# cycle.
|
|
144
139
|
# @return [void]
|
|
145
140
|
def repaint
|
|
146
|
-
return
|
|
141
|
+
return if rect.empty?
|
|
147
142
|
|
|
148
143
|
super
|
|
149
144
|
repaint_border
|
|
@@ -168,7 +163,7 @@ module Tuile
|
|
|
168
163
|
# Paints the window border.
|
|
169
164
|
# @return [void]
|
|
170
165
|
def repaint_border
|
|
171
|
-
return
|
|
166
|
+
return if rect.empty?
|
|
172
167
|
|
|
173
168
|
frame = build_frame(frame_caption)
|
|
174
169
|
frame = Rainbow(frame).green if active?
|