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,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
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petals
4
+ VERSION = "0.1.0"
5
+ 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"