Skip to main content

yog_kit/
host.rs

1//! The screen host: owns the view model, the view and the laid-out tree,
2//! runs binding updates and routes input. Used by [`crate::Screen`] (the
3//! game) and [`crate::testing::TestHost`] (tests) alike; nothing here needs
4//! a graphics context.
5
6use yog_api::ui::{FlexDir, LayoutNode, StateFlags, UiRoot, Units, Widget, layout, widget};
7
8mod input;
9
10use crate::screen::{self, Request};
11use crate::{Anchor, Cx, Parts, ScreenHandle, View, ViewEvent, ViewModel};
12
13/// All of its parent, as a percent.
14const FULL: f32 = 100.0;
15/// Pixels per mouse-wheel notch.
16const WHEEL_STEP: f32 = 16.0;
17/// Rows PgUp/PgDn move in a list whose row height or viewport is unknown.
18const FALLBACK_PAGE: u32 = 5;
19/// [`Host::log`] entries for a close / back request.
20pub(crate) const LOG_CLOSE: &str = "close";
21const LOG_BACK: &str = "back";
22
23/// A scroller's visible window during an update.
24#[derive(Debug, Clone, Copy, Default, PartialEq)]
25pub struct Viewport {
26    /// Scroll offset (0 = top).
27    pub offset: f32,
28    /// Height of the scroller in the last layout (0 before the first).
29    pub height: f32,
30}
31
32/// What a [`View::update`] changed, plus what it can ask of the host.
33#[derive(Debug, Default)]
34pub struct Update {
35    pub(crate) relayout: bool,
36    pub(crate) repaint: bool,
37    pub(crate) viewports: Vec<Viewport>,
38    pub(crate) text_sets: Vec<(u16, String)>,
39    pub(crate) scroll_resets: Vec<u16>,
40    pub(crate) lists: Vec<(u16, u32, Option<u32>)>,
41}
42
43impl Update {
44    /// A standalone update (no scrollers) — for tests of generated views.
45    pub fn new() -> Self {
46        Self::default()
47    }
48    /// A change that may move or resize widgets.
49    pub fn relayout(&mut self) {
50        self.relayout = true;
51    }
52    /// A paint-only change.
53    pub fn repaint(&mut self) {
54        self.repaint = true;
55    }
56    pub fn needs_relayout(&self) -> bool {
57        self.relayout
58    }
59    pub fn needs_repaint(&self) -> bool {
60        self.repaint || self.relayout
61    }
62    /// The visible window of scroller `i`.
63    pub fn viewport(&self, scroller: u16) -> Viewport {
64        self.viewports
65            .get(scroller as usize)
66            .copied()
67            .unwrap_or_default()
68    }
69    /// Sets text box `slot`'s text (kept if it's already that, so the caret
70    /// doesn't jump while typing).
71    pub fn set_text_box(&mut self, slot: u16, text: &str) {
72        self.text_sets.push((slot, text.to_owned()));
73    }
74    /// Scrolls scroller `i` back to the top.
75    pub fn reset_scroll(&mut self, scroller: u16) {
76        self.scroll_resets.push(scroller);
77        self.relayout();
78    }
79    /// Records list `owner`'s item count and selected index.
80    pub fn list_info(&mut self, owner: u16, len: u32, selected: Option<u32>) {
81        self.lists.push((owner, len, selected));
82    }
83}
84
85/// Child-index path of the first widget tagged `id`.
86pub(crate) fn find_path(w: &Widget, id: &str) -> Option<Vec<u16>> {
87    if w.id.as_deref() == Some(id) {
88        return Some(Vec::new());
89    }
90    w.children.iter().enumerate().find_map(|(i, c)| {
91        find_path(c, id).map(|mut p| {
92            p.insert(0, i as u16);
93            p
94        })
95    })
96}
97
98fn with_root(path: &[u16]) -> Vec<u16> {
99    let mut p = Vec::with_capacity(path.len() + 1);
100    p.push(0);
101    p.extend_from_slice(path);
102    p
103}
104
105pub(crate) struct Host<W: View> {
106    pub(crate) vm: W::Vm,
107    view: W,
108    /// Root = a full-screen panel whose only child is the view's content.
109    pub(crate) ui: UiRoot,
110    pub(crate) parts: Parts,
111    pub(crate) size: (f32, f32),
112    pub(crate) dirty: bool,
113    /// Content-relative paths.
114    hover: Option<Vec<u16>>,
115    pressed: Option<Vec<u16>>,
116    focus: Option<Vec<u16>>,
117    /// The focus came from the keyboard (draw its outline).
118    focus_visible: bool,
119    outlined: Option<Vec<u16>>,
120    mouse: Option<(f32, f32)>,
121    /// The surface a button went down on (it gets the drags and the release).
122    captured: Option<Vec<u16>>,
123    /// The surface under the cursor (it gets `Move`s, then a `Leave`).
124    hovered_surface: Option<Vec<u16>>,
125    opened: bool,
126    reveal: Option<(u16, u32)>,
127    /// Viewports the last update saw.
128    seen_viewports: Vec<Viewport>,
129    pub(crate) handle: ScreenHandle<W::Vm>,
130    /// What the screen asked for (close, navigate, ...), for tests.
131    pub(crate) log: Vec<String>,
132    /// Calls `yog_api` for close/navigation (off in tests).
133    pub(crate) live: bool,
134    /// Re-evaluate the bindings every frame: an inventory screen's slots
135    /// change by drag-and-drop, which runs no command.
136    pub(crate) every_frame: bool,
137}
138
139impl<W: View> Host<W> {
140    pub(crate) fn new(vm: W::Vm, handle: ScreenHandle<W::Vm>, live: bool) -> Self {
141        let (view, content, parts) = W::build();
142        let screen = widget::panel(FlexDir::Column).child(content);
143        Self {
144            vm,
145            view,
146            ui: UiRoot::new(W::META.id, screen),
147            parts,
148            size: (W::META.w, W::META.h),
149            dirty: true,
150            hover: None,
151            pressed: None,
152            focus: None,
153            focus_visible: false,
154            outlined: None,
155            mouse: None,
156            captured: None,
157            hovered_surface: None,
158            opened: false,
159            reveal: None,
160            seen_viewports: Vec::new(),
161            handle,
162            log: Vec::new(),
163            live,
164            every_frame: false,
165        }
166    }
167
168    pub(crate) fn content(&self) -> &Widget {
169        &self.ui.root.children[0]
170    }
171
172    fn node(&self, path: &[u16]) -> Option<&LayoutNode> {
173        layout::node_at(&self.ui.layout_root, &with_root(path))
174    }
175
176    #[cfg(any(test, feature = "testing"))]
177    pub(crate) fn content_node(&self, path: &[u16]) -> Option<&LayoutNode> {
178        self.node(path)
179    }
180
181    /// Content path of the clickable widget at `(x, y)`.
182    fn hit(&self, x: f32, y: f32) -> Option<Vec<u16>> {
183        let p = layout::hit_path(&self.ui.layout_root, x, y)?;
184        (p.first() == Some(&0)).then(|| p[1..].to_vec())
185    }
186
187    // ── view model calls ────────────────────────────────────────────────────
188
189    fn with_cx<R>(&mut self, f: impl FnOnce(&mut W::Vm, &mut W, &mut Cx<'_, W::Vm>) -> R) -> R {
190        let mut reqs = Vec::new();
191        let r = {
192            let mut cx = Cx::new(&mut reqs, &self.handle);
193            f(&mut self.vm, &mut self.view, &mut cx)
194        };
195        self.dirty = true;
196        for req in reqs {
197            self.request(req);
198        }
199        r
200    }
201
202    fn request(&mut self, req: Request) {
203        let id = W::META.id;
204        match req {
205            Request::Close => {
206                self.log.push(LOG_CLOSE.into());
207                if self.live {
208                    yog_api::close_ui(id);
209                }
210            }
211            Request::Navigate(to) => {
212                self.log.push(format!("navigate:{to}"));
213                if self.live {
214                    screen::navigate(id, &to);
215                }
216            }
217            Request::Back => {
218                self.log.push(LOG_BACK.into());
219                if !self.live || !screen::go_back() {
220                    self.request(Request::Close);
221                }
222            }
223            Request::Focus(wid) => {
224                if let Some(p) = find_path(self.content(), &wid) {
225                    self.set_focus(Some(p), true);
226                }
227            }
228            Request::Invalidate => self.dirty = true,
229        }
230    }
231
232    pub(crate) fn dispatch(&mut self, ev: ViewEvent) {
233        self.with_cx(|vm, view, cx| view.dispatch(vm, cx, ev));
234    }
235
236    fn open_vm(&mut self) {
237        self.opened = true;
238        self.reset_input();
239        self.with_cx(|vm, _, cx| vm.on_open(cx));
240    }
241
242    fn close_vm(&mut self) {
243        self.opened = false;
244        self.reset_input();
245        self.with_cx(|vm, _, cx| vm.on_close(cx));
246    }
247
248    fn reset_input(&mut self) {
249        self.hover = None;
250        self.pressed = None;
251        self.focus = None;
252        self.mouse = None;
253        for t in &mut self.parts.text_boxes {
254            t.state.blur();
255        }
256    }
257
258    pub(crate) fn drain_queue(&mut self) {
259        for job in self.handle.take_queue() {
260            job(&mut self.vm);
261            self.dirty = true;
262        }
263    }
264
265    // ── frame ───────────────────────────────────────────────────────────────
266
267    /// Brings the tree up to date and lays it out for a `sw` × `sh` GUI.
268    pub(crate) fn frame(&mut self, sw: f32, sh: f32) {
269        self.size = (sw, sh);
270        self.drain_queue();
271        self.dirty |= self.every_frame;
272        // A keyboard reveal can scroll a virtualized list, which needs a
273        // second update for the rows that came into view.
274        for _ in 0..2 {
275            if self.dirty {
276                self.run_update();
277            }
278            self.apply_parts();
279            self.apply_states();
280            self.layout();
281            if !self.do_reveal() && !self.viewports_changed() {
282                break;
283            }
284        }
285    }
286
287    fn viewports(&self) -> Vec<Viewport> {
288        self.parts
289            .scrollers
290            .iter()
291            .map(|s| Viewport {
292                offset: s.state.offset,
293                height: self.node(&s.path).map_or(0.0, |n| n.rect.h),
294            })
295            .collect()
296    }
297
298    /// A virtualized list's scroller changed size or offset since the last
299    /// update (the first layout; a `scroll_key` reset, which happens after
300    /// the update read the old offset) — its window must be recomputed.
301    fn viewports_changed(&mut self) -> bool {
302        let now = self.viewports();
303        let changed = self.parts.lists.iter().any(|l| {
304            let (Some(_), Some(s)) = (l.item_height, l.scroller) else {
305                return false;
306            };
307            let (a, b) = (self.seen_viewports.get(s as usize), now.get(s as usize));
308            a.map(|v| (v.height, v.offset)) != b.map(|v| (v.height, v.offset))
309        });
310        if changed {
311            self.dirty = true;
312        }
313        changed
314    }
315
316    fn run_update(&mut self) {
317        let viewports = self.viewports();
318        self.seen_viewports.clone_from(&viewports);
319        let mut u = Update {
320            viewports,
321            ..Update::default()
322        };
323        let content = &mut self.ui.root.children[0];
324        self.view.update(content, &self.vm, &mut u);
325        self.vm.customize(content);
326        for (slot, text) in u.text_sets {
327            if let Some(t) = self.parts.text_boxes.get_mut(slot as usize)
328                && t.state.text() != text
329            {
330                t.state.set_text(text);
331            }
332        }
333        for s in u.scroll_resets {
334            if let Some(s) = self.parts.scrollers.get_mut(s as usize) {
335                s.state.reset();
336            }
337        }
338        for (owner, len, selected) in u.lists {
339            if let Some(l) = self.parts.lists.iter_mut().find(|l| l.owner == owner) {
340                l.len = len;
341                l.selected = selected;
342            }
343        }
344        self.dirty = false;
345    }
346
347    fn apply_parts(&mut self) {
348        let content = &mut self.ui.root.children[0];
349        for s in &self.parts.scrollers {
350            if let Some(w) = content.get_path_mut(&s.path) {
351                w.style.scroll_y = s.state.offset;
352            }
353        }
354        for t in &mut self.parts.text_boxes {
355            if let Some(w) = content.get_path_mut(&t.path) {
356                t.state.apply(w);
357            }
358        }
359    }
360
361    fn apply_states(&mut self) {
362        let content = &mut self.ui.root.children[0];
363        content.clear_state_deep(StateFlags::HOVER | StateFlags::PRESSED | StateFlags::FOCUSED);
364        if let Some(p) = self.outlined.take()
365            && let Some(w) = content.get_path_mut(&p)
366        {
367            w.focused = false;
368        }
369        for (path, flag) in [
370            (&self.hover, StateFlags::HOVER),
371            (&self.pressed, StateFlags::PRESSED),
372            (&self.focus, StateFlags::FOCUSED),
373        ] {
374            if let Some(w) = path.as_deref().and_then(|p| content.get_path_mut(p)) {
375                w.set_state_inherited(flag, true);
376            }
377        }
378        if self.focus_visible
379            && let Some(p) = &self.focus
380            && let Some(w) = content.get_path_mut(p)
381            && w.is_interactive()
382        {
383            w.focused = true;
384            self.outlined = Some(p.clone());
385        }
386    }
387
388    fn layout(&mut self) {
389        let (sw, sh) = self.size;
390        let m = W::META;
391        let root = &mut self.ui.root;
392        root.style.w = Units::Px(sw);
393        root.style.h = Units::Px(sh);
394        let c = &mut root.children[0];
395        if m.anchor == Anchor::Fill {
396            c.style.w = Units::Percent(FULL);
397            c.style.h = Units::Percent(FULL);
398            c.style.pos = Some((0.0, 0.0));
399        } else {
400            c.style.w = Units::Px(m.w);
401            c.style.h = Units::Px(m.h);
402            c.style.pos = Some(m.anchor.place(sw, sh, m.w, m.h));
403        }
404        self.ui.layout(sw, sh);
405    }
406
407    /// Scrolls a list so the item asked for is visible; `true` when the
408    /// offset changed.
409    fn do_reveal(&mut self) -> bool {
410        let Some((owner, index)) = self.reveal.take() else {
411            return false;
412        };
413        let Some(list) = self.parts.lists.iter().find(|l| l.owner == owner) else {
414            return false;
415        };
416        let Some(si) = list.scroller else {
417            return false;
418        };
419        let Some(scroller) = self.parts.scrollers.get(si as usize) else {
420            return false;
421        };
422        let (Some(view), Some(items)) = (self.node(&scroller.path), self.node(&list.path)) else {
423            return false;
424        };
425        let found = items.children.iter().find(|c| {
426            c.action
427                .is_some_and(|a| a.owner == owner && a.index == index)
428        });
429        let (top, h) = match (found, list.item_height) {
430            (Some(n), _) => (n.rect.y - view.rect.y + view.scroll_y, n.rect.h),
431            (None, Some(ih)) => {
432                let w = self.content().get_path(&list.path);
433                let (gap, pad) = w.map_or((0.0, 0.0), |w| (w.style.gap, w.style.pad[0]));
434                (pad + index as f32 * (ih + gap), ih)
435            }
436            (None, None) => return false,
437        };
438        let (view_h, max) = (view.rect.h, view.max_scroll());
439        let state = &mut self.parts.scrollers[si as usize].state;
440        let before = state.offset;
441        state.scroll_into_view(top, h, view_h);
442        state.offset = state.offset.clamp(0.0, max.max(0.0));
443        if (state.offset - before).abs() > f32::EPSILON {
444            self.dirty = true;
445            true
446        } else {
447            false
448        }
449    }
450
451    /// The tooltip of what's under the cursor, drawn on top.
452    pub(crate) fn draw_tooltip(&self, gfx: &yog_api::GfxContext) {
453        if let Some((x, y)) = self.mouse
454            && let Some(text) = self.ui.tooltip_at(x, y)
455        {
456            UiRoot::render_tooltip(gfx, text, x, y);
457        }
458    }
459}