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