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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b58fb0aca2d20714534fbcead2c327b4f86525e603d1cfe8d337c516d015e71b
4
+ data.tar.gz: 9f771b13bcbc31849040cb2da89701698b0e7f0359431633850128956a03b718
5
+ SHA512:
6
+ metadata.gz: 59e1f8a164b9e7746bc28587d42cced08b29343e539b5418cbc79d46cacb3b9e3fdb9eee485f473aeafedbad2b7d99d2ef5363c77b2da73bf3385aebc9827e6e
7
+ data.tar.gz: c648d68002cfa44c7f5f3dfccb9fd0b5fce1c0eaa2d78f8c1d20be3634734950b864688af9a22243c14ec9177bf0b8ee006b0847898897efffec419da296f1e3
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petals
4
+ # A fuzzy-search command palette overlay.
5
+ # Renders as a centered modal over existing content.
6
+ #
7
+ # Usage:
8
+ # @palette = Petals::CommandPalette.new(
9
+ # items: [
10
+ # { label: "Run migrations", action: :run_migrate, key: "db:migrate" },
11
+ # { label: "Start server", action: :server_start, key: "server" },
12
+ # ]
13
+ # )
14
+ # # In update:
15
+ # when KeyMsg
16
+ # if @palette.visible?
17
+ # result = @palette.handle_key(msg)
18
+ # return result[:action] if result
19
+ # end
20
+ class CommandPalette
21
+ Item = Data.define(:label, :action, :key)
22
+
23
+ attr_reader :query, :cursor
24
+
25
+ def initialize(items:, placeholder: "Type to filter...")
26
+ @all_items = items.map { |i| Item.new(**i) }
27
+ @placeholder = placeholder
28
+ @query = ""
29
+ @cursor = 0
30
+ @visible = false
31
+ end
32
+
33
+ def show
34
+ @visible = true
35
+ @query = ""
36
+ @cursor = 0
37
+ end
38
+
39
+ def hide
40
+ @visible = false
41
+ end
42
+
43
+ def visible? = @visible
44
+
45
+ def handle_key(msg)
46
+ return nil unless @visible
47
+
48
+ case msg.key
49
+ when :escape
50
+ hide
51
+ nil
52
+ when :enter
53
+ selected = filtered_items[@cursor]
54
+ hide
55
+ selected
56
+ when :up, "k"
57
+ @cursor = [@cursor - 1, 0].max
58
+ nil
59
+ when :down, "j"
60
+ max = filtered_items.size - 1
61
+ @cursor = (@cursor + 1).clamp(0, [max, 0].max)
62
+ nil
63
+ when :backspace
64
+ @query = @query[0..-2] || ""
65
+ @cursor = 0
66
+ nil
67
+ else
68
+ if msg.key.is_a?(String) && msg.key.length == 1
69
+ @query += msg.key
70
+ @cursor = 0
71
+ end
72
+ nil
73
+ end
74
+ end
75
+
76
+ def render(width:, height:)
77
+ return "" unless @visible
78
+
79
+ items = filtered_items
80
+ modal_width = [width - 8, 60].min
81
+ modal_height = [items.size + 4, height - 4, 16].min
82
+
83
+ lines = []
84
+ query_display = @query.empty? ? "\e[38;5;240m#{@placeholder}\e[0m" : @query
85
+ lines << " > #{query_display}"
86
+ lines << ("\u2500" * (modal_width - 2))
87
+
88
+ visible_items = items.first(modal_height - 4)
89
+ visible_items.each_with_index do |item, i|
90
+ prefix = i == @cursor ? "\u25b6 " : " "
91
+ line = "#{prefix}#{item.label}"
92
+ lines << (i == @cursor ? "\e[7m#{line}\e[0m" : line)
93
+ end
94
+
95
+ lines << ("\u2500" * (modal_width - 2))
96
+ lines << "\e[38;5;240m \u2191\u2193 navigate Enter select Esc cancel\e[0m"
97
+
98
+ box_lines = lines.map { |l| "\u2502 #{l.ljust(modal_width - 4)} \u2502" }
99
+ top = "┌#{"\u2500" * (modal_width - 2)}┐"
100
+ bottom = "└#{"\u2500" * (modal_width - 2)}┘"
101
+
102
+ ([top] + box_lines + [bottom]).join("\n")
103
+ end
104
+
105
+ def filtered_items
106
+ return @all_items if @query.empty?
107
+
108
+ q = @query.downcase
109
+ @all_items.select do |item|
110
+ item.label.downcase.include?(q) || item.key.to_s.downcase.include?(q)
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petals
4
+ CursorBlinkMsg = Data.define(:id, :tag)
5
+
6
+ # Virtual text cursor with blink modes (blink, static, hide).
7
+ class Cursor
8
+ MODE_BLINK = :blink
9
+ MODE_STATIC = :static
10
+ MODE_HIDE = :hide
11
+
12
+ BLINK_SPEED = 0.53
13
+
14
+ @next_id = 0
15
+ @id_mutex = Mutex.new
16
+ @id_pid = Process.pid
17
+
18
+ def self.next_id
19
+ @id_mutex.synchronize do
20
+ if Process.pid != @id_pid
21
+ @id_pid = Process.pid
22
+ @next_id = 0
23
+ @id_mutex = Mutex.new
24
+ end
25
+ @next_id += 1
26
+ "#{@id_pid}-cur-#{@next_id}"
27
+ end
28
+ end
29
+
30
+ attr_reader :id, :mode, :blink_speed
31
+ attr_accessor :char, :blinked
32
+
33
+ def initialize(mode: MODE_BLINK, blink_speed: BLINK_SPEED)
34
+ @id = self.class.next_id
35
+ @mode = mode
36
+ @blink_speed = blink_speed
37
+ @char = " "
38
+ @blinked = false
39
+ @focused = false
40
+ @tag = 0
41
+ end
42
+
43
+ def focus
44
+ @focused = true
45
+ @blinked = false
46
+ @mode == MODE_BLINK ? blink_cmd : nil
47
+ end
48
+
49
+ def blur
50
+ @focused = false
51
+ @blinked = true
52
+ @tag += 1
53
+ nil
54
+ end
55
+
56
+ def focused?
57
+ @focused
58
+ end
59
+
60
+ def mode=(new_mode)
61
+ @mode = new_mode
62
+ @tag += 1
63
+ @blinked = new_mode == MODE_HIDE || !@focused
64
+ @mode == MODE_BLINK && @focused ? blink_cmd : nil
65
+ end
66
+
67
+ def blink_cmd
68
+ captured_id = @id
69
+ captured_tag = @tag
70
+ speed = @blink_speed
71
+ -> {
72
+ sleep(speed)
73
+ CursorBlinkMsg.new(id: captured_id, tag: captured_tag)
74
+ }
75
+ end
76
+
77
+ def update(msg)
78
+ return unless msg.is_a?(CursorBlinkMsg)
79
+ return unless @mode == MODE_BLINK && @focused
80
+ return unless msg.id == @id && msg.tag == @tag
81
+
82
+ @blinked = !@blinked
83
+ @tag += 1
84
+ blink_cmd
85
+ end
86
+
87
+ def view
88
+ return @char if @mode == MODE_HIDE
89
+ return @char if @blinked
90
+
91
+ "\e[7m#{@char}\e[0m"
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petals
4
+ class FilePicker
5
+ DEFAULT_KEY_MAP = KeyBinding.normalize({
6
+ up: [[:up, []], ["k", []]],
7
+ down: [[:down, []], ["j", []]],
8
+ open: [[:right, []], ["l", []], [:enter, []]],
9
+ back: [[:left, []], ["h", []], [:backspace, []]],
10
+ toggle_hidden: [[".", []]],
11
+ page_up: [[:page_up, []], ["b", [:ctrl]]],
12
+ page_down: [[:page_down, []], ["f", [:ctrl]]],
13
+ goto_top: [["g", []], [:home, []]],
14
+ goto_bottom: [["G", [:shift]], [:end_key, []]],
15
+ })
16
+ end
17
+ end
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petals
4
+ FilePickerReadDirMsg = Data.define(:id, :entries)
5
+
6
+ # Filesystem browser with async directory reading, navigation, and filtering.
7
+ class FilePicker
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}-fp-#{@next_id}"
21
+ end
22
+ end
23
+
24
+ attr_reader :id, :current_directory, :selected_path
25
+ attr_accessor :key_map, :allowed_types, :show_permissions, :show_size,
26
+ :show_hidden, :dir_allowed, :file_allowed, :height, :cursor_char
27
+
28
+ def initialize(directory: Dir.pwd, key_map: DEFAULT_KEY_MAP, height: 10,
29
+ allowed_types: [], show_permissions: false, show_size: false,
30
+ show_hidden: false, dir_allowed: false, file_allowed: true,
31
+ cursor_char: ">")
32
+ @id = self.class.next_id
33
+ @current_directory = File.expand_path(directory)
34
+ @key_map = key_map
35
+ @height = height
36
+ @allowed_types = allowed_types
37
+ @show_permissions = show_permissions
38
+ @show_size = show_size
39
+ @show_hidden = show_hidden
40
+ @dir_allowed = dir_allowed
41
+ @file_allowed = file_allowed
42
+ @cursor_char = cursor_char
43
+ @cursor = 0
44
+ @entries = []
45
+ @offset = 0
46
+ @selected_path = nil
47
+ @disabled_selected_path = nil
48
+ @dir_stack = []
49
+ end
50
+
51
+ def init_cmd
52
+ read_dir_cmd(@current_directory)
53
+ end
54
+
55
+ def update(msg)
56
+ case msg
57
+ when FilePickerReadDirMsg
58
+ return unless msg.id == @id
59
+
60
+ @entries = msg.entries
61
+ @cursor = @cursor.clamp(0, [entries_size - 1, 0].max)
62
+ clamp_offset
63
+ nil
64
+ when Chamomile::KeyMsg
65
+ handle_key(msg)
66
+ end
67
+ end
68
+
69
+ def view
70
+ return " (empty)" if @entries.empty?
71
+
72
+ visible = visible_entries
73
+ lines = visible.each_with_index.map do |entry, i|
74
+ idx = @offset + i
75
+ prefix = idx == @cursor ? "#{@cursor_char} " : " "
76
+ line = prefix + format_entry(entry)
77
+ if idx == @cursor
78
+ "\e[7m#{line}\e[0m"
79
+ else
80
+ line
81
+ end
82
+ end
83
+ lines.join("\n")
84
+ end
85
+
86
+ def highlighted_path
87
+ return nil if @entries.empty?
88
+
89
+ entry = @entries[@cursor]
90
+ return nil unless entry
91
+
92
+ File.join(@current_directory, entry[:name])
93
+ end
94
+
95
+ def did_select_file?(msg)
96
+ return [false, nil] unless msg.is_a?(Chamomile::KeyMsg)
97
+
98
+ if @selected_path
99
+ path = @selected_path
100
+ @selected_path = nil
101
+ [true, path]
102
+ else
103
+ [false, nil]
104
+ end
105
+ end
106
+
107
+ def did_select_disabled_file?(msg)
108
+ return [false, nil] unless msg.is_a?(Chamomile::KeyMsg)
109
+
110
+ if @disabled_selected_path
111
+ path = @disabled_selected_path
112
+ @disabled_selected_path = nil
113
+ [true, path]
114
+ else
115
+ [false, nil]
116
+ end
117
+ end
118
+
119
+ private
120
+
121
+ def handle_key(msg)
122
+ kb = KeyBinding
123
+
124
+ if kb.key_matches?(msg, @key_map, :up)
125
+ move_cursor(-1)
126
+ elsif kb.key_matches?(msg, @key_map, :down)
127
+ move_cursor(1)
128
+ elsif kb.key_matches?(msg, @key_map, :page_up)
129
+ move_cursor(-@height)
130
+ elsif kb.key_matches?(msg, @key_map, :page_down)
131
+ move_cursor(@height)
132
+ elsif kb.key_matches?(msg, @key_map, :goto_top)
133
+ move_cursor(-entries_size)
134
+ elsif kb.key_matches?(msg, @key_map, :goto_bottom)
135
+ move_cursor(entries_size)
136
+ elsif kb.key_matches?(msg, @key_map, :open)
137
+ open_entry
138
+ elsif kb.key_matches?(msg, @key_map, :back)
139
+ go_back
140
+ elsif kb.key_matches?(msg, @key_map, :toggle_hidden)
141
+ @show_hidden = !@show_hidden
142
+ read_dir_cmd(@current_directory)
143
+ end
144
+ end
145
+
146
+ def move_cursor(delta)
147
+ return if @entries.empty?
148
+
149
+ @cursor = (@cursor + delta).clamp(0, entries_size - 1)
150
+ clamp_offset
151
+ nil
152
+ end
153
+
154
+ def open_entry
155
+ return nil if @entries.empty?
156
+
157
+ entry = @entries[@cursor]
158
+ return nil unless entry
159
+
160
+ path = File.join(@current_directory, entry[:name])
161
+
162
+ if selectable?(entry)
163
+ @selected_path = path
164
+ elsif !entry[:directory] && @allowed_types.any?
165
+ @disabled_selected_path = path
166
+ end
167
+
168
+ if entry[:directory]
169
+ @dir_stack.push({ directory: @current_directory, cursor: @cursor, offset: @offset })
170
+ @current_directory = path
171
+ @cursor = 0
172
+ @offset = 0
173
+ return read_dir_cmd(path)
174
+ end
175
+
176
+ nil
177
+ end
178
+
179
+ def go_back
180
+ parent = File.dirname(@current_directory)
181
+ return nil if parent == @current_directory
182
+
183
+ if @dir_stack.any?
184
+ prev = @dir_stack.pop
185
+ @current_directory = prev[:directory]
186
+ @cursor = prev[:cursor]
187
+ @offset = prev[:offset]
188
+ else
189
+ @current_directory = parent
190
+ @cursor = 0
191
+ @offset = 0
192
+ end
193
+
194
+ read_dir_cmd(@current_directory)
195
+ end
196
+
197
+ def read_dir_cmd(dir)
198
+ captured_id = @id
199
+ show_hidden = @show_hidden
200
+ -> {
201
+ entries = begin
202
+ Dir.entries(dir)
203
+ .reject { |e| [".", ".."].include?(e) }
204
+ .reject { |e| !show_hidden && e.start_with?(".") }
205
+ .map { |e| build_entry(dir, e) }
206
+ .sort_by { |e| [e[:directory] ? 0 : 1, e[:name].downcase] }
207
+ rescue SystemCallError
208
+ []
209
+ end
210
+ FilePickerReadDirMsg.new(id: captured_id, entries: entries)
211
+ }
212
+ end
213
+
214
+ def build_entry(dir, name)
215
+ path = File.join(dir, name)
216
+ stat = begin
217
+ File.stat(path)
218
+ rescue SystemCallError
219
+ nil
220
+ end
221
+
222
+ {
223
+ name: name,
224
+ directory: stat&.directory? || false,
225
+ size: stat&.size || 0,
226
+ permissions: stat ? format("%o", stat.mode & 0o7777) : "0000",
227
+ }
228
+ end
229
+
230
+ def selectable?(entry)
231
+ if entry[:directory]
232
+ @dir_allowed
233
+ else
234
+ return false unless @file_allowed
235
+ return true if @allowed_types.empty?
236
+
237
+ ext = File.extname(entry[:name]).downcase
238
+ @allowed_types.any? { |t| t.downcase == ext || ".#{t.downcase}" == ext }
239
+ end
240
+ end
241
+
242
+ def format_entry(entry)
243
+ parts = []
244
+ parts << (entry[:directory] ? "#{entry[:name]}/" : entry[:name])
245
+ parts << human_size(entry[:size]) if @show_size && !entry[:directory]
246
+ parts << entry[:permissions] if @show_permissions
247
+ parts.join(" ")
248
+ end
249
+
250
+ def human_size(bytes)
251
+ return "0B" if bytes.zero?
252
+
253
+ units = %w[B KB MB GB TB]
254
+ exp = (Math.log(bytes) / Math.log(1024)).floor
255
+ exp = [exp, units.length - 1].min
256
+ size = bytes.to_f / (1024**exp)
257
+ exp.zero? ? "#{bytes}B" : format("%.1f%s", size, units[exp])
258
+ end
259
+
260
+ def entries_size
261
+ @entries.length
262
+ end
263
+
264
+ def visible_entries
265
+ @entries[@offset, @height] || []
266
+ end
267
+
268
+ def clamp_offset
269
+ return if @entries.empty?
270
+
271
+ @offset = @cursor if @cursor < @offset
272
+ @offset = @cursor - @height + 1 if @cursor >= @offset + @height
273
+ @offset = [0, @offset].max
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petals
4
+ # Auto-generated keybinding help view with short and full display modes.
5
+ class Help
6
+ ELLIPSIS = "\u2026"
7
+
8
+ attr_accessor :width, :short_separator, :full_separator, :ellipsis, :show_all
9
+
10
+ def initialize(width: 80)
11
+ @width = width
12
+ @short_separator = " \u2022 "
13
+ @full_separator = " "
14
+ @ellipsis = ELLIPSIS
15
+ @show_all = false
16
+ end
17
+
18
+ def short_help_view(bindings)
19
+ filtered = enabled_bindings(bindings)
20
+ return "" if filtered.empty?
21
+
22
+ parts = filtered.map { |b| "#{b[:key]} #{b[:desc]}" }
23
+ result = parts.join(@short_separator)
24
+
25
+ return result if @width <= 0 || result.length <= @width
26
+
27
+ truncate(parts)
28
+ end
29
+
30
+ def full_help_view(groups)
31
+ filtered_groups = groups.map { |g| enabled_bindings(g) }.reject(&:empty?)
32
+ return "" if filtered_groups.empty?
33
+
34
+ max_rows = filtered_groups.map(&:length).max
35
+ lines = Array.new(max_rows, "")
36
+
37
+ max_rows.times do |row|
38
+ cols = filtered_groups.map do |group|
39
+ if row < group.length
40
+ b = group[row]
41
+ "#{b[:key]} #{b[:desc]}"
42
+ else
43
+ ""
44
+ end
45
+ end
46
+ lines[row] = cols.join(@full_separator).rstrip
47
+ end
48
+
49
+ lines.join("\n")
50
+ end
51
+
52
+ def view(bindings_or_groups)
53
+ if @show_all
54
+ groups = bindings_or_groups.first.is_a?(Array) ? bindings_or_groups : [bindings_or_groups]
55
+ full_help_view(groups)
56
+ else
57
+ flat = bindings_or_groups.first.is_a?(Array) ? bindings_or_groups.flatten : bindings_or_groups
58
+ short_help_view(flat)
59
+ end
60
+ end
61
+
62
+ def update(_msg)
63
+ nil
64
+ end
65
+
66
+ private
67
+
68
+ def enabled_bindings(bindings)
69
+ bindings.select { |b| b.fetch(:enabled, true) }
70
+ end
71
+
72
+ def truncate(parts)
73
+ result = ""
74
+ parts.each_with_index do |part, i|
75
+ candidate = if i.zero?
76
+ part
77
+ else
78
+ "#{result}#{@short_separator}#{part}"
79
+ end
80
+ if candidate.length + @ellipsis.length + @short_separator.length > @width && i.positive?
81
+ return "#{result}#{@short_separator}#{@ellipsis}"
82
+ end
83
+
84
+ result = candidate
85
+ end
86
+ result
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petals
4
+ # Utilities for matching KeyMsg against normalized key maps.
5
+ module KeyBinding
6
+ # Normalize a key map so mod arrays are sorted and frozen.
7
+ # Call once at definition time to avoid per-keystroke allocations.
8
+ #
9
+ # normalize({ action => [[key, mods], ...] })
10
+ # # => { action => [[key, sorted_frozen_mods], ...] }
11
+ def self.normalize(key_map)
12
+ key_map.transform_values do |bindings|
13
+ bindings.map { |key, mods| [key, (mods || []).sort.freeze] }.freeze
14
+ end.freeze
15
+ end
16
+
17
+ # Check if a KeyMsg matches any binding for a named action.
18
+ # Expects a normalized key map (from .normalize) for best performance.
19
+ def self.key_matches?(msg, key_map, action)
20
+ return false unless msg.is_a?(Chamomile::KeyMsg)
21
+
22
+ bindings = key_map[action]
23
+ return false unless bindings
24
+
25
+ sorted_mod = msg.mod.sort
26
+ bindings.any? do |key, mods|
27
+ msg.key == key && sorted_mod == (mods || [])
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petals
4
+ class List
5
+ DEFAULT_KEY_MAP = KeyBinding.normalize({
6
+ cursor_up: [[:up, []], ["k", []]],
7
+ cursor_down: [[:down, []], ["j", []]],
8
+ next_page: [[:page_down, []], ["n", [:ctrl]]],
9
+ prev_page: [[:page_up, []], ["p", [:ctrl]]],
10
+ goto_start: [["g", []], [:home, []]],
11
+ goto_end: [["G", [:shift]], [:end_key, []]],
12
+ filter: [["/", []]],
13
+ clear_filter: [[:escape, []]],
14
+ accept_filter: [[:enter, []]],
15
+ show_full_help: [["?", []]],
16
+ })
17
+ end
18
+ end