rooibos 0.7.2 → 0.8.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: 84b4f67bd8240b412f78b91e8e8e52af841e6438694bb51013519d3ba6e28fe7
4
- data.tar.gz: 63ac12eba2aee98ecf595aa948d457babc3533ce66b2d9fa20eee5257e26bcf1
3
+ metadata.gz: 978bf3a71ec2efd09d2b4d1fd7f5d8c04bfe9af2705df1b26f3f341a0e56e1e3
4
+ data.tar.gz: 6ab63cbe1f40881689c49a4990e71f4e4ba1dcb5b76f81d27b9fe96a9257121d
5
5
  SHA512:
6
- metadata.gz: 193556d2bf1093e16c1229162e579113c3d4a75c472cf37c071db593f4d1506405de0c7eaf1bccfcb9b2555335ed304c64d25f1cd08231c7c084a58bfa9ba731
7
- data.tar.gz: a8ab3b783bc7f40527f05b15d7c32e971f70518cb9a4769f5d0312590ff866773debe986dcfb93ab36e8cf61a95dc40f9b91db842557633b36333296ffaf6fbf
6
+ metadata.gz: 4e89b8ec3d0fa1377204256c9bcd2a6ba9cbdea6e862b3b041b82e35482a84449308737ed087e13ef65266bce291dae8e35b7e6d778bc319dbbb6e1df25cdfa5
7
+ data.tar.gz: e64af325d6c7f4e578390d541f5414d5838aaa434ae8fd828d352b6d949b02fa1ea12102a41f3e0da01342e2ba5d2fa633901b8e3aa728300c54defc2ad3f879
data/REUSE.toml CHANGED
@@ -8,6 +8,11 @@ path = 'Gemfile.lock'
8
8
  SPDX-FileCopyrightText = "2026 Kerrick Long <me@kerricklong.com>"
9
9
  SPDX-License-Identifier = "CC0-1.0"
10
10
 
11
+ [[annotations]]
12
+ path = 'rbs_collection.lock.yaml'
13
+ SPDX-FileCopyrightText = "2026 Kerrick Long <me@kerricklong.com>"
14
+ SPDX-License-Identifier = "CC0-1.0"
15
+
11
16
  [[annotations]]
12
17
  path = 'README.rdoc'
13
18
  SPDX-FileCopyrightText = "2025 Kerrick Long <me@kerricklong.com>"
@@ -381,7 +381,7 @@ module Rooibos
381
381
  rooibos: lib/rooibos/rubocop.yml
382
382
 
383
383
  AllCops:
384
- TargetRubyVersion: 3.2
384
+ TargetRubyVersion: 3.3
385
385
 
386
386
  Style/StringLiterals:
387
387
  EnforcedStyle: double_quotes
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ module Rooibos
9
+ module Command
10
+ # A one-shot clock command.
11
+ #
12
+ # Applications display the time, throttle refreshes, and show
13
+ # "last updated 30 seconds ago." All of these call <tt>Time.now</tt>.
14
+ # But <tt>Time.now</tt> in Update is a side effect. The same model
15
+ # and message produce different results depending on when you call
16
+ # them. Testing becomes non-deterministic.
17
+ #
18
+ # This command waits, then sends a <tt>Message::Clock</tt> with the
19
+ # current time. It responds to cancellation cooperatively. When
20
+ # canceled, it sends <tt>Message::Canceled</tt> so you know the
21
+ # clock stopped.
22
+ #
23
+ # Use it for periodic time updates, scheduling, or any feature that
24
+ # asks "what time is it?"
25
+ #
26
+ # Prefer the <tt>Command.clock</tt> factory method for convenience.
27
+ #
28
+ # === Example: Periodic refresh
29
+ #
30
+ # def update(msg, model)
31
+ # case msg
32
+ # in { type: :clock, envelope: :refresh, time: }
33
+ # [model.with(current_time: time.utc.to_s),
34
+ # Command.clock(1, :refresh)]
35
+ # end
36
+ # end
37
+ #
38
+ # === Example: "Last updated" display
39
+ #
40
+ # def update(msg, model)
41
+ # case msg
42
+ # in { type: :clock, envelope: :clock, time: }
43
+ # ago = (time - model.last_fetch).round
44
+ # [model.with(status: "Updated #{ago}s ago"),
45
+ # Command.clock(1, :clock)]
46
+ # end
47
+ # end
48
+ class Clock < Data.define(:seconds, :envelope)
49
+ include Custom
50
+ include Timed
51
+
52
+ private def timed_response(_start_time)
53
+ Message::Clock.new(envelope:, time: Time.now)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ module Rooibos
9
+ module Command
10
+ # A command that generates a random value.
11
+ #
12
+ # Games roll dice. Encryption needs keys. Shuffling needs seeds.
13
+ # All of these call <tt>Kernel#rand</tt> or <tt>Random</tt>. But
14
+ # randomness in Update is a side effect: the same model and message
15
+ # would produce different results on each call. Testing would become
16
+ # fragile and non-deterministic.
17
+ #
18
+ # This command delegates to Ruby's <tt>Random</tt> class through
19
+ # the runtime. Update receives the result as a
20
+ # <tt>Message::Random</tt>.
21
+ #
22
+ # Use it for dice rolls, shuffles, key generation, or any feature
23
+ # that needs random values without side effects in Update.
24
+ #
25
+ # Prefer the <tt>Command.random</tt> factory method for convenience.
26
+ #
27
+ # === Example: Dice roll
28
+ #
29
+ # def update(msg, model)
30
+ # case msg
31
+ # in { type: :random, envelope: :roll, value: }
32
+ # model.with(die_face: value)
33
+ # end
34
+ # end
35
+ #
36
+ # === Example: Random float
37
+ #
38
+ # def update(msg, model)
39
+ # case msg
40
+ # in { type: :random, envelope: :spawn_chance, value: }
41
+ # model.with(should_spawn: value < 0.3)
42
+ # end
43
+ # end
44
+ class Random < Data.define(:args, :envelope)
45
+ include Custom
46
+
47
+ # Executes the random command.
48
+ #
49
+ # Without a leading symbol, calls <tt>Random.rand</tt> with the
50
+ # stored arguments. With a leading symbol, calls that method on
51
+ # <tt>Random</tt> via <tt>public_send</tt>.
52
+ #
53
+ # [out] Outlet for sending messages.
54
+ # [token] Cancellation token from the runtime.
55
+ def call(out, token)
56
+ if token.canceled?
57
+ out.put(Message::Canceled.new(command: self))
58
+ return
59
+ end
60
+
61
+ value = if args.first.is_a?(Symbol)
62
+ method_name = args.first
63
+ ::Random.public_send(method_name, *args[1..])
64
+ else
65
+ ::Random.rand(*args)
66
+ end
67
+ out.put(Ractor.make_shareable(Message::Random.new(envelope:, value:)))
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ module Rooibos
9
+ module Command
10
+ # Shared behavior for commands that wait, then respond.
11
+ #
12
+ # Timer and clock commands follow the same pattern: wait for
13
+ # <tt>seconds</tt>, check for cancellation, then send a response.
14
+ # Duplicating the cancellation dance is error-prone.
15
+ #
16
+ # This module extracts the wait-cancel-respond skeleton. Include
17
+ # it in any <tt>Data.define(:seconds, :envelope)</tt> command and
18
+ # implement <tt>timed_response</tt> to build the success message.
19
+ #
20
+ # === Example
21
+ #
22
+ # class MyTimer < Data.define(:seconds, :envelope)
23
+ # include Custom
24
+ # include Timed
25
+ #
26
+ # private def timed_response(start_time)
27
+ # Message::Timer.new(envelope:, elapsed: Time.now - start_time)
28
+ # end
29
+ # end
30
+ #
31
+ module Timed # :nodoc:
32
+ # Cooperative cancellation needs no grace period.
33
+ # The command responds instantly to cancellation via
34
+ # <tt>Concurrent::Cancellation.timeout</tt>.
35
+ def rooibos_cancellation_grace_period
36
+ 0
37
+ end
38
+
39
+ # Waits for <tt>seconds</tt>, then sends the result of
40
+ # <tt>timed_response</tt>. If canceled, sends
41
+ # <tt>Message::Canceled</tt> instead.
42
+ #
43
+ # [out] Outlet for sending messages.
44
+ # [token] Cancellation token from the runtime.
45
+ def call(out, token)
46
+ start_time = Time.now
47
+ timer_cancellation, _origin = Concurrent::Cancellation.timeout(seconds)
48
+ combined = token.join(timer_cancellation)
49
+ combined.origin.wait
50
+
51
+ if token.canceled?
52
+ out.put(Message::Canceled.new(command: self))
53
+ else
54
+ out.put(Ractor.make_shareable(timed_response(start_time)))
55
+ end
56
+ end
57
+ end
58
+ private_constant :Timed
59
+ end
60
+ end
@@ -49,34 +49,11 @@ module Rooibos
49
49
  # end
50
50
  class Wait < Data.define(:seconds, :envelope)
51
51
  include Custom
52
+ include Timed
52
53
 
53
- # Cooperative cancellation needs no grace period.
54
- # The command responds instantly to cancellation via
55
- # <tt>Concurrent::Cancellation.timeout</tt>.
56
- def rooibos_cancellation_grace_period
57
- 0
58
- end
59
-
60
- # Executes the timer.
61
- #
62
- # Waits for <tt>seconds</tt>, then sends <tt>TimerResponse</tt>.
63
- # If canceled, sends <tt>Message::Canceled</tt> instead.
64
- #
65
- # [out] Outlet for sending messages.
66
- # [token] Cancellation token from the runtime.
67
- def call(out, token)
68
- start_time = Time.now
69
- timer_cancellation, _origin = Concurrent::Cancellation.timeout(seconds)
70
- combined = token.join(timer_cancellation)
71
- combined.origin.wait
72
-
73
- if token.canceled?
74
- out.put(Message::Canceled.new(command: self))
75
- else
76
- elapsed = Time.now - start_time
77
- response = Message::Timer.new(envelope:, elapsed:)
78
- out.put(Ractor.make_shareable(response))
79
- end
54
+ private def timed_response(start_time)
55
+ elapsed = Time.now - start_time
56
+ Message::Timer.new(envelope:, elapsed:)
80
57
  end
81
58
  end
82
59
  end
@@ -9,7 +9,10 @@ require "concurrent-edge"
9
9
  require_relative "command/custom"
10
10
  require_relative "command/outlet"
11
11
  require_relative "command/lifecycle"
12
+ require_relative "command/timed"
12
13
  require_relative "command/wait"
14
+ require_relative "command/clock"
15
+ require_relative "command/random"
13
16
  require_relative "command/batch"
14
17
  require_relative "command/all"
15
18
  require_relative "command/http"
@@ -86,6 +89,16 @@ module Rooibos
86
89
  def call(_out, _token)
87
90
  raise "Separate command should never be dispatched directly"
88
91
  end
92
+
93
+ def extract_bubbles # :nodoc:
94
+ bubbles, rest = commands.partition { |c| c.is_a?(Bubble) }
95
+ remaining = case rest.size
96
+ when 0 then nil
97
+ when 1 then rest.first
98
+ else Separate.new(commands: rest)
99
+ end
100
+ [bubbles, remaining]
101
+ end
89
102
  end
90
103
  private_constant :Separate
91
104
 
@@ -584,6 +597,32 @@ module Rooibos
584
597
  # [tag] Symbol to tag the result message.
585
598
  singleton_class.alias_method :tick, :wait
586
599
 
600
+ # Creates a wall-clock time command.
601
+ #
602
+ # Waits for +seconds+ then sends +Message::Clock+ with the current time.
603
+ # Use for displaying time, throttling refreshes, or scheduling.
604
+ #
605
+ # [seconds] Duration to wait (Float or Integer).
606
+ # [envelope] Symbol to tag the result message.
607
+ def self.clock(seconds, envelope)
608
+ Clock.new(seconds:, envelope:)
609
+ end
610
+
611
+ # Creates a random value command.
612
+ #
613
+ # Delegates to Ruby's <tt>Random</tt> class through the runtime.
614
+ # The last argument is always the envelope. Everything before it
615
+ # maps to <tt>Random</tt>.
616
+ #
617
+ # Without a leading symbol, calls <tt>Random#rand</tt>.
618
+ # With a leading symbol, calls that method on <tt>Random</tt>.
619
+ #
620
+ # [*args] Arguments to pass to <tt>Random</tt>, followed by the envelope.
621
+ def self.random(*args)
622
+ envelope = args.pop
623
+ Random.new(args: args.freeze, envelope:)
624
+ end
625
+
587
626
  # Creates a parallel batch command.
588
627
  #
589
628
  # Applications fetch data from multiple sources. Dashboard panels load
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ module Rooibos
9
+ module Message
10
+ # Response from a clock command.
11
+ #
12
+ # Long-lived applications display the time. Dashboards show "last updated
13
+ # 30 seconds ago." Schedulers fire actions at specific hours. All of these
14
+ # need wall-clock time. But calling <tt>Time.now</tt> in Update is a side
15
+ # effect—the same model and message produce different results depending
16
+ # on when you call them.
17
+ #
18
+ # This response carries the wall-clock time from the runtime. Update
19
+ # pattern-matches on the envelope to distinguish multiple clocks.
20
+ #
21
+ # Use it to handle <tt>Command.clock</tt> completions.
22
+ #
23
+ # === Example
24
+ #
25
+ # case msg
26
+ # in { type: :clock, envelope: :refresh, time: }
27
+ # [model.with(last_refresh: time), Command.clock(1, :refresh)]
28
+ # in { type: :clock, envelope: :display, time: }
29
+ # model.with(current_time: time.utc.to_s)
30
+ # end
31
+ #
32
+ class Clock < Data.define(:envelope, :time)
33
+ include Predicates
34
+ # Returns <tt>true</tt> for clock responses.
35
+ def clock?
36
+ true
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ module Rooibos
9
+ module Message
10
+ # Response from a random command.
11
+ #
12
+ # Games roll dice. Encryption needs keys. Shuffling needs seeds.
13
+ # All of these call <tt>Kernel#rand</tt> or <tt>Random</tt>. But
14
+ # randomness in Update is a side effect: the same model and message
15
+ # would produce different results on each call. Testing would become
16
+ # fragile.
17
+ #
18
+ # This response carries the random value from the runtime. Update
19
+ # pattern-matches on the envelope to distinguish multiple rolls.
20
+ #
21
+ # Use it to handle <tt>Command.random</tt> completions.
22
+ #
23
+ # === Example
24
+ #
25
+ # case msg
26
+ # in { type: :random, envelope: :roll_die, value: }
27
+ # model.with(roll: value)
28
+ # in { type: :random, envelope: :shuffle_seed, value: }
29
+ # model.with(sort_seed: value)
30
+ # end
31
+ #
32
+ class Random < Data.define(:envelope, :value)
33
+ include Predicates
34
+ # Returns +true+ for random responses.
35
+ def random?
36
+ true
37
+ end
38
+ end
39
+ end
40
+ end
@@ -125,6 +125,8 @@ module Rooibos
125
125
  end
126
126
 
127
127
  require_relative "message/timer"
128
+ require_relative "message/clock"
129
+ require_relative "message/random"
128
130
  require_relative "message/open"
129
131
  require_relative "message/http_response"
130
132
  require_relative "message/system/batch"
@@ -12,8 +12,8 @@ module Rooibos
12
12
  module Flow
13
13
  # Outward flow: messages traveling toward the root.
14
14
  #
15
- # Observe → intercept.
16
- class Outward < Data.define(:observes, :receives, :routes)
15
+ # Observe → intercept → forward.
16
+ class Outward < Data.define(:observes, :receives, :forwards, :routes)
17
17
  include Dispatch
18
18
 
19
19
  # Sentinel: intercept consumed the bubble. Non-nil so
@@ -25,6 +25,7 @@ module Rooibos
25
25
  transition = run_all(observes, message, model)
26
26
  config = Configuration.new(message:, model: transition.model)
27
27
  intercept_first_matching(receives, config, transition) ||
28
+ apply_first_matching(forwards, config, transition) ||
28
29
  transition
29
30
  end
30
31
 
@@ -23,22 +23,22 @@ module Rooibos
23
23
  envelope: as
24
24
  ))
25
25
 
26
- def add_events(keys, to:, as: nil, guard: nil, when: nil, unless: nil) = add(Forward.new(
26
+ def add_events(keys, to: nil, as: nil, broadcast: false, broadcast_to: nil, guard: nil, when: nil, unless: nil) = add(Forward.new(
27
27
  predicate: Predicate::Events.new(keys:),
28
- targets: to,
28
+ targets: resolve_targets(to:, broadcast:, broadcast_to:),
29
29
  envelope: as,
30
30
  guard: Guard.from(guard:, when: binding.local_variable_get(:when), unless: binding.local_variable_get(:unless))
31
31
  ))
32
32
 
33
- def add_routed(envelopes, to:, as: nil) = add(Forward.new(
33
+ def add_routed(envelopes, to: nil, as: nil, broadcast: false, broadcast_to: nil) = add(Forward.new(
34
34
  predicate: Predicate::RoutedEnvelopes.new(envelopes:),
35
- targets: to,
35
+ targets: resolve_targets(to:, broadcast:, broadcast_to:),
36
36
  envelope: as
37
37
  ))
38
38
 
39
- def add_custom(predicate, to:, as: nil, **guard_opts) = add(Forward.new(
39
+ def add_custom(predicate, to: nil, as: nil, broadcast: false, broadcast_to: nil, **guard_opts) = add(Forward.new(
40
40
  predicate:,
41
- targets: to,
41
+ targets: resolve_targets(to:, broadcast:, broadcast_to:),
42
42
  envelope: as,
43
43
  guard: Guard.from(**guard_opts)
44
44
  ))
@@ -11,6 +11,7 @@ module Rooibos
11
11
  # :stopdoc:
12
12
  # Encapsulates dispatch logic - given frozen rule sets, processes messages.
13
13
  class RouterUpdate < Data.define(:inward, :outward)
14
+ Separate = Command.const_get(:Separate)
14
15
  def call(message, model)
15
16
  case message
16
17
  when Message::Bubbled then dispatch_outward(message.message, model)
@@ -20,18 +21,24 @@ module Rooibos
20
21
 
21
22
  private def dispatch_inward(message, model)
22
23
  transition = inward.call(message, model)
24
+ transition = extract_bubbles(transition)
25
+ transition = transition.with(command: nil) if transition.command.equal?(Flow::Outward::INTERCEPTED)
26
+ transition.to_a
27
+ end
28
+
29
+ private def extract_bubbles(transition)
23
30
  case transition.command
24
31
  when Command::Bubble # Sentinel; not a real Command to be handled by the runtime
25
32
  bubble_model, bubble_cmd = dispatch_outward(transition.command.message, transition.model)
26
- transition = transition.with_model(bubble_model).with_command(bubble_cmd)
27
- when Command::Batch
28
- transition = extract_bubbles_from_batch(transition)
33
+ transition.with_model(bubble_model).with_command(bubble_cmd)
34
+ when Command::Batch, Separate
35
+ extract_bubbles_from_compound(transition)
36
+ else
37
+ transition
29
38
  end
30
- transition = transition.with(command: nil) if transition.command.equal?(Flow::Outward::INTERCEPTED)
31
- transition.to_a
32
39
  end
33
40
 
34
- private def extract_bubbles_from_batch(transition)
41
+ private def extract_bubbles_from_compound(transition)
35
42
  bubbles, remaining = transition.command.extract_bubbles
36
43
  return transition if bubbles.empty?
37
44
 
@@ -39,7 +46,8 @@ module Rooibos
39
46
  bubbles.each do |bubble|
40
47
  model, cmd = dispatch_outward(bubble.message, result.model)
41
48
  result = Transition.new(model:, command: result.command)
42
- result = result.with_added_command(cmd) unless cmd.nil? || cmd.equal?(Flow::Outward::INTERCEPTED)
49
+ result = result.with_added_command(bubble) if cmd.nil?
50
+ result = result.with_added_command(cmd) unless cmd.equal?(Flow::Outward::INTERCEPTED)
43
51
  end
44
52
  result
45
53
  end
@@ -28,7 +28,8 @@ module Rooibos
28
28
 
29
29
  private def envelop(message)
30
30
  if envelope
31
- Message::Routed.new(envelope:, event: message)
31
+ event = message.is_a?(Message::Routed) ? message.event : message
32
+ Message::Routed.new(envelope:, event:)
32
33
  else
33
34
  message
34
35
  end
@@ -123,7 +123,7 @@ module Rooibos
123
123
  def from_router
124
124
  RouterUpdate.new(
125
125
  inward: Flow::Inward.new(observes:, receives:, forwards:, otherwises:, routes:),
126
- outward: Flow::Outward.new(observes:, receives:, routes:)
126
+ outward: Flow::Outward.new(observes:, receives:, forwards:, routes:)
127
127
  )
128
128
  end
129
129
 
@@ -165,22 +165,6 @@ module Rooibos
165
165
  routes.add(Route.new(prefix: prefix&.to_s&.to_sym, fragment: to, read:, write:))
166
166
  end
167
167
 
168
- # Forwards all instances of a class to routes.
169
- #
170
- # Matches messages by class. Ideal for custom message types or
171
- # RatatuiRuby event classes like <tt>Event::Resize</tt>.
172
- #
173
- # Use <tt>broadcast: true</tt> to send to all declared routes, or
174
- # <tt>broadcast_to:</tt> with an array of specific route targets.
175
- #
176
- # === Example
177
- #
178
- # forward_instances_of RatatuiRuby::Event::Resize, to: :main_layout
179
- # forward_instances_of ThemeChanged, broadcast: true
180
- def forward_instances_of(klass, ...)
181
- forwards.add_instances_of(klass, ...)
182
- end
183
-
184
168
  # Defines a named action referenceable by symbol.
185
169
  #
186
170
  # Actions are reusable handlers. Reference them by name in
@@ -314,6 +298,9 @@ module Rooibos
314
298
  # with a semantic envelope. This decouples keybindings from nested
315
299
  # fragment internals.
316
300
  #
301
+ # Use <tt>broadcast: true</tt> to send to all declared routes, or
302
+ # <tt>broadcast_to:</tt> with an array of specific route targets.
303
+ #
317
304
  # === Example
318
305
  #
319
306
  # forward_events :enter, to: :active_form, as: :submit
@@ -322,6 +309,22 @@ module Rooibos
322
309
  forwards.add_events(keys, to:, **)
323
310
  end
324
311
 
312
+ # Forwards all instances of a class to routes.
313
+ #
314
+ # Matches messages by class. Ideal for custom message types or
315
+ # RatatuiRuby event classes like <tt>Event::Resize</tt>.
316
+ #
317
+ # Use <tt>broadcast: true</tt> to send to all declared routes, or
318
+ # <tt>broadcast_to:</tt> with an array of specific route targets.
319
+ #
320
+ # === Example
321
+ #
322
+ # forward_instances_of RatatuiRuby::Event::Resize, to: :main_layout
323
+ # forward_instances_of ThemeChanged, broadcast: true
324
+ def forward_instances_of(klass, to: @_scoped_target, **)
325
+ forwards.add_instances_of(klass, to:, **)
326
+ end
327
+
325
328
  # Routes matching routed messages to a declared route.
326
329
  #
327
330
  # Matches <tt>Message::Routed</tt> messages by envelope. Use this
@@ -332,6 +335,9 @@ module Rooibos
332
335
  # Each layer speaks its inner fragment's API without knowing what
333
336
  # lies deeper.
334
337
  #
338
+ # Use <tt>broadcast: true</tt> to send to all declared routes, or
339
+ # <tt>broadcast_to:</tt> with an array of specific route targets.
340
+ #
335
341
  # === Example
336
342
  #
337
343
  # forward_routed :leaf_1, to: :top_leaf, as: :increment
@@ -120,16 +120,18 @@ module Rooibos
120
120
  # [view] Callable receiving <tt>(model, tui)</tt>, returns a widget. *Required if fragment not provided.*
121
121
  # [update] Callable receiving <tt>(message, model)</tt>, returns <tt>[new_model, command]</tt> or just <tt>new_model</tt>. *Required if fragment not provided.*
122
122
  # [command] Optional callable to run at startup. Returns a message for update.
123
+ # [update_every_frame] When +true+, <tt>Event::None</tt> (idle frame) events are passed to Update instead of being dropped. Use for animations, physics, or any per-frame state change. Default +false+.
123
124
  #
124
125
  # == Raises
125
126
  #
126
127
  # [Rooibos::Error::Invariant] If both fragment and any of (model, view, update, command) are provided.
127
- def self.run(root_fragment = nil, fps: 60, model: nil, view: nil, update: nil, command: nil)
128
+ def self.run(root_fragment = nil, fps: 60, model: nil, view: nil, update: nil, command: nil, update_every_frame: false)
128
129
  @fragment = fragment_from_kwargs(root_fragment, model:, view:, update:, command:)
129
130
  @view = @fragment::View
130
131
  @update = @fragment::Update
131
132
  @init_callable = init_callable
132
133
  @timeout = 1.0 / fps
134
+ @update_every_frame = update_every_frame
133
135
 
134
136
  start_runtime
135
137
  end
@@ -310,7 +312,7 @@ module Rooibos
310
312
 
311
313
  private def handle_ratatui_event
312
314
  message = @tui.poll_event(timeout: @timeout)
313
- return false if message.none?
315
+ return false if message.none? && !@update_every_frame
314
316
 
315
317
  # Handle sync events: wait for pending async work before continuing
316
318
  if message.sync?
@@ -68,6 +68,14 @@ module Rooibos
68
68
  # Alias for +Message::Timer+.
69
69
  Timer = Message::Timer
70
70
 
71
+ # Clock message type.
72
+ # Alias for +Message::Clock+.
73
+ Clock = Message::Clock
74
+
75
+ # Random value message type.
76
+ # Alias for +Message::Random+.
77
+ Rand = Message::Random
78
+
71
79
  # HTTP response message type.
72
80
  # Alias for +Message::HttpResponse+.
73
81
  Http = Message::HttpResponse
@@ -8,5 +8,5 @@
8
8
  module Rooibos
9
9
  # The version of this gem.
10
10
  # See https://semver.org/spec/v2.0.0.html
11
- VERSION = "0.7.2"
11
+ VERSION = "0.8.0"
12
12
  end
@@ -134,19 +134,72 @@ module Rooibos
134
134
  # Alias for wait, semantically used for recurring timers.
135
135
  def self.tick: (Float seconds, Symbol tag) -> Wait
136
136
 
137
+ # Methods expected from classes that include Timed.
138
+ interface _TimedBase
139
+ def seconds: () -> Float
140
+ def timed_response: (Time start_time) -> (Message::Timer | Message::Clock)
141
+ end
142
+
143
+ # Shared behavior for commands that wait, then respond.
144
+ module Timed : _TimedBase
145
+ # Cooperative cancellation needs no grace period.
146
+ def rooibos_cancellation_grace_period: () -> Integer
147
+
148
+ # Waits then sends the result of timed_response, or Message::Canceled.
149
+ def call: (Outlet out, Concurrent::Cancellation token) -> void
150
+ end
151
+
137
152
  # One-shot timer command that waits, then sends a message.
138
153
  class Wait < Data
139
154
  include Custom
155
+ include Timed
156
+
157
+ attr_reader seconds: Float
158
+ attr_reader envelope: Symbol
159
+
160
+ def self.new: (seconds: Float, envelope: Symbol) -> instance
161
+
162
+ private
163
+
164
+ # Builds the timer response with elapsed time.
165
+ def timed_response: (Time start_time) -> Message::Timer
166
+ end
167
+
168
+ # One-shot clock command that waits, then sends a message with wall-clock time.
169
+ class Clock < Data
170
+ include Custom
171
+ include Timed
140
172
 
141
173
  attr_reader seconds: Float
142
174
  attr_reader envelope: Symbol
143
175
 
144
176
  def self.new: (seconds: Float, envelope: Symbol) -> instance
145
177
 
146
- # Execute the timer with cooperative cancellation.
178
+ private
179
+
180
+ # Builds the clock response with wall-clock time.
181
+ def timed_response: (Time start_time) -> Message::Clock
182
+ end
183
+
184
+ # Creates a wall-clock time command.
185
+ def self.clock: (Float seconds, Symbol envelope) -> Clock
186
+
187
+ # A command that generates a random value.
188
+ class Random < Data
189
+ include Custom
190
+
191
+ attr_reader args: Array[(Symbol | Integer | Float | Range[Integer] | Range[Float])]
192
+ attr_reader envelope: Symbol
193
+
194
+ def self.new: (args: Array[(Symbol | Integer | Float | Range[Integer] | Range[Float])], envelope: Symbol) -> instance
195
+
196
+ # Execute the random command.
147
197
  def call: (Outlet out, Concurrent::Cancellation token) -> void
148
198
  end
149
199
 
200
+ # Creates a random value command.
201
+ def self.random: (*(Symbol | Integer | Float | Range[Integer] | Range[Float])) -> Random
202
+
150
203
  # Private wrapper for Command.custom.
151
204
  class Wrapped < Data
152
205
  include Custom
@@ -89,6 +89,28 @@ module Rooibos
89
89
  def deconstruct_keys: (Array[Symbol]? keys) -> Hash[Symbol, untyped]
90
90
  end
91
91
 
92
+ # Response from a clock command.
93
+ class Clock < Data
94
+ include Predicates
95
+ attr_reader envelope: Symbol
96
+ attr_reader time: Time
97
+
98
+ def self.new: (envelope: Symbol, time: Time) -> instance
99
+
100
+ def clock?: () -> bool
101
+ end
102
+
103
+ # Response from a random command.
104
+ class Random < Data
105
+ include Predicates
106
+ attr_reader envelope: Symbol
107
+ attr_reader value: (Integer | Float | String)
108
+
109
+ def self.new: (envelope: Symbol, value: (Integer | Float | String)) -> instance
110
+
111
+ def random?: () -> bool
112
+ end
113
+
92
114
  # Response from an HTTP command.
93
115
  class HttpResponse
94
116
  include Predicates
@@ -17,13 +17,13 @@ module Rooibos
17
17
  def add_instances_of: (Class klass, ?to: route_target?, ?as: Symbol?, ?broadcast: bool, ?broadcast_to: Array[route_target]?) -> void
18
18
 
19
19
  # Adds a forward rule matching key events.
20
- def add_events: (Symbol | Array[Symbol] keys, ?to: route_target?, ?as: Symbol?, ?guard: guard?, ?when: guard?, ?unless: guard?) -> void
20
+ def add_events: (Symbol | Array[Symbol] keys, ?to: route_target?, ?as: Symbol?, ?broadcast: bool, ?broadcast_to: Array[route_target]?, ?guard: guard?, ?when: guard?, ?unless: guard?) -> void
21
21
 
22
22
  # Adds a forward rule matching routed messages by envelope.
23
- def add_routed: (Symbol | Array[Symbol] envelopes, ?to: route_target?, ?as: Symbol?) -> void
23
+ def add_routed: (Symbol | Array[Symbol] envelopes, ?to: route_target?, ?as: Symbol?, ?broadcast: bool, ?broadcast_to: Array[route_target]?) -> void
24
24
 
25
25
  # Adds a forward rule matching a custom predicate.
26
- def add_custom: (guard predicate, ?to: route_target?, ?as: Symbol?) -> void
26
+ def add_custom: (guard predicate, ?to: route_target?, ?as: Symbol?, ?broadcast: bool, ?broadcast_to: Array[route_target]?) -> void
27
27
 
28
28
  private
29
29
 
@@ -30,7 +30,8 @@ module Rooibos
30
30
  # Starts the MVU event loop (positional fragment).
31
31
  def self.run: [Model] (
32
32
  ?Module? root_fragment,
33
- ?fps: Integer
33
+ ?fps: Integer,
34
+ ?update_every_frame: bool
34
35
  ) -> Model
35
36
 
36
37
  # Starts the MVU event loop (explicit parameters).
@@ -40,7 +41,8 @@ module Rooibos
40
41
  model: Model,
41
42
  view: ^(Model, RatatuiRuby::TUI) -> renderable,
42
43
  update: ^(RatatuiRuby::Event, Model) -> update_result?,
43
- ?command: Command::execution?
44
+ ?command: Command::execution?,
45
+ ?update_every_frame: bool
44
46
  ) -> Model
45
47
 
46
48
  # Starts the MVU event loop (explicit parameters without fps).
@@ -49,7 +51,8 @@ module Rooibos
49
51
  model: Model,
50
52
  view: ^(Model, RatatuiRuby::TUI) -> renderable,
51
53
  update: ^(RatatuiRuby::Event, Model) -> update_result?,
52
- ?command: Command::execution?
54
+ ?command: Command::execution?,
55
+ ?update_every_frame: bool
53
56
  ) -> Model
54
57
 
55
58
  # Normalizes Init callable return value to [model, command] tuple.
@@ -16,6 +16,8 @@ module Rooibos
16
16
  # Short aliases for Message types.
17
17
  module Msg
18
18
  Timer: singleton(Message::Timer)
19
+ Clock: singleton(Message::Clock)
20
+ Rand: singleton(Message::Random)
19
21
  Http: singleton(Message::HttpResponse)
20
22
 
21
23
  module Sh
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rooibos
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.2
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kerrick Long
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '1.4'
18
+ version: '1.5'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '1.4'
25
+ version: '1.5'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: concurrent-ruby
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -306,12 +306,15 @@ files:
306
306
  - lib/rooibos/command/all.rb
307
307
  - lib/rooibos/command/batch.rb
308
308
  - lib/rooibos/command/bubble.rb
309
+ - lib/rooibos/command/clock.rb
309
310
  - lib/rooibos/command/custom.rb
310
311
  - lib/rooibos/command/deliver.rb
311
312
  - lib/rooibos/command/http.rb
312
313
  - lib/rooibos/command/lifecycle.rb
313
314
  - lib/rooibos/command/open.rb
314
315
  - lib/rooibos/command/outlet.rb
316
+ - lib/rooibos/command/random.rb
317
+ - lib/rooibos/command/timed.rb
315
318
  - lib/rooibos/command/wait.rb
316
319
  - lib/rooibos/configuration.rb
317
320
  - lib/rooibos/error.rb
@@ -320,9 +323,11 @@ files:
320
323
  - lib/rooibos/message/batch.rb
321
324
  - lib/rooibos/message/bubbled.rb
322
325
  - lib/rooibos/message/canceled.rb
326
+ - lib/rooibos/message/clock.rb
323
327
  - lib/rooibos/message/error.rb
324
328
  - lib/rooibos/message/http_response.rb
325
329
  - lib/rooibos/message/open.rb
330
+ - lib/rooibos/message/random.rb
326
331
  - lib/rooibos/message/routed.rb
327
332
  - lib/rooibos/message/system/batch.rb
328
333
  - lib/rooibos/message/system/stream.rb
@@ -412,7 +417,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
412
417
  requirements:
413
418
  - - ">="
414
419
  - !ruby/object:Gem::Version
415
- version: 3.2.9
420
+ version: 3.3.11
416
421
  - - "<"
417
422
  - !ruby/object:Gem::Version
418
423
  version: '5'
@@ -422,7 +427,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
422
427
  - !ruby/object:Gem::Version
423
428
  version: '0'
424
429
  requirements: []
425
- rubygems_version: 4.0.3
430
+ rubygems_version: 4.0.6
426
431
  specification_version: 4
427
432
  summary: "☕ Confidently Build Terminal Apps"
428
433
  test_files: []