Aria 2.0.0
C++23 MVVM framework (C++20 minimum) — reactive, coroutine-first, ABI-layered
Loading...
Searching...
No Matches
navigation.hpp
Go to the documentation of this file.
1#pragma once
2
3// Navigator -- UI-toolkit-agnostic navigation stack for ViewModels.
4//
5// The protocol is built on four production-grade primitives that
6// every modern MVVM router needs:
7//
8// N-1 (Presentation kind). Each entry carries `Presentation::Push`
9// (default) or `Presentation::Modal`. `pop_to_root()` bottoms
10// out at the deepest non-modal entry; modals are torn down
11// individually via `dismiss_modal()` / `pop()`.
12//
13// N-2 (Result passing). `push_for_result<R>(...)` returns a
14// `std::shared_future<std::optional<R>>` (any-thread observable)
15// that resolves when the child entry calls
16// `Navigator::dismiss_with(result)` or pops without a value
17// (in which case the future resolves to `std::nullopt`). Mirrors
18// Android `registerForActivityResult` / iOS delegate-back.
19//
20// N-3 (Per-entry cancellation). Each entry owns a
21// `CancellationSource`; popping it (or the Navigator dropping
22// it for any reason) fires the source. ViewModels rooted at
23// this entry can `co_await` against `entry.token()` to abort
24// work the moment the user navigates away. Independent from
25// ViewModelScope -- works even if the VM itself is kept alive
26// elsewhere (e.g. cached for back-stack restoration).
27//
28// N-4 (Deep-link routing). `register_route("path/{id}", factory)`
29// registers a path -> factory mapping. `route("path/42")`
30// parses the URI, instantiates the factory with the captured
31// params, and invokes `push` / `replace_root` / `clear+push`
32// depending on a `RouteOptions` flag. Out-of-scope for this
33// layer: query strings + fragments; we keep it deliberately
34// tight.
35//
36// The legacy push / pop / replace / clear / pop_to_root surface is
37// preserved unchanged. New code should prefer the N-N primitives.
38
41#include "aria/property.hpp"
42
43#include <any>
44#include <cstddef>
45#include <future>
46#include <memory>
47#include <optional>
48#include <stdexcept>
49#include <string>
50#include <string_view>
51#include <type_traits>
52#include <unordered_map>
53#include <utility>
54#include <vector>
55
56namespace aria::binding {
57
64enum class Presentation : unsigned char {
65 Push = 0,
66 Modal = 1,
67};
68
77
78namespace detail {
79
84struct NavEntry {
85 std::shared_ptr<ViewModel> vm;
88 std::string route_path; // optional, set by deep-link
89
90 // Type-erased result delivery. `result_setter` is non-null iff
91 // the entry was created by push_for_result<R>; the setter knows
92 // how to turn an `std::any` into a typed result.
93 std::function<void(std::optional<std::any>)> result_setter;
94
95 NavEntry() = default;
96 NavEntry(const NavEntry&) = delete;
97 NavEntry& operator=(const NavEntry&) = delete;
98 NavEntry(NavEntry&&) noexcept = default;
99 NavEntry& operator=(NavEntry&&) noexcept = default;
100};
101
102} // namespace detail
103
105public:
110
111 Navigator() = default;
113
114 Navigator(const Navigator&) = delete;
115 Navigator& operator=(const Navigator&) = delete;
116
117 // ── Legacy ergonomics: typed push / replace returning the VM ──────
118
119 template<typename VM, typename... Args>
120 requires std::derived_from<VM, ViewModel>
121 std::shared_ptr<VM> push(Args&&... args) {
122 auto vm = std::make_shared<VM>(std::forward<Args>(args)...);
123 push(vm);
124 return vm;
125 }
126
127 template<typename VM, typename... Args>
128 requires std::derived_from<VM, ViewModel>
129 std::shared_ptr<VM> replace(Args&&... args) {
130 auto vm = std::make_shared<VM>(std::forward<Args>(args)...);
131 replace(vm);
132 return vm;
133 }
134
137 void push(std::shared_ptr<ViewModel> vm,
139 if (!vm) throw std::invalid_argument("Navigator::push: vm is null");
140 deactivate_top_();
141 detail::NavEntry e;
142 e.vm = std::move(vm);
143 e.kind = kind;
144 stack_.push_back(std::move(e));
145 stack_.back().vm->activate();
146 publish_();
147 }
148
152 void replace(std::shared_ptr<ViewModel> vm) {
153 if (!vm) throw std::invalid_argument("Navigator::replace: vm is null");
154 if (!stack_.empty()) {
155 tear_down_top_(/*result=*/std::nullopt);
156 }
157 detail::NavEntry e;
158 e.vm = std::move(vm);
159 stack_.push_back(std::move(e));
160 stack_.back().vm->activate();
161 publish_();
162 }
163
168 template<typename R, typename VM, typename... Args>
169 requires std::derived_from<VM, ViewModel>
170 [[nodiscard]] std::shared_future<std::optional<R>>
171 push_for_result(Args&&... args) {
172 auto vm = std::make_shared<VM>(std::forward<Args>(args)...);
173 return push_for_result<R>(std::shared_ptr<ViewModel>{vm});
174 }
175
176 template<typename R>
177 [[nodiscard]] std::shared_future<std::optional<R>>
178 push_for_result(std::shared_ptr<ViewModel> vm,
180 if (!vm) throw std::invalid_argument(
181 "Navigator::push_for_result: vm is null");
182
183 auto promise = std::make_shared<std::promise<std::optional<R>>>();
184 std::shared_future<std::optional<R>> fut =
185 promise->get_future().share();
186
187 deactivate_top_();
188 detail::NavEntry e;
189 e.vm = std::move(vm);
190 e.kind = kind;
191 // Capture the promise into a type-erased setter. `payload`
192 // is `std::nullopt` on cancel/pop-without-result, or holds
193 // an `std::any{R}` on dismiss_with<R>. Strong-capture the
194 // promise: nothing else owns it, and there is no cycle
195 // (promise does not reach back to the NavEntry).
196 auto p = promise;
197 e.result_setter = [p](std::optional<std::any> payload) {
198 try {
199 if (!payload.has_value()) {
200 p->set_value(std::nullopt);
201 } else {
202 p->set_value(std::any_cast<R>(*payload));
203 }
204 } catch (const std::bad_any_cast&) {
205 // Type mismatch -- callee called dismiss_with<X> for
206 // some X != R. Resolve to nullopt to keep N-2 honest;
207 // the trace sink (D-N) will record the mismatch when
208 // diagnostic categories grow to cover Navigator.
209 try { p->set_value(std::nullopt); } catch (...) {}
210 } catch (...) {
211 // Promise already satisfied (e.g. double dismiss);
212 // benign for callers.
213 }
214 };
215 stack_.push_back(std::move(e));
216 stack_.back().vm->activate();
217 publish_();
218 return fut;
219 }
220
226 template<typename R>
227 bool dismiss_with(R result) {
228 if (stack_.empty()) return false;
229 std::optional<std::any> payload{std::any{std::move(result)}};
230 tear_down_top_(std::move(payload));
231 if (!stack_.empty()) stack_.back().vm->activate();
232 publish_();
233 return true;
234 }
235
238 bool pop() {
239 if (stack_.empty()) return false;
240 tear_down_top_(std::nullopt);
241 if (!stack_.empty()) stack_.back().vm->activate();
242 publish_();
243 return true;
244 }
245
249 if (stack_.empty() || stack_.back().kind != Presentation::Modal) {
250 return false;
251 }
252 return pop();
253 }
254
257 void pop_to_root() {
258 while (stack_.size() > 1) {
259 tear_down_top_(std::nullopt);
260 }
261 if (!stack_.empty()) stack_.back().vm->activate();
262 publish_();
263 }
264
265 void clear() {
266 while (!stack_.empty()) {
267 tear_down_top_(std::nullopt);
268 }
269 publish_();
270 }
271
272 [[nodiscard]] bool empty() const noexcept { return stack_.empty(); }
273 [[nodiscard]] std::size_t size() const noexcept { return stack_.size(); }
274
275 [[nodiscard]] std::shared_ptr<ViewModel> at(std::size_t i) const {
276 return stack_.at(i).vm;
277 }
278
283 if (stack_.empty()) {
284 throw std::out_of_range("Navigator::top_token: stack is empty");
285 }
286 return stack_.back().cancel.token();
287 }
288
290 [[nodiscard]] Presentation top_presentation() const {
291 if (stack_.empty()) {
292 throw std::out_of_range(
293 "Navigator::top_presentation: stack is empty");
294 }
295 return stack_.back().kind;
296 }
297
298 // ── N-4: deep-link routing ─────────────────────────────────────────
299
301 using RouteParams = std::unordered_map<std::string, std::string>;
302 using RouteFactory = std::function<std::shared_ptr<ViewModel>(const RouteParams&)>;
303
307 void register_route(std::string pattern, RouteFactory factory) {
308 if (!factory) {
309 throw std::invalid_argument(
310 "Navigator::register_route: factory is null");
311 }
312 routes_.emplace(std::move(pattern), std::move(factory));
313 }
314
318 bool route(std::string_view path, RouteOptions opts = {}) {
319 for (const auto& [pattern, factory] : routes_) {
320 RouteParams params;
321 if (match_route_(pattern, path, params)) {
322 auto vm = factory(params);
323 if (!vm) return false;
324 if (opts.clear_stack) {
325 clear();
326 }
327 push(std::move(vm), opts.presentation);
328 if (!stack_.empty()) {
329 stack_.back().route_path = std::string(path);
330 }
331 return true;
332 }
333 }
334 return false;
335 }
336
337private:
338 void publish_() {
339 current = stack_.empty() ? nullptr : stack_.back().vm;
340 depth = stack_.size();
341 }
342
343 void deactivate_top_() {
344 if (!stack_.empty()) {
345 auto vm = stack_.back().vm;
346 vm->deactivate();
347 }
348 }
349
354 void tear_down_top_(std::optional<std::any> result_payload) {
355 if (stack_.empty()) return;
356 // Remove storage before running hooks/cancellation. A callback may
357 // navigate again, reallocating the stack or installing a new top.
358 auto e = std::move(stack_.back());
359 stack_.pop_back();
360 try { e.vm->deactivate(); }
361 catch (...) { ::aria::report_callback_failure("navigation.deactivate", std::current_exception()); }
362 if (e.result_setter) {
363 try { e.result_setter(std::move(result_payload)); } catch (...) {}
364 }
365 try { e.cancel.cancel(); } catch (...) {}
366 }
367
371 static bool match_route_(std::string_view pattern,
372 std::string_view path,
373 RouteParams& out) {
374 auto split = [](std::string_view s) {
375 std::vector<std::string_view> segs;
376 std::size_t start = 0;
377 for (std::size_t i = 0; i <= s.size(); ++i) {
378 if (i == s.size() || s[i] == '/') {
379 if (i > start) segs.push_back(s.substr(start, i - start));
380 start = i + 1;
381 }
382 }
383 return segs;
384 };
385 auto pat_segs = split(pattern);
386 auto path_segs = split(path);
387 if (pat_segs.size() != path_segs.size()) return false;
388 for (std::size_t i = 0; i < pat_segs.size(); ++i) {
389 const auto& ps = pat_segs[i];
390 if (ps.size() >= 2 && ps.front() == '{' && ps.back() == '}') {
391 if (path_segs[i].empty()) return false;
392 out.emplace(std::string(ps.substr(1, ps.size() - 2)),
393 std::string(path_segs[i]));
394 } else if (ps != path_segs[i]) {
395 return false;
396 }
397 }
398 return true;
399 }
400
401 std::vector<detail::NavEntry> stack_;
402 std::unordered_map<std::string, RouteFactory> routes_;
403};
404
405} // namespace aria::binding
Definition cancellation.hpp:218
Definition cancellation.hpp:182
std::shared_future< std::optional< R > > push_for_result(Args &&... args)
N-2: push a child entry that will eventually return a typed result R to the caller.
Definition navigation.hpp:171
void push(std::shared_ptr< ViewModel > vm, Presentation kind=Presentation::Push)
Push (Presentation::Push).
Definition navigation.hpp:137
std::size_t size() const noexcept
Definition navigation.hpp:273
Property< std::size_t > depth
Total stack depth (modals included).
Definition navigation.hpp:109
void clear()
Definition navigation.hpp:265
bool pop()
Pop the topmost entry.
Definition navigation.hpp:238
std::function< std::shared_ptr< ViewModel >(const RouteParams &)> RouteFactory
Definition navigation.hpp:302
bool dismiss_modal()
N-1: dismiss the topmost MODAL entry.
Definition navigation.hpp:248
bool dismiss_with(R result)
N-2: dismiss the topmost entry with a typed result.
Definition navigation.hpp:227
void pop_to_root()
Pop until only one Push entry remains.
Definition navigation.hpp:257
std::unordered_map< std::string, std::string > RouteParams
Param map captured from a route pattern (e.g. {id}).
Definition navigation.hpp:301
std::shared_ptr< ViewModel > at(std::size_t i) const
Definition navigation.hpp:275
Property< std::shared_ptr< ViewModel > > current
Topmost entry's VM, or nullptr if the stack is empty.
Definition navigation.hpp:107
std::shared_ptr< VM > replace(Args &&... args)
Definition navigation.hpp:129
void replace(std::shared_ptr< ViewModel > vm)
Replace the topmost entry (in place).
Definition navigation.hpp:152
Presentation top_presentation() const
Topmost entry's presentation kind.
Definition navigation.hpp:290
aria::async::CancellationToken top_token() const
Topmost entry's cancellation token.
Definition navigation.hpp:282
~Navigator()
Definition navigation.hpp:112
std::shared_ptr< VM > push(Args &&... args)
Definition navigation.hpp:121
Navigator & operator=(const Navigator &)=delete
void register_route(std::string pattern, RouteFactory factory)
Register a route pattern.
Definition navigation.hpp:307
Navigator(const Navigator &)=delete
bool empty() const noexcept
Definition navigation.hpp:272
bool route(std::string_view path, RouteOptions opts={})
Resolve path against registered patterns.
Definition navigation.hpp:318
std::shared_future< std::optional< R > > push_for_result(std::shared_ptr< ViewModel > vm, Presentation kind=Presentation::Push)
Definition navigation.hpp:178
Definition property.hpp:103
Definition binding_engine.hpp:25
Presentation
How a navigation entry is presented to the user.
Definition navigation.hpp:64
@ Push
Definition navigation.hpp:65
@ Modal
Definition navigation.hpp:66
void report_callback_failure(std::string_view category, std::exception_ptr exception, std::string_view message={}) noexcept
Report a callback failure.
Routing options for Navigator::route(...).
Definition navigation.hpp:70
Presentation presentation
Presentation kind for the deep-linked entry.
Definition navigation.hpp:75
bool clear_stack
If true, replace the entire stack with the deep-linked entry.
Definition navigation.hpp:73