chamomile 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: 7fdcff186debc52d374ae2cfe20a1808100371609fbb39bdb1c21ad04e9c10bc
4
+ data.tar.gz: 0c48a5deba22e6be5696aa1f400f206efeb36d897b1c863a936656829ea9d7c2
5
+ SHA512:
6
+ metadata.gz: db8d83c52ab888cd7f73025c00720211b175b1d2a0c89e8783a7e46b6609c6c2f7188a2e4a842130d996a3ae67a0896ee99e25b56f1d438333a54ef6b19a7261
7
+ data.tar.gz: 2486ca573415e2470e4e6248597a2110fe456f7c9e5ac6b30ea389f71d6480ff9787353158bd283a45ee722b3c6d5567e5259e3082ca7de9c7a0f8b8ad8621ea
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+ require "open3"
5
+
6
+ module Chamomile
7
+ # Internal command types intercepted by Program (not delivered to model)
8
+ WindowTitleCmd = Data.define(:title)
9
+ CursorPositionCmd = Data.define(:row, :col)
10
+ CursorShapeCmd = Data.define(:shape)
11
+ CursorVisibilityCmd = Data.define(:visible)
12
+ ExecCmd = Data.define(:command, :args, :callback)
13
+ PrintlnCmd = Data.define(:text)
14
+
15
+ # Internal compound/control command types
16
+ CancelCmd = Data.define(:token)
17
+ StreamCmd = Data.define(:token, :producer)
18
+
19
+ # Typed envelope for shell command results
20
+ ShellResult = Data.define(:envelope, :stdout, :stderr, :status, :success)
21
+
22
+ # Typed envelope for timer ticks
23
+ TimerTick = Data.define(:envelope, :time)
24
+
25
+ # Runtime mode-toggle messages (returned as Cmd from update, intercepted by Program)
26
+ EnterAltScreenMsg = Data.define
27
+ ExitAltScreenMsg = Data.define
28
+ EnableMouseCellMotionMsg = Data.define
29
+ EnableMouseAllMotionMsg = Data.define
30
+ DisableMouseMsg = Data.define
31
+ EnableBracketedPasteMsg = Data.define
32
+ DisableBracketedPasteMsg = Data.define
33
+ EnableReportFocusMsg = Data.define
34
+ DisableReportFocusMsg = Data.define
35
+ ClearScreenMsg = Data.define
36
+ RequestWindowSizeMsg = Data.define
37
+
38
+ # A cancel token for cancellable commands.
39
+ class CancelToken
40
+ def initialize
41
+ @cancelled = Concurrent::AtomicBoolean.new(false)
42
+ end
43
+
44
+ def cancel!
45
+ @cancelled.make_true
46
+ end
47
+
48
+ def cancelled?
49
+ @cancelled.true?
50
+ end
51
+ end
52
+
53
+ # Helper methods for creating command lambdas (quit, batch, tick, etc.).
54
+ module Commands
55
+ def quit
56
+ -> { QuitMsg.new }
57
+ end
58
+
59
+ def none
60
+ nil
61
+ end
62
+
63
+ def batch(*cmds)
64
+ valid = cmds.flatten.compact
65
+ return nil if valid.empty?
66
+
67
+ -> { valid }
68
+ end
69
+
70
+ def sequence(*cmds)
71
+ valid = cmds.flatten.compact
72
+ return nil if valid.empty?
73
+
74
+ -> { [:sequence, *valid] }
75
+ end
76
+
77
+ def tick(duration, &block)
78
+ -> {
79
+ sleep(duration)
80
+ block ? block.call : TickMsg.new(time: Time.now)
81
+ }
82
+ end
83
+
84
+ def every(duration, &block)
85
+ -> {
86
+ now = Time.now
87
+ next_tick = (now + duration) - (now.to_f % duration)
88
+ sleep(next_tick - Time.now)
89
+ block ? block.call : TickMsg.new(time: Time.now)
90
+ }
91
+ end
92
+
93
+ def cmd(callable)
94
+ -> { callable.call }
95
+ end
96
+
97
+ # Posts a message directly to the event queue — no thread, no async.
98
+ def deliver(msg)
99
+ -> { msg }
100
+ end
101
+
102
+ # Transforms a command's result before it reaches update.
103
+ def map(cmd, &transform)
104
+ return nil if cmd.nil?
105
+
106
+ -> {
107
+ result = cmd.call
108
+ result ? transform.call(result) : nil
109
+ }
110
+ end
111
+
112
+ # Creates a cancel token and returns [token, command_wrapper].
113
+ # The block receives the token for cooperative cancellation checking.
114
+ def cancellable(&block)
115
+ token = CancelToken.new
116
+ wrapped = -> {
117
+ return nil if token.cancelled?
118
+
119
+ block.call(token)
120
+ }
121
+ [token, wrapped]
122
+ end
123
+
124
+ # Returns a command that cancels a running token.
125
+ def cancel(token)
126
+ -> { CancelCmd.new(token: token) }
127
+ end
128
+
129
+ # A streaming command that emits multiple messages over time.
130
+ # The block receives a `push` callable and a `token` for cancellation.
131
+ # Returns [cancel_token, command].
132
+ def stream(&block)
133
+ token = CancelToken.new
134
+ cmd = -> {
135
+ return nil if token.cancelled?
136
+
137
+ StreamCmd.new(token: token, producer: block)
138
+ }
139
+ [token, cmd]
140
+ end
141
+
142
+ # Run a shell command and return a ShellResult message with the given envelope name.
143
+ def shell(command, envelope:, dir: Dir.pwd, env: {})
144
+ -> {
145
+ stdout, stderr, status = Open3.capture3(env, command, chdir: dir)
146
+ ShellResult.new(
147
+ envelope: envelope,
148
+ stdout: stdout.force_encoding("UTF-8"),
149
+ stderr: stderr.force_encoding("UTF-8"),
150
+ status: status.exitstatus,
151
+ success: status.success?
152
+ )
153
+ }
154
+ end
155
+
156
+ # Fire a timer message with a typed envelope.
157
+ def timer(duration, envelope:)
158
+ -> {
159
+ sleep(duration)
160
+ TimerTick.new(envelope: envelope, time: Time.now)
161
+ }
162
+ end
163
+
164
+ def window_title(title)
165
+ -> { WindowTitleCmd.new(title: title) }
166
+ end
167
+
168
+ def cursor_position(row, col)
169
+ -> { CursorPositionCmd.new(row: row, col: col) }
170
+ end
171
+
172
+ def cursor_shape(shape)
173
+ -> { CursorShapeCmd.new(shape: shape) }
174
+ end
175
+
176
+ def show_cursor
177
+ -> { CursorVisibilityCmd.new(visible: true) }
178
+ end
179
+
180
+ def hide_cursor
181
+ -> { CursorVisibilityCmd.new(visible: false) }
182
+ end
183
+
184
+ def exec(command, *args, &callback)
185
+ -> { ExecCmd.new(command: command, args: args, callback: callback) }
186
+ end
187
+
188
+ # Print a line above the rendered TUI area
189
+ def println(text)
190
+ -> { PrintlnCmd.new(text: text) }
191
+ end
192
+
193
+ # Runtime mode toggles — return these as commands from update
194
+ def enter_alt_screen
195
+ -> { EnterAltScreenMsg.new }
196
+ end
197
+
198
+ def exit_alt_screen
199
+ -> { ExitAltScreenMsg.new }
200
+ end
201
+
202
+ def enable_mouse_cell_motion
203
+ -> { EnableMouseCellMotionMsg.new }
204
+ end
205
+
206
+ def enable_mouse_all_motion
207
+ -> { EnableMouseAllMotionMsg.new }
208
+ end
209
+
210
+ def disable_mouse
211
+ -> { DisableMouseMsg.new }
212
+ end
213
+
214
+ def enable_bracketed_paste
215
+ -> { EnableBracketedPasteMsg.new }
216
+ end
217
+
218
+ def disable_bracketed_paste
219
+ -> { DisableBracketedPasteMsg.new }
220
+ end
221
+
222
+ def enable_report_focus
223
+ -> { EnableReportFocusMsg.new }
224
+ end
225
+
226
+ def disable_report_focus
227
+ -> { DisableReportFocusMsg.new }
228
+ end
229
+
230
+ def clear_screen
231
+ -> { ClearScreenMsg.new }
232
+ end
233
+
234
+ def request_window_size
235
+ -> { RequestWindowSizeMsg.new }
236
+ end
237
+
238
+ module_function :quit, :none, :batch, :sequence, :tick, :every, :cmd,
239
+ :deliver, :map, :cancellable, :cancel, :stream,
240
+ :shell, :timer,
241
+ :window_title, :cursor_position, :cursor_shape,
242
+ :show_cursor, :hide_cursor, :exec, :println,
243
+ :enter_alt_screen, :exit_alt_screen,
244
+ :enable_mouse_cell_motion, :enable_mouse_all_motion, :disable_mouse,
245
+ :enable_bracketed_paste, :disable_bracketed_paste,
246
+ :enable_report_focus, :disable_report_focus,
247
+ :clear_screen, :request_window_size
248
+ end
249
+ end
@@ -0,0 +1,329 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chamomile
4
+ # State-machine parser for ANSI escape sequences, CSI params, SGR mouse, and bracketed paste.
5
+ class EscapeParser
6
+ # States
7
+ GROUND = :ground
8
+ ESC_SEEN = :esc_seen
9
+ CSI_ENTRY = :csi_entry
10
+ SS3 = :ss3
11
+ PASTE = :paste
12
+
13
+ # CSI modifier mapping (xterm-style: param 2=Shift, 3=Alt, 4=Shift+Alt, 5=Ctrl, etc.)
14
+ MODIFIER_MAP = {
15
+ 2 => [:shift],
16
+ 3 => [:alt],
17
+ 4 => %i[shift alt],
18
+ 5 => [:ctrl],
19
+ 6 => %i[ctrl shift],
20
+ 7 => %i[ctrl alt],
21
+ 8 => %i[ctrl shift alt],
22
+ }.freeze
23
+
24
+ PASTE_START = "\e[200~"
25
+ PASTE_END = "\e[201~"
26
+
27
+ # Maximum paste buffer size (1MB) to prevent memory exhaustion
28
+ MAX_PASTE_SIZE = 1_048_576
29
+
30
+ def initialize
31
+ @state = GROUND
32
+ @buf = +""
33
+ @paste_buf = +""
34
+ end
35
+
36
+ # Feed raw bytes into the parser, yielding parsed messages.
37
+ def feed(bytes, &block)
38
+ bytes.each_char { |ch| process_char(ch, &block) }
39
+ end
40
+
41
+ # Call when IO.select times out — flushes ambiguous ESC as :escape key.
42
+ def timeout!
43
+ return unless @state == ESC_SEEN
44
+
45
+ @state = GROUND
46
+ @buf.clear
47
+ yield KeyMsg.new(key: :escape, mod: [])
48
+ end
49
+
50
+ private
51
+
52
+ def process_char(ch, &)
53
+ case @state
54
+ when GROUND
55
+ ground_char(ch, &)
56
+ when ESC_SEEN
57
+ esc_seen_char(ch, &)
58
+ when CSI_ENTRY
59
+ csi_char(ch, &)
60
+ when SS3
61
+ ss3_char(ch, &)
62
+ when PASTE
63
+ paste_char(ch, &)
64
+ end
65
+ end
66
+
67
+ def ground_char(ch)
68
+ case ch
69
+ when "\x00"
70
+ # NUL byte — ignore
71
+ when "\e"
72
+ @state = ESC_SEEN
73
+ @buf = +"\e"
74
+ when "\r", "\n"
75
+ yield KeyMsg.new(key: :enter, mod: [])
76
+ when "\t"
77
+ yield KeyMsg.new(key: :tab, mod: [])
78
+ when "\x7f", "\x08"
79
+ yield KeyMsg.new(key: :backspace, mod: [])
80
+ when ->(c) { c.ord.between?(1, 26) }
81
+ letter = (ch.ord + 96).chr # \x01 -> 'a', \x02 -> 'b', etc.
82
+ yield KeyMsg.new(key: letter, mod: [:ctrl])
83
+ when ->(c) { c.ord >= 32 }
84
+ yield KeyMsg.new(key: ch, mod: [])
85
+ end
86
+ end
87
+
88
+ def esc_seen_char(ch)
89
+ case ch
90
+ when "["
91
+ @state = CSI_ENTRY
92
+ @buf << ch
93
+ when "O"
94
+ @state = SS3
95
+ @buf << ch
96
+ when "\e"
97
+ # Another ESC: flush previous ESC as :escape, start new ESC
98
+ yield KeyMsg.new(key: :escape, mod: [])
99
+ @buf = +"\e"
100
+ # Stay in ESC_SEEN
101
+ else
102
+ # Alt + char: ESC followed by a printable character
103
+ @state = GROUND
104
+ @buf.clear
105
+ if ["\x7f", "\x08"].include?(ch)
106
+ yield KeyMsg.new(key: :backspace, mod: [:alt])
107
+ elsif ch.ord >= 32
108
+ yield KeyMsg.new(key: ch, mod: [:alt])
109
+ elsif ["\r", "\n"].include?(ch)
110
+ yield KeyMsg.new(key: :enter, mod: [:alt])
111
+ elsif ch == "\t"
112
+ yield KeyMsg.new(key: :tab, mod: [:alt])
113
+ elsif ch.ord.between?(1, 26)
114
+ letter = (ch.ord + 96).chr
115
+ yield KeyMsg.new(key: letter, mod: %i[alt ctrl])
116
+ end
117
+ end
118
+ end
119
+
120
+ def csi_char(ch, &)
121
+ @buf << ch
122
+
123
+ case ch
124
+ when "0".."9", ";", ":"
125
+ # Still collecting parameters; stay in CSI_ENTRY
126
+ return
127
+ when "<"
128
+ # SGR mouse prefix — stay collecting
129
+ return if @buf == "\e[<"
130
+
131
+ # Already past the <, this is a parameter char
132
+ return
133
+ end
134
+
135
+ # We have a final character — dispatch
136
+ @state = GROUND
137
+ seq = @buf.dup
138
+ @buf.clear
139
+
140
+ dispatch_csi(seq, &)
141
+ end
142
+
143
+ def ss3_char(ch)
144
+ @buf << ch
145
+ @state = GROUND
146
+ seq = @buf.dup
147
+ @buf.clear
148
+
149
+ case ch
150
+ when "P" then yield KeyMsg.new(key: :f1, mod: [])
151
+ when "Q" then yield KeyMsg.new(key: :f2, mod: [])
152
+ when "R" then yield KeyMsg.new(key: :f3, mod: [])
153
+ when "S" then yield KeyMsg.new(key: :f4, mod: [])
154
+ when "A" then yield KeyMsg.new(key: :up, mod: [])
155
+ when "B" then yield KeyMsg.new(key: :down, mod: [])
156
+ when "C" then yield KeyMsg.new(key: :right, mod: [])
157
+ when "D" then yield KeyMsg.new(key: :left, mod: [])
158
+ when "H" then yield KeyMsg.new(key: :home, mod: [])
159
+ when "F" then yield KeyMsg.new(key: :end_key, mod: [])
160
+ else
161
+ yield KeyMsg.new(key: seq, mod: [:unknown])
162
+ end
163
+ end
164
+
165
+ def dispatch_csi(seq, &)
166
+ # Check for bracketed paste start/end
167
+ if seq == PASTE_START
168
+ @state = PASTE
169
+ @paste_buf.clear
170
+ return
171
+ end
172
+
173
+ if seq == PASTE_END
174
+ # Shouldn't happen in CSI (we'd be in PASTE state), but handle gracefully
175
+ yield PasteMsg.new(content: @paste_buf.dup)
176
+ @paste_buf.clear
177
+ return
178
+ end
179
+
180
+ # Focus/Blur: \e[I (focus) and \e[O (blur)
181
+ if seq == "\e[I"
182
+ yield FocusMsg.new
183
+ return
184
+ end
185
+ if seq == "\e[O"
186
+ yield BlurMsg.new
187
+ return
188
+ end
189
+
190
+ # SGR mouse: \e[<Cb;Cx;CyM or \e[<Cb;Cx;Cym
191
+ if seq.start_with?("\e[<") && (seq.end_with?("M") || seq.end_with?("m"))
192
+ dispatch_sgr_mouse(seq, &)
193
+ return
194
+ end
195
+
196
+ # Parse CSI parameters: \e[ params final
197
+ body = seq[2..] # strip "\e["
198
+ final = body[-1]
199
+ params_str = body[0..-2]
200
+
201
+ # Split params by ";"
202
+ params = params_str.split(";").map { |p| p.empty? ? 1 : p.to_i }
203
+
204
+ case final
205
+ when "A" then yield key_with_modifiers(:up, params)
206
+ when "B" then yield key_with_modifiers(:down, params)
207
+ when "C" then yield key_with_modifiers(:right, params)
208
+ when "D" then yield key_with_modifiers(:left, params)
209
+ when "H" then yield key_with_modifiers(:home, params)
210
+ when "F" then yield key_with_modifiers(:end_key, params)
211
+ when "Z" then yield KeyMsg.new(key: :tab, mod: [:shift]) # Shift+Tab
212
+ when "~"
213
+ dispatch_tilde(params, &)
214
+ else
215
+ yield KeyMsg.new(key: seq, mod: [:unknown])
216
+ end
217
+ end
218
+
219
+ def dispatch_tilde(params)
220
+ key_code = params[0]
221
+ mod = extract_modifiers(params[1]) if params.length > 1
222
+
223
+ key = case key_code
224
+ when 1 then :home
225
+ when 2 then :insert
226
+ when 3 then :delete
227
+ when 4 then :end_key
228
+ when 5 then :page_up
229
+ when 6 then :page_down
230
+ when 15 then :f5
231
+ when 17 then :f6
232
+ when 18 then :f7
233
+ when 19 then :f8
234
+ when 20 then :f9
235
+ when 21 then :f10
236
+ when 23 then :f11
237
+ when 24 then :f12
238
+ when 200
239
+ @state = PASTE
240
+ @paste_buf.clear
241
+ return
242
+ when 201
243
+ yield PasteMsg.new(content: @paste_buf.dup)
244
+ @paste_buf.clear
245
+ return
246
+ else
247
+ return yield KeyMsg.new(key: "\e[#{params.join(";")}~", mod: [:unknown])
248
+ end
249
+
250
+ yield KeyMsg.new(key: key, mod: mod || [])
251
+ end
252
+
253
+ def dispatch_sgr_mouse(seq)
254
+ # Format: \e[<Cb;Cx;CyM (press) or \e[<Cb;Cx;Cym (release)
255
+ pressed = seq.end_with?("M")
256
+ body = seq[3..-2] # strip "\e[<" and final char
257
+ parts = body.split(";")
258
+ return if parts.length < 3
259
+
260
+ cb = parts[0].to_i
261
+ cx = parts[1].to_i
262
+ cy = parts[2].to_i
263
+
264
+ # Extract modifiers from button code
265
+ mod = []
266
+ mod << :shift if cb.anybits?(4)
267
+ mod << :alt if cb.anybits?(8)
268
+ mod << :ctrl if cb.anybits?(16)
269
+
270
+ # Extract button and action
271
+ base = cb & 3
272
+ motion = cb.anybits?(32)
273
+ wheel = cb.anybits?(64)
274
+
275
+ if wheel
276
+ button = base.zero? ? MOUSE_WHEEL_UP : MOUSE_WHEEL_DOWN
277
+ action = MOUSE_PRESS
278
+ elsif motion
279
+ button = resolve_button(base)
280
+ action = MOUSE_MOTION
281
+ else
282
+ button = resolve_button(base)
283
+ action = pressed ? MOUSE_PRESS : MOUSE_RELEASE
284
+ end
285
+
286
+ yield MouseMsg.new(x: cx, y: cy, button: button, action: action, mod: mod)
287
+ end
288
+
289
+ def paste_char(ch)
290
+ @paste_buf << ch
291
+
292
+ # Check if paste buffer ends with the paste-end sequence
293
+ if @paste_buf.end_with?(PASTE_END)
294
+ content = @paste_buf[0..-(PASTE_END.length + 1)]
295
+ @paste_buf.clear
296
+ @state = GROUND
297
+ yield PasteMsg.new(content: content)
298
+ elsif @paste_buf.bytesize > MAX_PASTE_SIZE
299
+ # Prevent memory exhaustion — flush what we have and reset
300
+ content = @paste_buf.dup
301
+ @paste_buf.clear
302
+ @state = GROUND
303
+ yield PasteMsg.new(content: content)
304
+ end
305
+ end
306
+
307
+ def resolve_button(base)
308
+ case base
309
+ when 0 then MOUSE_LEFT
310
+ when 1 then MOUSE_MIDDLE
311
+ when 2 then MOUSE_RIGHT
312
+ else MOUSE_NONE
313
+ end
314
+ end
315
+
316
+ def key_with_modifiers(key, params)
317
+ if params.length >= 2
318
+ mod = extract_modifiers(params[1])
319
+ KeyMsg.new(key: key, mod: mod)
320
+ else
321
+ KeyMsg.new(key: key, mod: [])
322
+ end
323
+ end
324
+
325
+ def extract_modifiers(code)
326
+ MODIFIER_MAP[code] || []
327
+ end
328
+ end
329
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+
5
+ module Chamomile
6
+ # Background thread that reads stdin and feeds bytes through EscapeParser.
7
+ class InputReader
8
+ def initialize(queue, input: $stdin)
9
+ @queue = queue
10
+ @input = input
11
+ @thread = nil
12
+ @running = false
13
+ @parser = EscapeParser.new
14
+ end
15
+
16
+ def start
17
+ return @thread if @running
18
+
19
+ @running = true
20
+ @thread = Thread.new { read_loop }
21
+ @thread.abort_on_exception = false
22
+ @thread
23
+ end
24
+
25
+ def stop
26
+ @running = false
27
+ return unless @thread
28
+
29
+ @thread.join(1)
30
+ @thread = nil
31
+ end
32
+
33
+ def running?
34
+ @running
35
+ end
36
+
37
+ private
38
+
39
+ def read_loop
40
+ while @running
41
+ begin
42
+ if @input.wait_readable(0.05)
43
+ bytes = @input.read_nonblock(256)
44
+ next if bytes.nil? || bytes.empty?
45
+
46
+ @parser.feed(bytes) { |msg| @queue.push(msg) }
47
+ else
48
+ @parser.timeout! { |msg| @queue.push(msg) }
49
+ end
50
+ rescue IO::WaitReadable
51
+ # Transient — retry on next iteration
52
+ rescue EOFError, Errno::EIO
53
+ @queue.push(QuitMsg.new)
54
+ break
55
+ rescue StandardError => e
56
+ Chamomile.log("InputReader error: #{e.class}: #{e.message}", level: :warn)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end