tuile 0.9.0 → 0.10.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 +32 -0
- data/DECISIONS.md +1961 -0
- data/README.md +31 -24
- data/book/04-event-loop.md +86 -0
- data/book/05-focus.md +82 -51
- data/book/06-theming.md +53 -1
- data/book/07-components.md +363 -9
- data/book/08-testing.md +21 -8
- data/book/09-styled-text.md +132 -0
- data/book/README.md +19 -13
- data/examples/sampler.rb +435 -20
- data/ideas/new-components.md +109 -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/button.rb +25 -21
- data/lib/tuile/component/checkbox.rb +133 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +281 -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.rb +3 -17
- data/lib/tuile/component/list.rb +8 -7
- data/lib/tuile/component/list_dropdown.rb +106 -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/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 +112 -83
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2043 -614
- metadata +18 -7
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A boolean input on one row. Space or a left click toggles it:
|
|
6
|
+
#
|
|
7
|
+
# [x] Enable syslog forwarding
|
|
8
|
+
# [ ] Enable syslog forwarding
|
|
9
|
+
#
|
|
10
|
+
# cb = Component::Checkbox.new("Enable syslog forwarding", value: true)
|
|
11
|
+
# cb.on_value_change = ->(on) { config.syslog = on }
|
|
12
|
+
# cb.toggle # unchecks it, firing the listener with false
|
|
13
|
+
# cb.checked? # => false
|
|
14
|
+
#
|
|
15
|
+
# {#value} is the canonical seam ({HasValue}), always `true`/`false` and
|
|
16
|
+
# never `nil`; {#checked?} / {#checked=} / {#toggle} are the domain-word face
|
|
17
|
+
# over it — one piece of state, four names. Unchecked is the
|
|
18
|
+
# {#empty_value}, so a fresh checkbox is {HasValue#empty? empty} and
|
|
19
|
+
# {HasValue#clear} unchecks.
|
|
20
|
+
#
|
|
21
|
+
# Space toggles. Enter is unhandled — unlike {Button} — simply because a
|
|
22
|
+
# checkbox has no default action to confirm, so it bubbles to an ancestor;
|
|
23
|
+
# treat that as this widget declining a key, not as a guarantee the framework
|
|
24
|
+
# makes (a {TextArea} claims Enter for newline, and a checkable row in a
|
|
25
|
+
# {Component::List} toggles on it).
|
|
26
|
+
#
|
|
27
|
+
# A tab stop, so Tab lands on it, and the widget highlights while on the focus
|
|
28
|
+
# chain. Assign a {#rect} (typically from the surrounding {Layout}) at least
|
|
29
|
+
# `caption.display_width + 4` wide; a narrower one ellipsizes the caption, a
|
|
30
|
+
# wider one leaves a dead tail — see {#extent}.
|
|
31
|
+
#
|
|
32
|
+
# == Implementation details
|
|
33
|
+
# The glyphs are a house convention rather than constants: three columns plus
|
|
34
|
+
# a trailing space (`[x] `, `[ ] `), ASCII because `☑`/`☐` are absent from
|
|
35
|
+
# most monospace fonts and the fallback glyph bleeds over its cell. A widget
|
|
36
|
+
# painting checkbox-like rows without instantiating a Checkbox — checkable
|
|
37
|
+
# rows in a {Component::List} — repeats those literals to match.
|
|
38
|
+
class Checkbox < Component
|
|
39
|
+
include Component::HasValue
|
|
40
|
+
include Component::HasCaption
|
|
41
|
+
|
|
42
|
+
# @param caption [String, StyledString, nil] the label, coerced as
|
|
43
|
+
# {HasCaption#caption=} coerces it.
|
|
44
|
+
# @param value [Boolean] initial state. Assigned through {#value=}, which
|
|
45
|
+
# also seeds the backing ivar — an unseeded checkbox would read `nil` and
|
|
46
|
+
# so report itself non-{HasValue#empty? empty} while fresh.
|
|
47
|
+
def initialize(caption = nil, value: false)
|
|
48
|
+
super()
|
|
49
|
+
self.caption = caption
|
|
50
|
+
self.value = value
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def tab_stop? = true
|
|
54
|
+
|
|
55
|
+
# @return [Boolean] `false` — {HasValue#empty?} means unchecked.
|
|
56
|
+
def empty_value = false
|
|
57
|
+
|
|
58
|
+
# Coerces to `true`/`false` before storing, so the two-state invariant holds
|
|
59
|
+
# whatever a caller assigns — and `cb.value = nil` on a fresh checkbox is
|
|
60
|
+
# the no-op it looks like rather than a spurious change event.
|
|
61
|
+
# @param new_value [Object] anything; truthiness decides.
|
|
62
|
+
# @return [void]
|
|
63
|
+
def value=(new_value)
|
|
64
|
+
super(new_value ? true : false)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Boolean] {#value} under its domain word — `license.checked?`
|
|
68
|
+
# reads better than `license.value`. Not a second piece of state.
|
|
69
|
+
def checked? = value
|
|
70
|
+
|
|
71
|
+
# {#value=} under its domain word. A delegator rather than an `alias`, so it
|
|
72
|
+
# keeps routing through the one write path even if a subclass overrides
|
|
73
|
+
# {#value=} (an `alias` would freeze this onto the body defined here).
|
|
74
|
+
# @param new_value [Object] anything; truthiness decides.
|
|
75
|
+
# @return [void]
|
|
76
|
+
def checked=(new_value)
|
|
77
|
+
self.value = new_value
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Flips {#value}.
|
|
81
|
+
# @return [void]
|
|
82
|
+
def toggle = (self.value = !value)
|
|
83
|
+
|
|
84
|
+
# The cells the widget actually paints: one row, `caption.display_width + 4`
|
|
85
|
+
# columns, clipped to {#rect}. A form column routinely hands a checkbox a
|
|
86
|
+
# 40-column rect for a 22-column `[ ] Enable syslog forwarding` — the extent
|
|
87
|
+
# is those 22 columns.
|
|
88
|
+
#
|
|
89
|
+
# Both the focus highlight and the click hit test use it, so a click on the
|
|
90
|
+
# blank tail — or on a lower row, when the rect is taller than one — does
|
|
91
|
+
# not toggle. It still *focuses*: {Component#handle_mouse}'s click-to-focus
|
|
92
|
+
# is ungated by geometry, and the tail is the field's own row.
|
|
93
|
+
#
|
|
94
|
+
# The extent ignores {Component#bg_color}: an inherited tint paints the dead
|
|
95
|
+
# tail, but a hit test that silently widened with a background would be a
|
|
96
|
+
# mode switch invisible in the code and untestable by inspection.
|
|
97
|
+
# @return [Rect]
|
|
98
|
+
def extent = Rect.new(rect.left, rect.top, [caption.display_width + 4, rect.width].min, 1)
|
|
99
|
+
|
|
100
|
+
# Toggles on Space. Every other key — Enter included — is left unhandled so
|
|
101
|
+
# it bubbles to an ancestor.
|
|
102
|
+
# @param key [String]
|
|
103
|
+
# @return [Boolean]
|
|
104
|
+
def handle_key(key)
|
|
105
|
+
return false unless key == " "
|
|
106
|
+
|
|
107
|
+
toggle
|
|
108
|
+
true
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Toggles on a left click within {#extent}; `super` runs first, so a click
|
|
112
|
+
# anywhere in {#rect} still focuses.
|
|
113
|
+
# @param event [MouseEvent]
|
|
114
|
+
# @return [void]
|
|
115
|
+
def handle_mouse(event)
|
|
116
|
+
super
|
|
117
|
+
return unless event.button == :left && extent.contains?(event.point)
|
|
118
|
+
|
|
119
|
+
toggle
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [void]
|
|
123
|
+
def repaint
|
|
124
|
+
super
|
|
125
|
+
return if rect.empty?
|
|
126
|
+
|
|
127
|
+
label = (StyledString.plain(value ? "[x] " : "[ ] ") + caption).ellipsize(rect.width)
|
|
128
|
+
label = label.with_bg(screen.theme.active_bg_color) if active?
|
|
129
|
+
draw_line(rect.left, rect.top, label)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# Multi-select from a set of typed items, one checkable row each. Arrows move
|
|
6
|
+
# a cursor; Space, Enter or a left click toggles the row under it:
|
|
7
|
+
#
|
|
8
|
+
# [x] Errors
|
|
9
|
+
# [ ] Warnings <- cursor row, highlighted across the full width
|
|
10
|
+
# [x] Info
|
|
11
|
+
# ^ the composed {List}'s one-column gutter
|
|
12
|
+
#
|
|
13
|
+
# cg = Component::CheckboxGroup.new(items: %w[Errors Warnings Info])
|
|
14
|
+
# cg.value = %w[Errors Info] # any Enumerable, stored as a Set
|
|
15
|
+
# cg.on_value_change = ->(set) { filter(set) } # once per toggle
|
|
16
|
+
# cg.value # => #<Set: {"Errors", "Info"}>
|
|
17
|
+
# cg.item_label = ->(level) { level.name } # default :to_s
|
|
18
|
+
#
|
|
19
|
+
# {#value} is a **frozen `Set` of the selected items themselves** — of
|
|
20
|
+
# whatever type {#items} holds, never their labels. Frozen so `cg.value <<
|
|
21
|
+
# item` fails loudly rather than mutating the selection behind
|
|
22
|
+
# {HasValue#on_value_change}'s back; assign a new set or an `Array` instead.
|
|
23
|
+
# Treat it as *unordered*: it iterates in toggle order, so use
|
|
24
|
+
# `cg.items & cg.value.to_a` when you need {#items} order.
|
|
25
|
+
#
|
|
26
|
+
# Composes rather than subclasses, like {ComboBox}: a {List} is its single
|
|
27
|
+
# {HasContent} child, which is where the cursor, scrolling, the scrollbar and
|
|
28
|
+
# per-row mouse hit-testing come from. `content` is that list, so an app can
|
|
29
|
+
# tune it (`scrollbar_visibility`, `show_cursor_when_inactive`, …). Rows
|
|
30
|
+
# beyond {#rect}'s height scroll; the inner list is the tab stop, not the
|
|
31
|
+
# group.
|
|
32
|
+
#
|
|
33
|
+
# == +items+ is chrome; +value+ is authoritative
|
|
34
|
+
# {#items=} changes only what is *presented*. It never touches {#value} and
|
|
35
|
+
# never fires {HasValue#on_value_change}, and a selected item absent from
|
|
36
|
+
# {#items} renders no checked row while surviving intact — so a form saved
|
|
37
|
+
# without the user editing anything changes nothing silently. Keeping the two
|
|
38
|
+
# in sync is the app's job: `cg.value &= cg.items.to_set` reconciles them.
|
|
39
|
+
# Same contract as {ComboBox#value}, one item at a time.
|
|
40
|
+
#
|
|
41
|
+
# There is no select-all — neither a key nor a header row. An app that wants
|
|
42
|
+
# one writes `cg.value = cg.items` behind its own affordance.
|
|
43
|
+
#
|
|
44
|
+
# == Implementation details
|
|
45
|
+
# Items need stable `#hash`/`#eql?`, since the selection is a `Set`: an item
|
|
46
|
+
# mutated after being selected becomes unfindable. Two `==`-equal items also
|
|
47
|
+
# share one selection — their rows check and uncheck together — whereas two
|
|
48
|
+
# *distinct* items that merely render the same label toggle independently,
|
|
49
|
+
# because a row resolves to an item by index.
|
|
50
|
+
#
|
|
51
|
+
# Rows repeat {Checkbox}'s `[x] `/`[ ] ` glyph convention rather than
|
|
52
|
+
# importing a constant from it.
|
|
53
|
+
#
|
|
54
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
55
|
+
class CheckboxGroup < Component
|
|
56
|
+
include HasContent
|
|
57
|
+
include HasValue
|
|
58
|
+
|
|
59
|
+
# @return [Set]
|
|
60
|
+
EMPTY_SELECTION = Set.new.freeze
|
|
61
|
+
private_constant :EMPTY_SELECTION
|
|
62
|
+
|
|
63
|
+
# @param items [Array] the items to present, one row each; also settable
|
|
64
|
+
# via {#items=}.
|
|
65
|
+
# @param value [Enumerable, nil] the initial selection. Seeds the backing
|
|
66
|
+
# ivar directly, so no listener fires and assignment order doesn't
|
|
67
|
+
# matter to a form helper.
|
|
68
|
+
def initialize(items: [], value: nil)
|
|
69
|
+
super()
|
|
70
|
+
@items = items.to_a
|
|
71
|
+
@item_label = :to_s.to_proc
|
|
72
|
+
@value = coerce(value)
|
|
73
|
+
@on_value_change = nil
|
|
74
|
+
|
|
75
|
+
list = List.new
|
|
76
|
+
# A List has no cursor at all by default (Cursor::None, position -1).
|
|
77
|
+
list.cursor = List::Cursor.new
|
|
78
|
+
list.on_item_chosen = ->(index, _line) { toggle_at(index) }
|
|
79
|
+
self.content = list
|
|
80
|
+
rebuild_rows
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Array] the presented items.
|
|
84
|
+
attr_reader :items
|
|
85
|
+
|
|
86
|
+
# @return [Proc, Method] item -> row label (a `String`, {StyledString}, or
|
|
87
|
+
# anything with `#to_s`); `:to_s` by default.
|
|
88
|
+
attr_reader :item_label
|
|
89
|
+
|
|
90
|
+
# Replaces the presented rows, leaving {#value} untouched.
|
|
91
|
+
# @param new_items [Array]
|
|
92
|
+
# @raise [TypeError] unless `new_items` is an `Array`.
|
|
93
|
+
# @return [void]
|
|
94
|
+
def items=(new_items)
|
|
95
|
+
raise TypeError, "expected Array, got #{new_items.inspect}" unless new_items.is_a?(Array)
|
|
96
|
+
|
|
97
|
+
@items = new_items
|
|
98
|
+
rebuild_rows
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @param proc [Proc, Method] item -> row label.
|
|
102
|
+
# @return [void]
|
|
103
|
+
def item_label=(proc)
|
|
104
|
+
@item_label = proc
|
|
105
|
+
rebuild_rows
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Set] the frozen empty set — {HasValue#empty?} means nothing is
|
|
109
|
+
# selected.
|
|
110
|
+
def empty_value = EMPTY_SELECTION
|
|
111
|
+
|
|
112
|
+
# Replaces the selection, firing {HasValue#on_value_change} when it really
|
|
113
|
+
# changed. Stores a frozen `Set` *copy*, so a set the caller goes on
|
|
114
|
+
# mutating can't reach in.
|
|
115
|
+
# @param new_value [Enumerable, nil] `nil` selects nothing.
|
|
116
|
+
# @raise [TypeError] unless `new_value` is an `Enumerable` or `nil`.
|
|
117
|
+
# @return [void]
|
|
118
|
+
def value=(new_value)
|
|
119
|
+
selected = coerce(new_value)
|
|
120
|
+
# HasValue#value= no-ops on an unchanged value; this guard is what also
|
|
121
|
+
# skips the row rebuild.
|
|
122
|
+
return if value == selected
|
|
123
|
+
|
|
124
|
+
super(selected)
|
|
125
|
+
rebuild_rows
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Toggles the cursor row on Space. Nothing else is claimed: the composed
|
|
129
|
+
# {List} — being the focused component — has already had its chance at the
|
|
130
|
+
# key (its arrows, Home/End, PgUp/PgDn, ^U/^D and Enter), and whatever
|
|
131
|
+
# neither of us wants bubbles on to an ancestor.
|
|
132
|
+
# @param key [String]
|
|
133
|
+
# @return [Boolean]
|
|
134
|
+
def handle_key(key)
|
|
135
|
+
return false unless key == " "
|
|
136
|
+
|
|
137
|
+
toggle_at(content.cursor.position)
|
|
138
|
+
true
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
protected
|
|
142
|
+
|
|
143
|
+
# Places the composed list across the whole rect ({HasContent} hook).
|
|
144
|
+
# @param list [Component]
|
|
145
|
+
# @return [void]
|
|
146
|
+
def layout(list) = (list.rect = rect)
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
# Flips membership of the item on row `index`; an index outside {#items} is
|
|
151
|
+
# ignored.
|
|
152
|
+
# @param index [Integer]
|
|
153
|
+
# @return [void]
|
|
154
|
+
def toggle_at(index)
|
|
155
|
+
return unless index.between?(0, @items.size - 1)
|
|
156
|
+
|
|
157
|
+
item = @items[index]
|
|
158
|
+
self.value = value.include?(item) ? value - [item] : value + [item]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Re-renders every row from the current items, labels and selection.
|
|
162
|
+
# @return [void]
|
|
163
|
+
def rebuild_rows
|
|
164
|
+
content.lines = @items.map do |item|
|
|
165
|
+
StyledString.plain(value.include?(item) ? "[x] " : "[ ] ") + label_for(item)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @param new_value [Enumerable, nil]
|
|
170
|
+
# @return [Set] a frozen copy; `nil` becomes {#empty_value}.
|
|
171
|
+
# @raise [TypeError] on anything else.
|
|
172
|
+
def coerce(new_value)
|
|
173
|
+
return empty_value if new_value.nil?
|
|
174
|
+
raise TypeError, "expected Enumerable, got #{new_value.inspect}" unless new_value.is_a?(Enumerable)
|
|
175
|
+
|
|
176
|
+
Set.new(new_value).freeze
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# @param item [Object]
|
|
180
|
+
# @return [StyledString, String] whichever {StyledString#+} accepts on the
|
|
181
|
+
# right — so a styled label keeps its spans and a plain one is parsed.
|
|
182
|
+
def label_for(item)
|
|
183
|
+
label = @item_label.call(item)
|
|
184
|
+
label.is_a?(StyledString) ? label : label.to_s
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A text field with a filtering dropdown: type to narrow the candidates,
|
|
6
|
+
# arrow to move the highlight, Enter (or click) to accept. Its {#value} is
|
|
7
|
+
# the *selected item* — of whatever type the items are — not the display
|
|
8
|
+
# string, so a combo over domain objects hands back the object:
|
|
9
|
+
#
|
|
10
|
+
# combo = Component::ComboBox.new
|
|
11
|
+
# combo.items = User.all # Array of any type
|
|
12
|
+
# combo.item_label = ->(u) { u.full_name } # item -> shown text; default :to_s
|
|
13
|
+
# combo.on_value_change = ->(u) { open(u) } # fires on commit, with the item
|
|
14
|
+
# combo.value = some_user # selects it; field shows its label
|
|
15
|
+
#
|
|
16
|
+
# It's the assembly you'd otherwise wire by hand — a {TextField} plus a
|
|
17
|
+
# non-modal {Popup} over a {List} — promoted to one component. Give it a
|
|
18
|
+
# single-row {#rect}; it paints the field across that row with a `▾` in the
|
|
19
|
+
# last column and floats the dropdown above or below.
|
|
20
|
+
#
|
|
21
|
+
# == The two values
|
|
22
|
+
# {#value} (the committed selection) and the field's typed text (a transient
|
|
23
|
+
# *query*) are deliberately distinct. Keystrokes move the query and refilter
|
|
24
|
+
# the list; only Enter/click commits, and only a commit changes {#value} and
|
|
25
|
+
# fires {#on_value_change}. An uncommitted query reverts to the current
|
|
26
|
+
# value's label when the dropdown is dismissed (ESC) or the combo loses
|
|
27
|
+
# focus. Selecting by list index (not by matching the label back) is what
|
|
28
|
+
# lets two items share a label and still resolve to the right object.
|
|
29
|
+
#
|
|
30
|
+
# The dropdown is a {ListDropdown}, tinted to read as a floating panel; see
|
|
31
|
+
# it for the theming knob.
|
|
32
|
+
#
|
|
33
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
34
|
+
class ComboBox < Component
|
|
35
|
+
include HasContent
|
|
36
|
+
include HasValue
|
|
37
|
+
|
|
38
|
+
# @param items [Array] the candidate items (any type); also settable via
|
|
39
|
+
# {#items=}.
|
|
40
|
+
def initialize(items: [])
|
|
41
|
+
super()
|
|
42
|
+
@value = nil
|
|
43
|
+
@on_value_change = nil
|
|
44
|
+
@items = items.to_a
|
|
45
|
+
@item_label = :to_s.to_proc
|
|
46
|
+
@filtered = []
|
|
47
|
+
@suppressing_filter = false
|
|
48
|
+
|
|
49
|
+
field = TextField.new
|
|
50
|
+
field.on_change = ->(_text) { refill unless @suppressing_filter }
|
|
51
|
+
field.on_key = method(:field_key)
|
|
52
|
+
self.content = field
|
|
53
|
+
|
|
54
|
+
@overlay = ListDropdown.new
|
|
55
|
+
@overlay.on_item_chosen = ->(index, _line) { commit(index) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Array] the candidate items.
|
|
59
|
+
attr_reader :items
|
|
60
|
+
|
|
61
|
+
# @return [Proc, Method] item -> shown label (a `String` or
|
|
62
|
+
# {StyledString}); the field shows its `#to_s`, the list its styled form.
|
|
63
|
+
attr_reader :item_label
|
|
64
|
+
|
|
65
|
+
# @param new_items [Array]
|
|
66
|
+
# @return [void]
|
|
67
|
+
def items=(new_items)
|
|
68
|
+
raise TypeError, "expected Array, got #{new_items.inspect}" unless new_items.is_a?(Array)
|
|
69
|
+
|
|
70
|
+
@items = new_items
|
|
71
|
+
refill if @overlay.open?
|
|
72
|
+
invalidate
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @param proc [Proc, Method] item -> shown label.
|
|
76
|
+
# @return [void]
|
|
77
|
+
def item_label=(proc)
|
|
78
|
+
@item_label = proc
|
|
79
|
+
sync_field(display_for(value)) # re-render the current selection
|
|
80
|
+
invalidate
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Selects `new_value` programmatically: updates the field to its label
|
|
84
|
+
# *without* opening the dropdown, then fires {#on_value_change}. `nil`
|
|
85
|
+
# clears the selection (blank field). The value need not be in {#items}.
|
|
86
|
+
# @param new_value [Object]
|
|
87
|
+
# @return [void]
|
|
88
|
+
def value=(new_value)
|
|
89
|
+
return if value == new_value
|
|
90
|
+
|
|
91
|
+
sync_field(display_for(new_value))
|
|
92
|
+
super
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [Point, nil] the field's caret position (the combo delegates the
|
|
96
|
+
# hardware cursor to its field).
|
|
97
|
+
def cursor_position = content.cursor_position
|
|
98
|
+
|
|
99
|
+
# @return [String]
|
|
100
|
+
def keyboard_hint = "↑↓ #{screen.theme.hint("select")} ⏎ #{screen.theme.hint("accept")}"
|
|
101
|
+
|
|
102
|
+
# Re-anchors the (open) dropdown after {HasContent#rect=} has resized the
|
|
103
|
+
# field via {#layout}.
|
|
104
|
+
# @param new_rect [Rect]
|
|
105
|
+
# @return [void]
|
|
106
|
+
def rect=(new_rect)
|
|
107
|
+
super
|
|
108
|
+
anchor if @overlay.open?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Closes the dropdown and reverts an uncommitted query when the combo
|
|
112
|
+
# leaves the focus chain — so tabbing away doesn't strand an open menu or
|
|
113
|
+
# a half-typed filter. Safe against re-entrancy: focus never sits inside
|
|
114
|
+
# the (non-focusable) {ListDropdown}, so closing the overlay repairs no
|
|
115
|
+
# focus.
|
|
116
|
+
# @param flag [Boolean]
|
|
117
|
+
# @return [void]
|
|
118
|
+
def active=(flag)
|
|
119
|
+
was = active?
|
|
120
|
+
super
|
|
121
|
+
return unless was && !active?
|
|
122
|
+
|
|
123
|
+
close_menu
|
|
124
|
+
revert_query
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @param event [MouseEvent]
|
|
128
|
+
# @return [void]
|
|
129
|
+
def handle_mouse(event)
|
|
130
|
+
if content.rect.contains?(event.point)
|
|
131
|
+
content.handle_mouse(event)
|
|
132
|
+
elsif event.button == :left && rect.contains?(event.point) # the ▾ cell
|
|
133
|
+
content.focus
|
|
134
|
+
@overlay.open? ? close_menu : open_menu
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @return [void]
|
|
139
|
+
def repaint
|
|
140
|
+
super
|
|
141
|
+
return if rect.empty?
|
|
142
|
+
|
|
143
|
+
well = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color
|
|
144
|
+
draw_char(rect.left + rect.width - 1, rect.top, "▾", StyledString::Style::DEFAULT.with(bg: well))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
protected
|
|
148
|
+
|
|
149
|
+
# Field spans the row bar the last column, which the `▾` occupies
|
|
150
|
+
# ({HasContent} layout hook).
|
|
151
|
+
# @param field [Component]
|
|
152
|
+
# @return [void]
|
|
153
|
+
def layout(field)
|
|
154
|
+
field.rect = Rect.new(rect.left, rect.top, [rect.width - 1, 0].max, 1)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
# The field's key interceptor: while the dropdown is open forwards movement
|
|
160
|
+
# to it ({ListDropdown#move}), commits on Enter ({ListDropdown#choose}),
|
|
161
|
+
# and dismisses on ESC (reverting the query); opens it on Down or Enter
|
|
162
|
+
# when closed. Everything else (printable keys, editing) falls through to
|
|
163
|
+
# the field, whose {TextField#on_change} refilters.
|
|
164
|
+
# @param key [String]
|
|
165
|
+
# @return [Boolean] true if consumed.
|
|
166
|
+
def field_key(key)
|
|
167
|
+
if @overlay.open?
|
|
168
|
+
if @overlay.move(key)
|
|
169
|
+
true
|
|
170
|
+
elsif key == Keys::ENTER
|
|
171
|
+
@overlay.choose
|
|
172
|
+
true
|
|
173
|
+
elsif key == Keys::ESC
|
|
174
|
+
close_menu
|
|
175
|
+
revert_query
|
|
176
|
+
true
|
|
177
|
+
else
|
|
178
|
+
false
|
|
179
|
+
end
|
|
180
|
+
elsif [Keys::DOWN_ARROW, Keys::ENTER].include?(key)
|
|
181
|
+
open_menu
|
|
182
|
+
true
|
|
183
|
+
else
|
|
184
|
+
false
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Recomputes the matches for the current query, opening the dropdown when
|
|
189
|
+
# there are any (and preselecting the current value's row) or closing it
|
|
190
|
+
# when there are none.
|
|
191
|
+
# @return [void]
|
|
192
|
+
def refill
|
|
193
|
+
@filtered = matching(content.text)
|
|
194
|
+
if @filtered.empty?
|
|
195
|
+
close_menu
|
|
196
|
+
else
|
|
197
|
+
@overlay.lines = @filtered.map { |item| @item_label.call(item) }
|
|
198
|
+
@overlay.cursor = List::Cursor.new(position: @filtered.index(value) || 0)
|
|
199
|
+
@overlay.open unless @overlay.open?
|
|
200
|
+
anchor
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Items whose label contains `query` (case-insensitive). A query still
|
|
205
|
+
# equal to the current value's label — the resting state, or a fresh
|
|
206
|
+
# open — is treated as "show everything", so Down opens the full list.
|
|
207
|
+
# @param query [String]
|
|
208
|
+
# @return [Array]
|
|
209
|
+
def matching(query)
|
|
210
|
+
return @items if query.empty? || query == display_for(value)
|
|
211
|
+
|
|
212
|
+
needle = query.downcase
|
|
213
|
+
@items.select { |item| @item_label.call(item).to_s.downcase.include?(needle) }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Commits the item at the menu's `index`: closes the dropdown and adopts
|
|
217
|
+
# it as {#value} (which repaints the field with its label).
|
|
218
|
+
# @param index [Integer]
|
|
219
|
+
# @return [void]
|
|
220
|
+
def commit(index)
|
|
221
|
+
item = @filtered[index]
|
|
222
|
+
close_menu
|
|
223
|
+
self.value = item
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# @return [void]
|
|
227
|
+
def open_menu = refill
|
|
228
|
+
|
|
229
|
+
# @return [void]
|
|
230
|
+
def close_menu = (@overlay.close if @overlay.open?)
|
|
231
|
+
|
|
232
|
+
# @return [void]
|
|
233
|
+
def revert_query = sync_field(display_for(value))
|
|
234
|
+
|
|
235
|
+
# Sets the field's text without triggering a refilter — for programmatic
|
|
236
|
+
# value changes and query reverts, which must not spring the dropdown.
|
|
237
|
+
# Parks the caret at the end: `text=` only *clamps* the caret, so a
|
|
238
|
+
# shorter query replaced by a longer label would otherwise strand it
|
|
239
|
+
# mid-word (commit "Go", then pick "Kotlin" → caret after "Ko").
|
|
240
|
+
# @param text [String]
|
|
241
|
+
# @return [void]
|
|
242
|
+
def sync_field(text)
|
|
243
|
+
@suppressing_filter = true
|
|
244
|
+
content.text = text
|
|
245
|
+
content.caret = content.text.length
|
|
246
|
+
ensure
|
|
247
|
+
@suppressing_filter = false
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# @param item [Object]
|
|
251
|
+
# @return [String] the plain-text label for `item`, or "" for nil.
|
|
252
|
+
def display_for(item) = item.nil? ? "" : @item_label.call(item).to_s
|
|
253
|
+
|
|
254
|
+
# Sizes and positions the dropdown against the field: full combo width,
|
|
255
|
+
# `min(matches, 10)` rows, below the field — flipped above when it won't
|
|
256
|
+
# fit beneath, clamped (with the list scrolling) when it fits neither.
|
|
257
|
+
# @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
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# The chrome text a component *wears* — a {Window}'s border title, a
|
|
6
|
+
# {Button}'s label — as opposed to the value it *holds*.
|
|
7
|
+
#
|
|
8
|
+
# button.caption = "Submit"
|
|
9
|
+
# window.caption = StyledString.styled("Settings", fg: Color::RED)
|
|
10
|
+
#
|
|
11
|
+
# Tuile's naming split, which decides what a new component gets:
|
|
12
|
+
# **caption** is chrome, authored by the app; **text** is the value the
|
|
13
|
+
# user edits (aliased to {HasValue#value} on {AbstractStringField}). A
|
|
14
|
+
# component may carry both, hence two mixins.
|
|
15
|
+
#
|
|
16
|
+
# Includers own the *rendering* — clipping, width arithmetic, decoration
|
|
17
|
+
# such as {Window}'s `[key]-` shortcut prefix; this holds only the text.
|
|
18
|
+
#
|
|
19
|
+
# == Implementation details
|
|
20
|
+
# Being a mixin is what lets tree-walking code find "the {Button} captioned
|
|
21
|
+
# Submit" via `is_a?(HasCaption)` plus a caption compare, rather than a
|
|
22
|
+
# hardcoded list of classes that happen to respond to `caption`. Don't
|
|
23
|
+
# collapse it back into per-class accessors.
|
|
24
|
+
module HasCaption
|
|
25
|
+
# Read through *this* method, never `@caption` — the ivar stays nil until
|
|
26
|
+
# the first non-empty set ({#caption=} short-circuits when unchanged).
|
|
27
|
+
# @return [StyledString] the caption; empty when never set.
|
|
28
|
+
def caption = @caption || StyledString::EMPTY
|
|
29
|
+
|
|
30
|
+
# Sets the caption and invalidates the component. No-op when unchanged. A
|
|
31
|
+
# `String` is parsed via {StyledString.parse} (embedded ANSI is honored);
|
|
32
|
+
# a {StyledString} is used as-is; `nil` clears it.
|
|
33
|
+
# @param new_caption [String, StyledString, nil]
|
|
34
|
+
# @return [void]
|
|
35
|
+
def caption=(new_caption)
|
|
36
|
+
new_caption = StyledString.parse(new_caption)
|
|
37
|
+
return if caption == new_caption
|
|
38
|
+
|
|
39
|
+
@caption = new_caption
|
|
40
|
+
invalidate
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -15,9 +15,6 @@ module Tuile
|
|
|
15
15
|
content.handle_mouse(event) if !content.nil? && content.rect.contains?(event.point)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
# @return [Array<Component>]
|
|
19
|
-
def children = content.nil? ? [] : [content]
|
|
20
|
-
|
|
21
18
|
# Sets the new content of this component. Updates `@content` itself;
|
|
22
19
|
# including classes may still override to add behaviour (e.g. a
|
|
23
20
|
# special-cased Array input) but should call `super` to perform the
|
|
@@ -34,10 +31,13 @@ module Tuile
|
|
|
34
31
|
end
|
|
35
32
|
|
|
36
33
|
old = self.content
|
|
37
|
-
|
|
34
|
+
# Detached without notifying, and notified at the very end: the focus
|
|
35
|
+
# repair in on_child_removed cascades into whatever occupies the slot
|
|
36
|
+
# *now*, so it has to see the new content (window_spec pins it).
|
|
37
|
+
detach_child(old) unless old.nil?
|
|
38
38
|
@content = content
|
|
39
39
|
unless content.nil?
|
|
40
|
-
content
|
|
40
|
+
add_child(content, at: 0) # content paints beneath a Window's footer
|
|
41
41
|
content.invalidate
|
|
42
42
|
layout(content)
|
|
43
43
|
end
|