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 +7 -0
- data/lib/chamomile/commands.rb +249 -0
- data/lib/chamomile/escape_parser.rb +329 -0
- data/lib/chamomile/input_reader.rb +61 -0
- data/lib/chamomile/key_map.rb +76 -0
- data/lib/chamomile/keymap.rb +77 -0
- data/lib/chamomile/logging.rb +34 -0
- data/lib/chamomile/messages.rb +49 -0
- data/lib/chamomile/model.rb +53 -0
- data/lib/chamomile/options.rb +55 -0
- data/lib/chamomile/program.rb +509 -0
- data/lib/chamomile/renderer.rb +248 -0
- data/lib/chamomile/router.rb +42 -0
- data/lib/chamomile/testing.rb +131 -0
- data/lib/chamomile/version.rb +5 -0
- data/lib/chamomile.rb +23 -0
- metadata +103 -0
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
|