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.
@@ -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 text input drops open, drives by forwarding movement keys, and commits 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
- # the caret stays in the driving input while the caller refills the rows,
9
- # moves the highlight, and reads the pick.
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, per keystroke in the driving input's key handler:
14
- # drop.lines = matches.map { |m| render(m) } # caller filters + renders
15
- # drop.rect = Rect.new(...) # caller anchors + sizes it
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; everything that varies stays
21
- # with the driver: geometry/anchoring, filtering, row rendering, the commit
22
- # action, and ESC/Enter handling. ESC and Enter carry driver-specific tails
23
- # (ESC may revert a query; Enter may commit via {#choose} *or* via a separate
24
- # submit path), so {#move} claims neither — the driver calls {#choose} and
25
- # {#close} from its own branches.
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 (and the caret) stay in its input, and a mouse click selects
37
- # an item without stealing focus — so the input never loses the cursor
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` (they belong to the driving field — caret movement
47
- # and typing) and Enter/ESC (they carry driver-specific tails — see the
48
- # class docs).
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 in the input
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
@@ -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
- # leading whitespace on a wrapped continuation: drop
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Tuile
4
4
  # @return [String]
5
- VERSION = "0.10.0"
5
+ VERSION = "0.11.0"
6
6
  end
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