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
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)
|
data/lib/thaum/action.rb
ADDED
|
@@ -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
|
data/lib/thaum/color.rb
ADDED
|
@@ -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
|