Skip to main content

pane/
loader.rs

1use serde::Deserialize;
2use std::sync::mpsc;
3use std::time::{Duration, SystemTime};
4
5// ── Internal Message Bus ──────────────────────────────────────────────────────
6
7#[derive(Debug)]
8pub(crate) enum UiMsg {
9    SwitchRoot(String),
10    SwitchTabPage {
11        tab_id: String,
12        page: usize,
13    },
14    Quit,
15    Custom(String),
16    Reload,
17    Slider(String, f32),
18    TextChanged(String, String),
19    TextSubmitted(String, String),
20    Toggle(String, bool),
21    Dropdown(String, usize, String),
22    Radio(String, usize, String),
23    /// Internal: spawns a toast widget into the active root. Fired by `PressAction::Toast`.
24    Toast {
25        message: String,
26        duration: f32,
27        x: f32,
28        y: f32,
29        width: f32,
30        height: f32,
31    },
32}
33
34// ── RON Data Structures ───────────────────────────────────────────────────────
35
36#[derive(Deserialize)]
37pub struct MenuDef {
38    #[serde(default)]
39    pub hot_reload: bool,
40    #[serde(default)]
41    pub headless_accessible: bool,
42    #[serde(default)]
43    pub shader_dirs: Vec<String>,
44    #[serde(default)]
45    pub style_dirs: Vec<String>,
46    pub background: Option<String>,
47    #[serde(default)]
48    pub clear_color: Option<crate::draw::Color>,
49    pub default_style: Option<String>,
50    pub start_root: String,
51    pub roots: Vec<RootDef>,
52}
53
54#[derive(Deserialize)]
55pub struct RootDef {
56    pub name: String,
57    #[serde(default)]
58    pub buttons: Vec<ButtonDef>,
59    #[serde(default)]
60    pub scroll_lists: Vec<ScrollListDef>,
61    #[serde(default)]
62    pub bars: Vec<BarDef>,
63    #[serde(default)]
64    pub popouts: Vec<PopoutDef>,
65    #[serde(default)]
66    pub toggles: Vec<ToggleDef>,
67    #[serde(default)]
68    pub sliders: Vec<SliderDef>,
69    #[serde(default)]
70    pub labels: Vec<FreeLabelDef>,
71    #[serde(default)]
72    pub dividers: Vec<DividerDef>,
73    #[serde(default)]
74    pub images: Vec<ImageDef>,
75    #[serde(default)]
76    pub text_boxes: Vec<TextBoxDef>,
77    #[serde(default)]
78    pub progress_bars: Vec<ProgressBarDef>,
79    #[serde(default)]
80    pub scroll_panes: Vec<ScrollPaneDef>,
81    #[serde(default)]
82    pub dropdowns: Vec<DropdownDef>,
83    #[serde(default)]
84    pub radio_groups: Vec<RadioGroupDef>,
85    #[serde(default)]
86    pub actors: Vec<ActorDef>,
87    #[serde(default)]
88    pub tabs: Vec<TabDef>,
89}
90
91#[derive(Deserialize)]
92pub struct BarDef {
93    pub id: String,
94    #[serde(default)]
95    pub edge: Option<BarEdgeDef>,
96    pub thickness: f32,
97    #[serde(default)]
98    pub pad: f32,
99    #[serde(default)]
100    pub gap: f32,
101    pub style: Option<String>,
102    pub items: Vec<BarItemDef>,
103    #[serde(default)]
104    pub manual: bool,
105    /// X position when `edge` is `None` or `Free` (screen-space, origin at center).
106    #[serde(default)]
107    pub x: f32,
108    /// Y position when `edge` is `None` or `Free`.
109    #[serde(default)]
110    pub y: f32,
111    /// Width override for free-positioned bars. If 0.0, uses `thickness` for vertical bars.
112    #[serde(default)]
113    pub width: f32,
114    /// Height override for free-positioned bars. If 0.0, uses `thickness` for horizontal bars.
115    #[serde(default)]
116    pub height: f32,
117}
118
119#[derive(Deserialize, PartialEq, Eq)]
120pub enum BarEdgeDef {
121    Top,
122    Bottom,
123    Left,
124    Right,
125    Free,
126}
127
128#[derive(Deserialize)]
129pub struct PopoutDef {
130    pub id: String,
131    #[serde(default)]
132    pub closed_x: f32,
133    #[serde(default)]
134    pub closed_y: f32,
135    #[serde(default)]
136    pub open_x: f32,
137    #[serde(default)]
138    pub open_y: f32,
139    pub width: f32,
140    pub height: f32,
141    pub toggle_id: String,
142    pub style: Option<String>,
143    pub edge: Option<PopoutEdgeDef>,
144    #[serde(default = "default_true")]
145    pub shadow: bool,
146    #[serde(default)]
147    pub horizontal: bool,
148    #[serde(default)]
149    pub gap: f32,
150    #[serde(default)]
151    pub full_span: bool,
152    #[serde(default)]
153    pub home_toggles: bool,
154    #[serde(default)]
155    pub items: Vec<PopoutItemDef>,
156}
157
158const fn default_true() -> bool {
159    true
160}
161
162#[derive(Deserialize, Clone, Copy)]
163pub enum PopoutEdgeDef {
164    Left,
165    Right,
166    Top,
167    Bottom,
168}
169
170#[derive(Deserialize)]
171pub enum PopoutItemDef {
172    Button(ButtonDef),
173    ScrollList(ScrollListDef),
174    Bar(BarDef),
175    Popout(PopoutDef),
176}
177
178#[derive(Deserialize)]
179pub enum BarItemDef {
180    Button(ListButtonDef),
181    Label(LabelDef),
182    Spacer,
183    ScrollList(ScrollListDef),
184}
185
186#[derive(Deserialize)]
187pub struct LabelDef {
188    pub id: String,
189    pub text: String,
190    #[serde(default = "default_label_size")]
191    pub size: f32,
192    pub color: Option<crate::draw::Color>,
193    #[serde(default)]
194    pub width: f32,
195}
196
197const fn default_label_size() -> f32 {
198    24.0
199}
200
201#[derive(Deserialize)]
202pub struct ButtonDef {
203    pub id: String,
204    pub x: f32,
205    pub y: f32,
206    pub width: f32,
207    pub height: f32,
208    pub text: String,
209    pub tooltip: Option<String>,
210    pub style: Option<String>,
211    pub on_press: PressAction,
212    #[serde(default)]
213    pub nav_default: bool,
214}
215
216#[derive(Deserialize)]
217pub struct ScrollListDef {
218    pub id: String,
219    pub x: f32,
220    pub y: f32,
221    pub width: f32,
222    pub height: f32,
223    #[serde(default)]
224    pub pad_left: f32,
225    #[serde(default)]
226    pub pad_right: f32,
227    #[serde(default)]
228    pub pad_top: f32,
229    #[serde(default)]
230    pub pad_bottom: f32,
231    #[serde(default)]
232    pub gap: f32,
233    pub style: Option<String>,
234    #[serde(default)]
235    pub horizontal: bool,
236    #[serde(default)]
237    pub full_span: bool,
238    pub items: Vec<ListButtonDef>,
239}
240
241#[derive(Deserialize)]
242pub struct ListButtonDef {
243    pub id: String,
244    pub height: f32,
245    /// Width used when the parent [`ScrollListDef`] has `horizontal: true`.
246    #[serde(default)]
247    pub width: f32,
248    pub text: String,
249    pub tooltip: Option<String>,
250    pub style: Option<String>,
251    pub on_press: PressAction,
252}
253
254#[derive(Deserialize, Clone)]
255pub enum PressAction {
256    /// Switch the active root to the named root.
257    SwitchRoot(String),
258    /// Jump a tab widget to a specific page index.
259    SwitchTabPage { tab_id: String, page: usize },
260    /// Exit the application.
261    Quit,
262    /// Print a debug message to stdout when the button is pressed.
263    Print(String),
264    /// Emit a [`crate::api::PaneAction::Custom`] with the given tag string.
265    Custom(String),
266    /// Show a transient toast notification. All geometry is in the 1080-unit grid space.
267    ///
268    /// `duration`, `width`, and `height` are optional and default to `2.0`, `400.0`, and `60.0`.
269    /// `x` and `y` default to `0.0` (screen centre).
270    Toast {
271        /// Text displayed inside the toast.
272        message: String,
273        /// How long the toast stays visible, in seconds. Defaults to `2.0`.
274        #[serde(default = "default_toast_duration")]
275        duration: f32,
276        /// Horizontal centre of the toast in grid units. Defaults to `0.0`.
277        #[serde(default)]
278        x: f32,
279        /// Vertical centre of the toast in grid units. Defaults to `0.0`.
280        #[serde(default)]
281        y: f32,
282        /// Width of the toast in grid units. Defaults to `400.0`.
283        #[serde(default = "default_toast_width")]
284        width: f32,
285        /// Height of the toast in grid units. Defaults to `60.0`.
286        #[serde(default = "default_toast_height")]
287        height: f32,
288    },
289    /// Internal: emitted by radio option buttons. Never appears in RON files.
290    RadioSelect { group_id: String, index: usize },
291    /// Internal: emitted by dropdown option buttons. Never appears in RON files.
292    DropdownSelect { dropdown_id: String, index: usize },
293}
294
295const fn default_toast_duration() -> f32 {
296    2.0
297}
298const fn default_toast_width() -> f32 {
299    400.0
300}
301const fn default_toast_height() -> f32 {
302    60.0
303}
304
305impl PressAction {
306    /// True for actions that require post-press root mutation (`RadioSelect`, `DropdownSelect`).
307    #[must_use]
308    pub const fn is_internal(&self) -> bool {
309        matches!(self, Self::RadioSelect { .. } | Self::DropdownSelect { .. })
310    }
311}
312
313#[derive(Deserialize)]
314pub struct ToggleDef {
315    pub id: String,
316    pub x: f32,
317    pub y: f32,
318    pub width: f32,
319    pub height: f32,
320    pub text: String,
321    pub tooltip: Option<String>,
322    #[serde(default)]
323    pub checked: bool,
324    pub style_off: String,
325    pub style_on: String,
326    pub on_change: ToggleAction,
327}
328
329#[derive(Deserialize, Clone)]
330pub enum ToggleAction {
331    Print,
332    Custom(String),
333}
334
335// ── Load ──────────────────────────────────────────────────────────────────────
336
337/// Parse a RON menu file, returning an error string on failure instead of panicking.
338/// Used during hot-reload so a bad edit doesn't crash the running app.
339pub(crate) fn load_menu_soft(path: &str) -> Result<MenuDef, String> {
340    let src = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
341    ron::Options::default()
342        .with_default_extension(
343            ron::extensions::Extensions::IMPLICIT_SOME
344                | ron::extensions::Extensions::UNWRAP_NEWTYPES,
345        )
346        .from_str(&src)
347        .map_err(|e| {
348            eprintln!("[pane_ui] Reload parse error: {e}");
349            e.to_string()
350        })
351}
352
353/// Parse a RON menu file, panicking with a clear message on failure.
354/// Used at startup where there is no sensible fallback.
355pub(crate) fn load_menu(path: &str) -> MenuDef {
356    load_menu_soft(path).unwrap_or_else(|e| panic!("[pane_ui] Failed to load '{path}': {e}"))
357}
358
359// ── Watcher ───────────────────────────────────────────────────────────────────
360
361/// Spawn a background thread that polls the menu RON file, shader dirs, and style dirs
362/// for modification-time changes every second, sending [`UiMsg::Reload`] on any change.
363pub(crate) fn spawn_watcher(
364    path: String,
365    shader_dirs: Vec<String>,
366    style_dirs: Vec<String>,
367    tx: mpsc::Sender<UiMsg>,
368) {
369    std::thread::spawn(move || {
370        let collect_stamps = || -> Vec<(String, Option<SystemTime>)> {
371            let mut stamps = Vec::new();
372            stamps.push((
373                path.clone(),
374                std::fs::metadata(&path).and_then(|m| m.modified()).ok(),
375            ));
376            for dir in &shader_dirs {
377                if let Ok(entries) = std::fs::read_dir(dir) {
378                    for entry in entries.filter_map(std::result::Result::ok) {
379                        let p = entry.path();
380                        if p.extension().is_some_and(|x| x == "wgsl") {
381                            stamps.push((
382                                p.to_string_lossy().to_string(),
383                                std::fs::metadata(&p).and_then(|m| m.modified()).ok(),
384                            ));
385                        }
386                    }
387                }
388            }
389            for dir in &style_dirs {
390                if let Ok(entries) = std::fs::read_dir(dir) {
391                    for entry in entries.filter_map(std::result::Result::ok) {
392                        let p = entry.path();
393                        if p.extension().is_some_and(|x| x == "ron") {
394                            stamps.push((
395                                p.to_string_lossy().to_string(),
396                                std::fs::metadata(&p).and_then(|m| m.modified()).ok(),
397                            ));
398                        }
399                    }
400                }
401            }
402            stamps
403        };
404
405        let mut last_stamps = collect_stamps();
406        loop {
407            std::thread::sleep(Duration::from_secs(1));
408            let new_stamps = collect_stamps();
409            if new_stamps != last_stamps {
410                let _ = tx.send(UiMsg::Reload);
411            }
412            last_stamps = new_stamps;
413        }
414    });
415}
416
417// ── Slider ────────────────────────────────────────────────────────────────────
418
419#[derive(Deserialize)]
420pub struct SliderDef {
421    pub id: String,
422    pub x: f32,
423    pub y: f32,
424    pub width: f32,
425    pub height: f32,
426    pub min: f32,
427    pub max: f32,
428    pub value: f32,
429    #[serde(default)]
430    pub step: Option<f32>,
431    pub tooltip: Option<String>,
432    pub style_track: String,
433    pub style_thumb: String,
434    pub on_change: SliderAction,
435}
436
437// ── Static Display Items ──────────────────────────────────────────────────────
438
439#[derive(Deserialize)]
440pub struct FreeLabelDef {
441    pub id: String,
442    pub x: f32,
443    pub y: f32,
444    pub text: String,
445    #[serde(default = "default_label_size")]
446    pub size: f32,
447    pub color: Option<crate::draw::Color>,
448    #[serde(default)]
449    pub width: f32,
450}
451
452#[derive(Deserialize)]
453pub struct DividerDef {
454    pub id: String,
455    pub x: f32,
456    pub y: f32,
457    pub width: f32,
458    pub height: f32,
459    pub style: String,
460    #[serde(default)]
461    pub full_span: bool,
462}
463
464#[derive(Deserialize)]
465pub struct ImageDef {
466    pub id: String,
467    pub x: f32,
468    pub y: f32,
469    pub width: f32,
470    pub height: f32,
471    pub path: String,
472    #[serde(default)]
473    pub gif_mode: Option<crate::textures::GifMode>,
474}
475
476#[derive(Deserialize)]
477pub struct TextBoxDef {
478    pub id: String,
479    pub x: f32,
480    pub y: f32,
481    pub width: f32,
482    pub height: f32,
483    #[serde(default)]
484    pub hint: String,
485    #[serde(default)]
486    pub max_len: Option<usize>,
487    pub tooltip: Option<String>,
488    pub style: String,
489    #[serde(default)]
490    pub style_focus: Option<String>,
491    pub on_change: TextBoxAction,
492    pub on_submit: TextBoxAction,
493    #[serde(default)]
494    pub password: bool,
495    #[serde(default)]
496    pub multiline: bool,
497    #[serde(default)]
498    pub rows: Option<u32>,
499    #[serde(default)]
500    pub font_size: Option<f32>,
501}
502
503#[derive(Deserialize)]
504pub struct ProgressBarDef {
505    pub id: String,
506    pub x: f32,
507    pub y: f32,
508    pub width: f32,
509    pub height: f32,
510    pub value: f32,
511    pub style_track: String,
512    pub style_fill: String,
513}
514
515#[derive(Deserialize, Clone)]
516pub enum TextBoxAction {
517    Print,
518    Custom(String),
519    None,
520}
521
522#[derive(Deserialize, Clone)]
523pub enum SliderAction {
524    Print,
525    Custom(String),
526}
527
528// ── ScrollPane ────────────────────────────────────────────────────────────────
529
530#[derive(Deserialize)]
531pub struct ScrollPaneDef {
532    pub id: String,
533    pub x: f32,
534    pub y: f32,
535    pub width: f32,
536    pub height: f32,
537    #[serde(default)]
538    pub pad_left: f32,
539    #[serde(default)]
540    pub pad_right: f32,
541    #[serde(default)]
542    pub pad_top: f32,
543    #[serde(default)]
544    pub pad_bottom: f32,
545    #[serde(default)]
546    pub gap: f32,
547    pub style: Option<String>,
548    #[serde(default)]
549    pub horizontal: bool,
550    #[serde(default)]
551    pub full_span: bool,
552    #[serde(default)]
553    pub manual: bool,
554    pub items: Vec<ContainerItemDef>,
555}
556
557#[derive(Deserialize)]
558pub enum ContainerItemDef {
559    Button(ButtonDef),
560    Toggle(ToggleDef),
561    Slider(SliderDef),
562    TextBox(TextBoxDef),
563    Label(FreeLabelDef),
564    Divider(DividerDef),
565    Image(ImageDef),
566    ProgressBar(ProgressBarDef),
567    ScrollPane(ScrollPaneDef),
568    ScrollList(ScrollListDef),
569    Tab(TabDef),
570}
571
572// ── Tab ───────────────────────────────────────────────────────────────────────
573
574#[derive(Deserialize)]
575pub struct TabPageDef {
576    pub label: String,
577    pub items: Vec<ContainerItemDef>,
578}
579
580#[derive(Deserialize)]
581pub struct TabDef {
582    pub id: String,
583    pub x: f32,
584    pub y: f32,
585    pub width: f32,
586    pub height: f32,
587    #[serde(default)]
588    pub pad_left: f32,
589    #[serde(default)]
590    pub pad_right: f32,
591    #[serde(default)]
592    pub pad_top: f32,
593    #[serde(default)]
594    pub pad_bottom: f32,
595    #[serde(default)]
596    pub gap: f32,
597    pub style: Option<String>,
598    pub pages: Vec<TabPageDef>,
599    #[serde(default)]
600    pub full_span: bool,
601}
602
603// ── Dropdown ──────────────────────────────────────────────────────────────────
604
605#[derive(Deserialize)]
606pub struct DropdownDef {
607    pub id: String,
608    pub x: f32,
609    pub y: f32,
610    pub width: f32,
611    pub height: f32,
612    pub options: Vec<String>,
613    #[serde(default)]
614    pub selected: usize,
615    pub tooltip: Option<String>,
616    pub style: Option<String>,
617    pub style_list: Option<String>,
618    pub style_item: Option<String>,
619    pub on_change: DropdownAction,
620}
621
622#[derive(Deserialize, Clone)]
623pub enum DropdownAction {
624    Print,
625    Custom(String),
626}
627
628// ── Actor ─────────────────────────────────────────────────────────────────────
629
630#[derive(Deserialize)]
631pub struct ActorDef {
632    pub id: String,
633    pub x: f32,
634    pub y: f32,
635    pub width: f32,
636    pub height: f32,
637    pub style: Option<String>,
638    /// Base gif path — always loops automatically. Optional.
639    pub gif: Option<String>,
640    #[serde(default)]
641    pub z_front: bool,
642    #[serde(default = "default_true")]
643    pub return_on_end: bool,
644    #[serde(default)]
645    pub behaviours: Vec<BehaviourDef>,
646}
647
648#[derive(Deserialize)]
649pub struct BehaviourDef {
650    pub trigger: TriggerDef,
651    pub action: ActionDef,
652}
653
654#[derive(Deserialize, Clone, Copy, PartialEq, Eq)]
655pub enum TriggerDef {
656    Always,
657    OnHoverSelf,
658    OnPressSelf,
659    OnClickSelf,
660    OnClickAnywhere,
661}
662
663#[derive(Deserialize, Clone)]
664pub enum ActionDef {
665    FollowCursor {
666        speed: f32,
667        #[serde(default)]
668        trail: f32,
669    },
670    MoveTo {
671        x: f32,
672        y: f32,
673        speed: f32,
674    },
675    /// Swap to a different gif while this trigger is active. Reverts when trigger ends.
676    SwapGif {
677        path: String,
678    },
679}
680
681// ── RadioGroup ────────────────────────────────────────────────────────────────
682
683#[derive(Deserialize)]
684pub struct RadioGroupDef {
685    pub id: String,
686    pub x: f32,
687    pub y: f32,
688    pub width: f32,
689    pub height: f32,
690    pub options: Vec<String>,
691    #[serde(default)]
692    pub selected: usize,
693    #[serde(default)]
694    pub gap: f32,
695    pub tooltip: Option<String>,
696    pub style_idle: Option<String>,
697    pub style_selected: Option<String>,
698    pub on_change: RadioAction,
699}
700
701#[derive(Deserialize, Clone)]
702pub enum RadioAction {
703    Print,
704    Custom(String),
705}