thaum 0.1.0 → 0.2.1

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +106 -14
  4. data/examples/checkbox.rb +89 -0
  5. data/examples/counter.rb +50 -0
  6. data/examples/hello_world.rb +28 -0
  7. data/examples/layout_demo.rb +138 -0
  8. data/examples/modal.rb +76 -0
  9. data/examples/mouse.rb +60 -0
  10. data/examples/octagram_picker.rb +224 -0
  11. data/examples/picker.rb +150 -0
  12. data/examples/progress_bar.rb +90 -0
  13. data/examples/scroll_view.rb +64 -0
  14. data/examples/select.rb +64 -0
  15. data/examples/spinner.rb +66 -0
  16. data/examples/status_bar.rb +65 -0
  17. data/examples/stopwatch.rb +84 -0
  18. data/examples/table.rb +196 -0
  19. data/examples/tabs.rb +112 -0
  20. data/examples/text.rb +101 -0
  21. data/examples/theme_picker.rb +95 -0
  22. data/examples/todo.rb +242 -0
  23. data/lib/thaum/action.rb +48 -0
  24. data/lib/thaum/app.rb +87 -0
  25. data/lib/thaum/color.rb +104 -0
  26. data/lib/thaum/concerns/context_update.rb +40 -0
  27. data/lib/thaum/concerns/focus.rb +53 -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 +115 -0
  36. data/lib/thaum/key_event.rb +13 -0
  37. data/lib/thaum/keys.rb +55 -0
  38. data/lib/thaum/layout.rb +347 -0
  39. data/lib/thaum/minitest.rb +64 -0
  40. data/lib/thaum/octagram.rb +76 -0
  41. data/lib/thaum/painter.rb +49 -0
  42. data/lib/thaum/rect.rb +5 -0
  43. data/lib/thaum/rendering/box_drawing.rb +186 -0
  44. data/lib/thaum/rendering/buffer.rb +84 -0
  45. data/lib/thaum/rendering/canvas.rb +221 -0
  46. data/lib/thaum/rendering/cell.rb +11 -0
  47. data/lib/thaum/rendering/renderer.rb +98 -0
  48. data/lib/thaum/rendering/style.rb +13 -0
  49. data/lib/thaum/run_loop.rb +182 -0
  50. data/lib/thaum/seq.rb +91 -0
  51. data/lib/thaum/sigil.rb +41 -0
  52. data/lib/thaum/sigils/button.rb +47 -0
  53. data/lib/thaum/sigils/checkbox.rb +57 -0
  54. data/lib/thaum/sigils/progress_bar.rb +65 -0
  55. data/lib/thaum/sigils/scroll_view.rb +115 -0
  56. data/lib/thaum/sigils/select.rb +56 -0
  57. data/lib/thaum/sigils/spinner.rb +39 -0
  58. data/lib/thaum/sigils/status_bar.rb +89 -0
  59. data/lib/thaum/sigils/table.rb +156 -0
  60. data/lib/thaum/sigils/tabs.rb +59 -0
  61. data/lib/thaum/sigils/text.rb +22 -0
  62. data/lib/thaum/sigils/text_input.rb +86 -0
  63. data/lib/thaum/terminal.rb +46 -0
  64. data/lib/thaum/themes.rb +267 -0
  65. data/lib/thaum/tree.rb +16 -0
  66. data/lib/thaum/version.rb +1 -1
  67. data/lib/thaum.rb +64 -1
  68. metadata +115 -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,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thaum
4
+ # Mixin that turns a plain class into a background "action".
5
+ #
6
+ # Including this module promotes every *public* instance method into a
7
+ # class method (see {ClassMethods#method_added}). Calling that class method
8
+ # schedules the work on the run loop's background thread pool:
9
+ #
10
+ # - A fresh instance is built with an argless `new`, so Actions must not
11
+ # require constructor arguments.
12
+ # - The method runs fire-and-forget; its return value is discarded.
13
+ # - Use {#emit} to push results back to the run loop as events.
14
+ # - Calling a promoted method outside a running Thaum app (no pool) raises
15
+ # {Thaum::Error}.
16
+ module Action
17
+ class << self
18
+ attr_accessor :queue, :pool
19
+ end
20
+
21
+ def self.included(base)
22
+ base.extend(ClassMethods)
23
+ end
24
+
25
+ def emit(event)
26
+ Thaum::Action.queue&.push(event)
27
+ end
28
+
29
+ module ClassMethods
30
+ def method_added(name)
31
+ super
32
+ return if name == :initialize
33
+ return unless public_method_defined?(name)
34
+ return if singleton_class.method_defined?(name, false)
35
+
36
+ define_singleton_method(name) do |*args, **kwargs|
37
+ pool = Thaum::Action.pool
38
+ unless pool
39
+ raise Thaum::Error,
40
+ "Thaum::Action method called outside a running Thaum app (no thread pool available)"
41
+ end
42
+
43
+ pool.post { new.public_send(name, *args, **kwargs) }
44
+ end
45
+ end
46
+ end
47
+ end
48
+ 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 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,104 @@
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
+ return :none if no_color?(env)
32
+
33
+ colorterm = env["COLORTERM"]
34
+ term = env["TERM"]
35
+ return :truecolor if %w[truecolor 24bit].include?(colorterm)
36
+ return :none if term.nil? || term.empty? || term == "dumb"
37
+ return :"256" if term.include?("256color")
38
+
39
+ :ansi
40
+ end
41
+
42
+ def self.no_color?(env)
43
+ value = env["NO_COLOR"]
44
+ !value.nil? && !value.empty?
45
+ end
46
+
47
+ def self.to_escape(color, capability:, base:)
48
+ return "" if capability == :none
49
+ return "" if color.nil?
50
+
51
+ if color.is_a?(Array)
52
+ hex, fallback = color
53
+ return capability == :ansi ? to_escape(fallback, capability:, base:) : to_escape(hex, capability:, base:)
54
+ end
55
+
56
+ case color
57
+ when Symbol then named_escape(name: color, base: base)
58
+ when String then hex_escape(hex: color, capability: capability, base: base)
59
+ else ""
60
+ end
61
+ end
62
+
63
+ def self.named_escape(name:, base:)
64
+ info = ANSI_16[name]
65
+ return "\e[#{base == 38 ? 39 : 49}m" if name == :default
66
+ return "" unless info
67
+
68
+ code = base == 38 ? info[:fg] : info[:bg]
69
+ "\e[#{code}m"
70
+ end
71
+
72
+ def self.hex_escape(hex:, capability:, base:)
73
+ return "" unless hex.match?(HEX_COLOR_PATTERN)
74
+
75
+ r = hex[1..2].to_i(16)
76
+ g = hex[3..4].to_i(16)
77
+ b = hex[5..6].to_i(16)
78
+
79
+ case capability
80
+ when :truecolor then "\e[#{base};2;#{r};#{g};#{b}m"
81
+ when :"256" then "\e[#{base};5;#{hex_to_256(r: r, g: g, b: b)}m"
82
+ when :ansi then named_escape(name: hex_to_ansi(r: r, g: g, b: b), base: base)
83
+ else ""
84
+ end
85
+ end
86
+
87
+ def self.hex_to_256(r:, g:, b:) # rubocop:disable Naming/VariableNumber
88
+ # 6x6x6 color cube: 16 + 36*r6 + 6*g6 + b6
89
+ r6 = (r * 5.0 / 255).round
90
+ g6 = (g * 5.0 / 255).round
91
+ b6 = (b * 5.0 / 255).round
92
+ 16 + (36 * r6) + (6 * g6) + b6
93
+ end
94
+
95
+ def self.hex_to_ansi(r:, g:, b:)
96
+ ANSI_16.min_by do |_name, info|
97
+ ar, ag, ab = info[:rgb]
98
+ ((r - ar)**2) + ((g - ag)**2) + ((b - ab)**2)
99
+ end.first
100
+ end
101
+
102
+ private_class_method :no_color?, :named_escape, :hex_escape, :hex_to_256, :hex_to_ansi # rubocop:disable Naming/VariableNumber
103
+ end
104
+ 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
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thaum
4
+ module Concerns
5
+ module Modal
6
+ # --- Modal ---
7
+
8
+ # Show a modal Sigil as an overlay above the layout tree. width/height
9
+ # are required; x/y are optional terminal-absolute coords — when nil
10
+ # the modal is centered on the current terminal size. Calling show_modal
11
+ # while a modal is already active fires on_blur + on_unmount on the
12
+ # outgoing modal and replaces it. The previously-focused Sigil (from
13
+ # before any modal was shown) is preserved so hide_modal can restore it.
14
+ def show_modal(sigil:, width:, height:, x: nil, y: nil)
15
+ replacing = !@modal_sigil.nil?
16
+ centered = x.nil? && y.nil?
17
+
18
+ if replacing
19
+ Thaum.safe_invoke("#{@modal_sigil.class}#on_blur") { @modal_sigil.on_blur }
20
+ Thaum.safe_invoke("#{@modal_sigil.class}#on_unmount") { @modal_sigil.on_unmount }
21
+ @modal_sigil.thaum_app = nil
22
+ @modal_sigil.handler_parent = nil
23
+ @modal_sigil.rect = nil
24
+ elsif @focused_sigil
25
+ # First modal — save the underlying focused Sigil and fire its on_blur.
26
+ @previous_focus = @focused_sigil
27
+ Thaum.safe_invoke("#{@focused_sigil.class}#on_blur") { @focused_sigil.on_blur }
28
+ @focused_sigil = nil
29
+ end
30
+
31
+ rect = compute_modal_rect(width: width, height: height, x: x, y: y)
32
+
33
+ @modal_sigil = sigil
34
+ @modal_rect = rect
35
+ @modal_centered = centered
36
+ @modal_decl_w = width
37
+ @modal_decl_h = height
38
+ sigil.thaum_app = self
39
+ sigil.handler_parent = self
40
+ sigil.rect = rect
41
+
42
+ Thaum.safe_invoke("#{sigil.class}#on_mount") { sigil.on_mount }
43
+ Thaum.safe_invoke("#{sigil.class}#on_focus") { sigil.on_focus }
44
+
45
+ request_render
46
+ sigil
47
+ end
48
+
49
+ def hide_modal
50
+ sigil = @modal_sigil
51
+ return unless sigil
52
+
53
+ Thaum.safe_invoke("#{sigil.class}#on_blur") { sigil.on_blur }
54
+ Thaum.safe_invoke("#{sigil.class}#on_unmount") { sigil.on_unmount }
55
+
56
+ @modal_sigil = nil
57
+ @modal_rect = nil
58
+ @modal_centered = false
59
+ @modal_decl_w = nil
60
+ @modal_decl_h = nil
61
+ sigil.thaum_app = nil
62
+ sigil.handler_parent = nil
63
+ sigil.rect = nil
64
+
65
+ restored = @previous_focus
66
+ @previous_focus = nil
67
+ if restored && focusable_and_mounted?(restored)
68
+ @focused_sigil = restored
69
+ Thaum.safe_invoke("#{restored.class}#on_focus") { restored.on_focus }
70
+ end
71
+
72
+ request_render
73
+ nil
74
+ end
75
+
76
+ def modal_active? = !@modal_sigil.nil?
77
+
78
+ # Called by the framework on ResizeEvent after the layout repartitions.
79
+ # Re-centers a default-centered modal on the new terminal dimensions.
80
+ # Modals with explicit x/y stay put.
81
+ def recompute_modal_rect
82
+ return unless @modal_sigil && @modal_centered
83
+
84
+ @modal_rect = compute_modal_rect(width: @modal_decl_w, height: @modal_decl_h, x: nil, y: nil)
85
+ @modal_sigil.rect = @modal_rect
86
+ end
87
+
88
+ private
89
+
90
+ # Compute the modal's terminal-absolute rect from declared size + optional
91
+ # x/y. When x/y are nil, centers on the App's current rect (set by
92
+ # run_partition to the terminal's dimensions). Overflow is allowed —
93
+ # the Buffer drops out-of-bounds cells at paint time.
94
+ def compute_modal_rect(width:, height:, x:, y:)
95
+ app_rect = @rect || Rect.new(x: 0, y: 0, width: 0, height: 0)
96
+ cx = x.nil? ? app_rect.x + ((app_rect.width - width) / 2) : x
97
+ cy = y.nil? ? app_rect.y + ((app_rect.height - height) / 2) : y
98
+ Rect.new(x: cx, y: cy, width: width, height: height)
99
+ end
100
+ end
101
+ end
102
+ end