chamomile 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f145ff12623c9aed6bee8c47585aea563619d086255be550282ac4df32f2e5b9
4
- data.tar.gz: 362dd705d7b703ae247ed844e47a3e679de59fee3d46c4059a01c790a5a13643
3
+ metadata.gz: cf8690ed6eaa09698bd3a64193250f4caef5ccbda8612da9dffd1f0b5a1125e6
4
+ data.tar.gz: cce5da2531a8feb9984f18603c722dd522780f2f2ead5075e9d6707a99f8c56d
5
5
  SHA512:
6
- metadata.gz: 2eb4d188e1d60890e83460e1a7d3d4171622521f87644a78e2d2b1268d8207190b310379fd06ff2d95158f842afa2cdbecb2770c13212b2813658c4f248923f9
7
- data.tar.gz: 75314103ae3b836eac6e3de81cb09696ecc647debb1a68f561a23f2e91402676edd10f628da9ae05139c2117549be51a72e2fc18f1b9ce68c0dff8dab6ba0654
6
+ metadata.gz: ae7732d2962818be4e4441a2a8dfe67b31df0db40fc3c8831e1020521080d90b6308fe235535b2b559bac361ec35e75ed88117396e5382bd2512de659bd1a9d6
7
+ data.tar.gz: bad50c84d2f86aca40ba4c09bd110aed17cf0c7e7e538aa391990c25b6ab5ed3177919e24843191bd4501267668bfb7a7cf38fb776039970a1d84fad1bc2defb
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chamomile
4
+ # Primary mixin for Chamomile applications. Combines Model + Commands
5
+ # and provides a declarative callback DSL for handling events.
6
+ #
7
+ # class Counter
8
+ # include Chamomile::Application
9
+ #
10
+ # def initialize
11
+ # @count = 0
12
+ # end
13
+ #
14
+ # on_key(:up, "k") { @count += 1 }
15
+ # on_key(:down, "j") { @count -= 1 }
16
+ # on_key("q") { quit }
17
+ #
18
+ # def view
19
+ # "Count: #{@count}"
20
+ # end
21
+ # end
22
+ module Application
23
+ # Include Model and Commands into Application itself (not the base class)
24
+ # so that Application's methods appear earlier in the MRO and can
25
+ # override Model's default NotImplementedError on update.
26
+ include Model
27
+ include Commands
28
+ include ViewDSL
29
+
30
+ def self.included(base)
31
+ base.extend(ClassMethods)
32
+ end
33
+
34
+ # Default update that dispatches to DSL-registered handlers.
35
+ # If a class defines its own `update(msg)`, that takes precedence
36
+ # over this (standard Ruby MRO).
37
+ def update(msg)
38
+ self.class._dispatch_event(self, msg)
39
+ end
40
+
41
+ module ClassMethods
42
+ # Register a handler for one or more key values.
43
+ # Keys can be symbols (:up, :down, :enter, etc.) or strings ("q", "k").
44
+ #
45
+ # on_key(:up, "k") { @count += 1 }
46
+ # on_key("q") { quit }
47
+ def on_key(*keys, &block)
48
+ keys.each do |key|
49
+ _key_handlers[key] = block
50
+ end
51
+ end
52
+
53
+ # Register a handler for terminal resize events.
54
+ #
55
+ # on_resize { |e| @width = e.width; @height = e.height }
56
+ def on_resize(&block)
57
+ _event_handlers[:resize] = block
58
+ end
59
+
60
+ # Register a handler for tick events.
61
+ #
62
+ # on_tick { refresh_data }
63
+ def on_tick(&block)
64
+ _event_handlers[:tick] = block
65
+ end
66
+
67
+ # Register a handler for mouse events.
68
+ #
69
+ # on_mouse { |e| handle_click(e) if e.press? }
70
+ def on_mouse(&block)
71
+ _event_handlers[:mouse] = block
72
+ end
73
+
74
+ # Register a handler for focus events.
75
+ #
76
+ # on_focus { @focused = true }
77
+ def on_focus(&block)
78
+ _event_handlers[:focus] = block
79
+ end
80
+
81
+ # Register a handler for blur events.
82
+ #
83
+ # on_blur { @focused = false }
84
+ def on_blur(&block)
85
+ _event_handlers[:blur] = block
86
+ end
87
+
88
+ # Register a handler for paste events.
89
+ #
90
+ # on_paste { |e| @clipboard = e.content }
91
+ def on_paste(&block)
92
+ _event_handlers[:paste] = block
93
+ end
94
+
95
+ # @api private — Key handlers hash (key_value → block)
96
+ def _key_handlers
97
+ @_key_handlers ||= {}
98
+ end
99
+
100
+ # @api private — Event handlers hash (event_type → block)
101
+ def _event_handlers
102
+ @_event_handlers ||= {}
103
+ end
104
+
105
+ # @api private — Dispatch an event to registered handlers.
106
+ # Returns command (a callable) or nil.
107
+ #
108
+ # The block's return value is the command. If you need to return a command
109
+ # (like `quit` or `tick`), ensure it is the last expression in the block:
110
+ #
111
+ # on_key("q") { @cleaning_up = true; quit } # correct — quit is last
112
+ # on_key("q") { quit; @cleaning_up = true } # BUG — quit is discarded
113
+ #
114
+ def _dispatch_event(instance, msg)
115
+ handler = case msg
116
+ when KeyEvent then _key_handlers[msg.key]
117
+ when ResizeEvent then _event_handlers[:resize]
118
+ when TickEvent then _event_handlers[:tick]
119
+ when MouseEvent then _event_handlers[:mouse]
120
+ when FocusEvent then _event_handlers[:focus]
121
+ when BlurEvent then _event_handlers[:blur]
122
+ when PasteEvent then _event_handlers[:paste]
123
+ end
124
+
125
+ return nil unless handler
126
+
127
+ result = instance.instance_exec(msg, &handler)
128
+ # Only return the result if it's a callable command (Proc/Lambda).
129
+ # Handler blocks often return non-command values (e.g., @count += 1 returns an Integer).
130
+ result.respond_to?(:call) ? result : nil
131
+ end
132
+
133
+ def inherited(subclass)
134
+ super
135
+ # Copy handlers to subclass so parent handlers are inherited
136
+ subclass.instance_variable_set(:@_key_handlers, _key_handlers.dup)
137
+ subclass.instance_variable_set(:@_event_handlers, _event_handlers.dup)
138
+ end
139
+ end
140
+ end
141
+ end
@@ -5,16 +5,30 @@ require "open3"
5
5
 
6
6
  module Chamomile
7
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)
8
+ WindowTitleCommand = Data.define(:title)
9
+ WindowTitleCmd = WindowTitleCommand # backward compat
10
+
11
+ CursorPositionCommand = Data.define(:row, :col)
12
+ CursorPositionCmd = CursorPositionCommand # backward compat
13
+
14
+ CursorShapeCommand = Data.define(:shape)
15
+ CursorShapeCmd = CursorShapeCommand # backward compat
16
+
17
+ CursorVisibilityCommand = Data.define(:visible)
18
+ CursorVisibilityCmd = CursorVisibilityCommand # backward compat
19
+
20
+ ExecCommand = Data.define(:command, :args, :callback)
21
+ ExecCmd = ExecCommand # backward compat
22
+
23
+ PrintlnCommand = Data.define(:text)
24
+ PrintlnCmd = PrintlnCommand # backward compat
14
25
 
15
26
  # Internal compound/control command types
16
- CancelCmd = Data.define(:token)
17
- StreamCmd = Data.define(:token, :producer)
27
+ CancelCommand = Data.define(:token)
28
+ CancelCmd = CancelCommand # backward compat
29
+
30
+ StreamCommand = Data.define(:token, :producer)
31
+ StreamCmd = StreamCommand # backward compat
18
32
 
19
33
  # Typed envelope for shell command results
20
34
  ShellResult = Data.define(:envelope, :stdout, :stderr, :status, :success)
@@ -53,7 +67,7 @@ module Chamomile
53
67
  # Helper methods for creating command lambdas (quit, batch, tick, etc.).
54
68
  module Commands
55
69
  def quit
56
- -> { QuitMsg.new }
70
+ -> { QuitEvent.new }
57
71
  end
58
72
 
59
73
  def none
@@ -74,10 +88,16 @@ module Chamomile
74
88
  -> { [:sequence, *valid] }
75
89
  end
76
90
 
91
+ # Run commands concurrently (Ruby-idiomatic alias for batch)
92
+ alias parallel batch
93
+
94
+ # Run commands in order (Ruby-idiomatic alias for sequence)
95
+ alias serial sequence
96
+
77
97
  def tick(duration, &block)
78
98
  -> {
79
99
  sleep(duration)
80
- block ? block.call : TickMsg.new(time: Time.now)
100
+ block ? block.call : TickEvent.new(time: Time.now)
81
101
  }
82
102
  end
83
103
 
@@ -86,7 +106,7 @@ module Chamomile
86
106
  now = Time.now
87
107
  next_tick = (now + duration) - (now.to_f % duration)
88
108
  sleep(next_tick - Time.now)
89
- block ? block.call : TickMsg.new(time: Time.now)
109
+ block ? block.call : TickEvent.new(time: Time.now)
90
110
  }
91
111
  end
92
112
 
@@ -123,7 +143,7 @@ module Chamomile
123
143
 
124
144
  # Returns a command that cancels a running token.
125
145
  def cancel(token)
126
- -> { CancelCmd.new(token: token) }
146
+ -> { CancelCommand.new(token: token) }
127
147
  end
128
148
 
129
149
  # A streaming command that emits multiple messages over time.
@@ -134,7 +154,7 @@ module Chamomile
134
154
  cmd = -> {
135
155
  return nil if token.cancelled?
136
156
 
137
- StreamCmd.new(token: token, producer: block)
157
+ StreamCommand.new(token: token, producer: block)
138
158
  }
139
159
  [token, cmd]
140
160
  end
@@ -162,32 +182,32 @@ module Chamomile
162
182
  end
163
183
 
164
184
  def window_title(title)
165
- -> { WindowTitleCmd.new(title: title) }
185
+ -> { WindowTitleCommand.new(title: title) }
166
186
  end
167
187
 
168
188
  def cursor_position(row, col)
169
- -> { CursorPositionCmd.new(row: row, col: col) }
189
+ -> { CursorPositionCommand.new(row: row, col: col) }
170
190
  end
171
191
 
172
192
  def cursor_shape(shape)
173
- -> { CursorShapeCmd.new(shape: shape) }
193
+ -> { CursorShapeCommand.new(shape: shape) }
174
194
  end
175
195
 
176
196
  def show_cursor
177
- -> { CursorVisibilityCmd.new(visible: true) }
197
+ -> { CursorVisibilityCommand.new(visible: true) }
178
198
  end
179
199
 
180
200
  def hide_cursor
181
- -> { CursorVisibilityCmd.new(visible: false) }
201
+ -> { CursorVisibilityCommand.new(visible: false) }
182
202
  end
183
203
 
184
204
  def exec(command, *args, &callback)
185
- -> { ExecCmd.new(command: command, args: args, callback: callback) }
205
+ -> { ExecCommand.new(command: command, args: args, callback: callback) }
186
206
  end
187
207
 
188
208
  # Print a line above the rendered TUI area
189
209
  def println(text)
190
- -> { PrintlnCmd.new(text: text) }
210
+ -> { PrintlnCommand.new(text: text) }
191
211
  end
192
212
 
193
213
  # Runtime mode toggles — return these as commands from update
@@ -235,7 +255,8 @@ module Chamomile
235
255
  -> { RequestWindowSizeMsg.new }
236
256
  end
237
257
 
238
- module_function :quit, :none, :batch, :sequence, :tick, :every, :cmd,
258
+ module_function :quit, :none, :batch, :sequence, :parallel, :serial,
259
+ :tick, :every, :cmd,
239
260
  :deliver, :map, :cancellable, :cancel, :stream,
240
261
  :shell, :timer,
241
262
  :window_title, :cursor_position, :cursor_shape,
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chamomile
4
+ # Block-style configuration for Chamomile.run.
5
+ #
6
+ # Chamomile.run(MyApp.new) do |config|
7
+ # config.alt_screen = true
8
+ # config.mouse = :cell_motion
9
+ # config.fps = 30
10
+ # end
11
+ class Configuration
12
+ attr_accessor :alt_screen, :mouse, :bracketed_paste,
13
+ :report_focus, :fps, :catch_panics
14
+
15
+ def initialize
16
+ @alt_screen = true
17
+ @mouse = :none
18
+ @bracketed_paste = true
19
+ @report_focus = false
20
+ @fps = 60
21
+ @catch_panics = true
22
+ end
23
+
24
+ def to_h
25
+ {
26
+ alt_screen: @alt_screen,
27
+ mouse: @mouse,
28
+ bracketed_paste: @bracketed_paste,
29
+ report_focus: @report_focus,
30
+ fps: @fps,
31
+ catch_panics: @catch_panics,
32
+ }
33
+ end
34
+ end
35
+ end
@@ -44,7 +44,7 @@ module Chamomile
44
44
 
45
45
  @state = GROUND
46
46
  @buf.clear
47
- yield KeyMsg.new(key: :escape, mod: [])
47
+ yield KeyEvent.new(key: :escape, mod: [])
48
48
  end
49
49
 
50
50
  private
@@ -72,16 +72,16 @@ module Chamomile
72
72
  @state = ESC_SEEN
73
73
  @buf = +"\e"
74
74
  when "\r", "\n"
75
- yield KeyMsg.new(key: :enter, mod: [])
75
+ yield KeyEvent.new(key: :enter, mod: [])
76
76
  when "\t"
77
- yield KeyMsg.new(key: :tab, mod: [])
77
+ yield KeyEvent.new(key: :tab, mod: [])
78
78
  when "\x7f", "\x08"
79
- yield KeyMsg.new(key: :backspace, mod: [])
79
+ yield KeyEvent.new(key: :backspace, mod: [])
80
80
  when ->(c) { c.ord.between?(1, 26) }
81
81
  letter = (ch.ord + 96).chr # \x01 -> 'a', \x02 -> 'b', etc.
82
- yield KeyMsg.new(key: letter, mod: [:ctrl])
82
+ yield KeyEvent.new(key: letter, mod: [:ctrl])
83
83
  when ->(c) { c.ord >= 32 }
84
- yield KeyMsg.new(key: ch, mod: [])
84
+ yield KeyEvent.new(key: ch, mod: [])
85
85
  end
86
86
  end
87
87
 
@@ -95,7 +95,7 @@ module Chamomile
95
95
  @buf << ch
96
96
  when "\e"
97
97
  # Another ESC: flush previous ESC as :escape, start new ESC
98
- yield KeyMsg.new(key: :escape, mod: [])
98
+ yield KeyEvent.new(key: :escape, mod: [])
99
99
  @buf = +"\e"
100
100
  # Stay in ESC_SEEN
101
101
  else
@@ -103,16 +103,16 @@ module Chamomile
103
103
  @state = GROUND
104
104
  @buf.clear
105
105
  if ["\x7f", "\x08"].include?(ch)
106
- yield KeyMsg.new(key: :backspace, mod: [:alt])
106
+ yield KeyEvent.new(key: :backspace, mod: [:alt])
107
107
  elsif ch.ord >= 32
108
- yield KeyMsg.new(key: ch, mod: [:alt])
108
+ yield KeyEvent.new(key: ch, mod: [:alt])
109
109
  elsif ["\r", "\n"].include?(ch)
110
- yield KeyMsg.new(key: :enter, mod: [:alt])
110
+ yield KeyEvent.new(key: :enter, mod: [:alt])
111
111
  elsif ch == "\t"
112
- yield KeyMsg.new(key: :tab, mod: [:alt])
112
+ yield KeyEvent.new(key: :tab, mod: [:alt])
113
113
  elsif ch.ord.between?(1, 26)
114
114
  letter = (ch.ord + 96).chr
115
- yield KeyMsg.new(key: letter, mod: %i[alt ctrl])
115
+ yield KeyEvent.new(key: letter, mod: %i[alt ctrl])
116
116
  end
117
117
  end
118
118
  end
@@ -147,18 +147,18 @@ module Chamomile
147
147
  @buf.clear
148
148
 
149
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: [])
150
+ when "P" then yield KeyEvent.new(key: :f1, mod: [])
151
+ when "Q" then yield KeyEvent.new(key: :f2, mod: [])
152
+ when "R" then yield KeyEvent.new(key: :f3, mod: [])
153
+ when "S" then yield KeyEvent.new(key: :f4, mod: [])
154
+ when "A" then yield KeyEvent.new(key: :up, mod: [])
155
+ when "B" then yield KeyEvent.new(key: :down, mod: [])
156
+ when "C" then yield KeyEvent.new(key: :right, mod: [])
157
+ when "D" then yield KeyEvent.new(key: :left, mod: [])
158
+ when "H" then yield KeyEvent.new(key: :home, mod: [])
159
+ when "F" then yield KeyEvent.new(key: :end_key, mod: [])
160
160
  else
161
- yield KeyMsg.new(key: seq, mod: [:unknown])
161
+ yield KeyEvent.new(key: seq, mod: [:unknown])
162
162
  end
163
163
  end
164
164
 
@@ -172,18 +172,18 @@ module Chamomile
172
172
 
173
173
  if seq == PASTE_END
174
174
  # Shouldn't happen in CSI (we'd be in PASTE state), but handle gracefully
175
- yield PasteMsg.new(content: @paste_buf.dup)
175
+ yield PasteEvent.new(content: @paste_buf.dup)
176
176
  @paste_buf.clear
177
177
  return
178
178
  end
179
179
 
180
180
  # Focus/Blur: \e[I (focus) and \e[O (blur)
181
181
  if seq == "\e[I"
182
- yield FocusMsg.new
182
+ yield FocusEvent.new
183
183
  return
184
184
  end
185
185
  if seq == "\e[O"
186
- yield BlurMsg.new
186
+ yield BlurEvent.new
187
187
  return
188
188
  end
189
189
 
@@ -208,11 +208,11 @@ module Chamomile
208
208
  when "D" then yield key_with_modifiers(:left, params)
209
209
  when "H" then yield key_with_modifiers(:home, params)
210
210
  when "F" then yield key_with_modifiers(:end_key, params)
211
- when "Z" then yield KeyMsg.new(key: :tab, mod: [:shift]) # Shift+Tab
211
+ when "Z" then yield KeyEvent.new(key: :tab, mod: [:shift]) # Shift+Tab
212
212
  when "~"
213
213
  dispatch_tilde(params, &)
214
214
  else
215
- yield KeyMsg.new(key: seq, mod: [:unknown])
215
+ yield KeyEvent.new(key: seq, mod: [:unknown])
216
216
  end
217
217
  end
218
218
 
@@ -240,14 +240,14 @@ module Chamomile
240
240
  @paste_buf.clear
241
241
  return
242
242
  when 201
243
- yield PasteMsg.new(content: @paste_buf.dup)
243
+ yield PasteEvent.new(content: @paste_buf.dup)
244
244
  @paste_buf.clear
245
245
  return
246
246
  else
247
- return yield KeyMsg.new(key: "\e[#{params.join(";")}~", mod: [:unknown])
247
+ return yield KeyEvent.new(key: "\e[#{params.join(";")}~", mod: [:unknown])
248
248
  end
249
249
 
250
- yield KeyMsg.new(key: key, mod: mod || [])
250
+ yield KeyEvent.new(key: key, mod: mod || [])
251
251
  end
252
252
 
253
253
  def dispatch_sgr_mouse(seq)
@@ -283,7 +283,7 @@ module Chamomile
283
283
  action = pressed ? MOUSE_PRESS : MOUSE_RELEASE
284
284
  end
285
285
 
286
- yield MouseMsg.new(x: cx, y: cy, button: button, action: action, mod: mod)
286
+ yield MouseEvent.new(x: cx, y: cy, button: button, action: action, mod: mod)
287
287
  end
288
288
 
289
289
  def paste_char(ch)
@@ -294,13 +294,13 @@ module Chamomile
294
294
  content = @paste_buf[0..-(PASTE_END.length + 1)]
295
295
  @paste_buf.clear
296
296
  @state = GROUND
297
- yield PasteMsg.new(content: content)
297
+ yield PasteEvent.new(content: content)
298
298
  elsif @paste_buf.bytesize > MAX_PASTE_SIZE
299
299
  # Prevent memory exhaustion — flush what we have and reset
300
300
  content = @paste_buf.dup
301
301
  @paste_buf.clear
302
302
  @state = GROUND
303
- yield PasteMsg.new(content: content)
303
+ yield PasteEvent.new(content: content)
304
304
  end
305
305
  end
306
306
 
@@ -316,9 +316,9 @@ module Chamomile
316
316
  def key_with_modifiers(key, params)
317
317
  if params.length >= 2
318
318
  mod = extract_modifiers(params[1])
319
- KeyMsg.new(key: key, mod: mod)
319
+ KeyEvent.new(key: key, mod: mod)
320
320
  else
321
- KeyMsg.new(key: key, mod: [])
321
+ KeyEvent.new(key: key, mod: [])
322
322
  end
323
323
  end
324
324