tuile 0.7.0 → 0.9.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 +26 -0
- data/README.md +51 -24
- data/book/01-first-app.md +186 -0
- data/book/02-repaint.md +177 -0
- data/book/03-layout.md +379 -0
- data/book/04-event-loop.md +209 -0
- data/book/05-focus.md +188 -0
- data/book/06-theming.md +250 -0
- data/book/07-components.md +231 -0
- data/book/08-testing.md +186 -0
- data/book/README.md +79 -0
- data/examples/hello_world.rb +1 -2
- data/examples/sampler.rb +109 -0
- data/lib/tuile/ansi.rb +16 -0
- data/lib/tuile/buffer.rb +481 -0
- data/lib/tuile/component/button.rb +3 -14
- data/lib/tuile/component/has_content.rb +0 -6
- data/lib/tuile/component/info_window.rb +4 -2
- data/lib/tuile/component/label.rb +15 -23
- data/lib/tuile/component/layout.rb +0 -21
- data/lib/tuile/component/list.rb +10 -37
- data/lib/tuile/component/log_window.rb +6 -5
- data/lib/tuile/component/picker_window.rb +4 -2
- data/lib/tuile/component/popup.rb +85 -55
- data/lib/tuile/component/text_area.rb +1 -1
- data/lib/tuile/component/text_field.rb +1 -1
- data/lib/tuile/component/text_input.rb +25 -9
- data/lib/tuile/component/text_view.rb +6 -30
- data/lib/tuile/component/window.rb +77 -112
- data/lib/tuile/component.rb +16 -71
- data/lib/tuile/event_queue.rb +31 -10
- data/lib/tuile/fake_event_queue.rb +21 -5
- data/lib/tuile/fake_screen.rb +14 -1
- data/lib/tuile/fraction.rb +46 -0
- data/lib/tuile/screen.rb +98 -105
- data/lib/tuile/screen_pane.rb +82 -19
- data/lib/tuile/styled_string.rb +40 -30
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +653 -282
- metadata +13 -3
- data/lib/tuile/sizing.rb +0 -59
data/lib/tuile/component/list.rb
CHANGED
|
@@ -147,7 +147,6 @@ module Tuile
|
|
|
147
147
|
update_top_line_if_auto_scroll
|
|
148
148
|
notify_cursor_changed
|
|
149
149
|
invalidate
|
|
150
|
-
self.content_size = compute_content_size
|
|
151
150
|
end
|
|
152
151
|
|
|
153
152
|
# Without a block, returns the current lines. With a block, fully
|
|
@@ -194,7 +193,6 @@ module Tuile
|
|
|
194
193
|
update_top_line_if_auto_scroll
|
|
195
194
|
notify_cursor_changed
|
|
196
195
|
invalidate
|
|
197
|
-
grow_content_size(new_lines)
|
|
198
196
|
end
|
|
199
197
|
|
|
200
198
|
def focusable? = true
|
|
@@ -204,11 +202,7 @@ module Tuile
|
|
|
204
202
|
# @param key [String] a key.
|
|
205
203
|
# @return [Boolean] true if the key was handled.
|
|
206
204
|
def handle_key(key)
|
|
207
|
-
if
|
|
208
|
-
false
|
|
209
|
-
elsif super
|
|
210
|
-
true
|
|
211
|
-
elsif key == Keys::PAGE_UP
|
|
205
|
+
if key == Keys::PAGE_UP
|
|
212
206
|
move_top_line_by(-viewport_lines)
|
|
213
207
|
true
|
|
214
208
|
elsif key == Keys::PAGE_DOWN
|
|
@@ -288,7 +282,7 @@ module Tuile
|
|
|
288
282
|
end
|
|
289
283
|
(0...rect.height).each do |row|
|
|
290
284
|
line = paintable_line(row + @top_line, row, scrollbar)
|
|
291
|
-
screen.
|
|
285
|
+
screen.buffer.set_line(rect.left, row + rect.top, line)
|
|
292
286
|
end
|
|
293
287
|
end
|
|
294
288
|
|
|
@@ -296,6 +290,8 @@ module Tuile
|
|
|
296
290
|
class Cursor
|
|
297
291
|
# @param position [Integer] the initial cursor position.
|
|
298
292
|
def initialize(position: 0)
|
|
293
|
+
raise "invalid position #{position}" unless position.is_a? Integer
|
|
294
|
+
|
|
299
295
|
@position = position
|
|
300
296
|
end
|
|
301
297
|
|
|
@@ -430,6 +426,8 @@ module Tuile
|
|
|
430
426
|
# empty.
|
|
431
427
|
# @param position [Integer] initial position.
|
|
432
428
|
def initialize(positions, position: positions[0])
|
|
429
|
+
raise "positions are empty" if positions.empty?
|
|
430
|
+
|
|
433
431
|
@positions = positions.sort
|
|
434
432
|
position = @positions[@positions.rindex { _1 < position } || 0] unless @positions.include?(position)
|
|
435
433
|
super(position: position)
|
|
@@ -508,30 +506,6 @@ module Tuile
|
|
|
508
506
|
|
|
509
507
|
private
|
|
510
508
|
|
|
511
|
-
# Natural size from scratch: longest line's display width plus the two
|
|
512
|
-
# single-space gutters {#pad_to_row} adds, × line count. An empty list
|
|
513
|
-
# is {Size::ZERO} (no gutters for no content).
|
|
514
|
-
# @return [Size]
|
|
515
|
-
def compute_content_size
|
|
516
|
-
content_w = @lines.map(&:display_width).max || 0
|
|
517
|
-
width = @lines.empty? ? 0 : content_w + 2
|
|
518
|
-
Size.new(width, @lines.size)
|
|
519
|
-
end
|
|
520
|
-
|
|
521
|
-
# Incremental {#content_size} update for appends: folds just the
|
|
522
|
-
# appended lines into the running maximum, keeping {#add_lines}
|
|
523
|
-
# O(appended) instead of re-scanning the whole list (LogWindow appends
|
|
524
|
-
# a line per log statement).
|
|
525
|
-
# @param appended [Array<StyledString>] the just-appended lines
|
|
526
|
-
# (already concatenated onto {@lines}).
|
|
527
|
-
# @return [void]
|
|
528
|
-
def grow_content_size(appended)
|
|
529
|
-
return if appended.empty?
|
|
530
|
-
|
|
531
|
-
appended_w = appended.map(&:display_width).max + 2
|
|
532
|
-
self.content_size = Size.new([content_size.width, appended_w].max, @lines.size)
|
|
533
|
-
end
|
|
534
|
-
|
|
535
509
|
# Coerces and flattens a list of input entries into trimmed
|
|
536
510
|
# {StyledString} lines. Each entry becomes a {StyledString} (String
|
|
537
511
|
# via {StyledString.parse}, StyledString passed through, anything else
|
|
@@ -759,15 +733,14 @@ module Tuile
|
|
|
759
733
|
# @param row_in_viewport [Integer] 0-based row within the viewport.
|
|
760
734
|
# @param scrollbar [VerticalScrollBar, nil] scrollbar instance, or nil
|
|
761
735
|
# if not shown.
|
|
762
|
-
# @return [
|
|
763
|
-
#
|
|
736
|
+
# @return [StyledString] paintable line exactly `rect.width` columns wide;
|
|
737
|
+
# highlighted if cursor is here.
|
|
764
738
|
def paintable_line(index, row_in_viewport, scrollbar)
|
|
765
739
|
base = index < @lines.size ? @padded_lines[index] : @blank_padded
|
|
766
740
|
is_cursor = (active? || @show_cursor_when_inactive) && index < @lines.size && @cursor.position == index
|
|
767
741
|
styled = is_cursor ? base.with_bg(screen.theme.active_bg_color) : base
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
out
|
|
742
|
+
styled += StyledString.plain(scrollbar.scrollbar_char(row_in_viewport)) if scrollbar
|
|
743
|
+
styled
|
|
771
744
|
end
|
|
772
745
|
end
|
|
773
746
|
end
|
|
@@ -15,11 +15,12 @@ module Tuile
|
|
|
15
15
|
# @param caption [String]
|
|
16
16
|
def initialize(caption = "Log")
|
|
17
17
|
super
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
view = Component::TextView.new
|
|
19
|
+
# Word-wrap long lines (stacktraces, wide log records) rather than
|
|
20
|
+
# ellipsizing them as a {List} would — a truncated log line hides the
|
|
21
|
+
# very detail you opened the log to read.
|
|
22
|
+
view.auto_scroll = true
|
|
23
|
+
self.content = view
|
|
23
24
|
self.scrollbar = true
|
|
24
25
|
end
|
|
25
26
|
|
|
@@ -50,11 +50,13 @@ module Tuile
|
|
|
50
50
|
# @return [Proc, nil]
|
|
51
51
|
attr_accessor :on_pick
|
|
52
52
|
|
|
53
|
+
# Handles an option-key press. Reached by bubbling: the inner {List}
|
|
54
|
+
# (the focused component) sees the key first and handles cursor/Enter
|
|
55
|
+
# picks; anything it declines bubbles up here, where a key matching an
|
|
56
|
+
# option's `key` picks that option.
|
|
53
57
|
# @param key [String]
|
|
54
58
|
# @return [Boolean]
|
|
55
59
|
def handle_key(key)
|
|
56
|
-
return true if super
|
|
57
|
-
|
|
58
60
|
if @options.any? { _1.key == key }
|
|
59
61
|
select_option(key)
|
|
60
62
|
true
|
|
@@ -2,10 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
|
-
#
|
|
6
|
-
# paints nothing — it's a transparent host that handles
|
|
7
|
-
# ({#open} / {#close} / {#open?}, ESC/q to close)
|
|
8
|
-
#
|
|
5
|
+
# An overlay that wraps any {Component} as its content. Popup itself
|
|
6
|
+
# paints nothing — it's a transparent host that handles its lifecycle
|
|
7
|
+
# ({#open} / {#close} / {#open?}, ESC/q to close) and holds a top-down
|
|
8
|
+
# {#size} the {Screen} applies.
|
|
9
|
+
#
|
|
10
|
+
# The popup does *not* size itself to its content. Its box is declared by
|
|
11
|
+
# {#size} — a {Fraction} (resolved against the screen every layout pass, so
|
|
12
|
+
# it tracks resize) or an absolute {Size} (clamped to the screen). The
|
|
13
|
+
# default is {Fraction::HALF}: half the screen, centered. The wrapped
|
|
14
|
+
# content then fills that box and handles its own overflow by wrapping and
|
|
15
|
+
# scrolling, so use content that can — a {Component::TextView} or
|
|
16
|
+
# {Component::TextArea} — for anything longer than fits. A
|
|
17
|
+
# {Component::Label} only truncates.
|
|
18
|
+
#
|
|
19
|
+
# Modal by default: it centers on the screen, grabs focus, eats keys, and
|
|
20
|
+
# blocks clicks beneath it. Pass `modal: false` for a non-modal overlay
|
|
21
|
+
# that floats above the content (still painted on top) without taking focus
|
|
22
|
+
# or capturing input — the caller positions it (via {#rect=}) and drives it
|
|
23
|
+
# from app code. That is the building block for an autocomplete/slash-command
|
|
24
|
+
# list anchored to a {Component::TextField} or {Component::TextArea} caret:
|
|
25
|
+
# typing keeps focus (and the cursor) in the input, an
|
|
26
|
+
# {Component::TextInput#on_change} listener refills the list, and an
|
|
27
|
+
# {Component::TextInput#on_key} interceptor forwards Up/Down/Enter to it.
|
|
28
|
+
# Such a caller owns the list data, so it sizes the overlay itself
|
|
29
|
+
# (`overlay.size = Size.new(longest, [items.size, 8].min)`) — still
|
|
30
|
+
# caller-decides, top-down.
|
|
9
31
|
#
|
|
10
32
|
# The wrapped content fills the popup's full {#rect}; if you want a frame
|
|
11
33
|
# and caption, wrap a {Component::Window} (or any subclass — including
|
|
@@ -26,15 +48,43 @@ module Tuile
|
|
|
26
48
|
include Component::HasContent
|
|
27
49
|
|
|
28
50
|
# @param content [Component, nil] initial content; can be set later via
|
|
29
|
-
# {#content=}.
|
|
30
|
-
|
|
51
|
+
# {#content=}. The content fills the popup's {#rect}; it does not
|
|
52
|
+
# determine the popup's size.
|
|
53
|
+
# @param modal [Boolean] true (default) for a centered, focus-grabbing,
|
|
54
|
+
# input-capturing modal; false for a non-modal overlay the caller
|
|
55
|
+
# positions and drives (see the class docs).
|
|
56
|
+
# @param size [Size, Fraction] the popup's size, applied top-down. A
|
|
57
|
+
# {Fraction} is resolved against the screen each layout pass; a {Size}
|
|
58
|
+
# is clamped to the screen. Defaults to {Fraction::HALF}.
|
|
59
|
+
def initialize(content: nil, modal: true, size: Fraction::HALF)
|
|
31
60
|
super()
|
|
61
|
+
@modal = modal
|
|
62
|
+
@size = size
|
|
32
63
|
@content = nil
|
|
33
64
|
self.content = content unless content.nil?
|
|
65
|
+
reposition
|
|
34
66
|
end
|
|
35
67
|
|
|
68
|
+
# @return [Size, Fraction] the popup's declared size. See {#size=}.
|
|
69
|
+
attr_reader :size
|
|
70
|
+
|
|
71
|
+
# @return [Boolean] whether this popup is modal. See {#initialize}.
|
|
72
|
+
def modal? = @modal
|
|
73
|
+
|
|
36
74
|
def focusable? = true
|
|
37
75
|
|
|
76
|
+
# Sets the popup's size and repositions it. Accepts a {Fraction}
|
|
77
|
+
# (resolved against the screen every layout pass, so it tracks resize) or
|
|
78
|
+
# an absolute {Size} (clamped to the screen). This is **authoritative**,
|
|
79
|
+
# not a preference: the screen applies exactly what you ask for (clamped),
|
|
80
|
+
# with no negotiation — a popup has no siblings to compete with.
|
|
81
|
+
# @param new_size [Size, Fraction]
|
|
82
|
+
# @return [void]
|
|
83
|
+
def size=(new_size)
|
|
84
|
+
@size = new_size
|
|
85
|
+
reposition
|
|
86
|
+
end
|
|
87
|
+
|
|
38
88
|
# Reassigns the popup's rect, escalating to a full scene repaint when an
|
|
39
89
|
# open popup shrinks or moves so its new rect no longer covers the cells
|
|
40
90
|
# it previously painted. A popup overdraws the scene without clipping and
|
|
@@ -50,20 +100,21 @@ module Tuile
|
|
|
50
100
|
screen.needs_full_repaint if open? && !new_rect.contains_rect?(old_rect)
|
|
51
101
|
end
|
|
52
102
|
|
|
53
|
-
# Mounts this popup on the {Screen}
|
|
54
|
-
#
|
|
55
|
-
# grown or shrunk while closed picks up the new size.
|
|
103
|
+
# Mounts this popup on the {Screen}, re-resolving its {#size} against the
|
|
104
|
+
# current screen first.
|
|
56
105
|
# @return [void]
|
|
57
106
|
def open
|
|
58
|
-
|
|
107
|
+
reposition
|
|
59
108
|
screen.add_popup(self)
|
|
60
109
|
end
|
|
61
110
|
|
|
62
111
|
# Constructs and opens a popup in one call.
|
|
63
112
|
# @param content [Component, nil]
|
|
113
|
+
# @param modal [Boolean] see {#initialize}.
|
|
114
|
+
# @param size [Size, Fraction] see {#initialize}.
|
|
64
115
|
# @return [Popup] the opened popup.
|
|
65
|
-
def self.open(content: nil)
|
|
66
|
-
Popup.new(content: content).tap(&:open)
|
|
116
|
+
def self.open(content: nil, modal: true, size: Fraction::HALF)
|
|
117
|
+
Popup.new(content: content, modal: modal, size: size).tap(&:open)
|
|
67
118
|
end
|
|
68
119
|
|
|
69
120
|
# Removes this popup from the {Screen}. No-op if not currently open.
|
|
@@ -77,33 +128,29 @@ module Tuile
|
|
|
77
128
|
screen.has_popup?(self)
|
|
78
129
|
end
|
|
79
130
|
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
131
|
+
# Re-resolves {#size} against the current screen and repositions the popup
|
|
132
|
+
# *itself* (this is not laying out content — the popup's own rect): a
|
|
133
|
+
# modal popup recenters; a non-modal overlay keeps its caller-assigned
|
|
134
|
+
# top-left (only its size follows the screen). Called on {#open}, on
|
|
135
|
+
# {#size=}, and by the screen's layout pass (so a {Fraction} size tracks
|
|
136
|
+
# SIGWINCH).
|
|
137
|
+
#
|
|
138
|
+
# The final rect is computed and assigned in one step rather than sizing
|
|
139
|
+
# at the origin and then centering: the intermediate origin rect rarely
|
|
140
|
+
# covers the previous one, which would make {#rect=}'s shrink/move
|
|
141
|
+
# detection fire a full repaint on every resize.
|
|
83
142
|
# @return [void]
|
|
84
|
-
def
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
# defaults to 12. Override in a subclass to allow taller popups.
|
|
90
|
-
def max_height = 12
|
|
91
|
-
|
|
92
|
-
# Sets the popup's content and auto-sizes the popup to fit.
|
|
93
|
-
# @param new_content [Component, nil]
|
|
94
|
-
def content=(new_content)
|
|
95
|
-
super
|
|
96
|
-
update_rect unless new_content.nil?
|
|
143
|
+
def reposition
|
|
144
|
+
size = @size.is_a?(Fraction) ? @size.resolve(screen.size) : @size.clamp(screen.size)
|
|
145
|
+
r = Rect.new(rect.left, rect.top, size.width, size.height)
|
|
146
|
+
r = r.centered(screen.size) if modal?
|
|
147
|
+
self.rect = r
|
|
97
148
|
end
|
|
98
149
|
|
|
99
|
-
#
|
|
100
|
-
# natural size changes — e.g. a {Label}'s `text=`, a {List}'s
|
|
101
|
-
# `add_line`, or a nested {Window} whose own content grew (the window
|
|
102
|
-
# recomputes its {Component#content_size} and the change bubbles here).
|
|
103
|
-
# @param _child [Component]
|
|
150
|
+
# Recenters the popup on the screen, preserving its current width/height.
|
|
104
151
|
# @return [void]
|
|
105
|
-
def
|
|
106
|
-
|
|
152
|
+
def center
|
|
153
|
+
self.rect = rect.centered(screen.size)
|
|
107
154
|
end
|
|
108
155
|
|
|
109
156
|
# Hint for the status bar: own "q Close" plus the wrapped content's hint.
|
|
@@ -114,11 +161,12 @@ module Tuile
|
|
|
114
161
|
child_hint.empty? ? prefix : "#{prefix} #{child_hint}"
|
|
115
162
|
end
|
|
116
163
|
|
|
164
|
+
# `q` and ESC close the popup. The popup sits on the focus chain of
|
|
165
|
+
# whatever it wraps, so the key reaches here by bubbling up from the
|
|
166
|
+
# focused content after that content declined to handle it.
|
|
117
167
|
# @param key [String]
|
|
118
168
|
# @return [Boolean] true if the key was handled.
|
|
119
169
|
def handle_key(key)
|
|
120
|
-
return true if super
|
|
121
|
-
|
|
122
170
|
if [Keys::ESC, "q"].include?(key)
|
|
123
171
|
close
|
|
124
172
|
true
|
|
@@ -135,24 +183,6 @@ module Tuile
|
|
|
135
183
|
def layout(content)
|
|
136
184
|
content.rect = rect
|
|
137
185
|
end
|
|
138
|
-
|
|
139
|
-
private
|
|
140
|
-
|
|
141
|
-
# Recompute width/height from {#content}'s natural size and recenter
|
|
142
|
-
# if currently open. Called whenever content is (re)assigned.
|
|
143
|
-
#
|
|
144
|
-
# Computes the final (centered) rect and assigns it in one step rather
|
|
145
|
-
# than positioning at the origin and then centering: the intermediate
|
|
146
|
-
# origin rect rarely covers the previous one, which would make
|
|
147
|
-
# {#rect=}'s shrink/move detection fire a full repaint on every resize.
|
|
148
|
-
# @return [void]
|
|
149
|
-
def update_rect
|
|
150
|
-
size = @content.content_size.clamp_height(max_height)
|
|
151
|
-
size = size.clamp(Size.new(screen.size.width * 4 / 5, screen.size.height * 4 / 5))
|
|
152
|
-
r = Rect.new(0, 0, size.width, size.height)
|
|
153
|
-
r = r.centered(screen.size) if open?
|
|
154
|
-
self.rect = r
|
|
155
|
-
end
|
|
156
186
|
end
|
|
157
187
|
end
|
|
158
188
|
end
|
|
@@ -80,7 +80,7 @@ module Tuile
|
|
|
80
80
|
chunk = @text[r[:start], r[:length]] || ""
|
|
81
81
|
chunk + (" " * (rect.width - r[:length]))
|
|
82
82
|
end
|
|
83
|
-
screen.
|
|
83
|
+
screen.buffer.set_line(rect.left, rect.top + screen_row, background(line))
|
|
84
84
|
end
|
|
85
85
|
end
|
|
86
86
|
|
|
@@ -60,7 +60,7 @@ module Tuile
|
|
|
60
60
|
return if rect.empty?
|
|
61
61
|
|
|
62
62
|
padded = @text + (" " * (rect.width - @text.length))
|
|
63
|
-
screen.
|
|
63
|
+
screen.buffer.set_line(rect.left, rect.top, background(padded))
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
protected
|
|
@@ -31,6 +31,7 @@ module Tuile
|
|
|
31
31
|
@text = +""
|
|
32
32
|
@caret = 0
|
|
33
33
|
@on_change = nil
|
|
34
|
+
@on_key = nil
|
|
34
35
|
@on_escape = method(:default_on_escape)
|
|
35
36
|
end
|
|
36
37
|
|
|
@@ -49,6 +50,20 @@ module Tuile
|
|
|
49
50
|
# @return [Proc, Method, nil] one-arg callable, or nil.
|
|
50
51
|
attr_accessor :on_change
|
|
51
52
|
|
|
53
|
+
# Optional interceptor consulted before the input's own key handling.
|
|
54
|
+
# Receives the pressed key; return a truthy value to consume it (the
|
|
55
|
+
# input then ignores that key), falsy to let normal editing proceed.
|
|
56
|
+
#
|
|
57
|
+
# The keyboard analog of {#on_change}: it lets app code layer behavior
|
|
58
|
+
# onto an input without subclassing. The motivating case is an
|
|
59
|
+
# autocomplete / slash-command overlay (a non-modal {Component::Popup}):
|
|
60
|
+
# while it is open the interceptor claims Up/Down/Enter/ESC and forwards
|
|
61
|
+
# them to the overlay's list, but lets ordinary characters fall through
|
|
62
|
+
# so typing keeps editing the field (and {#on_change} keeps refilling the
|
|
63
|
+
# list).
|
|
64
|
+
# @return [Proc, Method, nil] one-arg callable, or nil.
|
|
65
|
+
attr_accessor :on_key
|
|
66
|
+
|
|
52
67
|
# Callback fired when ESC is pressed. Defaults to a closure that clears
|
|
53
68
|
# focus (`screen.focused = nil`) so ESC visibly cancels text entry instead
|
|
54
69
|
# of bubbling to the parent — and, in particular, instead of reaching the
|
|
@@ -88,14 +103,15 @@ module Tuile
|
|
|
88
103
|
invalidate
|
|
89
104
|
end
|
|
90
105
|
|
|
91
|
-
# Handles a key.
|
|
92
|
-
#
|
|
93
|
-
#
|
|
106
|
+
# Handles a key. An {#on_key} interceptor (if set) gets first refusal —
|
|
107
|
+
# a truthy return consumes the key — otherwise it delegates to
|
|
108
|
+
# {#handle_text_input_key}. Dispatch ({ScreenPane#handle_key}) only routes
|
|
109
|
+
# keys here when this input is on the focus chain, so there is no
|
|
110
|
+
# {#active?} gate.
|
|
94
111
|
# @param key [String]
|
|
95
112
|
# @return [Boolean]
|
|
96
113
|
def handle_key(key)
|
|
97
|
-
return
|
|
98
|
-
return true if super
|
|
114
|
+
return true if @on_key&.call(key)
|
|
99
115
|
|
|
100
116
|
handle_text_input_key(key)
|
|
101
117
|
end
|
|
@@ -103,13 +119,13 @@ module Tuile
|
|
|
103
119
|
protected
|
|
104
120
|
|
|
105
121
|
# Renders `text` on the field's background well, looked up from the
|
|
106
|
-
# current {Screen#theme} at paint time: {Theme#
|
|
107
|
-
# input is on the active (focus) chain, {Theme#
|
|
122
|
+
# current {Screen#theme} at paint time: {Theme#active_bg_color} when this
|
|
123
|
+
# input is on the active (focus) chain, {Theme#input_bg_color} otherwise —
|
|
108
124
|
# visibly a field either way, distinctly highlighted when active.
|
|
109
125
|
# @param text [String]
|
|
110
|
-
# @return [
|
|
126
|
+
# @return [StyledString] text on the field's background well.
|
|
111
127
|
def background(text)
|
|
112
|
-
active? ? screen.theme.
|
|
128
|
+
StyledString.styled(text, bg: active? ? screen.theme.active_bg_color : screen.theme.input_bg_color)
|
|
113
129
|
end
|
|
114
130
|
|
|
115
131
|
# Input filter for {#text=}. Subclasses override to truncate or reject
|
|
@@ -122,7 +122,6 @@ module Tuile
|
|
|
122
122
|
rewrap
|
|
123
123
|
update_top_line_if_auto_scroll
|
|
124
124
|
invalidate
|
|
125
|
-
self.content_size = compute_content_size
|
|
126
125
|
end
|
|
127
126
|
|
|
128
127
|
# Creates a new empty {Region} at the spatial tail of the document
|
|
@@ -191,7 +190,6 @@ module Tuile
|
|
|
191
190
|
@text = nil
|
|
192
191
|
update_top_line_if_auto_scroll
|
|
193
192
|
invalidate
|
|
194
|
-
self.content_size = compute_content_size
|
|
195
193
|
end
|
|
196
194
|
|
|
197
195
|
# Verbatim append, returning `self` for chainability (`view << a << b`).
|
|
@@ -266,7 +264,6 @@ module Tuile
|
|
|
266
264
|
@top_line = top_line_max if @top_line > top_line_max
|
|
267
265
|
update_top_line_if_auto_scroll
|
|
268
266
|
invalidate
|
|
269
|
-
self.content_size = compute_content_size
|
|
270
267
|
end
|
|
271
268
|
|
|
272
269
|
# Replaces a contiguous range of hard lines with the parsed content
|
|
@@ -327,7 +324,6 @@ module Tuile
|
|
|
327
324
|
@top_line = top_line_max if @top_line > top_line_max
|
|
328
325
|
update_top_line_if_auto_scroll
|
|
329
326
|
invalidate
|
|
330
|
-
self.content_size = compute_content_size
|
|
331
327
|
end
|
|
332
328
|
|
|
333
329
|
# Inserts `str` at hard-line index `at`. Equivalent to
|
|
@@ -386,13 +382,6 @@ module Tuile
|
|
|
386
382
|
|
|
387
383
|
def tab_stop? = true
|
|
388
384
|
|
|
389
|
-
# @return [Size] longest hard-line's display width × number of hard
|
|
390
|
-
# lines. Reported on the *unwrapped* text — wrap-aware sizing would
|
|
391
|
-
# be circular (width depends on width). Empty text returns
|
|
392
|
-
# `Size.new(0, 0)`. Maintained incrementally by {#text=} and
|
|
393
|
-
# {#append}, so reads are O(1).
|
|
394
|
-
attr_reader :content_size
|
|
395
|
-
|
|
396
385
|
# @param key [String]
|
|
397
386
|
# @return [Boolean]
|
|
398
387
|
def handle_key(key)
|
|
@@ -437,7 +426,7 @@ module Tuile
|
|
|
437
426
|
end
|
|
438
427
|
(0...rect.height).each do |row|
|
|
439
428
|
line = paintable_line(row + @top_line, row, scrollbar)
|
|
440
|
-
screen.
|
|
429
|
+
screen.buffer.set_line(rect.left, rect.top + row, line)
|
|
441
430
|
end
|
|
442
431
|
end
|
|
443
432
|
|
|
@@ -544,7 +533,6 @@ module Tuile
|
|
|
544
533
|
@top_line = top_line_max if @top_line > top_line_max
|
|
545
534
|
update_top_line_if_auto_scroll
|
|
546
535
|
invalidate
|
|
547
|
-
self.content_size = compute_content_size
|
|
548
536
|
end
|
|
549
537
|
|
|
550
538
|
# Region-scoped {#replace}. Validates `range` against
|
|
@@ -570,7 +558,6 @@ module Tuile
|
|
|
570
558
|
@top_line = top_line_max if @top_line > top_line_max
|
|
571
559
|
update_top_line_if_auto_scroll
|
|
572
560
|
invalidate
|
|
573
|
-
self.content_size = compute_content_size
|
|
574
561
|
end
|
|
575
562
|
|
|
576
563
|
# Verbatim append into `region`.
|
|
@@ -610,7 +597,6 @@ module Tuile
|
|
|
610
597
|
@top_line = top_line_max if @top_line > top_line_max
|
|
611
598
|
update_top_line_if_auto_scroll
|
|
612
599
|
invalidate
|
|
613
|
-
self.content_size = compute_content_size
|
|
614
600
|
end
|
|
615
601
|
|
|
616
602
|
# Drops the last `n` hard lines from `region`'s tail via
|
|
@@ -632,7 +618,6 @@ module Tuile
|
|
|
632
618
|
@top_line = top_line_max if @top_line > top_line_max
|
|
633
619
|
update_top_line_if_auto_scroll
|
|
634
620
|
invalidate
|
|
635
|
-
self.content_size = compute_content_size
|
|
636
621
|
end
|
|
637
622
|
|
|
638
623
|
# Drops `region` from {@regions}: its hard lines are removed via
|
|
@@ -658,7 +643,6 @@ module Tuile
|
|
|
658
643
|
@top_line = top_line_max if @top_line > top_line_max
|
|
659
644
|
update_top_line_if_auto_scroll
|
|
660
645
|
invalidate
|
|
661
|
-
self.content_size = compute_content_size
|
|
662
646
|
end
|
|
663
647
|
|
|
664
648
|
# Adjusts region line counts after a {@hard_lines} splice that
|
|
@@ -832,13 +816,6 @@ module Tuile
|
|
|
832
816
|
StyledString.new(spans)
|
|
833
817
|
end
|
|
834
818
|
|
|
835
|
-
# @return [Size] {#content_size} computed from {@hard_lines}.
|
|
836
|
-
def compute_content_size
|
|
837
|
-
return Size::ZERO if @hard_lines.empty?
|
|
838
|
-
|
|
839
|
-
Size.new(@hard_lines.map(&:display_width).max || 0, @hard_lines.size)
|
|
840
|
-
end
|
|
841
|
-
|
|
842
819
|
# @return [Integer] column width available for wrapped text — viewport
|
|
843
820
|
# width minus the scrollbar gutter (when visible). `0` when {#rect}'s
|
|
844
821
|
# width is non-positive, which yields a degenerate "no wrap" result.
|
|
@@ -904,15 +881,14 @@ module Tuile
|
|
|
904
881
|
# @param index [Integer] 0-based index into `@physical_lines`.
|
|
905
882
|
# @param row_in_viewport [Integer] 0-based row within the viewport.
|
|
906
883
|
# @param scrollbar [VerticalScrollBar, nil]
|
|
907
|
-
# @return [
|
|
908
|
-
#
|
|
909
|
-
#
|
|
910
|
-
# ASCII-string concat of the scrollbar glyph when one is present.
|
|
884
|
+
# @return [StyledString] paintable line exactly `rect.width` columns wide.
|
|
885
|
+
# Body lines come pre-padded from {#rewrap}, so this reduces to a lookup
|
|
886
|
+
# plus a concat of the scrollbar glyph when one is present.
|
|
911
887
|
def paintable_line(index, row_in_viewport, scrollbar)
|
|
912
888
|
line = @physical_lines[index] || @blank_line
|
|
913
|
-
return line
|
|
889
|
+
return line unless scrollbar
|
|
914
890
|
|
|
915
|
-
line
|
|
891
|
+
line + StyledString.plain(scrollbar.scrollbar_char(row_in_viewport))
|
|
916
892
|
end
|
|
917
893
|
|
|
918
894
|
# A logical section of a {TextView}'s text — a contiguous run of
|