rooibos 0.6.1 → 0.6.2

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: 16b78b8c6fabd42cda3c443da5f6487f043f33190ac7bb8d2190add134d154be
4
- data.tar.gz: 790da4bb69ca1f711fe3ae5dc41c09f18d88db4986258716db7b6db6060e21a3
3
+ metadata.gz: f0bd248d3b6933a881c6a2dc2e01eff42ecf20ae9b8547237f216de1ba3fcc54
4
+ data.tar.gz: ef8473cff78d86f01022ae554b9a60bf7fdadbfd8528d499cd59ccc592ce5a2a
5
5
  SHA512:
6
- metadata.gz: d306617ae1c2532c8bd105941beea50d4715840c0a087ec0199863e5ea089f7db1cdb9e9dea2a1921dec4b783c9f953963fe89f2a41d981101cc751b0ac518bf
7
- data.tar.gz: 483be60cb25f8025bf222d8e63ba5d784695678aac93d883a0fa9ac83ba53832103e4f3c6be5b3908509d3b6b1f0d3572afa83de77e30398a12e7a56a9b25497
6
+ metadata.gz: e3dbe609e9423b3e7e9335c72da14e3eca798d4083baf6f7c17761eeb7f42267bd68d391e6e0102d225f9c3f7e4fc1799221547440baa2d5f9c28beed96ac5b8
7
+ data.tar.gz: 49d737453d32dc06193baf1b529270938ae8861afcf9f2e5c1faef0a6a04b594d2cd8d63dfb4892c51b2b64b5e06c52c3dbbb260daaa7184a961d0969ca26b39
data/.builds/ruby-3.2.yml CHANGED
@@ -16,7 +16,7 @@ packages:
16
16
  - clang
17
17
  - git
18
18
  artifacts:
19
- - rooibos/pkg/rooibos-0.6.1.gem
19
+ - rooibos/pkg/rooibos-0.6.2.gem
20
20
  sources:
21
21
  - https://git.sr.ht/~kerrick/rooibos
22
22
  tasks:
data/.builds/ruby-3.3.yml CHANGED
@@ -16,7 +16,7 @@ packages:
16
16
  - clang
17
17
  - git
18
18
  artifacts:
19
- - rooibos/pkg/rooibos-0.6.1.gem
19
+ - rooibos/pkg/rooibos-0.6.2.gem
20
20
  sources:
21
21
  - https://git.sr.ht/~kerrick/rooibos
22
22
  tasks:
data/.builds/ruby-3.4.yml CHANGED
@@ -16,7 +16,7 @@ packages:
16
16
  - clang
17
17
  - git
18
18
  artifacts:
19
- - rooibos/pkg/rooibos-0.6.1.gem
19
+ - rooibos/pkg/rooibos-0.6.2.gem
20
20
  sources:
21
21
  - https://git.sr.ht/~kerrick/rooibos
22
22
  tasks:
@@ -16,7 +16,7 @@ packages:
16
16
  - clang
17
17
  - git
18
18
  artifacts:
19
- - rooibos/pkg/rooibos-0.6.1.gem
19
+ - rooibos/pkg/rooibos-0.6.2.gem
20
20
  sources:
21
21
  - https://git.sr.ht/~kerrick/rooibos
22
22
  tasks:
data/CHANGELOG.md CHANGED
@@ -21,6 +21,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
21
21
 
22
22
  ### Removed
23
23
 
24
+ ## [0.6.2] - 2026-01-27
25
+
26
+ ### Added
27
+
28
+ - **Router `action` DSL Enhancements**: Multiple syntax forms for declaring actions with inline keybindings:
29
+ - Positional: `action :quit, -> { Command.exit }` (original)
30
+ - Keyword: `action quit: -> { Command.exit }` (cleaner)
31
+ - Inline keymap: `action quit: -> { Command.exit }, keymap: %i[ctrl_c q]`
32
+ - `key:` singular alias: `action go_home: FileList, key: :~`
33
+ - `keys:` plural alias: `action move_down: FileList, keys: %i[down j]`
34
+ - Anonymous: `action -> { Command.exit }, keymap: %i[ctrl_c q]` (no name, just binding)
35
+ - `mousemap:` for scroll bindings: `action scroll_up: ..., mousemap: %i[scroll_up]`
36
+
37
+ - **Routed Actions**: Actions can now target child fragments. When the value is a `Module`, the Router synthesizes a `Message::Routed` and dispatches to the child's Update:
38
+ - `action move_down: FileList` — declares `FileList` as target
39
+ - Key presses trigger `Message::Routed.new(envelope: :move_down, event: key_event)`
40
+
41
+ - **Message::Routed**: New message type for routed actions between parent and child fragments:
42
+ - `deconstruct_keys` returns `{ type: :routed, envelope:, event: }`
43
+ - Predicate methods via `method_missing`: `msg.move_down?`, `msg.go_back?`
44
+ - Carries original event for full context in child Update
45
+
46
+ - **Keymap `key` DSL Enhancements**: Multiple syntax forms for declaring key handlers:
47
+ - Variadic keys: `key :down, :j, action: :move_down` (multiple keys, one action)
48
+ - `action:` keyword: `key :q, action: :quit` (explicit action reference)
49
+ - Keyword syntax: `key q: -> { Command.exit }` (hash-style)
50
+ - Multi-keyword: `keys ctrl_c: -> { ... }, q: -> { ... }` (multiple bindings)
51
+ - Hash metaprogramming: `key(exit_bindings)` where `exit_bindings = { q: ..., esc: ... }`
52
+ - `keys` alias: `alias_method :keys, :key` for readability
53
+
54
+ ### Changed
55
+
56
+ ### Fixed
57
+
58
+ ### Removed
59
+
24
60
  ## [0.6.1] - 2026-01-26
25
61
 
26
62
  ### Added
@@ -260,6 +296,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
260
296
  - **First Release**: Empty release of `rooibos`, a Ruby implementation of The Elm Architecture (TEA) for `ratatui_ruby`. Scaffolding generated by `ratatui_ruby-devtools`.
261
297
 
262
298
  [Unreleased]: https://git.sr.ht/~kerrick/rooibos/refs/HEAD
299
+ [0.6.2]: https://git.sr.ht/~kerrick/rooibos/refs/v0.6.2
263
300
  [0.6.1]: https://git.sr.ht/~kerrick/rooibos/refs/v0.6.1
264
301
  [0.6.0]: https://git.sr.ht/~kerrick/rooibos/refs/v0.6.0
265
302
  [0.5.0]: https://git.sr.ht/~kerrick/rooibos/refs/v0.5.0
@@ -0,0 +1,56 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
3
+ SPDX-License-Identifier: CC-BY-SA-4.0
4
+ -->
5
+
6
+ # Maybe: Stateful Router
7
+
8
+ **Status:** Not implemented — noted for future consideration.
9
+
10
+ ## Problem
11
+
12
+ Current routed actions require the parent fragment's model to hold child fragments' models (e.g., `model.file_list`). The Router then needs to know which field name to use when dispatching to children and updating the parent model.
13
+
14
+ This couples the Router to the parent's model structure.
15
+
16
+ ## Proposed Alternative
17
+
18
+ Make the Router stateful, holding child fragment models internally:
19
+
20
+ ```ruby
21
+ module ClassMethods
22
+ def route(prefix, to:)
23
+ # Router would also initialize and store child model
24
+ child_model, init_command = to.const_get(:Init).()
25
+ routed_models[prefix] = child_model
26
+ routes[prefix] = to
27
+ end
28
+ end
29
+ ```
30
+
31
+ ## Pattern Symmetry
32
+
33
+ This mirrors the architecture at every level:
34
+
35
+ | Component | Holds Model | Calls Update |
36
+ |-----------|-------------|--------------|
37
+ | Runtime | Root model | Root Update |
38
+ | Router | Child models| Child Updates|
39
+
40
+ ## Pros
41
+
42
+ - Parent fragment doesn't need nested model composition
43
+ - Router becomes a true "mini-runtime" for children
44
+ - Enables features like broadcasting resize events to all children
45
+ - Parent's Update is simpler — just handles its own concerns
46
+
47
+ ## Cons
48
+
49
+ - Breaks from strict Elm pattern where parent Model contains child Models
50
+ - Router becomes stateful (but Runtime already is)
51
+ - Initialization order requires careful thought
52
+ - View needs a different way to access child models for rendering
53
+
54
+ ## Decision
55
+
56
+ Deferred. Current implementation requires parent to hold child models, which follows canonical Elm patterns. The stateful router approach is coherent but would be a significant architectural change.
@@ -133,6 +133,8 @@ _Note: If you started with negative-numbered stories, this happened in Story 0._
133
133
  - Selected item is visually highlighted
134
134
  - Selection wraps at top/bottom of list
135
135
  - `j`/`k` vim keys also work
136
+ - `Home`/`g` jumps to first item
137
+ - `End`/`G` jumps to last item
136
138
 
137
139
  ### Notes
138
140
  - Introduces state management (current selection index)
@@ -152,7 +154,11 @@ _Note: If you started with negative-numbered stories, this happened in Story 0._
152
154
  - File list updates to show new directory contents
153
155
  - Current path display updates
154
156
  - Enter on regular file does nothing (for now)
155
- - Backspace goes to parent directory
157
+ - Backspace or `←`/`h` goes to parent directory
158
+ - `→`/`l` enters directory (same as Enter)
159
+ - `~` jumps to home directory
160
+ - `/` jumps to root directory
161
+ - `R` refreshes current view
156
162
 
157
163
  ### Notes
158
164
  - Introduces directory traversal
@@ -215,6 +221,8 @@ _Note: If you started with negative-numbered stories, this happened in Story 0._
215
221
  - Binary files show "Binary file" message
216
222
  - Preview pane scrolls if content is long
217
223
  - File type is detected (text vs binary)
224
+ - `Space` toggles preview pane visibility
225
+ - `PgUp`/`PgDn` pages through preview content (when focused)
218
226
 
219
227
  ### Notes
220
228
  - Introduces file reading
@@ -256,10 +264,12 @@ _Note: If you started with negative-numbered stories, this happened in Story 0._
256
264
  - Active pane has visual indicator (highlighted border)
257
265
  - Keyboard shortcuts work in context of focused pane
258
266
  - Arrow keys navigate within focused pane
267
+ - `PgUp`/`PgDn` pages by visible height
259
268
 
260
269
  ### Notes
261
270
  - Introduces focus management
262
271
  - Introduces context-sensitive key handling
272
+ - Introduces cached layout pattern (store pane dimensions for dynamic paging)
263
273
  - Preview pane becomes interactive (scrollable)
264
274
 
265
275
  ---
@@ -10,12 +10,15 @@ require "rooibos"
10
10
 
11
11
  module Tutorial01
12
12
  module FileBrowser
13
- Model = Data.define(:current_directory, :file_names)
13
+ Entry = Data.define(:name, :directory?)
14
+
15
+ Model = Data.define(:current_directory, :entries)
14
16
 
15
17
  View = -> (model, tui) {
18
+ items = model.entries.map { |e| e.directory? ? "#{e.name}/" : e.name }
16
19
  tui.layout(children: [
17
20
  tui.paragraph(text: model.current_directory),
18
- tui.list(items: model.file_names),
21
+ tui.list(items:),
19
22
  ])
20
23
  }
21
24
 
@@ -27,10 +30,17 @@ module Tutorial01
27
30
  end
28
31
  }
29
32
 
33
+ ReadEntries = -> (path) {
34
+ Dir.children(path).map { |name|
35
+ full_path = File.join(path, name)
36
+ Entry.new(name:, directory?: File.directory?(full_path))
37
+ }.sort_by { |e| [e.directory? ? 0 : 1, e.name.downcase] }
38
+ }
39
+
30
40
  Init = -> {
31
41
  current_directory = Dir.pwd
32
- file_names = Dir.children(current_directory)
33
- Ractor.make_shareable Model.new(current_directory, file_names)
42
+ entries = ReadEntries.call(current_directory)
43
+ Ractor.make_shareable Model.new(current_directory, entries)
34
44
  }
35
45
  end
36
46
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ #
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ #++
8
+
9
+ require "rooibos"
10
+
11
+ module Tutorial02
12
+ module FileBrowser
13
+ Entry = Data.define(:name, :directory?)
14
+
15
+ Model = Data.define(:current_directory, :entries, :selected_index)
16
+
17
+ View = -> (model, tui) {
18
+ items = model.entries.map { |e| e.directory? ? "#{e.name}/" : e.name }
19
+ tui.layout(children: [
20
+ tui.paragraph(text: model.current_directory),
21
+ tui.list(
22
+ items:,
23
+ selected_index: model.selected_index,
24
+ highlight_style: tui.style(modifiers: [:reversed])
25
+ ),
26
+ ])
27
+ }
28
+
29
+ Update = -> (message, model) {
30
+ if message.ctrl_c? or message.q?
31
+ Rooibos::Command.exit
32
+ elsif message.down_arrow? or message.j?
33
+ new_index = (model.selected_index + 1) % model.entries.length
34
+ model.with(selected_index: new_index)
35
+ elsif message.up_arrow? or message.k?
36
+ new_index = (model.selected_index - 1) % model.entries.length
37
+ model.with(selected_index: new_index)
38
+ elsif message.home? or message.g?
39
+ model.with(selected_index: 0)
40
+ elsif message.end? or message.G?
41
+ model.with(selected_index: model.entries.length - 1)
42
+ else
43
+ model
44
+ end
45
+ }
46
+
47
+ ReadEntries = -> (path) {
48
+ Dir.children(path).map { |name|
49
+ full_path = File.join(path, name)
50
+ Entry.new(name:, directory?: File.directory?(full_path))
51
+ }.sort_by { |e| [e.directory? ? 0 : 1, e.name.downcase] }
52
+ }
53
+
54
+ Init = -> {
55
+ current_directory = Dir.pwd
56
+ entries = ReadEntries.call(current_directory)
57
+ Ractor.make_shareable Model.new(current_directory, entries, 0)
58
+ }
59
+ end
60
+ end
61
+
62
+ if __FILE__ == $0
63
+ Rooibos.run(Tutorial02::FileBrowser)
64
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ #
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ #++
8
+
9
+ require "rooibos"
10
+
11
+ module Tutorial03
12
+ module FileBrowser
13
+ Entry = Data.define(:name, :directory?)
14
+
15
+ Model = Data.define(:current_directory, :entries, :selected_index)
16
+
17
+ View = -> (model, tui) {
18
+ items = model.entries.map { |e| e.directory? ? "#{e.name}/" : e.name }
19
+ tui.layout(children: [
20
+ tui.paragraph(text: model.current_directory),
21
+ tui.list(
22
+ items:,
23
+ selected_index: model.selected_index,
24
+ highlight_style: tui.style(modifiers: [:reversed])
25
+ ),
26
+ ])
27
+ }
28
+
29
+ Update = -> (message, model) {
30
+ if message.ctrl_c? or message.q?
31
+ Rooibos::Command.exit
32
+ elsif message.down_arrow? or message.j?
33
+ new_index = (model.selected_index + 1) % model.entries.length
34
+ model.with(selected_index: new_index)
35
+ elsif message.up_arrow? or message.k?
36
+ new_index = (model.selected_index - 1) % model.entries.length
37
+ model.with(selected_index: new_index)
38
+ elsif message.home? or message.g?
39
+ model.with(selected_index: 0)
40
+ elsif message.end? or message.G?
41
+ model.with(selected_index: model.entries.length - 1)
42
+ elsif message.enter? or message.right_arrow? or message.l?
43
+ selected_entry = model.entries[model.selected_index]
44
+ if selected_entry.directory?
45
+ new_dir = File.join(model.current_directory, selected_entry.name)
46
+ new_entries = read_entries(new_dir)
47
+ model.with(current_directory: new_dir, entries: new_entries, selected_index: 0)
48
+ else
49
+ model
50
+ end
51
+ elsif message.backspace? or message.left_arrow? or message.h?
52
+ parent_dir = File.dirname(model.current_directory)
53
+ new_entries = read_entries(parent_dir)
54
+ model.with(current_directory: parent_dir, entries: new_entries, selected_index: 0)
55
+ elsif message.tilde?
56
+ home_dir = Dir.home
57
+ new_entries = read_entries(home_dir)
58
+ model.with(current_directory: home_dir, entries: new_entries, selected_index: 0)
59
+ elsif message.slash?
60
+ new_entries = read_entries("/")
61
+ model.with(current_directory: "/", entries: new_entries, selected_index: 0)
62
+ elsif message.R?
63
+ new_entries = read_entries(model.current_directory)
64
+ model.with(entries: new_entries)
65
+ else
66
+ model
67
+ end
68
+ }
69
+
70
+ ReadEntries = -> (path) {
71
+ Dir.children(path).map { |name|
72
+ full_path = File.join(path, name)
73
+ Entry.new(name:, directory?: File.directory?(full_path))
74
+ }.sort_by { |e| [e.directory? ? 0 : 1, e.name.downcase] }
75
+ }
76
+
77
+ Init = -> {
78
+ current_directory = Dir.pwd
79
+ entries = ReadEntries.call(current_directory)
80
+ Ractor.make_shareable Model.new(current_directory, entries, 0)
81
+ }
82
+
83
+ private_class_method def self.read_entries(path)
84
+ ReadEntries.call(path)
85
+ end
86
+ end
87
+ end
88
+
89
+ if __FILE__ == $0
90
+ Rooibos.run(Tutorial03::FileBrowser)
91
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ #
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ #++
8
+
9
+ require "rooibos"
10
+
11
+ module Tutorial06
12
+ # A Fragment is a module with Model, Init, Update, View.
13
+ # This FileList fragment handles the list of files and navigation.
14
+ module FileList
15
+ Entry = Data.define(:name, :directory?)
16
+
17
+ Model = Data.define(:current_directory, :entries, :selected_index)
18
+
19
+ View = -> (model, tui) {
20
+ items = model.entries.map { |e| e.directory? ? "#{e.name}/" : e.name }
21
+ tui.layout(children: [
22
+ tui.paragraph(text: model.current_directory),
23
+ tui.list(
24
+ items:,
25
+ selected_index: model.selected_index,
26
+ highlight_style: tui.style(modifiers: [:reversed])
27
+ ),
28
+ ])
29
+ }
30
+
31
+ Update = -> (message, model) {
32
+ if message.move_down?
33
+ new_index = (model.selected_index + 1) % model.entries.length
34
+ model.with(selected_index: new_index)
35
+ elsif message.move_up?
36
+ new_index = (model.selected_index - 1) % model.entries.length
37
+ model.with(selected_index: new_index)
38
+ elsif message.jump_to_first?
39
+ model.with(selected_index: 0)
40
+ elsif message.jump_to_last?
41
+ model.with(selected_index: model.entries.length - 1)
42
+ elsif message.enter?
43
+ selected_entry = model.entries[model.selected_index]
44
+ if selected_entry.directory?
45
+ new_dir = File.join(model.current_directory, selected_entry.name)
46
+ new_entries = read_entries(new_dir)
47
+ model.with(current_directory: new_dir, entries: new_entries, selected_index: 0)
48
+ else
49
+ nil
50
+ end
51
+ elsif message.go_back?
52
+ parent_dir = File.dirname(model.current_directory)
53
+ new_entries = read_entries(parent_dir)
54
+ model.with(current_directory: parent_dir, entries: new_entries, selected_index: 0)
55
+ elsif message.go_home?
56
+ home_dir = Dir.home
57
+ new_entries = read_entries(home_dir)
58
+ model.with(current_directory: home_dir, entries: new_entries, selected_index: 0)
59
+ elsif message.go_root?
60
+ root = File.expand_path("/")
61
+ new_entries = read_entries(root)
62
+ model.with(current_directory: root, entries: new_entries, selected_index: 0)
63
+ elsif message.refresh?
64
+ new_entries = read_entries(model.current_directory)
65
+ model.with(entries: new_entries)
66
+ end
67
+ }
68
+
69
+ ReadEntries = -> (path) {
70
+ Dir.children(path).map { |name|
71
+ full_path = File.join(path, name)
72
+ Entry.new(name:, directory?: File.directory?(full_path))
73
+ }.sort_by { |e| [e.directory? ? 0 : 1, e.name.downcase] }
74
+ }
75
+
76
+ Init = -> {
77
+ current_directory = Dir.pwd
78
+ entries = ReadEntries.call(current_directory)
79
+ Ractor.make_shareable Model.new(current_directory, entries, 0)
80
+ }
81
+
82
+ private_class_method def self.read_entries(path)
83
+ ReadEntries.call(path)
84
+ end
85
+ end
86
+
87
+ # The main FileBrowser delegates to the FileList fragment via Router.
88
+ module FileBrowser
89
+ include Rooibos::Router
90
+
91
+ Command = Rooibos::Command
92
+
93
+ # Parent model wraps child fragment
94
+ Model = Data.define(:file_list)
95
+
96
+ route :file_list, to: FileList
97
+
98
+ action go_back: FileList, keys: %i[backspace left h]
99
+ action go_home: FileList, key: :~
100
+ action go_root: FileList, key: :/
101
+ action refresh: FileList, key: :R
102
+ action enter: FileList, keys: %i[enter right l]
103
+ action move_down: FileList, keys: %i[down j]
104
+ action move_up: FileList, keys: %i[up k]
105
+ action jump_to_first: FileList, keys: %i[home g]
106
+ action jump_to_last: FileList, keys: %i[end G]
107
+ action -> { Command.exit }, keys: %i[ctrl_c q]
108
+
109
+ View = -> (model, tui) {
110
+ FileList::View.call(model.file_list, tui)
111
+ }
112
+
113
+ Update = from_router
114
+
115
+ Init = -> {
116
+ file_list = FileList::Init.()
117
+ Ractor.make_shareable Model.new(file_list:)
118
+ }
119
+ end
120
+ end
121
+
122
+ if __FILE__ == $0
123
+ Rooibos.run(Tutorial06::FileBrowser)
124
+ end
@@ -0,0 +1,51 @@
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
+ # Message synthesized by Router when keymap uses symbol + route.
11
+ #
12
+ # When a keymap entry uses a symbol instead of a handler, and specifies
13
+ # a route, the Router creates this message and dispatches it to the
14
+ # child fragment's UPDATE.
15
+ #
16
+ # === Example
17
+ #
18
+ # # In parent keymap:
19
+ # key :down, :move_down, route: :file_list
20
+ #
21
+ # # Router synthesizes:
22
+ # Routed.new(envelope: :move_down, event: key_event)
23
+ #
24
+ # # Child UPDATE matches:
25
+ # in { type: :routed, envelope: :move_down }
26
+ # handle_move_down(model)
27
+ Routed = Data.define(:envelope, :event) do
28
+ include Predicates
29
+
30
+ def deconstruct_keys(_keys)
31
+ { type: :routed, envelope:, event: }
32
+ end
33
+
34
+ def routed?
35
+ true
36
+ end
37
+
38
+ def respond_to_missing?(method_name, include_private = false)
39
+ method_name.end_with?("?") || super
40
+ end
41
+
42
+ def method_missing(method_name, *args, &block)
43
+ if method_name.end_with?("?")
44
+ method_name.to_s.chomp("?") == envelope.to_s
45
+ else
46
+ super
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -116,3 +116,4 @@ require_relative "message/all"
116
116
  require_relative "message/batch"
117
117
  require_relative "message/error"
118
118
  require_relative "message/canceled"
119
+ require_relative "message/routed"
@@ -65,12 +65,13 @@ module Rooibos
65
65
 
66
66
  # Class methods added when Router is included.
67
67
  module ClassMethods
68
- # Declares a route to a child.
68
+ # Declares a route to a child fragment.
69
69
  #
70
- # [prefix] Symbol or String identifying the route (normalized via +.to_s.to_sym+).
71
- # [to] The child module (must have UPDATE and INITIAL constants).
72
- def route(prefix, to:)
73
- routes[prefix.to_s.to_sym] = to
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
74
75
  end
75
76
 
76
77
  # Returns the registered routes hash.
@@ -84,17 +85,79 @@ module Rooibos
84
85
  # This avoids duplicating logic for keys and mouse events that do
85
86
  # the same thing.
86
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
+ #
87
92
  # [name] Symbol or String identifying the action (normalized via +.to_s.to_sym+).
88
- # [handler] Callable that returns a command or message.
89
- def action(name, handler)
90
- actions[name.to_s.to_sym] = handler
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
91
139
  end
92
140
 
93
- # Returns the registered actions hash.
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
149
+ end
150
+
151
+ # Returns the registered handler actions hash.
94
152
  def actions
95
153
  @actions ||= {}
96
154
  end
97
155
 
156
+ # Returns the registered routed actions hash.
157
+ def routed_actions
158
+ @routed_actions ||= {}
159
+ end
160
+
98
161
  # Declares key handlers in a block.
99
162
  #
100
163
  # === Example
@@ -150,6 +213,7 @@ module Rooibos
150
213
  RouterUpdate.new(
151
214
  routes:,
152
215
  actions:,
216
+ routed_actions:,
153
217
  key_handlers:,
154
218
  scroll_handlers:,
155
219
  click_handler:
@@ -159,9 +223,10 @@ module Rooibos
159
223
 
160
224
  # Internal UPDATE callable with proper typing.
161
225
  class RouterUpdate # :nodoc:
162
- def initialize(routes:, actions:, key_handlers:, scroll_handlers:, click_handler:)
226
+ def initialize(routes:, actions:, routed_actions:, key_handlers:, scroll_handlers:, click_handler:)
163
227
  @routes = routes
164
228
  @actions = actions
229
+ @routed_actions = routed_actions
165
230
  @key_handlers = key_handlers
166
231
  @scroll_handlers = scroll_handlers
167
232
  @click_handler = click_handler
@@ -195,6 +260,24 @@ module Rooibos
195
260
  if handler.nil? && config.action
196
261
  handler = @actions[config.action]
197
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
+
198
281
  next unless handler
199
282
 
200
283
  command = handler.call
@@ -260,18 +343,53 @@ module Rooibos
260
343
 
261
344
  # Registers a key handler.
262
345
  #
263
- # [key_name] String or Symbol for the key (normalized via +.to_s+).
264
- # [handler_or_action] Callable or Symbol (action name).
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).
265
355
  # [route] Optional route prefix for the command result.
266
356
  # [when/if/only/guard] Guard that runs if truthy (aliases).
267
357
  # [unless/except/skip] Guard that runs if falsy (negative aliases).
268
- def key(key_name, handler_or_action, route: nil, when: nil, if: nil, only: nil, guard: nil, unless: nil, except: nil, skip: nil)
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]
269
361
  handler = nil
270
- action = nil
271
- if handler_or_action.is_a?(Symbol)
272
- action = handler_or_action
273
- else
274
- handler = handler_or_action
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
275
393
  end
276
394
 
277
395
  guards = @guard_stack.dup
@@ -293,14 +411,24 @@ module Rooibos
293
411
  -> (model) { guards.all? { |g| g.call(model) } }
294
412
  end
295
413
 
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
418
+ end
419
+
420
+ private def register_key_handler(key_name, handler, action_name, route, guard)
296
421
  @handlers[key_name.to_s] = KeyHandlerConfig.new(
297
422
  handler:,
298
- action:,
423
+ action: action_name,
299
424
  route:,
300
- guard: combined_guard
425
+ guard:
301
426
  )
302
427
  end
303
428
 
429
+ # Alias for key (reads better with multiple keys)
430
+ alias_method :keys, :key
431
+
304
432
  # Applies a guard to all keys in the block.
305
433
  #
306
434
  # [when/if/only/guard] Guard that runs if truthy.
@@ -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.6.1"
11
+ VERSION = "0.6.2"
12
12
  end
@@ -181,5 +181,22 @@ module Rooibos
181
181
  def open?: () -> bool
182
182
  def deconstruct_keys: (Array[Symbol]? keys) -> Hash[Symbol, untyped]
183
183
  end
184
+
185
+ # Routed message from parent Router to child fragment.
186
+ class Routed < Data
187
+ include Predicates
188
+
189
+ attr_reader envelope: Symbol
190
+ attr_reader event: RatatuiRuby::Event?
191
+
192
+ def self.new: (envelope: Symbol, event: RatatuiRuby::Event?) -> instance
193
+
194
+ def routed?: () -> bool
195
+ def deconstruct_keys: (Array[Symbol]? keys) -> { type: Symbol, envelope: Symbol, event: RatatuiRuby::Event? }
196
+
197
+ # Predicate methods via method_missing (e.g., move_down?, go_back?)
198
+ def respond_to_missing?: (Symbol name, ?bool include_private) -> bool
199
+ def method_missing: (Symbol name, *untyped args, **untyped kwargs) ?{ () -> untyped } -> bool
200
+ end
184
201
  end
185
202
  end
@@ -48,18 +48,31 @@ module Rooibos
48
48
  module ClassMethods : Module
49
49
  @routes: Hash[Symbol, Module]
50
50
  @actions: Hash[Symbol, ^() -> Command::execution?]
51
- @key_handlers: Hash[String, KeyHandlerConfig]
51
+ @routed_actions: Hash[Symbol, Module]
52
+ @key_handlers: Hash[Symbol, KeyHandlerConfig]
52
53
  @scroll_handlers: Hash[Symbol, ScrollHandlerConfig]
53
54
  @click_handler: ClickHandlerConfig?
54
55
 
55
- def route: (Symbol | String prefix, to: Module) -> void
56
+ def route: (Symbol | String fragment_model_instance_attr, to: Module) -> void
56
57
  def routes: () -> Hash[Symbol, Module]
57
58
 
58
- def action: (Symbol | String name, ^() -> Command::execution? handler) -> void
59
+ # Action: multiple syntax forms supported
60
+ def action: (
61
+ ?(Symbol | String | ^() -> Command::execution?) name,
62
+ ?(^() -> Command::execution? | Module) value,
63
+ ?keymap: (Symbol | Array[Symbol])?,
64
+ ?key: (Symbol | Array[Symbol])?,
65
+ ?keys: (Symbol | Array[Symbol])?,
66
+ ?mousemap: (Symbol | Array[Symbol])?,
67
+ **(^() -> Command::execution? | Module) kwargs
68
+ ) -> void
59
69
  def actions: () -> Hash[Symbol, ^() -> Command::execution?]
70
+ def routed_actions: () -> Hash[Symbol, Module]
71
+
72
+ private def register_action: (Symbol name, (^() -> Command::execution? | Module) value) -> void
60
73
 
61
74
  def keymap: () { (KeymapBuilder) [self: KeymapBuilder] -> void } -> void
62
- def key_handlers: () -> Hash[String, KeyHandlerConfig]
75
+ def key_handlers: () -> Hash[Symbol, KeyHandlerConfig]
63
76
 
64
77
  def mousemap: () { (MousemapBuilder) [self: MousemapBuilder] -> void } -> void
65
78
  def scroll_handlers: () -> Hash[Symbol, ScrollHandlerConfig]
@@ -76,14 +89,16 @@ module Rooibos
76
89
  class RouterUpdate
77
90
  @routes: Hash[Symbol, Module]
78
91
  @actions: Hash[Symbol, ^() -> Command::execution?]
79
- @key_handlers: Hash[String, KeyHandlerConfig]
92
+ @routed_actions: Hash[Symbol, Module]
93
+ @key_handlers: Hash[Symbol, KeyHandlerConfig]
80
94
  @scroll_handlers: Hash[Symbol, ScrollHandlerConfig]
81
95
  @click_handler: ClickHandlerConfig?
82
96
 
83
97
  def initialize: (
84
98
  routes: Hash[Symbol, Module],
85
99
  actions: Hash[Symbol, ^() -> Command::execution?],
86
- key_handlers: Hash[String, KeyHandlerConfig],
100
+ routed_actions: Hash[Symbol, Module],
101
+ key_handlers: Hash[Symbol, KeyHandlerConfig],
87
102
  scroll_handlers: Hash[Symbol, ScrollHandlerConfig],
88
103
  click_handler: ClickHandlerConfig?
89
104
  ) -> void
@@ -104,9 +119,10 @@ module Rooibos
104
119
 
105
120
  def initialize: () -> void
106
121
 
122
+ # Variadic key binding: accepts multiple keys, optional action kwarg, hash bindings
107
123
  def key: (
108
- String | Symbol key_name,
109
- (^() -> Command::execution?) | Symbol handler_or_action,
124
+ *(String | Symbol | ^() -> Command::execution? | Hash[Symbol, (^() -> Command::execution?) | Symbol]) args,
125
+ ?action: Symbol?,
110
126
  ?route: Symbol?,
111
127
  ?when: (^(_DataModel) -> bool)?,
112
128
  ?if: (^(_DataModel) -> bool)?,
@@ -114,9 +130,13 @@ module Rooibos
114
130
  ?guard: (^(_DataModel) -> bool)?,
115
131
  ?unless: (^(_DataModel) -> bool)?,
116
132
  ?except: (^(_DataModel) -> bool)?,
117
- ?skip: (^(_DataModel) -> bool)?
133
+ ?skip: (^(_DataModel) -> bool)?,
134
+ **(^() -> Command::execution? | Symbol) bindings
118
135
  ) -> void
119
136
 
137
+ # Alias for key (reads better with multiple keys)
138
+ alias keys key
139
+
120
140
  def only: (
121
141
  ?when: (^(_DataModel) -> bool)?,
122
142
  ?if: (^(_DataModel) -> bool)?,
@@ -134,6 +154,7 @@ module Rooibos
134
154
  private
135
155
 
136
156
  def with_guard: ((^(_DataModel) -> bool)?) { () -> void } -> void
157
+ def register_key_handler: ((Symbol | String) key_name, (^() -> Command::execution? | Symbol)? handler, (Symbol | String | ^() -> Command::execution?)? action_name, Symbol? route, (^(_DataModel) -> bool)? guard) -> void
137
158
  end
138
159
 
139
160
  # Builder for mousemap DSL.
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.6.1
4
+ version: 0.6.2
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.2'
18
+ version: '1.3'
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.2'
25
+ version: '1.3'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: concurrent-ruby
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -278,6 +278,7 @@ files:
278
278
  - doc/contributors/documentation_stub_audit.md
279
279
  - doc/contributors/documentation_style.md
280
280
  - doc/contributors/e2e_pty.md
281
+ - doc/contributors/maybe_stateful_router.md
281
282
  - doc/contributors/specs/earliest_tutorial_steps_per_story.md
282
283
  - doc/contributors/specs/file_browser.md
283
284
  - doc/contributors/specs/file_browser_stories.md
@@ -360,7 +361,6 @@ files:
360
361
  - doc/tutorial/29_configuration.md
361
362
  - doc/tutorial/30_going_further.md
362
363
  - doc/tutorial/index.md
363
- - examples/app_file_browser/app.rb
364
364
  - examples/app_fractal_dashboard/README.md
365
365
  - examples/app_fractal_dashboard/app.rb
366
366
  - examples/app_fractal_dashboard/dashboard/base.rb
@@ -376,6 +376,10 @@ files:
376
376
  - examples/app_fractal_dashboard/fragments/stats_panel.rb
377
377
  - examples/app_fractal_dashboard/fragments/system_info.rb
378
378
  - examples/app_fractal_dashboard/fragments/uptime.rb
379
+ - examples/tutorial/01/app.rb
380
+ - examples/tutorial/02/app.rb
381
+ - examples/tutorial/03/app.rb
382
+ - examples/tutorial/06_safe_refactoring/app.rb
379
383
  - examples/verify_readme_usage/README.md
380
384
  - examples/verify_readme_usage/app.rb
381
385
  - examples/verify_website_first_app/app.rb
@@ -405,6 +409,7 @@ files:
405
409
  - lib/rooibos/message/error.rb
406
410
  - lib/rooibos/message/http_response.rb
407
411
  - lib/rooibos/message/open.rb
412
+ - lib/rooibos/message/routed.rb
408
413
  - lib/rooibos/message/system/batch.rb
409
414
  - lib/rooibos/message/system/stream.rb
410
415
  - lib/rooibos/message/timer.rb