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}