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, Reads};
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: guinea_app::feature::Lists;
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: guinea_app::feature::Lists;
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 <P::Installs as guinea_app::feature::Lists>::list,
152 &const { MountPage::<P>(std::marker::PhantomData) },
153 P::CACHE_STATE_IN_MEMORY,
154 )
155 .written(P::DECLARED)
156}
157
158pub const fn layout_entry<L: Layout>() -> SegmentEntry<Egui> {
159 SegmentEntry::new::<L>(
160 install_layout::<L>,
161 guinea_router::router::same_params::<L::Params>,
162 <L::Installs as guinea_app::feature::Lists>::list,
163 &const { MountLayout::<L>(std::marker::PhantomData) },
164 false,
165 )
166 .written(L::DECLARED)
167}
168
169fn install_page<P: Page>(
170 ctx: &FeatureInitContext,
171 params: &dyn std::any::Any,
172) -> anyhow::Result<()> {
173 let params = guinea_router::router::narrow::<P::Params, P>(params)?;
174 own(ctx, P::install(ctx, params)?);
175 keep(ctx, P::init(ctx, params));
176 Ok(())
177}
178
179/// Hands what a segment installed to its scope - a feature's lifetime is the
180/// segment's, and dropping this here would end it at the end of `install`.
181fn own<T: 'static>(ctx: &FeatureInitContext, installed: T) {
182 ctx.scope.own(guinea_core::scope::DropGuard(installed));
183}
184
185fn install_layout<L: Layout>(
186 ctx: &FeatureInitContext,
187 params: &dyn std::any::Any,
188) -> anyhow::Result<()> {
189 let params = guinea_router::router::narrow::<L::Params, L>(params)?;
190 own(ctx, L::install(ctx, params)?);
191 keep(ctx, L::init(ctx, params));
192 Ok(())
193}
194
195thread_local! {
196 /// Every mounted segment's own state, by the scope it is mounted in and
197 /// what it is.
198 ///
199 /// A segment's state has to outlive the frame and die with the mount,
200 /// and egui gives it nowhere to live: a [`Node`] is drawn once and
201 /// dropped. So the scope holds it - through this, because a scope keeps
202 /// reducers and teardowns, not nodes.
203 static MOUNTED: RefCell<HashMap<(usize, TypeId), Option<Box<dyn Any>>>> =
204 RefCell::new(HashMap::new());
205}
206
207/// Holds `node` for as long as the segment being installed is mounted.
208fn keep<S: 'static>(ctx: &FeatureInitContext, node: S) {
209 let at = (ctx.scope.key(), TypeId::of::<S>());
210
211 MOUNTED.with(|mounted| mounted.borrow_mut().insert(at, Some(Box::new(node))));
212 ctx.scope.own(Forget(at));
213}
214
215/// Drops a segment's state when its scope goes.
216struct Forget((usize, TypeId));
217
218impl guinea_core::scope::Teardown for Forget {
219 fn teardown(self) {
220 // `try_with`: a scope can outlive the thread local at thread
221 // teardown, and this runs from a `Drop`.
222 let _ = MOUNTED.try_with(|mounted| mounted.borrow_mut().remove(&self.0));
223 }
224}
225
226/// Draws with the segment's own state.
227///
228/// Taken out for the frame and put back after it, rather than borrowed
229/// across it: a segment draws its child inside its own drawing, and a page
230/// that navigates while drawing ends its own mount - after which there is
231/// nowhere to put anything back, and the state goes with it.
232///
233/// A segment mounted with no `install` behind it - which a test does, and
234/// nothing else - draws from a default that lasts the frame.
235fn with_mounted<S: Default + 'static, R>(scope: usize, draw: impl FnOnce(&mut S) -> R) -> R {
236 let at = (scope, TypeId::of::<S>());
237
238 let taken = MOUNTED.with(|mounted| mounted.borrow_mut().get_mut(&at).and_then(Option::take));
239 let mut node = taken
240 .and_then(|node| node.downcast::<S>().ok())
241 .map_or_else(S::default, |node| *node);
242
243 let drawn = draw(&mut node);
244
245 MOUNTED.with(|mounted| {
246 if let Some(slot) = mounted.borrow_mut().get_mut(&at) {
247 *slot = Some(Box::new(node));
248 }
249 });
250
251 drawn
252}
253
254/// A zero-sized marker per segment type: what a `const` entry points at to get
255/// its `&'static dyn Mount`.
256pub struct MountPage<P>(pub std::marker::PhantomData<P>);
257pub struct MountLayout<L>(pub std::marker::PhantomData<L>);
258
259impl<P: Page> Mount<Egui> for MountPage<P> {
260 fn view<'a>(&self, props: SegmentProps<Egui>, _nodes: &'a ()) -> Node {
261 let at = props.scopes[props.cursor].key();
262
263 Node::new(move |ui| {
264 let _drawing = guinea_core::observability::Rendering::of(std::any::type_name::<P>());
265 with_mounted::<P, _>(at, |page| {
266 page.render(&mut PageCx {
267 ui,
268 props,
269 page: std::marker::PhantomData,
270 })
271 })
272 })
273 }
274}
275
276impl<L: Layout> Mount<Egui> for MountLayout<L> {
277 fn view<'a>(&self, props: SegmentProps<Egui>, _nodes: &'a ()) -> Node {
278 let at = props.scopes[props.cursor].key();
279
280 Node::new(move |ui| {
281 let _drawing = guinea_core::observability::Rendering::of(std::any::type_name::<L>());
282 with_mounted::<L, _>(at, |layout| {
283 layout.render(&mut LayoutCx {
284 ui,
285 props,
286 layout: std::marker::PhantomData,
287 })
288 })
289 })
290 }
291}
292
293/// A one-segment chain, for a page drawn without a route tree.
294pub fn page_chain<P: Page>() -> &'static [SegmentEntry<Egui>] {
295 single_entry_chain(segment_entry::<P>())
296}
297
298/// What a page's drawing is handed.
299///
300/// Carries the page type, not because drawing needs it, but because reading
301/// does: what a segment may read is a fact about where it sits, and this is
302/// where that fact enters the signature.
303pub struct PageCx<'a, P> {
304 ui: &'a mut egui::Ui,
305 props: SegmentProps<Egui>,
306 page: std::marker::PhantomData<fn() -> P>,
307}
308
309fn feature_of<R: Reducer>(
310 props: &SegmentProps<Egui>,
311) -> (std::rc::Rc<R>, guinea_core::feature::Dispatch) {
312 let binding = props.binding::<R>();
313 (binding.get(), binding.dispatch())
314}
315
316impl<P> PageCx<'_, P> {
317 /// The reducer's state and actions.
318 ///
319 /// No subscription, as in the terminal: egui redraws the whole frame, so
320 /// there is nothing to invalidate - the next pass reads the state again.
321 /// What does need saying is when a frame should happen at all, and that is
322 /// the dispatcher's job.
323 ///
324 /// The feature that answers is the one this page installed, or the nearest
325 /// above that listed `R` in `Exports`. A read that reaches nothing panics
326 /// here, naming the chain it walked.
327 ///
328 /// The state comes shared, not copied: reading it every frame costs a
329 /// count, and a change made mid-frame goes to a copy.
330 pub fn read<R>(&mut self) -> (std::rc::Rc<R>, guinea_core::feature::Dispatch)
331 where
332 R: Reducer,
333 {
334 feature_of::<R>(&self.props)
335 }
336
337 /// The reducer's binding: its state, and a push straight into it.
338 ///
339 /// For state the UI owns outright - what is picked in a tree, which tab
340 /// is open - claimed with `cx.state::<R>().plain()`. There is no domain
341 /// to ask, so there is no actor to ask it through: a click is the whole
342 /// story.
343 ///
344 /// State a feature drives is not this: pushing into it goes behind the
345 /// back of whatever answers for it. Use [`PageCx::read`] and emit.
346 pub fn binding<R>(&self) -> ReducerBinding<R>
347 where
348 R: Reducer,
349 {
350 self.props.binding::<R>()
351 }
352}
353
354impl<P> Reads for PageCx<'_, P> {
355 fn read<R>(&mut self) -> (std::rc::Rc<R>, guinea_core::feature::Dispatch)
356 where
357 R: Reducer + PartialEq,
358 {
359 feature_of::<R>(&self.props)
360 }
361
362 fn dispatch<R>(&self) -> guinea_core::feature::Dispatch
363 where
364 R: Reducer,
365 {
366 self.props.binding::<R>().dispatch()
367 }
368}
369
370impl<P> PageCx<'_, P> {
371 pub fn ui(&mut self) -> &mut egui::Ui {
372 self.ui
373 }
374
375 /// A navigator over the route type the application runs.
376 pub fn navigate<R>(&self) -> NavigateHandle<Egui, R>
377 where
378 R: RouteChain<Egui> + Clone + PartialEq + 'static,
379 {
380 nav::current::<R>()
381 }
382}
383
384/// What a layout's drawing is handed. Same as a page's, plus the child.
385pub struct LayoutCx<'a, L> {
386 ui: &'a mut egui::Ui,
387 props: SegmentProps<Egui>,
388 layout: std::marker::PhantomData<fn() -> L>,
389}
390
391impl<L> LayoutCx<'_, L> {
392 /// See [`PageCx::read`].
393 pub fn read<R>(&mut self) -> (std::rc::Rc<R>, guinea_core::feature::Dispatch)
394 where
395 R: Reducer,
396 {
397 feature_of::<R>(&self.props)
398 }
399
400 /// See [`PageCx::binding`].
401 pub fn binding<R>(&self) -> ReducerBinding<R>
402 where
403 R: Reducer,
404 {
405 self.props.binding::<R>()
406 }
407}
408
409impl<L> Reads for LayoutCx<'_, L> {
410 fn read<R>(&mut self) -> (std::rc::Rc<R>, guinea_core::feature::Dispatch)
411 where
412 R: Reducer + PartialEq,
413 {
414 feature_of::<R>(&self.props)
415 }
416
417 fn dispatch<R>(&self) -> guinea_core::feature::Dispatch
418 where
419 R: Reducer,
420 {
421 self.props.binding::<R>().dispatch()
422 }
423}
424
425impl<L> LayoutCx<'_, L> {
426 pub fn ui(&mut self) -> &mut egui::Ui {
427 self.ui
428 }
429
430 /// A navigator over the route type the application runs.
431 pub fn navigate<R>(&self) -> NavigateHandle<Egui, R>
432 where
433 R: RouteChain<Egui> + Clone + PartialEq + 'static,
434 {
435 nav::current::<R>()
436 }
437
438 /// The next segment down the chain, for the layout to draw where it wants.
439 ///
440 /// Handed over rather than drawn here: a layout takes its `ui` from this
441 /// same context, so a method that drew the child would need the context
442 /// twice at once.
443 pub fn outlet(&self) -> Node {
444 self.props.outlet(&())
445 }
446
447 /// Whether the segment directly below is `P`.
448 ///
449 /// What a tab strip needs, and cheaper than it looks: the chain already
450 /// says which page is mounted, so highlighting the current tab needs
451 /// neither the route nor a copy of it in state.
452 pub fn child_is<P: 'static>(&self) -> bool {
453 self.props
454 .chain
455 .get(self.props.cursor + 1)
456 .is_some_and(|entry| (entry.type_id)() == std::any::TypeId::of::<P>())
457 }
458}