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
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Petals
|
|
4
|
+
# Single-line text input with cursor movement, editing, and echo modes.
|
|
5
|
+
class TextInput
|
|
6
|
+
ECHO_NORMAL = :normal
|
|
7
|
+
ECHO_PASSWORD = :password
|
|
8
|
+
ECHO_NONE = :none
|
|
9
|
+
|
|
10
|
+
attr_reader :value, :position, :err, :echo_char
|
|
11
|
+
attr_accessor :prompt, :placeholder, :char_limit, :width,
|
|
12
|
+
:echo_mode, :key_map
|
|
13
|
+
|
|
14
|
+
def initialize(prompt: "", placeholder: "", char_limit: 0, width: 0,
|
|
15
|
+
echo_mode: ECHO_NORMAL, echo_char: "*", key_map: DEFAULT_KEY_MAP,
|
|
16
|
+
validate: nil)
|
|
17
|
+
@prompt = prompt
|
|
18
|
+
@placeholder = placeholder
|
|
19
|
+
@char_limit = char_limit
|
|
20
|
+
@width = width
|
|
21
|
+
@echo_mode = echo_mode
|
|
22
|
+
self.echo_char = echo_char
|
|
23
|
+
@key_map = key_map
|
|
24
|
+
@validate = validate
|
|
25
|
+
@value = ""
|
|
26
|
+
@position = 0
|
|
27
|
+
@focused = false
|
|
28
|
+
@offset = 0
|
|
29
|
+
@err = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Focus management
|
|
33
|
+
|
|
34
|
+
def focus
|
|
35
|
+
@focused = true
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def blur
|
|
40
|
+
@focused = false
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def focused?
|
|
45
|
+
@focused
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def echo_char=(c)
|
|
49
|
+
@echo_char = c.to_s[0] || "*"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Value access
|
|
53
|
+
|
|
54
|
+
def value=(v)
|
|
55
|
+
v = v.to_s
|
|
56
|
+
v = v[0, @char_limit] if @char_limit.positive? && v.length > @char_limit
|
|
57
|
+
@value = v
|
|
58
|
+
@position = @position.clamp(0, @value.length)
|
|
59
|
+
run_validate
|
|
60
|
+
recalc_offset
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def position=(p)
|
|
64
|
+
@position = p.clamp(0, @value.length)
|
|
65
|
+
recalc_offset
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Elm protocol
|
|
69
|
+
|
|
70
|
+
def update(msg)
|
|
71
|
+
return unless @focused
|
|
72
|
+
|
|
73
|
+
case msg
|
|
74
|
+
when Chamomile::KeyMsg
|
|
75
|
+
handle_key(msg)
|
|
76
|
+
when Chamomile::PasteMsg
|
|
77
|
+
handle_paste(msg)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def view
|
|
84
|
+
return "#{@prompt}#{@placeholder}" if @value.empty? && !@placeholder.empty? && !@focused
|
|
85
|
+
|
|
86
|
+
display = build_display_value
|
|
87
|
+
visible = visible_portion(display)
|
|
88
|
+
|
|
89
|
+
if @focused
|
|
90
|
+
cursor_pos = @position - @offset
|
|
91
|
+
if cursor_pos >= 0 && cursor_pos < visible.length
|
|
92
|
+
before = visible[0, cursor_pos]
|
|
93
|
+
cursor_char = visible[cursor_pos]
|
|
94
|
+
after = visible[(cursor_pos + 1)..]
|
|
95
|
+
"#{@prompt}#{before}\e[7m#{cursor_char}\e[0m#{after}"
|
|
96
|
+
else
|
|
97
|
+
# Cursor at end
|
|
98
|
+
"#{@prompt}#{visible}\e[7m \e[0m"
|
|
99
|
+
end
|
|
100
|
+
else
|
|
101
|
+
"#{@prompt}#{visible}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def handle_key(msg)
|
|
108
|
+
kb = KeyBinding
|
|
109
|
+
|
|
110
|
+
if kb.key_matches?(msg, @key_map, :line_start)
|
|
111
|
+
@position = 0
|
|
112
|
+
elsif kb.key_matches?(msg, @key_map, :line_end)
|
|
113
|
+
@position = @value.length
|
|
114
|
+
elsif kb.key_matches?(msg, @key_map, :character_forward)
|
|
115
|
+
@position += 1 if @position < @value.length
|
|
116
|
+
elsif kb.key_matches?(msg, @key_map, :character_backward)
|
|
117
|
+
@position -= 1 if @position.positive?
|
|
118
|
+
elsif kb.key_matches?(msg, @key_map, :word_forward)
|
|
119
|
+
@position = next_word_boundary
|
|
120
|
+
elsif kb.key_matches?(msg, @key_map, :word_backward)
|
|
121
|
+
@position = prev_word_boundary
|
|
122
|
+
elsif kb.key_matches?(msg, @key_map, :delete_char_backward)
|
|
123
|
+
delete_char_backward
|
|
124
|
+
elsif kb.key_matches?(msg, @key_map, :delete_char_forward)
|
|
125
|
+
delete_char_forward
|
|
126
|
+
elsif kb.key_matches?(msg, @key_map, :delete_word_backward)
|
|
127
|
+
delete_word_backward
|
|
128
|
+
elsif kb.key_matches?(msg, @key_map, :delete_word_forward)
|
|
129
|
+
delete_word_forward
|
|
130
|
+
elsif kb.key_matches?(msg, @key_map, :delete_before_cursor)
|
|
131
|
+
delete_before_cursor
|
|
132
|
+
elsif kb.key_matches?(msg, @key_map, :delete_after_cursor)
|
|
133
|
+
delete_after_cursor
|
|
134
|
+
elsif printable?(msg)
|
|
135
|
+
insert_char(msg.key)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
recalc_offset
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def handle_paste(msg)
|
|
142
|
+
text = msg.content.gsub(/[^[:print:]\t]/, "")
|
|
143
|
+
return if text.empty?
|
|
144
|
+
|
|
145
|
+
if @char_limit.positive?
|
|
146
|
+
available = @char_limit - @value.length
|
|
147
|
+
return if available <= 0
|
|
148
|
+
|
|
149
|
+
text = text[0, available] if text.length > available
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
@value = @value[0, @position].to_s + text + @value[@position..].to_s
|
|
153
|
+
@position += text.length
|
|
154
|
+
run_validate
|
|
155
|
+
recalc_offset
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def printable?(msg)
|
|
159
|
+
return false unless msg.key.is_a?(String) && msg.key.length == 1
|
|
160
|
+
return false unless msg.mod.empty? || msg.mod == [:shift]
|
|
161
|
+
|
|
162
|
+
msg.key.match?(/[[:print:]]/)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def insert_char(char)
|
|
166
|
+
return if @char_limit.positive? && @value.length >= @char_limit
|
|
167
|
+
|
|
168
|
+
@value = @value[0, @position].to_s + char + @value[@position..].to_s
|
|
169
|
+
@position += 1
|
|
170
|
+
run_validate
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Deletion operations
|
|
174
|
+
|
|
175
|
+
def delete_char_backward
|
|
176
|
+
return if @position.zero?
|
|
177
|
+
|
|
178
|
+
@value = @value[0, @position - 1].to_s + @value[@position..].to_s
|
|
179
|
+
@position -= 1
|
|
180
|
+
run_validate
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def delete_char_forward
|
|
184
|
+
return if @position >= @value.length
|
|
185
|
+
|
|
186
|
+
@value = @value[0, @position].to_s + @value[(@position + 1)..].to_s
|
|
187
|
+
run_validate
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def delete_word_backward
|
|
191
|
+
return if @position.zero?
|
|
192
|
+
|
|
193
|
+
target = prev_word_boundary
|
|
194
|
+
@value = @value[0, target].to_s + @value[@position..].to_s
|
|
195
|
+
@position = target
|
|
196
|
+
run_validate
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def delete_word_forward
|
|
200
|
+
return if @position >= @value.length
|
|
201
|
+
|
|
202
|
+
target = next_word_boundary
|
|
203
|
+
@value = @value[0, @position].to_s + @value[target..].to_s
|
|
204
|
+
run_validate
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def delete_before_cursor
|
|
208
|
+
return if @position.zero?
|
|
209
|
+
|
|
210
|
+
@value = @value[@position..].to_s
|
|
211
|
+
@position = 0
|
|
212
|
+
run_validate
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def delete_after_cursor
|
|
216
|
+
return if @position >= @value.length
|
|
217
|
+
|
|
218
|
+
@value = @value[0, @position].to_s
|
|
219
|
+
run_validate
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Word boundary helpers — whitespace-delimited.
|
|
223
|
+
# In password/none mode, word ops jump to start/end.
|
|
224
|
+
|
|
225
|
+
def next_word_boundary
|
|
226
|
+
return @value.length if @echo_mode != ECHO_NORMAL
|
|
227
|
+
|
|
228
|
+
pos = @position
|
|
229
|
+
# Skip current non-space chars
|
|
230
|
+
pos += 1 while pos < @value.length && @value[pos] != " "
|
|
231
|
+
# Skip spaces
|
|
232
|
+
pos += 1 while pos < @value.length && @value[pos] == " "
|
|
233
|
+
pos
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def prev_word_boundary
|
|
237
|
+
return 0 if @echo_mode != ECHO_NORMAL
|
|
238
|
+
|
|
239
|
+
pos = @position
|
|
240
|
+
# Skip spaces behind cursor
|
|
241
|
+
pos -= 1 while pos.positive? && @value[pos - 1] == " "
|
|
242
|
+
# Skip non-space chars
|
|
243
|
+
pos -= 1 while pos.positive? && @value[pos - 1] != " "
|
|
244
|
+
pos
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Horizontal scrolling
|
|
248
|
+
|
|
249
|
+
def recalc_offset
|
|
250
|
+
if @width.positive?
|
|
251
|
+
content_width = @width - @prompt.length
|
|
252
|
+
content_width = 1 if content_width < 1
|
|
253
|
+
|
|
254
|
+
@offset = @position if @position < @offset
|
|
255
|
+
@offset = @position - content_width + 1 if @position >= @offset + content_width
|
|
256
|
+
@offset = 0 if @offset.negative?
|
|
257
|
+
else
|
|
258
|
+
@offset = 0
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def visible_portion(display)
|
|
263
|
+
if @width.positive?
|
|
264
|
+
content_width = @width - @prompt.length
|
|
265
|
+
content_width = 1 if content_width < 1
|
|
266
|
+
display[@offset, content_width] || ""
|
|
267
|
+
else
|
|
268
|
+
display
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Display value based on echo mode
|
|
273
|
+
|
|
274
|
+
def build_display_value
|
|
275
|
+
case @echo_mode
|
|
276
|
+
when ECHO_PASSWORD
|
|
277
|
+
@echo_char * @value.length
|
|
278
|
+
when ECHO_NONE
|
|
279
|
+
""
|
|
280
|
+
else
|
|
281
|
+
@value
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Validation
|
|
286
|
+
|
|
287
|
+
def run_validate
|
|
288
|
+
@err = @validate&.call(@value)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
data/lib/petals/timer.rb
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Petals
|
|
4
|
+
TimerTickMsg = Data.define(:id, :tag, :time)
|
|
5
|
+
TimerTimeoutMsg = Data.define(:id, :time)
|
|
6
|
+
|
|
7
|
+
# Countdown timer with timeout notification and tick-based updates.
|
|
8
|
+
class Timer
|
|
9
|
+
@next_id = 0
|
|
10
|
+
@id_mutex = Mutex.new
|
|
11
|
+
@id_pid = Process.pid
|
|
12
|
+
|
|
13
|
+
def self.next_id
|
|
14
|
+
@id_mutex.synchronize do
|
|
15
|
+
if Process.pid != @id_pid
|
|
16
|
+
@id_pid = Process.pid
|
|
17
|
+
@next_id = 0
|
|
18
|
+
@id_mutex = Mutex.new
|
|
19
|
+
end
|
|
20
|
+
@next_id += 1
|
|
21
|
+
"#{@id_pid}-tm-#{@next_id}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
attr_reader :id, :timeout, :interval, :remaining
|
|
26
|
+
|
|
27
|
+
def initialize(timeout:, interval: 1.0)
|
|
28
|
+
@id = self.class.next_id
|
|
29
|
+
@timeout = timeout.to_f
|
|
30
|
+
@interval = interval
|
|
31
|
+
@remaining = @timeout
|
|
32
|
+
@tag = 0
|
|
33
|
+
@running = false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def start_cmd
|
|
37
|
+
return nil if @running || timed_out?
|
|
38
|
+
|
|
39
|
+
@running = true
|
|
40
|
+
tick_cmd
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def stop
|
|
44
|
+
@running = false
|
|
45
|
+
@tag += 1
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def toggle
|
|
50
|
+
if @running
|
|
51
|
+
stop
|
|
52
|
+
nil
|
|
53
|
+
else
|
|
54
|
+
start_cmd
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def reset
|
|
59
|
+
@remaining = @timeout
|
|
60
|
+
@running = false
|
|
61
|
+
@tag += 1
|
|
62
|
+
self
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def running?
|
|
66
|
+
@running
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def timed_out?
|
|
70
|
+
@remaining <= 0
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def update(msg)
|
|
74
|
+
case msg
|
|
75
|
+
when TimerTickMsg
|
|
76
|
+
return unless msg.id == @id && msg.tag == @tag
|
|
77
|
+
|
|
78
|
+
@remaining = [(@remaining - @interval), 0.0].max
|
|
79
|
+
@tag += 1
|
|
80
|
+
|
|
81
|
+
if @remaining <= 0
|
|
82
|
+
@running = false
|
|
83
|
+
timeout_cmd
|
|
84
|
+
else
|
|
85
|
+
tick_cmd
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def view
|
|
91
|
+
total = @remaining.ceil.to_i
|
|
92
|
+
total = 0 if total.negative?
|
|
93
|
+
hours = total / 3600
|
|
94
|
+
minutes = (total % 3600) / 60
|
|
95
|
+
seconds = total % 60
|
|
96
|
+
|
|
97
|
+
if hours.positive?
|
|
98
|
+
format("%d:%02d:%02d", hours, minutes, seconds)
|
|
99
|
+
else
|
|
100
|
+
format("%02d:%02d", minutes, seconds)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def tick_cmd
|
|
107
|
+
captured_id = @id
|
|
108
|
+
captured_tag = @tag
|
|
109
|
+
interval = @interval
|
|
110
|
+
-> {
|
|
111
|
+
sleep(interval)
|
|
112
|
+
TimerTickMsg.new(id: captured_id, tag: captured_tag, time: Time.now)
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Returns a command that produces TimerTimeoutMsg (no sleep — immediate).
|
|
117
|
+
def timeout_cmd
|
|
118
|
+
captured_id = @id
|
|
119
|
+
-> { TimerTimeoutMsg.new(id: captured_id, time: Time.now) }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Petals
|
|
4
|
+
class Viewport
|
|
5
|
+
DEFAULT_KEY_MAP = KeyBinding.normalize({
|
|
6
|
+
up: [[:up, []], ["k", []]],
|
|
7
|
+
down: [[:down, []], ["j", []]],
|
|
8
|
+
page_up: [[:page_up, []], ["b", []]],
|
|
9
|
+
page_down: [[:page_down, []], ["f", []], [" ", []]],
|
|
10
|
+
half_page_up: [["u", [:ctrl]]],
|
|
11
|
+
half_page_down: [["d", [:ctrl]]],
|
|
12
|
+
goto_top: [["g", []]],
|
|
13
|
+
goto_bottom: [["G", [:shift]]],
|
|
14
|
+
left: [[:left, []], ["h", [:alt]]],
|
|
15
|
+
right: [[:right, []], ["l", [:alt]]],
|
|
16
|
+
})
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Petals
|
|
4
|
+
# Scrollable content pane with key binding and mouse wheel support.
|
|
5
|
+
class Viewport
|
|
6
|
+
attr_reader :width, :height, :y_offset, :x_offset
|
|
7
|
+
attr_accessor :key_map, :mouse_wheel_enabled, :mouse_wheel_delta, :soft_wrap
|
|
8
|
+
|
|
9
|
+
def initialize(width: 80, height: 24, key_map: DEFAULT_KEY_MAP)
|
|
10
|
+
@width = width
|
|
11
|
+
@height = height
|
|
12
|
+
@key_map = key_map
|
|
13
|
+
@lines = []
|
|
14
|
+
@y_offset = 0
|
|
15
|
+
@x_offset = 0
|
|
16
|
+
@mouse_wheel_enabled = true
|
|
17
|
+
@mouse_wheel_delta = 3
|
|
18
|
+
@soft_wrap = false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def set_width(new_width)
|
|
22
|
+
@width = new_width
|
|
23
|
+
clamp_offset
|
|
24
|
+
@x_offset = @x_offset.clamp(0, [max_horizontal_scroll, 0].max) unless @soft_wrap
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def set_height(new_height)
|
|
28
|
+
@height = new_height
|
|
29
|
+
clamp_offset
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def set_content(s)
|
|
33
|
+
@lines = s.split("\n", -1)
|
|
34
|
+
clamp_offset
|
|
35
|
+
@x_offset = @x_offset.clamp(0, [max_horizontal_scroll, 0].max) unless @soft_wrap
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def content
|
|
40
|
+
@lines.join("\n")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def total_line_count
|
|
44
|
+
@soft_wrap ? wrapped_lines.length : @lines.length
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def visible_line_count
|
|
48
|
+
[total_line_count, @height].min
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Vertical scrolling
|
|
52
|
+
|
|
53
|
+
def scroll_up(n = 1)
|
|
54
|
+
self.y_offset = @y_offset - n
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def scroll_down(n = 1)
|
|
58
|
+
self.y_offset = @y_offset + n
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def page_up
|
|
62
|
+
scroll_up(@height)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def page_down
|
|
66
|
+
scroll_down(@height)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def half_page_up
|
|
70
|
+
scroll_up(@height / 2)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def half_page_down
|
|
74
|
+
scroll_down(@height / 2)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def goto_top
|
|
78
|
+
self.y_offset = 0
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def goto_bottom
|
|
82
|
+
self.y_offset = max_scroll
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def y_offset=(n)
|
|
86
|
+
@y_offset = n.clamp(0, max_scroll)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Horizontal scrolling
|
|
90
|
+
|
|
91
|
+
def scroll_left(n = 1)
|
|
92
|
+
return if @soft_wrap
|
|
93
|
+
|
|
94
|
+
self.x_offset = @x_offset - n
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def scroll_right(n = 1)
|
|
98
|
+
return if @soft_wrap
|
|
99
|
+
|
|
100
|
+
self.x_offset = @x_offset + n
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def x_offset=(n)
|
|
104
|
+
return if @soft_wrap
|
|
105
|
+
|
|
106
|
+
@x_offset = n.clamp(0, [max_horizontal_scroll, 0].max)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def max_horizontal_scroll
|
|
110
|
+
return 0 if @lines.empty?
|
|
111
|
+
|
|
112
|
+
longest = @lines.map(&:length).max || 0
|
|
113
|
+
[longest - @width, 0].max
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Queries
|
|
117
|
+
|
|
118
|
+
def at_top?
|
|
119
|
+
@y_offset <= 0
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def at_bottom?
|
|
123
|
+
@y_offset >= max_scroll
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def scroll_percent
|
|
127
|
+
return 1.0 if max_scroll <= 0
|
|
128
|
+
|
|
129
|
+
@y_offset.to_f / max_scroll
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def ensure_visible(line)
|
|
133
|
+
if line < @y_offset
|
|
134
|
+
self.y_offset = line
|
|
135
|
+
elsif line >= @y_offset + @height
|
|
136
|
+
self.y_offset = line - @height + 1
|
|
137
|
+
end
|
|
138
|
+
self
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Elm protocol
|
|
142
|
+
|
|
143
|
+
def update(msg)
|
|
144
|
+
case msg
|
|
145
|
+
when Chamomile::KeyMsg
|
|
146
|
+
handle_key(msg)
|
|
147
|
+
when Chamomile::MouseMsg
|
|
148
|
+
handle_mouse(msg)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def view
|
|
155
|
+
if @soft_wrap
|
|
156
|
+
render_soft_wrap
|
|
157
|
+
else
|
|
158
|
+
render_normal
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def max_scroll
|
|
165
|
+
total = @soft_wrap ? wrapped_lines.length : @lines.length
|
|
166
|
+
[total - @height, 0].max
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def clamp_offset
|
|
170
|
+
@y_offset = @y_offset.clamp(0, max_scroll)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def render_normal
|
|
174
|
+
visible = @lines[@y_offset, @height] || []
|
|
175
|
+
rendered = visible.map { |line| truncate_line(line) }
|
|
176
|
+
padded = if rendered.length < @height
|
|
177
|
+
rendered + Array.new(@height - rendered.length, "")
|
|
178
|
+
else
|
|
179
|
+
rendered
|
|
180
|
+
end
|
|
181
|
+
padded.join("\n")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def render_soft_wrap
|
|
185
|
+
all = wrapped_lines
|
|
186
|
+
visible = all[@y_offset, @height] || []
|
|
187
|
+
padded = if visible.length < @height
|
|
188
|
+
visible + Array.new(@height - visible.length, "")
|
|
189
|
+
else
|
|
190
|
+
visible
|
|
191
|
+
end
|
|
192
|
+
padded.join("\n")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def truncate_line(line)
|
|
196
|
+
return "" if @width <= 0
|
|
197
|
+
|
|
198
|
+
if @x_offset >= line.length
|
|
199
|
+
""
|
|
200
|
+
else
|
|
201
|
+
line[@x_offset, @width] || ""
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def wrapped_lines
|
|
206
|
+
@lines.flat_map { |line| wrap_line(line) }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def wrap_line(line)
|
|
210
|
+
return [""] if line.empty? || @width <= 0
|
|
211
|
+
|
|
212
|
+
chunks = []
|
|
213
|
+
pos = 0
|
|
214
|
+
while pos < line.length
|
|
215
|
+
chunks << line[pos, @width]
|
|
216
|
+
pos += @width
|
|
217
|
+
end
|
|
218
|
+
chunks
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def handle_key(msg)
|
|
222
|
+
kb = KeyBinding
|
|
223
|
+
if kb.key_matches?(msg, @key_map, :up)
|
|
224
|
+
scroll_up
|
|
225
|
+
elsif kb.key_matches?(msg, @key_map, :down)
|
|
226
|
+
scroll_down
|
|
227
|
+
elsif kb.key_matches?(msg, @key_map, :page_up)
|
|
228
|
+
page_up
|
|
229
|
+
elsif kb.key_matches?(msg, @key_map, :page_down)
|
|
230
|
+
page_down
|
|
231
|
+
elsif kb.key_matches?(msg, @key_map, :half_page_up)
|
|
232
|
+
half_page_up
|
|
233
|
+
elsif kb.key_matches?(msg, @key_map, :half_page_down)
|
|
234
|
+
half_page_down
|
|
235
|
+
elsif kb.key_matches?(msg, @key_map, :goto_top)
|
|
236
|
+
goto_top
|
|
237
|
+
elsif kb.key_matches?(msg, @key_map, :goto_bottom)
|
|
238
|
+
goto_bottom
|
|
239
|
+
elsif kb.key_matches?(msg, @key_map, :left)
|
|
240
|
+
scroll_left
|
|
241
|
+
elsif kb.key_matches?(msg, @key_map, :right)
|
|
242
|
+
scroll_right
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def handle_mouse(msg)
|
|
247
|
+
return unless @mouse_wheel_enabled
|
|
248
|
+
|
|
249
|
+
case msg.button
|
|
250
|
+
when :wheel_up
|
|
251
|
+
scroll_up(@mouse_wheel_delta)
|
|
252
|
+
when :wheel_down
|
|
253
|
+
scroll_down(@mouse_wheel_delta)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
data/lib/petals.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "chamomile"
|
|
4
|
+
require_relative "petals/version"
|
|
5
|
+
require_relative "petals/key_binding"
|
|
6
|
+
require_relative "petals/spinner/types"
|
|
7
|
+
require_relative "petals/spinner"
|
|
8
|
+
require_relative "petals/text_input/key_map"
|
|
9
|
+
require_relative "petals/text_input"
|
|
10
|
+
require_relative "petals/stopwatch"
|
|
11
|
+
require_relative "petals/timer"
|
|
12
|
+
require_relative "petals/paginator/key_map"
|
|
13
|
+
require_relative "petals/paginator"
|
|
14
|
+
require_relative "petals/cursor"
|
|
15
|
+
require_relative "petals/help"
|
|
16
|
+
require_relative "petals/progress"
|
|
17
|
+
require_relative "petals/viewport/key_map"
|
|
18
|
+
require_relative "petals/viewport"
|
|
19
|
+
require_relative "petals/file_picker/key_map"
|
|
20
|
+
require_relative "petals/file_picker"
|
|
21
|
+
require_relative "petals/table/key_map"
|
|
22
|
+
require_relative "petals/table"
|
|
23
|
+
require_relative "petals/text_area/key_map"
|
|
24
|
+
require_relative "petals/text_area"
|
|
25
|
+
require_relative "petals/list/key_map"
|
|
26
|
+
require_relative "petals/list"
|
|
27
|
+
require_relative "petals/render_cache"
|
|
28
|
+
require_relative "petals/log_view"
|
|
29
|
+
require_relative "petals/command_palette"
|