guinea_eframe/lib.rs
1//! guinea on egui: the same router and features, drawn immediately.
2//!
3//! The closest relative among the backends is ratatui, not WinUI: egui is
4//! immediate, so a view is not a tree that is kept and diffed - it is drawing
5//! that happens inside one frame and leaves nothing behind. As there, a view
6//! is a [`Node`]: drawing deferred until someone supplies the [`egui::Ui`] to
7//! draw into, which is what lets a layout decide where its child goes.
8//!
9//! What differs from the terminal is who owns the loop. eframe owns it, the
10//! way the reactor does under WinUI, so [`run`] hands it over and puts the
11//! frame inside `eframe::App::update`. And unlike a terminal, egui sleeps when
12//! nothing happens - so work finished on another thread has to wake it, which
13//! is what the dispatcher's `request_repaint` is for.
14
15mod dispatcher;
16mod nav;
17mod run;
18
19pub use run::{MAIN, run};
20
21use std::any::{Any, TypeId};
22use std::cell::RefCell;
23use std::collections::HashMap;
24
25use guinea_app::feature::{FeatureInitContext, Reaches, Segment};
26use guinea_core::binding::ReducerBinding;
27use guinea_core::scope::Reducer;
28use guinea_router::router::{
29 Mount, NavigateHandle, RouteChain, SegmentEntry, SegmentProps, Ui, single_entry_chain,
30};
31
32/// egui as a [`Ui`].
33pub struct Egui;
34
35impl Ui for Egui {
36 type View<'a> = Node;
37 /// Nothing: an immediate-mode view draws from a snapshot inside the frame
38 /// and holds no reference to state afterwards.
39 type Nodes = ();
40}
41
42/// Drawing that has not happened yet.
43///
44/// `FnOnce` because a node is drawn exactly once per frame - the next frame
45/// mounts fresh ones.
46pub struct Node(Box<dyn FnOnce(&mut egui::Ui)>);
47
48impl Node {
49 pub fn new(draw: impl FnOnce(&mut egui::Ui) + 'static) -> Self {
50 Self(Box::new(draw))
51 }
52
53 /// Draws into `ui`.
54 pub fn draw(self, ui: &mut egui::Ui) {
55 (self.0)(ui)
56 }
57}
58
59/// A leaf of the route tree, and its own state.
60///
61/// The struct that implements this **is** the page's state, as in the WinUI
62/// and iced backends. What differs is that immediate mode has no later: the
63/// frame that sees the click is the frame that answers it, so there is no
64/// message and no `update` - [`Page::render`] takes `&mut self` and writes
65/// what it decided where it decided it.
66///
67/// What belongs here is what only this page has an opinion about: which row
68/// is picked, which tab is open, what is typed in a filter. What crosses the
69/// segment - what a domain owns, what another page reads - is a reducer, and
70/// reaches this page through [`PageCx::state`].
71pub trait Page: Default + Sized + 'static {
72 /// When `true`, the router keeps this page's reducer states in memory
73 /// while the page is not mounted.
74 const CACHE_STATE_IN_MEMORY: bool = false;
75
76 /// Where `impl Page` was written. `#[segment]` fills it in; an impl
77 /// without it loses only the source link.
78 const DECLARED: Option<guinea_core::actor::shape::Declared> = None;
79
80 /// What this page captured from the route, named by `routes!`. `()` for a
81 /// page that captures nothing.
82 ///
83 /// `PartialEq` because the router's one question about a capture is
84 /// whether it is still the same one - which decides what reinstalls and
85 /// which cached state may come back.
86 type Params: PartialEq + 'static;
87
88 /// What this segment installs, and `()` when it installs nothing.
89 ///
90 /// The list is not written beside the body - it *is* the body's
91 /// obligation: `install` returns it, so a feature that stops being
92 /// installed stops type-checking. Which is also why `install` has no
93 /// default any more.
94 ///
95 /// What is returned is owned by the segment's scope, which is what gives a
96 /// feature its own lifetime.
97 type Installs: 'static;
98
99 fn install(ctx: &FeatureInitContext, params: &Self::Params) -> anyhow::Result<Self::Installs>;
100
101 /// The state it starts with, when `Default` is not it.
102 ///
103 /// A constructor, not an effect: it runs once per mount, beside
104 /// [`install`](Self::install), and anything that has to reach a feature
105 /// belongs there instead.
106 fn init(_ctx: &FeatureInitContext, _params: &Self::Params) -> Self {
107 Self::default()
108 }
109
110 /// Draws the page, and changes it. Runs again for every frame, so this is
111 /// the drawing itself and not a description of it.
112 fn render(&mut self, cx: &mut PageCx<'_, Self>);
113}
114
115/// A branch: draws its own chrome and decides where its child goes. Its own
116/// state, the same way a [`Page`] is.
117pub trait Layout: Default + Sized + 'static {
118 /// Where `impl Layout` was written; see [`Page::DECLARED`].
119 const DECLARED: Option<guinea_core::actor::shape::Declared> = None;
120
121 /// What every page under this layout carries, derived by `routes!` as the
122 /// intersection of their parameters. A layout declares nothing; it is
123 /// handed what all of its children were reached with.
124 type Params: PartialEq + 'static;
125
126 /// What this segment installs, and `()` when it installs nothing.
127 ///
128 /// The list is not written beside the body - it *is* the body's
129 /// obligation: `install` returns it, so a feature that stops being
130 /// installed stops type-checking. Which is also why `install` has no
131 /// default any more.
132 ///
133 /// What is returned is owned by the segment's scope, which is what gives a
134 /// feature its own lifetime.
135 type Installs: 'static;
136
137 fn install(ctx: &FeatureInitContext, params: &Self::Params) -> anyhow::Result<Self::Installs>;
138
139 /// See [`Page::init`].
140 fn init(_ctx: &FeatureInitContext, _params: &Self::Params) -> Self {
141 Self::default()
142 }
143
144 fn render(&mut self, cx: &mut LayoutCx<'_, Self>);
145}
146
147pub const fn segment_entry<P: Page>() -> SegmentEntry<Egui> {
148 SegmentEntry::new::<P>(
149 install_page::<P>,
150 guinea_router::router::same_params::<P::Params>,
151 &const { MountPage::<P>(std::marker::PhantomData) },
152 P::CACHE_STATE_IN_MEMORY,
153 )
154 .written(P::DECLARED)
155}
156
157pub const fn layout_entry<L: Layout>() -> SegmentEntry<Egui> {
158 SegmentEntry::new::<L>(
159 install_layout::<L>,
160 guinea_router::router::same_params::<L::Params>,
161 &const { MountLayout::<L>(std::marker::PhantomData) },
162 false,
163 )
164 .written(L::DECLARED)
165}
166
167fn install_page<P: Page>(
168 ctx: &FeatureInitContext,
169 params: &dyn std::any::Any,
170) -> anyhow::Result<()> {
171 let params = guinea_router::router::narrow::<P::Params, P>(params)?;
172 own(ctx, P::install(ctx, params)?);
173 keep(ctx, P::init(ctx, params));
174 Ok(())
175}
176
177/// Hands what a segment installed to its scope - a feature's lifetime is the
178/// segment's, and dropping this here would end it at the end of `install`.
179fn own<T: 'static>(ctx: &FeatureInitContext, installed: T) {
180 ctx.scope.own(guinea_core::scope::DropGuard(installed));
181}
182
183fn install_layout<L: Layout>(
184 ctx: &FeatureInitContext,
185 params: &dyn std::any::Any,
186) -> anyhow::Result<()> {
187 let params = guinea_router::router::narrow::<L::Params, L>(params)?;
188 own(ctx, L::install(ctx, params)?);
189 keep(ctx, L::init(ctx, params));
190 Ok(())
191}
192
193thread_local! {
194 /// Every mounted segment's own state, by the scope it is mounted in and
195 /// what it is.
196 ///
197 /// A segment's state has to outlive the frame and die with the mount,
198 /// and egui gives it nowhere to live: a [`Node`] is drawn once and
199 /// dropped. So the scope holds it - through this, because a scope keeps
200 /// reducers and teardowns, not nodes.
201 static MOUNTED: RefCell<HashMap<(usize, TypeId), Option<Box<dyn Any>>>> =
202 RefCell::new(HashMap::new());
203}
204
205/// Holds `node` for as long as the segment being installed is mounted.
206fn keep<S: 'static>(ctx: &FeatureInitContext, node: S) {
207 let at = (ctx.scope.key(), TypeId::of::<S>());
208
209 MOUNTED.with(|mounted| mounted.borrow_mut().insert(at, Some(Box::new(node))));
210 ctx.scope.own(Forget(at));
211}
212
213/// Drops a segment's state when its scope goes.
214struct Forget((usize, TypeId));
215
216impl guinea_core::scope::Teardown for Forget {
217 fn teardown(self) {
218 // `try_with`: a scope can outlive the thread local at thread
219 // teardown, and this runs from a `Drop`.
220 let _ = MOUNTED.try_with(|mounted| mounted.borrow_mut().remove(&self.0));
221 }
222}
223
224/// Draws with the segment's own state.
225///
226/// Taken out for the frame and put back after it, rather than borrowed
227/// across it: a segment draws its child inside its own drawing, and a page
228/// that navigates while drawing ends its own mount - after which there is
229/// nowhere to put anything back, and the state goes with it.
230///
231/// A segment mounted with no `install` behind it - which a test does, and
232/// nothing else - draws from a default that lasts the frame.
233fn with_mounted<S: Default + 'static, R>(scope: usize, draw: impl FnOnce(&mut S) -> R) -> R {
234 let at = (scope, TypeId::of::<S>());
235
236 let taken = MOUNTED.with(|mounted| mounted.borrow_mut().get_mut(&at).and_then(Option::take));
237 let mut node = taken
238 .and_then(|node| node.downcast::<S>().ok())
239 .map_or_else(S::default, |node| *node);
240
241 let drawn = draw(&mut node);
242
243 MOUNTED.with(|mounted| {
244 if let Some(slot) = mounted.borrow_mut().get_mut(&at) {
245 *slot = Some(Box::new(node));
246 }
247 });
248
249 drawn
250}
251
252/// A zero-sized marker per segment type: what a `const` entry points at to get
253/// its `&'static dyn Mount`.
254pub struct MountPage<P>(pub std::marker::PhantomData<P>);
255pub struct MountLayout<L>(pub std::marker::PhantomData<L>);
256
257impl<P: Page> Mount<Egui> for MountPage<P> {
258 fn view<'a>(&self, props: SegmentProps<Egui>, _nodes: &'a ()) -> Node {
259 let at = props.scopes[props.cursor].key();
260
261 Node::new(move |ui| {
262 let _drawing = guinea_core::devtools::Rendering::of(std::any::type_name::<P>());
263 with_mounted::<P, _>(at, |page| {
264 page.render(&mut PageCx {
265 ui,
266 props,
267 page: std::marker::PhantomData,
268 })
269 })
270 })
271 }
272}
273
274impl<L: Layout> Mount<Egui> for MountLayout<L> {
275 fn view<'a>(&self, props: SegmentProps<Egui>, _nodes: &'a ()) -> Node {
276 let at = props.scopes[props.cursor].key();
277
278 Node::new(move |ui| {
279 let _drawing = guinea_core::devtools::Rendering::of(std::any::type_name::<L>());
280 with_mounted::<L, _>(at, |layout| {
281 layout.render(&mut LayoutCx {
282 ui,
283 props,
284 layout: std::marker::PhantomData,
285 })
286 })
287 })
288 }
289}
290
291/// A one-segment chain, for a page drawn without a route tree.
292pub fn page_chain<P: Page>() -> &'static [SegmentEntry<Egui>] {
293 single_entry_chain(segment_entry::<P>())
294}
295
296/// What a page's drawing is handed.
297///
298/// Carries the page type, not because drawing needs it, but because reading
299/// does: what a segment may read is a fact about where it sits, and this is
300/// where that fact enters the signature.
301pub struct PageCx<'a, P> {
302 ui: &'a mut egui::Ui,
303 props: SegmentProps<Egui>,
304 page: std::marker::PhantomData<fn() -> P>,
305}
306
307impl<P: Segment> PageCx<'_, P> {
308 /// The reducer's state and actions.
309 ///
310 /// No subscription, as in the terminal: egui redraws the whole frame, so
311 /// there is nothing to invalidate - the next pass reads the state again.
312 /// What does need saying is when a frame should happen at all, and that is
313 /// the dispatcher's job.
314 ///
315 /// Which feature answers is settled at build time: this page installed it,
316 /// or a segment above listed it in `Exports`. The `_` is [`Reaches`]'s
317 /// index, which says which of several impls applied - Rust has no partial
318 /// turbofish, so it has to be written.
319 ///
320 /// The state comes shared, not copied: reading it every frame costs a
321 /// count, and a change made mid-frame goes to a copy.
322 pub fn state<R, I>(&self) -> (std::rc::Rc<R>, guinea_core::feature::Dispatch)
323 where
324 R: Reducer,
325 P: Reaches<R, I>,
326 {
327 let binding = self.props.binding::<R>();
328 (binding.get(), binding.dispatch())
329 }
330
331 /// The reducer's binding: its state, and a push straight into it.
332 ///
333 /// For state the UI owns outright - what is picked in a tree, which tab
334 /// is open - claimed with `cx.state::<R>().plain()`. There is no domain
335 /// to ask, so there is no actor to ask it through: a click is the whole
336 /// story.
337 ///
338 /// State a feature drives is not this: pushing into it goes behind the
339 /// back of whatever answers for it. Use [`PageCx::state`] and emit.
340 pub fn binding<R, I>(&self) -> ReducerBinding<R>
341 where
342 R: Reducer,
343 P: Reaches<R, I>,
344 {
345 self.props.binding::<R>()
346 }
347}
348
349impl<P> PageCx<'_, P> {
350 pub fn ui(&mut self) -> &mut egui::Ui {
351 self.ui
352 }
353
354 /// A navigator over the route type the application runs.
355 pub fn navigate<R>(&self) -> NavigateHandle<Egui, R>
356 where
357 R: RouteChain<Egui> + Clone + PartialEq + 'static,
358 {
359 nav::current::<R>()
360 }
361}
362
363/// What a layout's drawing is handed. Same as a page's, plus the child.
364pub struct LayoutCx<'a, L> {
365 ui: &'a mut egui::Ui,
366 props: SegmentProps<Egui>,
367 layout: std::marker::PhantomData<fn() -> L>,
368}
369
370impl<L: Segment> LayoutCx<'_, L> {
371 /// See [`PageCx::state`].
372 pub fn state<R, I>(&self) -> (std::rc::Rc<R>, guinea_core::feature::Dispatch)
373 where
374 R: Reducer,
375 L: Reaches<R, I>,
376 {
377 let binding = self.props.binding::<R>();
378 (binding.get(), binding.dispatch())
379 }
380
381 /// See [`PageCx::binding`].
382 pub fn binding<R, I>(&self) -> ReducerBinding<R>
383 where
384 R: Reducer,
385 L: Reaches<R, I>,
386 {
387 self.props.binding::<R>()
388 }
389}
390
391impl<L> LayoutCx<'_, L> {
392 pub fn ui(&mut self) -> &mut egui::Ui {
393 self.ui
394 }
395
396 /// A navigator over the route type the application runs.
397 pub fn navigate<R>(&self) -> NavigateHandle<Egui, R>
398 where
399 R: RouteChain<Egui> + Clone + PartialEq + 'static,
400 {
401 nav::current::<R>()
402 }
403
404 /// The next segment down the chain, for the layout to draw where it wants.
405 ///
406 /// Handed over rather than drawn here: a layout takes its `ui` from this
407 /// same context, so a method that drew the child would need the context
408 /// twice at once.
409 pub fn outlet(&self) -> Node {
410 self.props.outlet(&())
411 }
412
413 /// Whether the segment directly below is `P`.
414 ///
415 /// What a tab strip needs, and cheaper than it looks: the chain already
416 /// says which page is mounted, so highlighting the current tab needs
417 /// neither the route nor a copy of it in state.
418 pub fn child_is<P: 'static>(&self) -> bool {
419 self.props
420 .chain
421 .get(self.props.cursor + 1)
422 .is_some_and(|entry| (entry.type_id)() == std::any::TypeId::of::<P>())
423 }
424}