Skip to main content

yog_kit/
screen.rs

1//! Registering a screen with the game, handles to it, the command context
2//! and navigation between screens.
3
4use std::any::{Any, TypeId};
5use std::marker::PhantomData;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
8
9use crate::host::Host;
10use crate::{ScreenMeta, View, ViewModel};
11
12type Job<V> = Box<dyn FnOnce(&mut V) + Send>;
13type Reader<V> = Box<dyn Fn(&mut dyn FnMut(&V)) -> bool + Send + Sync>;
14
15fn lock<T: ?Sized>(m: &Mutex<T>) -> MutexGuard<'_, T> {
16    m.lock().unwrap_or_else(|e| e.into_inner())
17}
18
19pub(crate) struct Shared<V> {
20    meta: ScreenMeta,
21    queue: Mutex<Vec<Job<V>>>,
22    reader: OnceLock<Reader<V>>,
23}
24
25/// A cloneable, thread-safe handle to a registered screen's view model.
26pub struct ScreenHandle<V> {
27    shared: Arc<Shared<V>>,
28}
29
30impl<V> Clone for ScreenHandle<V> {
31    fn clone(&self) -> Self {
32        Self {
33            shared: self.shared.clone(),
34        }
35    }
36}
37
38impl<V: ViewModel> ScreenHandle<V> {
39    pub(crate) fn new(meta: ScreenMeta) -> Self {
40        Self {
41            shared: Arc::new(Shared {
42                meta,
43                queue: Mutex::new(Vec::new()),
44                reader: OnceLock::new(),
45            }),
46        }
47    }
48
49    /// Changes the view model from any thread (a network handler, a
50    /// command, ...). Queued: runs on the render thread before the next
51    /// frame or event, then the bindings update. Never blocks.
52    pub fn update(&self, f: impl FnOnce(&mut V) + Send + 'static) {
53        lock(&self.shared.queue).push(Box::new(f));
54    }
55
56    /// Reads the view model — `None` while the screen is busy (rendering or
57    /// handling input, e.g. when called from one of its own commands).
58    pub fn read<R>(&self, f: impl FnOnce(&V) -> R) -> Option<R> {
59        let reader = self.shared.reader.get()?;
60        let mut f = Some(f);
61        let mut out = None;
62        reader(&mut |vm| {
63            if let Some(f) = f.take() {
64                out = Some(f(vm));
65            }
66        });
67        out
68    }
69
70    /// The screen's UI id.
71    pub fn id(&self) -> &'static str {
72        self.shared.meta.id
73    }
74
75    /// Opens the screen.
76    pub fn open(&self) {
77        let m = self.shared.meta;
78        yog_api::open_ui(m.id, m.modal, m.pause);
79    }
80
81    /// Closes the screen if it's open.
82    pub fn close(&self) {
83        yog_api::close_ui(self.shared.meta.id);
84    }
85
86    pub(crate) fn take_queue(&self) -> Vec<Job<V>> {
87        std::mem::take(&mut *lock(&self.shared.queue))
88    }
89
90    pub(crate) fn set_reader(&self, reader: Reader<V>) {
91        let _ = self.shared.reader.set(reader);
92    }
93}
94
95pub(crate) enum Request {
96    Close,
97    Navigate(String),
98    Back,
99    Focus(String),
100    Invalidate,
101}
102
103/// What a command or view-model hook can ask of its screen. Requests run
104/// right after the call returns.
105pub struct Cx<'a, V> {
106    reqs: &'a mut Vec<Request>,
107    handle: &'a ScreenHandle<V>,
108}
109
110impl<'a, V: ViewModel> Cx<'a, V> {
111    pub(crate) fn new(reqs: &'a mut Vec<Request>, handle: &'a ScreenHandle<V>) -> Self {
112        Self { reqs, handle }
113    }
114
115    /// Closes this screen.
116    pub fn close(&mut self) {
117        self.reqs.push(Request::Close);
118    }
119
120    /// Opens the screen `ui_id`; Esc there comes back here.
121    pub fn navigate(&mut self, ui_id: &str) {
122        self.reqs.push(Request::Navigate(ui_id.to_owned()));
123    }
124
125    /// Opens the yog-kit screen `W` of this mod after running `init` on its
126    /// view model; Esc there comes back here.
127    pub fn navigate_to<W: View>(&mut self, init: impl FnOnce(&mut W::Vm) + Send + 'static) {
128        if let Some(h) = handle_of::<W>() {
129            h.update(init);
130        }
131        self.navigate(W::META.id);
132    }
133
134    /// Back to the screen this one was navigated from, else closes it.
135    pub fn back(&mut self) {
136        self.reqs.push(Request::Back);
137    }
138
139    /// Moves the keyboard focus to the widget with `id`.
140    pub fn focus(&mut self, id: &str) {
141        self.reqs.push(Request::Focus(id.to_owned()));
142    }
143
144    /// A handle to this screen (keep it for updates from other threads).
145    pub fn handle(&self) -> ScreenHandle<V> {
146        self.handle.clone()
147    }
148
149    /// Re-evaluates the bindings (they already are after every command).
150    pub fn invalidate(&mut self) {
151        self.reqs.push(Request::Invalidate);
152    }
153}
154
155// ── navigation (per mod: each mod links its own yog-kit) ─────────────────────
156
157static NAV: Mutex<Vec<&'static str>> = Mutex::new(Vec::new());
158/// A navigation is closing the current screen — keep the stack.
159static NAVIGATING: AtomicBool = AtomicBool::new(false);
160static SCREENS: Mutex<Vec<ScreenMeta>> = Mutex::new(Vec::new());
161#[allow(clippy::type_complexity)]
162static HANDLES: Mutex<Vec<(TypeId, Box<dyn Any + Send>)>> = Mutex::new(Vec::new());
163
164fn open_id(id: &str) {
165    let meta = lock(&SCREENS).iter().find(|m| m.id == id).copied();
166    let (modal, pause) = meta.map_or((true, false), |m| (m.modal, m.pause));
167    yog_api::open_ui(id, modal, pause);
168}
169
170pub(crate) fn navigate(from: &'static str, to: &str) {
171    lock(&NAV).push(from);
172    NAVIGATING.store(true, Ordering::SeqCst);
173    open_id(to);
174}
175
176/// Opens the previous screen; `false` when there's none.
177pub(crate) fn go_back() -> bool {
178    let Some(prev) = lock(&NAV).pop() else {
179        return false;
180    };
181    NAVIGATING.store(true, Ordering::SeqCst);
182    open_id(prev);
183    true
184}
185
186/// A screen closed: unless it's a navigation, the history is over.
187pub(crate) fn screen_closed() {
188    if !NAVIGATING.swap(false, Ordering::SeqCst) {
189        lock(&NAV).clear();
190    }
191}
192
193/// The handle of the registered screen `W`.
194pub fn handle_of<W: View>() -> Option<ScreenHandle<W::Vm>> {
195    lock(&HANDLES)
196        .iter()
197        .find(|(t, _)| *t == TypeId::of::<W>())
198        .and_then(|(_, h)| h.downcast_ref::<ScreenHandle<W::Vm>>().cloned())
199}
200
201/// Hosts the compiled view `W` as a Yog screen.
202pub struct Screen<W: View>(PhantomData<W>);
203
204impl<W: View> Screen<W> {
205    /// Registers `W` (rendering + input) with the view model `vm`; called
206    /// by the generated `register()`.
207    pub fn register(registry: &mut yog_api::Registry, vm: W::Vm) -> ScreenHandle<W::Vm> {
208        let handle = ScreenHandle::<W::Vm>::new(W::META);
209        let host = Arc::new(Mutex::new(Host::<W>::new(vm, handle.clone(), true)));
210        let weak = Arc::downgrade(&host);
211        handle.set_reader(Box::new(move |f| {
212            let Some(host) = weak.upgrade() else {
213                return false;
214            };
215            let Ok(h) = host.try_lock() else {
216                return false;
217            };
218            f(&h.vm);
219            true
220        }));
221        lock(&SCREENS).push(W::META);
222        lock(&HANDLES).push((TypeId::of::<W>(), Box::new(handle.clone())));
223        lock(&host).every_frame = W::META.live;
224        // Opened by the game with the block it belongs to, not by `open()`.
225        let inventory = inventory_of::<W>();
226        let overlay = inventory.is_some();
227        if let Some(def) = inventory {
228            registry.register_inventory(def);
229            lock(&host).every_frame = true;
230        }
231
232        let render = host.clone();
233        registry.on_ui_render(W::META.id, move |gfx| {
234            let mut h = lock(&render);
235            let (w, hh) = gfx.screen_size();
236            h.frame(w as f32, hh as f32);
237            h.ui.render(gfx);
238            if !overlay {
239                h.draw_tooltip(gfx);
240            }
241        });
242        // An inventory screen's game-drawn items come after its markup: the
243        // host draws `<id>#overlay` after them, for what goes on top.
244        if overlay {
245            let top = host.clone();
246            let id: &'static str = Box::leak(format!("{}#overlay", W::META.id).into_boxed_str());
247            registry.on_ui_render(id, move |gfx| lock(&top).draw_tooltip(gfx));
248        }
249        registry.register_ui(W::META.id, move |_, ev| lock(&host).on_event(ev));
250        handle
251    }
252}
253
254/// The inventory screen `W`'s markup lays out — where its `<Slot>`s and
255/// `<PlayerInventory>` land at the design size — or `None` for a plain
256/// screen. Its id is the screen's: a block opens it with
257/// `BlockDef::inventory(ID)`.
258pub fn inventory_of<W: View>() -> Option<yog_api::InventoryDef> {
259    use yog_api::ui::widget::{PLAYER_INVENTORY_ID, SLOT_ID_PREFIX};
260    use yog_api::ui::{UiRoot, Widget};
261
262    /// Each slot: (index, its id, what it accepts).
263    fn slots(w: &Widget, out: &mut Vec<(usize, String, Vec<String>)>) {
264        if let Some(id) = w.id.as_deref()
265            && let Some(rest) = id.strip_prefix(SLOT_ID_PREFIX)
266        {
267            let (index, accepts) = rest.split_once('=').unwrap_or((rest, ""));
268            if let Ok(i) = index.parse::<usize>() {
269                let accepts = accepts.split('|').filter(|a| !a.is_empty()).map(str::to_owned).collect();
270                out.push((i, id.to_owned(), accepts));
271            }
272        }
273        for c in &w.children {
274            slots(c, out);
275        }
276    }
277
278    /// The machine a `<RecipeArea>` made of it, if any (`widget::machine`).
279    fn machine(w: &Widget) -> Option<yog_api::inventory::Machine> {
280        use yog_api::ui::widget::MACHINE_ID_PREFIX;
281        if let Some(rest) = w.id.as_deref().and_then(|id| id.strip_prefix(MACHINE_ID_PREFIX)) {
282            let mut parts = rest.splitn(3, ';');
283            let kind = parts.next()?.to_string();
284            let result = parts.next()?.parse().ok()?;
285            let places = parts
286                .next()
287                .unwrap_or_default()
288                .split(',')
289                .filter(|p| !p.is_empty())
290                .filter_map(|p| {
291                    let (slot, rest) = p.split_once('/')?;
292                    let (index, inv) = rest.split_once('=')?;
293                    Some(yog_api::inventory::MachinePlace { slot: slot.to_string(), index: index.parse().ok()?, inv: inv.parse().ok()? })
294                })
295                .collect();
296            return Some(yog_api::inventory::Machine { kind, places, result });
297        }
298        w.children.iter().find_map(machine)
299    }
300
301    let (_, content, _) = W::build();
302    let mut found = Vec::new();
303    slots(&content, &mut found);
304    let made = machine(&content);
305    let mut ui = UiRoot::new(W::META.id, content);
306    ui.layout(W::META.w, W::META.h);
307    // A real slot's position is its item's corner, inside the 18px frame.
308    let corner = |id: &str| ui.find_rect(id).map(|r| (r.x + 1.0, r.y + 1.0));
309    let player = corner(PLAYER_INVENTORY_ID);
310    if found.is_empty() && player.is_none() {
311        return None;
312    }
313    let count = found.iter().map(|(i, ..)| i + 1).max().unwrap_or(0);
314    let defaults = yog_api::InventoryDef::default_grid(count);
315    let layout = (0..count)
316        .map(|i| {
317            let slot = found.iter().find(|(j, ..)| *j == i);
318            match slot.and_then(|(_, id, accepts)| Some((corner(id)?, accepts))) {
319                Some(((x, y), accepts)) => yog_api::SlotLayout { x, y, accepts: accepts.clone() },
320                None => {
321                    yog_api::warn!("{}: no <Slot index=\"{i}\"> — it goes to the default grid", W::META.id);
322                    defaults[i].clone()
323                }
324            }
325        })
326        .collect();
327    let mut def = yog_api::InventoryDef::new(W::META.id, count)
328        .layout(layout)
329        .include_player_inventory(player.is_some())
330        .background_size(W::META.w, W::META.h);
331    if let Some((x, y)) = player {
332        def = def.player_inv_offset(x, y);
333    }
334    if let Some(m) = made {
335        def = def.machine(m);
336    }
337    Some(def)
338}