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
data/examples/todo.rb ADDED
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Usage: bundle exec ruby examples/todo.rb
4
+ #
5
+ # Centered todo card on the Gruvbox Dark theme.
6
+ # - type into the input + Enter to add (input clears)
7
+ # - ↑/↓ moves between the input and the list items
8
+ # - space toggles [ ] / [X]
9
+ # - delete removes the focused item
10
+ # - Tab switches between the input and the Quit button
11
+
12
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
13
+ require "thaum"
14
+
15
+ TodoItem = Data.define(:text, :done) do
16
+ def initialize(text:, done: false)
17
+ super
18
+ end
19
+
20
+ def toggle = with(done: !done)
21
+ end
22
+
23
+ class TodoList
24
+ include Thaum::Sigil
25
+
26
+ attr_reader :cursor, :items
27
+
28
+ def initialize
29
+ @items = []
30
+ @cursor = 0
31
+ end
32
+
33
+ def add(text)
34
+ @items << TodoItem.new(text: text)
35
+ end
36
+
37
+ def reset_cursor
38
+ @cursor = 0
39
+ end
40
+
41
+ def on_key(event)
42
+ case event.key
43
+ when :up then move_up_or_bubble(event)
44
+ when :down then @cursor = (@cursor + 1).clamp(0, [@items.size - 1, 0].max)
45
+ when " " then toggle
46
+ when :delete, :backspace then delete_item
47
+ else emit(event)
48
+ end
49
+ end
50
+
51
+ def render(canvas:, theme:)
52
+ canvas.fill(bg: theme.bg)
53
+ inner = canvas.border(fg: theme.border, style: :rounded)
54
+ @items.each_with_index do |item, i|
55
+ row = inner.row(i) or break
56
+
57
+ render_item(row, item, focused: focused? && i == @cursor, theme: theme)
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def move_up_or_bubble(event)
64
+ if @cursor.positive?
65
+ @cursor -= 1
66
+ else
67
+ emit(event) # bubble :up so the App can move focus back to the input
68
+ end
69
+ end
70
+
71
+ def render_item(row, item, focused:, theme:)
72
+ bg = focused ? theme.selection : theme.bg
73
+ fg = item_fg(item, focused: focused, theme: theme)
74
+ mark = item.done ? "[X]" : "[ ]"
75
+ row.fill(bg: bg)
76
+ row.text(content: " #{mark} #{item.text}", fg: fg, bg: bg)
77
+ end
78
+
79
+ def item_fg(item, focused:, theme:)
80
+ return theme.success_fg if item.done
81
+
82
+ focused ? theme.accent : theme.fg
83
+ end
84
+
85
+ def toggle
86
+ return if @items.empty?
87
+
88
+ @items[@cursor] = @items[@cursor].toggle
89
+ end
90
+
91
+ def delete_item
92
+ return if @items.empty?
93
+
94
+ @items.delete_at(@cursor)
95
+ @cursor = @cursor.clamp(0, [@items.size - 1, 0].max)
96
+ end
97
+ end
98
+
99
+ # Local button that fills its own bg so the card surface stays uniform.
100
+ class ActionButton
101
+ include Thaum::Sigil
102
+
103
+ def initialize(label:)
104
+ @label = label
105
+ end
106
+
107
+ def on_key(event)
108
+ case event.key
109
+ when :enter, " " then emit(Thaum::Button::PressedEvent.new(label: @label))
110
+ else emit(event)
111
+ end
112
+ end
113
+
114
+ def render(canvas:, theme:)
115
+ bg = focused? ? theme.selection : theme.bg
116
+ fg = focused? ? theme.accent : theme.fg
117
+ canvas.fill(bg: bg)
118
+ canvas.text(content: @label, fg: fg, bg: bg, align: :center)
119
+ end
120
+ end
121
+
122
+ class Help
123
+ include Thaum::Sigil
124
+
125
+ LINES = [
126
+ "↑/↓ navigate space toggles delete removes",
127
+ "tab to Quit enter adds item esc quits"
128
+ ].freeze
129
+
130
+ def focusable? = false
131
+
132
+ def render(canvas:, theme:)
133
+ canvas.fill(bg: theme.bg)
134
+ LINES.each_with_index do |line, i|
135
+ row = canvas.row(i) or break
136
+ row.text(content: " #{line}", fg: theme.info_fg)
137
+ end
138
+ end
139
+ end
140
+
141
+ class Surface
142
+ include Thaum::Sigil
143
+
144
+ def initialize(slot)
145
+ @slot = slot
146
+ end
147
+
148
+ def focusable? = false
149
+
150
+ def render(canvas:, theme:)
151
+ canvas.fill(bg: theme.send(@slot))
152
+ end
153
+ end
154
+
155
+ class TodoApp
156
+ include Thaum::App
157
+
158
+ CARD_WIDTH = 52
159
+ CARD_HEIGHT = 18
160
+
161
+ def initialize
162
+ @input = Thaum::TextInput.new
163
+ @list = TodoList.new
164
+ @quit = ActionButton.new(label: "Quit")
165
+ @help = Help.new
166
+ @pad_l = Surface.new(:bar_bg)
167
+ @pad_r = Surface.new(:bar_bg)
168
+ @pad_t = Surface.new(:bar_bg)
169
+ @pad_b = Surface.new(:bar_bg)
170
+ end
171
+
172
+ def theme = Thaum::Themes::GRUVBOX_DARK
173
+
174
+ def on_event(event)
175
+ case event
176
+ when Thaum::TextInput::SubmittedEvent then submit(event.value)
177
+ when Thaum::Button::PressedEvent then press(event.label)
178
+ end
179
+ end
180
+
181
+ def on_key(event)
182
+ case event.key
183
+ when :escape then quit
184
+ when :tab, :backtab then toggle_tab_focus
185
+ when :down then down_pressed
186
+ when :up then up_pressed
187
+ end
188
+ end
189
+
190
+ def partition
191
+ horizontal do
192
+ region(width: :fill) { @pad_l }
193
+ region(width: CARD_WIDTH) { column }
194
+ region(width: :fill) { @pad_r }
195
+ end
196
+ end
197
+
198
+ private
199
+
200
+ def column
201
+ vertical do
202
+ region(height: :fill) { @pad_t }
203
+ region(height: CARD_HEIGHT) { card }
204
+ region(height: :fill) { @pad_b }
205
+ end
206
+ end
207
+
208
+ def card
209
+ vertical do
210
+ region(height: 1) { @input }
211
+ region(height: :fill) { @list }
212
+ region(height: 1) { @quit }
213
+ region(height: 2) { @help }
214
+ end
215
+ end
216
+
217
+ def toggle_tab_focus
218
+ focus(focused_sigil.equal?(@input) ? @quit : @input)
219
+ end
220
+
221
+ def down_pressed
222
+ focus(@list) if focused_sigil.equal?(@input) && @list.items.any?
223
+ end
224
+
225
+ def up_pressed
226
+ focus(@input) if focused_sigil.equal?(@list)
227
+ end
228
+
229
+ def submit(value)
230
+ value = value.strip
231
+ return if value.empty?
232
+
233
+ @list.add(value)
234
+ @input.clear
235
+ end
236
+
237
+ def press(label)
238
+ quit if label == "Quit"
239
+ end
240
+ end
241
+
242
+ Thaum.run(TodoApp.new)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thaum
4
+ module Action
5
+ class << self
6
+ attr_accessor :queue, :pool
7
+ end
8
+
9
+ def self.included(base)
10
+ base.extend(ClassMethods)
11
+ end
12
+
13
+ def emit(event)
14
+ Thaum::Action.queue&.push(event)
15
+ end
16
+
17
+ module ClassMethods
18
+ def method_added(name)
19
+ super
20
+ return if name == :initialize
21
+ return unless public_method_defined?(name)
22
+ return if singleton_class.method_defined?(name, false)
23
+
24
+ define_singleton_method(name) do |*args, **kwargs|
25
+ Thaum::Action.pool.post { new.public_send(name, *args, **kwargs) }
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
data/lib/thaum/app.rb ADDED
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thaum
4
+ module App
5
+ include Concerns::Layout
6
+ include Concerns::Focus
7
+ include Concerns::ContextUpdate
8
+ include Concerns::Modal
9
+ include Concerns::TabNavigation
10
+
11
+ attr_reader :in_on_update, :modal_sigil, :modal_rect
12
+
13
+ # --- Quit ---
14
+
15
+ def quit = (@quit_requested = true)
16
+ def quit? = @quit_requested
17
+
18
+ # --- Render dirty flag ---
19
+
20
+ def request_render = (@dirty = true)
21
+ def dirty? = @dirty
22
+ def clear_dirty = (@dirty = false)
23
+
24
+ # --- Mount wiring ---
25
+
26
+ def wire_sigils
27
+ (@leaf_sigils || []).each { |s| s.thaum_app = self }
28
+ wire_handler_parents(handler_parent: self, app: self)
29
+ end
30
+
31
+ def thaum_app_ref = self
32
+
33
+ # Used by the run loop to seed the initially-focused Sigil before
34
+ # entering the main loop. Bypasses Focus#focus's mount check because
35
+ # the mount pass has already run.
36
+ def set_initial_focus(sigil) = (@focused_sigil = sigil)
37
+
38
+ # --- Dispatch from child (synchronous, main thread) ---
39
+
40
+ # A child here is either the focused Sigil (when it emits) or the
41
+ # outermost Octagram (which propagates from its own children). Tab
42
+ # cycling is intercepted before App#on_key so handlers see :tab with
43
+ # focus already updated.
44
+ def dispatch_from_child(event)
45
+ # When a modal is active, bubbled Tab/Shift-Tab is eaten. Other
46
+ # bubbled events still reach App handlers — emit from the modal Sigil
47
+ # can therefore reach App#on_event so apps can react to modal events.
48
+ return if @modal_sigil && event.is_a?(KeyEvent) && event.key == :tab
49
+
50
+ handle_tab_cycle(event) if event.is_a?(KeyEvent) && event.key == :tab
51
+
52
+ Dispatch.invoke_handler(target: self, event:, label: "App##{handler_name_for(event)}")
53
+ end
54
+ alias dispatch_from_sigil dispatch_from_child
55
+
56
+ # --- Theme (override to choose a non-default theme for this app) ---
57
+
58
+ def theme = Themes::DEFAULT
59
+
60
+ # --- Default App handlers (no-ops — App is top of dispatch chain) ---
61
+
62
+ def on_key(event); end
63
+ def on_mouse(event); end
64
+ def on_paste(event); end
65
+ def on_resize(event); end
66
+ def on_tick(event); end
67
+ def on_mount; end
68
+ def on_unmount; end
69
+
70
+ def on_event(event)
71
+ warn "[Thaum] unhandled event: #{event.class} #{event.inspect}"
72
+ end
73
+
74
+ private
75
+
76
+ def focusable_leaves = (@leaf_sigils || []).select(&:focusable?)
77
+
78
+ def handler_name_for(event)
79
+ case event
80
+ when KeyEvent then "on_key"
81
+ when MouseEvent then "on_mouse"
82
+ when PasteEvent then "on_paste"
83
+ else "on_event"
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thaum
4
+ # Capability detection and color-to-escape mapping with degradation
5
+ # (truecolor → 256 → 16 → none).
6
+ module Color
7
+ HEX_COLOR_PATTERN = /\A#[0-9a-fA-F]{6}\z/
8
+
9
+ # 16 ANSI color RGB approximations (VGA palette) and their SGR codes
10
+ # for foreground. Background = fg + 10. Bright = (90 or 100) base.
11
+ ANSI_16 = {
12
+ black: { rgb: [0, 0, 0], fg: 30, bg: 40 },
13
+ red: { rgb: [170, 0, 0], fg: 31, bg: 41 },
14
+ green: { rgb: [0, 170, 0], fg: 32, bg: 42 },
15
+ yellow: { rgb: [170, 85, 0], fg: 33, bg: 43 },
16
+ blue: { rgb: [0, 0, 170], fg: 34, bg: 44 },
17
+ magenta: { rgb: [170, 0, 170], fg: 35, bg: 45 },
18
+ cyan: { rgb: [0, 170, 170], fg: 36, bg: 46 },
19
+ white: { rgb: [170, 170, 170], fg: 37, bg: 47 },
20
+ bright_black: { rgb: [85, 85, 85], fg: 90, bg: 100 },
21
+ bright_red: { rgb: [255, 85, 85], fg: 91, bg: 101 },
22
+ bright_green: { rgb: [85, 255, 85], fg: 92, bg: 102 },
23
+ bright_yellow: { rgb: [255, 255, 85], fg: 93, bg: 103 },
24
+ bright_blue: { rgb: [85, 85, 255], fg: 94, bg: 104 },
25
+ bright_magenta: { rgb: [255, 85, 255], fg: 95, bg: 105 },
26
+ bright_cyan: { rgb: [85, 255, 255], fg: 96, bg: 106 },
27
+ bright_white: { rgb: [255, 255, 255], fg: 97, bg: 107 }
28
+ }.freeze
29
+
30
+ def self.detect(env)
31
+ colorterm = env["COLORTERM"]
32
+ term = env["TERM"]
33
+ return :truecolor if %w[truecolor 24bit].include?(colorterm)
34
+ return :none if term.nil? || term.empty? || term == "dumb"
35
+ return :"256" if term.include?("256color")
36
+
37
+ :ansi
38
+ end
39
+
40
+ def self.to_escape(color, capability:, base:)
41
+ return "" if capability == :none
42
+ return "" if color.nil?
43
+
44
+ if color.is_a?(Array)
45
+ hex, fallback = color
46
+ return capability == :ansi ? to_escape(fallback, capability:, base:) : to_escape(hex, capability:, base:)
47
+ end
48
+
49
+ case color
50
+ when Symbol then named_escape(name: color, base: base)
51
+ when String then hex_escape(hex: color, capability: capability, base: base)
52
+ else ""
53
+ end
54
+ end
55
+
56
+ def self.named_escape(name:, base:)
57
+ info = ANSI_16[name]
58
+ return "\e[#{base == 38 ? 39 : 49}m" if name == :default
59
+ return "" unless info
60
+
61
+ code = base == 38 ? info[:fg] : info[:bg]
62
+ "\e[#{code}m"
63
+ end
64
+
65
+ def self.hex_escape(hex:, capability:, base:)
66
+ return "" unless hex.match?(HEX_COLOR_PATTERN)
67
+
68
+ r = hex[1..2].to_i(16)
69
+ g = hex[3..4].to_i(16)
70
+ b = hex[5..6].to_i(16)
71
+
72
+ case capability
73
+ when :truecolor then "\e[#{base};2;#{r};#{g};#{b}m"
74
+ when :"256" then "\e[#{base};5;#{hex_to_256(r: r, g: g, b: b)}m"
75
+ when :ansi then named_escape(name: hex_to_ansi(r: r, g: g, b: b), base: base)
76
+ else ""
77
+ end
78
+ end
79
+
80
+ def self.hex_to_256(r:, g:, b:) # rubocop:disable Naming/VariableNumber
81
+ # 6x6x6 color cube: 16 + 36*r6 + 6*g6 + b6
82
+ r6 = (r * 5.0 / 255).round
83
+ g6 = (g * 5.0 / 255).round
84
+ b6 = (b * 5.0 / 255).round
85
+ 16 + (36 * r6) + (6 * g6) + b6
86
+ end
87
+
88
+ def self.hex_to_ansi(r:, g:, b:)
89
+ ANSI_16.min_by do |_name, info|
90
+ ar, ag, ab = info[:rgb]
91
+ ((r - ar)**2) + ((g - ag)**2) + ((b - ab)**2)
92
+ end.first
93
+ end
94
+
95
+ private_class_method :named_escape, :hex_escape, :hex_to_256, :hex_to_ansi # rubocop:disable Naming/VariableNumber
96
+ end
97
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thaum
4
+ module Concerns
5
+ module ContextUpdate
6
+ # --- Context ---
7
+
8
+ def update_context(hash)
9
+ cloned = deep_freeze(hash.dup)
10
+ @context = cloned
11
+ @in_on_update = true
12
+ begin
13
+ Tree.walk(self) do |node|
14
+ next unless node.is_a?(Sigil) || node.is_a?(Octagram)
15
+
16
+ node.on_update(cloned)
17
+ end
18
+ # Modal Sigil receives on_update last, after the layout tree.
19
+ @modal_sigil&.on_update(cloned)
20
+ ensure
21
+ @in_on_update = false
22
+ end
23
+ request_render
24
+ end
25
+
26
+ private
27
+
28
+ def deep_freeze(obj)
29
+ case obj
30
+ when Hash
31
+ obj.transform_values! { |v| deep_freeze(v) }.freeze
32
+ when Array
33
+ obj.map! { |v| deep_freeze(v) }.freeze
34
+ else
35
+ obj
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thaum
4
+ module Concerns
5
+ module Focus
6
+ # --- Focus ---
7
+
8
+ def focus(sigil)
9
+ return if @modal_sigil
10
+ return if @focused_sigil.equal?(sigil)
11
+ return if sigil && !focusable_and_mounted?(sigil)
12
+
13
+ old = @focused_sigil
14
+ @focused_sigil = sigil
15
+ old&.on_blur
16
+ sigil&.on_focus
17
+ request_render
18
+ end
19
+
20
+ def focus_next
21
+ return if @modal_sigil
22
+
23
+ leaves = effective_focus_order
24
+ return if leaves.empty?
25
+
26
+ idx = @focused_sigil ? (leaves.index(@focused_sigil) || -1) : -1
27
+ focus(leaves[(idx + 1) % leaves.size])
28
+ end
29
+
30
+ def focus_prev
31
+ return if @modal_sigil
32
+
33
+ leaves = effective_focus_order
34
+ return if leaves.empty?
35
+
36
+ idx = @focused_sigil ? (leaves.index(@focused_sigil) || 0) : 0
37
+ focus(leaves[(idx - 1) % leaves.size])
38
+ end
39
+
40
+ def focused_sigil = @focused_sigil
41
+
42
+ def initial_focus
43
+ (@leaf_sigils || []).find(&:focusable?)
44
+ end
45
+
46
+ private
47
+
48
+ def focusable_and_mounted?(sigil)
49
+ sigil.focusable? && (@leaf_sigils || []).include?(sigil)
50
+ end
51
+ end
52
+ end
53
+ end