launcher/pages/mods/ui.rs
1use std::collections::HashMap;
2use std::path::Path;
3
4use eframe::egui;
5use egui::{Align, Layout, RichText, Stroke, StrokeKind, WidgetInfo, WidgetText, WidgetType};
6use egui_commonmark::{CommonMarkCache, CommonMarkViewer};
7use serde_json::Value;
8use supreme_fs::GameStorage;
9use supreme_hook_config::{ModParameter, ParameterKind};
10
11use super::account;
12use super::tasks;
13use super::tournament;
14use super::types::ModsPageState;
15use super::watch;
16use crate::launcher_config::LauncherConfig;
17
18pub fn mods_page(
19 ui: &mut egui::Ui,
20 storage: &GameStorage,
21 state: &mut ModsPageState,
22 launcher_config: &mut LauncherConfig,
23) -> bool {
24 let mut config_changed = false;
25
26 if !state.is_initialized {
27 state.is_initialized = true;
28 state.mods = tasks::discover_mods(storage);
29 tournament::prefill_tournament_player(storage, &mut state.mods, &mut config_changed);
30 // After the prefill: a stored account enforces its identity
31 // (nickname + hidden push-URL params) over whatever the prefill
32 // or a hand-edited mods.json put there.
33 config_changed |= account::adopt_stored_account(storage, state, launcher_config);
34 state.watcher = watch::ModsWatcher::start(&tasks::mods_dir(storage), ui.ctx().clone());
35 }
36
37 // Live-reload: a mod folder was added/removed, or some file inside one
38 // (most importantly mod.json) changed on disk. `selected` is left
39 // alone, it's just a `dir_name`, so it naturally survives a rescan,
40 // and if the selected mod is now gone the details pane below simply
41 // won't find it anymore. This also catches our own writes below,
42 // as it's a fallback for the immediate signals those actions already
43 // return, not the only way this fires.
44 if state.watcher.as_ref().is_some_and(watch::ModsWatcher::poll) {
45 state.mods = tasks::discover_mods(storage);
46 config_changed = true;
47 }
48
49 // Tournament: land finished room tasks before rendering, the
50 // list slot refreshes the `{room_count}` cache / room_list popup, the
51 // action slot persists a create/join result (selection + parameter
52 // overrides, flagged via `config_changed` so the caller refreshes the
53 // hook resolver). Page (re)activation then refreshes the room cache.
54 config_changed |= tournament::poll_tasks(ui.ctx(), storage, state);
55 tournament::on_page_activation(ui.ctx(), storage, state);
56
57 ui.horizontal(|ui| {
58 ui.heading("Mods");
59 if ui
60 .button(RichText::new("📂").size(16.0))
61 .on_hover_text("Open mods folder")
62 .clicked()
63 {
64 let _ = open::that(tasks::mods_dir(storage));
65 }
66 });
67 ui.add_space(10.0);
68
69 if state.mods.is_empty() {
70 let mut cache = CommonMarkCache::default();
71 CommonMarkViewer::new().show(ui, &mut cache, "No mods installed yet. Drop a mod folder (with its own mod.json) into the `mods` folder using the `📂` button at the top of this page.\n\nThis page will refresh automatically.");
72 return config_changed;
73 }
74
75 egui::Panel::left("mods_list_panel")
76 .resizable(true)
77 .min_size(220.0)
78 .max_size(320.0)
79 .show(ui, |ui| {
80 egui::ScrollArea::vertical().show(ui, |ui| {
81 if render_mod_list(ui, storage, state) {
82 config_changed = true;
83 }
84 });
85 });
86
87 egui::CentralPanel::default().show(ui, |ui| match state.selected.clone() {
88 Some(dir_name) => {
89 if render_mod_details(ui, storage, state, &dir_name) {
90 config_changed = true;
91 }
92 }
93 None => {
94 ui.centered_and_justified(|ui| {
95 ui.weak("Select a mod to see its details.");
96 });
97 }
98 });
99
100 // Open tournament/ui-schema popup + error window, drawn above the
101 // panels (same layer mechanism as the news/board windows). The
102 // account popup's Log out changes the mod configuration too.
103 config_changed |= tournament::render_popups(ui.ctx(), storage, state);
104
105 // Flush a changed account record into the launcher's own config,
106 // AFTER the popups so a logout click lands in the same frame.
107 account::sync_stored_account(state, launcher_config);
108
109 config_changed
110}
111
112enum RowAction {
113 None,
114 ToggleEnabled(usize),
115 Select(String),
116 MoveUp(usize),
117 MoveDown(usize),
118}
119
120fn render_mod_list(ui: &mut egui::Ui, storage: &GameStorage, state: &mut ModsPageState) -> bool {
121 let len = state.mods.len();
122 let mut action = RowAction::None;
123
124 for i in 0..len {
125 let m = &state.mods[i];
126 let is_selected = state.selected.as_deref() == Some(m.dir_name.as_str());
127 let mut enabled = m.enabled;
128
129 ui.horizontal(|ui| {
130 if ui.checkbox(&mut enabled, "").changed() {
131 action = RowAction::ToggleEnabled(i);
132 }
133
134 let display_text = if m.load_error.is_some() {
135 RichText::new(&m.dir_name).color(egui::Color32::RED)
136 } else {
137 RichText::new(&m.config.name)
138 };
139 let mut label_response = ui.selectable_label(is_selected, display_text);
140 if let Some(err) = &m.load_error {
141 label_response = label_response.on_hover_text(err);
142 }
143 if label_response.clicked() {
144 action = RowAction::Select(m.dir_name.clone());
145 }
146
147 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
148 if ui
149 .add_enabled(i + 1 < len, egui::Button::new("⬇"))
150 .clicked()
151 {
152 action = RowAction::MoveDown(i);
153 }
154 if ui.add_enabled(i > 0, egui::Button::new("⬆")).clicked() {
155 action = RowAction::MoveUp(i);
156 }
157 });
158 });
159 }
160
161 match action {
162 RowAction::None => false,
163 RowAction::ToggleEnabled(i) => {
164 state.mods[i].enabled = !state.mods[i].enabled;
165 persist_order(storage, state)
166 }
167 RowAction::Select(dir_name) => {
168 state.selected = Some(dir_name);
169 false
170 }
171 RowAction::MoveUp(i) => {
172 state.mods.swap(i, i - 1);
173 persist_order(storage, state)
174 }
175 RowAction::MoveDown(i) => {
176 state.mods.swap(i, i + 1);
177 persist_order(storage, state)
178 }
179 }
180}
181
182/// Persists the mods list's current order/enabled state. Returns whether
183/// it actually landed on disk, so the caller knows whether the active
184/// redirection rules (and thus e.g. `supreme_fs::GameStorage`'s cached
185/// hook resolver) need refreshing, not just on toggle/reorder failure,
186/// but also so a no-op frame doesn't trigger a refresh for nothing.
187fn persist_order(storage: &GameStorage, state: &ModsPageState) -> bool {
188 if let Err(e) = tasks::save_enabled_order(storage, &state.mods) {
189 eprintln!("Failed to save enabled mods: {e}");
190 false
191 } else {
192 true
193 }
194}
195
196fn render_mod_details(
197 ui: &mut egui::Ui,
198 storage: &GameStorage,
199 state: &mut ModsPageState,
200 dir_name: &str,
201) -> bool {
202 // Wrap entire details view in a scroll area so long parameter lists
203 // or descriptions don't get clipped. The closure returns whether a
204 // parameter edit was persisted this frame, propagated out via
205 // `ScrollAreaOutput::inner`: same signal `render_mod_list` gives for
206 // a toggle/reorder, so the caller can refresh the active redirection
207 // rules (and anything derived from them, like the Display page's
208 // renderer list) either way.
209 egui::ScrollArea::vertical()
210 .show(ui, |ui| {
211 let Some(m) = state.mods.iter_mut().find(|m| m.dir_name == dir_name) else {
212 return false;
213 };
214
215 render_mod_header(
216 ui,
217 &m.config.name,
218 m.config.version.as_deref(),
219 m.config.author.as_deref(),
220 m.config.license.as_deref(),
221 &m.warnings,
222 );
223
224 if let Some(description) = &m.config.description {
225 ui.add_space(6.0);
226 // Render markdown instead of plain text.
227 // egui_commonmark renders inline by default, so we use a vertical
228 // layout to make it behave like a block-level paragraph.
229 let mod_dir = tasks::mods_dir(storage).join(dir_name);
230 let image_base_uri = path_to_file_uri_prefix(&mod_dir);
231 ui.vertical(|ui| {
232 let mut cache = CommonMarkCache::default();
233 CommonMarkViewer::new()
234 .default_implicit_uri_scheme(image_base_uri)
235 .show(ui, &mut cache, description);
236 });
237 }
238
239 if let Some(err) = &m.load_error {
240 ui.add_space(10.0);
241 ui.colored_label(egui::Color32::RED, err);
242 return false;
243 }
244
245 let mut config_changed = false;
246
247 if !m.config.parameters.is_empty() {
248 ui.add_space(6.0);
249
250 // Images in a mod's help_text/description are given relative to the
251 // mod's own folder (e.g. `transparent-ground.png` or
252 // `.supreme/mods/transparent-things/transparent-ground.png`), so
253 // point egui_commonmark's implicit `file://` scheme straight at
254 // that folder. Building an absolute `file://` prefix up front
255 // avoids relying on egui_commonmark to resolve a relative path
256 // itself, which is what produced malformed `file://.supreme/...`
257 // URIs on Windows (parsed as a UNC host) and Wine.
258 let mod_dir = tasks::mods_dir(storage).join(dir_name);
259 let image_base_uri = path_to_file_uri_prefix(&mod_dir);
260
261 let parameters = m.config.parameters.clone();
262 let mut changed = false;
263
264 // Build the effective configuration once for enable_if evaluation.
265 // This resolves disabled parameters to their defaults so that
266 // dependent parameters see consistent values.
267 let effective = m.config.effective_configuration(&m.configuration);
268
269 for param in ¶meters {
270 let is_enabled = match ¶m.enable_if {
271 None => true,
272 Some(expr) => supreme_hook_config::script_engine::evaluate_bool_expression(
273 expr,
274 ¶meters,
275 &effective,
276 )
277 .unwrap_or(false),
278 };
279
280 // When a parameter becomes disabled, remove any user override
281 // so it cleanly falls back to its default. This also ensures
282 // the override doesn't linger and affect other expressions.
283 if !is_enabled && m.configuration.contains_key(¶m.id) {
284 m.configuration.remove(¶m.id);
285 changed = true;
286 }
287
288 // hidden parameters are launcher-set
289 // (`tournament_env`/`tournament_difficulty`/room id …
290 // written when the player creates or joins a room), they
291 // are declared so the resolver's enable_if expressions can
292 // see them, but a user has no business editing them. The
293 // cleanup above still runs for them; only the editor row
294 // is skipped.
295 if param.hidden {
296 continue;
297 }
298
299 ui.add_enabled_ui(is_enabled, |ui| {
300 let forced_error =
301 state
302 .tournament_param_error
303 .as_ref()
304 .is_some_and(|(dir, param_id)| {
305 dir == &m.dir_name && param_id == ¶m.id
306 });
307 changed |= render_parameter(
308 ui,
309 param,
310 &m.config.configuration,
311 &mut m.configuration,
312 &image_base_uri,
313 forced_error,
314 );
315 });
316 }
317
318 // Persisted below, after `m`'s borrow is done (the ui-schema
319 // block still needs it), same signal, one save.
320 if changed {
321 config_changed = true;
322 }
323 }
324
325 // The `ui` schema: generic buttons for enabled mods
326 // with a `ui` section, plus the current tournament selection
327 // summary. Clicks open popups / start background tournament
328 // actions, persistence for those flows through
329 // `tournament::poll_tasks` at the top of `mods_page`.
330 if m.enabled
331 && m.load_error.is_none()
332 && m.config
333 .ui
334 .as_ref()
335 .is_some_and(|ui_schema| !ui_schema.buttons.is_empty())
336 {
337 let action_in_flight = state.action_task.is_some();
338 // Split borrows: `m` holds the `state.mods` loan, these are
339 // the disjoint popup/room/account fields the buttons need.
340 let ModsPageState {
341 room_cache,
342 list_task,
343 open_popup,
344 popup_values,
345 popup_serial,
346 account: mod_account,
347 avatar,
348 login_popup,
349 ..
350 } = state;
351 tournament::render_mod_actions(
352 ui,
353 storage,
354 m,
355 action_in_flight,
356 tournament::RoomActions {
357 room_cache,
358 open_popup,
359 popup_values,
360 list_task,
361 popup_serial,
362 account: &*mod_account,
363 avatar: &*avatar,
364 login_popup,
365 },
366 );
367 }
368
369 if !config_changed {
370 return false;
371 }
372 if let Err(e) = tasks::persist_mod_configurations(storage, &state.mods) {
373 eprintln!("Failed to save mod configuration for '{dir_name}': {e}");
374 return false;
375 }
376 true
377 })
378 .inner
379}
380
381/// Renders the control for one parameter and applies any edit directly to
382/// `configuration`. Returns whether the value changed this frame, so the
383/// caller knows to persist.
384fn render_mod_header(
385 ui: &mut egui::Ui,
386 name: &str,
387 version: Option<&str>,
388 author: Option<&str>,
389 license: Option<&str>,
390 warnings: &[String],
391) {
392 let meta_parts: Vec<&str> = [version, author, license]
393 .into_iter()
394 .flatten()
395 .map(|s| s.trim())
396 .filter(|s| !s.is_empty())
397 .collect();
398
399 let labelled: Vec<String> = [
400 version.map(|v| format!("Version {v}")),
401 author.map(|a| format!("By {a}")),
402 license.map(|l| format!("License: {l}")),
403 ]
404 .into_iter()
405 .flatten()
406 .collect();
407 let meta_string = labelled.join(" · ");
408 let has_meta = !meta_parts.is_empty();
409
410 let title_wt = WidgetText::from(egui::RichText::new(name));
411 let title_w = title_wt
412 .clone()
413 .into_galley(
414 ui,
415 Some(egui::TextWrapMode::Truncate),
416 f32::INFINITY,
417 egui::TextStyle::Heading,
418 )
419 .size()
420 .x;
421 let meta_wt = if has_meta {
422 Some(WidgetText::from(RichText::new(meta_string.as_str()).weak()))
423 } else {
424 None
425 };
426 let meta_w = meta_wt
427 .as_ref()
428 .map(|wt| {
429 wt.clone()
430 .into_galley(
431 ui,
432 Some(egui::TextWrapMode::Truncate),
433 f32::INFINITY,
434 egui::TextStyle::Body,
435 )
436 .size()
437 .x
438 })
439 .unwrap_or(0.0);
440
441 // Inside a ScrollArea, available_width() can be unreliable on the first
442 // frame. Fall back to max_rect width which is always valid.
443 let available = ui.available_width().max(ui.max_rect().width());
444 let min_gap = ui.spacing().item_spacing.x * 2.0;
445
446 let fits_on_one_line = has_meta && title_w + min_gap + meta_w <= available;
447
448 if fits_on_one_line {
449 ui.horizontal(|ui| {
450 ui.heading(name);
451 render_warning_icon(ui, warnings);
452 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
453 ui.label(meta_wt.unwrap());
454 });
455 });
456 } else {
457 ui.horizontal(|ui| {
458 ui.heading(name);
459 render_warning_icon(ui, warnings);
460 });
461 if let Some(meta_wt) = meta_wt {
462 ui.label(meta_wt);
463 }
464 }
465}
466
467/// Renders a subtle ⚠️ icon inline after the mod title when there are
468/// unknown-key warnings. Hovering shows the full list. Does nothing when
469/// `warnings` is empty, so callers don't need to guard.
470fn render_warning_icon(ui: &mut egui::Ui, warnings: &[String]) {
471 if warnings.is_empty() {
472 return;
473 }
474 let warning_text = warnings.join("\n");
475 ui.label(
476 RichText::new("⚠️")
477 .size(14.0)
478 .color(ui.visuals().warn_fg_color),
479 )
480 .on_hover_text(warning_text);
481}
482
483fn render_parameter(
484 ui: &mut egui::Ui,
485 param: &ModParameter,
486 defaults: &HashMap<String, Value>,
487 configuration: &mut HashMap<String, Value>,
488 image_base_uri: &str,
489 forced_error: bool,
490) -> bool {
491 let mut changed = false;
492
493 egui::Frame::group(ui.style())
494 .fill(ui.visuals().faint_bg_color)
495 .inner_margin(egui::Margin::from(6.0))
496 .stroke(Stroke::NONE)
497 .corner_radius(4.0)
498 .show(ui, |ui| {
499 ui.set_min_width(ui.available_width());
500
501 ui.horizontal(|ui| {
502 ui.label(¶m.name);
503
504 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
505 match ¶m.kind {
506 ParameterKind::Boolean => {
507 let mut value = effective_value(¶m.id, defaults, configuration)
508 .and_then(Value::as_bool)
509 .unwrap_or(false);
510 if ui.add(toggle(&mut value)).changed() {
511 configuration.insert(param.id.clone(), Value::Bool(value));
512 changed = true;
513 }
514 }
515 ParameterKind::Dropdown { choices } => {
516 // Empty string reads as "(None)" for display, but only
517 // when `""` is itself one of `choices`, there's no
518 // implicit, always-available None option. A dropdown with
519 // no empty-string choice is a mandatory pick among
520 // `choices`, with no off state (and always reads truthy
521 // for gating once anything's been picked).
522 let current = effective_value(¶m.id, defaults, configuration)
523 .and_then(Value::as_str)
524 .unwrap_or("")
525 .to_owned();
526
527 egui::ComboBox::from_id_salt(¶m.id)
528 .selected_text(if current.is_empty() {
529 "(None)"
530 } else {
531 current.as_str()
532 })
533 .show_ui(ui, |ui| {
534 for choice in choices {
535 let label = if choice.is_empty() {
536 "(None)"
537 } else {
538 choice.as_str()
539 };
540 if ui.selectable_label(¤t == choice, label).clicked()
541 {
542 configuration.insert(
543 param.id.clone(),
544 Value::String(choice.clone()),
545 );
546 changed = true;
547 }
548 }
549 });
550 }
551 ParameterKind::Number { min, max, step } => {
552 let mut value = effective_value(¶m.id, defaults, configuration)
553 .and_then(Value::as_f64)
554 .map_or(*min, |v| v as f32);
555 let mut slider = egui::Slider::new(&mut value, *min..=*max);
556 if let Some(step) = step {
557 slider = slider.step_by(f64::from(*step));
558 }
559 let width = 180.0_f32.min(ui.available_width());
560 let response =
561 ui.add_sized([width, ui.spacing().interact_size.y], slider);
562 if response.changed() {
563 configuration.insert(param.id.clone(), serde_json::json!(value));
564 }
565 if response.drag_stopped() {
566 changed = true;
567 }
568 }
569 ParameterKind::Text => {
570 let mut value = effective_value(¶m.id, defaults, configuration)
571 .and_then(Value::as_str)
572 .unwrap_or("")
573 .to_owned();
574 let width = 200.0_f32.min(ui.available_width());
575 // Live validation on tagged text parameters (the
576 // visible tournament player name): the reason
577 // renders as an error stroke + a warning icon
578 // next to the control, both while editing and
579 // when a refused create/join forces the
580 // highlight on.
581 let mut reason = param.validation.as_deref().and_then(|tag| {
582 supreme_hook_config::validation::validate_value(tag, &value).err()
583 });
584 if reason.is_none() && forced_error {
585 reason = Some(format!(
586 "A tournament action was refused: {} needs a valid value.",
587 param.name
588 ));
589 }
590 let mut edited = false;
591 ui.horizontal(|ui| {
592 let response = ui.add_sized(
593 [width, ui.spacing().interact_size.y],
594 egui::TextEdit::singleline(&mut value),
595 );
596 edited = response.changed();
597 if let Some(reason) = &reason {
598 ui.painter().rect_stroke(
599 response.rect,
600 4.0,
601 egui::Stroke::new(1.6, ui.visuals().error_fg_color),
602 egui::StrokeKind::Inside,
603 );
604 ui.label(
605 RichText::new("\u{26A0}")
606 .color(ui.visuals().error_fg_color),
607 )
608 .on_hover_text(reason);
609 }
610 });
611 if edited {
612 configuration.insert(param.id.clone(), Value::String(value));
613 changed = true;
614 }
615 }
616 }
617 });
618 });
619
620 if !param.help_text.is_empty() {
621 let mut cache = CommonMarkCache::default();
622 CommonMarkViewer::new()
623 .default_implicit_uri_scheme(image_base_uri)
624 .show(ui, &mut cache, ¶m.help_text);
625 }
626 });
627
628 ui.add_space(8.0);
629
630 changed
631}
632
633/// Turns an absolute directory path into a `file://` prefix suitable for
634/// [`CommonMarkViewer::default_implicit_uri_scheme`], so that a relative
635/// image path in markdown (e.g. `foo.png` or `.supreme/mods/x/foo.png`)
636/// resolves unambiguously against that directory.
637///
638/// egui_commonmark builds the final image URI via plain string
639/// concatenation (`format!("{scheme}{relative_path}")`), so the prefix
640/// returned here must already end in `/` and must not leave any part of
641/// the relative path looking like a URI authority. In particular, a plain
642/// `"file://" + "some/relative/path"` is wrong on every platform: with no
643/// authority marker, the URI parser reads the first path segment as a
644/// hostname, and on Windows that host is then resolved as a UNC network
645/// share (which is where "Error 37: network name cannot be found" comes
646/// from), Wine reports the equivalent failure as a plain path-not-found.
647/// Anchoring the scheme to an absolute path up front avoids the ambiguity
648/// entirely.
649///
650/// `dir` is expected to already be absolute (join of a known storage root
651/// with a mod's directory name); if it isn't, it's resolved relative to
652/// the current working directory as a best effort, since a relative path
653/// here would reintroduce the same ambiguity this function exists to avoid.
654fn path_to_file_uri_prefix(dir: &Path) -> String {
655 let dir = if dir.is_absolute() {
656 dir.to_path_buf()
657 } else {
658 std::env::current_dir()
659 .map(|cwd| cwd.join(dir))
660 .unwrap_or_else(|_| dir.to_path_buf())
661 };
662
663 let mut s = dir.to_string_lossy().replace('\\', "/");
664 if !s.ends_with('/') {
665 s.push('/');
666 }
667
668 if s.starts_with('/') {
669 // Already-absolute Unix path: file:///path/to/mod/
670 format!("file://{s}")
671 } else {
672 // Windows drive-letter path (e.g. "C:/Users/.../mod/"): needs an
673 // extra leading slash for the empty-authority form.
674 format!("file:///{s}")
675 }
676}
677
678/// The value actually shown for a parameter: an explicit override (from
679/// the active preset's configuration in `mods.json`, what a person
680/// has actually changed via the launcher) if one exists, else the mod's
681/// own default from its `mod.json` `configuration` block, else unset
682/// entirely.
683fn effective_value<'a>(
684 id: &str,
685 defaults: &'a HashMap<String, Value>,
686 overrides: &'a HashMap<String, Value>,
687) -> Option<&'a Value> {
688 overrides.get(id).or_else(|| defaults.get(id))
689}
690
691// ---------------------------------------------------------------------------
692// iOS‑style toggle switch (from egui demo)
693// ---------------------------------------------------------------------------
694
695pub fn toggle_ui(ui: &mut egui::Ui, on: &mut bool) -> egui::Response {
696 let desired_size = ui.spacing().interact_size.y * egui::vec2(2.0, 1.0);
697 let (rect, mut response) = ui.allocate_exact_size(desired_size, egui::Sense::click());
698
699 if response.clicked() {
700 *on = !*on;
701 response.mark_changed();
702 }
703 response.widget_info(|| WidgetInfo::selected(WidgetType::Checkbox, ui.is_enabled(), *on, ""));
704
705 if ui.is_rect_visible(rect) {
706 let how_on = ui.ctx().animate_bool_responsive(response.id, *on);
707 let visuals = ui.style().interact_selectable(&response, *on);
708 let rect = rect.expand(visuals.expansion);
709 let radius = 0.5 * rect.height();
710 ui.painter().rect(
711 rect,
712 radius,
713 visuals.bg_fill,
714 visuals.bg_stroke,
715 StrokeKind::Inside,
716 );
717 let circle_x = egui::lerp((rect.left() + radius)..=(rect.right() - radius), how_on);
718 let center = egui::pos2(circle_x, rect.center().y);
719 ui.painter()
720 .circle(center, 0.75 * radius, visuals.bg_fill, visuals.fg_stroke);
721 }
722
723 response
724}
725
726pub fn toggle(on: &mut bool) -> impl egui::Widget + '_ {
727 move |ui: &mut egui::Ui| toggle_ui(ui, on)
728}