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
|
@@ -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,263 @@
|
|
|
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). One row, or none at all when the combo itself
|
|
151
|
+
# was given none — a starved parent must not hand out a rect it doesn't own.
|
|
152
|
+
# @param field [Component]
|
|
153
|
+
# @return [void]
|
|
154
|
+
def layout(field)
|
|
155
|
+
field.rect = Rect.new(rect.left, rect.top, [rect.width - 1, 0].max, [rect.height, 1].min)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
# The field's key interceptor: while the dropdown is open forwards movement
|
|
161
|
+
# to it ({ListDropdown#move}), commits on Enter ({ListDropdown#choose}),
|
|
162
|
+
# and dismisses on ESC (reverting the query); opens it on Down or Enter
|
|
163
|
+
# when closed. Everything else (printable keys, editing) falls through to
|
|
164
|
+
# the field, whose {TextField#on_change} refilters.
|
|
165
|
+
# @param key [String]
|
|
166
|
+
# @return [Boolean] true if consumed.
|
|
167
|
+
def field_key(key)
|
|
168
|
+
if @overlay.open?
|
|
169
|
+
if @overlay.move(key)
|
|
170
|
+
true
|
|
171
|
+
elsif key == Keys::ENTER
|
|
172
|
+
@overlay.choose
|
|
173
|
+
true
|
|
174
|
+
elsif key == Keys::ESC
|
|
175
|
+
close_menu
|
|
176
|
+
revert_query
|
|
177
|
+
true
|
|
178
|
+
else
|
|
179
|
+
false
|
|
180
|
+
end
|
|
181
|
+
elsif [Keys::DOWN_ARROW, Keys::ENTER].include?(key)
|
|
182
|
+
open_menu
|
|
183
|
+
true
|
|
184
|
+
else
|
|
185
|
+
false
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Recomputes the matches for the current query, opening the dropdown when
|
|
190
|
+
# there are any (and preselecting the current value's row) or closing it
|
|
191
|
+
# when there are none.
|
|
192
|
+
# @return [void]
|
|
193
|
+
def refill
|
|
194
|
+
@filtered = matching(content.text)
|
|
195
|
+
if @filtered.empty?
|
|
196
|
+
close_menu
|
|
197
|
+
else
|
|
198
|
+
@overlay.lines = @filtered.map { |item| @item_label.call(item) }
|
|
199
|
+
@overlay.cursor = List::Cursor.new(position: @filtered.index(value) || 0)
|
|
200
|
+
@overlay.open unless @overlay.open?
|
|
201
|
+
anchor
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Items whose label contains `query` (case-insensitive). A query still
|
|
206
|
+
# equal to the current value's label — the resting state, or a fresh
|
|
207
|
+
# open — is treated as "show everything", so Down opens the full list.
|
|
208
|
+
# @param query [String]
|
|
209
|
+
# @return [Array]
|
|
210
|
+
def matching(query)
|
|
211
|
+
return @items if query.empty? || query == display_for(value)
|
|
212
|
+
|
|
213
|
+
needle = query.downcase
|
|
214
|
+
@items.select { |item| @item_label.call(item).to_s.downcase.include?(needle) }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Commits the item at the menu's `index`: closes the dropdown and adopts
|
|
218
|
+
# it as {#value} (which repaints the field with its label).
|
|
219
|
+
# @param index [Integer]
|
|
220
|
+
# @return [void]
|
|
221
|
+
def commit(index)
|
|
222
|
+
item = @filtered[index]
|
|
223
|
+
close_menu
|
|
224
|
+
self.value = item
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# @return [void]
|
|
228
|
+
def open_menu = refill
|
|
229
|
+
|
|
230
|
+
# @return [void]
|
|
231
|
+
def close_menu = (@overlay.close if @overlay.open?)
|
|
232
|
+
|
|
233
|
+
# @return [void]
|
|
234
|
+
def revert_query = sync_field(display_for(value))
|
|
235
|
+
|
|
236
|
+
# Sets the field's text without triggering a refilter — for programmatic
|
|
237
|
+
# value changes and query reverts, which must not spring the dropdown.
|
|
238
|
+
# Parks the caret at the end: `text=` only *clamps* the caret, so a
|
|
239
|
+
# shorter query replaced by a longer label would otherwise strand it
|
|
240
|
+
# mid-word (commit "Go", then pick "Kotlin" → caret after "Ko").
|
|
241
|
+
# @param text [String]
|
|
242
|
+
# @return [void]
|
|
243
|
+
def sync_field(text)
|
|
244
|
+
@suppressing_filter = true
|
|
245
|
+
content.text = text
|
|
246
|
+
content.caret = content.text.length
|
|
247
|
+
ensure
|
|
248
|
+
@suppressing_filter = false
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# @param item [Object]
|
|
252
|
+
# @return [String] the plain-text label for `item`, or "" for nil.
|
|
253
|
+
def display_for(item) = item.nil? ? "" : @item_label.call(item).to_s
|
|
254
|
+
|
|
255
|
+
# Places the dropdown at the combo's own width, so both its edges line up
|
|
256
|
+
# with the field — at the cost of the scrollbar taking its column from the
|
|
257
|
+
# labels, which ellipsize a column earlier once the list scrolls. That is
|
|
258
|
+
# the trade a measuring driver ({Select}) makes the other way.
|
|
259
|
+
# @return [void]
|
|
260
|
+
def anchor = @overlay.anchor_to(rect, rows: @filtered.size)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
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
|
|
@@ -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
|