rooibos 0.6.2 → 0.7.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.
Files changed (217) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSES/BSD-2-Clause.txt +9 -0
  3. data/REUSE.toml +5 -0
  4. data/exe/.gitkeep +0 -0
  5. data/lib/rooibos/cli/commands/new.rb +24 -0
  6. data/lib/rooibos/command/batch.rb +10 -0
  7. data/lib/rooibos/command/bubble.rb +34 -0
  8. data/lib/rooibos/command/custom.rb +3 -2
  9. data/lib/rooibos/command/deliver.rb +50 -0
  10. data/lib/rooibos/command/http.rb +1 -1
  11. data/lib/rooibos/command/lifecycle.rb +3 -1
  12. data/lib/rooibos/command/outlet.rb +19 -9
  13. data/lib/rooibos/command.rb +107 -3
  14. data/lib/rooibos/configuration.rb +29 -0
  15. data/lib/rooibos/message/bubbled.rb +29 -0
  16. data/lib/rooibos/message.rb +24 -6
  17. data/lib/rooibos/router/action.rb +36 -0
  18. data/lib/rooibos/router/flow/dispatch.rb +39 -0
  19. data/lib/rooibos/router/flow/inward.rb +41 -0
  20. data/lib/rooibos/router/flow/outward.rb +44 -0
  21. data/lib/rooibos/router/guard.rb +56 -0
  22. data/lib/rooibos/router/predicate.rb +65 -0
  23. data/lib/rooibos/router/registry/actions.rb +41 -0
  24. data/lib/rooibos/router/registry/forwards.rb +58 -0
  25. data/lib/rooibos/router/registry/observes.rb +57 -0
  26. data/lib/rooibos/router/registry/otherwises.rb +29 -0
  27. data/lib/rooibos/router/registry/receives.rb +57 -0
  28. data/lib/rooibos/router/registry/routes.rb +59 -0
  29. data/lib/rooibos/router/registry.rb +26 -0
  30. data/lib/rooibos/router/route.rb +42 -0
  31. data/lib/rooibos/router/router_update.rb +53 -0
  32. data/lib/rooibos/router/rule/forward.rb +39 -0
  33. data/lib/rooibos/router/rule/observe.rb +22 -0
  34. data/lib/rooibos/router/rule/otherwise.rb +26 -0
  35. data/lib/rooibos/router/rule/receive.rb +22 -0
  36. data/lib/rooibos/router/rule.rb +40 -0
  37. data/lib/rooibos/router.rb +424 -438
  38. data/lib/rooibos/runtime.rb +37 -52
  39. data/lib/rooibos/test_helper.rb +22 -0
  40. data/lib/rooibos/transition.rb +92 -0
  41. data/lib/rooibos/version.rb +1 -1
  42. data/lib/rooibos.rb +2 -57
  43. data/sig/rooibos/cli.rbs +1 -0
  44. data/sig/rooibos/command.rbs +44 -0
  45. data/sig/rooibos/configuration.rbs +20 -0
  46. data/sig/rooibos/message.rbs +12 -0
  47. data/sig/rooibos/router/action.rbs +33 -0
  48. data/sig/rooibos/router/actions.rbs +27 -0
  49. data/sig/rooibos/router/flow/dispatch.rbs +29 -0
  50. data/sig/rooibos/router/flow/inward.rbs +37 -0
  51. data/sig/rooibos/router/flow/outward.rbs +36 -0
  52. data/sig/rooibos/router/forward.rbs +35 -0
  53. data/sig/rooibos/router/forwards.rbs +34 -0
  54. data/sig/rooibos/router/guard.rbs +21 -0
  55. data/sig/rooibos/router/observe.rbs +20 -0
  56. data/sig/rooibos/router/observes.rbs +38 -0
  57. data/sig/rooibos/router/otherwise.rbs +22 -0
  58. data/sig/rooibos/router/otherwises.rbs +20 -0
  59. data/sig/rooibos/router/predicate.rbs +51 -0
  60. data/sig/rooibos/router/receive.rbs +20 -0
  61. data/sig/rooibos/router/receives.rbs +38 -0
  62. data/sig/rooibos/router/registry.rbs +24 -0
  63. data/sig/rooibos/router/route.rbs +46 -0
  64. data/sig/rooibos/router/router_update.rbs +33 -0
  65. data/sig/rooibos/router/routes.rbs +41 -0
  66. data/sig/rooibos/router/rule.rbs +36 -0
  67. data/sig/rooibos/router.rbs +216 -161
  68. data/sig/rooibos/runtime.rbs +0 -1
  69. data/sig/rooibos/test_helper.rbs +6 -0
  70. data/sig/rooibos/transition.rbs +33 -0
  71. data/sig/rooibos.rbs +0 -10
  72. metadata +144 -198
  73. data/.builds/ruby-3.2.yml +0 -55
  74. data/.builds/ruby-3.3.yml +0 -55
  75. data/.builds/ruby-3.4.yml +0 -55
  76. data/.builds/ruby-4.0.0.yml +0 -55
  77. data/.pre-commit-config.yaml +0 -16
  78. data/.rubocop.yml +0 -8
  79. data/AGENTS.md +0 -108
  80. data/CHANGELOG.md +0 -308
  81. data/README.md +0 -183
  82. data/README.rdoc +0 -374
  83. data/Rakefile +0 -16
  84. data/Steepfile +0 -13
  85. data/doc/best_practices/forms_and_validation.md +0 -20
  86. data/doc/best_practices/http_workflows.md +0 -20
  87. data/doc/best_practices/index.md +0 -26
  88. data/doc/best_practices/lists_and_tables.md +0 -20
  89. data/doc/best_practices/modal_dialogs.md +0 -20
  90. data/doc/best_practices/no_stateful_widgets.md +0 -184
  91. data/doc/best_practices/orchestration.md +0 -20
  92. data/doc/best_practices/streaming_data.md +0 -20
  93. data/doc/contributors/design/commands_and_outlets.md +0 -214
  94. data/doc/contributors/design/mvu_tea_implementations_research.md +0 -373
  95. data/doc/contributors/documentation_plan.md +0 -616
  96. data/doc/contributors/documentation_stub_audit.md +0 -112
  97. data/doc/contributors/documentation_style.md +0 -275
  98. data/doc/contributors/e2e_pty.md +0 -168
  99. data/doc/contributors/maybe_stateful_router.md +0 -56
  100. data/doc/contributors/specs/earliest_tutorial_steps_per_story.md +0 -70
  101. data/doc/contributors/specs/file_browser.md +0 -789
  102. data/doc/contributors/specs/file_browser_stories.md +0 -784
  103. data/doc/contributors/specs/tutorials_to_stories.rb +0 -167
  104. data/doc/contributors/todo/scrollbar.md +0 -118
  105. data/doc/contributors/tutorial_old/01_project_setup.md +0 -20
  106. data/doc/contributors/tutorial_old/02_hello_world.md +0 -24
  107. data/doc/contributors/tutorial_old/03_adding_state.md +0 -26
  108. data/doc/contributors/tutorial_old/06_organizing_your_code.md +0 -20
  109. data/doc/contributors/tutorial_old/07_your_first_command.md +0 -21
  110. data/doc/contributors/tutorial_old/08_the_preview_pane.md +0 -20
  111. data/doc/contributors/tutorial_old/09_loading_states.md +0 -20
  112. data/doc/contributors/tutorial_old/10_testing_your_app.md +0 -20
  113. data/doc/contributors/tutorial_old/11_polish_and_refine.md +0 -20
  114. data/doc/contributors/tutorial_old/12_going_further.md +0 -20
  115. data/doc/contributors/tutorial_old/index.md +0 -20
  116. data/doc/custom.css +0 -22
  117. data/doc/essentials/commands.md +0 -20
  118. data/doc/essentials/index.md +0 -31
  119. data/doc/essentials/messages.md +0 -21
  120. data/doc/essentials/models.md +0 -21
  121. data/doc/essentials/shortcuts.md +0 -19
  122. data/doc/essentials/the_elm_architecture.md +0 -24
  123. data/doc/essentials/the_runtime.md +0 -21
  124. data/doc/essentials/update_functions.md +0 -20
  125. data/doc/essentials/views.md +0 -22
  126. data/doc/getting_started/for_go_developers.md +0 -16
  127. data/doc/getting_started/for_python_developers.md +0 -16
  128. data/doc/getting_started/for_rails_developers.md +0 -17
  129. data/doc/getting_started/for_ratatui_ruby_developers.md +0 -17
  130. data/doc/getting_started/for_react_developers.md +0 -17
  131. data/doc/getting_started/index.md +0 -52
  132. data/doc/getting_started/install.md +0 -20
  133. data/doc/getting_started/quickstart.md +0 -20
  134. data/doc/getting_started/ruby_primer.md +0 -19
  135. data/doc/getting_started/why_rooibos.md +0 -20
  136. data/doc/images/verify_readme_usage.png +0 -0
  137. data/doc/images/widget_cmd_exec.png +0 -0
  138. data/doc/index.md +0 -93
  139. data/doc/scaling_up/async_patterns.md +0 -20
  140. data/doc/scaling_up/command_composition.md +0 -20
  141. data/doc/scaling_up/custom_commands.md +0 -21
  142. data/doc/scaling_up/fractal_architecture.md +0 -20
  143. data/doc/scaling_up/index.md +0 -30
  144. data/doc/scaling_up/message_routing.md +0 -20
  145. data/doc/scaling_up/ractor_safety.md +0 -20
  146. data/doc/scaling_up/testing.md +0 -21
  147. data/doc/troubleshooting/common_errors.md +0 -20
  148. data/doc/troubleshooting/debugging.md +0 -21
  149. data/doc/troubleshooting/index.md +0 -23
  150. data/doc/troubleshooting/performance.md +0 -20
  151. data/doc/tutorial/01_project_setup.md +0 -44
  152. data/doc/tutorial/02_hello_world.md +0 -45
  153. data/doc/tutorial/03_static_file_list.md +0 -44
  154. data/doc/tutorial/04_arrow_navigation.md +0 -47
  155. data/doc/tutorial/05_real_files.md +0 -45
  156. data/doc/tutorial/06_safe_refactoring.md +0 -21
  157. data/doc/tutorial/07_red_first_tdd.md +0 -26
  158. data/doc/tutorial/08_file_metadata.md +0 -42
  159. data/doc/tutorial/09_text_preview.md +0 -44
  160. data/doc/tutorial/10_directory_tree.md +0 -42
  161. data/doc/tutorial/11_pane_focus.md +0 -40
  162. data/doc/tutorial/12_sorting.md +0 -41
  163. data/doc/tutorial/13_filtering.md +0 -43
  164. data/doc/tutorial/14_toggle_hidden.md +0 -41
  165. data/doc/tutorial/15_text_input_widget.md +0 -43
  166. data/doc/tutorial/16_rename_files.md +0 -42
  167. data/doc/tutorial/17_confirmation_dialogs.md +0 -43
  168. data/doc/tutorial/18_progress_indicators.md +0 -43
  169. data/doc/tutorial/19_atomic_operations.md +0 -42
  170. data/doc/tutorial/20_external_editor.md +0 -42
  171. data/doc/tutorial/21_modal_overlays.md +0 -41
  172. data/doc/tutorial/22_error_handling.md +0 -43
  173. data/doc/tutorial/23_terminal_capabilities.md +0 -53
  174. data/doc/tutorial/24_mouse_events.md +0 -43
  175. data/doc/tutorial/25_resize_events.md +0 -43
  176. data/doc/tutorial/26_loading_states.md +0 -42
  177. data/doc/tutorial/27_performance.md +0 -43
  178. data/doc/tutorial/28_color_schemes.md +0 -47
  179. data/doc/tutorial/29_configuration.md +0 -124
  180. data/doc/tutorial/30_going_further.md +0 -17
  181. data/doc/tutorial/index.md +0 -17
  182. data/examples/app_fractal_dashboard/README.md +0 -60
  183. data/examples/app_fractal_dashboard/app.rb +0 -63
  184. data/examples/app_fractal_dashboard/dashboard/base.rb +0 -73
  185. data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +0 -86
  186. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +0 -87
  187. data/examples/app_fractal_dashboard/dashboard/update_router.rb +0 -43
  188. data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +0 -81
  189. data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +0 -82
  190. data/examples/app_fractal_dashboard/fragments/custom_shell_output.rb +0 -90
  191. data/examples/app_fractal_dashboard/fragments/disk_usage.rb +0 -47
  192. data/examples/app_fractal_dashboard/fragments/network_panel.rb +0 -45
  193. data/examples/app_fractal_dashboard/fragments/ping.rb +0 -47
  194. data/examples/app_fractal_dashboard/fragments/stats_panel.rb +0 -45
  195. data/examples/app_fractal_dashboard/fragments/system_info.rb +0 -47
  196. data/examples/app_fractal_dashboard/fragments/uptime.rb +0 -47
  197. data/examples/tutorial/01/app.rb +0 -50
  198. data/examples/tutorial/02/app.rb +0 -64
  199. data/examples/tutorial/03/app.rb +0 -91
  200. data/examples/tutorial/06_safe_refactoring/app.rb +0 -124
  201. data/examples/verify_readme_usage/README.md +0 -54
  202. data/examples/verify_readme_usage/app.rb +0 -47
  203. data/examples/verify_website_first_app/app.rb +0 -85
  204. data/examples/verify_website_hello_mvu/app.rb +0 -31
  205. data/examples/widget_command_system/README.md +0 -70
  206. data/examples/widget_command_system/app.rb +0 -134
  207. data/generate_tutorial_stubs.rb +0 -126
  208. data/mise.toml +0 -8
  209. data/rbs_collection.lock.yaml +0 -108
  210. data/rbs_collection.yaml +0 -15
  211. data/tasks/example_viewer.html.erb +0 -172
  212. data/tasks/install.rake +0 -29
  213. data/tasks/resources/build.yml.erb +0 -55
  214. data/tasks/resources/index.html.erb +0 -44
  215. data/tasks/resources/rubies.yml +0 -7
  216. data/tasks/steep.rake +0 -11
  217. /data/{vendor/goodcop/base.yml → lib/rooibos/rubocop.yml} +0 -0
@@ -1,527 +1,513 @@
1
- #--
2
- # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
3
1
  # frozen_string_literal: true
4
2
 
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
5
  # SPDX-License-Identifier: LGPL-3.0-or-later
6
6
  #++
7
7
 
8
+ require_relative "router/route"
9
+ require_relative "router/registry/routes"
10
+ require_relative "router/guard"
11
+ require_relative "router/rule"
12
+ require_relative "router/predicate"
13
+ require_relative "router/registry"
14
+ require_relative "router/action"
15
+ require_relative "router/registry/actions"
16
+ require_relative "router/rule/forward"
17
+ require_relative "router/registry/forwards"
18
+ require_relative "router/rule/receive"
19
+ require_relative "router/registry/receives"
20
+ require_relative "router/rule/observe"
21
+ require_relative "router/registry/observes"
22
+ require_relative "router/rule/otherwise"
23
+ require_relative "router/registry/otherwises"
24
+ require_relative "router/flow/dispatch"
25
+ require_relative "router/flow/inward"
26
+ require_relative "router/flow/outward"
27
+ require_relative "router/router_update"
28
+
8
29
  module Rooibos
9
- # Declarative DSL for Fractal Architecture.
30
+ # Fractal routing DSL for composing hierarchical updates.
10
31
  #
11
- # Large applications decompose into fragments. Each fragment has its own Model,
12
- # UPDATE, and VIEW. Parent fragments route messages to child fragments and compose views.
13
- # Writing this routing logic by hand is tedious and error-prone.
32
+ # A growing app accumulates message-handling logic. One Update handles
33
+ # dozens of cases. Model fields multiply. View code sprawls.
14
34
  #
15
- # Include this module to declare routes and keymaps. Call +from_router+ to
16
- # generate an Update lambda that handles routing automatically.
35
+ # Include Router in a fragment module. It decomposes your Update
36
+ # into declarative rules: routes bind nested fragments to model slices,
37
+ # forwards route messages inward, receives handle them exclusively,
38
+ # and observers process without stopping the flow.
17
39
  #
18
- # A *fragment* is a module containing <tt>Model</tt>, <tt>Init</tt>,
19
- # <tt>Update</tt>, and <tt>View</tt> constants. Fragments compose: parent fragments
20
- # delegate to child fragments.
40
+ # Use it to build tab containers, panel layouts, or any hierarchy
41
+ # where messages flow inward through nested fragments.
21
42
  #
22
43
  # === Example
23
44
  #
24
- # class Dashboard
45
+ # module Dashboard
25
46
  # include Rooibos::Router
26
47
  #
27
- # route :stats, to: StatsPanel
28
- # route :network, to: NetworkPanel
48
+ # route :sidebar, to: Sidebar
49
+ # route :main, to: MainPanel
50
+ #
51
+ # receive_events :ctrl_c, :quit
52
+ # action :quit, -> { Rooibos::Command.exit }
29
53
  #
30
- # keymap do
31
- # key "s", -> { SystemInfo.fetch_command }, route: :stats
32
- # key "q", -> { Command.exit }
33
- # end
54
+ # forward_events :enter, to: :main, as: :submit
55
+ # otherwise route_to: :main
34
56
  #
35
- # Model = Data.define(:stats, :network)
36
- # Init = -> { Model.new(stats: StatsPanel::Init.(), network: NetworkPanel::Init.()) }
37
- # View = ->(model, tui) { ... }
38
57
  # Update = from_router
39
58
  # end
40
59
  module Router
41
- # Configuration for key handlers.
42
- KeyHandlerConfig = Data.define(:handler, :action, :route, :guard) do
43
- def initialize(handler: nil, action: nil, route: nil, guard: nil)
44
- super
45
- end
46
- end
47
-
48
- # Configuration for scroll handlers (no coordinates).
49
- ScrollHandlerConfig = Data.define(:handler, :action) do
50
- def initialize(handler: nil, action: nil)
51
- super
52
- end
53
- end
54
-
55
- # Configuration for click handlers (x, y coordinates).
56
- ClickHandlerConfig = Data.define(:handler, :action) do
57
- def initialize(handler: nil, action: nil)
58
- super
59
- end
60
- end
60
+ # Sentinel for "all routes" - unique object prevents accidental collision
61
+ ALL_ROUTES = Object.new.freeze
62
+ private_constant :ALL_ROUTES
61
63
 
62
64
  def self.included(base) # :nodoc:
63
65
  base.extend(ClassMethods)
64
66
  end
65
67
 
66
- # Class methods added when Router is included.
68
+ # The Router declaration surface.
69
+ #
70
+ # Fragments grow. One Update handles dozens of cases. Routing logic,
71
+ # keybindings, and guard conditions tangle together.
72
+ #
73
+ # These class methods decompose that logic into declarative rules.
74
+ # Declare routes, forwards, receives, observes, and otherwises.
75
+ # Call <tt>from_router</tt> to freeze them into an Update callable.
76
+ #
77
+ # Use it inside any module that includes <tt>Rooibos::Router</tt>.
67
78
  module ClassMethods
68
- # Declares a route to a child fragment.
69
- #
70
- # [fragment_model_instance_attr] Symbol naming the attr on the parent's model
71
- # that holds this fragment's model instance (normalized via +.to_s.to_sym+).
72
- # [to] The child fragment module (must have Update and Init constants).
73
- def route(fragment_model_instance_attr, to:)
74
- routes[fragment_model_instance_attr.to_s.to_sym] = to
79
+ private def routes
80
+ @routes ||= Routes.new
75
81
  end
76
82
 
77
- # Returns the registered routes hash.
78
- def routes
79
- @routes ||= {}
83
+ private def actions
84
+ @actions ||= Actions.new
80
85
  end
81
86
 
82
- # Declares a named action.
83
- #
84
- # Actions are shared handlers that keymap and mousemap can reference.
85
- # This avoids duplicating logic for keys and mouse events that do
86
- # the same thing.
87
- #
88
- # Supports both positional and keyword syntax:
89
- # action :scroll_up, -> { Command.scroll(-1) } # Positional
90
- # action scroll_up: -> { Command.scroll(-1) } # Keyword
91
- #
92
- # [name] Symbol or String identifying the action (normalized via +.to_s.to_sym+).
93
- # [value] Callable that returns a command or message.
94
- def action(name = nil, value = nil, keymap: nil, key: nil, keys: nil, mousemap: nil, **kwargs)
95
- # key: and keys: are aliases for keymap:
96
- effective_keymap = keymap || key || keys
97
- action_name, action_value = if name && value
98
- # Positional: action :name, handler
99
- [name, value]
100
- elsif name.respond_to?(:call) && value.nil?
101
- # Anonymous: action -> { ... }, keymap: %i[...]
102
- # No name, just handler with bindings
103
- [nil, name]
104
- elsif kwargs.size == 1
105
- # Keyword: action name: handler
106
- kwargs.first
107
- else
108
- raise ArgumentError, "action requires (name, value) or (name: value)"
109
- end
110
-
111
- # @type var action_name: Symbol?
112
- # @type var action_value: (^() -> Command::execution? | Module)?
113
- register_action(action_name, action_value) if action_name && action_value
114
-
115
- # For anonymous actions, store handler directly in keymap
116
- handler_for_keymap = action_name.nil? ? action_value : nil
117
-
118
- # Register keymap bindings if provided
119
- if effective_keymap
120
- Array(effective_keymap).each do |key_name|
121
- key_handlers[key_name.to_s.to_sym] = Router::KeyHandlerConfig.new(
122
- handler: handler_for_keymap,
123
- action: action_name&.to_s&.to_sym,
124
- guard: nil,
125
- route: nil
126
- )
127
- end
128
- end
129
-
130
- # Register mousemap bindings if provided
131
- if mousemap
132
- Array(mousemap).each do |mouse_event|
133
- scroll_handlers[mouse_event.to_s.to_sym] = Router::ScrollHandlerConfig.new(
134
- handler: handler_for_keymap,
135
- action: action_name&.to_s&.to_sym
136
- )
137
- end
138
- end
87
+ private def forwards
88
+ @forwards ||= Forwards.new
139
89
  end
140
90
 
141
- private def register_action(name, value)
142
- key = name.to_s.to_sym
143
- case value
144
- when Module
145
- routed_actions[key] = value
146
- else
147
- actions[key] = value
148
- end
91
+ private def receives
92
+ @receives ||= Receives.new(actions:)
149
93
  end
150
94
 
151
- # Returns the registered handler actions hash.
152
- def actions
153
- @actions ||= {}
95
+ private def observes
96
+ @observes ||= Observes.new(actions:)
154
97
  end
155
98
 
156
- # Returns the registered routed actions hash.
157
- def routed_actions
158
- @routed_actions ||= {}
99
+ private def otherwises
100
+ @otherwises ||= Otherwises.new
159
101
  end
160
102
 
161
- # Declares key handlers in a block.
103
+ # Assembles all declared routes, forwards, receives, observes, and
104
+ # otherwises into a frozen RouterUpdate callable.
105
+ #
106
+ # Call this once at the end of your Router declarations. Assign the
107
+ # result to <tt>Update</tt> so the runtime dispatches messages through
108
+ # your router.
109
+ #
110
+ # Raises Rooibos::Error::Invariant if any forward or otherwise target
111
+ # is ambiguous (e.g. two routes share the same prefix or fragment).
162
112
  #
163
113
  # === Example
164
114
  #
165
- # keymap do
166
- # key "q", -> { Command.exit }
167
- # key :up, :scroll_up # Delegate to action
115
+ # module MyFragment
116
+ # include Rooibos::Router
117
+ #
118
+ # route :child, to: ChildFragment
119
+ # forward_events :enter, to: :child, as: :submit
120
+ #
121
+ # Update = from_router
168
122
  # end
169
- def keymap(&)
170
- builder = KeymapBuilder.new
171
- builder.instance_eval(&)
172
- @key_handlers = builder.handlers
123
+ def from_router
124
+ RouterUpdate.new(
125
+ inward: Flow::Inward.new(observes:, receives:, forwards:, otherwises:, routes:),
126
+ outward: Flow::Outward.new(observes:, receives:, routes:)
127
+ )
173
128
  end
174
129
 
175
- # Declares mouse handlers in a block.
130
+ # Declares a child route binding a nested fragment to a model slice.
131
+ #
132
+ # The simplest form names a model attribute. <tt>:sidebar</tt> means
133
+ # "read from <tt>model.sidebar</tt>, write back with
134
+ # <tt>model.with(sidebar: ...)</tt>."
135
+ #
136
+ # When your model stores fragments in hashes or other structures, pass
137
+ # <tt>read:</tt> and <tt>write:</tt> lambdas for custom extraction and
138
+ # merging. A route with lambdas has no prefix symbol.
139
+ #
140
+ # Returns the Route object. Capture it when neither the prefix symbol
141
+ # nor the fragment module can unambiguously identify the route.
142
+ #
143
+ # [prefix] Symbol or String naming the model attribute. Optional when
144
+ # using <tt>read:</tt>/<tt>write:</tt>.
145
+ # [to] The fragment module whose <tt>Update</tt> handles messages.
146
+ # [read] Lambda <tt>->(model) -> nested_model</tt>. Overrides prefix-based extraction.
147
+ # [write] Lambda <tt>->(model, value) -> model</tt>. Overrides prefix-based merging.
176
148
  #
177
149
  # === Example
178
150
  #
179
- # mousemap do
180
- # click -> (x, y) { [:clicked, x, y] }
181
- # scroll :up, :scroll_up # Delegate to action
182
- # end
183
- def mousemap(&)
184
- builder = MousemapBuilder.new
185
- builder.instance_eval(&)
186
- @scroll_handlers = builder.scroll_handlers
187
- @click_handler = builder.click_handler
151
+ # # Named attribute (most common)
152
+ # route :sidebar, to: Sidebar
153
+ #
154
+ # # Custom accessors for hash-stored fragments
155
+ # route read: ->(model) { model.panels[:sidebar] },
156
+ # write: ->(model, value) { model.with(panels: model.panels.merge(sidebar: value)) },
157
+ # to: Sidebar
158
+ #
159
+ # # Capture for disambiguation
160
+ # ACTIVE = route read: ->(m) { m.tabs[m.active_tab] },
161
+ # write: ->(m, v) { m.with(tabs: m.tabs.merge(m.active_tab => v)) },
162
+ # to: TabContent
163
+ # forward_events :enter, to: ACTIVE, as: :submit
164
+ def route(prefix = nil, to:, read: nil, write: nil, **)
165
+ routes.add(Route.new(prefix: prefix&.to_s&.to_sym, fragment: to, read:, write:))
188
166
  end
189
167
 
190
- # Returns the registered key handlers hash.
191
- private def key_handlers
192
- @key_handlers ||= {}
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, ...)
193
182
  end
194
183
 
195
- # Returns the registered scroll handlers hash.
196
- private def scroll_handlers
197
- @scroll_handlers ||= {}
184
+ # Defines a named action referenceable by symbol.
185
+ #
186
+ # Actions are reusable handlers. Reference them by name in
187
+ # <tt>receive*</tt>, <tt>intercept*</tt>, and <tt>observe*</tt>
188
+ # methods anywhere a handler lambda is accepted.
189
+ #
190
+ # Lambda actions run directly. Routed actions dispatch a
191
+ # <tt>Message::Routed</tt> to a fragment, using the action name as
192
+ # the envelope.
193
+ #
194
+ # [name] Symbol identifying the action.
195
+ # [handler] A lambda or a fragment Module for routed dispatch.
196
+ #
197
+ # === Example
198
+ #
199
+ # # Lambda action
200
+ # action :quit, -> { Rooibos::Command.exit }
201
+ #
202
+ # # Keyword form
203
+ # action scroll_up: ->(_, model) { model.with(offset: model.offset - 1) }
204
+ #
205
+ # # Routed action (dispatches :go_back to HistoryPanel)
206
+ # action :go_back, HistoryPanel
207
+ def action(name = nil, handler = nil, **kwargs)
208
+ if name && handler
209
+ actions.add(name, handler)
210
+ elsif kwargs.any?
211
+ kwargs.each { |k, v| actions.add(k, v) }
212
+ else
213
+ raise ArgumentError, "action requires name and handler, or keyword arguments"
214
+ end
198
215
  end
199
216
 
200
- # Returns the registered click handler, if any.
201
- private def click_handler
202
- @click_handler
217
+ # Handles matching key events directly. Stops further processing.
218
+ #
219
+ # Matches raw RatatuiRuby events by their <tt>to_sym</tt> value.
220
+ # The second argument is an action name (Symbol) or a handler lambda.
221
+ # The first matching receive wins; later handlers do not run.
222
+ #
223
+ # <tt>intercept_events</tt> is an alias. Use <tt>receive</tt> when the
224
+ # message is addressed to you. Use <tt>intercept</tt> when stopping a
225
+ # bubbled message mid-chain.
226
+ #
227
+ # === Example
228
+ #
229
+ # receive_events :ctrl_c, :quit
230
+ # receive_events :q, :quit
231
+ # receive_events :enter, ->(_, model) { model.with(submitted: true) }
232
+ def receive_events(...)
233
+ receives.add_events(...)
203
234
  end
204
235
 
205
- # Generates an UPDATE lambda from routes, keymap, and mousemap.
236
+ # Handles matching routed messages. Stops further processing.
206
237
  #
207
- # The generated UPDATE:
208
- # 1. Routes prefixed messages to child UPDATEs
209
- # 2. Handles keyboard events via keymap
210
- # 3. Handles mouse events via mousemap
211
- # 4. Returns model unchanged for unhandled messages
212
- def from_router
213
- RouterUpdate.new(
214
- routes:,
215
- actions:,
216
- routed_actions:,
217
- key_handlers:,
218
- scroll_handlers:,
219
- click_handler:
220
- )
238
+ # Matches <tt>Message::Routed</tt> messages by their envelope symbol.
239
+ # Use this when an outer fragment has forwarded a message with
240
+ # <tt>as:</tt> and your fragment handles it.
241
+ #
242
+ # <tt>intercept_routed</tt> is an alias.
243
+ #
244
+ # === Example
245
+ #
246
+ # receive_routed :panel_self,
247
+ # ->(_, model) { model.with(count: model.count + 1) }
248
+ def receive_routed(...)
249
+ receives.add_routed(...)
221
250
  end
222
- end
223
251
 
224
- # Internal UPDATE callable with proper typing.
225
- class RouterUpdate # :nodoc:
226
- def initialize(routes:, actions:, routed_actions:, key_handlers:, scroll_handlers:, click_handler:)
227
- @routes = routes
228
- @actions = actions
229
- @routed_actions = routed_actions
230
- @key_handlers = key_handlers
231
- @scroll_handlers = scroll_handlers
232
- @click_handler = click_handler
252
+ # Handles matching class instances. Stops further processing.
253
+ #
254
+ # Matches messages by class. Use <tt>receive</tt> for messages
255
+ # addressed to you. Use <tt>intercept</tt> to stop a bubbled message
256
+ # mid-chain.
257
+ #
258
+ # <tt>intercept_instances_of</tt> is an alias.
259
+ #
260
+ # === Example
261
+ #
262
+ # receive_instances_of FatalError,
263
+ # ->(msg, model) { [model.with(error: msg), Rooibos::Command.exit] }
264
+ def receive_instances_of(...)
265
+ receives.add_instances_of(...)
233
266
  end
234
267
 
235
- # Process message and return [model, command] tuple.
236
- def call(message, model)
237
- # 1. Try routing prefixed messages to child fragments
238
- @routes.each do |prefix, fragment|
239
- fragment_update = fragment.const_get(:Update)
240
- result = Rooibos.delegate(message, prefix, fragment_update, model.public_send(prefix))
241
- if result
242
- new_fragment_model, command = result
243
- return [model.with(prefix => new_fragment_model), command] #: [_DataModel, Command::execution?]
244
- end
245
- end
246
-
247
- # 2. Try keymap handlers (message is an Event::Key)
248
- if message.is_a?(RatatuiRuby::Event::Key)
249
- @key_handlers.each do |key_name, config|
250
- predicate = :"#{key_name}?"
251
- next unless message.respond_to?(predicate) && message.public_send(predicate)
252
-
253
- # Check guard if present
254
- if (config.guard) && !config.guard.call(model)
255
- next
256
- end
257
-
258
- # Get handler - either inline or from actions registry
259
- handler = config.handler
260
- if handler.nil? && config.action
261
- handler = @actions[config.action]
262
- end
263
-
264
- # Check for routed action if no handler found
265
- if handler.nil? && config.action
266
- routed_fragment = @routed_actions[config.action]
267
- if routed_fragment
268
- # Find the model attr for this fragment
269
- fragment_model_instance_attr = @routes.key(routed_fragment)
270
- next unless fragment_model_instance_attr
271
-
272
- # Synthesize Message::Routed and dispatch to child
273
- routed_message = Rooibos::Message::Routed.new(envelope: config.action, event: message)
274
- child_update = routed_fragment.const_get(:Update)
275
- previous_child_fragment_model_instance = model.public_send(fragment_model_instance_attr)
276
- updated_child_fragment_model_instance, command = child_update.call(routed_message, previous_child_fragment_model_instance)
277
- return [model.with(fragment_model_instance_attr => updated_child_fragment_model_instance), command]
278
- end
279
- end
280
-
281
- next unless handler
282
-
283
- command = handler.call
284
- if command && config.route
285
- command = Rooibos.route(command, config.route)
286
- end
287
- return [model, command] #: [_DataModel, Command::execution?]
288
- end
289
- end
290
-
291
- # 3. Try mousemap handlers (message is an Event::Mouse)
292
- if message.is_a?(RatatuiRuby::Event::Mouse)
293
- # Scroll events (handler takes no arguments)
294
- if message.scroll_up?
295
- config = @scroll_handlers[:scroll_up]
296
- if config
297
- scroll_handler = config.handler
298
- if scroll_handler.nil? && config.action
299
- scroll_handler = @actions[config.action]
300
- end
301
- return [model, scroll_handler&.call] #: [_DataModel, Command::execution?]
302
- end
303
- end
304
- if message.scroll_down?
305
- config = @scroll_handlers[:scroll_down]
306
- if config
307
- scroll_handler = config.handler
308
- if scroll_handler.nil? && config.action
309
- scroll_handler = @actions[config.action]
310
- end
311
- return [model, scroll_handler&.call] #: [_DataModel, Command::execution?]
312
- end
313
- end
314
- # Click events (handler takes x, y coordinates)
315
- click_config = @click_handler
316
- if message.down? && click_config
317
- click_handler_proc = click_config.handler
318
- if click_handler_proc.nil? && click_config.action
319
- # Actions don't take coordinates, so just call without args
320
- action_handler = @actions[click_config.action]
321
- return [model, action_handler&.call] #: [_DataModel, Command::execution?]
322
- elsif click_handler_proc
323
- return [model, click_handler_proc.call(message.x, message.y)] #: [_DataModel, Command::execution?]
324
- end
325
- end
326
- end
327
-
328
- # 4. Unhandled - return model unchanged
329
- [model, nil] #: [_DataModel, Command::execution?]
268
+ # Handles any message. Stops further processing.
269
+ #
270
+ # Matches every message. Combine with guards to create conditional
271
+ # catch-alls. For example, block all input when a fragment is inactive.
272
+ #
273
+ # <tt>intercept_all</tt> is an alias.
274
+ #
275
+ # === Example
276
+ #
277
+ # receive_all ->(msg, model) { [model, nil] },
278
+ # unless: ->(_, model) { model.active }
279
+ def receive_all(...)
280
+ receives.add_all(...)
330
281
  end
331
- end
332
- private_constant :RouterUpdate
333
-
334
- # Builder for keymap DSL.
335
- class KeymapBuilder
336
- # Returns the registered handlers hash.
337
- attr_reader :handlers
338
282
 
339
- def initialize # :nodoc:
340
- @handlers = {}
341
- @guard_stack = []
283
+ # Handles messages matching a custom predicate. Stops further processing.
284
+ #
285
+ # The predicate lambda receives <tt>(message, model)</tt>. If it returns
286
+ # a truthy value, the handler runs and no later handlers execute.
287
+ #
288
+ # <tt>intercept</tt> is an alias.
289
+ #
290
+ # === Example
291
+ #
292
+ # receive ->(msg, _) { msg.key? && msg.text? },
293
+ # ->(msg, model) { model.with(buffer: model.buffer + msg.char) }
294
+ def receive(...)
295
+ receives.add_custom(...)
342
296
  end
343
297
 
344
- # Registers a key handler.
345
- #
346
- # Supports multiple forms:
347
- # key :q, -> { Command.exit } # Single key with handler
348
- # key :q, :quit # Single key with action name
349
- # key :down, :j, action: :move_down # Multiple keys with action
350
- # key :enter, -> { ... }, route: :foo # With options
351
- #
352
- # [*key_names] One or more key names (String or Symbol).
353
- # [handler_or_action] Callable or Symbol (action name) - optional if action: given.
354
- # [action] Action name as keyword arg (alternative to positional).
355
- # [route] Optional route prefix for the command result.
356
- # [when/if/only/guard] Guard that runs if truthy (aliases).
357
- # [unless/except/skip] Guard that runs if falsy (negative aliases).
358
- def key(*args, action: nil, route: nil, when: nil, if: nil, only: nil, guard: nil, unless: nil, except: nil, skip: nil, **bindings)
359
- # Parse args: all symbols/strings are keys, last callable is handler
360
- key_names = [] #: Array[Symbol | String]
361
- handler = nil
362
- action_name = action
363
-
364
- args.each do |arg|
365
- if arg.is_a?(Hash)
366
- # Hash passed positionally: key({q: -> { ... }})
367
- bindings.merge!(arg)
368
- elsif arg.respond_to?(:call)
369
- handler = arg
370
- elsif arg.is_a?(Symbol) || arg.is_a?(String)
371
- # Could be a key name or action name (positional action from old API)
372
- key_names << arg
373
- end
374
- end
375
-
376
- # Keyword syntax: key ctrl_c: -> { ... }
377
- # Each kwarg is key_name => handler
378
- bindings.each do |key_name, handler_or_action|
379
- if handler_or_action.respond_to?(:call)
380
- register_key_handler(key_name, handler_or_action, nil, route, nil)
381
- else
382
- register_key_handler(key_name, nil, handler_or_action, route, nil)
383
- end
384
- end
385
-
386
- # If we had keyword bindings, skip positional processing
387
- return if bindings.any?
388
-
389
- # Old API: key :q, :quit - last symbol is the action
390
- if handler.nil? && action_name.nil? && key_names.size >= 2
391
- # Check if last "key" is actually an action by seeing if it looks like a handler
392
- action_name = key_names.pop
393
- end
394
-
395
- guards = @guard_stack.dup
396
-
397
- # Positive guards (when, if, only, guard)
398
- positive = binding.local_variable_get(:when) ||
399
- binding.local_variable_get(:if) ||
400
- only ||
401
- guard
402
- guards << positive if positive
403
-
404
- # Negative guards (unless, except, skip) - wrap to invert
405
- negative = binding.local_variable_get(:unless) || except || skip
406
- if negative
407
- guards << -> (model) { !negative.call(model) }
408
- end
409
-
410
- combined_guard = if guards.any?
411
- -> (model) { guards.all? { |g| g.call(model) } }
412
- end
298
+ alias intercept_events receive_events
299
+ alias intercept_routed receive_routed
300
+ alias intercept_instances_of receive_instances_of
301
+ alias intercept_all receive_all
302
+ alias intercept receive
413
303
 
414
- # Register each key
415
- key_names.each do |key_name|
416
- register_key_handler(key_name, handler, action_name, route, combined_guard)
417
- end
304
+ # Routes matching key events to a declared route.
305
+ #
306
+ # Matches raw RatatuiRuby events by their <tt>to_sym</tt> value.
307
+ # Pass a symbol for a single event or an array for multiple events
308
+ # that route to the same destination.
309
+ #
310
+ # The <tt>to:</tt> parameter accepts a symbol (model attribute), a
311
+ # module (fragment), or a Route (return value of <tt>route</tt>).
312
+ #
313
+ # Use <tt>as:</tt> to wrap the event in a <tt>Message::Routed</tt>
314
+ # with a semantic envelope. This decouples keybindings from nested
315
+ # fragment internals.
316
+ #
317
+ # === Example
318
+ #
319
+ # forward_events :enter, to: :active_form, as: :submit
320
+ # forward_events [:up, :k], to: :list, as: :move_up
321
+ def forward_events(keys, to: @_scoped_target, **)
322
+ forwards.add_events(keys, to:, **)
418
323
  end
419
324
 
420
- private def register_key_handler(key_name, handler, action_name, route, guard)
421
- @handlers[key_name.to_s] = KeyHandlerConfig.new(
422
- handler:,
423
- action: action_name,
424
- route:,
425
- guard:
426
- )
325
+ # Routes matching routed messages to a declared route.
326
+ #
327
+ # Matches <tt>Message::Routed</tt> messages by envelope. Use this
328
+ # when an outer fragment has already routed an event and you need
329
+ # to route it further to a nested fragment.
330
+ #
331
+ # Use <tt>as:</tt> to transform the envelope before forwarding.
332
+ # Each layer speaks its inner fragment's API without knowing what
333
+ # lies deeper.
334
+ #
335
+ # === Example
336
+ #
337
+ # forward_routed :leaf_1, to: :top_leaf, as: :increment
338
+ # forward_routed :leaf_2, to: :bottom_leaf, as: :increment
339
+ def forward_routed(envelopes, to: @_scoped_target, **)
340
+ forwards.add_routed(envelopes, to:, **)
427
341
  end
428
342
 
429
- # Alias for key (reads better with multiple keys)
430
- alias_method :keys, :key
431
-
432
- # Applies a guard to all keys in the block.
343
+ # Routes any message to a declared route.
433
344
  #
434
- # [when/if/only/guard] Guard that runs if truthy.
435
- def only(when: nil, if: nil, only: nil, guard: nil, &)
436
- arg_count = 0
437
- arg_count += 1 if binding.local_variable_get(:when)
438
- arg_count += 1 if binding.local_variable_get(:if)
439
- arg_count += 1 if only
440
- arg_count += 1 if guard
441
-
442
- if arg_count > 1
443
- raise ArgumentError, "only accepts exactly one of: when, if, only, guard"
444
- end
445
-
446
- positive = binding.local_variable_get(:when) ||
447
- binding.local_variable_get(:if) ||
448
- only ||
449
- guard
450
- with_guard(positive, &)
345
+ # Matches every message. Combine with guards to conditionally route
346
+ # unhandled messages. Without guards, acts as a catch-all forward.
347
+ #
348
+ # === Example
349
+ #
350
+ # only when: -> (_, model) { model.active_tab == :counter_tab } do
351
+ # forward_all to: :counter_tab
352
+ # end
353
+ # forward_all to: :active_panel
354
+ def forward_all(to: @_scoped_target, **guard_opts)
355
+ forwards.add_custom(Predicate::Always.new, to:, **guard_opts)
451
356
  end
452
357
 
453
- # Skips all keys in the block when the guard is true.
358
+ # Routes messages matching a custom predicate to a declared route.
454
359
  #
455
- # [when/if/skip/guard] Guard that skips if truthy.
456
- def skip(when: nil, if: nil, skip: nil, guard: nil, &)
457
- arg_count = 0
458
- arg_count += 1 if binding.local_variable_get(:when)
459
- arg_count += 1 if binding.local_variable_get(:if)
460
- arg_count += 1 if skip
461
- arg_count += 1 if guard
360
+ # The predicate lambda receives <tt>(message, model)</tt>. If it
361
+ # returns a truthy value, the message is forwarded. Use this for
362
+ # complex matching logic that the specialized variants cannot express.
363
+ #
364
+ # === Example
365
+ #
366
+ # forward ->(msg, _) { msg.key? && msg.ctrl? }, to: :editor
367
+ # forward ->(msg, _) { msg.key? && msg.shift? }, to: Sidebar
368
+ def forward(predicate, to: @_scoped_target, **)
369
+ forwards.add_custom(predicate, to:, **)
370
+ end
462
371
 
463
- if arg_count > 1
464
- raise ArgumentError, "skip accepts exactly one of: when, if, skip, guard"
465
- end
372
+ # Observes matching key events. Does not stop further processing.
373
+ #
374
+ # Matches raw RatatuiRuby events by <tt>to_sym</tt>. All matching
375
+ # observers run in declaration order. The message continues to later
376
+ # handlers. Use observe for side effects that should not block other
377
+ # handlers: logging, counting, updating derived state.
378
+ #
379
+ # === Example
380
+ #
381
+ # observe_events :enter,
382
+ # ->(_, model) { [model, Rooibos::Command.custom(Logger.log("Enter pressed"))] }
383
+ def observe_events(...)
384
+ observes.add_events(...)
385
+ end
466
386
 
467
- skip_guard = binding.local_variable_get(:when) ||
468
- binding.local_variable_get(:if) ||
469
- skip ||
470
- guard
387
+ # Observes matching routed messages. Does not stop further processing.
388
+ #
389
+ # Matches <tt>Message::Routed</tt> by envelope. The message continues
390
+ # to later handlers after this observer runs.
391
+ #
392
+ # === Example
393
+ #
394
+ # observe_routed :submit,
395
+ # ->(_, model) { model.with(submissions: model.submissions + 1) }
396
+ def observe_routed(...)
397
+ observes.add_routed(...)
398
+ end
471
399
 
472
- # Invert the guard: skip when true means run when false
473
- inverted = skip_guard ? -> (model) { !skip_guard.call(model) } : nil
474
- with_guard(inverted, &)
400
+ # Observes matching class instances. Does not stop further processing.
401
+ #
402
+ # Matches messages by class. Use it to react to custom message types
403
+ # while allowing them to continue to other handlers.
404
+ #
405
+ # === Example
406
+ #
407
+ # observe_instances_of LeafReset,
408
+ # ->(_, model) { model.with(nested_resets: model.nested_resets + 1) }
409
+ def observe_instances_of(...)
410
+ observes.add_instances_of(...)
475
411
  end
476
- private def with_guard(guard, &block)
477
- if guard
478
- @guard_stack << guard
479
- begin
480
- block.call
481
- ensure
482
- @guard_stack.pop
483
- end
484
- else
485
- block.call
486
- end
412
+
413
+ # Observes any message. Does not stop further processing.
414
+ #
415
+ # Matches every message. Useful for metrics, debugging, or global
416
+ # state updates that apply regardless of message type.
417
+ #
418
+ # === Example
419
+ #
420
+ # observe_all ->(msg, model) {
421
+ # model.with(message_count: model.message_count + 1)
422
+ # }
423
+ def observe_all(...)
424
+ observes.add_all(...)
487
425
  end
488
- end
489
426
 
490
- # Builder for mousemap DSL.
491
- class MousemapBuilder
492
- # Returns the registered scroll handlers (scroll_up, scroll_down).
493
- attr_reader :scroll_handlers
427
+ # Observes messages matching a custom predicate. Does not stop further
428
+ # processing.
429
+ #
430
+ # The predicate lambda receives <tt>(message, model)</tt>. All matching
431
+ # observers run. The message continues to later handlers.
432
+ #
433
+ # === Example
434
+ #
435
+ # observe ->(msg, _) { msg.leaf_reset? || msg.panel_reset? },
436
+ # ->(_, model) { model.with(total_resets: model.total_resets + 1) }
437
+ def observe(...)
438
+ observes.add_custom(...)
439
+ end
494
440
 
495
- # Returns the registered click handler.
496
- attr_reader :click_handler
441
+ # Catches unhandled messages as a router-level fallback.
442
+ #
443
+ # Messages not handled by <tt>receive</tt>, <tt>intercept</tt>, or
444
+ # <tt>forward</tt> fall through to <tt>otherwise</tt>. The
445
+ # <tt>route_to:</tt> parameter accepts the same three forms as
446
+ # <tt>to:</tt> in the forward family. Multiple <tt>otherwise</tt>
447
+ # declarations with guards create a conditional fallthrough chain.
448
+ #
449
+ # This keeps outer fragments minimal. Declare what you handle;
450
+ # everything else flows to the nested fragment.
451
+ #
452
+ # === Example
453
+ #
454
+ # otherwise route_to: :counter_tab,
455
+ # when: ->(_, model) { model.active_tab == :counter }
456
+ # otherwise route_to: :color_tab,
457
+ # when: ->(_, model) { model.active_tab == :color }
458
+ # otherwise route_to: :dashboard
459
+ def otherwise(...)
460
+ otherwises.add(...)
461
+ end
497
462
 
498
- def initialize # :nodoc:
499
- @scroll_handlers = {}
500
- @click_handler = nil
463
+ # Scopes a positive guard over declarations within the block.
464
+ #
465
+ # Every forward, receive, intercept, observe, and otherwise inside
466
+ # the block runs only when the guard returns truthy. Blocks nest;
467
+ # inner guards combine with outer ones.
468
+ #
469
+ # === Example
470
+ #
471
+ # only when: -> (_, model) { model.focused? } do
472
+ # forward_events :j, to: :list, as: :move_down
473
+ # end
474
+ private def only(when: nil, if: nil, &)
475
+ Guard.scoped(binding.local_variable_get(:when) || binding.local_variable_get(:if), &)
501
476
  end
502
477
 
503
- # Registers a click handler.
478
+ # Scopes a negative guard over declarations within the block.
504
479
  #
505
- # [handler_or_action] Callable `^(Integer, Integer) -> Command` or Symbol (action name).
506
- def click(handler_or_action)
507
- if handler_or_action.is_a?(Symbol)
508
- @click_handler = ClickHandlerConfig.new(action: handler_or_action)
509
- else
510
- @click_handler = ClickHandlerConfig.new(handler: handler_or_action)
511
- end
480
+ # The inverse of <tt>only</tt>. Declarations inside the block run
481
+ # only when the guard returns falsy.
482
+ #
483
+ # === Example
484
+ #
485
+ # skip when: -> (_, model) { model.locked? } do
486
+ # receive_events :d, :delete
487
+ # end
488
+ private def skip(when: nil, if: nil, &)
489
+ positive = binding.local_variable_get(:when) || binding.local_variable_get(:if)
490
+ Guard.scoped(-> (msg, model) { !positive.call(msg, model) }, &)
512
491
  end
513
492
 
514
- # Registers a scroll handler.
493
+ # Scopes the default <tt>to:</tt> target for forwards and receives
494
+ # within the block.
515
495
  #
516
- # [direction] <tt>:up</tt> or <tt>:down</tt>.
517
- # [handler_or_action] Callable `^() -> Command` or Symbol (action name).
518
- def scroll(direction, handler_or_action)
519
- config = if handler_or_action.is_a?(Symbol)
520
- ScrollHandlerConfig.new(action: handler_or_action)
521
- else
522
- ScrollHandlerConfig.new(handler: handler_or_action)
523
- end
524
- @scroll_handlers[:"scroll_#{direction}"] = config
496
+ # Avoids repeating <tt>to: :some_route</tt> on every declaration.
497
+ # The scoped target resets after the block.
498
+ #
499
+ # === Example
500
+ #
501
+ # route_to :left_panel do
502
+ # forward_events :a, as: :panel_self
503
+ # forward_events :"1", as: :leaf_1
504
+ # forward_events :"2", as: :leaf_2
505
+ # end
506
+ private def route_to(target)
507
+ @_scoped_target = target
508
+ yield if block_given?
509
+ ensure
510
+ @_scoped_target = nil
525
511
  end
526
512
  end
527
513
  end