tuile 0.10.0 → 0.12.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 +109 -64
- data/DECISIONS.md +1299 -22
- data/README.md +18 -13
- data/TERMINOLOGY.md +61 -0
- data/book/02-repaint.md +1 -1
- data/book/03-layout.md +154 -9
- data/book/05-focus.md +2 -0
- data/book/06-theming.md +1 -1
- data/book/07-components.md +202 -37
- data/book/README.md +3 -1
- data/examples/file_commander.rb +5 -4
- data/examples/sampler.rb +320 -133
- data/ideas/arrow-key-navigation.md +205 -0
- data/ideas/new-components.md +26 -12
- data/lib/tuile/buffer.rb +7 -7
- data/lib/tuile/component/big_decimal_field.rb +199 -0
- data/lib/tuile/component/button.rb +1 -1
- data/lib/tuile/component/checkbox.rb +11 -10
- data/lib/tuile/component/checkbox_group.rb +31 -26
- data/lib/tuile/component/combo_box.rb +18 -33
- data/lib/tuile/component/float_field.rb +161 -0
- data/lib/tuile/component/info_window.rb +1 -1
- data/lib/tuile/component/label.rb +14 -14
- 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 -1
- data/lib/tuile/component/list.rb +291 -216
- data/lib/tuile/component/list_dropdown.rb +82 -24
- data/lib/tuile/component/notification.rb +317 -0
- data/lib/tuile/component/picker_window.rb +3 -3
- data/lib/tuile/component/popup.rb +8 -10
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/radio_group.rb +32 -30
- data/lib/tuile/component/select.rb +251 -0
- data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
- data/lib/tuile/component/text_area.rb +79 -273
- data/lib/tuile/component/text_field.rb +1 -1
- data/lib/tuile/component/text_view.rb +191 -177
- data/lib/tuile/component/window.rb +8 -8
- data/lib/tuile/component.rb +5 -5
- data/lib/tuile/screen.rb +1 -1
- data/lib/tuile/styled_string.rb +25 -15
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +6 -6
- data/lib/tuile.rb +4 -0
- data/sig/tuile.rbs +1670 -406
- metadata +11 -1
|
@@ -23,12 +23,13 @@ module Tuile
|
|
|
23
23
|
# Treat it as *unordered*: it iterates in toggle order, so use
|
|
24
24
|
# `cg.items & cg.value.to_a` when you need {#items} order.
|
|
25
25
|
#
|
|
26
|
-
# Composes rather than subclasses, like {ComboBox}: a {List}
|
|
27
|
-
# {HasContent} child, which is where the cursor, scrolling, the
|
|
28
|
-
# per-row mouse hit-testing come from
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
26
|
+
# Composes rather than subclasses, like {ComboBox}: a {List} of the items is
|
|
27
|
+
# its single {HasContent} child, which is where the cursor, scrolling, the
|
|
28
|
+
# scrollbar and per-row mouse hit-testing come from — the group only supplies
|
|
29
|
+
# the {List#renderer} that puts the box in front of the label. `content` is
|
|
30
|
+
# that list, so an app can tune it (`scrollbar_visibility`,
|
|
31
|
+
# `show_cursor_when_inactive`, …). Rows beyond {#rect}'s height scroll; the
|
|
32
|
+
# inner list is the tab stop, not the group.
|
|
32
33
|
#
|
|
33
34
|
# == +items+ is chrome; +value+ is authoritative
|
|
34
35
|
# {#items=} changes only what is *presented*. It never touches {#value} and
|
|
@@ -46,7 +47,7 @@ module Tuile
|
|
|
46
47
|
# mutated after being selected becomes unfindable. Two `==`-equal items also
|
|
47
48
|
# share one selection — their rows check and uncheck together — whereas two
|
|
48
49
|
# *distinct* items that merely render the same label toggle independently,
|
|
49
|
-
# because a row resolves to
|
|
50
|
+
# because a row resolves to its own item, never to its label.
|
|
50
51
|
#
|
|
51
52
|
# Rows repeat {Checkbox}'s `[x] `/`[ ] ` glyph convention rather than
|
|
52
53
|
# importing a constant from it.
|
|
@@ -67,7 +68,6 @@ module Tuile
|
|
|
67
68
|
# matter to a form helper.
|
|
68
69
|
def initialize(items: [], value: nil)
|
|
69
70
|
super()
|
|
70
|
-
@items = items.to_a
|
|
71
71
|
@item_label = :to_s.to_proc
|
|
72
72
|
@value = coerce(value)
|
|
73
73
|
@on_value_change = nil
|
|
@@ -75,13 +75,14 @@ module Tuile
|
|
|
75
75
|
list = List.new
|
|
76
76
|
# A List has no cursor at all by default (Cursor::None, position -1).
|
|
77
77
|
list.cursor = List::Cursor.new
|
|
78
|
-
list.
|
|
78
|
+
list.renderer = method(:render_row)
|
|
79
|
+
list.on_item_chosen = ->(_index, item) { toggle(item) }
|
|
80
|
+
list.items = items.to_a
|
|
79
81
|
self.content = list
|
|
80
|
-
rebuild_rows
|
|
81
82
|
end
|
|
82
83
|
|
|
83
84
|
# @return [Array] the presented items.
|
|
84
|
-
|
|
85
|
+
def items = content.items
|
|
85
86
|
|
|
86
87
|
# @return [Proc, Method] item -> row label (a `String`, {StyledString}, or
|
|
87
88
|
# anything with `#to_s`); `:to_s` by default.
|
|
@@ -92,17 +93,14 @@ module Tuile
|
|
|
92
93
|
# @raise [TypeError] unless `new_items` is an `Array`.
|
|
93
94
|
# @return [void]
|
|
94
95
|
def items=(new_items)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
@items = new_items
|
|
98
|
-
rebuild_rows
|
|
96
|
+
content.items = new_items
|
|
99
97
|
end
|
|
100
98
|
|
|
101
99
|
# @param proc [Proc, Method] item -> row label.
|
|
102
100
|
# @return [void]
|
|
103
101
|
def item_label=(proc)
|
|
104
102
|
@item_label = proc
|
|
105
|
-
|
|
103
|
+
content.refresh_rows
|
|
106
104
|
end
|
|
107
105
|
|
|
108
106
|
# @return [Set] the frozen empty set — {HasValue#empty?} means nothing is
|
|
@@ -122,7 +120,7 @@ module Tuile
|
|
|
122
120
|
return if value == selected
|
|
123
121
|
|
|
124
122
|
super(selected)
|
|
125
|
-
|
|
123
|
+
content.refresh_rows
|
|
126
124
|
end
|
|
127
125
|
|
|
128
126
|
# Toggles the cursor row on Space. Nothing else is claimed: the composed
|
|
@@ -148,22 +146,29 @@ module Tuile
|
|
|
148
146
|
private
|
|
149
147
|
|
|
150
148
|
# Flips membership of the item on row `index`; an index outside {#items} is
|
|
151
|
-
# ignored
|
|
149
|
+
# ignored — {List::Cursor::None}'s `-1` would otherwise toggle the *last*
|
|
150
|
+
# item.
|
|
152
151
|
# @param index [Integer]
|
|
153
152
|
# @return [void]
|
|
154
153
|
def toggle_at(index)
|
|
155
|
-
return unless index.between?(0,
|
|
154
|
+
return unless index.between?(0, items.size - 1)
|
|
156
155
|
|
|
157
|
-
|
|
158
|
-
self.value = value.include?(item) ? value - [item] : value + [item]
|
|
156
|
+
toggle(items[index])
|
|
159
157
|
end
|
|
160
158
|
|
|
161
|
-
#
|
|
159
|
+
# Flips `item`'s membership of {#value}.
|
|
160
|
+
# @param item [Object]
|
|
162
161
|
# @return [void]
|
|
163
|
-
def
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
162
|
+
def toggle(item)
|
|
163
|
+
self.value = value.include?(item) ? value - [item] : value + [item]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# @param item [Object]
|
|
167
|
+
# @return [StyledString] the item's row: its label behind a checkmark box.
|
|
168
|
+
# The {List} calls this at paint time, so the boxes track {#value}
|
|
169
|
+
# without re-rendering anything but the visible rows.
|
|
170
|
+
def render_row(item)
|
|
171
|
+
StyledString.plain(value.include?(item) ? "[x] " : "[ ] ") + label_for(item)
|
|
167
172
|
end
|
|
168
173
|
|
|
169
174
|
# @param new_value [Enumerable, nil]
|
|
@@ -52,7 +52,8 @@ module Tuile
|
|
|
52
52
|
self.content = field
|
|
53
53
|
|
|
54
54
|
@overlay = ListDropdown.new
|
|
55
|
-
@overlay.
|
|
55
|
+
@overlay.renderer = ->(item) { @item_label.call(item) }
|
|
56
|
+
@overlay.on_item_chosen = ->(_index, item) { commit(item) }
|
|
56
57
|
end
|
|
57
58
|
|
|
58
59
|
# @return [Array] the candidate items.
|
|
@@ -147,11 +148,12 @@ module Tuile
|
|
|
147
148
|
protected
|
|
148
149
|
|
|
149
150
|
# Field spans the row bar the last column, which the `▾` occupies
|
|
150
|
-
# ({HasContent} layout hook).
|
|
151
|
+
# ({HasContent} layout hook). One row, or none at all when the combo itself
|
|
152
|
+
# was given none — a starved parent must not hand out a rect it doesn't own.
|
|
151
153
|
# @param field [Component]
|
|
152
154
|
# @return [void]
|
|
153
155
|
def layout(field)
|
|
154
|
-
field.rect = Rect.new(rect.left, rect.top, [rect.width - 1, 0].max, 1)
|
|
156
|
+
field.rect = Rect.new(rect.left, rect.top, [rect.width - 1, 0].max, [rect.height, 1].min)
|
|
155
157
|
end
|
|
156
158
|
|
|
157
159
|
private
|
|
@@ -194,7 +196,7 @@ module Tuile
|
|
|
194
196
|
if @filtered.empty?
|
|
195
197
|
close_menu
|
|
196
198
|
else
|
|
197
|
-
@overlay.
|
|
199
|
+
@overlay.items = @filtered
|
|
198
200
|
@overlay.cursor = List::Cursor.new(position: @filtered.index(value) || 0)
|
|
199
201
|
@overlay.open unless @overlay.open?
|
|
200
202
|
anchor
|
|
@@ -213,12 +215,11 @@ module Tuile
|
|
|
213
215
|
@items.select { |item| @item_label.call(item).to_s.downcase.include?(needle) }
|
|
214
216
|
end
|
|
215
217
|
|
|
216
|
-
# Commits the item
|
|
217
|
-
#
|
|
218
|
-
# @param
|
|
218
|
+
# Commits the item chosen from the menu: closes the dropdown and adopts it
|
|
219
|
+
# as {#value} (which repaints the field with its label).
|
|
220
|
+
# @param item [Object]
|
|
219
221
|
# @return [void]
|
|
220
|
-
def commit(
|
|
221
|
-
item = @filtered[index]
|
|
222
|
+
def commit(item)
|
|
222
223
|
close_menu
|
|
223
224
|
self.value = item
|
|
224
225
|
end
|
|
@@ -234,6 +235,9 @@ module Tuile
|
|
|
234
235
|
|
|
235
236
|
# Sets the field's text without triggering a refilter — for programmatic
|
|
236
237
|
# value changes and query reverts, which must not spring the dropdown.
|
|
238
|
+
# Every programmatic write to the field goes through here; a direct
|
|
239
|
+
# `content.text =` reaches the field's `on_change` and pops the dropdown
|
|
240
|
+
# open on a {#value=} the user never asked to browse.
|
|
237
241
|
# Parks the caret at the end: `text=` only *clamps* the caret, so a
|
|
238
242
|
# shorter query replaced by a longer label would otherwise strand it
|
|
239
243
|
# mid-word (commit "Go", then pick "Kotlin" → caret after "Ko").
|
|
@@ -251,31 +255,12 @@ module Tuile
|
|
|
251
255
|
# @return [String] the plain-text label for `item`, or "" for nil.
|
|
252
256
|
def display_for(item) = item.nil? ? "" : @item_label.call(item).to_s
|
|
253
257
|
|
|
254
|
-
#
|
|
255
|
-
#
|
|
256
|
-
#
|
|
258
|
+
# Places the dropdown at the combo's own width, so both its edges line up
|
|
259
|
+
# with the field — at the cost of the scrollbar taking its column from the
|
|
260
|
+
# labels, which ellipsize a column earlier once the list scrolls. That is
|
|
261
|
+
# the trade a measuring driver ({Select}) makes the other way.
|
|
257
262
|
# @return [void]
|
|
258
|
-
def anchor
|
|
259
|
-
desired = [@filtered.size, MAX_VISIBLE_ROWS].min
|
|
260
|
-
below = screen.size.height - (rect.top + 1)
|
|
261
|
-
above = rect.top
|
|
262
|
-
if desired <= below
|
|
263
|
-
top = rect.top + 1
|
|
264
|
-
height = desired
|
|
265
|
-
elsif above >= below
|
|
266
|
-
height = [desired, above].min
|
|
267
|
-
top = rect.top - height
|
|
268
|
-
else
|
|
269
|
-
height = below
|
|
270
|
-
top = rect.top + 1
|
|
271
|
-
end
|
|
272
|
-
@overlay.size = Size.new(rect.width, height)
|
|
273
|
-
@overlay.rect = Rect.new(rect.left, top, rect.width, height)
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
# Most matches shown before the dropdown scrolls.
|
|
277
|
-
# @return [Integer]
|
|
278
|
-
MAX_VISIBLE_ROWS = 10
|
|
263
|
+
def anchor = @overlay.anchor_to(rect, rows: @filtered.size)
|
|
279
264
|
end
|
|
280
265
|
end
|
|
281
266
|
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A single-line field whose {#value} is a `Float` (or `nil` when empty) —
|
|
6
|
+
# the {IntegerField} twin, one Ruby type over. Give it a single-row {#rect}:
|
|
7
|
+
#
|
|
8
|
+
# field = Component::FloatField.new
|
|
9
|
+
# field.on_value_change = ->(x) { puts x.inspect } # Float or nil, per change
|
|
10
|
+
# field.value = 19.99 # field shows "19.99"
|
|
11
|
+
# field.clear # empties it; value => nil
|
|
12
|
+
#
|
|
13
|
+
# Only `0`–`9`, one leading `-` and one `.` can be typed; any other
|
|
14
|
+
# printable key is dropped without moving the caret. Up/Down step by `1.0`
|
|
15
|
+
# (an empty field counting as `0.0`). A `Float` is a binary double, so this
|
|
16
|
+
# is the wrong field for money — hold that as `Integer` cents in an
|
|
17
|
+
# {IntegerField} — and range checks (`min`/`max`) belong to a forms layer,
|
|
18
|
+
# not here.
|
|
19
|
+
#
|
|
20
|
+
# == Implementation details
|
|
21
|
+
# {#value} is a *derived parse*: the buffer is the single source of truth,
|
|
22
|
+
# recomputed on read and left exactly as typed (`"007"` keeps its zeros).
|
|
23
|
+
# It reads `nil` for a buffer that isn't a number (`""`, a lone `"-"`) but
|
|
24
|
+
# `1.0` / `0.5` for a half-typed `"1."` / `".5"`, so reaching for the
|
|
25
|
+
# decimal point doesn't blink the value to `nil` and back through
|
|
26
|
+
# {#on_value_change} — which fires per keystroke, but only on a real *value*
|
|
27
|
+
# change (`"7"`→`"07"` is silent). The parse also accepts the exponent
|
|
28
|
+
# `Float#to_s` writes for extreme magnitudes, so `value = 1e-5` round-trips
|
|
29
|
+
# through the `"1.0e-05"` it displays, though no key types an `e`.
|
|
30
|
+
#
|
|
31
|
+
# It *composes* a {TextField} (its single {HasContent} child) rather than
|
|
32
|
+
# subclassing one, so its face carries only the typed {HasValue} seam, never
|
|
33
|
+
# the widget's `String`-typed `text`.
|
|
34
|
+
#
|
|
35
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
36
|
+
class FloatField < Component
|
|
37
|
+
include HasContent
|
|
38
|
+
include HasValue
|
|
39
|
+
|
|
40
|
+
# A buffer {#value} parses: an optional sign, digits with an optional
|
|
41
|
+
# fractional part (either side may be empty, but not both), and the
|
|
42
|
+
# exponent {#value=} can write.
|
|
43
|
+
# @return [Regexp]
|
|
44
|
+
NUMERIC = /\A-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?\z/
|
|
45
|
+
private_constant :NUMERIC
|
|
46
|
+
|
|
47
|
+
def initialize
|
|
48
|
+
super()
|
|
49
|
+
@last_value = nil
|
|
50
|
+
field = TextField.new
|
|
51
|
+
field.on_change = ->(_text) { fire_if_changed }
|
|
52
|
+
field.on_key = method(:field_key)
|
|
53
|
+
self.content = field
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Float, nil] the parsed buffer; `nil` when empty or not a
|
|
57
|
+
# number (e.g. a lone `"-"`).
|
|
58
|
+
def value
|
|
59
|
+
text = content.text
|
|
60
|
+
text.match?(NUMERIC) ? text.to_f : nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Writes `new_value` into the buffer and parks the caret at its end; fires
|
|
64
|
+
# {#on_value_change} only if the value actually changed.
|
|
65
|
+
# @param new_value [Numeric, nil] `nil` empties the field; anything else
|
|
66
|
+
# is coerced with `Float()`, so an `Integer` `3` shows as `"3.0"`.
|
|
67
|
+
# @raise [ArgumentError] on a non-numeric `String`, a NaN or an infinity.
|
|
68
|
+
# @raise [TypeError] on a value `Float()` won't take at all (an `Array`).
|
|
69
|
+
# @return [void]
|
|
70
|
+
def value=(new_value)
|
|
71
|
+
content.text = new_value.nil? ? "" : coerce(new_value).to_s
|
|
72
|
+
content.caret = content.text.length
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# `nil`, not `""`: a numeric field with no parseable number is empty.
|
|
76
|
+
# @return [nil]
|
|
77
|
+
def empty_value = nil
|
|
78
|
+
|
|
79
|
+
# @return [Point, nil] the field's caret (the hardware cursor is delegated
|
|
80
|
+
# to the inner field).
|
|
81
|
+
def cursor_position = content.cursor_position
|
|
82
|
+
|
|
83
|
+
# Fired when ENTER is pressed in the field; see {TextField#on_enter}.
|
|
84
|
+
# @return [Proc, Method, nil] no-arg callable, or nil.
|
|
85
|
+
def on_enter = content.on_enter
|
|
86
|
+
|
|
87
|
+
# @param callback [Proc, Method, nil]
|
|
88
|
+
# @return [void]
|
|
89
|
+
def on_enter=(callback)
|
|
90
|
+
content.on_enter = callback
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
protected
|
|
94
|
+
|
|
95
|
+
# Places the wrapped field across the whole rect ({HasContent} hook).
|
|
96
|
+
# @param field [Component]
|
|
97
|
+
# @return [void]
|
|
98
|
+
def layout(field) = (field.rect = rect)
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# @param new_value [Numeric]
|
|
103
|
+
# @return [Float]
|
|
104
|
+
# @raise [ArgumentError] on a NaN or an infinity — `Float::NAN.to_s` is
|
|
105
|
+
# `"NaN"`, which no parse reads back, so writing one would silently
|
|
106
|
+
# turn the value `nil`.
|
|
107
|
+
def coerce(new_value)
|
|
108
|
+
float = Float(new_value)
|
|
109
|
+
raise ArgumentError, "value must be finite, got #{float}" unless float.finite?
|
|
110
|
+
|
|
111
|
+
float
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The field's key interceptor, consulted *before* the field acts on the
|
|
115
|
+
# key — which is what lets a rejected character be swallowed without the
|
|
116
|
+
# caret ever moving.
|
|
117
|
+
# @param key [String]
|
|
118
|
+
# @return [Boolean] true to consume the key.
|
|
119
|
+
def field_key(key)
|
|
120
|
+
case key
|
|
121
|
+
when Keys::UP_ARROW then step(1.0)
|
|
122
|
+
when Keys::DOWN_ARROW then step(-1.0)
|
|
123
|
+
else return Keys.printable?(key) && !accepts?(key)
|
|
124
|
+
end
|
|
125
|
+
true
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Nudges {#value} by `delta`, treating an empty/un-parseable field as
|
|
129
|
+
# `0.0`.
|
|
130
|
+
# @param delta [Float]
|
|
131
|
+
# @return [void]
|
|
132
|
+
def step(delta) = (self.value = (value || 0.0) + delta)
|
|
133
|
+
|
|
134
|
+
# Whether `char` may be inserted. Deliberately shallow: it keeps the
|
|
135
|
+
# buffer *typeable* rather than always-valid — a transient `"-"` or
|
|
136
|
+
# `"1."` has to be reachable — and {#value} decides what parses.
|
|
137
|
+
# @param char [String] a single printable character.
|
|
138
|
+
# @return [Boolean]
|
|
139
|
+
def accepts?(char)
|
|
140
|
+
case char
|
|
141
|
+
when /\A[0-9]\z/ then true
|
|
142
|
+
when "-" then content.caret.zero? && !content.text.start_with?("-")
|
|
143
|
+
when "." then !content.text.include?(".")
|
|
144
|
+
else false
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Re-emits {#on_value_change} with the freshly-parsed {#value}, but only
|
|
149
|
+
# when it differs from the last one fired — so a buffer edit that leaves
|
|
150
|
+
# the value unchanged (`"7"`→`"07"`) stays silent.
|
|
151
|
+
# @return [void]
|
|
152
|
+
def fire_if_changed
|
|
153
|
+
v = value
|
|
154
|
+
return if v == @last_value
|
|
155
|
+
|
|
156
|
+
@last_value = v
|
|
157
|
+
on_value_change&.call(v)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -25,7 +25,7 @@ module Tuile
|
|
|
25
25
|
# list wraps and scrolls within it. Defaults to {Fraction::HALF}.
|
|
26
26
|
# @return [Popup] the opened popup.
|
|
27
27
|
def self.open(caption, lines, size: Fraction::HALF)
|
|
28
|
-
Popup.
|
|
28
|
+
Popup.new(content: InfoWindow.new(caption, lines), size: size).open
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
end
|
|
@@ -16,8 +16,8 @@ module Tuile
|
|
|
16
16
|
super()
|
|
17
17
|
@text = StyledString::EMPTY
|
|
18
18
|
@bg = nil
|
|
19
|
-
@
|
|
20
|
-
@
|
|
19
|
+
@rows = []
|
|
20
|
+
@blank_row = ""
|
|
21
21
|
self.text = text unless text.nil?
|
|
22
22
|
end
|
|
23
23
|
|
|
@@ -43,7 +43,7 @@ module Tuile
|
|
|
43
43
|
return if @text == new_text
|
|
44
44
|
|
|
45
45
|
@text = new_text
|
|
46
|
-
|
|
46
|
+
update_rows
|
|
47
47
|
invalidate
|
|
48
48
|
end
|
|
49
49
|
|
|
@@ -60,7 +60,7 @@ module Tuile
|
|
|
60
60
|
return if @bg == new_bg
|
|
61
61
|
|
|
62
62
|
@bg = new_bg
|
|
63
|
-
|
|
63
|
+
update_rows
|
|
64
64
|
invalidate
|
|
65
65
|
end
|
|
66
66
|
|
|
@@ -69,15 +69,15 @@ module Tuile
|
|
|
69
69
|
# Skips the {Component#repaint} default's auto-clear: every row is
|
|
70
70
|
# painted explicitly (with pre-padded blanks past the last line), so
|
|
71
71
|
# the "fully draw over your rect" contract is met without an upfront
|
|
72
|
-
# wipe. Rows go through {Component#
|
|
72
|
+
# wipe. Rows go through {Component#draw_text}, so the padding and blank
|
|
73
73
|
# rows inherit {Component#effective_bg_color} when {#bg} is unset.
|
|
74
74
|
# @return [void]
|
|
75
75
|
def repaint
|
|
76
76
|
return if rect.empty?
|
|
77
77
|
|
|
78
78
|
(0...rect.height).each do |row|
|
|
79
|
-
line = @
|
|
80
|
-
|
|
79
|
+
line = @rows[row] || @blank_row
|
|
80
|
+
draw_text(rect.left, rect.top + row, line)
|
|
81
81
|
end
|
|
82
82
|
end
|
|
83
83
|
|
|
@@ -86,22 +86,22 @@ module Tuile
|
|
|
86
86
|
# @return [void]
|
|
87
87
|
def on_width_changed
|
|
88
88
|
super
|
|
89
|
-
|
|
89
|
+
update_rows
|
|
90
90
|
end
|
|
91
91
|
|
|
92
92
|
private
|
|
93
93
|
|
|
94
|
-
# Recomputes {@
|
|
94
|
+
# Recomputes {@rows} for the current text and rect width.
|
|
95
95
|
# Each line is ellipsized to fit and padded with trailing spaces out to
|
|
96
|
-
# the full width, so {#repaint} is just a lookup + {Buffer#
|
|
97
|
-
# row. {@
|
|
96
|
+
# the full width, so {#repaint} is just a lookup + {Buffer#set_text} per
|
|
97
|
+
# row. {@blank_row} covers rows past the last text line. When {#bg} is
|
|
98
98
|
# set, every produced line (and the blank row) has the bg applied
|
|
99
99
|
# uniformly.
|
|
100
100
|
# @return [void]
|
|
101
|
-
def
|
|
101
|
+
def update_rows
|
|
102
102
|
width = rect.width.clamp(0, nil)
|
|
103
|
-
@
|
|
104
|
-
@
|
|
103
|
+
@blank_row = apply_bg(StyledString.plain(" " * width))
|
|
104
|
+
@rows = @text.lines.map { |line| apply_bg(pad_to(line.ellipsize(width), width)) }
|
|
105
105
|
end
|
|
106
106
|
|
|
107
107
|
# @param line [StyledString]
|