Skip to main content

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 &parameters {
270                    let is_enabled = match &param.enable_if {
271                        None => true,
272                        Some(expr) => supreme_hook_config::script_engine::evaluate_bool_expression(
273                            expr,
274                            &parameters,
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(&param.id) {
284                        m.configuration.remove(&param.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 == &param.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(&param.name);
503
504                ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
505                    match &param.kind {
506                        ParameterKind::Boolean => {
507                            let mut value = effective_value(&param.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(&param.id, defaults, configuration)
523                                .and_then(Value::as_str)
524                                .unwrap_or("")
525                                .to_owned();
526
527                            egui::ComboBox::from_id_salt(&param.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(&current == 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(&param.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(&param.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, &param.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}