tuile 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +109 -64
  3. data/DECISIONS.md +1299 -22
  4. data/README.md +18 -13
  5. data/TERMINOLOGY.md +61 -0
  6. data/book/02-repaint.md +1 -1
  7. data/book/03-layout.md +154 -9
  8. data/book/05-focus.md +2 -0
  9. data/book/06-theming.md +1 -1
  10. data/book/07-components.md +202 -37
  11. data/book/README.md +3 -1
  12. data/examples/file_commander.rb +5 -4
  13. data/examples/sampler.rb +320 -133
  14. data/ideas/arrow-key-navigation.md +205 -0
  15. data/ideas/new-components.md +26 -12
  16. data/lib/tuile/buffer.rb +7 -7
  17. data/lib/tuile/component/big_decimal_field.rb +199 -0
  18. data/lib/tuile/component/button.rb +1 -1
  19. data/lib/tuile/component/checkbox.rb +11 -10
  20. data/lib/tuile/component/checkbox_group.rb +31 -26
  21. data/lib/tuile/component/combo_box.rb +18 -33
  22. data/lib/tuile/component/float_field.rb +161 -0
  23. data/lib/tuile/component/info_window.rb +1 -1
  24. data/lib/tuile/component/label.rb +14 -14
  25. data/lib/tuile/component/layout/box.rb +316 -0
  26. data/lib/tuile/component/layout/horizontal.rb +40 -0
  27. data/lib/tuile/component/layout/vertical.rb +41 -0
  28. data/lib/tuile/component/layout.rb +149 -1
  29. data/lib/tuile/component/list.rb +291 -216
  30. data/lib/tuile/component/list_dropdown.rb +82 -24
  31. data/lib/tuile/component/notification.rb +317 -0
  32. data/lib/tuile/component/picker_window.rb +3 -3
  33. data/lib/tuile/component/popup.rb +8 -10
  34. data/lib/tuile/component/progress_bar.rb +1 -1
  35. data/lib/tuile/component/radio_group.rb +32 -30
  36. data/lib/tuile/component/select.rb +251 -0
  37. data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
  38. data/lib/tuile/component/text_area.rb +79 -273
  39. data/lib/tuile/component/text_field.rb +1 -1
  40. data/lib/tuile/component/text_view.rb +191 -177
  41. data/lib/tuile/component/window.rb +8 -8
  42. data/lib/tuile/component.rb +5 -5
  43. data/lib/tuile/screen.rb +1 -1
  44. data/lib/tuile/styled_string.rb +25 -15
  45. data/lib/tuile/version.rb +1 -1
  46. data/lib/tuile/vertical_scroll_bar.rb +6 -6
  47. data/lib/tuile.rb +4 -0
  48. data/sig/tuile.rbs +1670 -406
  49. metadata +11 -1
@@ -3,26 +3,28 @@
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
- # 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
12
+ # drop.renderer = method(:label_for) # caller renders
13
+ # drop.on_item_chosen = ->(_index, item) { commit(item) } # caller commits
14
+ # # …then, from the driver's key handler:
15
+ # drop.items = matches # caller filters
16
+ # drop.anchor_to(rect, rows: matches.size) # below the driver, or flipped
16
17
  # drop.open
17
18
  # return true if drop.move(key) # Up/Down/PgUp/PgDn/^U/^D → list scroll
18
19
  # drop.choose if key == Keys::ENTER # commit the highlight
19
20
  #
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.
21
+ # It owns only what every such dropdown shares — *placement* included, via
22
+ # {#anchor_to}. What stays with the driver: the width **policy** ({#anchor_to}
23
+ # measures nothing itself), filtering, row rendering, the commit action, and
24
+ # ESC/Enter handling. ESC and Enter carry driver-specific tails (ESC may
25
+ # revert a query; Enter may commit via {#choose} *or* via a separate submit
26
+ # path), so {#move} claims neither — the driver calls {#choose} and {#close}
27
+ # from its own branches.
26
28
  #
27
29
  # == Theming
28
30
  # Borderless, told apart from the content beneath by a background tint —
@@ -33,8 +35,8 @@ module Tuile
33
35
  # UI-thread-confined, like every component (see {Screen}).
34
36
  class ListDropdown < Popup
35
37
  # 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
38
+ # while focus stays on it, and a mouse click selects an item without
39
+ # stealing focus — so a driving text input never loses its caret
38
40
  # mid-interaction.
39
41
  class Menu < List
40
42
  def focusable? = false
@@ -43,29 +45,42 @@ module Tuile
43
45
 
44
46
  # Cursor-movement keys forwarded to the list by {#move}: the two vertical
45
47
  # 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).
48
+ # Home/End and `j`/`k` — a jump to the first/last row is the driver's call,
49
+ # and both drivers decline it ({ComboBox}'s field needs Home/End for the
50
+ # caret; {Select} would spend a branch on what a second arrow press already
51
+ # does) — and Enter/ESC, which carry driver-specific tails (see the class
52
+ # docs).
49
53
  # @return [Array<String>]
50
54
  MOVE_KEYS = [Keys::UP_ARROW, Keys::DOWN_ARROW, Keys::PAGE_UP, Keys::PAGE_DOWN,
51
55
  Keys::CTRL_U, Keys::CTRL_D].freeze
52
56
 
57
+ # Most rows shown before the list scrolls; {#anchor_to}'s `max_rows`
58
+ # default.
59
+ # @return [Integer]
60
+ MAX_VISIBLE_ROWS = 10
61
+
53
62
  def initialize
54
63
  @list = Menu.new
55
64
  @list.cursor = List::Cursor.new
56
- @list.show_cursor_when_inactive = true # highlight the selection though focus stays in the input
65
+ @list.show_cursor_when_inactive = true # highlight the selection though focus stays on the driver
57
66
  super(content: @list, modal: false)
58
67
  self.bg_color = Theme.ref(:input_bg_color)
59
68
  end
60
69
 
61
- # @param lines [Array] the rows to show; see {List#lines=}.
70
+ # @param items [Array] the items to show, one row each; see {List#items=}.
62
71
  # @return [void]
63
- def lines=(lines)
64
- @list.lines = lines
72
+ def items=(items)
73
+ @list.items = items
65
74
  end
66
75
 
67
- # @return [Array<StyledString>] the current rows.
68
- def lines = @list.lines
76
+ # @return [Array] the items currently shown.
77
+ def items = @list.items
78
+
79
+ # @param proc [Proc, Method] item -> row; see {List#renderer}.
80
+ # @return [void]
81
+ def renderer=(proc)
82
+ @list.renderer = proc
83
+ end
69
84
 
70
85
  # @param proc [Proc, Method, nil] commit callback; see {List#on_item_chosen}.
71
86
  # @return [void]
@@ -82,6 +97,49 @@ module Tuile
82
97
  # @return [List::Cursor] the list's cursor (the current highlight).
83
98
  def cursor = @list.cursor
84
99
 
100
+ # Sizes and places the dropdown against `anchor`: directly beneath it,
101
+ # flipped above when `rows` won't fit below, clamped — with the list
102
+ # scrolling — when neither side has room. Horizontally the left edges line
103
+ # up, sliding left only far enough to keep the panel on screen.
104
+ #
105
+ # drop.anchor_to(field.rect, rows: matches.size) # field width
106
+ # drop.anchor_to(rect, rows: items.size, width: measured) # own width
107
+ #
108
+ # Vertical flips but horizontal slides because covering the driver would
109
+ # hide what is being chosen, while sharing its columns is the point.
110
+ #
111
+ # @param anchor [Rect] the driver's rect; the dropdown never covers it.
112
+ # @param rows [Integer] how many rows there are to show — the content
113
+ # count, not the height: more than fits turns the scrollbar on. `0`
114
+ # collapses the dropdown to an empty rect (drivers close instead).
115
+ # @param width [Integer] the panel's width in columns, clamped to the
116
+ # screen. Defaults to the anchor's, which lines both edges up with a
117
+ # field; a driver that measured its labels passes its own. A label wider
118
+ # than the screen clips — {List} has no horizontal scrolling.
119
+ # @param max_rows [Integer] rows shown before the list scrolls.
120
+ # @return [void]
121
+ def anchor_to(anchor, rows:, width: anchor.width, max_rows: MAX_VISIBLE_ROWS)
122
+ desired = [rows, max_rows].min
123
+ below = screen.size.height - (anchor.top + 1)
124
+ above = anchor.top
125
+ if desired <= below
126
+ top = anchor.top + 1
127
+ height = desired
128
+ elsif above >= below
129
+ height = [desired, above].min
130
+ top = anchor.top - height
131
+ else
132
+ height = below
133
+ top = anchor.top + 1
134
+ end
135
+ width = [width, screen.size.width].min
136
+ self.size = Size.new(width, height)
137
+ self.rect = Rect.new([anchor.left, screen.size.width - width].min.clamp(0, nil), top, width, height)
138
+ # After the geometry: the setter rebuilds the list's padded rows against
139
+ # the width it can see, and the gutter takes a column off it.
140
+ @list.scrollbar_visibility = rows > height ? :visible : :gone
141
+ end
142
+
85
143
  # Forwards a cursor-movement key to the list. The driver calls this from
86
144
  # its own key handler; a truthy return means "consumed — stop here", falsy
87
145
  # means "not mine — proceed with normal editing/dispatch". Only {MOVE_KEYS}
@@ -0,0 +1,317 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A transient message in the screen's top-right corner — the TTY toast:
6
+ #
7
+ # Component::Notification.show("Saved")
8
+ # Component::Notification.show("Disk full", color: Theme.ref(:error))
9
+ #
10
+ # ┌─────────┐ ← flush: row 0, right edge at the last column
11
+ # │Saved │ ← oldest on top, retires in 3 s
12
+ # │Disk full│ ← then this one, 3 s after that
13
+ # └─────────┘
14
+ #
15
+ # {show} is the only entry point ({new} is private): it finds the live
16
+ # notification and appends to it, so a burst stacks as entries in one box
17
+ # instead of opening five overlapping ones.
18
+ #
19
+ # One repeating ticker retires the **oldest** entry every {DISPLAY_SECONDS}
20
+ # and closes the box when the last one goes — five messages raised together
21
+ # appear at once and drain over fifteen seconds. A message arriving mid-cycle
22
+ # waits its turn and does *not* restart the clock, so the bottom entry of a
23
+ # full box is visible for about `N × DISPLAY_SECONDS`. Past {MAX_MESSAGES} a
24
+ # message is dropped and reported to {Tuile.logger}; an app notifying faster
25
+ # than that wants a {Component::LogWindow}.
26
+ #
27
+ # The box is flush to the corner, at most {WIDTH_FRACTION} of the screen wide
28
+ # (floor {MIN_CAP_WIDTH}) and {HEIGHT_FRACTION} tall, and **grows but never
29
+ # shrinks** while it lives; a long message wraps to {MAX_ROWS_PER_MESSAGE}
30
+ # rows and is then ellipsized, and entries past the height cap wait unpainted.
31
+ # `DECISIONS.md` `D-notification` has why each of those is what it is.
32
+ #
33
+ # Three things it deliberately doesn't do:
34
+ #
35
+ # - **Take focus, or receive keys.** A non-modal popup sits off the
36
+ # key-dispatch scope ({ScreenPane#handle_key}), so not even {Popup}'s
37
+ # `q`/ESC arrives here. A left click dismisses ({#handle_mouse}); an app
38
+ # wanting a key registers a global shortcut and calls {#close}.
39
+ # - **Follow a theme flip.** A `Theme::Ref` `color:` is resolved once, when
40
+ # the message is added — a toast lives seconds, so there is no
41
+ # {Component#on_theme_changed} rebuild.
42
+ # - **Take a size.** {#size=} raises; the messages decide.
43
+ class Notification < Popup
44
+ # Most messages held at once, counting both the painted ones and any
45
+ # waiting for room. Chosen from reading time rather than geometry: the
46
+ # drain rate is one message per {DISPLAY_SECONDS}, so the queue length *is*
47
+ # a duration, and 5 × 3 s is about the longest a corner box should own the
48
+ # screen — and about as many short lines as anyone reads.
49
+ # @return [Integer]
50
+ MAX_MESSAGES = 5
51
+
52
+ # Rows a single message may occupy before it is ellipsized.
53
+ # @return [Integer]
54
+ MAX_ROWS_PER_MESSAGE = 3
55
+
56
+ # Seconds between retirements — how long the oldest message is held.
57
+ # @return [Float]
58
+ DISPLAY_SECONDS = 3.0
59
+
60
+ # Fraction of the screen width the box may not exceed (see {MIN_CAP_WIDTH}).
61
+ # @return [Float]
62
+ WIDTH_FRACTION = 0.4
63
+
64
+ # Fraction of the screen height the box may not exceed.
65
+ # @return [Float]
66
+ HEIGHT_FRACTION = 0.4
67
+
68
+ # Floor under the width cap, so 40 % of an 80-column terminal doesn't
69
+ # ellipsize every message down to five words.
70
+ # @return [Integer]
71
+ MIN_CAP_WIDTH = 34
72
+
73
+ # Separator for re-joining wrapped rows before ellipsizing.
74
+ # @return [StyledString]
75
+ SPACE = StyledString.parse(" ")
76
+
77
+ # Hard-line separator handed to {TextView#text=}.
78
+ # @return [StyledString]
79
+ ROW_BREAK = StyledString.parse("\n")
80
+ private_constant :SPACE, :ROW_BREAK
81
+
82
+ # Shows `text` in the corner, creating the box if none is open and
83
+ # appending to it if one is.
84
+ #
85
+ # @param text [String, StyledString, nil] the message. A `String` is parsed
86
+ # via {StyledString.parse}, so embedded ANSI is honored. `nil` and the
87
+ # empty string are no-ops (nothing is shown, nothing is created).
88
+ # @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil]
89
+ # applied to every span of the message via {StyledString#with_fg}. A
90
+ # {Theme::Ref} is resolved against the current theme *now* — see the
91
+ # class docs on theme following. `nil` leaves the message's own colors
92
+ # alone.
93
+ # @return [Notification, nil] the live notification, or `nil` when `text`
94
+ # was empty.
95
+ # @raise [Tuile::Error] when the screen is closed, or when called from a
96
+ # thread that doesn't currently own the UI — a background job raising a
97
+ # notification must marshal it: `screen.event_queue.submit { ... }`.
98
+ def self.show(text, color: nil)
99
+ Screen.instance.check_locked
100
+ return nil if StyledString.parse(text).empty?
101
+
102
+ live = Screen.instance.pane.popups.find { _1.is_a?(Notification) }
103
+ return live.tap { _1.add_message(text, color: color) } unless live.nil?
104
+
105
+ # Message first, so the box is sized before it is mounted: opening an
106
+ # empty 0×0 popup and then growing it would paint a frame of nothing.
107
+ new.tap do |notification|
108
+ notification.add_message(text, color: color)
109
+ notification.open
110
+ end
111
+ end
112
+
113
+ private_class_method :new
114
+
115
+ def initialize
116
+ # Built before `super`, because Popup#initialize assigns the content and
117
+ # calls #reposition, and our override reads every one of these.
118
+ @messages = []
119
+ @high_water = 0
120
+ @ticker = nil
121
+ @view = TextView.new
122
+ @window = Window.new
123
+ @window.content = @view
124
+ super(content: @window, modal: false)
125
+ end
126
+
127
+ # Load-bearing, not cosmetic: focus landing inside a non-modal popup sits
128
+ # outside the key-dispatch scope, where {ScreenPane#handle_key} delivers to
129
+ # nobody — every keystroke would go dead until the user pressed Tab.
130
+ # @return [Boolean] false.
131
+ def focusable? = false
132
+
133
+ # @return [Boolean] false — see {#focusable?}.
134
+ def tab_stop? = false
135
+
136
+ # Empty: a non-modal popup never owns the status bar, and {Popup}'s
137
+ # inherited `q Close` hint would be a lie here — no key ever reaches a
138
+ # notification.
139
+ # @return [String]
140
+ def keyboard_hint = ""
141
+
142
+ # Appends a message, dropping it (with a {Tuile.logger} warning) once
143
+ # {MAX_MESSAGES} are held. Public so a caller holding the instance can
144
+ # append without repeating {show}'s lookup.
145
+ # @param text [String, StyledString, nil] see {show}. Empty is a no-op.
146
+ # @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil]
147
+ # see {show}.
148
+ # @return [void]
149
+ # @raise [Tuile::Error] see {show}.
150
+ def add_message(text, color: nil)
151
+ # Explicit rather than inherited-through-invalidate: this appends to
152
+ # @messages before anything repaints, so a wrong-thread call has to fail
153
+ # before the message is recorded, not after.
154
+ screen.check_locked
155
+ message = build_message(text, color)
156
+ return if message.empty?
157
+
158
+ if @messages.size >= MAX_MESSAGES
159
+ Tuile.logger.warn("Notification: dropping #{message.to_s.inspect}, " \
160
+ "#{MAX_MESSAGES} messages already queued")
161
+ return
162
+ end
163
+
164
+ @messages << message
165
+ @high_water = [@high_water, natural_width(message)].max
166
+ reposition
167
+ sync_ticker
168
+ end
169
+
170
+ # Recomputes the box from its messages and re-anchors it to the screen's
171
+ # top-right corner — so a SIGWINCH re-wraps and re-anchors, where
172
+ # {Popup#reposition} would have kept the stale left column of a *derived*
173
+ # position (off-screen entirely if the terminal narrowed).
174
+ #
175
+ # Rebuilds the {TextView}'s text too, and every mutation routes through
176
+ # here, because the four are one computation: the wrap width *is* the box
177
+ # width, the height *is* the wrapped row count, the left edge *is* derived
178
+ # from the width.
179
+ # @return [void]
180
+ def reposition
181
+ if @messages.empty?
182
+ self.rect = Rect.new(0, 0, 0, 0)
183
+ return
184
+ end
185
+
186
+ width = box_width
187
+ rows = @messages.flat_map { |message| wrap_message(message, width - 2) }
188
+ height = [rows.size + 2, cap_height].min
189
+ @size = Size.new(width, height)
190
+ @view.text = join_rows(rows)
191
+ self.rect = Rect.new([screen.size.width - width, 0].max, 0, width, height)
192
+ end
193
+
194
+ # A notification is sized by its messages, so this always raises. Failing
195
+ # loudly beats accepting a size the next {#reposition} would discard.
196
+ # @param _new_size [Size, Fraction]
197
+ # @raise [Tuile::Error] always.
198
+ # @return [void]
199
+ def size=(_new_size)
200
+ raise Tuile::Error, "Notification sizes itself from its messages; #{self.class}#size= is not settable"
201
+ end
202
+
203
+ # A left click dismisses the whole box, every message with it. Other buttons
204
+ # are consumed and inert — including the scroll wheel, which would otherwise
205
+ # nuke the box on a stray spin.
206
+ #
207
+ # Deliberately *replaces* rather than augments: neither `super` nor
208
+ # {HasContent#handle_mouse} may run, since both end at a
209
+ # `screen.focused = …` inside this subtree (see {#focusable?}).
210
+ # @param event [MouseEvent]
211
+ # @return [void]
212
+ def handle_mouse(event)
213
+ close if event.button == :left
214
+ end
215
+
216
+ # @return [void]
217
+ def on_attached = sync_ticker
218
+
219
+ # @return [void]
220
+ def on_detached = sync_ticker
221
+
222
+ private
223
+
224
+ # Retires the oldest message, closing the box when it was the last. Runs on
225
+ # the event-loop thread, from the ticker.
226
+ # @return [void]
227
+ def retire_oldest
228
+ @messages.shift
229
+ @messages.empty? ? close : reposition
230
+ sync_ticker
231
+ end
232
+
233
+ # Syncs the retirement clock from the invariant "something to retire, and on
234
+ # screen" — the sole writer of `@ticker`. Four sites change whether it is
235
+ # wanted (append, a retirement that empties the queue, {#close}, detach),
236
+ # which is the 2×2 a start-in-{#on_attached} / cancel-in-{#on_detached} pair
237
+ # gets half wrong. The early return is also what keeps an append from
238
+ # *restarting* the clock and extending the oldest message's life.
239
+ # @return [void]
240
+ def sync_ticker
241
+ want = attached? && !@messages.empty?
242
+ return if want == !@ticker.nil?
243
+
244
+ if want
245
+ @ticker = screen.event_queue.tick(DISPLAY_SECONDS) { retire_oldest }
246
+ else
247
+ @ticker.cancel
248
+ @ticker = nil
249
+ end
250
+ end
251
+
252
+ # @param text [String, StyledString, nil]
253
+ # @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil]
254
+ # @return [StyledString]
255
+ def build_message(text, color)
256
+ message = StyledString.parse(text)
257
+ return message if color.nil? || message.empty?
258
+
259
+ message.with_fg(color.is_a?(Theme::Ref) ? color.resolve(screen.theme) : color)
260
+ end
261
+
262
+ # Columns the message would like, ignoring wrapping — the *widest* of its
263
+ # hard lines, not the sum of its spans (which would add every line
264
+ # together for a message carrying `\n`).
265
+ # @param message [StyledString]
266
+ # @return [Integer]
267
+ def natural_width(message) = message.lines.map(&:display_width).max || 0
268
+
269
+ # Grow-only: the high-water mark is kept in *desired* columns and the cap
270
+ # is applied here, last. Storing the clamped value instead would let a
271
+ # SIGWINCH that narrows the terminal ratchet the box permanently down to
272
+ # the narrow cap, with nothing to restore it when the terminal widens.
273
+ # @return [Integer]
274
+ def box_width = [@high_water + 2, cap_width].min
275
+
276
+ # @return [Integer]
277
+ def cap_width
278
+ [[(screen.size.width * WIDTH_FRACTION).to_i, MIN_CAP_WIDTH].max, screen.size.width].min
279
+ end
280
+
281
+ # @return [Integer] at least 3: two border rows plus one row of message.
282
+ def cap_height
283
+ [[(screen.size.height * HEIGHT_FRACTION).to_i, 3].max, screen.size.height].min
284
+ end
285
+
286
+ # Wraps one message to `width` columns, capped at {MAX_ROWS_PER_MESSAGE}
287
+ # rows.
288
+ #
289
+ # The overflow is ellipsized from the *joined remainder*, not by
290
+ # ellipsizing the last kept row: that row usually already fits `width`, so
291
+ # {StyledString#ellipsize} would be a no-op and the message would be
292
+ # truncated with no `…` to say so.
293
+ # @param message [StyledString]
294
+ # @param width [Integer]
295
+ # @return [Array<StyledString>]
296
+ def wrap_message(message, width)
297
+ rows = message.wrap(width)
298
+ return rows if rows.size <= MAX_ROWS_PER_MESSAGE
299
+
300
+ kept = rows.take(MAX_ROWS_PER_MESSAGE - 1)
301
+ rest = rows[(MAX_ROWS_PER_MESSAGE - 1)..].inject { |joined, row| joined + SPACE + row }
302
+ kept + [rest.ellipsize(width)]
303
+ end
304
+
305
+ # Joins pre-wrapped rows into one {StyledString} with `\n` separators, so
306
+ # {TextView} takes them as hard lines and its own wrap is a no-op over
307
+ # them (each row already fits the width it will be painted at).
308
+ # @param rows [Array<StyledString>]
309
+ # @return [StyledString]
310
+ def join_rows(rows)
311
+ return StyledString::EMPTY if rows.empty?
312
+
313
+ rows.inject { |joined, row| joined + ROW_BREAK + row }
314
+ end
315
+ end
316
+ end
317
+ end
@@ -37,9 +37,10 @@ module Tuile
37
37
  @options = options.map { Option.new(_1[0], _1[1]) }
38
38
  @block = block
39
39
  list = Component::List.new
40
- list.lines = @options.map { "#{_1.key} #{screen.theme.hint(_1.caption)}" }
40
+ list.renderer = ->(option) { "#{option.key} #{screen.theme.hint(option.caption)}" }
41
+ list.items = @options
41
42
  list.cursor = Component::List::Cursor.new
42
- list.on_item_chosen = ->(index, _line) { select_option(@options[index].key) }
43
+ list.on_item_chosen = ->(_index, option) { select_option(option.key) }
43
44
  self.content = list
44
45
  # Optional hook for a containing Popup to dismiss itself after a pick.
45
46
  @on_pick = nil
@@ -83,7 +84,6 @@ module Tuile
83
84
  popup = Popup.new(content: picker)
84
85
  picker.on_pick = -> { popup.close }
85
86
  popup.open
86
- popup
87
87
  end
88
88
 
89
89
  protected
@@ -98,19 +98,17 @@ module Tuile
98
98
 
99
99
  # Mounts this popup on the {Screen}, re-resolving its {#size} against the
100
100
  # current screen first.
101
- # @return [void]
101
+ #
102
+ # popup = Component::Popup.new(content: window).open # construct and mount
103
+ #
104
+ # There is deliberately no class-level `Popup.open` factory — see
105
+ # `DECISIONS.md` `D-popup-open`; returning `self` is what keeps the
106
+ # one-liner above available without one.
107
+ # @return [self]
102
108
  def open
103
109
  reposition
104
110
  screen.add_popup(self)
105
- end
106
-
107
- # Constructs and opens a popup in one call.
108
- # @param content [Component, nil]
109
- # @param modal [Boolean] see {#initialize}.
110
- # @param size [Size, Fraction] see {#initialize}.
111
- # @return [Popup] the opened popup.
112
- def self.open(content: nil, modal: true, size: Fraction::HALF)
113
- Popup.new(content: content, modal: modal, size: size).tap(&:open)
111
+ self
114
112
  end
115
113
 
116
114
  # Removes this popup from the {Screen}. No-op if not currently open.
@@ -207,7 +207,7 @@ module Tuile
207
207
  def repaint
208
208
  return if rect.empty?
209
209
 
210
- draw_line(rect.left, rect.top, StyledString.styled(glyphs(rect.width), fg: resolved_bar_color))
210
+ draw_text(rect.left, rect.top, StyledString.styled(glyphs(rect.width), fg: resolved_bar_color))
211
211
  clear_background(Rect.new(rect.left, rect.top + 1, rect.width, rect.height - 1)) if rect.height > 1
212
212
  end
213
213
 
@@ -21,12 +21,13 @@ module Tuile
21
21
  # initial state, and assigning it is the only way back, since Space on the
22
22
  # already-selected row is a no-op rather than a deselect.
23
23
  #
24
- # Composes rather than subclasses, like {ComboBox}: a {List} is its single
25
- # {HasContent} child, which is where the cursor, scrolling, the scrollbar
26
- # and per-row mouse hit-testing come from. `content` is that list, so an app
27
- # can tune it (`scrollbar_visibility`, `show_cursor_when_inactive`, …). Rows
28
- # beyond {#rect}'s height scroll; the inner list is the tab stop, not the
29
- # group.
24
+ # Composes rather than subclasses, like {ComboBox}: a {List} of the items
25
+ # is its single {HasContent} child, which is where the cursor, scrolling,
26
+ # the scrollbar and per-row mouse hit-testing come from — the group only
27
+ # supplies the {List#renderer} that puts the marker in front of the label.
28
+ # `content` is that list, so an app can tune it (`scrollbar_visibility`,
29
+ # `show_cursor_when_inactive`, …). Rows beyond {#rect}'s height scroll; the
30
+ # inner list is the tab stop, not the group.
30
31
  #
31
32
  # == The cursor is chrome
32
33
  # The cursor and the selection are two independent things, as in
@@ -50,7 +51,7 @@ module Tuile
50
51
  # == Implementation details
51
52
  # Two `==`-equal items share one selection, so selecting either marks both
52
53
  # rows; two *distinct* items that merely render the same label stay
53
- # independent, because a row resolves to an item by index.
54
+ # independent, because a row resolves to its own item, never to its label.
54
55
  #
55
56
  # Rows are `(*) `/`( ) ` literals, mirroring {Checkbox}'s convention rather
56
57
  # than importing constants from it. ASCII deliberately: `(•)` would measure
@@ -69,7 +70,6 @@ module Tuile
69
70
  # doesn't matter to a form helper.
70
71
  def initialize(items: [], value: nil)
71
72
  super()
72
- @items = items.to_a
73
73
  @item_label = :to_s.to_proc
74
74
  @value = value
75
75
  @on_value_change = nil
@@ -77,13 +77,14 @@ module Tuile
77
77
  list = List.new
78
78
  # A List has no cursor at all by default (Cursor::None, position -1).
79
79
  list.cursor = List::Cursor.new
80
- list.on_item_chosen = ->(index, _line) { select_at(index) }
80
+ list.renderer = method(:render_row)
81
+ list.on_item_chosen = ->(_index, item) { self.value = item }
82
+ list.items = items.to_a
81
83
  self.content = list
82
- rebuild_rows
83
84
  end
84
85
 
85
86
  # @return [Array] the presented items.
86
- attr_reader :items
87
+ def items = content.items
87
88
 
88
89
  # @return [Proc, Method] item -> row label (a `String`, {StyledString}, or
89
90
  # anything with `#to_s`); `:to_s` by default.
@@ -97,18 +98,17 @@ module Tuile
97
98
  def items=(new_items)
98
99
  raise TypeError, "expected Array, got #{new_items.inspect}" unless new_items.is_a?(Array)
99
100
 
100
- @items = new_items
101
- # Before the rebuild, so the single {List#on_cursor_changed} that
102
- # {List#lines=} fires reports the final row rather than a stale one.
103
- clamp_cursor
104
- rebuild_rows
101
+ # Before the items land, so the single {List#on_cursor_changed} that
102
+ # {List#items=} fires reports the final row rather than a stale one.
103
+ clamp_cursor(new_items.size)
104
+ content.items = new_items
105
105
  end
106
106
 
107
107
  # @param proc [Proc, Method] item -> row label.
108
108
  # @return [void]
109
109
  def item_label=(proc)
110
110
  @item_label = proc
111
- rebuild_rows
111
+ content.refresh_rows
112
112
  end
113
113
 
114
114
  # Selects `new_value`, firing {HasValue#on_value_change} when it really
@@ -122,7 +122,7 @@ module Tuile
122
122
  return if value == new_value
123
123
 
124
124
  super
125
- rebuild_rows
125
+ content.refresh_rows
126
126
  end
127
127
 
128
128
  # Selects the cursor row on Space. Nothing else is claimed: the composed
@@ -147,33 +147,35 @@ module Tuile
147
147
 
148
148
  private
149
149
 
150
- # Selects the item on row `index`; an index outside {#items} is ignored.
150
+ # Selects the item on row `index`; an index outside {#items} is ignored —
151
+ # {List::Cursor::None}'s `-1` would otherwise select the *last* item.
151
152
  # @param index [Integer]
152
153
  # @return [void]
153
154
  def select_at(index)
154
- return unless index.between?(0, @items.size - 1)
155
+ return unless index.between?(0, items.size - 1)
155
156
 
156
- self.value = @items[index]
157
+ self.value = items[index]
157
158
  end
158
159
 
159
- # Re-renders every row from the current items, labels and selection.
160
- # @return [void]
161
- def rebuild_rows
162
- content.lines = @items.map do |item|
163
- StyledString.plain(item == value ? "(*) " : "( ) ") + label_for(item)
164
- end
160
+ # @param item [Object]
161
+ # @return [StyledString] the item's row: its label behind a selection
162
+ # marker. The {List} calls this at paint time, so the marker tracks
163
+ # {#value} without re-rendering anything but the visible rows.
164
+ def render_row(item)
165
+ StyledString.plain(item == value ? "(*) " : "( ) ") + label_for(item)
165
166
  end
166
167
 
167
168
  # Pulls an over-range cursor back onto the last row (row 0 when there are
168
- # none). {List#lines=} leaves a stale cursor alone, which would strand it
169
+ # none). {List#items=} leaves a stale cursor alone, which would strand it
169
170
  # off-content: no highlight, a dead Enter, and a Space that resolves to
170
171
  # `nil` and silently clears the selection.
172
+ # @param item_count [Integer] size of the incoming item list.
171
173
  # @return [void]
172
- def clamp_cursor
174
+ def clamp_cursor(item_count)
173
175
  cursor = content.cursor
174
176
  # go_to_last funnels through Cursor#go's clamp(0, nil), so an empty
175
177
  # items list floors at 0 instead of going negative.
176
- cursor.go_to_last(@items.size) if cursor.position >= @items.size
178
+ cursor.go_to_last(item_count) if cursor.position >= item_count
177
179
  end
178
180
 
179
181
  # @param item [Object]