tuile 0.9.0 → 0.11.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 +81 -36
- data/DECISIONS.md +2566 -0
- data/README.md +37 -24
- data/book/03-layout.md +153 -8
- data/book/04-event-loop.md +86 -0
- data/book/05-focus.md +84 -51
- data/book/06-theming.md +53 -1
- data/book/07-components.md +458 -9
- data/book/08-testing.md +21 -8
- data/book/09-styled-text.md +132 -0
- data/book/README.md +22 -14
- data/examples/sampler.rb +632 -67
- data/ideas/arrow-key-navigation.md +205 -0
- data/ideas/new-components.md +118 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +52 -51
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/big_decimal_field.rb +199 -0
- data/lib/tuile/component/button.rb +25 -21
- data/lib/tuile/component/checkbox.rb +134 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +263 -0
- data/lib/tuile/component/float_field.rb +161 -0
- data/lib/tuile/component/has_caption.rb +44 -0
- data/lib/tuile/component/has_content.rb +5 -5
- data/lib/tuile/component/has_value.rb +64 -0
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +13 -10
- data/lib/tuile/component/layout/box.rb +316 -0
- data/lib/tuile/component/layout/horizontal.rb +40 -0
- data/lib/tuile/component/layout/vertical.rb +41 -0
- data/lib/tuile/component/layout.rb +149 -15
- data/lib/tuile/component/list.rb +8 -7
- data/lib/tuile/component/list_dropdown.rb +157 -0
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +10 -14
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/select.rb +251 -0
- data/lib/tuile/component/text_area.rb +189 -65
- data/lib/tuile/component/text_field.rb +170 -32
- data/lib/tuile/component/text_view.rb +57 -114
- data/lib/tuile/component/window.rb +32 -47
- data/lib/tuile/component.rb +250 -87
- data/lib/tuile/event_queue.rb +14 -17
- data/lib/tuile/fake_event_queue.rb +11 -2
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +6 -10
- data/lib/tuile/screen.rb +202 -104
- data/lib/tuile/screen_pane.rb +51 -41
- data/lib/tuile/styled_string.rb +125 -86
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +4 -0
- data/sig/tuile.rbs +2962 -680
- metadata +25 -7
|
@@ -2,23 +2,76 @@
|
|
|
2
2
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
|
-
# A single-line text input
|
|
5
|
+
# A single-line text input with a real hardware caret, scrolling
|
|
6
|
+
# horizontally to keep that caret in view:
|
|
6
7
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
8
|
+
# f = TextField.new
|
|
9
|
+
# f.rect = Rect.new(0, 0, 6, 1) # six columns wide …
|
|
10
|
+
# f.text = "hello world" # … eleven columns of text, so it scrolls
|
|
11
|
+
# f.caret = 11 # paints "world " — left_column 6, cursor on the last column
|
|
12
|
+
# f.caret = 0 # paints "hello " — left_column 0
|
|
10
13
|
#
|
|
11
|
-
# The
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
|
|
14
|
+
# The field's width never bounds its contents — {#max_text_length} does, and
|
|
15
|
+
# only for typing.
|
|
16
|
+
#
|
|
17
|
+
# == Implementation details
|
|
18
|
+
#
|
|
19
|
+
# Two axes run through this class and are *not* interchangeable:
|
|
20
|
+
#
|
|
21
|
+
# - an **index** counts characters into {#text} — {#caret},
|
|
22
|
+
# {#max_text_length}, `text[i]`, every edit;
|
|
23
|
+
# - a **column** counts terminal cells — {#rect}, {#left_column},
|
|
24
|
+
# {#cursor_position}, a {MouseEvent}.
|
|
25
|
+
#
|
|
26
|
+
# They coincide only while every glyph is one column wide. A fullwidth CJK
|
|
27
|
+
# char is two columns and a combining mark zero, so index 3 of `"日本語"` is
|
|
28
|
+
# column 6. Every crossing goes through the private `column_at` / `index_at`
|
|
29
|
+
# pair; adding an index to a column anywhere else is the bug those two exist
|
|
30
|
+
# to prevent.
|
|
31
|
+
#
|
|
32
|
+
# Indices count characters while widths measure grapheme clusters, but the
|
|
33
|
+
# caret never falls between the two: {AbstractStringField} keeps it on a
|
|
34
|
+
# cluster boundary, so a column derived from it always names a real glyph
|
|
35
|
+
# edge.
|
|
36
|
+
#
|
|
37
|
+
# What gets *painted* is {#display_text}, a third seam that is `text` itself
|
|
38
|
+
# here and the mask in {PasswordField}. Every column measurement reads it, so
|
|
39
|
+
# a subclass showing something else overrides that and never {#repaint} —
|
|
40
|
+
# overriding the paint alone leaves the measurements on the buffer while the
|
|
41
|
+
# cells show the substitute, and the two drift apart by a growing offset.
|
|
42
|
+
class TextField < AbstractStringField
|
|
15
43
|
def initialize
|
|
16
44
|
super
|
|
45
|
+
@left_column = 0
|
|
46
|
+
@max_text_length = nil
|
|
17
47
|
@on_key_up = nil
|
|
18
48
|
@on_key_down = nil
|
|
19
49
|
@on_enter = nil
|
|
20
50
|
end
|
|
21
51
|
|
|
52
|
+
# Optional cap on {#text}'s length **in characters** — a wide glyph counts
|
|
53
|
+
# once. Typing into a field already at the cap does nothing.
|
|
54
|
+
#
|
|
55
|
+
# Deliberately does not police {#text=}: lowering the cap under an existing
|
|
56
|
+
# value leaves that value intact rather than silently trimming it.
|
|
57
|
+
# @return [Integer, nil] maximum characters, or nil for unbounded (default).
|
|
58
|
+
attr_reader :max_text_length
|
|
59
|
+
|
|
60
|
+
# @param max [Integer, nil]
|
|
61
|
+
# @return [void]
|
|
62
|
+
# @raise [TypeError] unless `max` is an Integer or nil.
|
|
63
|
+
# @raise [ArgumentError] if `max` is negative.
|
|
64
|
+
def max_text_length=(max)
|
|
65
|
+
raise TypeError, "expected Integer or nil, got #{max.inspect}" unless max.nil? || max.is_a?(Integer)
|
|
66
|
+
raise ArgumentError, "expected a non-negative max, got #{max}" if max&.negative?
|
|
67
|
+
|
|
68
|
+
@max_text_length = max
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Integer] text column drawn in the field's leftmost cell — the
|
|
72
|
+
# horizontal scroll offset. Follows {#caret}, always on a glyph boundary.
|
|
73
|
+
attr_reader :left_column
|
|
74
|
+
|
|
22
75
|
# Optional callback fired when the UP arrow key is pressed. When set, UP
|
|
23
76
|
# is consumed by the field; when nil, UP falls through to the parent
|
|
24
77
|
# (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
|
|
@@ -43,37 +96,33 @@ module Tuile
|
|
|
43
96
|
def cursor_position
|
|
44
97
|
return nil unless rect.width.positive?
|
|
45
98
|
|
|
46
|
-
|
|
99
|
+
# Scrolling already keeps the caret inside the rect, so the cap is a
|
|
100
|
+
# guard rather than a policy: a cursor parked at rect.left + rect.width
|
|
101
|
+
# reads as column 0 of the next row on an auto-wrapping terminal.
|
|
102
|
+
offset = (column_at(@caret) - @left_column).clamp(0, rect.width - 1)
|
|
103
|
+
Point.new(rect.left + offset, rect.top)
|
|
47
104
|
end
|
|
48
105
|
|
|
106
|
+
# Places the caret at the clicked column. A click on the right half of a
|
|
107
|
+
# wide glyph lands *after* it, as in any editor.
|
|
49
108
|
# @param event [MouseEvent]
|
|
50
109
|
# @return [void]
|
|
51
110
|
def handle_mouse(event)
|
|
52
111
|
super
|
|
53
112
|
return unless event.button == :left && rect.contains?(event.point)
|
|
54
113
|
|
|
55
|
-
self.caret = (event.x - rect.left
|
|
114
|
+
self.caret = index_at(event.x - rect.left + @left_column)
|
|
56
115
|
end
|
|
57
116
|
|
|
58
117
|
# @return [void]
|
|
59
118
|
def repaint
|
|
60
119
|
return if rect.empty?
|
|
61
120
|
|
|
62
|
-
|
|
63
|
-
screen.buffer.set_line(rect.left, rect.top, background(padded))
|
|
121
|
+
screen.buffer.set_line(rect.left, rect.top, background(visible_text))
|
|
64
122
|
end
|
|
65
123
|
|
|
66
124
|
protected
|
|
67
125
|
|
|
68
|
-
# Truncate to fit `rect.width - 1` — single-line fields can't grow past
|
|
69
|
-
# their width.
|
|
70
|
-
# @param new_text [String]
|
|
71
|
-
# @return [String]
|
|
72
|
-
def preprocess_text(new_text)
|
|
73
|
-
new_text = new_text.to_s
|
|
74
|
-
new_text.length > max_text_length ? new_text[0, max_text_length] : new_text
|
|
75
|
-
end
|
|
76
|
-
|
|
77
126
|
# @param key [String]
|
|
78
127
|
# @return [Boolean]
|
|
79
128
|
def handle_text_input_key(key)
|
|
@@ -102,32 +151,121 @@ module Tuile
|
|
|
102
151
|
true
|
|
103
152
|
end
|
|
104
153
|
|
|
154
|
+
# @return [void]
|
|
155
|
+
def on_text_mutated
|
|
156
|
+
adjust_left_column
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @return [void]
|
|
160
|
+
def on_caret_mutated
|
|
161
|
+
adjust_left_column
|
|
162
|
+
end
|
|
163
|
+
|
|
105
164
|
# @return [void]
|
|
106
165
|
def on_width_changed
|
|
107
166
|
super
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
@text = @text[0, [max_text_length, 0].max]
|
|
111
|
-
@caret = @caret.clamp(0, @text.length)
|
|
112
|
-
@on_change&.call(@text)
|
|
167
|
+
adjust_left_column
|
|
113
168
|
end
|
|
114
169
|
|
|
115
|
-
|
|
170
|
+
# What the field paints in place of {#text}: one display character per
|
|
171
|
+
# {#text} character, in order. `column_at` measures `display_text[0, i]` as
|
|
172
|
+
# the rendering of `text[0, i]`, so an override that changes the character
|
|
173
|
+
# count — or reorders — desynchronizes the caret from the display. Nothing
|
|
174
|
+
# enforces it at runtime; a subclass pins it with a spec.
|
|
175
|
+
# @return [String] {#text} itself, unless a subclass substitutes.
|
|
176
|
+
def display_text = @text
|
|
116
177
|
|
|
117
|
-
|
|
118
|
-
# @return [Integer]
|
|
119
|
-
def max_text_length = (rect.width - 1).clamp(0, nil)
|
|
178
|
+
private
|
|
120
179
|
|
|
121
180
|
# @param char [String]
|
|
122
|
-
# @return [Boolean]
|
|
181
|
+
# @return [Boolean] always true — a field at {#max_text_length} swallows the
|
|
182
|
+
# key rather than declining it, so typing can never fall through to a
|
|
183
|
+
# scope-wide binding.
|
|
123
184
|
def insert(char)
|
|
124
|
-
return
|
|
185
|
+
return true if @max_text_length && @text.length >= @max_text_length
|
|
125
186
|
|
|
126
187
|
new_text = @text.dup.insert(@caret, char)
|
|
127
188
|
@caret += 1
|
|
128
189
|
self.text = new_text
|
|
129
190
|
true
|
|
130
191
|
end
|
|
192
|
+
|
|
193
|
+
# @param index [Integer] a {#text} index in `0..text.length`.
|
|
194
|
+
# @return [Integer] the column it sits at. An index landing inside a
|
|
195
|
+
# grapheme cluster measures the whole cluster, putting the caret just
|
|
196
|
+
# past it.
|
|
197
|
+
def column_at(index) = columns_of(display_text[0, index] || "")
|
|
198
|
+
|
|
199
|
+
# @param column [Integer] a text column (0 is the first glyph).
|
|
200
|
+
# @return [Integer] the nearest {#text} index — a column falling in a wide
|
|
201
|
+
# glyph's right half resolves past it.
|
|
202
|
+
def index_at(column)
|
|
203
|
+
col = 0
|
|
204
|
+
i = 0
|
|
205
|
+
display_text.each_grapheme_cluster do |g|
|
|
206
|
+
w = Buffer.display_width(g)
|
|
207
|
+
return i if column < col + ((w + 1) / 2)
|
|
208
|
+
|
|
209
|
+
col += w
|
|
210
|
+
i += g.length
|
|
211
|
+
end
|
|
212
|
+
i
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# @return [Integer] total display width of {#text}.
|
|
216
|
+
def text_columns = column_at(@text.length)
|
|
217
|
+
|
|
218
|
+
# @return [String] the windowed text, padded with spaces to `rect.width`.
|
|
219
|
+
# A wide glyph straddling the right edge is dropped rather than painted
|
|
220
|
+
# as a half glyph.
|
|
221
|
+
def visible_text
|
|
222
|
+
right = @left_column + rect.width
|
|
223
|
+
visible = +""
|
|
224
|
+
width = 0
|
|
225
|
+
col = 0
|
|
226
|
+
display_text.each_grapheme_cluster do |g|
|
|
227
|
+
start = col
|
|
228
|
+
col += Buffer.display_width(g)
|
|
229
|
+
next if start < @left_column
|
|
230
|
+
break if col > right
|
|
231
|
+
|
|
232
|
+
visible << g
|
|
233
|
+
width = col - @left_column
|
|
234
|
+
end
|
|
235
|
+
visible << (" " * (rect.width - width))
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Scrolls the minimum needed to keep the caret's column visible.
|
|
239
|
+
# @return [void]
|
|
240
|
+
def adjust_left_column
|
|
241
|
+
return unless rect.width.positive?
|
|
242
|
+
|
|
243
|
+
col = column_at(@caret)
|
|
244
|
+
@left_column = col if col < @left_column
|
|
245
|
+
@left_column = col - rect.width + 1 if col > @left_column + rect.width - 1
|
|
246
|
+
# The caret may park one column past the last glyph, so the scrollable
|
|
247
|
+
# range runs one column past the text.
|
|
248
|
+
@left_column = snap_to_glyph_start(@left_column.clamp(0, [text_columns - rect.width + 1, 0].max))
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# @param column [Integer]
|
|
252
|
+
# @return [Integer] the smallest glyph-boundary column `>= column`, so the
|
|
253
|
+
# window never opens on a wide glyph's right half.
|
|
254
|
+
#
|
|
255
|
+
# Snapping *right* is the only safe direction, and not because it shows
|
|
256
|
+
# more: the caret's own column is always a glyph boundary, so the next
|
|
257
|
+
# boundary at or after `left_column` can never overshoot it. Snapping left
|
|
258
|
+
# instead pulls the window's right edge inward, which strands the caret
|
|
259
|
+
# outside it whenever wide glyphs exactly fill a narrow field.
|
|
260
|
+
def snap_to_glyph_start(column)
|
|
261
|
+
col = 0
|
|
262
|
+
display_text.each_grapheme_cluster do |g|
|
|
263
|
+
return col if col >= column
|
|
264
|
+
|
|
265
|
+
col += Buffer.display_width(g)
|
|
266
|
+
end
|
|
267
|
+
col
|
|
268
|
+
end
|
|
131
269
|
end
|
|
132
270
|
end
|
|
133
271
|
end
|
|
@@ -3,31 +3,25 @@
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
5
|
# A read-only viewer for prose: chunks of formatted text that scroll
|
|
6
|
-
# vertically. Shape-wise a hybrid between {Label} (string
|
|
7
|
-
#
|
|
6
|
+
# vertically. Shape-wise a hybrid between {Label} (string content via
|
|
7
|
+
# {#text=}) and {List} (scroll keys, optional scrollbar, auto-scroll).
|
|
8
8
|
#
|
|
9
|
-
# Text is
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# ANSI
|
|
13
|
-
#
|
|
14
|
-
# so embedded ANSI is honored) or a {StyledString} directly; {#text}
|
|
15
|
-
# always returns the {StyledString}.
|
|
9
|
+
# Text is a {StyledString}: embedded `\n` are hard line breaks, longer lines
|
|
10
|
+
# are word-wrapped via {StyledString#wrap} with style spans preserved across
|
|
11
|
+
# wrap boundaries. {#text=} takes a {String} (parsed via {StyledString.parse},
|
|
12
|
+
# honoring embedded ANSI) or a {StyledString}; {#text} always returns the
|
|
13
|
+
# {StyledString}.
|
|
16
14
|
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# {#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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.
|
|
15
|
+
# Pick the right incremental primitive: {#append} (aliased `<<`) concatenates
|
|
16
|
+
# a chunk verbatim onto the buffer (stream-friendly, `\n` → hard breaks);
|
|
17
|
+
# {#add_line} starts the chunk on a fresh line (the "log entry" convenience);
|
|
18
|
+
# {#remove_last_n_lines} pops hard lines off the tail, so a caller streaming
|
|
19
|
+
# reformattable content can retract and rewrite it; {#replace} / {#insert}
|
|
20
|
+
# splice a range in place. Turn on {#auto_scroll} to keep the latest content
|
|
21
|
+
# in view.
|
|
28
22
|
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
23
|
+
# Meant to be the content of a {Window} — focus indication and keyboard-hint
|
|
24
|
+
# surfacing rely on the surrounding window chrome.
|
|
31
25
|
class TextView < Component
|
|
32
26
|
def initialize
|
|
33
27
|
super
|
|
@@ -55,21 +49,14 @@ module Tuile
|
|
|
55
49
|
@auto_scroll = false
|
|
56
50
|
@follow = true
|
|
57
51
|
@scrollbar_visibility = :gone
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
# code that never calls {#create_region} sees the same behavior as
|
|
61
|
-
# before (a single region containing everything); apps that do call
|
|
62
|
-
# {#create_region} stack additional regions at the spatial tail.
|
|
52
|
+
# Always ≥1 region; the implicit default owns any hard lines no
|
|
53
|
+
# app-created region claims. See {Region}.
|
|
63
54
|
@regions = [Region.send(:new, self)]
|
|
64
55
|
end
|
|
65
56
|
|
|
66
|
-
# @return [StyledString] the current text
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
# whole buffer; the joined {StyledString} returned here is
|
|
70
|
-
# reconstructed on first read after a mutation and cached, so
|
|
71
|
-
# repeated reads are O(1) but the first read after {#append} pays
|
|
72
|
-
# O(total spans).
|
|
57
|
+
# @return [StyledString] the current text (empty by default). Rebuilt
|
|
58
|
+
# lazily on the first read after a mutation (O(total spans)), then
|
|
59
|
+
# cached — repeated reads are O(1).
|
|
73
60
|
def text
|
|
74
61
|
@text ||= build_text
|
|
75
62
|
end
|
|
@@ -144,19 +131,12 @@ module Tuile
|
|
|
144
131
|
# @return [Boolean] true iff {#text} is empty (no hard lines).
|
|
145
132
|
def empty? = @hard_lines.empty?
|
|
146
133
|
|
|
147
|
-
# Appends `str` verbatim. Embedded `\n`
|
|
148
|
-
#
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
# For the "add an entry on a new line" pattern use {#add_line}.
|
|
154
|
-
#
|
|
155
|
-
# Cost is O(appended + width-of-current-last-hard-line) — the
|
|
156
|
-
# previously last hard line is re-wrapped (because the extension may
|
|
157
|
-
# cause it to wrap differently), any additional hard lines created by
|
|
158
|
-
# embedded `\n` are wrapped fresh. The cached {#text} is invalidated
|
|
159
|
-
# and rebuilt on demand.
|
|
134
|
+
# Appends `str` verbatim. Embedded `\n` become hard line breaks; otherwise
|
|
135
|
+
# the text is concatenated onto the current last hard line. Designed for
|
|
136
|
+
# streaming use (feed each partial chunk straight in). Accepts the same
|
|
137
|
+
# input forms as {#text=}; empty/`nil` is a no-op. For the "entry on a new
|
|
138
|
+
# line" pattern use {#add_line}. Cost is O(appended + width of the last
|
|
139
|
+
# hard line), which is re-wrapped since the extension may wrap differently.
|
|
160
140
|
# @param str [String, StyledString, nil]
|
|
161
141
|
# @return [void]
|
|
162
142
|
def append(str)
|
|
@@ -221,19 +201,11 @@ module Tuile
|
|
|
221
201
|
end
|
|
222
202
|
end
|
|
223
203
|
|
|
224
|
-
# Drops the last `n` hard lines from the buffer
|
|
225
|
-
#
|
|
226
|
-
#
|
|
227
|
-
#
|
|
228
|
-
#
|
|
229
|
-
# by `append(new_tail)` to replace the damaged region in place.
|
|
230
|
-
#
|
|
231
|
-
# `n == 0` and the empty-buffer case are no-ops (no invalidation).
|
|
232
|
-
# `n >= hard-line count` empties the buffer.
|
|
233
|
-
#
|
|
234
|
-
# Operates on **hard lines** (the `\n`-delimited entries the
|
|
235
|
-
# buffer stores), not on wrapped physical rows — same granularity
|
|
236
|
-
# as {#add_line}. Cost is O(rendered-rows of the popped lines).
|
|
204
|
+
# Drops the last `n` hard lines from the buffer — the inverse of building
|
|
205
|
+
# up a tail with {#append} / {#add_line}, so a caller can `remove` then
|
|
206
|
+
# `append` to rewrite a damaged tail in place. Operates on **hard lines**
|
|
207
|
+
# (the `\n`-delimited entries), not wrapped physical rows. `n == 0` and the
|
|
208
|
+
# empty buffer are no-ops; `n >= hard-line count` empties the buffer.
|
|
237
209
|
# @param n [Integer] number of hard lines to drop; must be >= 0.
|
|
238
210
|
# @raise [TypeError] if `n` isn't an `Integer`.
|
|
239
211
|
# @raise [ArgumentError] if `n` is negative.
|
|
@@ -266,40 +238,21 @@ module Tuile
|
|
|
266
238
|
invalidate
|
|
267
239
|
end
|
|
268
240
|
|
|
269
|
-
# Replaces a contiguous range of hard lines with the parsed content
|
|
270
|
-
#
|
|
271
|
-
#
|
|
272
|
-
#
|
|
273
|
-
#
|
|
274
|
-
# `"\n"` in the replacement produces multiple hard lines, so a single
|
|
275
|
-
# `replace` can grow or shrink the buffer.
|
|
241
|
+
# Replaces a contiguous range of hard lines with the parsed content of
|
|
242
|
+
# `str` (parsed like {#text=}: `String` → {StyledString.parse}, `nil` →
|
|
243
|
+
# empty, so `nil` deletes the range). Embedded `"\n"` yields multiple hard
|
|
244
|
+
# lines, so one `replace` can grow or shrink the buffer. `range` selects
|
|
245
|
+
# which hard lines to swap out:
|
|
276
246
|
#
|
|
277
|
-
# `
|
|
278
|
-
#
|
|
279
|
-
# - an
|
|
280
|
-
#
|
|
281
|
-
# - a non-empty `Range` of hard-line indices replaces those lines;
|
|
282
|
-
# - an empty `Range` (e.g. `2...2`, or the canonical end-insertion
|
|
283
|
-
# `hard_lines.size...hard_lines.size`) is *insertion* at that
|
|
284
|
-
# position — no lines are removed. {#insert} is a thin alias for
|
|
285
|
-
# this case.
|
|
286
|
-
#
|
|
287
|
-
# Endpoints must be non-negative integers; `begin` may equal
|
|
288
|
-
# `hard-line count` (insertion at the end), `end` may not exceed
|
|
289
|
-
# `hard-line count - 1`. `nil` endpoints (beginless / endless ranges)
|
|
290
|
-
# are not accepted.
|
|
291
|
-
#
|
|
292
|
-
# Cost is roughly `O(from + length + new content)`: the splice
|
|
293
|
-
# updates only the affected slice of the physical-row buffer, using
|
|
294
|
-
# the per-hard-line wrap-count cache to locate the starting offset
|
|
295
|
-
# without re-wrapping preceding lines. Lines outside the splice are
|
|
296
|
-
# never re-wrapped. {#top_line} is clamped if the new line count
|
|
297
|
-
# puts it past the end; {#auto_scroll} pins it to the bottom as
|
|
298
|
-
# usual. The call is a no-op (no invalidation) when the parsed
|
|
299
|
-
# replacement equals the covered range (vacuously true for an empty
|
|
300
|
-
# range plus empty replacement, so `replace(n...n, "")` is a cheap
|
|
301
|
-
# no-op).
|
|
247
|
+
# - an `Integer` `n` is shorthand for `n..n` (replace one existing line);
|
|
248
|
+
# - a non-empty `Range` replaces those lines;
|
|
249
|
+
# - an empty `Range` (e.g. `2...2`, or `size...size` at the end) is
|
|
250
|
+
# *insertion* at that position — nothing removed. {#insert} aliases this.
|
|
302
251
|
#
|
|
252
|
+
# Splices in place — only the affected slice of the physical-row buffer is
|
|
253
|
+
# touched, no preceding lines re-wrapped (cost O(from + length + new
|
|
254
|
+
# content)). A no-op when the replacement equals the covered range, so
|
|
255
|
+
# `replace(n...n, "")` is cheap.
|
|
303
256
|
# @param range [Range, Integer] hard-line indices to replace.
|
|
304
257
|
# @param str [String, StyledString, nil] replacement content.
|
|
305
258
|
# @raise [TypeError] if `range` is neither an `Integer` nor a `Range`,
|
|
@@ -417,6 +370,8 @@ module Tuile
|
|
|
417
370
|
# Skips the {Component#repaint} default's auto-clear: every row is
|
|
418
371
|
# painted explicitly (with padded blanks past the last line), so the
|
|
419
372
|
# "fully draw over your rect" contract is met without an upfront wipe.
|
|
373
|
+
# Rows go through {Component#draw_line}, so content and blank rows inherit
|
|
374
|
+
# {Component#effective_bg_color} (a {#bg_color} set here or on an ancestor).
|
|
420
375
|
# @return [void]
|
|
421
376
|
def repaint
|
|
422
377
|
return if rect.empty?
|
|
@@ -426,7 +381,7 @@ module Tuile
|
|
|
426
381
|
end
|
|
427
382
|
(0...rect.height).each do |row|
|
|
428
383
|
line = paintable_line(row + @top_line, row, scrollbar)
|
|
429
|
-
|
|
384
|
+
draw_line(rect.left, rect.top + row, line)
|
|
430
385
|
end
|
|
431
386
|
end
|
|
432
387
|
|
|
@@ -645,19 +600,12 @@ module Tuile
|
|
|
645
600
|
invalidate
|
|
646
601
|
end
|
|
647
602
|
|
|
648
|
-
# Adjusts region line counts after a {@hard_lines} splice that
|
|
649
|
-
#
|
|
650
|
-
#
|
|
651
|
-
#
|
|
652
|
-
#
|
|
653
|
-
#
|
|
654
|
-
# region that lost lines — that's the natural home for the
|
|
655
|
-
# replacement content.
|
|
656
|
-
# 2. Credit `added_count` to that region. For pure insertions (no
|
|
657
|
-
# removal), there's no "first overlapping region" to pick from;
|
|
658
|
-
# walk regions and credit the latest one starting at `from` (the
|
|
659
|
-
# boundary tiebreaker matches the spatial-tail-routing of
|
|
660
|
-
# {#append}). Past-the-end inserts fall back to the tail region.
|
|
603
|
+
# Adjusts region line counts after a {@hard_lines} splice that removed
|
|
604
|
+
# `removed_count` lines at `from` and inserted `added_count`. Subtracts
|
|
605
|
+
# each region's overlap with the removed range, then credits the added
|
|
606
|
+
# lines to the first region that lost lines. Pure insertions have no such
|
|
607
|
+
# region — they credit the latest region starting at `from`, matching
|
|
608
|
+
# {#append}'s spatial-tail routing (past-the-end falls back to the tail).
|
|
661
609
|
# @param from [Integer]
|
|
662
610
|
# @param removed_count [Integer]
|
|
663
611
|
# @param added_count [Integer]
|
|
@@ -956,14 +904,9 @@ module Tuile
|
|
|
956
904
|
end
|
|
957
905
|
|
|
958
906
|
# Verbatim append into this region's tail. Same semantics as
|
|
959
|
-
# {TextView#append} but scoped
|
|
960
|
-
#
|
|
961
|
-
#
|
|
962
|
-
# is a no-op (but still raises when detached). When the region is
|
|
963
|
-
# the spatial tail of the view, this uses the incremental
|
|
964
|
-
# {TextView#append} path; mid-document regions splice the affected
|
|
965
|
-
# slice of the physical-row buffer (lines outside the region are
|
|
966
|
-
# not re-wrapped).
|
|
907
|
+
# {TextView#append} but scoped: embedded `"\n"` creates new hard lines
|
|
908
|
+
# within the region, other input extends the region's last hard line.
|
|
909
|
+
# Empty / `nil` is a no-op (but still raises when detached).
|
|
967
910
|
# @param str [String, StyledString, nil]
|
|
968
911
|
# @raise [RuntimeError] when the region is detached.
|
|
969
912
|
# @return [void]
|
|
@@ -13,12 +13,14 @@ module Tuile
|
|
|
13
13
|
# by {Component#invalidate}; subclasses don't need to re-check.)
|
|
14
14
|
class Window < Component
|
|
15
15
|
include Component::HasContent
|
|
16
|
+
include Component::HasCaption
|
|
16
17
|
|
|
17
|
-
# @param caption [String]
|
|
18
|
-
|
|
18
|
+
# @param caption [String, StyledString, nil] the border title, coerced
|
|
19
|
+
# the same way {HasCaption#caption=} coerces it.
|
|
20
|
+
def initialize(caption = nil)
|
|
19
21
|
super()
|
|
20
22
|
@border_right = 1
|
|
21
|
-
|
|
23
|
+
self.caption = caption
|
|
22
24
|
@content = nil
|
|
23
25
|
# Optional bottom-row widget slot (e.g. a search field), spanning the
|
|
24
26
|
# full inner width; and optional bottom-border chrome text embedded in
|
|
@@ -75,10 +77,12 @@ module Tuile
|
|
|
75
77
|
end
|
|
76
78
|
|
|
77
79
|
old = @footer
|
|
78
|
-
|
|
80
|
+
# Same slot-swap order as HasContent#content=: notified last, so the
|
|
81
|
+
# focus repair cascades into the new occupant rather than the old.
|
|
82
|
+
detach_child(old) unless old.nil?
|
|
79
83
|
@footer = new_footer
|
|
80
84
|
unless new_footer.nil?
|
|
81
|
-
new_footer
|
|
85
|
+
add_child(new_footer) # appended: the footer paints over the border row
|
|
82
86
|
new_footer.invalidate
|
|
83
87
|
layout_footer
|
|
84
88
|
end
|
|
@@ -86,11 +90,6 @@ module Tuile
|
|
|
86
90
|
on_child_removed(old) unless old.nil?
|
|
87
91
|
end
|
|
88
92
|
|
|
89
|
-
# @return [Array<Component>]
|
|
90
|
-
def children
|
|
91
|
-
@footer.nil? ? super : super + [@footer]
|
|
92
|
-
end
|
|
93
|
-
|
|
94
93
|
# @param event [MouseEvent]
|
|
95
94
|
# @return [void]
|
|
96
95
|
def handle_mouse(event)
|
|
@@ -122,16 +121,6 @@ module Tuile
|
|
|
122
121
|
layout(content)
|
|
123
122
|
end
|
|
124
123
|
|
|
125
|
-
# @return [String] the current caption, empty by default.
|
|
126
|
-
attr_reader :caption
|
|
127
|
-
|
|
128
|
-
# Sets new caption and repaints the window.
|
|
129
|
-
# @param new_caption [String]
|
|
130
|
-
def caption=(new_caption)
|
|
131
|
-
@caption = new_caption
|
|
132
|
-
invalidate
|
|
133
|
-
end
|
|
134
|
-
|
|
135
124
|
# Fully repaints the window: both frame and contents.
|
|
136
125
|
#
|
|
137
126
|
# Window deliberately paints over its entire rect (border around the
|
|
@@ -150,14 +139,6 @@ module Tuile
|
|
|
150
139
|
repaint_border
|
|
151
140
|
end
|
|
152
141
|
|
|
153
|
-
# @param key [String, nil]
|
|
154
|
-
# @return [void]
|
|
155
|
-
def key_shortcut=(key)
|
|
156
|
-
super
|
|
157
|
-
# The shortcut key is shown in the caption — repaint.
|
|
158
|
-
invalidate
|
|
159
|
-
end
|
|
160
|
-
|
|
161
142
|
protected
|
|
162
143
|
|
|
163
144
|
# @param content [Component]
|
|
@@ -166,9 +147,12 @@ module Tuile
|
|
|
166
147
|
content.rect = Rect.new(rect.left + 1, rect.top + 1, rect.width - 1 - @border_right, rect.height - 2)
|
|
167
148
|
end
|
|
168
149
|
|
|
169
|
-
# Paints the window border
|
|
170
|
-
#
|
|
171
|
-
#
|
|
150
|
+
# Paints the window border via {Component#draw_line}/{Component#draw_char},
|
|
151
|
+
# so the border cells inherit {Component#effective_bg_color} — a
|
|
152
|
+
# {Component#bg_color} on the window tints border and content alike. Both
|
|
153
|
+
# border lines are clipped by *display* width, so no caption overflows the
|
|
154
|
+
# box; when the window is active the whole border — the caption's own
|
|
155
|
+
# colors included — is drawn in {Theme#active_border_color}.
|
|
172
156
|
# @return [void]
|
|
173
157
|
def repaint_border
|
|
174
158
|
return if rect.empty?
|
|
@@ -178,19 +162,28 @@ module Tuile
|
|
|
178
162
|
top = rect.top
|
|
179
163
|
left = rect.left
|
|
180
164
|
inner_w = [w - 2, 0].max
|
|
181
|
-
title = frame_caption.to_s
|
|
182
|
-
title = title[0, inner_w] if title.length > inner_w
|
|
183
|
-
dashes = "─" * (inner_w - title.length)
|
|
184
165
|
|
|
185
166
|
fg = active? ? screen.theme.active_border_color : nil
|
|
186
167
|
bar = StyledString::Style.new(fg: fg)
|
|
187
|
-
|
|
188
|
-
buf.set_line(left, top, StyledString.styled("┌#{title}#{dashes}┐", fg: fg))
|
|
168
|
+
draw_line(left, top, top_border(inner_w, fg).slice(0, w))
|
|
189
169
|
(1..(h - 2)).each do |dy|
|
|
190
|
-
|
|
191
|
-
|
|
170
|
+
draw_char(left, top + dy, "│", bar)
|
|
171
|
+
draw_char(left + w - 1, top + dy, "│", bar)
|
|
192
172
|
end
|
|
193
|
-
|
|
173
|
+
draw_line(left, top + h - 1, bottom_border(inner_w, fg).slice(0, w)) if h >= 2
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Builds the top border line: corners, {#caption} embedded at its own
|
|
177
|
+
# width, dashes filling the remainder. The caption keeps its own styling
|
|
178
|
+
# unless `fg` is set — an active window's border claims it.
|
|
179
|
+
# @param inner_w [Integer] the border's interior width.
|
|
180
|
+
# @param fg [Color, nil] the active-border color, or nil when inactive.
|
|
181
|
+
# @return [StyledString]
|
|
182
|
+
def top_border(inner_w, fg)
|
|
183
|
+
title = caption.slice(0, inner_w)
|
|
184
|
+
title = title.with_fg(fg) if fg
|
|
185
|
+
dashes = StyledString.styled("─" * (inner_w - title.display_width), fg: fg)
|
|
186
|
+
StyledString.styled("┌", fg: fg) + title + dashes + StyledString.styled("┐", fg: fg)
|
|
194
187
|
end
|
|
195
188
|
|
|
196
189
|
# Builds the bottom border line. The corners take the border color; the
|
|
@@ -212,14 +205,6 @@ module Tuile
|
|
|
212
205
|
StyledString.styled("└", fg: fg) + interior + StyledString.styled("┘", fg: fg)
|
|
213
206
|
end
|
|
214
207
|
|
|
215
|
-
# The caption text as it appears in the rendered border, including the
|
|
216
|
-
# shortcut prefix when {#key_shortcut} is set.
|
|
217
|
-
# @return [String]
|
|
218
|
-
def frame_caption
|
|
219
|
-
c = @caption || ""
|
|
220
|
-
key_shortcut.nil? ? c : "[#{key_shortcut}]-#{c}"
|
|
221
|
-
end
|
|
222
|
-
|
|
223
208
|
private
|
|
224
209
|
|
|
225
210
|
# Positions the footer over the bottom border row, spanning the full
|