tuile 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/DECISIONS.md +1961 -0
  4. data/README.md +82 -48
  5. data/book/01-first-app.md +186 -0
  6. data/book/02-repaint.md +177 -0
  7. data/book/03-layout.md +379 -0
  8. data/book/04-event-loop.md +295 -0
  9. data/book/05-focus.md +219 -0
  10. data/book/06-theming.md +302 -0
  11. data/book/07-components.md +585 -0
  12. data/book/08-testing.md +199 -0
  13. data/book/09-styled-text.md +132 -0
  14. data/book/README.md +85 -0
  15. data/examples/hello_world.rb +1 -2
  16. data/examples/sampler.rb +435 -20
  17. data/ideas/new-components.md +109 -0
  18. data/ideas/per-component-buffers.md +55 -0
  19. data/lib/tuile/buffer.rb +113 -43
  20. data/lib/tuile/color.rb +4 -10
  21. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  22. data/lib/tuile/component/button.rb +25 -29
  23. data/lib/tuile/component/checkbox.rb +133 -0
  24. data/lib/tuile/component/checkbox_group.rb +188 -0
  25. data/lib/tuile/component/combo_box.rb +281 -0
  26. data/lib/tuile/component/has_caption.rb +44 -0
  27. data/lib/tuile/component/has_content.rb +5 -5
  28. data/lib/tuile/component/has_value.rb +64 -0
  29. data/lib/tuile/component/info_window.rb +4 -2
  30. data/lib/tuile/component/integer_field.rb +135 -0
  31. data/lib/tuile/component/label.rb +20 -25
  32. data/lib/tuile/component/layout.rb +3 -26
  33. data/lib/tuile/component/list.rb +8 -33
  34. data/lib/tuile/component/list_dropdown.rb +106 -0
  35. data/lib/tuile/component/log_window.rb +0 -14
  36. data/lib/tuile/component/password_field.rb +105 -0
  37. data/lib/tuile/component/popup.rb +70 -79
  38. data/lib/tuile/component/progress_bar.rb +278 -0
  39. data/lib/tuile/component/radio_group.rb +188 -0
  40. data/lib/tuile/component/text_area.rb +189 -65
  41. data/lib/tuile/component/text_field.rb +170 -32
  42. data/lib/tuile/component/text_view.rb +57 -137
  43. data/lib/tuile/component/window.rb +88 -121
  44. data/lib/tuile/component.rb +246 -142
  45. data/lib/tuile/event_queue.rb +39 -21
  46. data/lib/tuile/fake_event_queue.rb +32 -7
  47. data/lib/tuile/fake_screen.rb +4 -5
  48. data/lib/tuile/fraction.rb +42 -0
  49. data/lib/tuile/screen.rb +210 -109
  50. data/lib/tuile/screen_pane.rb +56 -44
  51. data/lib/tuile/styled_string.rb +112 -83
  52. data/lib/tuile/theme.rb +78 -41
  53. data/lib/tuile/version.rb +1 -1
  54. data/sig/tuile.rbs +2291 -890
  55. metadata +28 -9
  56. data/ideas/back-buffer.md +0 -217
  57. data/lib/tuile/sizing.rb +0 -59
@@ -4,18 +4,25 @@ module Tuile
4
4
  class Component
5
5
  # An overlay that wraps any {Component} as its content. Popup itself
6
6
  # paints nothing — it's a transparent host that handles its lifecycle
7
- # ({#open} / {#close} / {#open?}, ESC/q to close) and auto-sizes to the
8
- # wrapped content.
7
+ # ({#open} / {#close} / {#open?}, ESC/q to close) and holds a top-down
8
+ # {#size} the {Screen} applies.
9
+ #
10
+ # The popup does *not* size itself to its content. Its box is declared by
11
+ # {#size} — a {Fraction} (resolved against the screen every layout pass, so
12
+ # it tracks resize) or an absolute {Size} (clamped to the screen). The
13
+ # default is {Fraction::HALF}: half the screen, centered. The wrapped
14
+ # content then fills that box and handles its own overflow by wrapping and
15
+ # scrolling, so use content that can — a {Component::TextView} or
16
+ # {Component::TextArea} — for anything longer than fits. A
17
+ # {Component::Label} only truncates.
9
18
  #
10
19
  # Modal by default: it centers on the screen, grabs focus, eats keys, and
11
20
  # blocks clicks beneath it. Pass `modal: false` for a non-modal overlay
12
- # that floats above the content (still painted on top, still auto-sized)
13
- # without taking focus or capturing input — the caller positions it (via
14
- # {#rect=}) and drives it from app code. That is the building block for an
15
- # autocomplete/slash-command list anchored to a {Component::TextField} or
16
- # {Component::TextArea} caret: typing keeps focus (and the cursor) in the
17
- # input, an {Component::TextInput#on_change} listener refills the list, and
18
- # an {Component::TextInput#on_key} interceptor forwards Up/Down/Enter to it.
21
+ # that floats above the content without taking focus or capturing input —
22
+ # the caller positions it (via {#rect=}), sizes it, and drives it from app
23
+ # code. That's the building block for an autocomplete/slash-command list
24
+ # anchored to a text field's caret: typing keeps focus in the input while
25
+ # the caller refills and drives the overlay.
19
26
  #
20
27
  # The wrapped content fills the popup's full {#rect}; if you want a frame
21
28
  # and caption, wrap a {Component::Window} (or any subclass — including
@@ -28,30 +35,52 @@ module Tuile
28
35
  # Bare content also works (a {Component::Label}, a {Component::List}…), in
29
36
  # which case the popup is borderless.
30
37
  #
31
- # `q` and ESC close the popup. Any nested {Component::TextField} that owns
32
- # the hardware cursor swallows printable keys first via the standard
33
- # cursor-owner suppression in {Component#handle_key}, so typing `q` into a
34
- # text field doesn't dismiss the popup.
38
+ # `q` and ESC close the popup — handled here, at the top of the popup's own
39
+ # subtree, so the key only arrives after every component on the focus chain
40
+ # declined it (see {ScreenPane#handle_key}). That's why typing `q` into a
41
+ # nested {Component::TextField} doesn't dismiss the popup: the field
42
+ # consumes it first.
35
43
  class Popup < Component
36
44
  include Component::HasContent
37
45
 
38
46
  # @param content [Component, nil] initial content; can be set later via
39
- # {#content=}. When provided here, the popup auto-sizes to fit.
47
+ # {#content=}. The content fills the popup's {#rect}; it does not
48
+ # determine the popup's size.
40
49
  # @param modal [Boolean] true (default) for a centered, focus-grabbing,
41
50
  # input-capturing modal; false for a non-modal overlay the caller
42
51
  # positions and drives (see the class docs).
43
- def initialize(content: nil, modal: true)
52
+ # @param size [Size, Fraction] the popup's size, applied top-down. A
53
+ # {Fraction} is resolved against the screen each layout pass; a {Size}
54
+ # is clamped to the screen. Defaults to {Fraction::HALF}.
55
+ def initialize(content: nil, modal: true, size: Fraction::HALF)
44
56
  super()
45
57
  @modal = modal
58
+ @size = size
46
59
  @content = nil
47
60
  self.content = content unless content.nil?
61
+ reposition
48
62
  end
49
63
 
64
+ # @return [Size, Fraction] the popup's declared size. See {#size=}.
65
+ attr_reader :size
66
+
50
67
  # @return [Boolean] whether this popup is modal. See {#initialize}.
51
68
  def modal? = @modal
52
69
 
53
70
  def focusable? = true
54
71
 
72
+ # Sets the popup's size and repositions it. Accepts a {Fraction}
73
+ # (resolved against the screen every layout pass, so it tracks resize) or
74
+ # an absolute {Size} (clamped to the screen). This is **authoritative**,
75
+ # not a preference: the screen applies exactly what you ask for (clamped),
76
+ # with no negotiation — a popup has no siblings to compete with.
77
+ # @param new_size [Size, Fraction]
78
+ # @return [void]
79
+ def size=(new_size)
80
+ @size = new_size
81
+ reposition
82
+ end
83
+
55
84
  # Reassigns the popup's rect, escalating to a full scene repaint when an
56
85
  # open popup shrinks or moves so its new rect no longer covers the cells
57
86
  # it previously painted. A popup overdraws the scene without clipping and
@@ -67,20 +96,21 @@ module Tuile
67
96
  screen.needs_full_repaint if open? && !new_rect.contains_rect?(old_rect)
68
97
  end
69
98
 
70
- # Mounts this popup on the {Screen}. Recomputes the popup's size from
71
- # the current content first, so reopening a popup whose content has
72
- # grown or shrunk while closed picks up the new size.
99
+ # Mounts this popup on the {Screen}, re-resolving its {#size} against the
100
+ # current screen first.
73
101
  # @return [void]
74
102
  def open
75
- update_rect unless @content.nil?
103
+ reposition
76
104
  screen.add_popup(self)
77
105
  end
78
106
 
79
107
  # Constructs and opens a popup in one call.
80
108
  # @param content [Component, nil]
109
+ # @param modal [Boolean] see {#initialize}.
110
+ # @param size [Size, Fraction] see {#initialize}.
81
111
  # @return [Popup] the opened popup.
82
- def self.open(content: nil)
83
- Popup.new(content: content).tap(&:open)
112
+ def self.open(content: nil, modal: true, size: Fraction::HALF)
113
+ Popup.new(content: content, modal: modal, size: size).tap(&:open)
84
114
  end
85
115
 
86
116
  # Removes this popup from the {Screen}. No-op if not currently open.
@@ -94,44 +124,29 @@ module Tuile
94
124
  screen.has_popup?(self)
95
125
  end
96
126
 
97
- # Recenters the popup on the screen, preserving its current width/height.
98
- # Called automatically by the screen's layout pass and by {#content=}
99
- # when the popup is open.
127
+ # Re-resolves {#size} against the current screen and repositions the popup
128
+ # *itself* (this is not laying out content — the popup's own rect): a
129
+ # modal popup recenters; a non-modal overlay keeps its caller-assigned
130
+ # top-left (only its size follows the screen). Called on {#open}, on
131
+ # {#size=}, and by the screen's layout pass (so a {Fraction} size tracks
132
+ # SIGWINCH).
133
+ #
134
+ # The final rect is computed and assigned in one step rather than sizing
135
+ # at the origin and then centering: the intermediate origin rect rarely
136
+ # covers the previous one, which would make {#rect=}'s shrink/move
137
+ # detection fire a full repaint on every resize.
100
138
  # @return [void]
101
- def center
102
- self.rect = rect.centered(screen.size)
103
- end
104
-
105
- # @return [Integer] max height the popup will grow to fit its content.
106
- # Defers to the content's {Component#popup_max_height} advice when it
107
- # gives one, else defaults to 12. Override in a subclass to allow
108
- # taller popups regardless of content.
109
- def max_height = @content&.popup_max_height || 12
110
-
111
- # @return [Integer] min height the popup occupies even when its content
112
- # is shorter. Defers to the content's {Component#popup_min_height}
113
- # advice when it gives one, else defaults to 0 (size purely to
114
- # content) — so a {Component::LogWindow} stays readable while only a
115
- # few lines are in without callers wiring up a subclass. Override in a
116
- # subclass to keep any popup from collapsing to a couple of rows.
117
- # Capped at the same 4/5-of-screen ceiling {#update_rect} applies.
118
- def min_height = @content&.popup_min_height || 0
119
-
120
- # Sets the popup's content and auto-sizes the popup to fit.
121
- # @param new_content [Component, nil]
122
- def content=(new_content)
123
- super
124
- update_rect unless new_content.nil?
139
+ def reposition
140
+ size = @size.is_a?(Fraction) ? @size.resolve(screen.size) : @size.clamp(screen.size)
141
+ r = Rect.new(rect.left, rect.top, size.width, size.height)
142
+ r = r.centered(screen.size) if modal?
143
+ self.rect = r
125
144
  end
126
145
 
127
- # Re-sizes (and recenters, when open) whenever the wrapped content's
128
- # natural size changes — e.g. a {Label}'s `text=`, a {List}'s
129
- # `add_line`, or a nested {Window} whose own content grew (the window
130
- # recomputes its {Component#content_size} and the change bubbles here).
131
- # @param _child [Component]
146
+ # Recenters the popup on the screen, preserving its current width/height.
132
147
  # @return [void]
133
- def on_child_content_size_changed(_child)
134
- update_rect
148
+ def center
149
+ self.rect = rect.centered(screen.size)
135
150
  end
136
151
 
137
152
  # Hint for the status bar: own "q Close" plus the wrapped content's hint.
@@ -164,30 +179,6 @@ module Tuile
164
179
  def layout(content)
165
180
  content.rect = rect
166
181
  end
167
-
168
- private
169
-
170
- # Recompute width/height from {#content}'s natural size and recenter
171
- # if currently open. Called whenever content is (re)assigned.
172
- #
173
- # Computes the final (centered) rect and assigns it in one step rather
174
- # than positioning at the origin and then centering: the intermediate
175
- # origin rect rarely covers the previous one, which would make
176
- # {#rect=}'s shrink/move detection fire a full repaint on every resize.
177
- # @return [void]
178
- def update_rect
179
- ceiling = screen.size.height * 4 / 5
180
- size = @content.content_size.clamp_height(max_height)
181
- size = size.clamp(Size.new(screen.size.width * 4 / 5, ceiling))
182
- floor = min_height.clamp(0, ceiling)
183
- size = Size.new(size.width, floor) if size.height < floor
184
- # A non-modal overlay is positioned by the caller, so an open one keeps
185
- # its current top-left when its content resizes; a modal popup recenters.
186
- origin = open? && !modal? ? Point.new(rect.left, rect.top) : Point.new(0, 0)
187
- r = Rect.new(origin.x, origin.y, size.width, size.height)
188
- r = r.centered(screen.size) if open? && modal?
189
- self.rect = r
190
- end
191
182
  end
192
183
  end
193
184
  end
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A one-row progress bar: a run of `█` growing left to right across {#rect},
6
+ # over a `░` track.
7
+ #
8
+ # ████████░░░░░░░░░░░░
9
+ #
10
+ # bar = Component::ProgressBar.new(range: 0..files.size)
11
+ # label = Component::Label.new
12
+ # add(bar)
13
+ # add(label)
14
+ #
15
+ # def rect=(new_rect) # the enclosing Layout positions both
16
+ # super
17
+ # bar.rect = Rect.new(rect.left, rect.top, rect.width, 1)
18
+ # label.rect = Rect.new(rect.left, rect.top + 1, rect.width, 1)
19
+ # end
20
+ #
21
+ # bar.value = done
22
+ # label.text = "#{bar.percent}% — #{done}/#{files.size}"
23
+ #
24
+ # The bar paints no text of its own: put a {Label} beside it and feed it
25
+ # {#percent} or {#fraction}, so the app words it ("42% — 3/7 files") and
26
+ # places it freely. Display-only — not focusable, no keys, no mouse.
27
+ #
28
+ # While the total is still unknown, {#indeterminate=} swaps the fill for a
29
+ # block sliding across the bar:
30
+ #
31
+ # ░░░░░░░████░░░░░░░░░
32
+ #
33
+ # Both endpoints are exact: the bar is full only at {#max} and empty only at
34
+ # {#min}, so a full bar always means done. Assign a one-row {#rect}; a taller
35
+ # one paints the bar on its first row and leaves the rest to the background.
36
+ #
37
+ # == Implementation details
38
+ # The `█`/`░` pair is the same one {VerticalScrollBar} uses — East-Asian
39
+ # Ambiguous and Neutral respectively, so under an ambiguous-as-wide terminal
40
+ # the rendered length would vary with the fill level. Shipped anyway, per
41
+ # `DECISIONS.md` `D-ambiguous-width`: a bar that rhymes with the scrollbar
42
+ # beats a third convention, and if that bet is ever reversed both swap
43
+ # together.
44
+ class ProgressBar < Component
45
+ # Range covering the whole bar when none is given.
46
+ # @return [Range]
47
+ DEFAULT_RANGE = (0.0..1.0)
48
+
49
+ # Frames per second of the indeterminate animation. The block advances one
50
+ # cell per frame, so this is also its speed in cells/second.
51
+ # @return [Integer]
52
+ INDETERMINATE_FPS = 5
53
+
54
+ # The indeterminate block is this fraction of the bar, at least one cell.
55
+ # @return [Integer]
56
+ BLOCK_DIVISOR = 5
57
+
58
+ # @param range [Range] initial {#range=}.
59
+ # @param value [Numeric, nil] initial {#value=}; `nil` starts at the range's
60
+ # lower bound.
61
+ # @param indeterminate [Boolean] initial {#indeterminate=}.
62
+ def initialize(range: DEFAULT_RANGE, value: nil, indeterminate: false)
63
+ super()
64
+ @value = 0.0
65
+ @min = 0.0
66
+ @max = 1.0
67
+ @phase = 0
68
+ @ticker = nil
69
+ @indeterminate = false
70
+ @bar_color = nil
71
+ self.range = range
72
+ self.value = value unless value.nil?
73
+ self.indeterminate = indeterminate
74
+ end
75
+
76
+ # @return [Float] lower bound of {#range}.
77
+ attr_reader :min
78
+
79
+ # @return [Float] upper bound of {#range}.
80
+ attr_reader :max
81
+
82
+ # @return [Color, nil] the value as set, so a {Theme::Ref} comes back
83
+ # unresolved. Both glyphs paint in it; `nil` (the default) is the
84
+ # terminal's default foreground.
85
+ attr_reader :bar_color
86
+
87
+ # @return [Range] the scale {#value} is measured against.
88
+ def range = @min..@max
89
+
90
+ # Replaces the scale, re-clamping {#value} into it. `min == max` is legal
91
+ # and reads as complete — a zero-length job has nothing outstanding — so
92
+ # `bar.range = 0..files.size` needs no special case for an empty list.
93
+ #
94
+ # @param new_range [Range] inclusive; endpoints Numeric and finite.
95
+ # @return [void]
96
+ # @raise [ArgumentError] on an exclusive, inverted or non-finite range, or
97
+ # an endpoint `Float()` cannot parse.
98
+ # @raise [TypeError] on a beginless or endless range — its `nil` endpoint
99
+ # is what `Float()` refuses — or any other type it refuses outright.
100
+ def range=(new_range)
101
+ raise ArgumentError, "range must be inclusive, got #{new_range.inspect}" if new_range.exclude_end?
102
+
103
+ min = Float(new_range.begin)
104
+ max = Float(new_range.end)
105
+ raise ArgumentError, "range end #{max} is below its start #{min}" if max < min
106
+
107
+ unless min.finite? && max.finite?
108
+ raise ArgumentError, "range endpoints must be finite (use indeterminate = true)"
109
+ end
110
+
111
+ @min = min
112
+ @max = max
113
+ self.value = @value
114
+ invalidate # the scale moved even when the clamped value did not
115
+ end
116
+
117
+ # @return [Float] the progress, clamped into {#range} when assigned — so
118
+ # `bar.value = 999` on a `0..250` bar reads back as `250.0`.
119
+ attr_reader :value
120
+
121
+ # @param new_value [Numeric] clamped into {#range}.
122
+ # @return [void]
123
+ # @raise [ArgumentError] on NaN — typically `done.to_f / total` with a zero
124
+ # total, which wants {#indeterminate=} instead — or on a String `Float()`
125
+ # cannot parse.
126
+ # @raise [TypeError] on `nil` and other types `Float()` refuses outright.
127
+ def value=(new_value)
128
+ new_value = Float(new_value)
129
+ raise ArgumentError, "value must be a number, got NaN" if new_value.nan?
130
+
131
+ new_value = new_value.clamp(@min, @max)
132
+ return if @value == new_value
133
+
134
+ @value = new_value
135
+ invalidate
136
+ end
137
+
138
+ # @return [Float] {#value} as `0.0..1.0`. `1.0` when the range is empty.
139
+ def fraction
140
+ return 1.0 if @max == @min
141
+
142
+ (@value - @min) / (@max - @min)
143
+ end
144
+
145
+ # @return [Integer] {#fraction} as `0..100`, floored — `100` means done and
146
+ # nothing else does, matching the painted bar exactly.
147
+ def percent = scale(100)
148
+
149
+ # Sets the color of both glyphs, live-resolved at paint time when given a
150
+ # {Theme::Ref} (so it follows a {Screen#theme=} with no
151
+ # {Component#on_theme_changed} hook).
152
+ #
153
+ # bar.bar_color = Color::GREEN
154
+ # bar.bar_color = Theme.ref(:brand_ok) # an app #custom token
155
+ #
156
+ # @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil]
157
+ # coerced via {Color.coerce} unless it is a {Theme::Ref}; `nil` is the
158
+ # terminal default.
159
+ # @return [void]
160
+ # @raise [KeyError] when a {Theme::Ref} names a token the current theme
161
+ # lacks.
162
+ def bar_color=(color)
163
+ color = Color.coerce(color) unless color.is_a?(Theme::Ref)
164
+ return if @bar_color == color
165
+
166
+ color.resolve(screen.theme) if color.is_a?(Theme::Ref) # fail fast on a bad token
167
+
168
+ @bar_color = color
169
+ invalidate
170
+ end
171
+
172
+ # @return [Boolean] whether the sliding-block animation is showing.
173
+ def indeterminate? = @indeterminate
174
+
175
+ # Switches between the fill and the sliding block. {#value} keeps working
176
+ # while indeterminate — it is simply not painted — so switching back shows
177
+ # the progress that accumulated meanwhile.
178
+ #
179
+ # The animation only runs while the bar is {Component#attached? attached},
180
+ # and stops on detach. It also keeps the event loop awake at
181
+ # {INDETERMINATE_FPS}, so turn it off (or remove the bar) when the job ends.
182
+ #
183
+ # @param flag [Boolean] coerced; truthiness decides.
184
+ # @return [void]
185
+ def indeterminate=(flag)
186
+ flag = flag ? true : false
187
+ return if @indeterminate == flag
188
+
189
+ @indeterminate = flag
190
+ sync_ticker
191
+ invalidate # the picture changes now, not on the next frame
192
+ end
193
+
194
+ # @return [void]
195
+ def on_attached = sync_ticker
196
+
197
+ # @return [void]
198
+ def on_detached = sync_ticker
199
+
200
+ # Paints the bar on the first row of {#rect} and blanks the rest.
201
+ #
202
+ # Deliberately not `super`: {Component#repaint}'s default blanks the
203
+ # *whole* rect, which dirties every cell of the bar's own row before it is
204
+ # painted over — so {Buffer#flush} re-emits the entire row every frame
205
+ # instead of the one or two cells that actually moved.
206
+ # @return [void]
207
+ def repaint
208
+ return if rect.empty?
209
+
210
+ draw_line(rect.left, rect.top, StyledString.styled(glyphs(rect.width), fg: resolved_bar_color))
211
+ clear_background(Rect.new(rect.left, rect.top + 1, rect.width, rect.height - 1)) if rect.height > 1
212
+ end
213
+
214
+ private
215
+
216
+ # Filled cells out of `steps` — the rect width when painting, 100 for
217
+ # {#percent}, so the bar and a {Label} showing the percentage can never
218
+ # disagree about being done.
219
+ # @param steps [Integer]
220
+ # @return [Integer]
221
+ def scale(steps)
222
+ return 0 if fraction <= 0.0
223
+ return steps if fraction >= 1.0
224
+ return 0 if steps < 2 # no interior to land in; fills only when done
225
+
226
+ (fraction * steps).floor.clamp(1, steps - 1)
227
+ end
228
+
229
+ # @param width [Integer] columns available.
230
+ # @return [String] the row, `width` glyphs wide.
231
+ def glyphs(width)
232
+ start, length = @indeterminate ? block_at(width) : [0, scale(width)]
233
+ [("░" * start), ("█" * length), ("░" * (width - start - length))].join
234
+ end
235
+
236
+ # Where the sliding block sits this frame: it enters at the left edge and
237
+ # leaves at the right, one cell per frame, then loops. The period is one
238
+ # short of `width + block` so at least one cell is always lit — a full
239
+ # `width + block` blanks the bar for exactly one frame per cycle.
240
+ # @param width [Integer] columns available.
241
+ # @return [Array(Integer, Integer)] start column and length, clipped.
242
+ def block_at(width)
243
+ block = [width / BLOCK_DIVISOR, 1].max
244
+ start = (@phase % (width + block - 1)) - (block - 1)
245
+ first = [start, 0].max
246
+ last = [start + block, width].min
247
+ [first, last - first]
248
+ end
249
+
250
+ # @return [Color, nil]
251
+ def resolved_bar_color
252
+ @bar_color.is_a?(Theme::Ref) ? @bar_color.resolve(screen.theme) : @bar_color
253
+ end
254
+
255
+ # Brings the ticker in line with "animating and on screen". The sole writer
256
+ # of `@ticker`, and idempotent, so the attach/detach hooks and
257
+ # {#indeterminate=} are all the same call and a repeated `indeterminate =
258
+ # true` cannot start a second one.
259
+ # @return [void]
260
+ def sync_ticker
261
+ want = attached? && @indeterminate
262
+ return if want == !@ticker.nil?
263
+
264
+ if want
265
+ @ticker = screen.event_queue.tick_fps(INDETERMINATE_FPS) do |tick|
266
+ # The paint that already happened is frame 0; a ticker's first
267
+ # firing is one interval later, so it is frame 1.
268
+ @phase = tick + 1
269
+ invalidate
270
+ end
271
+ else
272
+ @ticker.cancel
273
+ @ticker = nil
274
+ end
275
+ end
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # Single-select from a set of typed items, one row each. Arrows move a
6
+ # cursor; Space, Enter or a left click selects the row under it:
7
+ #
8
+ # (*) Ascending
9
+ # ( ) Descending <- cursor row, highlighted across the full width
10
+ # ( ) Unsorted
11
+ # ^ the composed {List}'s one-column gutter
12
+ #
13
+ # rg = Component::RadioGroup.new(items: %w[Ascending Descending Unsorted])
14
+ # rg.value = "Descending" # or seed it via the ctor
15
+ # rg.on_value_change = ->(order) { resort(order) }
16
+ # rg.value # => "Descending"
17
+ # rg.item_label = ->(o) { o.title } # default :to_s
18
+ #
19
+ # {#value} is **the selected item itself** — of whatever type {#items}
20
+ # holds, never its label. `nil` means nothing is selected: that is the
21
+ # initial state, and assigning it is the only way back, since Space on the
22
+ # already-selected row is a no-op rather than a deselect.
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.
30
+ #
31
+ # == The cursor is chrome
32
+ # The cursor and the selection are two independent things, as in
33
+ # {CheckboxGroup} — arrows roam without changing {#value}, so a listener
34
+ # that resorts a pane fires once on intent instead of once per row crossed.
35
+ # {#value=} therefore does *not* move the cursor. An app that wants it
36
+ # parked on the selection parks it:
37
+ #
38
+ # rg.content.cursor = List::Cursor.new(position: rg.items.index(rg.value))
39
+ #
40
+ # {#items=} is the one thing that moves it, clamping it back into range.
41
+ #
42
+ # == +items+ is chrome; +value+ is authoritative
43
+ # {#items=} changes only what is *presented*. It never touches {#value} and
44
+ # never fires {HasValue#on_value_change}, and a selected item absent from
45
+ # {#items} renders no marked row while surviving intact — so a form saved
46
+ # without the user editing anything changes nothing silently. Keeping the
47
+ # two in sync is the app's job. Same contract as {ComboBox#value} and
48
+ # {CheckboxGroup#value}.
49
+ #
50
+ # == Implementation details
51
+ # Two `==`-equal items share one selection, so selecting either marks both
52
+ # rows; two *distinct* items that merely render the same label stay
53
+ # independent, because a row resolves to an item by index.
54
+ #
55
+ # Rows are `(*) `/`( ) ` literals, mirroring {Checkbox}'s convention rather
56
+ # than importing constants from it. ASCII deliberately: `(•)` would measure
57
+ # two columns in a terminal configured for East-Asian-Ambiguous glyphs and
58
+ # shift every row's text, which no test would catch.
59
+ #
60
+ # UI-thread-confined, like every component (see {Screen}).
61
+ class RadioGroup < Component
62
+ include HasContent
63
+ include HasValue
64
+
65
+ # @param items [Array] the items to present, one row each; also settable
66
+ # via {#items=}.
67
+ # @param value [Object, nil] the initially selected item. Seeds the
68
+ # backing ivar directly, so no listener fires and assignment order
69
+ # doesn't matter to a form helper.
70
+ def initialize(items: [], value: nil)
71
+ super()
72
+ @items = items.to_a
73
+ @item_label = :to_s.to_proc
74
+ @value = value
75
+ @on_value_change = nil
76
+
77
+ list = List.new
78
+ # A List has no cursor at all by default (Cursor::None, position -1).
79
+ list.cursor = List::Cursor.new
80
+ list.on_item_chosen = ->(index, _line) { select_at(index) }
81
+ self.content = list
82
+ rebuild_rows
83
+ end
84
+
85
+ # @return [Array] the presented items.
86
+ attr_reader :items
87
+
88
+ # @return [Proc, Method] item -> row label (a `String`, {StyledString}, or
89
+ # anything with `#to_s`); `:to_s` by default.
90
+ attr_reader :item_label
91
+
92
+ # Replaces the presented rows, leaving {#value} untouched and clamping the
93
+ # cursor back into range.
94
+ # @param new_items [Array]
95
+ # @raise [TypeError] unless `new_items` is an `Array`.
96
+ # @return [void]
97
+ def items=(new_items)
98
+ raise TypeError, "expected Array, got #{new_items.inspect}" unless new_items.is_a?(Array)
99
+
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
105
+ end
106
+
107
+ # @param proc [Proc, Method] item -> row label.
108
+ # @return [void]
109
+ def item_label=(proc)
110
+ @item_label = proc
111
+ rebuild_rows
112
+ end
113
+
114
+ # Selects `new_value`, firing {HasValue#on_value_change} when it really
115
+ # changed. The cursor stays where it is.
116
+ # @param new_value [Object, nil] `nil` selects nothing; an item outside
117
+ # {#items} is kept but renders no marked row.
118
+ # @return [void]
119
+ def value=(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 == new_value
123
+
124
+ super
125
+ rebuild_rows
126
+ end
127
+
128
+ # Selects 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
+ select_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
+ # Selects the item on row `index`; an index outside {#items} is ignored.
151
+ # @param index [Integer]
152
+ # @return [void]
153
+ def select_at(index)
154
+ return unless index.between?(0, @items.size - 1)
155
+
156
+ self.value = @items[index]
157
+ end
158
+
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
165
+ end
166
+
167
+ # 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
+ # off-content: no highlight, a dead Enter, and a Space that resolves to
170
+ # `nil` and silently clears the selection.
171
+ # @return [void]
172
+ def clamp_cursor
173
+ cursor = content.cursor
174
+ # go_to_last funnels through Cursor#go's clamp(0, nil), so an empty
175
+ # items list floors at 0 instead of going negative.
176
+ cursor.go_to_last(@items.size) if cursor.position >= @items.size
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