chamomile-petals 0.1.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.
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petals
4
+ ProgressFrameMsg = Data.define(:id, :tag)
5
+
6
+ # Animated progress bar with spring-based animation and optional gradient.
7
+ class Progress
8
+ DEFAULT_WIDTH = 40
9
+ FULL_CHAR = "\u2588"
10
+ EMPTY_CHAR = "\u2591"
11
+
12
+ # Spring constants
13
+ FREQUENCY = 14.0
14
+ DAMPING = 2.2
15
+ EPSILON = 0.001
16
+ FPS = 60
17
+
18
+ @next_id = 0
19
+ @id_mutex = Mutex.new
20
+ @id_pid = Process.pid
21
+
22
+ def self.next_id
23
+ @id_mutex.synchronize do
24
+ if Process.pid != @id_pid
25
+ @id_pid = Process.pid
26
+ @next_id = 0
27
+ @id_mutex = Mutex.new
28
+ end
29
+ @next_id += 1
30
+ "#{@id_pid}-pg-#{@next_id}"
31
+ end
32
+ end
33
+
34
+ attr_reader :id, :percent, :frequency, :damping
35
+ attr_accessor :width, :full_char, :empty_char, :show_percentage,
36
+ :percentage_format, :full_color, :empty_color, :gradient
37
+
38
+ def initialize(width: DEFAULT_WIDTH, show_percentage: true, percentage_format: " %3.0f%%",
39
+ full_char: FULL_CHAR, empty_char: EMPTY_CHAR,
40
+ full_color: nil, empty_color: nil, gradient: nil,
41
+ frequency: FREQUENCY, damping: DAMPING)
42
+ @id = self.class.next_id
43
+ @width = width
44
+ @full_char = full_char
45
+ @empty_char = empty_char
46
+ @show_percentage = show_percentage
47
+ @percentage_format = percentage_format
48
+ @full_color = full_color
49
+ @empty_color = empty_color
50
+ @gradient = gradient
51
+ @frequency = frequency.to_f
52
+ @damping = damping.to_f
53
+ @percent = 0.0
54
+ @target = 0.0
55
+ @velocity = 0.0
56
+ @tag = 0
57
+ @animating = false
58
+ end
59
+
60
+ def set_percent(p)
61
+ @target = p.to_f.clamp(0.0, 1.0)
62
+ start_animation
63
+ end
64
+
65
+ def incr_percent(v)
66
+ set_percent(@target + v)
67
+ end
68
+
69
+ def decr_percent(v)
70
+ set_percent(@target - v)
71
+ end
72
+
73
+ def set_spring_options(frequency, damping)
74
+ @frequency = frequency.to_f
75
+ @damping = damping.to_f
76
+ end
77
+
78
+ def animating?
79
+ @animating
80
+ end
81
+
82
+ def update(msg)
83
+ return unless msg.is_a?(ProgressFrameMsg)
84
+ return unless msg.id == @id && msg.tag == @tag
85
+
86
+ dt = 1.0 / FPS
87
+ force = (@target - @percent) * @frequency
88
+ @velocity = (@velocity + (force * dt)) * (1.0 / (1.0 + (@damping * dt)))
89
+ @percent += @velocity * dt
90
+ @percent = @percent.clamp(0.0, 1.0)
91
+
92
+ if (@percent - @target).abs < EPSILON && @velocity.abs < EPSILON
93
+ @percent = @target
94
+ @velocity = 0.0
95
+ @animating = false
96
+ @tag += 1
97
+ nil
98
+ else
99
+ @tag += 1
100
+ frame_cmd
101
+ end
102
+ end
103
+
104
+ def view
105
+ render_bar(@percent)
106
+ end
107
+
108
+ def view_as(percent)
109
+ render_bar(percent.to_f.clamp(0.0, 1.0))
110
+ end
111
+
112
+ private
113
+
114
+ def start_animation
115
+ @tag += 1
116
+ if @animating
117
+ @velocity = 0.0
118
+ return frame_cmd
119
+ end
120
+
121
+ @animating = true
122
+ frame_cmd
123
+ end
124
+
125
+ def frame_cmd
126
+ captured_id = @id
127
+ captured_tag = @tag
128
+ -> {
129
+ sleep(1.0 / FPS)
130
+ ProgressFrameMsg.new(id: captured_id, tag: captured_tag)
131
+ }
132
+ end
133
+
134
+ def render_bar(pct)
135
+ pct_text = @show_percentage ? format(@percentage_format, pct * 100) : ""
136
+ bar_width = [@width - pct_text.length, 0].max
137
+
138
+ filled_width = (pct * bar_width).round
139
+ empty_width = bar_width - filled_width
140
+
141
+ bar = if @gradient && @gradient.length >= 2
142
+ render_gradient_bar(pct, filled_width, empty_width)
143
+ elsif @full_color || @empty_color
144
+ render_colored_bar(filled_width, empty_width)
145
+ else
146
+ (@full_char * filled_width) + (@empty_char * empty_width)
147
+ end
148
+
149
+ bar + pct_text
150
+ end
151
+
152
+ def render_colored_bar(filled_width, empty_width)
153
+ result = ""
154
+ if @full_color && filled_width.positive?
155
+ r, g, b = @full_color
156
+ result += "\e[38;2;#{r};#{g};#{b}m#{@full_char * filled_width}\e[0m"
157
+ else
158
+ result += @full_char * filled_width
159
+ end
160
+ if @empty_color && empty_width.positive?
161
+ r, g, b = @empty_color
162
+ result += "\e[38;2;#{r};#{g};#{b}m#{@empty_char * empty_width}\e[0m"
163
+ else
164
+ result += @empty_char * empty_width
165
+ end
166
+ result
167
+ end
168
+
169
+ def render_gradient_bar(_pct, filled_width, empty_width)
170
+ return @empty_char * @width if filled_width.zero?
171
+
172
+ chars = filled_width.times.map do |i|
173
+ t = filled_width > 1 ? i.to_f / (filled_width - 1) : 0.0
174
+ r, g, b = lerp_color(t)
175
+ "\e[38;2;#{r};#{g};#{b}m#{@full_char}\e[0m"
176
+ end
177
+ chars.join + (@empty_char * empty_width)
178
+ end
179
+
180
+ def lerp_color(t)
181
+ colors = @gradient
182
+ return colors[0] if t <= 0.0
183
+ return colors[-1] if t >= 1.0
184
+
185
+ scaled = t * (colors.length - 1)
186
+ idx = scaled.floor
187
+ idx = [idx, colors.length - 2].min
188
+ frac = scaled - idx
189
+
190
+ c1 = colors[idx]
191
+ c2 = colors[idx + 1]
192
+ [
193
+ (c1[0] + ((c2[0] - c1[0]) * frac)).round,
194
+ (c1[1] + ((c2[1] - c1[1]) * frac)).round,
195
+ (c1[2] + ((c2[2] - c1[2]) * frac)).round,
196
+ ]
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petals
4
+ # Mixin for render result caching keyed on content hash.
5
+ # Include in components whose render output depends only on their data and dimensions.
6
+ module RenderCache
7
+ def cached_render(width:, height:, cache_key:)
8
+ key = [width, height, cache_key]
9
+ return @render_cache if @render_cache_key == key
10
+
11
+ @render_cache_key = key
12
+ @render_cache = yield
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petals
4
+ SpinnerType = Data.define(:frames, :fps)
5
+
6
+ module Spinners
7
+ LINE = SpinnerType.new(frames: %w[| / - \\], fps: 10)
8
+ DOT = SpinnerType.new(frames: %w[⣾ ⣽ ⣻ ⢿ ⡿ ⣟ ⣯ ⣷], fps: 10)
9
+ MINI_DOT = SpinnerType.new(frames: %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏], fps: 12)
10
+ JUMP = SpinnerType.new(frames: %w[⢄ ⢂ ⢁ ⡁ ⡈ ⡐ ⡠], fps: 10)
11
+ PULSE = SpinnerType.new(frames: %w[█ ▓ ▒ ░], fps: 8)
12
+ POINTS = SpinnerType.new(frames: ["∙∙∙", "●∙∙", "∙●∙", "∙∙●"], fps: 7)
13
+ GLOBE = SpinnerType.new(frames: %w[🌍 🌎 🌏], fps: 4)
14
+ MOON = SpinnerType.new(frames: %w[🌑 🌒 🌓 🌔 🌕 🌖 🌗 🌘], fps: 8)
15
+ MONKEY = SpinnerType.new(frames: %w[🙈 🙉 🙊], fps: 3)
16
+ METER = SpinnerType.new(frames: ["▱▱▱", "▰▱▱", "▰▰▱", "▰▰▰", "▰▰▱", "▰▱▱", "▱▱▱"], fps: 7)
17
+ HAMBURGER = SpinnerType.new(frames: %w[☱ ☲ ☴ ☲], fps: 3)
18
+ ELLIPSIS = SpinnerType.new(frames: ["", ".", "..", "..."], fps: 3)
19
+ end
20
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petals
4
+ SpinnerTickMsg = Data.define(:id, :tag, :time)
5
+
6
+ # Animated spinner with configurable frame types and tick-based updates.
7
+ class Spinner
8
+ @next_id = 0
9
+ @id_mutex = Mutex.new
10
+ @id_pid = Process.pid
11
+
12
+ def self.next_id
13
+ @id_mutex.synchronize do
14
+ if Process.pid != @id_pid
15
+ @id_pid = Process.pid
16
+ @next_id = 0
17
+ @id_mutex = Mutex.new
18
+ end
19
+ @next_id += 1
20
+ "#{@id_pid}-#{@next_id}"
21
+ end
22
+ end
23
+
24
+ attr_reader :id, :spinner_type
25
+
26
+ def initialize(type: Spinners::LINE)
27
+ @id = self.class.next_id
28
+ @spinner_type = type
29
+ @frame = 0
30
+ @tag = 0
31
+ end
32
+
33
+ # Returns a command that sleeps for the frame interval then produces a SpinnerTickMsg.
34
+ def tick_cmd
35
+ captured_id = @id
36
+ captured_tag = @tag
37
+ fps = @spinner_type.fps
38
+ -> {
39
+ sleep(1.0 / fps)
40
+ SpinnerTickMsg.new(id: captured_id, tag: captured_tag, time: Time.now)
41
+ }
42
+ end
43
+
44
+ # Advance frame if msg is a matching SpinnerTickMsg; return cmd or nil.
45
+ def update(msg)
46
+ return unless msg.is_a?(SpinnerTickMsg)
47
+ return unless msg.id == @id && msg.tag == @tag
48
+
49
+ @frame = (@frame + 1) % @spinner_type.frames.size
50
+ @tag += 1
51
+ tick_cmd
52
+ end
53
+
54
+ # Current frame string.
55
+ def view
56
+ @spinner_type.frames[@frame]
57
+ end
58
+
59
+ # Reset to first frame, invalidate in-flight ticks.
60
+ def reset
61
+ @frame = 0
62
+ @tag += 1
63
+ self
64
+ end
65
+
66
+ # Change spinner type, reset frame, invalidate in-flight ticks.
67
+ def spinner_type=(type)
68
+ @spinner_type = type
69
+ @frame = 0
70
+ @tag += 1
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petals
4
+ StopwatchTickMsg = Data.define(:id, :tag, :time)
5
+
6
+ # Count-up stopwatch with start/stop/reset and tick-based updates.
7
+ class Stopwatch
8
+ @next_id = 0
9
+ @id_mutex = Mutex.new
10
+ @id_pid = Process.pid
11
+
12
+ def self.next_id
13
+ @id_mutex.synchronize do
14
+ if Process.pid != @id_pid
15
+ @id_pid = Process.pid
16
+ @next_id = 0
17
+ @id_mutex = Mutex.new
18
+ end
19
+ @next_id += 1
20
+ "#{@id_pid}-sw-#{@next_id}"
21
+ end
22
+ end
23
+
24
+ attr_reader :id, :interval, :elapsed
25
+
26
+ def initialize(interval: 1.0)
27
+ @id = self.class.next_id
28
+ @interval = interval
29
+ @elapsed = 0.0
30
+ @tag = 0
31
+ @running = false
32
+ end
33
+
34
+ def start_cmd
35
+ return nil if @running
36
+
37
+ @running = true
38
+ tick_cmd
39
+ end
40
+
41
+ def stop
42
+ @running = false
43
+ @tag += 1
44
+ self
45
+ end
46
+
47
+ def toggle
48
+ if @running
49
+ stop
50
+ nil
51
+ else
52
+ start_cmd
53
+ end
54
+ end
55
+
56
+ def reset
57
+ @elapsed = 0.0
58
+ @running = false
59
+ @tag += 1
60
+ self
61
+ end
62
+
63
+ def running?
64
+ @running
65
+ end
66
+
67
+ def update(msg)
68
+ return unless msg.is_a?(StopwatchTickMsg)
69
+ return unless msg.id == @id && msg.tag == @tag
70
+
71
+ @elapsed += @interval
72
+ @tag += 1
73
+ tick_cmd
74
+ end
75
+
76
+ def view
77
+ total = @elapsed.ceil.to_i
78
+ hours = total / 3600
79
+ minutes = (total % 3600) / 60
80
+ seconds = total % 60
81
+
82
+ if hours.positive?
83
+ format("%d:%02d:%02d", hours, minutes, seconds)
84
+ else
85
+ format("%02d:%02d", minutes, seconds)
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def tick_cmd
92
+ captured_id = @id
93
+ captured_tag = @tag
94
+ interval = @interval
95
+ -> {
96
+ sleep(interval)
97
+ StopwatchTickMsg.new(id: captured_id, tag: captured_tag, time: Time.now)
98
+ }
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petals
4
+ class Table
5
+ DEFAULT_KEY_MAP = KeyBinding.normalize({
6
+ up: [[:up, []], ["k", []]],
7
+ down: [[:down, []], ["j", []]],
8
+ page_up: [[:page_up, []]],
9
+ page_down: [[:page_down, []]],
10
+ goto_top: [["g", []]],
11
+ goto_bottom: [["G", [:shift]]],
12
+ })
13
+ end
14
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petals
4
+ # Tabular data display with selection, scrolling, and focus gating.
5
+ class Table
6
+ Column = Data.define(:title, :width)
7
+
8
+ attr_accessor :columns, :key_map, :height
9
+ attr_reader :cursor, :rows
10
+
11
+ def initialize(columns: [], rows: [], height: 10, key_map: DEFAULT_KEY_MAP)
12
+ @columns = columns
13
+ @rows = rows
14
+ @height = height
15
+ @key_map = key_map
16
+ @cursor = 0
17
+ @offset = 0
18
+ @focused = false
19
+ end
20
+
21
+ def rows=(rows)
22
+ @rows = rows
23
+ @cursor = @cursor.clamp(0, [row_count - 1, 0].max)
24
+ clamp_offset
25
+ end
26
+
27
+ def selected_row
28
+ return nil if @rows.empty?
29
+
30
+ @rows[@cursor]
31
+ end
32
+
33
+ def move_up(n = 1)
34
+ @cursor = (@cursor - n).clamp(0, [row_count - 1, 0].max)
35
+ clamp_offset
36
+ end
37
+
38
+ def move_down(n = 1)
39
+ @cursor = (@cursor + n).clamp(0, [row_count - 1, 0].max)
40
+ clamp_offset
41
+ end
42
+
43
+ def goto_top
44
+ @cursor = 0
45
+ clamp_offset
46
+ end
47
+
48
+ def goto_bottom
49
+ @cursor = [row_count - 1, 0].max
50
+ clamp_offset
51
+ end
52
+
53
+ def cursor=(n)
54
+ @cursor = n.clamp(0, [row_count - 1, 0].max)
55
+ clamp_offset
56
+ end
57
+
58
+ def focus
59
+ @focused = true
60
+ self
61
+ end
62
+
63
+ def blur
64
+ @focused = false
65
+ self
66
+ end
67
+
68
+ def focused?
69
+ @focused
70
+ end
71
+
72
+ def update(msg)
73
+ return unless @focused
74
+
75
+ case msg
76
+ when Chamomile::KeyMsg
77
+ handle_key(msg)
78
+ end
79
+
80
+ nil
81
+ end
82
+
83
+ def view
84
+ header = render_header
85
+ separator = render_separator
86
+ body = render_body
87
+
88
+ [header, separator, body].compact.join("\n")
89
+ end
90
+
91
+ private
92
+
93
+ def row_count
94
+ @rows.length
95
+ end
96
+
97
+ def handle_key(msg)
98
+ kb = KeyBinding
99
+ if kb.key_matches?(msg, @key_map, :up)
100
+ move_up
101
+ elsif kb.key_matches?(msg, @key_map, :down)
102
+ move_down
103
+ elsif kb.key_matches?(msg, @key_map, :page_up)
104
+ move_up(@height)
105
+ elsif kb.key_matches?(msg, @key_map, :page_down)
106
+ move_down(@height)
107
+ elsif kb.key_matches?(msg, @key_map, :goto_top)
108
+ goto_top
109
+ elsif kb.key_matches?(msg, @key_map, :goto_bottom)
110
+ goto_bottom
111
+ end
112
+ end
113
+
114
+ def render_header
115
+ return "" if @columns.empty?
116
+
117
+ @columns.map { |col| truncate_pad(col.title, col.width) }.join(" ")
118
+ end
119
+
120
+ def render_separator
121
+ return "" if @columns.empty?
122
+
123
+ @columns.map { |col| "\u2500" * col.width }.join(" ")
124
+ end
125
+
126
+ def render_body
127
+ return "" if @rows.empty?
128
+
129
+ visible = @rows[@offset, @height] || []
130
+ lines = visible.each_with_index.map do |row, i|
131
+ idx = @offset + i
132
+ line = render_row(row)
133
+ if idx == @cursor
134
+ "\e[7m#{line}\e[0m"
135
+ else
136
+ line
137
+ end
138
+ end
139
+ lines.join("\n")
140
+ end
141
+
142
+ def render_row(row)
143
+ @columns.each_with_index.map do |col, i|
144
+ cell = row[i].to_s
145
+ truncate_pad(cell, col.width)
146
+ end.join(" ")
147
+ end
148
+
149
+ def truncate_pad(text, width)
150
+ if text.length > width
151
+ width > 1 ? "#{text[0, width - 1]}\u2026" : text[0, width]
152
+ else
153
+ text.ljust(width)
154
+ end
155
+ end
156
+
157
+ def clamp_offset
158
+ return if @rows.empty?
159
+
160
+ @offset = @cursor if @cursor < @offset
161
+ @offset = @cursor - @height + 1 if @cursor >= @offset + @height
162
+ @offset = [0, @offset].max
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petals
4
+ class TextArea
5
+ DEFAULT_KEY_MAP = KeyBinding.normalize({
6
+ character_forward: [[:right, []], ["f", [:ctrl]]],
7
+ character_backward: [[:left, []], ["b", [:ctrl]]],
8
+ word_forward: [[:right, [:alt]], [:right, [:ctrl]], ["f", [:alt]]],
9
+ word_backward: [[:left, [:alt]], [:left, [:ctrl]], ["b", [:alt]]],
10
+ delete_word_backward: [[:backspace, [:alt]], ["w", [:ctrl]]],
11
+ delete_word_forward: [[:delete, [:alt]], ["d", [:alt]]],
12
+ delete_after_cursor: [["k", [:ctrl]]],
13
+ delete_before_cursor: [["u", [:ctrl]]],
14
+ delete_char_backward: [[:backspace, []], ["h", [:ctrl]]],
15
+ delete_char_forward: [[:delete, []], ["d", [:ctrl]]],
16
+ line_start: [[:home, []], ["a", [:ctrl]]],
17
+ line_end: [[:end_key, []], ["e", [:ctrl]]],
18
+ line_up: [[:up, []]],
19
+ line_down: [[:down, []]],
20
+ new_line: [[:enter, []]],
21
+ input_begin: [[:home, [:ctrl]]],
22
+ input_end: [[:end_key, [:ctrl]]],
23
+ page_up: [[:page_up, []]],
24
+ page_down: [[:page_down, []]],
25
+ })
26
+ end
27
+ end