tuile 0.10.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 +77 -64
- data/DECISIONS.md +619 -14
- data/README.md +6 -0
- data/book/03-layout.md +153 -8
- data/book/05-focus.md +2 -0
- data/book/07-components.md +105 -10
- data/book/README.md +3 -1
- data/examples/sampler.rb +282 -132
- data/ideas/arrow-key-navigation.md +205 -0
- data/ideas/new-components.md +17 -8
- data/lib/tuile/component/big_decimal_field.rb +199 -0
- data/lib/tuile/component/checkbox.rb +10 -9
- data/lib/tuile/component/combo_box.rb +8 -26
- data/lib/tuile/component/float_field.rb +161 -0
- 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_dropdown.rb +69 -18
- data/lib/tuile/component/select.rb +251 -0
- data/lib/tuile/styled_string.rb +13 -3
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +4 -0
- data/sig/tuile.rbs +882 -29
- metadata +8 -1
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
5
|
# A layout doesn't paint anything by itself: its job is to position child
|
|
6
|
-
# components.
|
|
6
|
+
# components. Two families, both top-down (see book ch3):
|
|
7
|
+
#
|
|
8
|
+
# - {Absolute} — you override {Component#rect=} and compute every child's
|
|
9
|
+
# rectangle yourself. Total control, and the base for anything unusual.
|
|
10
|
+
# - {Box} / {Vertical} / {Horizontal} — you declare each child's extent as
|
|
11
|
+
# a {Fixed}, {Percent} or {Expand} constraint and the layout does the
|
|
12
|
+
# arithmetic. Sugar over the same `rect=` assignment, for the common case.
|
|
7
13
|
#
|
|
8
14
|
# Children that fully tile the layout's rect repaint themselves and
|
|
9
15
|
# cover everything; children that leave gaps (e.g. a form with widgets
|
|
@@ -11,6 +17,148 @@ module Tuile
|
|
|
11
17
|
# the background is cleared and children are re-invalidated so they
|
|
12
18
|
# paint over a clean surface.
|
|
13
19
|
class Layout < Component
|
|
20
|
+
# How much space a child gets along one axis of a {Box}: exactly {#cells},
|
|
21
|
+
# clamped to whatever is still unassigned.
|
|
22
|
+
#
|
|
23
|
+
# add(prompt, Fixed[4]) # 4 rows in a Vertical
|
|
24
|
+
# add(field, Fixed[1], cross: Fixed[30]) # 1 row, 30 columns wide
|
|
25
|
+
#
|
|
26
|
+
# `Fixed[0]` hides the child — it gets an empty rect and paints nothing.
|
|
27
|
+
#
|
|
28
|
+
# @!attribute [r] cells
|
|
29
|
+
# @return [Integer] cell count along the axis.
|
|
30
|
+
class Fixed < Data.define(:cells)
|
|
31
|
+
# @param cells [Integer] cell count along the axis; `>= 0`.
|
|
32
|
+
# @raise [ArgumentError] unless `cells` is a non-negative Integer.
|
|
33
|
+
def initialize(cells:)
|
|
34
|
+
unless cells.is_a?(Integer) && !cells.negative?
|
|
35
|
+
raise ArgumentError, "Fixed expects a non-negative Integer, got #{cells.inspect}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
super
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A percentage of the space *available* along a {Box}'s axis — measured
|
|
43
|
+
# after {Box#padding} and {Box#spacing} have come off, so two `Percent[50]`
|
|
44
|
+
# children fit exactly rather than overflowing by the gap between them.
|
|
45
|
+
#
|
|
46
|
+
# add(left, Percent[60])
|
|
47
|
+
# add(right, Percent[40])
|
|
48
|
+
#
|
|
49
|
+
# @!attribute [r] percent
|
|
50
|
+
# @return [Numeric] percentage of the available extent, `0..100`.
|
|
51
|
+
class Percent < Data.define(:percent)
|
|
52
|
+
# @param percent [Numeric] percentage of the available extent, `0..100`.
|
|
53
|
+
# @raise [ArgumentError] unless `percent` is a Numeric in `0..100`.
|
|
54
|
+
def initialize(percent:)
|
|
55
|
+
unless percent.is_a?(Numeric) && percent.between?(0, 100)
|
|
56
|
+
raise ArgumentError, "Percent expects a Numeric in 0..100, got #{percent.inspect}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
super
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A share of whatever a {Box} has left once its {Fixed} and {Percent}
|
|
64
|
+
# children have taken theirs, split between the `Expand` children in
|
|
65
|
+
# proportion to their weights:
|
|
66
|
+
#
|
|
67
|
+
# add(header, Fixed[1])
|
|
68
|
+
# add(body, Expand[2]) # gets twice…
|
|
69
|
+
# add(side, Expand[1]) # …what this one gets
|
|
70
|
+
#
|
|
71
|
+
# Main axis only — {Box#add} rejects one passed as `cross:`, where a child
|
|
72
|
+
# has no siblings to compete with and so nothing for a weight to mean.
|
|
73
|
+
#
|
|
74
|
+
# @!attribute [r] weight
|
|
75
|
+
# @return [Integer] relative share of the leftover space.
|
|
76
|
+
class Expand < Data.define(:weight)
|
|
77
|
+
# @param weight [Integer] relative share; `>= 1`.
|
|
78
|
+
# @raise [ArgumentError] unless `weight` is a positive Integer.
|
|
79
|
+
def initialize(weight:)
|
|
80
|
+
unless weight.is_a?(Integer) && weight.positive?
|
|
81
|
+
raise ArgumentError, "Expand expects a positive Integer weight, got #{weight.inspect}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
super
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Per-edge padding for a {Box}, in cells:
|
|
89
|
+
#
|
|
90
|
+
# Insets[top: 1] # one blank row above the children
|
|
91
|
+
# Insets[top: 1, left: 2, right: 2] # unnamed edges default to 0
|
|
92
|
+
# Insets.coerce(1) # uniform on all four edges
|
|
93
|
+
#
|
|
94
|
+
# Keyword-only: AWT and JavaFX order these same four numbers differently,
|
|
95
|
+
# so a positional form would be a coin flip.
|
|
96
|
+
#
|
|
97
|
+
# @!attribute [r] top
|
|
98
|
+
# @return [Integer] cells inset from the top edge.
|
|
99
|
+
# @!attribute [r] right
|
|
100
|
+
# @return [Integer] cells inset from the right edge.
|
|
101
|
+
# @!attribute [r] bottom
|
|
102
|
+
# @return [Integer] cells inset from the bottom edge.
|
|
103
|
+
# @!attribute [r] left
|
|
104
|
+
# @return [Integer] cells inset from the left edge.
|
|
105
|
+
class Insets < Data.define(:top, :right, :bottom, :left)
|
|
106
|
+
# @param positional [Array] must be empty — see the class doc.
|
|
107
|
+
# @param kwargs [Hash{Symbol => Integer}] any of `top:`/`right:`/`bottom:`/`left:`.
|
|
108
|
+
# @raise [ArgumentError] if any positional argument is given.
|
|
109
|
+
# @return [Insets]
|
|
110
|
+
def self.new(*positional, **kwargs)
|
|
111
|
+
raise ArgumentError, "Insets is keyword-only, got #{positional.inspect}" unless positional.empty?
|
|
112
|
+
|
|
113
|
+
super(**kwargs)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Needed because `Data`'s inherited `[]` never dispatches through a `new`
|
|
117
|
+
# override, so the guard above alone would miss `Insets[1, 2, 3, 4]`.
|
|
118
|
+
# @param positional [Array] must be empty.
|
|
119
|
+
# @param kwargs [Hash{Symbol => Integer}] any of `top:`/`right:`/`bottom:`/`left:`.
|
|
120
|
+
# @raise [ArgumentError] if any positional argument is given.
|
|
121
|
+
# @return [Insets]
|
|
122
|
+
def self.[](*positional, **kwargs) = new(*positional, **kwargs)
|
|
123
|
+
|
|
124
|
+
# @param value [Insets, Integer] an Integer becomes a uniform inset.
|
|
125
|
+
# @raise [ArgumentError] on anything else, or a negative Integer.
|
|
126
|
+
# @return [Insets]
|
|
127
|
+
def self.coerce(value)
|
|
128
|
+
return value if value.is_a?(Insets)
|
|
129
|
+
unless value.is_a?(Integer) && !value.negative?
|
|
130
|
+
raise ArgumentError, "expected Insets or a non-negative Integer, got #{value.inspect}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
new(top: value, right: value, bottom: value, left: value)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @param top [Integer] cells inset from the top edge; `>= 0`.
|
|
137
|
+
# @param right [Integer] cells inset from the right edge; `>= 0`.
|
|
138
|
+
# @param bottom [Integer] cells inset from the bottom edge; `>= 0`.
|
|
139
|
+
# @param left [Integer] cells inset from the left edge; `>= 0`.
|
|
140
|
+
# @raise [ArgumentError] unless every edge is a non-negative Integer.
|
|
141
|
+
def initialize(top: 0, right: 0, bottom: 0, left: 0)
|
|
142
|
+
{ top:, right:, bottom:, left: }.each do |edge, cells|
|
|
143
|
+
unless cells.is_a?(Integer) && !cells.negative?
|
|
144
|
+
raise ArgumentError, "Insets #{edge}: expected a non-negative Integer, got #{cells.inspect}"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
super
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @return [Integer] `left` + `right`.
|
|
152
|
+
def horizontal = left + right
|
|
153
|
+
|
|
154
|
+
# @return [Integer] `top` + `bottom`.
|
|
155
|
+
def vertical = top + bottom
|
|
156
|
+
|
|
157
|
+
# No padding on any edge.
|
|
158
|
+
# @return [Insets]
|
|
159
|
+
ZERO = new
|
|
160
|
+
end
|
|
161
|
+
|
|
14
162
|
# Layouts are focusable containers — like {Window} and {Popup}, they
|
|
15
163
|
# don't accept input themselves but they need to participate in the
|
|
16
164
|
# {HasContent} focus cascade so a Popup wrapping a Layout wrapping a
|
|
@@ -3,26 +3,27 @@
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
5
|
# A borderless, tinted, non-focusable floating selection list — the dropdown
|
|
6
|
-
# a
|
|
6
|
+
# a *driver* drops open, drives by forwarding movement keys, and commits a
|
|
7
7
|
# pick from: a non-modal {Popup} wrapping a {List} that never takes focus, so
|
|
8
|
-
#
|
|
9
|
-
#
|
|
8
|
+
# focus stays on the driver while the caller refills the rows, moves the
|
|
9
|
+
# highlight, and reads the pick.
|
|
10
10
|
#
|
|
11
11
|
# drop = Component::ListDropdown.new
|
|
12
12
|
# drop.on_item_chosen = ->(index, _line) { commit(index) } # caller commits
|
|
13
|
-
# # …then,
|
|
14
|
-
# drop.lines = matches.map { |m| render(m) }
|
|
15
|
-
# drop.rect
|
|
13
|
+
# # …then, from the driver's key handler:
|
|
14
|
+
# drop.lines = matches.map { |m| render(m) } # caller filters + renders
|
|
15
|
+
# drop.anchor_to(rect, rows: matches.size) # below the driver, or flipped
|
|
16
16
|
# drop.open
|
|
17
17
|
# return true if drop.move(key) # Up/Down/PgUp/PgDn/^U/^D → list scroll
|
|
18
18
|
# drop.choose if key == Keys::ENTER # commit the highlight
|
|
19
19
|
#
|
|
20
|
-
# It owns only what every such dropdown shares
|
|
21
|
-
# with the driver:
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
# {#
|
|
20
|
+
# It owns only what every such dropdown shares — *placement* included, via
|
|
21
|
+
# {#anchor_to}. What stays with the driver: the width **policy** ({#anchor_to}
|
|
22
|
+
# measures nothing itself), filtering, row rendering, the commit action, and
|
|
23
|
+
# ESC/Enter handling. ESC and Enter carry driver-specific tails (ESC may
|
|
24
|
+
# revert a query; Enter may commit via {#choose} *or* via a separate submit
|
|
25
|
+
# path), so {#move} claims neither — the driver calls {#choose} and {#close}
|
|
26
|
+
# from its own branches.
|
|
26
27
|
#
|
|
27
28
|
# == Theming
|
|
28
29
|
# Borderless, told apart from the content beneath by a background tint —
|
|
@@ -33,8 +34,8 @@ module Tuile
|
|
|
33
34
|
# UI-thread-confined, like every component (see {Screen}).
|
|
34
35
|
class ListDropdown < Popup
|
|
35
36
|
# The dropdown's {List}. Non-focusable on purpose: the driver forwards keys
|
|
36
|
-
# while focus
|
|
37
|
-
#
|
|
37
|
+
# while focus stays on it, and a mouse click selects an item without
|
|
38
|
+
# stealing focus — so a driving text input never loses its caret
|
|
38
39
|
# mid-interaction.
|
|
39
40
|
class Menu < List
|
|
40
41
|
def focusable? = false
|
|
@@ -43,17 +44,24 @@ module Tuile
|
|
|
43
44
|
|
|
44
45
|
# Cursor-movement keys forwarded to the list by {#move}: the two vertical
|
|
45
46
|
# arrows, page up/down, and Ctrl+U/D half-page jumps. Deliberately excludes
|
|
46
|
-
# Home/End and `j`/`k`
|
|
47
|
-
# and
|
|
48
|
-
#
|
|
47
|
+
# Home/End and `j`/`k` — a jump to the first/last row is the driver's call,
|
|
48
|
+
# and both drivers decline it ({ComboBox}'s field needs Home/End for the
|
|
49
|
+
# caret; {Select} would spend a branch on what a second arrow press already
|
|
50
|
+
# does) — and Enter/ESC, which carry driver-specific tails (see the class
|
|
51
|
+
# docs).
|
|
49
52
|
# @return [Array<String>]
|
|
50
53
|
MOVE_KEYS = [Keys::UP_ARROW, Keys::DOWN_ARROW, Keys::PAGE_UP, Keys::PAGE_DOWN,
|
|
51
54
|
Keys::CTRL_U, Keys::CTRL_D].freeze
|
|
52
55
|
|
|
56
|
+
# Most rows shown before the list scrolls; {#anchor_to}'s `max_rows`
|
|
57
|
+
# default.
|
|
58
|
+
# @return [Integer]
|
|
59
|
+
MAX_VISIBLE_ROWS = 10
|
|
60
|
+
|
|
53
61
|
def initialize
|
|
54
62
|
@list = Menu.new
|
|
55
63
|
@list.cursor = List::Cursor.new
|
|
56
|
-
@list.show_cursor_when_inactive = true # highlight the selection though focus stays
|
|
64
|
+
@list.show_cursor_when_inactive = true # highlight the selection though focus stays on the driver
|
|
57
65
|
super(content: @list, modal: false)
|
|
58
66
|
self.bg_color = Theme.ref(:input_bg_color)
|
|
59
67
|
end
|
|
@@ -82,6 +90,49 @@ module Tuile
|
|
|
82
90
|
# @return [List::Cursor] the list's cursor (the current highlight).
|
|
83
91
|
def cursor = @list.cursor
|
|
84
92
|
|
|
93
|
+
# Sizes and places the dropdown against `anchor`: directly beneath it,
|
|
94
|
+
# flipped above when `rows` won't fit below, clamped — with the list
|
|
95
|
+
# scrolling — when neither side has room. Horizontally the left edges line
|
|
96
|
+
# up, sliding left only far enough to keep the panel on screen.
|
|
97
|
+
#
|
|
98
|
+
# drop.anchor_to(field.rect, rows: matches.size) # field width
|
|
99
|
+
# drop.anchor_to(rect, rows: items.size, width: measured) # own width
|
|
100
|
+
#
|
|
101
|
+
# Vertical flips but horizontal slides because covering the driver would
|
|
102
|
+
# hide what is being chosen, while sharing its columns is the point.
|
|
103
|
+
#
|
|
104
|
+
# @param anchor [Rect] the driver's rect; the dropdown never covers it.
|
|
105
|
+
# @param rows [Integer] how many rows there are to show — the content
|
|
106
|
+
# count, not the height: more than fits turns the scrollbar on. `0`
|
|
107
|
+
# collapses the dropdown to an empty rect (drivers close instead).
|
|
108
|
+
# @param width [Integer] the panel's width in columns, clamped to the
|
|
109
|
+
# screen. Defaults to the anchor's, which lines both edges up with a
|
|
110
|
+
# field; a driver that measured its labels passes its own. A label wider
|
|
111
|
+
# than the screen clips — {List} has no horizontal scrolling.
|
|
112
|
+
# @param max_rows [Integer] rows shown before the list scrolls.
|
|
113
|
+
# @return [void]
|
|
114
|
+
def anchor_to(anchor, rows:, width: anchor.width, max_rows: MAX_VISIBLE_ROWS)
|
|
115
|
+
desired = [rows, max_rows].min
|
|
116
|
+
below = screen.size.height - (anchor.top + 1)
|
|
117
|
+
above = anchor.top
|
|
118
|
+
if desired <= below
|
|
119
|
+
top = anchor.top + 1
|
|
120
|
+
height = desired
|
|
121
|
+
elsif above >= below
|
|
122
|
+
height = [desired, above].min
|
|
123
|
+
top = anchor.top - height
|
|
124
|
+
else
|
|
125
|
+
height = below
|
|
126
|
+
top = anchor.top + 1
|
|
127
|
+
end
|
|
128
|
+
width = [width, screen.size.width].min
|
|
129
|
+
self.size = Size.new(width, height)
|
|
130
|
+
self.rect = Rect.new([anchor.left, screen.size.width - width].min.clamp(0, nil), top, width, height)
|
|
131
|
+
# After the geometry: the setter rebuilds the list's padded rows against
|
|
132
|
+
# the width it can see, and the gutter takes a column off it.
|
|
133
|
+
@list.scrollbar_visibility = rows > height ? :visible : :gone
|
|
134
|
+
end
|
|
135
|
+
|
|
85
136
|
# Forwards a cursor-movement key to the list. The driver calls this from
|
|
86
137
|
# its own key handler; a truthy return means "consumed — stop here", falsy
|
|
87
138
|
# means "not mine — proceed with normal editing/dispatch". Only {MOVE_KEYS}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A closed-choice field on one row: the selected item's label plus a `▾`
|
|
6
|
+
# affordance, dropping open a {ListDropdown} of the options. Enter, Space or
|
|
7
|
+
# Down opens it; the arrows (and PgUp/PgDn) move the highlight; Enter or
|
|
8
|
+
# Space commits; ESC dismisses without committing.
|
|
9
|
+
#
|
|
10
|
+
# warn ▾ <- the face: one row, on a field well
|
|
11
|
+
# debug <- the dropdown, measured to the widest label
|
|
12
|
+
# info (the one-column gutters are {List}'s)
|
|
13
|
+
# warn <- highlighted: the value's row, on open
|
|
14
|
+
# error
|
|
15
|
+
#
|
|
16
|
+
# sel = Component::Select.new(items: LogLevel.all)
|
|
17
|
+
# sel.item_label = ->(l) { l.name } # item -> shown label; default :to_s
|
|
18
|
+
# sel.on_value_change = ->(l) { relog(l) } # fires on commit, with the item
|
|
19
|
+
# sel.value = LogLevel::WARN # selects it; the face shows its label
|
|
20
|
+
#
|
|
21
|
+
# Use it for an **enum** — labels the developer authored, a closed set known
|
|
22
|
+
# when the code is written: log level, sort order, line endings, Yes/No/Ask.
|
|
23
|
+
# For items the app supplies at runtime with labels you don't control
|
|
24
|
+
# (countries, users, branches) reach for {ComboBox} instead, where filtering
|
|
25
|
+
# is the navigation. Item count is a symptom, not the criterion; book ch7 has
|
|
26
|
+
# the widget-choice table.
|
|
27
|
+
#
|
|
28
|
+
# {#value} is the selected *item*, of whatever type {#items} holds, never its
|
|
29
|
+
# label; `nil` — a blank face — is the initial state and stays legal, so an
|
|
30
|
+
# optional enum field needs no placeholder. As on {ComboBox}, {#items=} is
|
|
31
|
+
# chrome: it never touches {#value}, never fires {HasValue#on_value_change},
|
|
32
|
+
# and a value absent from {#items} survives intact while rendering nothing
|
|
33
|
+
# selected. Keeping the two in sync is the app's job.
|
|
34
|
+
#
|
|
35
|
+
# == It claims no printable key but Space
|
|
36
|
+
# Enter, Space, ESC, {ListDropdown::MOVE_KEYS} and the mouse. *Every other*
|
|
37
|
+
# printable key bubbles past it (key-dispatch rung 3), so a form's `s`-to-save
|
|
38
|
+
# and a layout's `1`/`2`/`3` pane jumps keep working while a Select has focus
|
|
39
|
+
# — the one capability no {ComboBox} configuration can offer, since a text
|
|
40
|
+
# field eats printables unconditionally. Space is the single exception, and it
|
|
41
|
+
# forecloses nothing: every activatable widget in the gem already claims it.
|
|
42
|
+
# Home/End are declined too, so they stay available app-wide.
|
|
43
|
+
#
|
|
44
|
+
# There is no type-ahead: a hidden prefix buffer *is* the ComboBox query with
|
|
45
|
+
# the feedback removed (`DECISIONS.md` `D-select`). Which is also why labels
|
|
46
|
+
# need no prefix-disambiguation.
|
|
47
|
+
#
|
|
48
|
+
# == Implementation details
|
|
49
|
+
# A leaf widget: it paints its own row (the face is *derived* from {#value}
|
|
50
|
+
# each paint, never a synced copy) and owns the dropdown as an overlay, which
|
|
51
|
+
# is not a child — like {ComboBox}'s. The well is read from
|
|
52
|
+
# {Screen#theme} at paint time, so it tracks a theme flip with no hook.
|
|
53
|
+
#
|
|
54
|
+
# The dropdown is at least as wide as the face and grows to fit the widest
|
|
55
|
+
# label, so the labels are never the thing that ellipsizes. It is not opened
|
|
56
|
+
# at all when {#items} is empty: an item-less Select is a programming bug, and
|
|
57
|
+
# an empty tinted panel reads as a broken list rather than as "nothing to
|
|
58
|
+
# pick". Enter/Space/Down are claimed either way.
|
|
59
|
+
#
|
|
60
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
61
|
+
class Select < Component
|
|
62
|
+
include HasValue
|
|
63
|
+
|
|
64
|
+
# @param items [Array] the options (any type); also settable via {#items=}.
|
|
65
|
+
# @param value [Object, nil] the initially selected item. Seeds the backing
|
|
66
|
+
# ivar directly, so no listener fires and assignment order doesn't matter
|
|
67
|
+
# 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 = value
|
|
73
|
+
@on_value_change = nil
|
|
74
|
+
@overlay = ListDropdown.new
|
|
75
|
+
@overlay.on_item_chosen = ->(index, _line) { commit(index) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Array] the options.
|
|
79
|
+
attr_reader :items
|
|
80
|
+
|
|
81
|
+
# @return [Proc, Method] item -> shown label (a `String` or
|
|
82
|
+
# {StyledString}); `:to_s` by default. Never called with `nil` — an
|
|
83
|
+
# unselected Select renders a blank face.
|
|
84
|
+
attr_reader :item_label
|
|
85
|
+
|
|
86
|
+
def tab_stop? = true
|
|
87
|
+
|
|
88
|
+
# Replaces the options, leaving {#value} untouched. An open dropdown is
|
|
89
|
+
# rebuilt (and re-measured) around them, or closed when none are left.
|
|
90
|
+
# @param new_items [Array]
|
|
91
|
+
# @raise [TypeError] unless `new_items` is an `Array`.
|
|
92
|
+
# @return [void]
|
|
93
|
+
def items=(new_items)
|
|
94
|
+
raise TypeError, "expected Array, got #{new_items.inspect}" unless new_items.is_a?(Array)
|
|
95
|
+
|
|
96
|
+
@items = new_items
|
|
97
|
+
refill if @overlay.open?
|
|
98
|
+
invalidate
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @param proc [Proc, Method] item -> shown label.
|
|
102
|
+
# @return [void]
|
|
103
|
+
def item_label=(proc)
|
|
104
|
+
@item_label = proc
|
|
105
|
+
refill if @overlay.open?
|
|
106
|
+
invalidate
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @return [String]
|
|
110
|
+
def keyboard_hint = "⏎ #{screen.theme.hint("open")} ↑↓ #{screen.theme.hint("select")}"
|
|
111
|
+
|
|
112
|
+
# Re-anchors the (open) dropdown after a move or resize.
|
|
113
|
+
# @param new_rect [Rect]
|
|
114
|
+
# @return [void]
|
|
115
|
+
def rect=(new_rect)
|
|
116
|
+
super
|
|
117
|
+
anchor if @overlay.open?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Closes the dropdown when the Select leaves the focus chain, so tabbing
|
|
121
|
+
# away doesn't strand an open menu. Safe against re-entrancy: focus never
|
|
122
|
+
# sits inside the (non-focusable) {ListDropdown}, so closing it repairs no
|
|
123
|
+
# focus.
|
|
124
|
+
# @param flag [Boolean]
|
|
125
|
+
# @return [void]
|
|
126
|
+
def active=(flag)
|
|
127
|
+
was = active?
|
|
128
|
+
super
|
|
129
|
+
close_menu if was && !active?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Opens the dropdown on Enter, Space or Down; while it is open, forwards
|
|
133
|
+
# {ListDropdown::MOVE_KEYS} to it, commits the highlight on Enter or Space,
|
|
134
|
+
# and dismisses on ESC. Everything else — every other printable included —
|
|
135
|
+
# is left unhandled so it bubbles to an ancestor.
|
|
136
|
+
# @param key [String]
|
|
137
|
+
# @return [Boolean]
|
|
138
|
+
def handle_key(key)
|
|
139
|
+
if @overlay.open?
|
|
140
|
+
return true if @overlay.move(key)
|
|
141
|
+
|
|
142
|
+
case key
|
|
143
|
+
when Keys::ENTER, " " then @overlay.choose
|
|
144
|
+
when Keys::ESC then close_menu
|
|
145
|
+
else return false
|
|
146
|
+
end
|
|
147
|
+
true
|
|
148
|
+
elsif [Keys::ENTER, " ", Keys::DOWN_ARROW].include?(key)
|
|
149
|
+
open_menu
|
|
150
|
+
true
|
|
151
|
+
else
|
|
152
|
+
false
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Toggles the dropdown on a left click anywhere in {#rect} — a field's
|
|
157
|
+
# affordance is its whole row, as the well advertises; `super` runs first,
|
|
158
|
+
# so the click also focuses.
|
|
159
|
+
# @param event [MouseEvent]
|
|
160
|
+
# @return [void]
|
|
161
|
+
def handle_mouse(event)
|
|
162
|
+
super
|
|
163
|
+
return unless event.button == :left && rect.contains?(event.point)
|
|
164
|
+
|
|
165
|
+
@overlay.open? ? close_menu : open_menu
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# @return [void]
|
|
169
|
+
def repaint
|
|
170
|
+
return if rect.empty?
|
|
171
|
+
|
|
172
|
+
tail = Rect.new(rect.left, rect.top + 1, rect.width, rect.height - 1)
|
|
173
|
+
clear_background(tail) unless tail.empty?
|
|
174
|
+
draw_line(rect.left, rect.top, face_row)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
# The painted row: the value's label padded across all but the last column,
|
|
180
|
+
# then the `▾`, all on the field well — {Theme#active_bg_color} while on the
|
|
181
|
+
# focus chain, {Theme#input_bg_color} otherwise.
|
|
182
|
+
# @return [StyledString]
|
|
183
|
+
def face_row
|
|
184
|
+
width = [rect.width - 1, 0].max
|
|
185
|
+
label = label_for(value).ellipsize(width)
|
|
186
|
+
row = label + StyledString.plain("#{" " * (width - label.display_width)}▾")
|
|
187
|
+
row.with_bg(active? ? screen.theme.active_bg_color : screen.theme.input_bg_color)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Rebuilds the dropdown's rows, highlight and geometry, opening it if
|
|
191
|
+
# needed; closes it instead when there is nothing to show.
|
|
192
|
+
# @return [void]
|
|
193
|
+
def refill
|
|
194
|
+
if @items.empty?
|
|
195
|
+
close_menu
|
|
196
|
+
return
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
@overlay.lines = @items.map { |item| label_for(item) }
|
|
200
|
+
@overlay.cursor = List::Cursor.new(position: @items.index(value) || 0)
|
|
201
|
+
@overlay.open unless @overlay.open?
|
|
202
|
+
anchor
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# @return [void]
|
|
206
|
+
def open_menu = refill
|
|
207
|
+
|
|
208
|
+
# @return [void]
|
|
209
|
+
def close_menu = (@overlay.close if @overlay.open?)
|
|
210
|
+
|
|
211
|
+
# Adopts the item on row `index` as {#value} and closes the dropdown.
|
|
212
|
+
# @param index [Integer]
|
|
213
|
+
# @return [void]
|
|
214
|
+
def commit(index)
|
|
215
|
+
item = @items[index]
|
|
216
|
+
close_menu
|
|
217
|
+
self.value = item
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# @return [void]
|
|
221
|
+
def anchor = @overlay.anchor_to(rect, rows: @items.size, width: menu_width)
|
|
222
|
+
|
|
223
|
+
# The dropdown's width: the widest label plus {List}'s two row gutters, plus
|
|
224
|
+
# the scrollbar column when the rows can't all be shown at once — but never
|
|
225
|
+
# narrower than the Select itself, so both edges line up with the face and
|
|
226
|
+
# the panel reads as belonging to it. Only a label that needs more pushes it
|
|
227
|
+
# wider.
|
|
228
|
+
#
|
|
229
|
+
# A dropdown the screen clamps shorter than
|
|
230
|
+
# {ListDropdown::MAX_VISIBLE_ROWS} scrolls without having bought that
|
|
231
|
+
# column, ellipsizing its labels one early — the {ComboBox} trade, in the
|
|
232
|
+
# one case measuring can't predict the height.
|
|
233
|
+
# @return [Integer]
|
|
234
|
+
def menu_width
|
|
235
|
+
widest = @items.map { |item| label_for(item).display_width }.max || 0
|
|
236
|
+
measured = widest + 2 + (@items.size > ListDropdown::MAX_VISIBLE_ROWS ? 1 : 0)
|
|
237
|
+
[measured, rect.width].max
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# @param item [Object]
|
|
241
|
+
# @return [StyledString] `item`'s label, or empty for `nil` — so {#value}
|
|
242
|
+
# being unset never reaches an {#item_label} that assumes an item.
|
|
243
|
+
def label_for(item)
|
|
244
|
+
return StyledString::EMPTY if item.nil?
|
|
245
|
+
|
|
246
|
+
label = @item_label.call(item)
|
|
247
|
+
label.is_a?(StyledString) ? label : StyledString.parse(label.to_s)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
data/lib/tuile/styled_string.rb
CHANGED
|
@@ -542,11 +542,20 @@ module Tuile
|
|
|
542
542
|
# wrapped continuations, hard `"\n"` breaks preserved as separate output
|
|
543
543
|
# lines.
|
|
544
544
|
#
|
|
545
|
+
# An indent is content, so it survives onto the first row — but there is no
|
|
546
|
+
# hanging indent:
|
|
547
|
+
#
|
|
548
|
+
# StyledString.plain(" read config").wrap(20).map(&:to_s)
|
|
549
|
+
# # => [" read config"] indent kept; the line never wrapped
|
|
550
|
+
# StyledString.plain(" read config").wrap(6).map(&:to_s)
|
|
551
|
+
# # => [" read", "config"] ...but a continuation starts at column 0
|
|
552
|
+
#
|
|
545
553
|
# Whitespace runs are space or tab; other characters are treated as word
|
|
546
554
|
# content. When a single character is wider than `width` (e.g. a 2-column
|
|
547
555
|
# CJK character with `width = 1`), it is still emitted on its own line at
|
|
548
556
|
# its natural width. The "no line exceeds `width`" guarantee therefore
|
|
549
|
-
# holds whenever every character is at most `width` columns wide.
|
|
557
|
+
# holds whenever every character is at most `width` columns wide. An indent
|
|
558
|
+
# that alone exceeds `width` is dropped rather than given a row of its own.
|
|
550
559
|
#
|
|
551
560
|
# @param width [Integer, nil] target column width. `nil` or `<= 0` skips
|
|
552
561
|
# wrapping and returns each hard-line as-is, so callers can pass a
|
|
@@ -720,8 +729,9 @@ module Tuile
|
|
|
720
729
|
|
|
721
730
|
tokenize_for_wrap(hard_line).each do |type, glyphs, w|
|
|
722
731
|
if type == :space
|
|
723
|
-
if line_w.zero?
|
|
724
|
-
#
|
|
732
|
+
if line_w.zero? && (!result.empty? || w > width)
|
|
733
|
+
# Nothing to emit: a continuation's leading run was consumed by the
|
|
734
|
+
# break, and an indent wider than the viewport conveys no nesting.
|
|
725
735
|
elsif line_w + w <= width
|
|
726
736
|
line_glyphs.concat(glyphs)
|
|
727
737
|
line_w += w
|
data/lib/tuile/version.rb
CHANGED
data/lib/tuile.rb
CHANGED
|
@@ -32,5 +32,9 @@ module Tuile
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
loader = Zeitwerk::Loader.for_gem
|
|
35
|
+
# Keeps Tuile's one optional dependency optional: the file requires
|
|
36
|
+
# `bigdecimal` at load, so a host app calling Zeitwerk::Loader.eager_load_all
|
|
37
|
+
# would otherwise raise LoadError for a component it never names.
|
|
38
|
+
loader.do_not_eager_load("#{__dir__}/tuile/component/big_decimal_field.rb")
|
|
35
39
|
loader.setup
|
|
36
40
|
end
|