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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +106 -14
  3. data/examples/checkbox.rb +89 -0
  4. data/examples/counter.rb +50 -0
  5. data/examples/hello_world.rb +28 -0
  6. data/examples/layout_demo.rb +138 -0
  7. data/examples/modal.rb +76 -0
  8. data/examples/mouse.rb +60 -0
  9. data/examples/octagram_picker.rb +224 -0
  10. data/examples/picker.rb +150 -0
  11. data/examples/progress_bar.rb +90 -0
  12. data/examples/scroll_view.rb +64 -0
  13. data/examples/select.rb +64 -0
  14. data/examples/spinner.rb +66 -0
  15. data/examples/status_bar.rb +65 -0
  16. data/examples/stopwatch.rb +84 -0
  17. data/examples/table.rb +196 -0
  18. data/examples/tabs.rb +112 -0
  19. data/examples/text.rb +101 -0
  20. data/examples/theme_picker.rb +95 -0
  21. data/examples/todo.rb +242 -0
  22. data/lib/thaum/action.rb +30 -0
  23. data/lib/thaum/app.rb +87 -0
  24. data/lib/thaum/color.rb +97 -0
  25. data/lib/thaum/concerns/context_update.rb +40 -0
  26. data/lib/thaum/concerns/focus.rb +53 -0
  27. data/lib/thaum/concerns/layout.rb +349 -0
  28. data/lib/thaum/concerns/modal.rb +102 -0
  29. data/lib/thaum/concerns/tab_navigation.rb +97 -0
  30. data/lib/thaum/dispatch.rb +149 -0
  31. data/lib/thaum/escape_parser.rb +265 -0
  32. data/lib/thaum/event.rb +13 -0
  33. data/lib/thaum/events.rb +28 -0
  34. data/lib/thaum/hit_test.rb +28 -0
  35. data/lib/thaum/input_reader.rb +46 -0
  36. data/lib/thaum/key_event.rb +13 -0
  37. data/lib/thaum/keys.rb +55 -0
  38. data/lib/thaum/minitest.rb +64 -0
  39. data/lib/thaum/octagram.rb +76 -0
  40. data/lib/thaum/painter.rb +49 -0
  41. data/lib/thaum/rect.rb +5 -0
  42. data/lib/thaum/rendering/box_drawing.rb +186 -0
  43. data/lib/thaum/rendering/buffer.rb +84 -0
  44. data/lib/thaum/rendering/canvas.rb +219 -0
  45. data/lib/thaum/rendering/cell.rb +11 -0
  46. data/lib/thaum/rendering/renderer.rb +98 -0
  47. data/lib/thaum/rendering/style.rb +13 -0
  48. data/lib/thaum/run_loop.rb +182 -0
  49. data/lib/thaum/seq.rb +91 -0
  50. data/lib/thaum/sigil.rb +41 -0
  51. data/lib/thaum/sigils/button.rb +47 -0
  52. data/lib/thaum/sigils/checkbox.rb +57 -0
  53. data/lib/thaum/sigils/progress_bar.rb +65 -0
  54. data/lib/thaum/sigils/scroll_view.rb +115 -0
  55. data/lib/thaum/sigils/select.rb +56 -0
  56. data/lib/thaum/sigils/spinner.rb +39 -0
  57. data/lib/thaum/sigils/status_bar.rb +89 -0
  58. data/lib/thaum/sigils/table.rb +156 -0
  59. data/lib/thaum/sigils/tabs.rb +59 -0
  60. data/lib/thaum/sigils/text.rb +22 -0
  61. data/lib/thaum/sigils/text_input.rb +86 -0
  62. data/lib/thaum/terminal.rb +46 -0
  63. data/lib/thaum/themes.rb +267 -0
  64. data/lib/thaum/tree.rb +16 -0
  65. data/lib/thaum/version.rb +1 -1
  66. data/lib/thaum.rb +64 -1
  67. 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
@@ -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