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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -0
- data/DECISIONS.md +1961 -0
- data/README.md +82 -48
- data/book/01-first-app.md +186 -0
- data/book/02-repaint.md +177 -0
- data/book/03-layout.md +379 -0
- data/book/04-event-loop.md +295 -0
- data/book/05-focus.md +219 -0
- data/book/06-theming.md +302 -0
- data/book/07-components.md +585 -0
- data/book/08-testing.md +199 -0
- data/book/09-styled-text.md +132 -0
- data/book/README.md +85 -0
- data/examples/hello_world.rb +1 -2
- data/examples/sampler.rb +435 -20
- data/ideas/new-components.md +109 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +113 -43
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/button.rb +25 -29
- data/lib/tuile/component/checkbox.rb +133 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +281 -0
- data/lib/tuile/component/has_caption.rb +44 -0
- data/lib/tuile/component/has_content.rb +5 -5
- data/lib/tuile/component/has_value.rb +64 -0
- data/lib/tuile/component/info_window.rb +4 -2
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +20 -25
- data/lib/tuile/component/layout.rb +3 -26
- data/lib/tuile/component/list.rb +8 -33
- data/lib/tuile/component/list_dropdown.rb +106 -0
- data/lib/tuile/component/log_window.rb +0 -14
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +70 -79
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/text_area.rb +189 -65
- data/lib/tuile/component/text_field.rb +170 -32
- data/lib/tuile/component/text_view.rb +57 -137
- data/lib/tuile/component/window.rb +88 -121
- data/lib/tuile/component.rb +246 -142
- data/lib/tuile/event_queue.rb +39 -21
- data/lib/tuile/fake_event_queue.rb +32 -7
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +42 -0
- data/lib/tuile/screen.rb +210 -109
- data/lib/tuile/screen_pane.rb +56 -44
- data/lib/tuile/styled_string.rb +112 -83
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2291 -890
- metadata +28 -9
- data/ideas/back-buffer.md +0 -217
- 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
|
|
8
|
-
#
|
|
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
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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
|
|
32
|
-
# the
|
|
33
|
-
#
|
|
34
|
-
#
|
|
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=}.
|
|
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
|
-
|
|
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}
|
|
71
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
#
|
|
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
|
|
134
|
-
|
|
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
|