thaum 0.1.0 → 0.2.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/README.md +106 -14
- data/examples/checkbox.rb +89 -0
- data/examples/counter.rb +50 -0
- data/examples/hello_world.rb +28 -0
- data/examples/layout_demo.rb +138 -0
- data/examples/modal.rb +76 -0
- data/examples/mouse.rb +60 -0
- data/examples/octagram_picker.rb +224 -0
- data/examples/picker.rb +150 -0
- data/examples/progress_bar.rb +90 -0
- data/examples/scroll_view.rb +64 -0
- data/examples/select.rb +64 -0
- data/examples/spinner.rb +66 -0
- data/examples/status_bar.rb +65 -0
- data/examples/stopwatch.rb +84 -0
- data/examples/table.rb +196 -0
- data/examples/tabs.rb +112 -0
- data/examples/text.rb +101 -0
- data/examples/theme_picker.rb +95 -0
- data/examples/todo.rb +242 -0
- data/lib/thaum/action.rb +30 -0
- data/lib/thaum/app.rb +87 -0
- data/lib/thaum/color.rb +97 -0
- data/lib/thaum/concerns/context_update.rb +40 -0
- data/lib/thaum/concerns/focus.rb +53 -0
- data/lib/thaum/concerns/layout.rb +349 -0
- data/lib/thaum/concerns/modal.rb +102 -0
- data/lib/thaum/concerns/tab_navigation.rb +97 -0
- data/lib/thaum/dispatch.rb +149 -0
- data/lib/thaum/escape_parser.rb +265 -0
- data/lib/thaum/event.rb +13 -0
- data/lib/thaum/events.rb +28 -0
- data/lib/thaum/hit_test.rb +28 -0
- data/lib/thaum/input_reader.rb +46 -0
- data/lib/thaum/key_event.rb +13 -0
- data/lib/thaum/keys.rb +55 -0
- data/lib/thaum/minitest.rb +64 -0
- data/lib/thaum/octagram.rb +76 -0
- data/lib/thaum/painter.rb +49 -0
- data/lib/thaum/rect.rb +5 -0
- data/lib/thaum/rendering/box_drawing.rb +186 -0
- data/lib/thaum/rendering/buffer.rb +84 -0
- data/lib/thaum/rendering/canvas.rb +219 -0
- data/lib/thaum/rendering/cell.rb +11 -0
- data/lib/thaum/rendering/renderer.rb +98 -0
- data/lib/thaum/rendering/style.rb +13 -0
- data/lib/thaum/run_loop.rb +182 -0
- data/lib/thaum/seq.rb +91 -0
- data/lib/thaum/sigil.rb +41 -0
- data/lib/thaum/sigils/button.rb +47 -0
- data/lib/thaum/sigils/checkbox.rb +57 -0
- data/lib/thaum/sigils/progress_bar.rb +65 -0
- data/lib/thaum/sigils/scroll_view.rb +115 -0
- data/lib/thaum/sigils/select.rb +56 -0
- data/lib/thaum/sigils/spinner.rb +39 -0
- data/lib/thaum/sigils/status_bar.rb +89 -0
- data/lib/thaum/sigils/table.rb +156 -0
- data/lib/thaum/sigils/tabs.rb +59 -0
- data/lib/thaum/sigils/text.rb +22 -0
- data/lib/thaum/sigils/text_input.rb +86 -0
- data/lib/thaum/terminal.rb +46 -0
- data/lib/thaum/themes.rb +267 -0
- data/lib/thaum/tree.rb +16 -0
- data/lib/thaum/version.rb +1 -1
- data/lib/thaum.rb +64 -1
- metadata +114 -4
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thaum
|
|
4
|
+
# Owns the main run loop: terminal setup, the event queue, mount/draw
|
|
5
|
+
# pass, suspend/resume choreography, and graceful teardown.
|
|
6
|
+
module RunLoop
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Startup entry point. Blocks until app.quit is called. Returns nil.
|
|
10
|
+
def run(app:, tick: 0.1, threads: 4)
|
|
11
|
+
terminal = Terminal.new
|
|
12
|
+
queue = Thread::Queue.new
|
|
13
|
+
capability = Color.detect(ENV)
|
|
14
|
+
warn "[Thaum] color not supported, rendering without color" if capability == :none
|
|
15
|
+
renderer = Rendering::Renderer.new(capability: capability)
|
|
16
|
+
pool = Concurrent::FixedThreadPool.new(threads)
|
|
17
|
+
tick_task = build_tick_task(queue:, interval: tick)
|
|
18
|
+
|
|
19
|
+
Thaum::Action.queue = queue
|
|
20
|
+
Thaum::Action.pool = pool
|
|
21
|
+
|
|
22
|
+
install_traps(queue:, terminal:)
|
|
23
|
+
terminal.setup
|
|
24
|
+
cols, rows = terminal.size
|
|
25
|
+
|
|
26
|
+
mount_pass(app:, renderer:, cols:, rows:)
|
|
27
|
+
|
|
28
|
+
input_reader = InputReader.new(input: $stdin, queue: queue)
|
|
29
|
+
input_reader.start
|
|
30
|
+
tick_task.execute
|
|
31
|
+
|
|
32
|
+
event_loop(app:, queue:, terminal:, renderer:, cols:, rows:)
|
|
33
|
+
ensure
|
|
34
|
+
tick_task&.shutdown
|
|
35
|
+
input_reader&.stop
|
|
36
|
+
shutdown_pool(pool:)
|
|
37
|
+
Thaum::Action.queue = nil
|
|
38
|
+
Thaum::Action.pool = nil
|
|
39
|
+
terminal&.teardown
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def event_loop(app:, queue:, terminal:, renderer:, cols:, rows:)
|
|
43
|
+
until app.quit?
|
|
44
|
+
event = queue.pop
|
|
45
|
+
|
|
46
|
+
if event == :suspend
|
|
47
|
+
cols, rows = Suspender.suspend(app:, terminal:, queue:)
|
|
48
|
+
next
|
|
49
|
+
end
|
|
50
|
+
# Stray :resume outside a suspend window — ignore.
|
|
51
|
+
next if event == :resume
|
|
52
|
+
|
|
53
|
+
cols, rows = handle_resize(app:, event:, _cols: cols, _rows: rows) if event.is_a?(ResizeEvent)
|
|
54
|
+
|
|
55
|
+
Dispatch.from_queue(app:, event:)
|
|
56
|
+
|
|
57
|
+
next unless app.dirty?
|
|
58
|
+
|
|
59
|
+
app.clear_dirty
|
|
60
|
+
Thaum.safe_invoke("render") { Painter.paint(app:, renderer:, cols:, rows:) }
|
|
61
|
+
end
|
|
62
|
+
[cols, rows]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def mount_pass(app:, renderer:, cols:, rows:)
|
|
66
|
+
app.run_partition(rect: Rect.new(x: 0, y: 0, width: cols, height: rows))
|
|
67
|
+
app.wire_sigils
|
|
68
|
+
app.validate_focus_order_tree
|
|
69
|
+
Thaum.safe_invoke("App#on_mount") { app.on_mount }
|
|
70
|
+
Tree.walk(app) do |node|
|
|
71
|
+
next unless node.is_a?(Sigil) || node.is_a?(Octagram)
|
|
72
|
+
|
|
73
|
+
Thaum.safe_invoke("#{node.class}#on_mount") { node.on_mount }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
first = Thaum.safe_invoke("App#initial_focus") { app.initial_focus }
|
|
77
|
+
if first
|
|
78
|
+
app.set_initial_focus(first)
|
|
79
|
+
Thaum.safe_invoke("#{first.class}#on_focus") { first.on_focus }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
Painter.paint(app:, renderer:, cols:, rows:)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def handle_resize(app:, event:, _cols: nil, _rows: nil)
|
|
86
|
+
cols = event.width
|
|
87
|
+
rows = event.height
|
|
88
|
+
Thaum.safe_invoke("App#run_partition") do
|
|
89
|
+
app.run_partition(rect: Rect.new(x: 0, y: 0, width: cols, height: rows))
|
|
90
|
+
end
|
|
91
|
+
Thaum.safe_invoke("App#recompute_modal_rect") { app.recompute_modal_rect }
|
|
92
|
+
[cols, rows]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def build_tick_task(queue:, interval:)
|
|
96
|
+
last_tick = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
97
|
+
Concurrent::TimerTask.new(execution_interval: interval) do
|
|
98
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
99
|
+
queue.push(TickEvent.new(time: now, delta: now - last_tick))
|
|
100
|
+
last_tick = now
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def install_traps(queue:, terminal:)
|
|
105
|
+
install_resize_trap(queue:, terminal:)
|
|
106
|
+
install_suspend_traps(queue:)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Push a ResizeEvent on SIGWINCH.
|
|
110
|
+
def install_resize_trap(queue:, terminal:)
|
|
111
|
+
Signal.trap("WINCH") do
|
|
112
|
+
w, h = terminal.size
|
|
113
|
+
begin
|
|
114
|
+
queue.push(ResizeEvent.new(width: w, height: h))
|
|
115
|
+
rescue ClosedQueueError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Push :suspend / :resume sentinels onto the main queue from signal
|
|
122
|
+
# handlers. The actual suspend dance happens in Suspender so the signal
|
|
123
|
+
# handler stays minimal (signal-handler context has tight restrictions).
|
|
124
|
+
def install_suspend_traps(queue:)
|
|
125
|
+
Signal.trap("TSTP") do
|
|
126
|
+
queue.push(:suspend)
|
|
127
|
+
rescue ClosedQueueError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
Signal.trap("CONT") do
|
|
131
|
+
queue.push(:resume)
|
|
132
|
+
rescue ClosedQueueError
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def shutdown_pool(pool:)
|
|
138
|
+
return unless pool
|
|
139
|
+
|
|
140
|
+
pool.shutdown
|
|
141
|
+
pool.wait_for_termination(1) || pool.kill
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Handles a dequeued :suspend sentinel: tear the terminal down, actually
|
|
146
|
+
# stop the process, then on resume re-setup, repartition with current
|
|
147
|
+
# size, and force a re-render. Drains stray sentinels that arrive
|
|
148
|
+
# between teardown and resume. Internal — not delivered to App handlers
|
|
149
|
+
# per the spec.
|
|
150
|
+
module Suspender
|
|
151
|
+
module_function
|
|
152
|
+
|
|
153
|
+
def suspend(app:, terminal:, queue:)
|
|
154
|
+
Thaum.safe_invoke("Terminal#teardown(suspend)") { terminal.teardown }
|
|
155
|
+
|
|
156
|
+
# Reset TSTP to default so kill("TSTP", pid) actually stops us.
|
|
157
|
+
prev_tstp = Signal.trap("TSTP", "DEFAULT")
|
|
158
|
+
Process.kill("TSTP", Process.pid)
|
|
159
|
+
# ── process is stopped here; resumes when shell sends SIGCONT ──
|
|
160
|
+
Signal.trap("TSTP", prev_tstp)
|
|
161
|
+
|
|
162
|
+
drain_until_resume(queue:)
|
|
163
|
+
|
|
164
|
+
Thaum.safe_invoke("Terminal#setup(resume)") { terminal.setup }
|
|
165
|
+
cols, rows = terminal.size
|
|
166
|
+
Thaum.safe_invoke("App#run_partition(resume)") do
|
|
167
|
+
app.run_partition(rect: Rect.new(x: 0, y: 0, width: cols, height: rows))
|
|
168
|
+
end
|
|
169
|
+
app.request_render
|
|
170
|
+
[cols, rows]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def drain_until_resume(queue:)
|
|
174
|
+
loop do
|
|
175
|
+
msg = queue.pop
|
|
176
|
+
break if msg == :resume
|
|
177
|
+
# Ignore everything else during the suspended window — we
|
|
178
|
+
# re-render from scratch on resume anyway.
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
data/lib/thaum/seq.rb
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thaum
|
|
4
|
+
# Sequences for terminal control and input.
|
|
5
|
+
module Seq
|
|
6
|
+
# Mode toggles
|
|
7
|
+
ALT_SCREEN_ON = "\e[?1049h"
|
|
8
|
+
ALT_SCREEN_OFF = "\e[?1049l"
|
|
9
|
+
CURSOR_HIDE = "\e[?25l"
|
|
10
|
+
CURSOR_SHOW = "\e[?25h"
|
|
11
|
+
BRACKETED_PASTE_ON = "\e[?2004h"
|
|
12
|
+
BRACKETED_PASTE_OFF = "\e[?2004l"
|
|
13
|
+
SGR_MOUSE_ON = "\e[?1006h"
|
|
14
|
+
SGR_MOUSE_OFF = "\e[?1006l"
|
|
15
|
+
CELL_MOTION_ON = "\e[?1002h"
|
|
16
|
+
CELL_MOTION_OFF = "\e[?1002l"
|
|
17
|
+
SYNC_BEGIN = "\e[?2026h"
|
|
18
|
+
SYNC_END = "\e[?2026l"
|
|
19
|
+
|
|
20
|
+
# Select Graphic Rendition (SGR) attributes
|
|
21
|
+
RESET = "\e[0m"
|
|
22
|
+
BOLD = "\e[1m"
|
|
23
|
+
DIM = "\e[2m"
|
|
24
|
+
ITALIC = "\e[3m"
|
|
25
|
+
UNDERLINE = "\e[4m"
|
|
26
|
+
|
|
27
|
+
# Select Graphic Rendition (SGR) named color codes
|
|
28
|
+
FG = {
|
|
29
|
+
black: 30, red: 31, green: 32, yellow: 33,
|
|
30
|
+
blue: 34, magenta: 35, cyan: 36, white: 37,
|
|
31
|
+
bright_black: 90, bright_red: 91, bright_green: 92, bright_yellow: 93,
|
|
32
|
+
bright_blue: 94, bright_magenta: 95, bright_cyan: 96, bright_white: 97,
|
|
33
|
+
default: 39
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
BG = {
|
|
37
|
+
black: 40, red: 41, green: 42, yellow: 43,
|
|
38
|
+
blue: 44, magenta: 45, cyan: 46, white: 47,
|
|
39
|
+
bright_black: 100, bright_red: 101, bright_green: 102, bright_yellow: 103,
|
|
40
|
+
bright_blue: 104, bright_magenta: 105, bright_cyan: 106, bright_white: 107,
|
|
41
|
+
default: 49
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
# Control Sequence Introducer (CSI) input sequences (\e[ + final byte)
|
|
45
|
+
CSI_UP = "\e[A"
|
|
46
|
+
CSI_DOWN = "\e[B"
|
|
47
|
+
CSI_RIGHT = "\e[C"
|
|
48
|
+
CSI_LEFT = "\e[D"
|
|
49
|
+
CSI_HOME = "\e[H"
|
|
50
|
+
CSI_END = "\e[F"
|
|
51
|
+
CSI_SHIFT_TAB = "\e[Z"
|
|
52
|
+
|
|
53
|
+
# Single Shift 3 (SS3) input sequences (\eO + final byte) — VT100/application cursor mode
|
|
54
|
+
SS3_F1 = "\eOP"
|
|
55
|
+
SS3_F2 = "\eOQ"
|
|
56
|
+
SS3_F3 = "\eOR"
|
|
57
|
+
SS3_F4 = "\eOS"
|
|
58
|
+
SS3_HOME = "\eOH"
|
|
59
|
+
SS3_END = "\eOF"
|
|
60
|
+
SS3_UP = "\eOA"
|
|
61
|
+
SS3_DOWN = "\eOB"
|
|
62
|
+
SS3_RIGHT = "\eOC"
|
|
63
|
+
SS3_LEFT = "\eOD"
|
|
64
|
+
|
|
65
|
+
# Tilde input sequences (\e[ + code + ~)
|
|
66
|
+
# F6-F8 skip 16 because some terminals use it for a modifier variant.
|
|
67
|
+
TILDE_HOME = "\e[1~"
|
|
68
|
+
TILDE_INSERT = "\e[2~"
|
|
69
|
+
TILDE_DELETE = "\e[3~"
|
|
70
|
+
TILDE_END = "\e[4~"
|
|
71
|
+
TILDE_PAGE_UP = "\e[5~"
|
|
72
|
+
TILDE_PAGE_DOWN = "\e[6~"
|
|
73
|
+
TILDE_F1 = "\e[11~"
|
|
74
|
+
TILDE_F2 = "\e[12~"
|
|
75
|
+
TILDE_F3 = "\e[13~"
|
|
76
|
+
TILDE_F4 = "\e[14~"
|
|
77
|
+
TILDE_F5 = "\e[15~"
|
|
78
|
+
TILDE_F6 = "\e[17~"
|
|
79
|
+
TILDE_F7 = "\e[18~"
|
|
80
|
+
TILDE_F8 = "\e[19~"
|
|
81
|
+
TILDE_F9 = "\e[20~"
|
|
82
|
+
TILDE_F10 = "\e[21~"
|
|
83
|
+
TILDE_F11 = "\e[23~"
|
|
84
|
+
TILDE_F12 = "\e[24~"
|
|
85
|
+
|
|
86
|
+
# Dynamic sequences
|
|
87
|
+
def self.cursor_pos(x:, y:) = "\e[#{y};#{x}H"
|
|
88
|
+
def self.color(code) = "\e[#{code}m"
|
|
89
|
+
def self.truecolor(base:, r:, g:, b:) = "\e[#{base};2;#{r};#{g};#{b}m"
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/thaum/sigil.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thaum
|
|
4
|
+
module Sigil
|
|
5
|
+
attr_accessor :rect, :thaum_app, :handler_parent
|
|
6
|
+
|
|
7
|
+
# The safe-nav `&.` makes `focused_sigil` nil when no app is set; nil.equal?(self) is false.
|
|
8
|
+
def focused? = @thaum_app&.focused_sigil.equal?(self)
|
|
9
|
+
def focusable? = true
|
|
10
|
+
def request_render = @thaum_app&.request_render
|
|
11
|
+
|
|
12
|
+
def emit(event)
|
|
13
|
+
app = @thaum_app or return
|
|
14
|
+
raise Thaum::EmitFromUpdateError, "emit called from on_update" if app.in_on_update
|
|
15
|
+
|
|
16
|
+
if event.is_a?(Thaum::TickEvent) || event.is_a?(Thaum::ResizeEvent)
|
|
17
|
+
warn "[Thaum] dropping #{event.class} from #{self.class}: " \
|
|
18
|
+
"framework-internal events cannot be emitted from Sigils or Actions"
|
|
19
|
+
return
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
(@handler_parent || app).dispatch_from_child(event)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Rendering — override in subclass
|
|
26
|
+
def render(canvas:, theme:); end
|
|
27
|
+
|
|
28
|
+
# Terminal event handlers — defaults propagate via emit
|
|
29
|
+
def on_key(event) = emit(event)
|
|
30
|
+
def on_mouse(event) = emit(event)
|
|
31
|
+
def on_paste(event) = emit(event)
|
|
32
|
+
|
|
33
|
+
# Lifecycle — override for side effects
|
|
34
|
+
def on_mount; end
|
|
35
|
+
def on_unmount; end
|
|
36
|
+
def on_focus; end
|
|
37
|
+
def on_blur; end
|
|
38
|
+
def on_update(context); end
|
|
39
|
+
def on_tick(event); end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thaum
|
|
4
|
+
class Button
|
|
5
|
+
include Sigil
|
|
6
|
+
|
|
7
|
+
PressedEvent = Thaum::Event.define(:label)
|
|
8
|
+
|
|
9
|
+
attr_reader :label
|
|
10
|
+
|
|
11
|
+
def initialize(label:, disabled: false)
|
|
12
|
+
@label = label
|
|
13
|
+
@disabled = disabled
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def disabled? = @disabled
|
|
17
|
+
def focusable? = !@disabled
|
|
18
|
+
|
|
19
|
+
def on_key(event)
|
|
20
|
+
case event.key
|
|
21
|
+
when :enter, " " then activate
|
|
22
|
+
else emit event
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def render(canvas:, theme:)
|
|
27
|
+
fg, bg = colors(theme)
|
|
28
|
+
canvas.fill(bg: bg) if bg
|
|
29
|
+
canvas.text(content: @label, fg: fg, bg: bg, align: :center)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def colors(theme)
|
|
35
|
+
return [theme.dim, nil] if disabled?
|
|
36
|
+
return [theme.accent, theme.pressed] if focused?
|
|
37
|
+
|
|
38
|
+
[theme.fg, nil]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def activate
|
|
42
|
+
return if disabled?
|
|
43
|
+
|
|
44
|
+
emit PressedEvent.new(label: @label)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thaum
|
|
4
|
+
# Tri-state boolean: unchecked, checked, indeterminate. Space (or Enter)
|
|
5
|
+
# toggles between unchecked and checked; indeterminate is set
|
|
6
|
+
# programmatically and any user toggle clears it. Emits Checkbox::ChangedEvent
|
|
7
|
+
# with the new :checked value.
|
|
8
|
+
class Checkbox
|
|
9
|
+
include Sigil
|
|
10
|
+
|
|
11
|
+
ChangedEvent = Thaum::Event.define(:checked)
|
|
12
|
+
|
|
13
|
+
attr_reader :checked, :indeterminate
|
|
14
|
+
attr_accessor :label
|
|
15
|
+
|
|
16
|
+
def initialize(checked: false, indeterminate: false, label: nil)
|
|
17
|
+
@checked = checked
|
|
18
|
+
@indeterminate = indeterminate
|
|
19
|
+
@label = label
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def checked? = @checked
|
|
23
|
+
def indeterminate? = @indeterminate
|
|
24
|
+
|
|
25
|
+
def indeterminate=(value)
|
|
26
|
+
@indeterminate = value
|
|
27
|
+
@checked = false if value
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def on_key(event)
|
|
31
|
+
case event.key
|
|
32
|
+
when " ", :enter then toggle
|
|
33
|
+
else emit(event)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def render(canvas:, theme:)
|
|
38
|
+
canvas.fill(bg: theme.bg)
|
|
39
|
+
fg = focused? ? theme.accent : theme.fg
|
|
40
|
+
canvas.text(content: "#{mark} #{@label}".rstrip, fg: fg, bg: theme.bg)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def toggle
|
|
46
|
+
@indeterminate = false
|
|
47
|
+
@checked = !checked?
|
|
48
|
+
emit ChangedEvent.new(checked: checked?)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def mark
|
|
52
|
+
return "[-]" if indeterminate?
|
|
53
|
+
|
|
54
|
+
checked? ? "[X]" : "[ ]"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thaum
|
|
4
|
+
# Horizontal progress bar. Two modes:
|
|
5
|
+
# - determinate (default): `value` is 0.0..1.0, fills proportionally.
|
|
6
|
+
# - indeterminate: a fixed-width block walks across the bar on each tick.
|
|
7
|
+
# Non-focusable.
|
|
8
|
+
class ProgressBar
|
|
9
|
+
include Sigil
|
|
10
|
+
|
|
11
|
+
INDETERMINATE_BLOCK_WIDTH = 6
|
|
12
|
+
INDETERMINATE_INTERVAL = 0.1
|
|
13
|
+
|
|
14
|
+
attr_accessor :value
|
|
15
|
+
attr_reader :indeterminate
|
|
16
|
+
|
|
17
|
+
def initialize(value: 0.0, indeterminate: false)
|
|
18
|
+
@value = value
|
|
19
|
+
@indeterminate = indeterminate
|
|
20
|
+
@offset = 0
|
|
21
|
+
@elapsed = 0.0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def focusable? = false
|
|
25
|
+
def indeterminate? = @indeterminate
|
|
26
|
+
|
|
27
|
+
def on_tick(event)
|
|
28
|
+
return unless indeterminate?
|
|
29
|
+
|
|
30
|
+
@elapsed += event.delta
|
|
31
|
+
advanced = false
|
|
32
|
+
while @elapsed >= INDETERMINATE_INTERVAL
|
|
33
|
+
@elapsed -= INDETERMINATE_INTERVAL
|
|
34
|
+
@offset += 1
|
|
35
|
+
advanced = true
|
|
36
|
+
end
|
|
37
|
+
request_render if advanced
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def render(canvas:, theme:)
|
|
41
|
+
canvas.fill(bg: theme.bg)
|
|
42
|
+
if indeterminate?
|
|
43
|
+
render_indeterminate(canvas: canvas,
|
|
44
|
+
theme: theme)
|
|
45
|
+
else
|
|
46
|
+
render_determinate(canvas: canvas, theme: theme)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def render_determinate(canvas:, theme:)
|
|
53
|
+
filled = (@value.clamp(0.0, 1.0) * canvas.width).round
|
|
54
|
+
canvas.fill(bg: theme.accent, width: filled) if filled.positive?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def render_indeterminate(canvas:, theme:)
|
|
58
|
+
span = canvas.width + INDETERMINATE_BLOCK_WIDTH
|
|
59
|
+
start = (@offset % span) - INDETERMINATE_BLOCK_WIDTH
|
|
60
|
+
x = [start, 0].max
|
|
61
|
+
w = [INDETERMINATE_BLOCK_WIDTH - (x - start), canvas.width - x].min
|
|
62
|
+
canvas.fill(bg: theme.accent, x: x, width: w) if w.positive?
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thaum
|
|
4
|
+
# A scrollable viewport over a list of pre-rendered text rows.
|
|
5
|
+
#
|
|
6
|
+
# This is the row-list variant — apps supply an Array of String rows
|
|
7
|
+
# (one row per element) and ScrollView handles vertical + horizontal
|
|
8
|
+
# scrolling. Long rows are sliced by display columns, so wide chars
|
|
9
|
+
# (CJK, emoji) scroll cleanly.
|
|
10
|
+
class ScrollView
|
|
11
|
+
include Sigil
|
|
12
|
+
|
|
13
|
+
WHEEL_STEP = 3
|
|
14
|
+
|
|
15
|
+
attr_reader :offset_y, :offset_x, :rows
|
|
16
|
+
|
|
17
|
+
def initialize(rows: [])
|
|
18
|
+
@rows = rows
|
|
19
|
+
@offset_y = 0
|
|
20
|
+
@offset_x = 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def rows=(new_rows)
|
|
24
|
+
@rows = new_rows
|
|
25
|
+
@offset_y = @offset_y.clamp(0, [rows.length - 1, 0].max)
|
|
26
|
+
@offset_y = 0 if rows.empty?
|
|
27
|
+
@offset_x = @offset_x.clamp(0, max_x)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def on_key(event)
|
|
31
|
+
case event.key
|
|
32
|
+
when :up then @offset_y = [@offset_y - 1, 0].max
|
|
33
|
+
when :down then @offset_y = [@offset_y + 1, max_offset_y_estimate].min
|
|
34
|
+
when :page_up then @offset_y = [@offset_y - 10, 0].max
|
|
35
|
+
when :page_down then @offset_y = [@offset_y + 10, max_offset_y_estimate].min
|
|
36
|
+
when :home then @offset_y = 0
|
|
37
|
+
when :end then @offset_y = max_offset_y_estimate
|
|
38
|
+
when :left then @offset_x = [@offset_x - 1, 0].max
|
|
39
|
+
when :right then @offset_x = [@offset_x + 1, max_x].min
|
|
40
|
+
else emit event
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def on_mouse(event)
|
|
45
|
+
case event.button
|
|
46
|
+
when :wheel_up then @offset_y = [@offset_y - WHEEL_STEP, 0].max
|
|
47
|
+
when :wheel_down then @offset_y = [@offset_y + WHEEL_STEP, max_offset_y_estimate].min
|
|
48
|
+
else emit event
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def render(canvas:, theme:)
|
|
53
|
+
canvas.fill(bg: theme.bg)
|
|
54
|
+
|
|
55
|
+
# Per-canvas vertical clamp: don't scroll past the last page.
|
|
56
|
+
max_offset = [rows.length - canvas.height, 0].max
|
|
57
|
+
@offset_y = max_offset if @offset_y > max_offset
|
|
58
|
+
|
|
59
|
+
canvas.height.times do |row_idx|
|
|
60
|
+
file_row = @offset_y + row_idx
|
|
61
|
+
text = rows[file_row]
|
|
62
|
+
break if text.nil?
|
|
63
|
+
|
|
64
|
+
sliced = slice_by_columns(str: text, start_col: @offset_x, max_cols: canvas.width)
|
|
65
|
+
canvas.row(row_idx).text(content: sliced, fg: theme.fg)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
draw_indicators(canvas: canvas, theme: theme)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def max_offset_y_estimate
|
|
74
|
+
[rows.length - 1, 0].max
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def max_x
|
|
78
|
+
return 0 if rows.empty?
|
|
79
|
+
|
|
80
|
+
widest = rows.map(&:display_width).max
|
|
81
|
+
[widest - 1, 0].max
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Skip `start_col` display columns of input, then collect up to `max_cols`
|
|
85
|
+
# columns of output. Wide chars (display_width == 2) that straddle either
|
|
86
|
+
# edge are dropped to keep alignment honest.
|
|
87
|
+
def slice_by_columns(str:, start_col:, max_cols:)
|
|
88
|
+
return "" if str.nil? || str.empty?
|
|
89
|
+
|
|
90
|
+
result = +""
|
|
91
|
+
col = 0
|
|
92
|
+
str.each_char do |ch|
|
|
93
|
+
w = ch.display_width
|
|
94
|
+
if col >= start_col
|
|
95
|
+
break if (col - start_col) + w > max_cols
|
|
96
|
+
|
|
97
|
+
result << ch
|
|
98
|
+
end
|
|
99
|
+
col += w
|
|
100
|
+
end
|
|
101
|
+
result
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def draw_indicators(canvas:, theme:)
|
|
105
|
+
return if canvas.width.zero? || canvas.height.zero?
|
|
106
|
+
|
|
107
|
+
canvas.text(content: "▲", x: canvas.width - 1, y: 0, fg: theme.dim) if @offset_y.positive?
|
|
108
|
+
|
|
109
|
+
bottom_visible_row = @offset_y + canvas.height
|
|
110
|
+
return unless bottom_visible_row < rows.length
|
|
111
|
+
|
|
112
|
+
canvas.text(content: "▼", x: canvas.width - 1, y: canvas.height - 1, fg: theme.dim)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thaum
|
|
4
|
+
class Select
|
|
5
|
+
include Sigil
|
|
6
|
+
|
|
7
|
+
SelectedEvent = Thaum::Event.define(:index, :item)
|
|
8
|
+
|
|
9
|
+
attr_reader :items, :cursor
|
|
10
|
+
|
|
11
|
+
def initialize(items:, cursor: 0)
|
|
12
|
+
@items = items
|
|
13
|
+
@cursor = cursor
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def current = items[cursor]
|
|
17
|
+
|
|
18
|
+
def on_key(event)
|
|
19
|
+
case event.key
|
|
20
|
+
when :down then @cursor = [cursor + 1, items.length - 1].min if items.any?
|
|
21
|
+
when :up then @cursor = [cursor - 1, 0].max
|
|
22
|
+
when :enter then emit_selected
|
|
23
|
+
else emit(event)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def render(canvas:, theme:)
|
|
28
|
+
offset = scroll_offset(canvas.height)
|
|
29
|
+
visible_range = offset...(offset + canvas.height)
|
|
30
|
+
|
|
31
|
+
visible_range.each_with_index do |item_idx, row_idx|
|
|
32
|
+
item = items[item_idx] or next
|
|
33
|
+
row = canvas.row(row_idx) or break
|
|
34
|
+
|
|
35
|
+
bg = item_idx == cursor ? theme.selection : theme.bg
|
|
36
|
+
fg = item_idx == cursor ? theme.selection_fg : theme.fg
|
|
37
|
+
row.fill(bg: bg)
|
|
38
|
+
row.text(content: item.to_s, fg: fg, bg: bg)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def emit_selected
|
|
45
|
+
return if items.empty?
|
|
46
|
+
|
|
47
|
+
emit SelectedEvent.new(index: cursor, item: items[cursor])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def scroll_offset(height)
|
|
51
|
+
return 0 if cursor < height
|
|
52
|
+
|
|
53
|
+
cursor - height + 1
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thaum
|
|
4
|
+
# Animated activity indicator. Advances one frame per `interval` seconds,
|
|
5
|
+
# driven by tick deltas (not wall clock — works with whatever tick rate
|
|
6
|
+
# the app runs at). Non-focusable.
|
|
7
|
+
class Spinner
|
|
8
|
+
include Sigil
|
|
9
|
+
|
|
10
|
+
DEFAULT_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :frame, :frames, :interval
|
|
13
|
+
|
|
14
|
+
def initialize(frames: DEFAULT_FRAMES, interval: 0.1)
|
|
15
|
+
@frames = frames
|
|
16
|
+
@interval = interval
|
|
17
|
+
@frame = 0
|
|
18
|
+
@elapsed = 0.0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def focusable? = false
|
|
22
|
+
|
|
23
|
+
def on_tick(event)
|
|
24
|
+
@elapsed += event.delta
|
|
25
|
+
advanced = false
|
|
26
|
+
while @elapsed >= @interval
|
|
27
|
+
@elapsed -= @interval
|
|
28
|
+
@frame = (@frame + 1) % @frames.length
|
|
29
|
+
advanced = true
|
|
30
|
+
end
|
|
31
|
+
request_render if advanced
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def render(canvas:, theme:)
|
|
35
|
+
canvas.fill(bg: theme.bg)
|
|
36
|
+
canvas.text(content: @frames[@frame], fg: theme.accent, bg: theme.bg)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|