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 +7 -0
- data/lib/petals/command_palette.rb +114 -0
- data/lib/petals/cursor.rb +94 -0
- data/lib/petals/file_picker/key_map.rb +17 -0
- data/lib/petals/file_picker.rb +276 -0
- data/lib/petals/help.rb +89 -0
- data/lib/petals/key_binding.rb +31 -0
- data/lib/petals/list/key_map.rb +18 -0
- data/lib/petals/list.rb +432 -0
- data/lib/petals/log_view.rb +141 -0
- data/lib/petals/paginator/key_map.rb +10 -0
- data/lib/petals/paginator.rb +97 -0
- data/lib/petals/progress.rb +199 -0
- data/lib/petals/render_cache.rb +15 -0
- data/lib/petals/spinner/types.rb +20 -0
- data/lib/petals/spinner.rb +73 -0
- data/lib/petals/stopwatch.rb +101 -0
- data/lib/petals/table/key_map.rb +14 -0
- data/lib/petals/table.rb +165 -0
- data/lib/petals/text_area/key_map.rb +27 -0
- data/lib/petals/text_area.rb +449 -0
- data/lib/petals/text_input/key_map.rb +20 -0
- data/lib/petals/text_input.rb +291 -0
- data/lib/petals/timer.rb +122 -0
- data/lib/petals/version.rb +5 -0
- data/lib/petals/viewport/key_map.rb +18 -0
- data/lib/petals/viewport.rb +257 -0
- data/lib/petals.rb +29 -0
- metadata +115 -0
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
|
data/lib/petals/help.rb
ADDED
|
@@ -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
|