Skip to main content

supreme_hook_config/
ui.rs

1//! The optional `ui` section of a mod's `mod.json`, the launcher-UI
2//! schema the Mods page renders generically: buttons shown for the mod,
3//! and the popups those buttons open. See
4//! [`crate::mod_config::ModConfig::ui`], which holds this as an
5//! [`Option`], a mod without a `ui` section (e.g. the existing
6//! practice-as-race mod) stays fully valid and simply gets no buttons.
7//!
8//! Wire shape (per the tournament design contract):
9//!
10//! ```json
11//! "ui": { "buttons": [ { "id": "create_room", "label": "Create",
12//!                        "action": "tournament.create", "popup": "create_room" } ],
13//!         "popups": { "create_room": { "title": "Create Tournament Room",
14//!                                      "kind": "form", "fields": [ ... ] } } }
15//! ```
16//!
17//! Design notes:
18//! - **Parse liberally, validate strictly.** Every field has a serde
19//!   default, so a partial `ui` section (buttons without popups, a popup
20//!   without fields, …) still *parses*; structural problems surface as
21//!   per-mod validation errors from [`ModUi::validate`], collected by
22//!   [`crate::mod_config::ModConfig::validate`] exactly like parameter
23//!   and rule problems, a broken mod shows up in the launcher's mod list
24//!   as broken rather than failing to parse outright.
25//! - **Forward compatible.** Unknown extra keys are ignored by serde (and
26//!   reported as non-fatal warnings by
27//!   [`crate::mod_config::ModConfig::load_from_path_with_warnings`]).
28//!   Unknown *kind* strings (e.g. a `"kind": "datepicker"` field written
29//!   by a newer launcher) round-trip through the [`UiFieldKind::Unknown`]
30//!   / [`UiPopupKind::Unknown`] catch-all variants instead of failing to
31//!   parse, and are then rejected by [`ModUi::validate`] with a clear
32//!   message, same mechanism, at the right layer.
33//! - `button.label` is displayed verbatim; `{placeholder}` tokens inside
34//!   it (e.g. `"Join ({room_count})"`) are filled in live by the launcher
35//!   (from its current `room_list` response), never interpolated here.
36//! - `button.action` strings in the `tournament.*` namespace are
37//!   handled by launcher logic; other actions are free-form for future
38//!   use. This crate stores them opaquely.
39
40use serde::{Deserialize, Serialize};
41use std::collections::BTreeMap;
42
43/// A mod's authored launcher-UI schema: which buttons to show for the
44/// mod, and which popups they open. `popups` is a map keyed by popup id
45/// so buttons can reference popups by name, and validation can catch a
46/// button pointing at a popup that doesn't exist
47/// (see [`ModUi::validate`]).
48#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
49pub struct ModUi {
50    /// Buttons rendered for this mod, in authored order.
51    #[serde(default)]
52    pub buttons: Vec<UiButton>,
53    /// Popups buttons can open, keyed by popup id. May be empty even
54    /// when buttons exist (e.g. action-only buttons that don't open a
55    /// popup).
56    #[serde(default)]
57    pub popups: BTreeMap<String, UiPopup>,
58}
59
60impl ModUi {
61    /// Structural validation of the whole `ui` section:
62    ///
63    /// - button ids must be unique and non-empty;
64    /// - every button's `action` must be non-empty;
65    /// - every button's `popup` reference (if any) must name an entry in
66    ///   [`ModUi::popups`], no dangling refs;
67    /// - a `select` field must offer at least one option;
68    /// - unknown popup/field kind strings are rejected (they parse via
69    ///   the `Unknown` catch-alls precisely so this check can describe
70    ///   them instead of a raw serde error).
71    ///
72    /// # Errors
73    ///
74    /// Returns every problem found (not just the first), matching
75    /// [`crate::mod_config::ModConfig::validate`]'s collect-everything
76    /// convention.
77    pub fn validate(&self) -> Result<(), Vec<String>> {
78        let mut errors = Vec::new();
79
80        let mut seen_button_ids = std::collections::HashSet::new();
81        for (index, button) in self.buttons.iter().enumerate() {
82            if button.id.trim().is_empty() {
83                errors.push(format!("ui: buttons[{index}] has an empty id"));
84            } else if !seen_button_ids.insert(button.id.as_str()) {
85                errors.push(format!(
86                    "ui: duplicate button id `{}`; every button id must be unique",
87                    button.id
88                ));
89            }
90
91            if button.action.trim().is_empty() {
92                errors.push(format!("ui: button `{}` has an empty action", button.id));
93            }
94
95            if let Some(popup) = &button.popup
96                && !self.popups.contains_key(popup)
97            {
98                errors.push(format!(
99                    "ui: button `{}` references popup `{popup}`, which is not defined in `popups`",
100                    button.id
101                ));
102            }
103        }
104
105        for (popup_id, popup) in &self.popups {
106            if let UiPopupKind::Unknown(kind) = &popup.kind {
107                errors.push(format!(
108                    "ui: popup `{popup_id}` has unknown kind `{kind}` (expected `form` or `room_list`)"
109                ));
110            }
111
112            for (index, field) in popup.fields.iter().enumerate() {
113                if let UiFieldKind::Unknown(kind) = &field.kind {
114                    errors.push(format!(
115                        "ui: popup `{popup_id}` fields[{index}] has unknown kind `{kind}` \
116                         (expected `text`, `select`, `number`, `duration`, or `datetime`)"
117                    ));
118                }
119
120                if field.kind == UiFieldKind::Select && field.options.is_empty() {
121                    errors.push(format!(
122                        "ui: popup `{popup_id}` select field `{}` needs at least one option",
123                        field.id
124                    ));
125                }
126
127                if let Some(tag) = &field.validation {
128                    if !crate::validation::is_known_tag(tag) {
129                        errors.push(format!(
130                            "ui: popup `{popup_id}` field `{}` has unknown validation tag \
131                             `{tag}` (expected one of: {})",
132                            field.id,
133                            crate::validation::KNOWN_TAGS.join(", ")
134                        ));
135                    } else if field.kind != UiFieldKind::Text {
136                        errors.push(format!(
137                            "ui: popup `{popup_id}` field `{}` carries a validation tag, \
138                             which only text fields enforce",
139                            field.id
140                        ));
141                    }
142                }
143            }
144        }
145
146        if errors.is_empty() {
147            Ok(())
148        } else {
149            Err(errors)
150        }
151    }
152}
153
154/// One button rendered for a mod on the Mods page. Clicking it performs
155/// `action` (launcher-handled, e.g. `tournament.create`), optionally
156/// opening the popup named by `popup` first to collect input.
157#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
158pub struct UiButton {
159    /// Stable identifier for this button within the mod's `ui` section.
160    /// Must be unique, see [`ModUi::validate`].
161    #[serde(default)]
162    pub id: String,
163    /// Button caption. May contain `{placeholder}` tokens the launcher
164    /// fills in live (e.g. `"Join ({room_count})"`); stored verbatim.
165    #[serde(default)]
166    pub label: String,
167    /// Launcher-handled action name (e.g. `tournament.create`). Must
168    /// be non-empty, see [`ModUi::validate`].
169    #[serde(default)]
170    pub action: String,
171    /// Optional popup id (a key into [`ModUi::popups`]) opened when the
172    /// button is pressed, to collect input before the action runs.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub popup: Option<String>,
175}
176
177/// One popup a [`UiButton`] can open: a titled window that is either an
178/// input form ([`UiPopupKind::Form`]) or a live room list
179/// ([`UiPopupKind::RoomList`]).
180#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
181pub struct UiPopup {
182    /// Window title.
183    #[serde(default)]
184    pub title: String,
185    /// What kind of popup this is, defaults to [`UiPopupKind::Form`]
186    /// when omitted, since a fields-only popup (the common shape) is a
187    /// form.
188    #[serde(default)]
189    pub kind: UiPopupKind,
190    /// Input fields, meaningful for [`UiPopupKind::Form`] popups;
191    /// empty for [`UiPopupKind::RoomList`] popups (their content comes
192    /// from the launcher's live room data, not the mod's authoring).
193    #[serde(default)]
194    pub fields: Vec<UiField>,
195}
196
197/// What kind of popup a [`UiPopup`] is. Unknown kind strings from a
198/// newer launcher round-trip through [`UiPopupKind::Unknown`] so they
199/// fail *validation* (with the raw string in the message) instead of
200/// failing to parse.
201#[derive(Debug, Clone, PartialEq, Eq, Default)]
202pub enum UiPopupKind {
203    /// An input form built from [`UiPopup::fields`].
204    #[default]
205    Form,
206    /// A live list of joinable rooms fetched by the launcher.
207    RoomList,
208    /// A kind string this launcher version doesn't know, always
209    /// rejected by [`ModUi::validate`].
210    Unknown(String),
211}
212
213impl UiPopupKind {
214    /// The wire string this kind serializes to, the known kinds' snake
215    /// case names, or the raw unknown string verbatim.
216    #[must_use]
217    pub fn as_str(&self) -> &str {
218        match self {
219            Self::Form => "form",
220            Self::RoomList => "room_list",
221            Self::Unknown(kind) => kind.as_str(),
222        }
223    }
224}
225
226impl Serialize for UiPopupKind {
227    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
228        serializer.serialize_str(self.as_str())
229    }
230}
231
232impl<'de> Deserialize<'de> for UiPopupKind {
233    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
234        let kind = String::deserialize(deserializer)?;
235        Ok(match kind.as_str() {
236            "form" => Self::Form,
237            "room_list" => Self::RoomList,
238            other => Self::Unknown(other.to_string()),
239        })
240    }
241}
242
243/// One input field inside a [`UiPopupKind::Form`] popup.
244#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
245pub struct UiField {
246    /// Stable identifier for the collected value (what the launcher
247    /// hands to the button's `action` handling).
248    #[serde(default)]
249    pub id: String,
250    /// What kind of control to render, defaults to
251    /// [`UiFieldKind::Text`] when omitted.
252    #[serde(default)]
253    pub kind: UiFieldKind,
254    /// Human-readable label shown next to the control.
255    #[serde(default)]
256    pub label: String,
257    /// Choices for [`UiFieldKind::Select`] fields; empty for every
258    /// other kind (and required non-empty for selects, see
259    /// [`ModUi::validate`]).
260    #[serde(default)]
261    pub options: Vec<String>,
262    /// short hint text rendered inside the control itself (egui's
263    /// `hint_text` on text fields), the "(optional)" style note a mod can
264    /// attach without bloating the label. `None` = no hint.
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub hint: Option<String>,
267    /// Optional name of a shared validation rule set (see
268    /// [`crate::validation`]) the launcher enforces live on this field:
269    /// invalid input renders an error stroke and a warning icon whose
270    /// hover carries the reason, and the popup's confirm button stays
271    /// disabled while the value is invalid. Known tags: `player_name`,
272    /// `room_name`; unknown tags fail [`ModUi::validate`] so a newer mod
273    /// on an older launcher reports itself. Only meaningful on text
274    /// fields.
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub validation: Option<String>,
277}
278
279/// What kind of control a [`UiField`] renders. Unknown kind strings
280/// round-trip through [`UiFieldKind::Unknown`] so they fail *validation*
281/// (with the raw string in the message) instead of failing to parse.
282#[derive(Debug, Clone, PartialEq, Eq, Default)]
283pub enum UiFieldKind {
284    /// Free-text input.
285    #[default]
286    Text,
287    /// A choice among [`UiField::options`], must be non-empty.
288    Select,
289    /// Numeric input.
290    Number,
291    /// A duration picker (values like `"1h"`, `"6h"`, `"24h"`).
292    Duration,
293    /// A date+time picker (room planning windows). The collected
294    /// value is a UTC epoch-seconds string; the launcher renders it with
295    /// egui's `DatePickerButton` plus hour/minute steppers. Defaults (now /
296    /// tomorrow) are applied by the launcher when the popup opens.
297    DateTime,
298    /// A kind string this launcher version doesn't know, always
299    /// rejected by [`ModUi::validate`].
300    Unknown(String),
301}
302
303impl UiFieldKind {
304    /// The wire string this kind serializes to, the known kinds' snake
305    /// case names, or the raw unknown string verbatim.
306    #[must_use]
307    pub fn as_str(&self) -> &str {
308        match self {
309            Self::Text => "text",
310            Self::Select => "select",
311            Self::Number => "number",
312            Self::Duration => "duration",
313            Self::DateTime => "datetime",
314            Self::Unknown(kind) => kind.as_str(),
315        }
316    }
317}
318
319impl Serialize for UiFieldKind {
320    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
321        serializer.serialize_str(self.as_str())
322    }
323}
324
325impl<'de> Deserialize<'de> for UiFieldKind {
326    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
327        let kind = String::deserialize(deserializer)?;
328        Ok(match kind.as_str() {
329            "text" => Self::Text,
330            "select" => Self::Select,
331            "number" => Self::Number,
332            "duration" => Self::Duration,
333            "datetime" => Self::DateTime,
334            other => Self::Unknown(other.to_string()),
335        })
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn empty_ui_validates() {
345        assert!(ModUi::default().validate().is_ok());
346    }
347
348    #[test]
349    fn unknown_kinds_serialize_round_trip_verbatim() {
350        let ui: ModUi = serde_json::from_str(
351            r#"{"buttons":[],"popups":{"p":{"title":"t","kind":"calendar",
352               "fields":[{"id":"f","kind":"datepicker","label":"l"}]}}}"#,
353        )
354        .unwrap();
355        assert_eq!(ui.popups["p"].kind, UiPopupKind::Unknown("calendar".into()));
356        assert_eq!(
357            ui.popups["p"].fields[0].kind,
358            UiFieldKind::Unknown("datepicker".into())
359        );
360
361        let json = serde_json::to_string(&ui).unwrap();
362        assert!(json.contains(r#""kind":"calendar""#));
363        assert!(json.contains(r#""kind":"datepicker""#));
364    }
365
366    #[test]
367    fn field_kind_defaults_to_text_when_omitted() {
368        let field: UiField = serde_json::from_str(r#"{"id":"x","label":"X"}"#).unwrap();
369        assert_eq!(field.kind, UiFieldKind::Text);
370    }
371
372    #[test]
373    fn field_hint_parses_and_round_trips() {
374        // the hint is optional, survives the round trip, and is
375        // OMITTED from the serialization when absent (mod.jsons written by
376        // older launchers stay byte-stable apart from the new field).
377        let field: UiField =
378            serde_json::from_str(r#"{"id":"name","label":"Room name","hint":"(optional)"}"#)
379                .unwrap();
380        assert_eq!(field.hint.as_deref(), Some("(optional)"));
381
382        let bare: UiField = serde_json::from_str(r#"{"id":"x","label":"X"}"#).unwrap();
383        assert_eq!(bare.hint, None);
384        assert!(!serde_json::to_string(&bare).unwrap().contains("hint"));
385
386        let hinted = serde_json::to_string(&field).unwrap();
387        assert!(hinted.contains(r#""hint":"(optional)""#));
388    }
389
390    #[test]
391    fn popup_kind_defaults_to_form_when_omitted() {
392        let popup: UiPopup = serde_json::from_str(r#"{"title":"t","fields":[]}"#).unwrap();
393        assert_eq!(popup.kind, UiPopupKind::Form);
394    }
395
396    #[test]
397    fn field_validation_tag_round_trips_and_unknown_tags_are_rejected() {
398        // The tag is optional, survives the round trip, and is OMITTED
399        // from the serialization when absent (mod.jsons written by older
400        // launchers stay byte-stable apart from the new key).
401        let bare: UiField = serde_json::from_str(r#"{"id":"x","label":"X"}"#).unwrap();
402        assert_eq!(bare.validation, None);
403        assert!(!serde_json::to_string(&bare).unwrap().contains("validation"));
404
405        let tagged: UiField =
406            serde_json::from_str(r#"{"id":"name","label":"Room name","validation":"room_name"}"#)
407                .unwrap();
408        assert_eq!(tagged.validation.as_deref(), Some("room_name"));
409        assert!(
410            serde_json::to_string(&tagged)
411                .unwrap()
412                .contains(r#""validation":"room_name""#)
413        );
414
415        // Unknown tags parse but fail validation, naming the tag.
416        let ui: ModUi = serde_json::from_str(
417            r#"{"buttons":[],"popups":{"p":{"title":"t",
418               "fields":[{"id":"f","kind":"text","label":"l","validation":"numeric"}]}}}"#,
419        )
420        .unwrap();
421        let errors = ui.validate().unwrap_err();
422        assert!(
423            errors
424                .iter()
425                .any(|e| e.contains("unknown validation tag `numeric`")),
426            "errors: {errors:?}"
427        );
428
429        // A tag on a non-text field is meaningless, rejected too.
430        let ui: ModUi = serde_json::from_str(
431            r#"{"buttons":[],"popups":{"p":{"title":"t",
432               "fields":[{"id":"f","kind":"number","label":"l","validation":"room_name"}]}}}"#,
433        )
434        .unwrap();
435        let errors = ui.validate().unwrap_err();
436        assert!(
437            errors
438                .iter()
439                .any(|e| e.contains("only text fields enforce")),
440            "errors: {errors:?}"
441        );
442    }
443}