From b3fc3c5fa3cf67fac3f92dc2826246da01b68af9 Mon Sep 17 00:00:00 2001 From: Luc Ruigrok <64922122+Luc-nbr@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:12:05 +0100 Subject: [PATCH 01/11] feat(ui): interface themes for the game's own screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mod or a plugin can now give the game's own screens a look of their own, chosen in Options › Interface › Interface theme (docs/THEMES.md). A theme is a folder with a theme.json (Themes// of a content folder, or a plugin's own folder): - the palette: omsi_ui::theme's constants are now a Palette in force (theme::th(), set by themes::sync from the setting ui_theme), read by the widget kit, the pause menu, its settings windows and lists, the photo mode, the developer tools, the permission dialog, the headset's menu and the plugins' panels and notifications; colours a theme leaves out follow the ones they go with; - glass: Ui::surface draws a panel as frosted glass - the game's picture behind the pause menu, rendered small when the menu opens (and every 1.5 s while a LAN game goes on), blurred on the CPU, under the panel's colour, with a light edge and a sheen; the rail becomes a floating pane; - the loading screen: the theme's colours and logo, which can be the loading bar itself - its path id="progress" drawn along (icons::path_length, icons::progress_svg) or the logo filled from the left as far as the loading is; - the pause menu's logo and name. openOMSI's own look is unchanged and stays the default. Plugins see the themes with ui.themes and ui.theme and can switch with ui.set_theme (omsi.ui.version 6; the API docs regenerated). --- CHANGELOG.md | 11 + crates/omsi-app/src/app.rs | 1 + crates/omsi-app/src/app/groups.rs | 2 + .../src/app_events/frame/integrations.rs | 19 + .../omsi-app/src/app_events/frame/present.rs | 45 ++ crates/omsi-app/src/devtools/panel.rs | 24 +- crates/omsi-app/src/game_lists/dropdowns.rs | 2 + crates/omsi-app/src/game_lists/menu_pages.rs | 1 + crates/omsi-app/src/lib.rs | 2 + crates/omsi-app/src/photo/panel.rs | 56 +- .../src/process_permissions/dialog.rs | 4 +- crates/omsi-app/src/settings.rs | 11 +- crates/omsi-app/src/shell/mod.rs | 55 +- crates/omsi-app/src/shell/pause.rs | 109 ++-- .../omsi-app/src/shell/pause/navigation_ui.rs | 82 +-- .../src/shell/pause/navigation_ui_tests.rs | 4 +- .../src/shell/pause/quick_actions_ui.rs | 16 +- .../src/shell/pause/settings_ui_tests.rs | 2 +- crates/omsi-app/src/themes.rs | 533 ++++++++++++++++++ crates/omsi-app/src/ui.rs | 134 +++-- crates/omsi-app/src/ui/menu.rs | 100 ++-- crates/omsi-launcher-core/src/lib.rs | 5 +- crates/omsi-plugin-ui/src/lib.rs | 24 +- crates/omsi-plugin-ui/src/nav.rs | 8 +- crates/omsi-plugin-ui/src/widgets.rs | 4 +- crates/omsi-plugin/src/api/fns/game.rs | 13 + crates/omsi-plugin/src/ui.rs | 11 +- crates/omsi-plugin/src/ui/theme.rs | 72 +++ crates/omsi-ui/src/icons.rs | 127 +++++ crates/omsi-ui/src/kit.rs | 168 +++--- crates/omsi-ui/src/theme.rs | 300 ++++++++-- docs/PLUGINS.md | 23 +- docs/THEMES.md | 98 ++++ docs/plugin-api.json | 36 ++ 34 files changed, 1772 insertions(+), 330 deletions(-) create mode 100644 crates/omsi-app/src/themes.rs create mode 100644 crates/omsi-plugin/src/ui/theme.rs create mode 100644 docs/THEMES.md diff --git a/CHANGELOG.md b/CHANGELOG.md index bb65d21d1..0b42df485 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ promoted to stable releases (see [docs/VERSIONING.md](docs/VERSIONING.md)); the ### New +- **Interface themes**: a mod or a plugin can give the game's own screens a look of their own, + chosen in Options › Interface › Interface theme ([docs/THEMES.md](docs/THEMES.md)). A theme is a + folder with a `theme.json` (`Themes//` of a content folder, or a plugin's own folder): the + colours of the pause menu, its settings windows and lists, the photo mode, the developer tools + and the plugins' panels and notifications; the corners of the panels; the panels as frosted + glass over the game's picture (blurred when the menu opens, and every second and a half while + the game goes on behind it); the logo and the name at the top of the pause menu; and the + loading screen's colours and logo, which can itself be the loading bar - its path + `id="progress"` drawn along, or the logo filled from the left, as far as the loading is. + openOMSI's own look is unchanged and stays the default. Plugins see the themes with + `ui.themes` and `ui.theme` and can switch to one with `ui.set_theme` (`omsi.ui.version` 6). - **LAN protocol 15**: a game of this version cannot join a session of an older one, nor the other way round (the AI traffic goes as a table, see below). Update the game on both sides. - **Every light casts a shadow** (Enhanced and Enhanced+): the lamps' shadow maps were four, now diff --git a/crates/omsi-app/src/app.rs b/crates/omsi-app/src/app.rs index c17ebcc64..ee21d1dbb 100644 --- a/crates/omsi-app/src/app.rs +++ b/crates/omsi-app/src/app.rs @@ -832,6 +832,7 @@ impl App { /// One loading screen picture (the map, or our bus going to the server): `title`, bar at `shown`. fn loading_frame(&mut self, renderer: omsi_render::Renderer, mut scene: Scene, title: &str, shown: f32) { let mut reconfigure = false; + crate::themes::sync(&self.settings.ui_theme); if let (Some(ui), Some(s), Some(win)) = ( self.ui.as_mut(), self.gfx.surface.as_ref(), diff --git a/crates/omsi-app/src/app/groups.rs b/crates/omsi-app/src/app/groups.rs index 30c00bec2..2217783ca 100644 --- a/crates/omsi-app/src/app/groups.rs +++ b/crates/omsi-app/src/app/groups.rs @@ -424,6 +424,8 @@ pub(crate) struct MenuState { pub(crate) hover_hand: bool, /// The game menu (Escape, OMSI's `open_mainmenue`): the chosen line of it. pub(crate) game_menu: Option, + /// When the picture behind a glass theme's pause menu was last taken (`frame_glass_backdrop`). + pub(crate) glass_taken: Option, /// The first line of the game menu (or chooser) shown, when a finger has scrolled it /// (in lines, fractional while dragged); `None`: the chosen line is kept in view. pub(crate) menu_top: Option, diff --git a/crates/omsi-app/src/app_events/frame/integrations.rs b/crates/omsi-app/src/app_events/frame/integrations.rs index 035a5f0d9..a1ec92c87 100644 --- a/crates/omsi-app/src/app_events/frame/integrations.rs +++ b/crates/omsi-app/src/app_events/frame/integrations.rs @@ -50,6 +50,17 @@ impl App { // the mouse on the plugins' script textures since their frame, and which those are now plugins.pointer = std::mem::take(&mut self.integrations.plugin_pointer.pending); self.integrations.plugin_pointer.pages = plugins.pointer_textures(); + if !plugins.is_empty() { + // the interface themes there are, and the one in use (`ui.themes`, `ui.theme`) + let mut ui = plugins.ui.borrow_mut(); + if ui.themes().is_empty() { + let list = crate::themes::choices().iter().map(|(id, name)| omsi_plugin::ui::ThemeInfo { id: id.clone(), name: name.clone() }).collect(); + ui.set_themes(list, &self.settings.ui_theme); + } else if ui.theme() != self.settings.ui_theme { + let list = ui.themes().to_vec(); + ui.set_themes(list, &self.settings.ui_theme); + } + } if !plugins.is_empty() && !self.paused { let keys = std::mem::take(&mut self.integrations.plugin_keys); let events = std::mem::take(&mut self.integrations.plugin_events); @@ -97,7 +108,15 @@ impl App { } // the passengers report their tickets only while a plugin hears of them let hear_tickets = plugins.hear_tickets(); + // a plugin switched the interface theme (`ui.set_theme`): the player's setting, as if + // chosen in Options › Interface + let theme_ask = plugins.ui.borrow_mut().take_theme_ask(); self.integrations.plugins = Some(plugins); + if let Some(id) = theme_ask.filter(|id| *id != self.settings.ui_theme) { + log::info!("plugins: the interface theme \"{id}\" asked for"); + let value = if id.is_empty() { crate::themes::OWN.to_string() } else { id }; + crate::game_lists::store_with(self, |v| v["ui_theme"] = serde_json::json!(value)); + } // OMSI_WATCH_VARS=a,b: every change of those variables of the player's bus if let (Some(p), Some(list)) = (self.player.as_ref(), omsi_cfg::flags::OMSI_WATCH_VARS.var()) { thread_local!(static LAST: std::cell::RefCell> = Default::default()); diff --git a/crates/omsi-app/src/app_events/frame/present.rs b/crates/omsi-app/src/app_events/frame/present.rs index 2ebfb8d31..414625e1b 100644 --- a/crates/omsi-app/src/app_events/frame/present.rs +++ b/crates/omsi-app/src/app_events/frame/present.rs @@ -3,6 +3,10 @@ use super::*; +/// How often the picture behind a glass theme's pause menu is taken again while the game goes +/// on behind it (seconds). +const GLASS_AGAIN: f32 = 1.5; + crate::app::stage_ctx!( /// The lighting stage's part of the game. LightingCx { reads: [net, world, clock], writes: [weather, player] } @@ -46,6 +50,7 @@ impl App { ) { let mut finish = false; let mut reconfigure = false; + crate::themes::sync(&self.settings.ui_theme); let shot = self.perf.shot.take(); if let Some(s) = self.gfx.surface.as_ref() { let (w, h) = (s.config.width, s.config.height); @@ -68,6 +73,8 @@ impl App { if self.photo.is_some() { self.photo_render(lighting); } + // the picture behind a glass theme's pause menu + self.frame_glass_backdrop(lighting); let (frame, view, shown_nothing) = self.frame_acquire(&mut reconfigure); let presented = frame.is_some(); match view { @@ -99,6 +106,44 @@ impl App { } } + /// A glass theme's pause menu: the game's picture behind it, small and blurred, for its + /// panels to show through them (`themes::blur`) - taken when the menu opens, and again + /// now and then while the game goes on behind it (not paused: a LAN session). + fn frame_glass_backdrop(&mut self, lighting: &omsi_render::Lighting) { + let glass = omsi_ui::theme::th().glass.filter(|_| self.menus.game_menu.is_some() && self.photo.is_none() && !self.vr_active()); + let Some(glass) = glass else { + if self.shell.has_backdrop() { + self.shell.drop_backdrop(); + } + self.menus.glass_taken = None; + return; + }; + let stale = self.menus.glass_taken.is_none_or(|t| !self.paused && t.elapsed().as_secs_f32() > GLASS_AGAIN); + if self.shell.has_backdrop() && !stale { + return; + } + let (Some(s), Some(r), Some(scene), Some(cam)) = (self.gfx.surface.as_ref(), self.renderer.as_mut(), self.scene.as_mut(), self.camera.as_ref()) else { + return; + }; + let (w, h) = (s.config.width, s.config.height); + let (bw, bh) = crate::themes::backdrop_size(w, h); + // (twice as large, then halved: the small picture's edges smooth) + let hud = std::mem::take(&mut scene.overlays); + let picture = r.render_to_image(scene, bw * 2, bh * 2, cam, lighting); + scene.overlays = hud; + self.menus.glass_taken = Some(Instant::now()); + match picture { + Ok(px) => { + let mut px = crate::themes::halve(&px, bw * 2, bh * 2); + // (the blur's radius in the window's points, in the small picture's pixels) + let k = bw as f32 / w.max(1) as f32 * self.shell.scale; + crate::themes::blur(&mut px, bw as usize, bh as usize, (glass.blur * k).round() as usize); + self.shell.set_backdrop(bw, bh, px); + } + Err(e) => log::warn!("glass theme: the picture behind the pause menu could not be rendered: {e}"), + } + } + /// `shot ` from the input script. fn frame_shot(&mut self, shot: (PathBuf, bool), lighting: &omsi_render::Lighting) { let (Some(s), Some(r), Some(scene), Some(cam)) = ( diff --git a/crates/omsi-app/src/devtools/panel.rs b/crates/omsi-app/src/devtools/panel.rs index ab831ae69..13c4e887d 100644 --- a/crates/omsi-app/src/devtools/panel.rs +++ b/crates/omsi-app/src/devtools/panel.rs @@ -23,12 +23,12 @@ pub(crate) fn draw(ui: &mut Ui, d: &mut DevTools, mut p: Option<&mut Player>, si let r = Rect::new((size.x - WIDTH - 12.0).max(0.0), 12.0, WIDTH.min(size.x), (size.y - 24.0).max(200.0)); ui.panel(r); let inner = r.inset(14.0); - ui.text(&omsi_ui::tr("Developer tools"), Vec2::new(inner.x, inner.y + 15.0), 16.0, Weight::Bold, TEXT, Align::Left); + ui.text(&omsi_ui::tr("Developer tools"), Vec2::new(inner.x, inner.y + 15.0), 16.0, Weight::Bold, th().text, Align::Left); if ui.icon_button("dt-close", Vec2::new(inner.right() - 10.0, inner.y + 9.0), 12.0, "close", "Close (Ctrl+Shift+D)") { out.push(Request::Close); } let bus = p.as_ref().map(|p| p.vehicle.ty.def.path.file_name().unwrap_or_default().to_string_lossy().to_string()).unwrap_or_else(|| omsi_ui::tr("no bus of one's own").into_owned()); - ui.text_in(&bus, Rect::new(inner.x, inner.y + 24.0, inner.w, 18.0), 12.0, Weight::Regular, TEXT_DIM, Align::Left); + ui.text_in(&bus, Rect::new(inner.x, inner.y + 24.0, inner.w, 18.0), 12.0, Weight::Regular, th().text_dim, Align::Left); let tabs = TABS.map(|tab| omsi_ui::tr(tab).into_owned()); let tab_labels = tabs.iter().map(|tab| tab.as_str()).collect::>(); ui.segmented("dt-tabs", Rect::new(inner.x, inner.y + 48.0, inner.w, 30.0), &mut d.tab, &tab_labels); @@ -77,7 +77,7 @@ fn variables(ui: &mut Ui, d: &mut DevTools, mut p: Option<&mut Player>, r: Rect) let (watch, selected) = (&d.watch, d.selected); ui.scroll_area("dt-watch", Rect::new(r.x, y, r.w, watch_h), &mut |ui, area| { if watch.is_empty() { - ui.text_in(&omsi_ui::tr("Nothing watched: pick variables from the list below"), Rect::new(area.x, area.y, area.w, LINE), 12.0, Weight::Regular, TEXT_FAINT, Align::Left); + ui.text_in(&omsi_ui::tr("Nothing watched: pick variables from the list below"), Rect::new(area.x, area.y, area.w, LINE), 12.0, Weight::Regular, th().text_faint, Align::Left); return LINE; } for (k, w) in watch.iter().enumerate() { @@ -89,9 +89,9 @@ fn variables(ui: &mut Ui, d: &mut DevTools, mut p: Option<&mut Player>, r: Rect) choose = Some(k); } let name = if w.string { format!("${}", w.name) } else { w.name.clone() }; - ui.text_in(&name, Rect::new(row.x + 8.0, row.y, row.w * 0.55, row.h), 12.5, Weight::Medium, TEXT, Align::Left); + ui.text_in(&name, Rect::new(row.x + 8.0, row.y, row.w * 0.55, row.h), 12.5, Weight::Medium, th().text, Align::Left); let held = if w.hold.is_some() { format!(" [{}]", omsi_ui::tr("held")) } else { String::new() }; - ui.text_in(&format!("{}{held}", value_of(pv, &w.name, w.string)), Rect::new(row.x + row.w * 0.55, row.y, row.w * 0.45 - 34.0, row.h), 12.5, Weight::Regular, if w.hold.is_some() { ACCENT } else { TEXT_SOFT }, Align::Right); + ui.text_in(&format!("{}{held}", value_of(pv, &w.name, w.string)), Rect::new(row.x + row.w * 0.55, row.y, row.w * 0.45 - 34.0, row.h), 12.5, Weight::Regular, if w.hold.is_some() { th().accent } else { th().text_soft }, Align::Right); if ui.icon_button(&format!("dt-wx{k}"), Vec2::new(row.right() - 12.0, row.center().y), 10.0, "close", "Stop watching") { remove = Some(k); } @@ -152,8 +152,8 @@ fn variables(ui: &mut Ui, d: &mut DevTools, mut p: Option<&mut Player>, r: Rect) if ui.row(&format!("dt-a{k}"), row, false) { add = Some(n.to_string()); } - ui.text_in(n, Rect::new(row.x + 8.0, row.y, row.w * 0.6, row.h), 12.5, Weight::Regular, TEXT_SOFT, Align::Left); - ui.text_in(&value_of(Some(pl), n, strings), Rect::new(row.x + row.w * 0.6, row.y, row.w * 0.4 - 8.0, row.h), 12.5, Weight::Regular, TEXT_DIM, Align::Right); + ui.text_in(n, Rect::new(row.x + 8.0, row.y, row.w * 0.6, row.h), 12.5, Weight::Regular, th().text_soft, Align::Left); + ui.text_in(&value_of(Some(pl), n, strings), Rect::new(row.x + row.w * 0.6, row.y, row.w * 0.4 - 8.0, row.h), 12.5, Weight::Regular, th().text_dim, Align::Right); } found.len() as f32 * LINE }); @@ -230,7 +230,7 @@ fn mirrors(ui: &mut Ui, d: &mut DevTools, p: Option<&mut Player>, r: Rect, out: let n = p.vehicle.ty.def.cameras_reflexion.len(); let mut y = r.y; if n == 0 { - ui.text_in(&omsi_ui::tr("This bus has no mirrors"), Rect::new(r.x, y, r.w, LINE), 13.0, Weight::Regular, TEXT_DIM, Align::Left); + ui.text_in(&omsi_ui::tr("This bus has no mirrors"), Rect::new(r.x, y, r.w, LINE), 13.0, Weight::Regular, th().text_dim, Align::Left); return; } d.mirror = d.mirror.min(n - 1); @@ -244,7 +244,7 @@ fn mirrors(ui: &mut Ui, d: &mut DevTools, p: Option<&mut Player>, r: Rect, out: let yaw = omsi_ui::tr("yaw"); let pitch = omsi_ui::tr("pitch"); let fov = omsi_ui::tr("fov"); - ui.text_in(&format!("{k} {:.2}, {:.2}, {:.2} {yaw} {:.0} {pitch} {:.0} {fov} {:.0}", c.pos[0], c.pos[1], c.pos[2], c.yaw, c.pitch, c.fov), row.pad(8.0, 0.0), 12.0, Weight::Regular, TEXT_SOFT, Align::Left); + ui.text_in(&format!("{k} {:.2}, {:.2}, {:.2} {yaw} {:.0} {pitch} {:.0} {fov} {:.0}", c.pos[0], c.pos[1], c.pos[2], c.yaw, c.pitch, c.fov), row.pad(8.0, 0.0), 12.0, Weight::Regular, th().text_soft, Align::Left); y += LINE; } y += 8.0; @@ -283,7 +283,7 @@ fn mirrors(ui: &mut Ui, d: &mut DevTools, p: Option<&mut Player>, r: Rect, out: y += 42.0; if let Some(block) = mirror_block(p, i) { for line in block.lines() { - ui.text_in(line, Rect::new(r.x + 4.0, y, r.w, 18.0), 12.0, Weight::Regular, TEXT_SOFT, Align::Left); + ui.text_in(line, Rect::new(r.x + 4.0, y, r.w, 18.0), 12.0, Weight::Regular, th().text_soft, Align::Left); y += 17.0; } } @@ -315,7 +315,7 @@ fn files(ui: &mut Ui, d: &mut DevTools, r: Rect, out: &mut Vec) { if y + h > r.bottom() { break; } - ui.paragraph(m, Vec2::new(r.x, y), r.w, 12.0, Weight::Regular, TEXT_SOFT); + ui.paragraph(m, Vec2::new(r.x, y), r.w, 12.0, Weight::Regular, th().text_soft); y += h + 6.0; } if y + 26.0 < r.bottom() { @@ -334,7 +334,7 @@ fn files(ui: &mut Ui, d: &mut DevTools, r: Rect, out: &mut Vec) { Kind::Constfiles => "Constant files", Kind::Scripts => "Scripts", }; - ui.text_in(&format!("{name} ({})", omsi_ui::tr(kind)), Rect::new(r.x, y, r.w, 16.0), 11.5, Weight::Regular, TEXT_DIM, Align::Left); + ui.text_in(&format!("{name} ({})", omsi_ui::tr(kind)), Rect::new(r.x, y, r.w, 16.0), 11.5, Weight::Regular, th().text_dim, Align::Left); y += 16.0; } } diff --git a/crates/omsi-app/src/game_lists/dropdowns.rs b/crates/omsi-app/src/game_lists/dropdowns.rs index 72b56b0a9..db9452e0c 100644 --- a/crates/omsi-app/src/game_lists/dropdowns.rs +++ b/crates/omsi-app/src/game_lists/dropdowns.rs @@ -298,6 +298,8 @@ pub(super) fn select_options(key: &str) -> Vec<(&'static str, &'static str)> { // (OMSI's AIMaxHumans, #2087) "ai_max_humans" => vec![("50", "50"), ("100", "100"), ("200", "200"), ("400", "400"), ("720", "720"), ("1000", "1000")], "language" => omsi_launcher_lib::LANGUAGES.iter().map(|l| (l.0, l.1)).collect(), + // (openOMSI's own look as "openomsi": the setting reads it as "") + "ui_theme" => crate::themes::choices().iter().map(|(id, name)| (if id.is_empty() { crate::themes::OWN } else { id.as_str() }, name.as_str())).collect(), "vr_scale" => vec![("0.5", "50%"), ("0.65", "65%"), ("0.8", "80%"), ("1", "100%")], "vr_head_smoothing_ms" => vec![("0", "Off"), ("5", "5 ms"), ("10", "10 ms"), ("20", "20 ms"), ("30", "30 ms")], "vr_mirror_rate" => vec![("0", "Off"), ("8", "8/s"), ("16", "16/s"), ("24", "24/s"), ("32", "32/s"), ("48", "48/s"), ("60", "60/s"), ("90", "90/s"), ("120", "120/s"), ("180", "180/s"), ("240", "240/s"), ("360", "360/s"), ("-1", "Every frame")], diff --git a/crates/omsi-app/src/game_lists/menu_pages.rs b/crates/omsi-app/src/game_lists/menu_pages.rs index 807c6979e..3a02ce965 100644 --- a/crates/omsi-app/src/game_lists/menu_pages.rs +++ b/crates/omsi-app/src/game_lists/menu_pages.rs @@ -258,6 +258,7 @@ pub(super) fn options_pages(app: &App) -> Vec { let interface: Vec<(String, String)> = vec![ pick("language", "Language", "The language of the game's interface"), pick("units", "Units", "Speeds, distances and temperatures in the game's own texts"), + pick("ui_theme", "Interface theme", "The look of the menus, the panels and the loading screen: openOMSI's own, or one a mod or plugin brings"), switch_row(app, "machine_translation", "Translate the remaining texts automatically (offline, downloads 620 MB once)", "Translates texts nobody has translated, on this machine"), slider_row(app, "ui_scale", "Game interface size", "The size of the texts, the menu, the timetable and the navigator", &pct), switch_row(app, "ui_scale_window", "Interface grows with the window", "On a window taller than 1080p the interface grows with it"), diff --git a/crates/omsi-app/src/lib.rs b/crates/omsi-app/src/lib.rs index 7c91cae08..d1767420b 100644 --- a/crates/omsi-app/src/lib.rs +++ b/crates/omsi-app/src/lib.rs @@ -100,6 +100,7 @@ mod photo; mod depot_bus; mod devtools; mod telemetry; +mod themes; use omsi_scene::threads; use omsi_scene::tiles; mod traffic; @@ -713,6 +714,7 @@ fn assemble_app(args: Args, settings: settings::Settings) -> App { hover_part: None, hover_hand: false, game_menu: None, + glass_taken: None, menu_top: None, menu_scroll_drag: false, dd_scroll_drag: None, diff --git a/crates/omsi-app/src/photo/panel.rs b/crates/omsi-app/src/photo/panel.rs index c7cda285e..6ab5209f0 100644 --- a/crates/omsi-app/src/photo/panel.rs +++ b/crates/omsi-app/src/photo/panel.rs @@ -97,7 +97,7 @@ fn stepped_in(ui: &mut Ui, name: &str, r: Rect, at: &mut usize, lo: usize, hi: u /// A setting the camera chose by itself, shown as a camera's display shows it. fn auto_row(ui: &mut Ui, r: Rect, label: &str, value: &str) { ui.label(Rect::new(r.x, r.y, r.w * 0.5, r.h), label); - ui.text_in(value, Rect::new(r.x + r.w * 0.5, r.y, r.w * 0.5, r.h), 13.0, Weight::Bold, ACCENT, Align::Right); + ui.text_in(value, Rect::new(r.x + r.w * 0.5, r.y, r.w * 0.5, r.h), 13.0, Weight::Bold, th().accent, Align::Right); } /// The rows of the open page, from `y` down; returns where they end. @@ -185,7 +185,7 @@ fn pro_page(ui: &mut Ui, ph: &mut Photo, info: &Info, x: f32, mut y: f32, w: f32 ui.label(Rect::new(x, y, w * 0.30, ROW - 2.0), "Mode"); ui.segmented("ph-xmode", Rect::new(x + w * 0.30, y, w * 0.70, ROW - 2.0), &mut e.mode, &exposure::MODES); y += ROW + 4.0; - note(ui, &mut y, ["Program: the camera sets the aperture and the time.", "Aperture priority: you set the aperture, the camera the time.", "Shutter priority: you set the time, the camera the aperture.", "Manual: you set everything; the meter shows how far off it is."][e.mode.min(3)], TEXT_DIM); + note(ui, &mut y, ["Program: the camera sets the aperture and the time.", "Aperture priority: you set the aperture, the camera the time.", "Shutter priority: you set the time, the camera the aperture.", "Manual: you set everything; the meter shows how far off it is."][e.mode.min(3)], th().text_dim); if e.mode == 1 || e.mode == 3 { stepped_in(ui, "ph-xap", row(&mut y), &mut e.aperture, lens_lo, lens_hi, "Aperture", &|k| format!("f/{}", exposure::APERTURES[k])); } else { @@ -211,13 +211,13 @@ fn pro_page(ui: &mut Ui, ph: &mut Photo, info: &Info, x: f32, mut y: f32, w: f32 let r = row(&mut y); meter_scale(ui, r, m.offset); if m.offset.abs() > 0.5 && e.mode != 3 { - note(ui, &mut y, "The lens or the shutter ran out of range: the photo comes out off the meter.", WARN); + note(ui, &mut y, "The lens or the shutter ran out of range: the photo comes out off the meter.", th().warn); } } section(ui, &mut y, "Shutter"); motion_row(ui, row(&mut y), s); if s.motion && !info.bus_moving { - note(ui, &mut y, "The bus stands still: nothing to blur.", WARN); + note(ui, &mut y, "The bus stands still: nothing to blur.", th().warn); } let labels: Vec<&str> = BRACKETS.iter().map(|b| b.1).collect(); select_row(ui, "ph-bracket", row(&mut y), "Exposure bracketing", &mut s.bracket, &labels); @@ -257,7 +257,7 @@ fn pro_page(ui: &mut Ui, ph: &mut Photo, info: &Info, x: f32, mut y: f32, w: f32 if ui.button("ph-hyper", row(&mut y), &omsi_ui::tr("Focus at the hyperfocal distance"), Some("landscape"), ButtonKind::Normal) { acts.push(Request::Hyperfocal); } - note(ui, &mut y, "Click on the picture to focus on what is there.", TEXT_DIM); + note(ui, &mut y, "Click on the picture to focus on what is there.", th().text_dim); section(ui, &mut y, "Bokeh"); let labels: Vec<&str> = BLADES.iter().map(|b| b.1).collect(); select_row(ui, "ph-blades", row(&mut y), "Aperture blades", &mut s.blades, &labels); @@ -267,7 +267,7 @@ fn pro_page(ui: &mut Ui, ph: &mut Photo, info: &Info, x: f32, mut y: f32, w: f32 ui.slider("ph-swing", row(&mut y), &mut s.tilt[1], -8.0, 8.0, 0.05, "Swing", &|v| format!("{v:+.2}°")); ui.slider("ph-shiftv", row(&mut y), &mut s.shift[1], -12.0, 12.0, 0.5, "Shift up / down", &|v| format!("{v:+.1} mm")); ui.slider("ph-shifth", row(&mut y), &mut s.shift[0], -12.0, 12.0, 0.5, "Shift right / left", &|v| format!("{v:+.1} mm")); - note(ui, &mut y, "Tilt leans the plane of focus (a miniature look, or the whole street sharp); shift keeps the verticals upright.", TEXT_DIM); + note(ui, &mut y, "Tilt leans the plane of focus (a miniature look, or the whole street sharp); shift keeps the verticals upright.", th().text_dim); section(ui, &mut y, "Quality"); let labels: Vec<&str> = QUALITY.iter().map(|q| q.1).collect(); select_row(ui, "ph-quality", row(&mut y), "Samples", &mut s.quality, &labels); @@ -322,7 +322,7 @@ fn pro_page(ui: &mut Ui, ph: &mut Photo, info: &Info, x: f32, mut y: f32, w: f32 section(ui, &mut y, "Time of day"); let r = row(&mut y); ui.label(Rect::new(r.x, r.y, r.w * 0.38, r.h), "Clock"); - ui.text_in(&info.time, Rect::new(r.x + r.w * 0.38, r.y, r.w * 0.62, r.h), 15.0, Weight::Bold, ACCENT, Align::Center); + ui.text_in(&info.time, Rect::new(r.x + r.w * 0.38, r.y, r.w * 0.62, r.h), 15.0, Weight::Bold, th().accent, Align::Center); let r = row(&mut y); let bw = (r.w - 18.0) / 4.0; for (k, (text, secs)) in [("-1 h", -3600.0), ("-15 min", -900.0), ("+15 min", 900.0), ("+1 h", 3600.0)].into_iter().enumerate() { @@ -334,7 +334,7 @@ fn pro_page(ui: &mut Ui, ph: &mut Photo, info: &Info, x: f32, mut y: f32, w: f32 let labels: Vec<&str> = SIZES.iter().map(|z| z.1).collect(); select_row(ui, "ph-size", row(&mut y), "Photo size", &mut s.size, &labels); ui.toggle("ph-png16", row(&mut y), &mut s.png16, "16 bits a channel (for editing)"); - note(ui, &mut y, "Photos are saved as PNG into the Screenshots folder of the game, with the camera's settings (aperture, time, ISO, focal length) in their metadata.", TEXT_DIM); + note(ui, &mut y, "Photos are saved as PNG into the Screenshots folder of the game, with the camera's settings (aperture, time, ISO, focal length) in their metadata.", th().text_dim); y += 18.0; } } @@ -345,15 +345,15 @@ fn pro_page(ui: &mut Ui, ph: &mut Photo, info: &Info, x: f32, mut y: f32, w: f32 fn meter_scale(ui: &mut Ui, r: Rect, offset: f32) { ui.label(Rect::new(r.x, r.y, r.w * 0.22, r.h), "Meter"); let s = Rect::new(r.x + r.w * 0.24, r.y + r.h * 0.5 - 1.0, r.w * 0.62, 2.0); - ui.p().rect(s, TEXT_DIM.alpha(0.5)); + ui.p().rect(s, th().text_dim.alpha(0.5)); for k in -3..=3 { let tx = s.x + s.w * (k as f32 + 3.0) / 6.0; let hgt = if k == 0 { 12.0 } else { 7.0 }; - ui.p().rect(Rect::new(tx - 0.5, s.y - hgt * 0.5, 1.0, hgt), TEXT_DIM); + ui.p().rect(Rect::new(tx - 0.5, s.y - hgt * 0.5, 1.0, hgt), th().text_dim); } let v = offset.clamp(-3.2, 3.2); let nx = s.x + s.w * (v + 3.0) / 6.0; - let c = if offset.abs() < 0.35 { OK } else if offset.abs() < 1.5 { WARN } else { DANGER }; + let c = if offset.abs() < 0.35 { th().ok } else if offset.abs() < 1.5 { th().warn } else { th().danger }; ui.p().rounded(Rect::new(nx - 3.0, s.y - 8.0, 6.0, 16.0), 3.0, c); ui.text_in(&format!("{offset:+.1}"), Rect::new(r.x + r.w * 0.88, r.y, r.w * 0.12, r.h), 12.0, Weight::Medium, c, Align::Right); } @@ -415,7 +415,7 @@ fn simple_page(ui: &mut Ui, ph: &mut Photo, info: &Info, x: f32, mut y: f32, w: let d = ph.focus_distance(); let s = &mut ph.settings; let _ = s; - ui.text_in(&format!("{d:.1} m"), Rect::new(r.x + r.w * 0.5, r.y, r.w * 0.5, r.h), 12.5, Weight::Medium, TEXT, Align::Right); + ui.text_in(&format!("{d:.1} m"), Rect::new(r.x + r.w * 0.5, r.y, r.w * 0.5, r.h), 12.5, Weight::Medium, th().text, Align::Right); } else { // 0.3 .. 300 m on a log scale let mut t = (s.focus / 0.3).ln() / 1000f32.ln(); @@ -427,7 +427,7 @@ fn simple_page(ui: &mut Ui, ph: &mut Photo, info: &Info, x: f32, mut y: f32, w: } } let r = Rect::new(x, y, w, 34.0); - ui.paragraph(&omsi_ui::tr("Click on the picture to focus on what is there."), Vec2::new(r.x, r.y), r.w, 11.5, Weight::Regular, TEXT_DIM); + ui.paragraph(&omsi_ui::tr("Click on the picture to focus on what is there."), Vec2::new(r.x, r.y), r.w, 11.5, Weight::Regular, th().text_dim); y += 24.0; let s = &mut ph.settings; stepped(ui, "ph-fstop", row(&mut y), &mut s.fstop, FSTOPS.len(), "Aperture", &|k| format!("f/{}", FSTOPS[k])); @@ -442,7 +442,7 @@ fn simple_page(ui: &mut Ui, ph: &mut Photo, info: &Info, x: f32, mut y: f32, w: stepped(ui, "ph-shutter", row(&mut y), &mut s.shutter, SHUTTERS.len(), "Shutter speed", &|k| SHUTTERS[k].1.to_string()); if s.motion && !info.bus_moving { let r = row(&mut y); - ui.text_in("The bus stands still: nothing to blur.", r, 12.0, Weight::Regular, WARN, Align::Left); + ui.text_in("The bus stands still: nothing to blur.", r, 12.0, Weight::Regular, th().warn, Align::Left); } section(ui, &mut y, "Quality"); let labels: Vec<&str> = QUALITY.iter().map(|q| q.1).collect(); @@ -482,7 +482,7 @@ fn simple_page(ui: &mut Ui, ph: &mut Photo, info: &Info, x: f32, mut y: f32, w: section(ui, &mut y, "Time of day"); let r = row(&mut y); ui.label(Rect::new(r.x, r.y, r.w * 0.38, r.h), "Clock"); - ui.text_in(&info.time, Rect::new(r.x + r.w * 0.38, r.y, r.w * 0.62, r.h), 15.0, Weight::Bold, ACCENT, Align::Center); + ui.text_in(&info.time, Rect::new(r.x + r.w * 0.38, r.y, r.w * 0.62, r.h), 15.0, Weight::Bold, th().accent, Align::Center); let r = row(&mut y); let bw = (r.w - 18.0) / 4.0; for (k, (text, secs)) in [("-1 h", -3600.0), ("-15 min", -900.0), ("+15 min", 900.0), ("+1 h", 3600.0)].into_iter().enumerate() { @@ -494,7 +494,7 @@ fn simple_page(ui: &mut Ui, ph: &mut Photo, info: &Info, x: f32, mut y: f32, w: let labels: Vec<&str> = SIZES.iter().map(|z| z.1).collect(); select_row(ui, "ph-size", row(&mut y), "Photo size", &mut s.size, &labels); let r = Rect::new(x, y, w, 40.0); - ui.paragraph("Photos are saved as PNG into the Screenshots folder of the game.", Vec2::new(r.x, r.y), r.w, 12.0, Weight::Regular, TEXT_DIM); + ui.paragraph("Photos are saved as PNG into the Screenshots folder of the game.", Vec2::new(r.x, r.y), r.w, 12.0, Weight::Regular, th().text_dim); y += 44.0; } } @@ -506,7 +506,7 @@ fn simple_page(ui: &mut Ui, ph: &mut Photo, info: &Info, x: f32, mut y: f32, w: fn level(ui: &mut Ui, f: Rect, roll: f32, pitch: f32) { let m = f.center(); let ok = roll.abs() < 0.3; - let c = if ok { OK } else { Color::WHITE.alpha(0.8) }; + let c = if ok { th().ok } else { Color::WHITE.alpha(0.8) }; let (s, co) = (-roll.to_radians()).sin_cos(); for k in -40i32..=40 { if k.abs() < 6 { @@ -522,7 +522,7 @@ fn level(ui: &mut Ui, f: Rect, roll: f32, pitch: f32) { /// Where a click focused: the corners of a small square. fn focus_mark(ui: &mut Ui, at: Vec2, a: f32) { - let c = OK.alpha(a); + let c = th().ok.alpha(a); let (r, l) = (22.0, 8.0); for (sx, sy) in [(-1.0, -1.0), (1.0, -1.0), (-1.0, 1.0), (1.0, 1.0)] { let (cx, cy) = (at.x + sx * r, at.y + sy * r); @@ -547,7 +547,7 @@ fn histogram(ui: &mut Ui, r: Rect, h: &[[u32; 64]; 4]) { // (burnt out or black: a mark at the edge) let tot: u32 = h[3].iter().sum::().max(1); if h[3][63] as f32 / tot as f32 > 0.01 { - ui.p().rect(Rect::new(inner.right() - 3.0, inner.y, 3.0, inner.h), DANGER); + ui.p().rect(Rect::new(inner.right() - 3.0, inner.y, 3.0, inner.h), th().danger); } if h[3][0] as f32 / tot as f32 > 0.01 { ui.p().rect(Rect::new(inner.x, inner.y, 3.0, inner.h), Color::rgba(77, 128, 255, 1.0)); @@ -628,15 +628,15 @@ pub(crate) fn draw(sh: &mut Shell, ph: &mut Photo, info: &Info) { } let a = left.clamp(0.0, 1.0); // (flat, as the launcher's toasts: lighter under the mouse, no edge) - let bg = if hover { SURFACE_3.alpha(a) } else { SURFACE_2.alpha(a) }; + let bg = if hover { th().surface_3.alpha(a) } else { th().surface_2.alpha(a) }; ui.p().rounded(r, 8.0, bg); let failed = text.starts_with(omsi_ui::tr("Not saved:").as_ref()); - let (icon, tint) = if ph.saved.is_some() { ("check_circle", OK) } else if failed { ("error", DANGER) } else { ("info", TEXT_DIM) }; + let (icon, tint) = if ph.saved.is_some() { ("check_circle", th().ok) } else if failed { ("error", th().danger) } else { ("info", th().text_dim) }; ui.icon(icon, Vec2::new(r.x + 20.0, r.y + 20.0), 18.0, tint.alpha(a)); - ui.text_in(&text, Rect::new(r.x + 36.0, r.y, r.w - 44.0, 40.0), 13.0, Weight::Medium, TEXT.alpha(a), Align::Left); + ui.text_in(&text, Rect::new(r.x + 36.0, r.y, r.w - 44.0, 40.0), 13.0, Weight::Medium, th().text.alpha(a), Align::Left); if let (Some(place), Some(path)) = (place, path) { - ui.text_in(&place, Rect::new(r.x + 36.0, r.y + 30.0, r.w - 72.0, 18.0), 11.5, Weight::Regular, TEXT_DIM.alpha(a), Align::Left); - ui.icon("folder_open", Vec2::new(r.right() - 22.0, r.y + 39.0), 16.0, if hover { ACCENT.alpha(a) } else { TEXT_DIM.alpha(a) }); + ui.text_in(&place, Rect::new(r.x + 36.0, r.y + 30.0, r.w - 72.0, 18.0), 11.5, Weight::Regular, th().text_dim.alpha(a), Align::Left); + ui.icon("folder_open", Vec2::new(r.right() - 22.0, r.y + 39.0), 16.0, if hover { th().accent.alpha(a) } else { th().text_dim.alpha(a) }); if clicked { acts.push(Request::Reveal(path)); } @@ -672,8 +672,8 @@ pub(crate) fn draw(sh: &mut Shell, ph: &mut Photo, info: &Info) { ph.panel_rect = touch.then(|| [r.x * sh_scale, r.y * sh_scale, r.w * sh_scale, r.h * sh_scale]); ui.panel(r); let inner = Rect::new(r.x + 18.0, r.y + 16.0, r.w - 36.0, r.h - 32.0); - ui.icon("photo_camera", Vec2::new(inner.x + 12.0, inner.y + 14.0), 20.0, ACCENT); - ui.text("Photo mode", Vec2::new(inner.x + 32.0, inner.y + 21.0), 18.0, Weight::Bold, TEXT, Align::Left); + ui.icon("photo_camera", Vec2::new(inner.x + 12.0, inner.y + 14.0), 20.0, th().accent); + ui.text("Photo mode", Vec2::new(inner.x + 32.0, inner.y + 21.0), 18.0, Weight::Bold, th().text, Align::Left); let mm = ph.settings.focal(ph.pro); let fnum = ph.fstop(); let exif = if ph.pro { @@ -685,7 +685,7 @@ pub(crate) fn draw(sh: &mut Shell, ph: &mut Photo, info: &Info) { } else { format!("{mm:.0} mm | {:.0}°", ph.settings.fov) }; - ui.text_in(&exif, Rect::new(inner.x, inner.y + 32.0, inner.w, 18.0), 12.0, Weight::Regular, TEXT_DIM, Align::Left); + ui.text_in(&exif, Rect::new(inner.x, inner.y + 32.0, inner.w, 18.0), 12.0, Weight::Regular, th().text_dim, Align::Left); let mut tab = ph.tab.min(tabs(ph.pro).len() - 1); if ui.segmented("ph-tabs", Rect::new(inner.x, inner.y + 58.0, inner.w, 34.0), &mut tab, tabs(ph.pro)) { ph.tab = tab; @@ -712,7 +712,7 @@ pub(crate) fn draw(sh: &mut Shell, ph: &mut Photo, info: &Info) { } // the keys, over the buttons (going down was nowhere to be found) let keys = Rect::new(inner.x, inner.bottom() - 44.0 - 46.0 - 40.0, inner.w, 34.0); - ui.paragraph(&omsi_ui::tr("W A S D: move, Space: up, Ctrl: down, Shift: faster, drag or arrows: look"), Vec2::new(keys.x, keys.y), keys.w, 11.5, Weight::Regular, TEXT_DIM); + ui.paragraph(&omsi_ui::tr("W A S D: move, Space: up, Ctrl: down, Shift: faster, drag or arrows: look"), Vec2::new(keys.x, keys.y), keys.w, 11.5, Weight::Regular, th().text_dim); let b = Rect::new(inner.x, inner.bottom() - 44.0 - 46.0, inner.w, 44.0); if ui.button("ph-take", b, &if busy { format!("{} {n} / {target}", omsi_ui::tr("Taking...")) } else { omsi_ui::tr("Take photo").into_owned() }, Some("photo_camera"), ButtonKind::Primary) && !busy { acts.push(Request::Take); diff --git a/crates/omsi-app/src/process_permissions/dialog.rs b/crates/omsi-app/src/process_permissions/dialog.rs index e88c5011b..df4e126e5 100644 --- a/crates/omsi-app/src/process_permissions/dialog.rs +++ b/crates/omsi-app/src/process_permissions/dialog.rs @@ -135,7 +135,7 @@ pub(super) fn draw( Vec2::new(r.x + 20.0, r.y + 32.0), title_size, Weight::Bold, - TEXT, + th().text, Align::Left, ); let footer = if narrow { 132.0 } else { 92.0 }; @@ -152,7 +152,7 @@ pub(super) fn draw( body.w - 16.0, 14.0, Weight::Regular, - TEXT, + th().text, ) }, ); diff --git a/crates/omsi-app/src/settings.rs b/crates/omsi-app/src/settings.rs index 828eef105..1ce09c0f0 100644 --- a/crates/omsi-app/src/settings.rs +++ b/crates/omsi-app/src/settings.rs @@ -235,6 +235,8 @@ pub struct Settings { pub ui_scale: f32, /// Ordered pause-menu action IDs as JSON; "default" keeps the built-in shortcuts. pub pause_quick_actions: String, + /// The interface theme (`themes`): its id, "" for openOMSI's own look. + pub ui_theme: String, /// The interface grows with a window taller than 1080 lines (`ui::size_factor`); off, /// it keeps its size on any window, as before. pub ui_scale_window: bool, @@ -522,7 +524,7 @@ impl Settings { } /// The defaults of a computer. fn desktop() -> Self { - Self { msaa: 4, anisotropy: 8, ssao: true, shadows: true, shadow_size: 2048, shadow_blobs: true, navigator: true, ui_opacity: 0.85, notes: true, ui_scale: 1.0, pause_quick_actions: "default".into(), ui_scale_window: true, navigator_corner: "bottom-left".into(), boarding: "auto".into(), passenger_animation: "original".into(), units: "metric".into(), photo_mode: "simple".into(), character_models: "original".into(), standing_chance: 0.05, ambient: true, vol_ambient: 0.8, radio_fx: Default::default(), detail_textures: true, exact_fare: true, pax_bad_driving: true, auto_announce: false, enhanced: false, graphics: "vanilla_plus".into(), triple: Default::default(), triple_span: true, triple_hud_center: true, vr: false, vr_scale: 0.65, vr_head_smoothing_ms: 0.0, vr_mirror_rate: 16.0, vr_desktop_mirror: true, fullscreen: false, vsync: true, volume: 0.6, drive_keys: "simple".into(), touch_door_mode: "standard".into(), touch_pedal_length: 1.0, touch_pedal_deadzone: 0.15, post_aa: "fxaa".into(), render_scale: 0.0, upscaler: "default".into(), fsr_quality: "quality".into(), fsr_sharpness: 0.8, language: "ENG".into(), pax_voices: "all".into(), nav_arrows: false, nav_ai: true, nav_surface: true, nav_light: false, nav_3d: true, nav_widget: true, milezero: false, nav_size: 1.0, get_up: false, trip_summary: true, full_tank: true, sim_thread: false, texture_compression: true, gpu_texture_compression: true, video_memory: 0, auto_clutch: true, momentary_gears: false, auto_shift: false, min_obj_size: 0.013, max_obj_dist: -1.0, max_fps: 0, chat: true, chat_size: 1.0, tooltips: true, name_tags: true, show_fps: false, perf_overlay: "off".into(), clouds: true, cloud_quality: "high".into(), street_light: "off".into(), rain_quality: "high".into(), vehicle_smoke: "all".into(), puddles: "high".into(), windy_trees: true, pax_density: 1.0, vol_ai: 1.0, vol_scenery: 1.0, mirror_size: 256, mirror_hud: 0, mirror_hud_every_frame: false, mirror_refresh: "full".into(), mirror_quality: "standard".into(), mirror_shadows: false, mirror_particles: false, doppler: true, driver: true, maintenance: 0, ai_unsched_factor: 1.0, ai_max_scheduled: 0, ai_max_parked: 0, ai_max_humans: 200, ai_wait_timed_stops_only: false, collision_vehicles: true, collision_objects: true, collision_pedestrians: true, realistic_road_physics: false, head_movement: true, driverview_smooth: true, hands_in_cab: false, alt_view: true, precision_zoom: false, time_speed: 1.0, time_sync: false, metar_sync: false, metar_station: String::new(), machine_translation: false, shadow_casters: "all".into(), ctrl_deadzone: 0.0, right_stick_look: true, ctrl_background: false, pad_steer_smooth: 120.0, pad_steer_linear: false, pad_steer_speed: 2.0, pad_deadzone: 0.08, pad_type: "auto".into(), pad_buttons: true, arrows_switch_cams: true, ctrl_off: String::new(), steering_linear: false, old_steering: false, red_steer_spd: false, reflections: 1.0, led_glow: 6, led_mips: 1.3, night_brightness: 0.0, mouse_sens: 1.0, mouse_pedal_strength: 1.0, graphics_api: "auto".into(), ff_invert: false, ff_enabled: true, ff_road_vib: 1.0, ff_engine_vib: 1.0, ff_fade: 0.28, brake_hold: true, mouse_steering: false, mouse_right_off: false, mouse_smooth: true, mouse_hold: true, mouse_pedals: true, key_pedal_speed: 1.0, look_sens: 1.0, look_smoothing_ms: 0.0, blinker_cancel: true, wheel_range: 900.0, wheel_lock: 0.0, fov: 0.0, camera_collision: true, steer_look: false, steer_look_angle: 30.0, steer_look_response: 0.25, head_idle: 0.0, head_idle_pace: 1.0, pedal_throttle: 1.0, pedal_brake: 1.0, seat: [0.0; 3], seat_pitch_deg: 0.0, head_tracking: false, head_tracking_port: 4242, head_tracking_invert: String::new(), head_tracking_yaw_sens: 100.0, head_tracking_pitch_sens: 100.0, head_tracking_roll_sens: 100.0, head_tracking_x_sens: 100.0, head_tracking_y_sens: 100.0, head_tracking_z_sens: 100.0, head_tracking_invert_yaw: false, head_tracking_invert_pitch: false, head_tracking_invert_roll: false, head_tracking_invert_x: false, head_tracking_invert_y: false, head_tracking_invert_z: false, head_tracking_source: "auto".into(), tobii_edges: false, head_tracking_smoothing: 0.5, head_tracking_dead_zone: 0.1, discord_status: true, discord_app_id: String::new(), voice_enabled: false, voice_mode: "ptt".into(), voice_mic_gain: 1.0, voice_threshold_db: -45.0, voice_input_device: String::new(), voice_volume: 1.0, voice_volume_nearby: 1.0, voice_volume_radio: 1.0, voice_range: 75.0, voice_talker_list: true, voice_all_off: false, info_bar: false, hud_sizes: [1.0; 3] } + Self { msaa: 4, anisotropy: 8, ssao: true, shadows: true, shadow_size: 2048, shadow_blobs: true, navigator: true, ui_opacity: 0.85, notes: true, ui_scale: 1.0, pause_quick_actions: "default".into(), ui_theme: String::new(), ui_scale_window: true, navigator_corner: "bottom-left".into(), boarding: "auto".into(), passenger_animation: "original".into(), units: "metric".into(), photo_mode: "simple".into(), character_models: "original".into(), standing_chance: 0.05, ambient: true, vol_ambient: 0.8, radio_fx: Default::default(), detail_textures: true, exact_fare: true, pax_bad_driving: true, auto_announce: false, enhanced: false, graphics: "vanilla_plus".into(), triple: Default::default(), triple_span: true, triple_hud_center: true, vr: false, vr_scale: 0.65, vr_head_smoothing_ms: 0.0, vr_mirror_rate: 16.0, vr_desktop_mirror: true, fullscreen: false, vsync: true, volume: 0.6, drive_keys: "simple".into(), touch_door_mode: "standard".into(), touch_pedal_length: 1.0, touch_pedal_deadzone: 0.15, post_aa: "fxaa".into(), render_scale: 0.0, upscaler: "default".into(), fsr_quality: "quality".into(), fsr_sharpness: 0.8, language: "ENG".into(), pax_voices: "all".into(), nav_arrows: false, nav_ai: true, nav_surface: true, nav_light: false, nav_3d: true, nav_widget: true, milezero: false, nav_size: 1.0, get_up: false, trip_summary: true, full_tank: true, sim_thread: false, texture_compression: true, gpu_texture_compression: true, video_memory: 0, auto_clutch: true, momentary_gears: false, auto_shift: false, min_obj_size: 0.013, max_obj_dist: -1.0, max_fps: 0, chat: true, chat_size: 1.0, tooltips: true, name_tags: true, show_fps: false, perf_overlay: "off".into(), clouds: true, cloud_quality: "high".into(), street_light: "off".into(), rain_quality: "high".into(), vehicle_smoke: "all".into(), puddles: "high".into(), windy_trees: true, pax_density: 1.0, vol_ai: 1.0, vol_scenery: 1.0, mirror_size: 256, mirror_hud: 0, mirror_hud_every_frame: false, mirror_refresh: "full".into(), mirror_quality: "standard".into(), mirror_shadows: false, mirror_particles: false, doppler: true, driver: true, maintenance: 0, ai_unsched_factor: 1.0, ai_max_scheduled: 0, ai_max_parked: 0, ai_max_humans: 200, ai_wait_timed_stops_only: false, collision_vehicles: true, collision_objects: true, collision_pedestrians: true, realistic_road_physics: false, head_movement: true, driverview_smooth: true, hands_in_cab: false, alt_view: true, precision_zoom: false, time_speed: 1.0, time_sync: false, metar_sync: false, metar_station: String::new(), machine_translation: false, shadow_casters: "all".into(), ctrl_deadzone: 0.0, right_stick_look: true, ctrl_background: false, pad_steer_smooth: 120.0, pad_steer_linear: false, pad_steer_speed: 2.0, pad_deadzone: 0.08, pad_type: "auto".into(), pad_buttons: true, arrows_switch_cams: true, ctrl_off: String::new(), steering_linear: false, old_steering: false, red_steer_spd: false, reflections: 1.0, led_glow: 6, led_mips: 1.3, night_brightness: 0.0, mouse_sens: 1.0, mouse_pedal_strength: 1.0, graphics_api: "auto".into(), ff_invert: false, ff_enabled: true, ff_road_vib: 1.0, ff_engine_vib: 1.0, ff_fade: 0.28, brake_hold: true, mouse_steering: false, mouse_right_off: false, mouse_smooth: true, mouse_hold: true, mouse_pedals: true, key_pedal_speed: 1.0, look_sens: 1.0, look_smoothing_ms: 0.0, blinker_cancel: true, wheel_range: 900.0, wheel_lock: 0.0, fov: 0.0, camera_collision: true, steer_look: false, steer_look_angle: 30.0, steer_look_response: 0.25, head_idle: 0.0, head_idle_pace: 1.0, pedal_throttle: 1.0, pedal_brake: 1.0, seat: [0.0; 3], seat_pitch_deg: 0.0, head_tracking: false, head_tracking_port: 4242, head_tracking_invert: String::new(), head_tracking_yaw_sens: 100.0, head_tracking_pitch_sens: 100.0, head_tracking_roll_sens: 100.0, head_tracking_x_sens: 100.0, head_tracking_y_sens: 100.0, head_tracking_z_sens: 100.0, head_tracking_invert_yaw: false, head_tracking_invert_pitch: false, head_tracking_invert_roll: false, head_tracking_invert_x: false, head_tracking_invert_y: false, head_tracking_invert_z: false, head_tracking_source: "auto".into(), tobii_edges: false, head_tracking_smoothing: 0.5, head_tracking_dead_zone: 0.1, discord_status: true, discord_app_id: String::new(), voice_enabled: false, voice_mode: "ptt".into(), voice_mic_gain: 1.0, voice_threshold_db: -45.0, voice_input_device: String::new(), voice_volume: 1.0, voice_volume_nearby: 1.0, voice_volume_radio: 1.0, voice_range: 75.0, voice_talker_list: true, voice_all_off: false, info_bar: false, hud_sizes: [1.0; 3] } } } @@ -577,11 +579,12 @@ impl Settings { s } - /// One of the Tobii head-tracking lines or the pause menu's shortcuts of the settings file (`from_text`). + /// One of the Tobii head-tracking lines, the pause menu's shortcuts or the interface theme of the settings file (`from_text`). fn read_extra(&mut self, k: &str, v: &str) { match k { "head_tracking_source" => self.head_tracking_source = omsi_launcher_lib::head_tracking_source(v).to_string(), "pause_quick_actions" => self.pause_quick_actions = v.to_string(), + "ui_theme" => self.ui_theme = crate::themes::setting(v), "tobii_edges" => self.tobii_edges = matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "on" | "yes"), _ => { let x = omsi_launcher_lib::head_tracking_value(k, v.parse::().ok()) as f32; @@ -861,7 +864,7 @@ impl Settings { "head_tracking_invert_x" => s.head_tracking_invert_x = b(v), "head_tracking_invert_y" => s.head_tracking_invert_y = b(v), "head_tracking_invert_z" => s.head_tracking_invert_z = b(v), - "head_tracking_source" | "tobii_edges" | "head_tracking_smoothing" | "head_tracking_dead_zone" | "pause_quick_actions" => s.read_extra(&k, v), + "head_tracking_source" | "tobii_edges" | "head_tracking_smoothing" | "head_tracking_dead_zone" | "pause_quick_actions" | "ui_theme" => s.read_extra(&k, v), "discord_status" => s.discord_status = b(v), "discord_app_id" => s.discord_app_id = v.trim().to_string(), "voice_enabled" => s.voice_enabled = b(v), @@ -947,7 +950,7 @@ impl Settings { "vr_head_smoothing_ms={}\nvr_mirror_rate={}\nvr_desktop_mirror={}\nled_glow={}\nled_mips={}\nnight_brightness={}\nui_scale={}\nui_scale_window={}\nchat_size={}\nnotes={}\nff_road_vib={}\nff_engine_vib={}\nff_fade={}\nrealistic_road_physics={}\n", self.vr_head_smoothing_ms, self.vr_mirror_rate, self.vr_desktop_mirror as u8, self.led_glow, self.led_mips, self.night_brightness, self.ui_scale, self.ui_scale_window as u8, self.chat_size, self.notes as u8, self.ff_road_vib, self.ff_engine_vib, self.ff_fade, self.realistic_road_physics as u8, )); - text.push_str(&format!("pause_quick_actions={}\n", self.pause_quick_actions)); + text.push_str(&format!("pause_quick_actions={}\nui_theme={}\n", self.pause_quick_actions, if self.ui_theme.is_empty() { crate::themes::OWN } else { &self.ui_theme })); text.push_str(&format!("upscaler={}\nfsr_quality={}\nfsr_sharpness={}\n", self.upscaler, self.fsr_quality, self.fsr_sharpness)); text.push_str(&format!("mouse_pedal_strength={}\n", self.mouse_pedal_strength)); text.push_str(&format!("pax_bad_driving={}\n", self.pax_bad_driving as u8)); diff --git a/crates/omsi-app/src/shell/mod.rs b/crates/omsi-app/src/shell/mod.rs index 11f13601e..da3e1242f 100644 --- a/crates/omsi-app/src/shell/mod.rs +++ b/crates/omsi-app/src/shell/mod.rs @@ -68,6 +68,16 @@ pub(crate) struct Shell { /// the `shot` pictures. pub(crate) logo_tex: Option, shot_logo: Option, + /// The interface theme whose logo `logo_tex` is (none: openOMSI's), and the name it shows + /// beside it (none: "openOMSI"). + logo_of: Option, + pub(crate) logo_title: Option, + /// The game's picture blurred, for a glass theme's panels (`themes::blur`): sent to the + /// graphics card with the next frame; its texture there, kept for the next time; and + /// whether it is shown now. + backdrop: Option<(u32, u32, Vec)>, + backdrop_tex: Option, + backdrop_on: bool, /// Physical pixels per point of the last frame. pub(crate) scale: f32, /// The mouse was over a panel of the screens in the last frame. @@ -91,7 +101,7 @@ impl Shell { // (the desktop launcher's calm look: graphite surfaces, no edges) let mut ui = Ui::new(); ui.flat = true; - Shell { process_prompt: None, process_dialog: Default::default(), ui, pause_category: Default::default(), quick_edit: false, quick_focus: 0, gpu: None, shot_gpu: None, drawn: None, actions: Vec::new(), picture: None, picture_tex: None, logo_tex: None, shot_logo: None, scale: 1.0, over_ui: false, last_sel: None, dd_anchor: None, opaque: false } + Shell { process_prompt: None, process_dialog: Default::default(), ui, pause_category: Default::default(), quick_edit: false, quick_focus: 0, gpu: None, shot_gpu: None, drawn: None, actions: Vec::new(), picture: None, picture_tex: None, logo_tex: None, shot_logo: None, logo_of: None, logo_title: None, backdrop: None, backdrop_tex: None, backdrop_on: false, scale: 1.0, over_ui: false, last_sel: None, dd_anchor: None, opaque: false } } /// A frame of the screens begins: the window's size in physical pixels and how many of @@ -106,6 +116,8 @@ impl Shell { self.scale = next_scale; self.ui.input.touch = crate::platform::touch_controls(); self.ui.begin(Vec2::new(width / self.scale, height / self.scale), self.scale, dt); + self.ui.backdrop = self.backdrop_tex.filter(|_| self.backdrop_on); + self.logo_title = crate::themes::current().and_then(|t| t.title.clone()); } /// The frame of the screens is drawn: its layers kept for `render`. @@ -133,6 +145,23 @@ impl Shell { self.picture = Some((w, h, rgba)); } + /// The game's picture blurred (RGBA, sRGB), for a glass theme's panels from the next frame. + pub(crate) fn set_backdrop(&mut self, w: u32, h: u32, rgba: Vec) { + self.backdrop = Some((w, h, rgba)); + self.backdrop_on = true; + } + + /// The blurred picture is no longer shown (until the next `set_backdrop`). + pub(crate) fn drop_backdrop(&mut self) { + self.backdrop = None; + self.backdrop_on = false; + } + + /// A blurred picture is shown, or on its way to the card. + pub(crate) fn has_backdrop(&self) -> bool { + self.backdrop_on + } + /// The photo is no longer shown. pub(crate) fn drop_picture(&mut self) { self.picture = None; @@ -181,10 +210,32 @@ impl Shell { self.ui.atlas.mark_all_dirty(); self.picture_tex = None; self.logo_tex = None; + // (the blurred picture went with the old card: the game makes it again) + self.backdrop_tex = None; + self.backdrop_on = self.backdrop.is_some(); } let Some((gpu, _)) = self.gpu.as_mut() else { return }; + // the logo: the interface theme's, when it has one + let theme = crate::themes::current().filter(|t| t.logo.is_some()); + let logo_of = theme.as_ref().map(|t| t.id.clone()); + if logo_of != self.logo_of { + if let Some(id) = self.logo_tex.take() { + gpu.free(id); + } + self.logo_of = logo_of; + } if self.logo_tex.is_none() { - self.logo_tex = logo_pixels().map(|px| gpu.add_image(&r.device, &r.queue, LOGO_PX, LOGO_PX, &px)); + let px = match theme.as_ref().and_then(|t| t.logo.as_ref()) { + Some(logo) => logo.square_pixels(LOGO_PX), + None => logo_pixels(), + }; + self.logo_tex = px.map(|px| gpu.add_image(&r.device, &r.queue, LOGO_PX, LOGO_PX, &px)); + } + if let Some((bw, bh, px)) = self.backdrop.take() { + match self.backdrop_tex { + Some(id) => gpu.update_image(&r.device, &r.queue, id, bw, bh, &px), + None => self.backdrop_tex = Some(gpu.add_image(&r.device, &r.queue, bw, bh, &px)), + } } if let Some((pw, ph, px)) = self.picture.take() { match self.picture_tex { diff --git a/crates/omsi-app/src/shell/pause.rs b/crates/omsi-app/src/shell/pause.rs index 4ca8506c0..ca2a866dd 100644 --- a/crates/omsi-app/src/shell/pause.rs +++ b/crates/omsi-app/src/shell/pause.rs @@ -23,10 +23,12 @@ use omsi_ui::theme::*; use omsi_ui::kit::{id_of, ButtonKind, Ui}; use crate::ui::{DropdownView, MenuKind, Preview}; -/// The solid background behind a list or settings window. -const PAGE: Color = BG; -/// Keep the paused game visible behind the category home pages. -const HOME_VEIL: Color = Color([BG.0[0], BG.0[1], BG.0[2], 0.72]); +/// How much of the background lies over the picture behind the category home pages (the +/// paused game stays visible), and behind a list or settings window (solid; in a glass theme +/// over the picture blurred). +const HOME_VEIL: f32 = 0.72; +const GLASS_HOME_VEIL: f32 = 0.3; +const GLASS_PAGE_VEIL: f32 = 0.5; /// Compact margins for the existing settings and list pages. const NARROW: f32 = 560.0; /// A list's row. @@ -117,7 +119,16 @@ pub(crate) fn draw(sh: &mut Shell, v: &PauseView) { let left = if sidebar { RAIL_W } else { 0.0 }; let page = Rect::new(left, 0.0, (size.x - left).max(0.0), size.y); sh.ui.solid(page); - sh.ui.p().rect(page, if v.list.is_some() { PAGE } else { HOME_VEIL }); + let bg = th().bg; + match (th().glass, sh.ui.backdrop) { + (Some(_), Some(tex)) if v.list.is_some() => { + let all = Rect::new(0.0, 0.0, size.x, size.y); + sh.ui.image(all, tex, 0.0); + sh.ui.p().rect(all, bg.alpha(GLASS_PAGE_VEIL)); + } + (Some(_), _) => sh.ui.p().rect(page, bg.alpha(if v.list.is_some() { GLASS_PAGE_VEIL.max(HOME_VEIL) } else { GLASS_HOME_VEIL })), + (None, _) => sh.ui.p().rect(page, if v.list.is_some() { bg } else { bg.alpha(HOME_VEIL) }), + } if let Some(list) = v.list.as_ref() { list_page(sh, v, list, page); } else { @@ -144,9 +155,9 @@ fn tabs(ui: &mut Ui, at: Vec2, titles: &[String], chosen: usize, max_w: f32) -> let r = Rect::new(x, at.y + row as f32 * 38.0, w, 38.0); let (h, _, clicked) = ui.interact(id_of(&format!("pause-tab-{i}")), r); let on = i == chosen; - ui.text_in(t, r, 14.0, Weight::Medium, if on || h { INK } else { INK_MUTED }, Align::Center); + ui.text_in(t, r, 14.0, Weight::Medium, if on || h { th().ink } else { th().ink_muted }, Align::Center); if on { - ui.p().rect(Rect::new(r.x, r.bottom() - 2.0, r.w, 2.0), ACCENT); + ui.p().rect(Rect::new(r.x, r.bottom() - 2.0, r.w, 2.0), th().accent); } if clicked && !on { out = Some(i); @@ -173,10 +184,10 @@ fn list_page(sh: &mut Shell, v: &PauseView, list: &ListView, page: Rect) { }); } let tx = area.x + 46.0; - sh.ui.text(&title, Vec2::new(tx, area.y + 26.0), 26.0, Weight::Bold, INK, Align::Left); + sh.ui.text(&title, Vec2::new(tx, area.y + 26.0), 26.0, Weight::Bold, th().ink, Align::Left); let sub = if sub.is_empty() { list_hint(list.kind) } else { sub }; if !sub.is_empty() { - sh.ui.text_in(&sub, Rect::new(tx, area.y + 36.0, area.w - 46.0, 20.0), 13.0, Weight::Regular, INK_MUTED, Align::Left); + sh.ui.text_in(&sub, Rect::new(tx, area.y + 36.0, area.w - 46.0, 20.0), 13.0, Weight::Regular, th().ink_muted, Align::Left); } let mut body = Rect::new(area.x, area.y + 70.0, area.w, (area.h - 70.0).max(40.0)); // the pages of a settings window: the launcher's tabs @@ -309,8 +320,8 @@ fn settings_rows(sh: &mut Shell, v: &PauseView, list: &ListView, body: Rect, anc if id == crate::game_lists::HEADING { // (a group's title as the launcher's card title) let t = parse_row(label).name; - ui.p().rounded(Rect::new(inner.x, y + 7.0, 3.0, 20.0), 1.5, ACCENT); - ui.text_in(t, Rect::new(inner.x + 12.0, y + 4.0, inner.w - 12.0, 28.0), 15.0, Weight::Bold, INK, Align::Left); + ui.p().rounded(Rect::new(inner.x, y + 7.0, 3.0, 20.0), 1.5, th().accent); + ui.text_in(t, Rect::new(inner.x + 12.0, y + 4.0, inner.w - 12.0, 28.0), 15.0, Weight::Bold, th().ink, Align::Left); y += rh; continue; } @@ -362,10 +373,10 @@ fn settings_row(ui: &mut Ui, k: usize, label: &str, r: Rect, kbd: bool, dd_open: let t = ui.anim(id ^ 0x51, if hov || kbd { 1.0 } else { 0.0 }, 0.06); if t > 0.01 { // (half a step lighter than the panel: the fields in the row stay apart from it) - ui.p().rounded(r, 6.0, SURFACE.mix(SURFACE_2, 0.5).alpha(t)); + ui.p().rounded(r, 6.0, th().surface.mix(th().surface_2, 0.5).alpha(t)); } if kbd { - ui.p().rounded(Rect::new(r.x, r.y + 8.0, 2.0, r.h - 16.0), 1.0, ACCENT); + ui.p().rounded(Rect::new(r.x, r.y + 8.0, 2.0, r.h - 16.0), 1.0, th().accent); } let lx = r.x + 8.0; let ctl_w = (r.w * 0.5).min(300.0); @@ -374,12 +385,12 @@ fn settings_row(ui: &mut Ui, k: usize, label: &str, r: Rect, kbd: bool, dd_open: "s" | "i" => r.w - 80.0, _ => ctl.x - lx - 12.0, }; - let ink = TEXT_SOFT.mix(TEXT, t); + let ink = th().text_soft.mix(th().text, t); if row.desc.is_empty() { ui.text_in(row.name, Rect::new(lx, r.y, name_w, r.h), 13.0, Weight::Regular, ink, Align::Left); } else { ui.text_in(row.name, Rect::new(lx, r.y + 6.0, name_w, 20.0), 13.0, Weight::Medium, ink, Align::Left); - ui.text_in(row.desc, Rect::new(lx, r.y + 25.0, name_w, 17.0), 11.5, Weight::Regular, TEXT_DIM, Align::Left); + ui.text_in(row.desc, Rect::new(lx, r.y + 25.0, name_w, 17.0), 11.5, Weight::Regular, th().text_dim, Align::Left); // (cut short by a narrow column: the whole of it under the mouse) if ui.width(row.desc, 11.5, Weight::Regular) > name_w { ui.tooltip(Rect::new(lx, r.y, name_w, r.h), row.desc); @@ -403,17 +414,17 @@ fn settings_row(ui: &mut Ui, k: usize, label: &str, r: Rect, kbd: bool, dd_open: } // a stepper: the value between the two arrows of a field "c" => { - ui.p().rounded(ctl, 6.0, SURFACE_2); - ui.text_in(row.value, ctl.pad(36.0, 0.0), 13.0, Weight::Regular, INK, Align::Center); + ui.p().rounded(ctl, 6.0, th().surface_2); + ui.text_in(row.value, ctl.pad(36.0, 0.0), 13.0, Weight::Regular, th().ink, Align::Center); let left = Rect::new(ctl.x, ctl.y, 34.0, ctl.h); let right = Rect::new(ctl.right() - 34.0, ctl.y, 34.0, ctl.h); let mut out = None; for (half, icon, fx) in [(left, "chevron_left", 0.0), (right, "chevron_right", 1.0)] { let (h, _, clicked) = ui.interact(id_of(&format!("pause-st-{k}-{icon}")), half); if h { - ui.p().rounded(half.inset(3.0), 5.0, SURFACE_3); + ui.p().rounded(half.inset(3.0), 5.0, th().surface_3); } - ui.icon(icon, half.center(), 18.0, if h { ACCENT } else { TEXT_DIM }); + ui.icon(icon, half.center(), 18.0, if h { th().accent } else { th().text_dim }); if clicked { out = Some(Action::Control(k, fx)); } @@ -425,16 +436,16 @@ fn settings_row(ui: &mut Ui, k: usize, label: &str, r: Rect, kbd: bool, dd_open: let (h, _, clicked) = ui.interact(id, r); let a = ui.anim(id ^ 0x77, if h || dd_open { 1.0 } else { 0.0 }, 0.08); // (open: a step lighter, as under the mouse; no ring round it) - ui.p().rounded(ctl, 6.0, SURFACE_2.mix(SURFACE_3, a)); + ui.p().rounded(ctl, 6.0, th().surface_2.mix(th().surface_3, a)); // (without a value it opens a list of its own: a way on, not a drop-down) let text = if row.value.is_empty() { omsi_ui::tr("Change").into_owned() } else { row.value.to_string() }; - ui.text_in(&text, Rect::new(ctl.x + 12.0, ctl.y, ctl.w - 40.0, ctl.h), 13.0, Weight::Regular, if row.value.is_empty() { TEXT_SOFT.mix(TEXT, a) } else { TEXT }, Align::Left); + ui.text_in(&text, Rect::new(ctl.x + 12.0, ctl.y, ctl.w - 40.0, ctl.h), 13.0, Weight::Regular, if row.value.is_empty() { th().text_soft.mix(th().text, a) } else { th().text }, Align::Left); let icon = match (row.value.is_empty(), dd_open) { (true, _) => "chevron_right", (false, true) => "expand_less", (false, false) => "expand_more", }; - ui.icon(icon, Vec2::new(ctl.right() - 18.0, ctl.center().y), 18.0, if row.value.is_empty() { TEXT_DIM.mix(ACCENT, a) } else { TEXT_DIM }); + ui.icon(icon, Vec2::new(ctl.right() - 18.0, ctl.center().y), 18.0, if row.value.is_empty() { th().text_dim.mix(th().accent, a) } else { th().text_dim }); if dd_open { *dd_anchor = Some(ctl); } @@ -442,14 +453,14 @@ fn settings_row(ui: &mut Ui, k: usize, label: &str, r: Rect, kbd: bool, dd_open: } // information only "i" => { - ui.text_in(row.value, Rect::new(r.x + r.w * 0.4, r.y, r.w * 0.6 - 8.0, r.h), 13.0, Weight::Medium, TEXT, Align::Right); + ui.text_in(row.value, Rect::new(r.x + r.w * 0.4, r.y, r.w * 0.6 - 8.0, r.h), 13.0, Weight::Medium, th().text, Align::Right); None } // a value being typed "E" => { - ui.p().rounded(ctl, 6.0, SURFACE_3); + ui.p().rounded(ctl, 6.0, th().surface_3); let caret = if (ui.time * 2.0) as i64 % 2 == 0 { "|" } else { "" }; - ui.text_in(&format!("{}{caret}", row.value), Rect::new(ctl.x + 12.0, ctl.y, ctl.w - 24.0, ctl.h), 13.0, Weight::Medium, TEXT, Align::Left); + ui.text_in(&format!("{}{caret}", row.value), Rect::new(ctl.x + 12.0, ctl.y, ctl.w - 24.0, ctl.h), 13.0, Weight::Medium, th().text, Align::Left); None } // a button with its text as the value @@ -479,7 +490,7 @@ fn dropdown(sh: &mut Shell, d: &DropdownView, a: Rect, kbd: bool) { // (over the scroll area's clip: a layer of its own over the whole window) ui.push_clip(Rect::new(0.0, 0.0, ui.size.x, ui.size.y), 0.0); ui.p().rounded(rr.inset(-2.0), 10.0, Color::rgba(0, 0, 0, 0.35)); - ui.p().rounded(rr, 8.0, SURFACE_2); + ui.p().rounded(rr, 8.0, th().surface_2); let row = 34.0; let id = id_of("pause-dropdown"); let content = d.items.len() as f32 * row + 8.0; @@ -508,12 +519,12 @@ fn dropdown(sh: &mut Shell, d: &DropdownView, a: Rect, kbd: bool) { let cell = Rect::new(rr.x + 4.0, y, rr.w - 8.0, row); let (h, _, clicked) = ui.interact(id_of(&format!("pause-dd-{i}")), cell); if d.current == Some(i) { - ui.p().rounded(cell, 5.0, SURFACE_3); - ui.icon("check", Vec2::new(cell.right() - 16.0, cell.center().y), 15.0, ACCENT); + ui.p().rounded(cell, 5.0, th().surface_3); + ui.icon("check", Vec2::new(cell.right() - 16.0, cell.center().y), 15.0, th().accent); } else if h || (kbd && d.sel == i) { - ui.p().rounded(cell, 5.0, SURFACE_3.alpha(0.6)); + ui.p().rounded(cell, 5.0, th().surface_3.alpha(0.6)); } - ui.text_in(item, Rect::new(cell.x + 10.0, cell.y, cell.w - 36.0, cell.h), 13.0, Weight::Regular, TEXT, Align::Left); + ui.text_in(item, Rect::new(cell.x + 10.0, cell.y, cell.w - 36.0, cell.h), 13.0, Weight::Regular, th().text, Align::Left); if clicked { picked = Some(i); } @@ -577,23 +588,23 @@ fn list_rows(sh: &mut Shell, v: &PauseView, list: &ListView, r: Rect, keep_marke acts.push(Action::Choose(k)); } let h = ui.hover(row); - let ink = if marked || h { TEXT } else { TEXT_SOFT }; + let ink = if marked || h { th().text } else { th().text_soft }; let cy = row.center().y; if id == "search" { let field = row.inset(2.0); - ui.p().rounded(field, 6.0, SURFACE_2); - ui.icon("search", Vec2::new(field.x + 16.0, cy), 16.0, TEXT_DIM); - ui.text_in(label, Rect::new(field.x + 34.0, field.y, field.w - 44.0, field.h), 13.0, Weight::Regular, TEXT_SOFT, Align::Left); + ui.p().rounded(field, 6.0, th().surface_2); + ui.icon("search", Vec2::new(field.x + 16.0, cy), 16.0, th().text_dim); + ui.text_in(label, Rect::new(field.x + 34.0, field.y, field.w - 44.0, field.h), 13.0, Weight::Regular, th().text_soft, Align::Left); } else if let Some((name, info)) = label.strip_prefix(line_pre.as_str()).and_then(|rest| rest.rsplit_once(" (")).map(|(n, t)| (n, t.trim_end_matches(')'))) { // a line of the timetable: its number on a sign, as a line sign on a bus let sw = (ui.width(name, 14.0, Weight::Bold) + 22.0).max(48.0); let sign = Rect::new(row.x + 10.0, cy - 13.0, sw, 26.0); - ui.p().rounded(sign, 6.0, ACCENT.mix(Color::rgba(150, 104, 30, 1.0), if marked || h { 0.0 } else { 0.3 })); + ui.p().rounded(sign, 6.0, th().accent.mix(Color::rgba(150, 104, 30, 1.0), if marked || h { 0.0 } else { 0.3 })); ui.text_in(name, sign, 14.0, Weight::Bold, Color::rgba(18, 14, 8, 1.0), Align::Center); ui.text_in(info, Rect::new(sign.right() + 12.0, row.y, row.right() - sign.right() - 40.0, row.h), 13.0, Weight::Regular, ink, Align::Left); - ui.icon("chevron_right", Vec2::new(row.right() - 16.0, cy), 16.0, TEXT_DIM); + ui.icon("chevron_right", Vec2::new(row.right() - 16.0, cy), 16.0, th().text_dim); } else if let Some((code, name)) = code_and_name(label).filter(|_| code_w > 0.0) { - ui.text_in(code, Rect::new(row.x + 12.0, row.y, code_w, row.h), 13.0, Weight::Medium, ACCENT, Align::Right); + ui.text_in(code, Rect::new(row.x + 12.0, row.y, code_w, row.h), 13.0, Weight::Medium, th().accent, Align::Right); ui.text_in(name, Rect::new(row.x + 24.0 + code_w, row.y, row.w - code_w - 36.0, row.h), 13.0, Weight::Regular, ink, Align::Left); } else { let (text, more) = plain(label); @@ -601,10 +612,10 @@ fn list_rows(sh: &mut Shell, v: &PauseView, list: &ListView, r: Rect, keep_marke let row_parts = parse_row(text); ui.text_in(row_parts.name, Rect::new(row.x + 12.0, row.y, row.w - 60.0, row.h), 13.0, Weight::Regular, ink, Align::Left); if !row_parts.value.is_empty() && row_parts.kind != "a" { - ui.text_in(row_parts.value, Rect::new(row.x + row.w * 0.5, row.y, row.w * 0.5 - 36.0, row.h), 12.5, Weight::Medium, TEXT_DIM, Align::Right); + ui.text_in(row_parts.value, Rect::new(row.x + row.w * 0.5, row.y, row.w * 0.5 - 36.0, row.h), 12.5, Weight::Medium, th().text_dim, Align::Right); } if more { - ui.icon("chevron_right", Vec2::new(row.right() - 16.0, cy), 16.0, TEXT_DIM); + ui.icon("chevron_right", Vec2::new(row.right() - 16.0, cy), 16.0, th().text_dim); } } y += LIST_ROW; @@ -655,8 +666,8 @@ fn preview(sh: &mut Shell, p: &Preview, list: &ListView, r: Rect) { let ui = &mut sh.ui; ui.panel(r); let inner = Rect::new(r.x + 18.0, r.y + 16.0, r.w - 36.0, r.h - 32.0); - ui.text_in(&p.title, Rect::new(inner.x, inner.y, inner.w, 24.0), 16.0, Weight::Bold, TEXT, Align::Left); - ui.text_in(&p.meta, Rect::new(inner.x, inner.y + 26.0, inner.w, 18.0), 12.0, Weight::Regular, TEXT_DIM, Align::Left); + ui.text_in(&p.title, Rect::new(inner.x, inner.y, inner.w, 24.0), 16.0, Weight::Bold, th().text, Align::Left); + ui.text_in(&p.meta, Rect::new(inner.x, inner.y + 26.0, inner.w, 18.0), 12.0, Weight::Regular, th().text_dim, Align::Left); let mut top = inner.y + 54.0; top += 6.0; if let Some(time) = p.time.as_ref() { @@ -669,7 +680,7 @@ fn preview(sh: &mut Shell, p: &Preview, list: &ListView, r: Rect) { acts.push(Action::Pane(code)); } } - ui.text_in(time, Rect::new(prev.right() + 8.0, top, next.x - prev.right() - 16.0, bh), 18.0, Weight::Bold, ACCENT, Align::Center); + ui.text_in(time, Rect::new(prev.right() + 8.0, top, next.x - prev.right() - 16.0, bh), 18.0, Weight::Bold, th().accent, Align::Center); top += bh + 10.0; } let lh = 28.0; @@ -688,8 +699,8 @@ fn preview(sh: &mut Shell, p: &Preview, list: &ListView, r: Rect) { if i == chosen { first_view = Some(i as f32 * lh); } - ui.text_in(when, Rect::new(row.right() - 70.0, row.y, 60.0, row.h), 12.5, Weight::Medium, ACCENT, Align::Right); - ui.text_in(what, Rect::new(row.x + 10.0, row.y, row.w - 90.0, row.h), 12.5, Weight::Regular, if i == chosen { TEXT } else { TEXT_SOFT }, Align::Left); + ui.text_in(when, Rect::new(row.right() - 70.0, row.y, 60.0, row.h), 12.5, Weight::Medium, th().accent, Align::Right); + ui.text_in(what, Rect::new(row.x + 10.0, row.y, row.w - 90.0, row.h), 12.5, Weight::Regular, if i == chosen { th().text } else { th().text_soft }, Align::Left); } p.rows.len() as f32 * lh }); @@ -709,18 +720,18 @@ fn preview(sh: &mut Shell, p: &Preview, list: &ListView, r: Rect) { ui.scroll_area(&key, rows_r, &mut |ui, view| { for (i, (what, when)) in p.rows.iter().enumerate() { let y = view.y + i as f32 * lh; - ui.text_in(when, Rect::new(view.right() - 78.0, y, 70.0, lh), 12.5, Weight::Medium, ACCENT, Align::Right); + ui.text_in(when, Rect::new(view.right() - 78.0, y, 70.0, lh), 12.5, Weight::Medium, th().accent, Align::Right); match what.strip_prefix(tp.as_str()) { Some(rest) => { let (num, dest) = rest.split_once(" › ").unwrap_or((rest, "")); let tw = (ui.width(num.trim(), 12.5, Weight::Bold) + 16.0).max(34.0); let tile = Rect::new(view.x, y + 3.0, tw, lh - 6.0); - ui.p().rounded(tile, 5.0, ACCENT.alpha(0.16)); - ui.text_in(num.trim(), tile, 12.5, Weight::Bold, ACCENT, Align::Center); - ui.text_in(dest, Rect::new(tile.right() + 10.0, y, view.w - tw - 100.0, lh), 12.5, Weight::Regular, TEXT_SOFT, Align::Left); + ui.p().rounded(tile, 5.0, th().accent.alpha(0.16)); + ui.text_in(num.trim(), tile, 12.5, Weight::Bold, th().accent, Align::Center); + ui.text_in(dest, Rect::new(tile.right() + 10.0, y, view.w - tw - 100.0, lh), 12.5, Weight::Regular, th().text_soft, Align::Left); } None => { - ui.text_in(what, Rect::new(view.x, y, view.w - 90.0, lh), 12.5, Weight::Regular, TEXT_SOFT, Align::Left); + ui.text_in(what, Rect::new(view.x, y, view.w - 90.0, lh), 12.5, Weight::Regular, th().text_soft, Align::Left); } } } diff --git a/crates/omsi-app/src/shell/pause/navigation_ui.rs b/crates/omsi-app/src/shell/pause/navigation_ui.rs index d7e8e7a0e..d20e6acea 100644 --- a/crates/omsi-app/src/shell/pause/navigation_ui.rs +++ b/crates/omsi-app/src/shell/pause/navigation_ui.rs @@ -26,15 +26,19 @@ fn session_action(sh: &mut Shell, v: &PauseView, id: &str, rect: Rect, kind: But let (hovered, _, clicked) = ui.interact(id_of(&format!("pause-session-{id}")), rect); let t = ui.anim(id_of(&format!("pause-session-hover-{id}")), if hovered { 1.0 } else { 0.0 }, 0.08); let primary = kind == ButtonKind::Primary; - let fill = if primary { ACCENT.mix(ACCENT_HOVER, t) } else { SURFACE_2.mix(DANGER_2.alpha(0.15), t) }; - let ink = if primary { ACCENT_INK } else { DANGER_2 }; - ui.p().rounded(rect, 8.0, fill); + let fill = if primary { th().accent.mix(th().accent_hover, t) } else { th().surface_2.mix(th().danger_2.alpha(0.15), t) }; + let ink = if primary { th().accent_ink } else { th().danger_2 }; + if primary { + ui.p().rounded(rect, th().radius, fill); + } else { + ui.surface(rect, th().radius, fill); + } let width = ui.width(&label, 18.0, Weight::Bold); let x = rect.center().x - (width + 32.0) * 0.5; ui.icon(rail_icon(id), Vec2::new(x + 12.0, rect.center().y), 24.0, ink); ui.text_in(&label, Rect::new(x + 32.0, rect.y, width + 2.0, rect.h), 18.0, Weight::Bold, ink, Align::Left); if v.kbd && !sh.quick_edit && v.list.is_none() && v.rail_sel == Some(k) { - ui.p().rounded_border(rect.inset(-2.0), 9.0, 2.0, ACCENT); + ui.p().rounded_border(rect.inset(-2.0), 9.0, 2.0, th().accent); } if clicked { activate(sh, v, k); @@ -46,14 +50,28 @@ pub(super) fn rail(sh: &mut Shell, v: &PauseView) { let r = Rect::new(0.0, 0.0, RAIL_W, size.y); let ui = &mut sh.ui; ui.solid(r); - ui.p().rect(r, SIDEBAR); + if th().glass.is_some() { + // (a glass theme: the rail a pane of its own over the picture) + ui.surface(Rect::new(8.0, 8.0, RAIL_W - 14.0, (size.y - 16.0).max(0.0)), th().radius + 6.0, th().sidebar); + } else { + ui.p().rect(r, th().sidebar); + } if let Some(tex) = sh.logo_tex { ui.image(Rect::new(18.0, 18.0, 32.0, 32.0), tex, 7.0); } - ui.text("openOMSI", Vec2::new(60.0, 32.0), 18.0, Weight::Bold, INK, Align::Left); - ui.text_in(crate::startup::VERSION, Rect::new(60.0, 38.0, RAIL_W - 72.0, 18.0), 11.0, Weight::Regular, INK_MUTED, Align::Left); + // the game's name and version; a theme's name over openOMSI's version + match sh.logo_title.as_deref() { + Some(title) => { + ui.text_in(title, Rect::new(60.0, 17.0, RAIL_W - 72.0, 22.0), 18.0, Weight::Bold, th().ink, Align::Left); + ui.text_in(&format!("openOMSI {}", crate::startup::VERSION), Rect::new(60.0, 38.0, RAIL_W - 72.0, 18.0), 11.0, Weight::Regular, th().ink_muted, Align::Left); + } + None => { + ui.text("openOMSI", Vec2::new(60.0, 32.0), 18.0, Weight::Bold, th().ink, Align::Left); + ui.text_in(crate::startup::VERSION, Rect::new(60.0, 38.0, RAIL_W - 72.0, 18.0), 11.0, Weight::Regular, th().ink_muted, Align::Left); + } + } session_action(sh, v, "resume", Rect::new(16.0, 78.0, RAIL_W - 32.0, 54.0), ButtonKind::Primary); - sh.ui.text_in(&omsi_ui::tr("Pause menu"), Rect::new(26.0, 144.0, RAIL_W - 42.0, 20.0), 11.0, Weight::Medium, INK_MUTED, Align::Left); + sh.ui.text_in(&omsi_ui::tr("Pause menu"), Rect::new(26.0, 144.0, RAIL_W - 42.0, 20.0), 11.0, Weight::Medium, th().ink_muted, Align::Left); let show_driver = size.y >= 440.0; let foot = if show_driver { 138.0 } else { 76.0 }; let area = Rect::new(12.0, 176.0, RAIL_W - 24.0, (size.y - 176.0 - foot).max(0.0)); @@ -86,20 +104,20 @@ pub(super) fn rail(sh: &mut Shell, v: &PauseView) { fn driver(sh: &mut Shell, v: &PauseView, height: f32) { let ui = &mut sh.ui; let chip = Rect::new(16.0, height - 66.0, RAIL_W - 32.0, 52.0); - ui.p().rounded(chip, 8.0, SURFACE); + ui.surface(chip, th().radius, th().surface); let av = Vec2::new(chip.x + 24.0, chip.center().y); - ui.p().circle(av, 16.0, SURFACE_3); + ui.p().circle(av, 16.0, th().surface_3); let initial = v.summary.driver.chars().next().map(|c| c.to_uppercase().to_string()).unwrap_or_else(|| "?".into()); - ui.text_in(&initial, Rect::new(av.x - 16.0, av.y - 16.0, 32.0, 32.0), 13.0, Weight::Bold, INK_2, Align::Center); - ui.text_in(&v.summary.driver, Rect::new(chip.x + 50.0, chip.y + 7.0, chip.w - 58.0, 19.0), 13.0, Weight::Bold, INK, Align::Left); - ui.text_in(&v.summary.bus, Rect::new(chip.x + 50.0, chip.y + 27.0, chip.w - 58.0, 17.0), 11.0, Weight::Regular, INK_MUTED, Align::Left); + ui.text_in(&initial, Rect::new(av.x - 16.0, av.y - 16.0, 32.0, 32.0), 13.0, Weight::Bold, th().ink_2, Align::Center); + ui.text_in(&v.summary.driver, Rect::new(chip.x + 50.0, chip.y + 7.0, chip.w - 58.0, 19.0), 13.0, Weight::Bold, th().ink, Align::Left); + ui.text_in(&v.summary.bus, Rect::new(chip.x + 50.0, chip.y + 27.0, chip.w - 58.0, 17.0), 11.0, Weight::Regular, th().ink_muted, Align::Left); } fn category_button(ui: &mut Ui, category: Category, selected: bool, rect: Rect) -> bool { let (hovered, _, clicked) = ui.interact(id_of(&format!("pause-category-{category:?}")), rect); let t = ui.anim(id_of(&format!("pause-category-hover-{category:?}")), if hovered { 1.0 } else { 0.0 }, 0.08); - ui.p().rounded(rect, 7.0, if selected { ACCENT.alpha(0.10) } else { SURFACE_2.alpha(t) }); - let color = if selected { ACCENT } else { INK_2.mix(INK, t) }; + ui.p().rounded(rect, 7.0, if selected { th().accent.alpha(0.10) } else { th().surface_2.alpha(t) }); + let color = if selected { th().accent } else { th().ink_2.mix(th().ink, t) }; ui.icon(category.icon(), Vec2::new(rect.x + 23.0, rect.center().y), 22.0, color); ui.text_in(&omsi_ui::tr(category.title()), Rect::new(rect.x + 48.0, rect.y, (rect.w - 71.0).max(0.0), rect.h), 17.0, Weight::Medium, color, Align::Left); ui.icon("chevron_right", Vec2::new(rect.right() - 15.0, rect.center().y), 15.0, color.alpha(0.6)); @@ -119,8 +137,8 @@ pub(super) fn page(sh: &mut Shell, v: &PauseView, page: Rect, compact: bool) { let tight = compact && page.h < 420.0; if !tight { let header = Rect::new(x, 28.0, width, 22.0); - sh.ui.text_in(&omsi_ui::tr("Pause menu"), header, 11.0, Weight::Medium, INK_MUTED, Align::Left); - sh.ui.text_in(&omsi_ui::tr(if v.paused { "Paused" } else { "Menu" }), header, 11.0, Weight::Medium, ACCENT, Align::Right); + sh.ui.text_in(&omsi_ui::tr("Pause menu"), header, 11.0, Weight::Medium, th().ink_muted, Align::Left); + sh.ui.text_in(&omsi_ui::tr(if v.paused { "Paused" } else { "Menu" }), header, 11.0, Weight::Medium, th().accent, Align::Right); } let short = compact && page.h < 640.0; let mut top = 86.0; @@ -136,9 +154,9 @@ pub(super) fn page(sh: &mut Shell, v: &PauseView, page: Rect, compact: bool) { } let editing = sh.quick_edit; let title_w = if category == Category::Home { (width - 98.0).max(0.0) } else { width }; - sh.ui.text_in(&omsi_ui::tr(category.title()), Rect::new(x, top, title_w, 46.0), if category == Category::Home && width < 420.0 { 26.0 } else { 34.0 }, Weight::Bold, INK, Align::Left); + sh.ui.text_in(&omsi_ui::tr(category.title()), Rect::new(x, top, title_w, 46.0), if category == Category::Home && width < 420.0 { 26.0 } else { 34.0 }, Weight::Bold, th().ink, Align::Left); if !short { - sh.ui.text_in(&omsi_ui::tr(if sh.quick_edit { "Choose actions and set their order" } else { category.hint() }), Rect::new(x, top + 54.0, width, 28.0), 16.0, Weight::Regular, INK_2, Align::Left); + sh.ui.text_in(&omsi_ui::tr(if sh.quick_edit { "Choose actions and set their order" } else { category.hint() }), Rect::new(x, top + 54.0, width, 28.0), 16.0, Weight::Regular, th().ink_2, Align::Left); } if category == Category::Home { let edit_rect = Rect::new(x + width - 90.0, top + 4.0, 90.0, 38.0); @@ -170,7 +188,7 @@ pub(super) fn page(sh: &mut Shell, v: &PauseView, page: Rect, compact: bool) { let text = omsi_ui::tr(if sh.quick_edit { "Up / Down: select | Space: toggle | Left / Right: reorder | R: reset | Esc: done" } else { "Esc resumes | Left / Right: categories | Up / Down: actions" }); let footer = Rect::new(x, page.h - 27.0, width, 18.0); let shown = if sh.quick_edit && width < 750.0 { "↑ ↓ / Space / ← → / R / Esc" } else { &text }; - sh.ui.text_in(shown, footer, 12.0, Weight::Regular, INK_MUTED, Align::Left); + sh.ui.text_in(shown, footer, 12.0, Weight::Regular, th().ink_muted, Align::Left); if sh.quick_edit { sh.ui.tooltip(footer, &text); } } } @@ -200,7 +218,7 @@ fn compact_categories(sh: &mut Shell, ids: &[&str], area: Rect, keyboard: bool) picked = Some(*category); } if selected == *category { - ui.p().rounded(Rect::new(r.x + 8.0, r.bottom() - 2.0, (r.w - 16.0).max(0.0), 2.0), 1.0, ACCENT); + ui.p().rounded(Rect::new(r.x + 8.0, r.bottom() - 2.0, (r.w - 16.0).max(0.0), 2.0), 1.0, th().accent); } } y + 34.0 @@ -231,7 +249,7 @@ fn cards(sh: &mut Shell, v: &PauseView, category: Category, area: Rect) { Rect::new(view.x, view.y, view.w, 40.0), 13.0, Weight::Regular, - INK_MUTED, + th().ink_muted, Align::Left, ); } @@ -265,22 +283,22 @@ fn action_card(ui: &mut Ui, v: &PauseView, index: usize, rect: Rect, keyboard: b let disabled = v.disabled.contains(id); let (hovered, _, clicked) = ui.interact(id_of(&format!("pause-card-{index}-{id}")), rect); let t = ui.anim(id_of(&format!("pause-card-hover-{index}-{id}")), if hovered && !disabled { 1.0 } else { 0.0 }, 0.08); - let fill = if keyboard { SURFACE.mix(ACCENT, 0.12) } else { SURFACE.mix(SURFACE_2, t) }; - ui.p().rounded(rect, 10.0, fill); + let fill = if keyboard { th().surface.mix(th().accent, 0.12) } else { th().surface.mix(th().surface_2, t) }; + ui.surface(rect, th().radius + 2.0, fill); if keyboard { - ui.p().rounded_border(rect.inset(1.0), 9.0, 2.0, ACCENT); + ui.p().rounded_border(rect.inset(1.0), 9.0, 2.0, th().accent); } let danger = *id == "endduty"; let ink = if disabled { - INK_MUTED + th().ink_muted } else if danger { - DANGER_2 + th().danger_2 } else { - INK + th().ink }; - let icon_c = if shortcut { ACCENT } else { INK_2 }; + let icon_c = if shortcut { th().accent } else { th().ink_2 }; let tile = Rect::new(rect.x + 18.0, rect.y + 37.0, 46.0, 46.0); - ui.p().rounded(tile, 8.0, if shortcut { ACCENT.alpha(0.10) } else { SURFACE_2 }); + ui.p().rounded(tile, 8.0, if shortcut { th().accent.alpha(0.10) } else { th().surface_2 }); let icon = plugin .as_ref() .and_then(|p| p.icon.as_deref()) @@ -292,8 +310,8 @@ fn action_card(ui: &mut Ui, v: &PauseView, index: usize, rect: Rect, keyboard: b let content = Rect::new(rect.x + 80.0, rect.y + 26.0, (rect.w - 112.0).max(0.0), 29.0); ui.text_in(text, content, 18.0, Weight::Bold, ink, Align::Left); let hint = omsi_ui::tr(action_hint(id)); - ui.text_in(&hint, Rect::new(content.x, rect.y + 65.0, content.w, 25.0), 14.5, Weight::Regular, INK_MUTED, Align::Left); - ui.icon("chevron_right", Vec2::new(rect.right() - 19.0, rect.center().y), 17.0, INK_MUTED); + ui.text_in(&hint, Rect::new(content.x, rect.y + 65.0, content.w, 25.0), 14.5, Weight::Regular, th().ink_muted, Align::Left); + ui.icon("chevron_right", Vec2::new(rect.right() - 19.0, rect.center().y), 17.0, th().ink_muted); clicked && !disabled } diff --git a/crates/omsi-app/src/shell/pause/navigation_ui_tests.rs b/crates/omsi-app/src/shell/pause/navigation_ui_tests.rs index fba974c48..6acdee898 100644 --- a/crates/omsi-app/src/shell/pause/navigation_ui_tests.rs +++ b/crates/omsi-app/src/shell/pause/navigation_ui_tests.rs @@ -169,7 +169,7 @@ fn pause_navigation_flat_cards_keep_keyboard_focus_visible() { sh.begin(1440.0, 900.0, 1.0, 1.0 / 60.0); assert!(sh.ui.flat); action_card(&mut sh.ui, &v, 2, Rect::new(350.0, 184.0, 480.0, 120.0), selected, false); - counts.push(sh.ui.p().verts.iter().filter(|vertex| vertex.color == ACCENT.0).count()); + counts.push(sh.ui.p().verts.iter().filter(|vertex| vertex.color == th().accent.0).count()); } assert_eq!(counts[0], 0); assert!(counts[1] > 0, "Keyboard focus must survive the flat theme"); @@ -184,7 +184,7 @@ fn pause_navigation_quit_keeps_keyboard_focus_visible() { let mut sh = Shell::new(); sh.begin(1440.0, 900.0, 1.0, 1.0 / 60.0); session_action(&mut sh, &v, "quit", Rect::new(16.0, 768.0, 204.0, 54.0), ButtonKind::Danger); - assert!(sh.ui.p().verts.iter().any(|vertex| vertex.color == ACCENT.0)); + assert!(sh.ui.p().verts.iter().any(|vertex| vertex.color == th().accent.0)); } #[test] diff --git a/crates/omsi-app/src/shell/pause/quick_actions_ui.rs b/crates/omsi-app/src/shell/pause/quick_actions_ui.rs index 492808dd8..1686e27b0 100644 --- a/crates/omsi-app/src/shell/pause/quick_actions_ui.rs +++ b/crates/omsi-app/src/shell/pause/quick_actions_ui.rs @@ -21,24 +21,24 @@ pub(super) fn editor(sh: &mut Shell, v: &PauseView, area: Rect) { let keyboard = v.kbd && !sh.ui.hover(area); let row_h = 62.0; let scroll_rect = Rect::new(area.x, area.y, area.w, (area.h - 54.0).max(0.0)); - sh.ui.p().rounded(area, 10.0, SURFACE); + sh.ui.surface(area, th().radius + 2.0, th().surface); sh.ui.scroll_area("pause-quick-editor", scroll_rect, &mut |ui, view| { for (i, id) in entries.iter().enumerate() { let rect = Rect::new(view.x + 8.0, view.y + i as f32 * row_h + 4.0, (view.w - 16.0).max(0.0), row_h - 8.0); let available = v.rail.iter().any(|line| line.0 == id && line.2.is_none()) && !v.disabled.contains(&id.as_str()); let on = chosen.contains(id); - if keyboard && focus == i { ui.p().rounded_border(rect, 6.0, 2.0, ACCENT); } + if keyboard && focus == i { ui.p().rounded_border(rect, 6.0, 2.0, th().accent); } let field = Rect::new(rect.x, rect.y, (rect.w - 88.0).max(0.0), rect.h); let (hover, _, clicked) = ui.interact(id_of(&format!("pause-quick-toggle-{id}")), field); - if hover { ui.p().rounded(field, 6.0, SURFACE_2); } + if hover { ui.p().rounded(field, 6.0, th().surface_2); } let check = Rect::new(field.x + 8.0, field.center().y - 10.0, 20.0, 20.0); - ui.p().rounded_border(check, 3.0, 1.0, if on { ACCENT } else { INK_MUTED }); - if on { ui.icon("check", check.center(), 18.0, ACCENT); } + ui.p().rounded_border(check, 3.0, 1.0, if on { th().accent } else { th().ink_muted }); + if on { ui.icon("check", check.center(), 18.0, th().accent); } let text = label(id, v.rail); let name = Rect::new(field.x + 38.0, field.y, (field.w - 44.0).max(0.0), if available { field.h } else { 30.0 }); - ui.text_in(&text, name, 15.0, Weight::Medium, if available { INK } else { INK_MUTED }, Align::Left); + ui.text_in(&text, name, 15.0, Weight::Medium, if available { th().ink } else { th().ink_muted }, Align::Left); ui.tooltip(field, &text); - if !available { ui.text_in(&omsi_ui::tr("Currently unavailable"), Rect::new(name.x, field.y + 29.0, name.w, 18.0), 11.0, Weight::Regular, INK_MUTED, Align::Left); } + if !available { ui.text_in(&omsi_ui::tr("Currently unavailable"), Rect::new(name.x, field.y + 29.0, name.w, 18.0), 11.0, Weight::Regular, th().ink_muted, Align::Left); } if clicked { edits.push(Edit::Toggle(id.clone())); picked = Some(i); } for (down, icon, tip, x) in [(false, "expand_less", "Up", rect.right() - 84.0), (true, "expand_more", "Down", rect.right() - 40.0)] { let r = Rect::new(x, rect.y + 7.0, 36.0, 40.0); @@ -49,7 +49,7 @@ pub(super) fn editor(sh: &mut Shell, v: &PauseView, area: Rect) { edits.push(Edit::Move(id.clone(), down)); picked = Some(i); } ui.tooltip(r, &omsi_ui::tr(tip)); - } else { ui.icon(icon, r.center(), 18.0, INK_MUTED.alpha(0.3)); } + } else { ui.icon(icon, r.center(), 18.0, th().ink_muted.alpha(0.3)); } } } entries.len() as f32 * row_h + 8.0 diff --git a/crates/omsi-app/src/shell/pause/settings_ui_tests.rs b/crates/omsi-app/src/shell/pause/settings_ui_tests.rs index 2820234b3..3f43a77da 100644 --- a/crates/omsi-app/src/shell/pause/settings_ui_tests.rs +++ b/crates/omsi-app/src/shell/pause/settings_ui_tests.rs @@ -205,7 +205,7 @@ fn pause_settings_headings_draw_accent_markers_without_actions() { let marker: Vec<_> = verts .iter() .filter(|v| { - v.color == ACCENT.0 + v.color == th().accent.0 && (318.0..=321.0).contains(&v.pos[0]) && (top..=top + 20.0).contains(&v.pos[1]) }) diff --git a/crates/omsi-app/src/themes.rs b/crates/omsi-app/src/themes.rs new file mode 100644 index 000000000..3246777a4 --- /dev/null +++ b/crates/omsi-app/src/themes.rs @@ -0,0 +1,533 @@ +//! Interface themes: a look of its own for the game's screens - the colours of the menus, the +//! panels as frosted glass over the picture, the loading screen's logo and how it shows the +//! progress, the logo at the top of the pause menu - from a theme file (docs/THEMES.md). +//! +//! A theme is a folder with a `theme.json`: `Themes//` of a content root, or a plugin's own +//! folder (`plugins//theme.json`, its id the plugin's name). The player chooses one in +//! Options › Interface (`Settings::ui_theme`); a plugin may switch to one (`omsi.ui.set_theme`). +//! A content root another machine sent (a LAN host's mods) brings none. + +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; + +use omsi_ui::theme::{self, Glass, Palette}; +use omsi_ui::Color; + +/// openOMSI's own look as the settings file writes it (the setting reads it as ""). +pub(crate) const OWN: &str = "openomsi"; +/// The longest theme file, SVG logo or picture that is read (bytes). +const MAX_FILE: u64 = 4 << 20; +/// The longest name and title of a theme (characters). +const MAX_NAME: usize = 40; +/// The track under a logo drawn along its path: the whole path, this faint. +const TRACK: f32 = 0.18; + +/// How the loading screen shows how far the loading is. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub(crate) enum Progress { + /// openOMSI's striped bar along the bottom, in the theme's accent. + #[default] + Bar, + /// The logo's path `progress` drawn along as far as the loading is (an SVG logo). + Stroke, + /// The logo filled from the left as far as the loading is, faint beyond. + Fill, +} + +/// A logo of a theme: an SVG (its source, its size and the length of its path `progress` +/// when it has one) or a picture. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Logo { + Svg { src: String, size: (f32, f32), progress_len: Option }, + Picture { w: u32, h: u32, rgba: Arc> }, +} + +impl Logo { + /// Width over height. + pub fn aspect(&self) -> f32 { + let (w, h) = match self { + Logo::Svg { size, .. } => *size, + Logo::Picture { w, h, .. } => (*w as f32, *h as f32), + }; + (w / h.max(1e-3)).clamp(0.05, 20.0) + } + + /// Its pixels fitted into a square of `n` (the pause menu's logo), centred. + pub fn square_pixels(&self, n: u32) -> Option> { + let a = self.aspect(); + let (w, h) = if a >= 1.0 { (n, ((n as f32 / a).round() as u32).max(1)) } else { (((n as f32 * a).round() as u32).max(1), n) }; + let px = self.pixels(w, h, None)?; + let (x0, y0) = ((n - w) / 2, (n - h) / 2); + let mut out = vec![0u8; (n * n * 4) as usize]; + for y in 0..h { + let from = (y * w * 4) as usize; + let to = (((y0 + y) * n + x0) * 4) as usize; + out[to..to + (w * 4) as usize].copy_from_slice(&px[from..from + (w * 4) as usize]); + } + Some(out) + } + + /// Its pixels at `w` x `h` (straight RGBA) - with the loading's progress `frac` shown as + /// `progress` has it (none: the whole logo). + pub fn pixels(&self, w: u32, h: u32, progress: Option<(Progress, f32)>) -> Option> { + if let (Logo::Svg { src, progress_len: Some(len), .. }, Some((Progress::Stroke, frac))) = (self, progress) { + return omsi_ui::icons::rasterize_rgba(&omsi_ui::icons::progress_svg(src, "progress", *len, frac, TRACK), w, h); + } + let mut px = match self { + Logo::Svg { src, .. } => omsi_ui::icons::rasterize_rgba(src, w, h)?, + Logo::Picture { w: pw, h: ph, rgba } => { + let img = image::RgbaImage::from_raw(*pw, *ph, rgba.to_vec())?; + image::imageops::resize(&img, w, h, image::imageops::FilterType::Triangle).into_raw() + } + }; + if let Some((_, frac)) = progress { + // (filled from the left: beyond the progress grey and faint) + let cut = (w as f32 * frac.clamp(0.0, 1.0)).round() as usize; + for (i, p) in px.chunks_mut(4).enumerate() { + if i % w as usize >= cut { + let grey = (0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) as u8; + p[..3].fill(grey); + p[3] = (p[3] as f32 * TRACK) as u8; + } + } + } + Some(px) + } +} + +/// The loading screen of a theme. +#[derive(Clone, Debug, PartialEq, Default)] +pub(crate) struct Loading { + pub background: Option, + /// The map's name and the tips. + pub text: Option, + /// In the middle, in place of openOMSI's wordmark. + pub logo: Option, + pub progress: Progress, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Theme { + pub id: String, + pub name: String, + pub palette: Palette, + pub loading: Loading, + /// The logo at the top of the pause menu, and the name beside it (openOMSI's version + /// stays under it). + pub logo: Option, + pub title: Option, +} + +/// A theme file read: `text` of the folder `dir`, the theme `id`. What it gets wrong it says; +/// what it does not know is logged and left out. +pub(crate) fn parse(id: &str, dir: &Path, text: &str) -> Result { + let v: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("theme.json: {e}"))?; + let o = v.as_object().ok_or("theme.json: an object is expected")?; + let known = ["name", "title", "colors", "radius", "glass", "loading", "logo", "author", "version", "description"]; + for k in o.keys().filter(|k| !known.contains(&k.as_str())) { + log::warn!("interface theme \"{id}\": \"{k}\" is not known, left out"); + } + let name = match o.get("name") { + Some(n) => text_of(n, "name")?, + None => id.to_string(), + }; + let mut colors: Vec<(String, Color)> = Vec::new(); + if let Some(c) = o.get("colors") { + for (k, val) in c.as_object().ok_or("colors: an object of colours is expected")? { + colors.push((k.clone(), color_of(val).ok_or_else(|| format!("colors.{k}: \"#rrggbb\", \"#rrggbbaa\" or [r, g, b, a] is expected"))?)); + } + } + let (mut palette, unknown) = Palette::with_colors(colors.iter().map(|(k, c)| (k.as_str(), *c))); + for k in unknown { + log::warn!("interface theme \"{id}\": colors.{k} is not known, left out"); + } + if let Some(r) = o.get("radius") { + palette.radius = r.as_f64().filter(|r| (0.0..=24.0).contains(r)).ok_or("radius: 0 to 24 is expected")? as f32; + } + palette.glass = match o.get("glass") { + None | Some(serde_json::Value::Bool(false)) | Some(serde_json::Value::Null) => None, + Some(serde_json::Value::Bool(true)) => Some(Glass::DEFAULT), + Some(serde_json::Value::Object(g)) => { + let num = |k: &str, max: f64, default: f32| -> Result { + match g.get(k) { + None => Ok(default), + Some(x) => x.as_f64().filter(|x| (0.0..=max).contains(x)).map(|x| x as f32).ok_or(format!("glass.{k}: 0 to {max} is expected")), + } + }; + let d = Glass::DEFAULT; + Some(Glass { opacity: num("opacity", 1.0, d.opacity)?, blur: num("blur", 64.0, d.blur)?, edge: num("edge", 1.0, d.edge)?, highlight: num("highlight", 1.0, d.highlight)? }) + } + Some(_) => return Err("glass: true, false or an object is expected".into()), + }; + let mut loading = Loading::default(); + if let Some(l) = o.get("loading") { + let l = l.as_object().ok_or("loading: an object is expected")?; + if let Some(c) = l.get("background") { + loading.background = Some(color_of(c).ok_or("loading.background: a colour is expected")?); + } + if let Some(c) = l.get("text") { + loading.text = Some(color_of(c).ok_or("loading.text: a colour is expected")?); + } + if let Some(f) = l.get("logo") { + loading.logo = Some(read_logo(dir, &text_of(f, "loading.logo")?)?); + } + loading.progress = match l.get("progress").and_then(|p| p.as_str()).unwrap_or("bar") { + "bar" => Progress::Bar, + "stroke" => Progress::Stroke, + "fill" => Progress::Fill, + other => return Err(format!("loading.progress: \"bar\", \"stroke\" or \"fill\" is expected, not \"{other}\"")), + }; + match (&loading.logo, loading.progress) { + (None, Progress::Stroke | Progress::Fill) => return Err("loading.progress: a logo (loading.logo) is needed to show the progress".into()), + // (a logo without the path to draw along: filled instead) + (Some(Logo::Picture { .. } | Logo::Svg { progress_len: None, .. }), Progress::Stroke) => { + log::warn!("interface theme \"{id}\": the loading logo has no path with id=\"progress\": it is filled from the left instead"); + loading.progress = Progress::Fill; + } + _ => {} + } + } + let logo = o.get("logo").map(|f| text_of(f, "logo").and_then(|f| read_logo(dir, &f))).transpose()?; + let title = o.get("title").map(|t| text_of(t, "title")).transpose()?; + Ok(Theme { id: id.to_string(), name, palette, loading, logo, title }) +} + +/// A name or a file of the theme file: a text, on one line, not too long. +fn text_of(v: &serde_json::Value, what: &str) -> Result { + let t = v.as_str().ok_or(format!("{what}: a text is expected"))?.trim(); + if t.is_empty() || t.chars().count() > 200 || t.chars().any(char::is_control) { + return Err(format!("{what}: one line of text is expected")); + } + Ok(if what == "name" || what == "title" { t.chars().take(MAX_NAME).collect() } else { t.to_string() }) +} + +/// A colour of the theme file: `"#rrggbb"`, `"#rrggbbaa"` or `[r, g, b]` / `[r, g, b, a]` +/// (0 to 255, the opacity 0 to 1). +fn color_of(v: &serde_json::Value) -> Option { + match v { + serde_json::Value::String(s) => theme::parse_hex(s), + serde_json::Value::Array(a) if a.len() == 3 || a.len() == 4 => { + let n: Vec = a.iter().map(|x| x.as_f64()).collect::>()?; + let byte = |x: f64| (0.0..=255.0).contains(&x).then_some(x.round() as u8); + let alpha = n.get(3).copied().unwrap_or(1.0); + (0.0..=1.0).contains(&alpha).then_some(())?; + Some(Color::rgba(byte(n[0])?, byte(n[1])?, byte(n[2])?, alpha as f32)) + } + _ => None, + } +} + +/// The logo `file` of the theme's folder `dir`: an SVG or a picture (PNG, TGA, BMP, JPEG, +/// DDS). Only a file in the folder (or below it) is read. +fn read_logo(dir: &Path, file: &str) -> Result { + let rel = Path::new(file); + if !rel.components().all(|c| matches!(c, Component::Normal(_))) { + return Err(format!("{file}: a file in the theme's folder is expected")); + } + let path = dir.join(rel); + let len = std::fs::metadata(&path).map_err(|e| format!("{file}: {e}"))?.len(); + if len > MAX_FILE { + return Err(format!("{file}: larger than {} MB", MAX_FILE >> 20)); + } + if file.to_ascii_lowercase().ends_with(".svg") { + let src = std::fs::read_to_string(&path).map_err(|e| format!("{file}: {e}"))?; + let size = omsi_ui::icons::svg_size(&src).filter(|s| s.0 > 0.0 && s.1 > 0.0).ok_or(format!("{file}: not an SVG that can be drawn"))?; + let progress_len = omsi_ui::icons::path_length(&src, "progress"); + return Ok(Logo::Svg { src, size, progress_len }); + } + let img = omsi_texture::decode_file(&path).map_err(|e| format!("{file}: {e}"))?; + if img.width == 0 || img.height == 0 || img.width > 4096 || img.height > 4096 { + return Err(format!("{file}: 1 to 4096 pixels wide and high is expected")); + } + Ok(Logo::Picture { w: img.width, h: img.height, rgba: Arc::new(img.rgba) }) +} + +/// A theme's id is the name of its folder: letters, digits, `-`, `_` and `.`. +fn valid_id(id: &str) -> bool { + !id.is_empty() && id.len() <= MAX_NAME && id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) && !id.eq_ignore_ascii_case("openomsi") +} + +/// Every theme there is, (id, folder): the content roots' `Themes` folders first, then the +/// plugins'; of two of one id (in any case) the first. +pub(crate) fn found() -> Vec<(String, PathBuf)> { + let roots: Vec = omsi_cfg::content_roots().iter().filter(|r| !omsi_cfg::is_sandbox(r)).cloned().collect(); + found_in(&roots) +} + +fn found_in(roots: &[PathBuf]) -> Vec<(String, PathBuf)> { + let mut out: Vec<(String, PathBuf)> = Vec::new(); + for folder in ["themes", "plugins"] { + for root in roots { + let Some(dir) = omsi_plugin::resolve_path(root, folder).filter(|d| d.is_dir()) else { continue }; + let Ok(entries) = std::fs::read_dir(&dir) else { continue }; + let mut dirs: Vec = entries.flatten().map(|e| e.path()).filter(|p| p.join("theme.json").is_file()).collect(); + dirs.sort(); + for d in dirs { + let Some(id) = d.file_name().and_then(|n| n.to_str()).filter(|id| valid_id(id)).map(str::to_string) else { continue }; + if !out.iter().any(|(o, _)| o.eq_ignore_ascii_case(&id)) { + out.push((id, d)); + } + } + } + } + out +} + +/// The theme `id` of `themes`, read. +fn load_from(themes: &[(String, PathBuf)], id: &str) -> Result { + let (id, dir) = themes.iter().find(|(t, _)| t.eq_ignore_ascii_case(id)).ok_or("there is no theme of that id")?; + let file = dir.join("theme.json"); + let len = std::fs::metadata(&file).map_err(|e| e.to_string())?.len(); + if len > MAX_FILE { + return Err("theme.json is too large".into()); + } + let text = std::fs::read_to_string(&file).map_err(|e| format!("theme.json: {e}"))?; + parse(id, dir, &text) +} + +/// The value of the setting `ui_theme` as read: one line, "" for openOMSI's own look. +pub(crate) fn setting(v: &str) -> String { + let v = v.trim(); + if v.eq_ignore_ascii_case("openomsi") || v.eq_ignore_ascii_case("default") || !valid_id(v) { + return String::new(); + } + v.to_string() +} + +struct State { + wanted: String, + theme: Option>, +} + +static STATE: Mutex = Mutex::new(State { wanted: String::new(), theme: None }); + +/// Puts the theme `id` in force (the setting's value), when it is not already: read anew, its +/// palette for the screens. A theme that cannot be read leaves openOMSI's look, with a line in +/// the log why. Cheap to call every frame. +pub(crate) fn sync(id: &str) { + let mut s = STATE.lock().unwrap_or_else(|e| e.into_inner()); + if s.wanted == id { + return; + } + s.wanted = id.to_string(); + s.theme = None; + let mut palette = Palette::OPENOMSI; + if !id.is_empty() { + match load_from(&found(), id) { + Ok(t) => { + log::info!("interface theme \"{}\" ({}): glass {}, loading {:?}", t.id, t.name, t.palette.glass.is_some(), t.loading.progress); + palette = t.palette; + s.theme = Some(Arc::new(t)); + } + Err(e) => log::warn!("interface theme \"{id}\": {e} - openOMSI's own look"), + } + } + theme::set(palette); +} + +/// The theme in force (none: openOMSI's own look). +pub(crate) fn current() -> Option> { + STATE.lock().unwrap_or_else(|e| e.into_inner()).theme.clone() +} + +/// The themes the player can choose, (id, name): openOMSI's own look first. Looked for once. +pub(crate) fn choices() -> &'static [(String, String)] { + static LIST: OnceLock> = OnceLock::new(); + LIST.get_or_init(|| { + let mut out = vec![(String::new(), "openOMSI".to_string())]; + for (id, dir) in found() { + // (its name, without reading its pictures) + let name = std::fs::read_to_string(dir.join("theme.json")).ok().and_then(|t| serde_json::from_str::(&t).ok()).and_then(|v| v.get("name").and_then(|n| n.as_str()).map(|n| n.trim().chars().take(MAX_NAME).collect::())).filter(|n| !n.is_empty()).unwrap_or_else(|| id.clone()); + out.push((id, name)); + } + out + }) +} + +/// The picture behind a glass theme's panels: the game's at about this many pixels across. +pub(crate) const BACKDROP_W: u32 = 320; + +/// The size of the backdrop for a window of `w` x `h`. +pub(crate) fn backdrop_size(w: u32, h: u32) -> (u32, u32) { + let bw = BACKDROP_W.min(w.max(1)); + (bw, ((h as u64 * bw as u64) / w.max(1) as u64).max(1) as u32) +} + +/// `px` (RGBA, `w` x `h`, both even) at half the size: each pixel the mean of four. +pub(crate) fn halve(px: &[u8], w: u32, h: u32) -> Vec { + let (hw, hh) = ((w / 2) as usize, (h / 2) as usize); + let w = w as usize; + let mut out = vec![0u8; hw * hh * 4]; + if px.len() < w * hh * 2 * 4 { + return out; + } + for y in 0..hh { + for x in 0..hw { + for c in 0..4 { + let at = |dx: usize, dy: usize| px[((2 * y + dy) * w + 2 * x + dx) * 4 + c] as u32; + out[(y * hw + x) * 4 + c] = ((at(0, 0) + at(1, 0) + at(0, 1) + at(1, 1) + 2) / 4) as u8; + } + } + } + out +} + +/// `px` (RGBA, `w` x `h`) blurred by `radius` pixels: three box blurs one after the other, +/// close to a Gaussian's; opaque. +pub(crate) fn blur(px: &mut [u8], w: usize, h: usize, radius: usize) { + if w == 0 || h == 0 || px.len() < w * h * 4 { + return; + } + let r = radius.min(w.max(h)); + let mut tmp = vec![0u8; w * h * 4]; + for _ in 0..3 { + if r > 0 { + box_pass(px, &mut tmp, w, h, r, true); + box_pass(&tmp, px, w, h, r, false); + } + } + for p in px.chunks_mut(4) { + p[3] = 255; + } +} + +/// One box blur along the rows (`across`) or the columns, edges held. +fn box_pass(src: &[u8], dst: &mut [u8], w: usize, h: usize, r: usize, across: bool) { + let (lines, len) = if across { (h, w) } else { (w, h) }; + let at = |line: usize, i: usize| if across { (line * w + i) * 4 } else { (i * w + line) * 4 }; + let n = (2 * r + 1) as u32; + for line in 0..lines { + let mut sum = [0u32; 4]; + for k in 0..=2 * r { + let i = (k as isize - r as isize).clamp(0, len as isize - 1) as usize; + for c in 0..4 { + sum[c] += src[at(line, i) + c] as u32; + } + } + for i in 0..len { + for c in 0..4 { + dst[at(line, i) + c] = (sum[c] / n) as u8; + } + let out = (i as isize - r as isize).clamp(0, len as isize - 1) as usize; + let inn = (i + r + 1).min(len - 1); + for c in 0..4 { + sum[c] = sum[c] + src[at(line, inn) + c] as u32 - src[at(line, out) + c] as u32; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn folder(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("openomsi-themes-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + const LOGO: &str = r##""##; + + /// A theme of a few colours, glass and a logo that is the loading bar. + #[test] + fn a_theme_is_read_whole() { + let dir = folder("whole"); + std::fs::write(dir.join("loading.svg"), LOGO).unwrap(); + std::fs::write(dir.join("mark.svg"), LOGO).unwrap(); + let text = r##"{ + "name": "Omsi-Hub", "title": "Omsi-Hub", + "colors": { "accent": "#f58620", "background": "#0a142e", "surface": [16, 30, 62], "ink": "#ffffff" }, + "radius": 14, + "glass": { "opacity": 0.55, "blur": 28 }, + "loading": { "background": "#07101f", "text": "#c8d2e6", "logo": "loading.svg", "progress": "stroke" }, + "logo": "mark.svg", + "sparkle": true + }"##; + let t = parse("omsihub", &dir, text).unwrap(); + assert_eq!((t.name.as_str(), t.title.as_deref()), ("Omsi-Hub", Some("Omsi-Hub"))); + assert_eq!(t.palette.accent, Color::rgba(0xf5, 0x86, 0x20, 1.0)); + assert_eq!((t.palette.surface, t.palette.panel), (Color::rgba(16, 30, 62, 1.0), Color::rgba(16, 30, 62, 1.0))); + assert_eq!(t.palette.radius, 14.0); + assert_eq!(t.palette.glass, Some(Glass { opacity: 0.55, blur: 28.0, ..Glass::DEFAULT })); + assert_eq!(t.loading.progress, Progress::Stroke); + assert_eq!(t.loading.background, Some(Color::rgba(7, 16, 31, 1.0))); + let logo = t.loading.logo.as_ref().unwrap(); + assert_eq!(logo.aspect(), 2.0); + // the loading bar: drawn along the path as far as the loading is + let half = logo.pixels(200, 100, Some((Progress::Stroke, 0.5))).unwrap(); + let whole = logo.pixels(200, 100, None).unwrap(); + let ink = |px: &[u8]| px.chunks(4).filter(|p| p[3] > 128).count(); + assert!(ink(&half) > ink(&whole) / 4 && ink(&half) < ink(&whole), "{} of {}", ink(&half), ink(&whole)); + // filled from the left: the right half faint + let fill = logo.pixels(200, 100, Some((Progress::Fill, 0.5))).unwrap(); + assert!(fill.chunks(4).enumerate().filter(|(i, _)| i % 200 >= 100).all(|(_, p)| p[3] <= 50)); + assert!(t.logo.is_some()); + } + + /// What a theme file gets wrong is said, and nothing outside its folder is read. + #[test] + fn a_wrong_theme_says_why() { + let dir = folder("wrong"); + let bad = |text: &str| parse("x", &dir, text).unwrap_err(); + assert!(bad("[]").contains("object")); + assert!(bad(r#"{"colors": {"accent": "orange"}}"#).contains("colors.accent")); + assert!(bad(r#"{"radius": 80}"#).contains("radius")); + assert!(bad(r#"{"glass": {"opacity": 2}}"#).contains("glass.opacity")); + assert!(bad(r#"{"loading": {"progress": "stroke"}}"#).contains("logo")); + assert!(bad(r#"{"loading": {"progress": "spin"}}"#).contains("spin")); + assert!(bad(r#"{"logo": "../../secret.svg"}"#).contains("theme's folder")); + assert!(bad(r#"{"logo": "/etc/x.svg"}"#).contains("theme's folder")); + assert!(bad(r#"{"logo": "missing.svg"}"#).contains("missing.svg")); + // (a stroke without the path to draw along: filled; no name: the id) + std::fs::write(dir.join("plain.svg"), r#""#).unwrap(); + let t = parse("x", &dir, r#"{"loading": {"logo": "plain.svg", "progress": "stroke"}, "glass": true}"#).unwrap(); + assert_eq!((t.name.as_str(), t.loading.progress, t.palette.glass), ("x", Progress::Fill, Some(Glass::DEFAULT))); + assert_eq!(t.palette.accent, Palette::OPENOMSI.accent); + } + + /// Themes are found in the content roots' `Themes` folders and in the plugins' folders; + /// the first of an id wins. + #[test] + fn themes_are_found_in_themes_and_plugins() { + let root = folder("roots"); + let a = root.join("a"); + let b = root.join("b"); + for (dir, json) in [(a.join("Themes/Night"), r#"{"name": "Night"}"#), (b.join("Themes/night"), r#"{"name": "Other"}"#), (b.join("plugins/omsihub"), r##"{"name": "Omsi-Hub", "colors": {"accent": "#f58620"}}"##), (b.join("plugins/plain"), "")] { + std::fs::create_dir_all(&dir).unwrap(); + if !json.is_empty() { + std::fs::write(dir.join("theme.json"), json).unwrap(); + } + } + std::fs::create_dir_all(b.join("Themes/bad id!")).unwrap(); + std::fs::write(b.join("Themes/bad id!/theme.json"), "{}").unwrap(); + let found = found_in(&[a.clone(), b.clone()]); + let ids: Vec<&str> = found.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, ["Night", "omsihub"]); + let t = load_from(&found, "OMSIHUB").unwrap(); + assert_eq!((t.id.as_str(), t.palette.accent), ("omsihub", Color::rgba(0xf5, 0x86, 0x20, 1.0))); + assert!(load_from(&found, "plain").is_err()); + // the setting: one id, openOMSI's own look by any of its names + assert_eq!(setting(" omsihub "), "omsihub"); + for own in ["", "openOMSI", "default", "a b", "../x"] { + assert_eq!(setting(own), "", "{own}"); + } + } + + /// The glass's picture: blurred (the edge of a white half softened), opaque, the same + /// size; small, whatever the window. + #[test] + fn the_backdrop_is_blurred_and_small() { + assert_eq!(backdrop_size(1920, 1080), (320, 180)); + assert_eq!(backdrop_size(200, 100), (200, 100)); + let (w, h) = (40, 10); + let mut px: Vec = (0..w * h).flat_map(|i| if i % w < w / 2 { [255, 255, 255, 200] } else { [0, 0, 0, 200] }).collect(); + blur(&mut px, w, h, 3); + let at = |x: usize| px[(5 * w + x) * 4]; + assert_eq!((at(0), at(w - 1)), (255, 0)); + assert!(at(w / 2 - 1) < 230 && at(w / 2) > 25, "{} {}", at(w / 2 - 1), at(w / 2)); + assert!((1..w).all(|x| at(x) <= at(x - 1)), "it falls evenly"); + assert!(px.chunks(4).all(|p| p[3] == 255)); + } +} diff --git a/crates/omsi-app/src/ui.rs b/crates/omsi-app/src/ui.rs index be1fb38ff..5ffa9574c 100644 --- a/crates/omsi-app/src/ui.rs +++ b/crates/omsi-app/src/ui.rs @@ -1216,24 +1216,62 @@ fn expected_of(v: &serde_json::Value) -> Option { const WORDMARK: &str = include_str!("../../../assets/logos/openomsi-wordmark-light.svg"); /// The pixels of a stretch of the loading bar `w` x `h` with its stripes: 45 degree bands of -/// the accent and a lighter one, `period` pixels apart along the bar, moved `phase` pixels on. -fn stripe_pixels(w: u32, h: u32, period: u32, phase: u32, alpha: u8) -> Vec { +/// the accent `dark` and a lighter one `light`, `period` pixels apart along the bar, moved +/// `phase` pixels on. +fn stripe_pixels(w: u32, h: u32, period: u32, phase: u32, alpha: u8, dark: [u8; 3], light: [u8; 3]) -> Vec { let mut px = Vec::with_capacity((w * h * 4) as usize); for y in 0..h { for x in 0..w { - let light = (x + period * 4 + phase - y % period) % period < period / 2; - let c = if light { [244, 182, 82] } else { [232, 160, 48] }; + let lit = (x + period * 4 + phase - y % period) % period < period / 2; + let c = if lit { light } else { dark }; px.extend_from_slice(&[c[0], c[1], c[2], alpha]); } } px } +/// The loading screen's colours: the background, the texts (the map's name, the tips), how +/// far (the per cent), the accent (the bar, "TIPS"), the bar's track, and the bar's stripes - +/// openOMSI's, or an interface theme's (`themes`). +struct LoadingLook { + bg: [u8; 3], + text: [u8; 3], + ink: [u8; 3], + accent: [u8; 3], + track: [u8; 3], + stripes: ([u8; 3], [u8; 3]), +} + +impl LoadingLook { + fn of(theme: Option<&crate::themes::Theme>) -> LoadingLook { + let rgb = |c: omsi_ui::Color| { + let [r, g, b, _] = omsi_ui::theme::rgba8(c); + [r, g, b] + }; + match theme { + None => LoadingLook { bg: [18, 18, 18], text: [184, 184, 184], ink: [237, 237, 237], accent: [232, 160, 48], track: [42, 42, 42], stripes: ([232, 160, 48], [244, 182, 82]) }, + Some(t) => { + let p = t.palette; + LoadingLook { + bg: rgb(t.loading.background.unwrap_or(p.bg)), + text: rgb(t.loading.text.unwrap_or(p.ink_2)), + ink: rgb(p.ink), + accent: rgb(p.accent), + track: rgb(p.surface_2), + stripes: (rgb(p.accent), rgb(p.accent.lighten(0.18))), + } + } + } + } +} + impl Ui { /// The loading screen: the openOMSI wordmark large in the middle and the map's name under /// it; along the bottom a square amber bar whose stripes slide on (it is working), over /// it a rotating tip on the left and how far on the right. All of it `opacity` (fading /// over the first pictures of the session); `clock` moves the stripes and the tips. + /// An interface theme (`themes`) gives its colours and its logo, which may itself be the + /// bar: drawn along its path, or filled from the left, as far as the loading is. #[allow(clippy::too_many_arguments)] pub fn loading_screen(&mut self, r: &Renderer, scene: &mut Scene, width: f32, height: f32, scale: f32, title: &str, progress: f32, opacity: f32, clock: f32, tip: (&str, f32)) { let s = scale.max(0.5); @@ -1241,66 +1279,92 @@ impl Ui { if k <= 0.0 { return; } + let theme = crate::themes::current(); + let look = LoadingLook::of(theme.as_deref()); + let with = |c: [u8; 3], a: u8| [c[0], c[1], c[2], a]; let a = |v: f32| (v * k).round().clamp(0.0, 255.0) as u8; - let bg = self.text.solid(r, scene, [18, 18, 18, a(255.0)]); + let p = progress.clamp(0.0, 1.0); + let bg = self.text.solid(r, scene, with(look.bg, a(255.0))); scene.overlays.push((bg, [0.0, 0.0, width, height])); - // the wordmark, large, a little above the middle - let lw = (560.0 * s).min(width * 0.6).round() as u32; - let lh = (lw as f32 * 303.0 / 1142.0).round() as u32; let ka = a(255.0); - let logo = self.text.cached(r, scene, ("\u{0}wordmark".to_string(), lw, [255, 255, 255, ka]), (lw, lh), || { - let mut px = omsi_ui::icons::rasterize_rgba(WORDMARK, lw, lh).unwrap_or_else(|| vec![0; (lw * lh * 4) as usize]); - for p in px.chunks_mut(4) { - p[3] = (p[3] as u32 * ka as u32 / 255) as u8; + let fade = move |mut px: Vec| { + for q in px.chunks_mut(4) { + q[3] = (q[3] as u32 * ka as u32 / 255) as u8; } px - }); + }; + // the logo, large, a little above the middle: openOMSI's wordmark, or the theme's + let theme_logo = theme.as_ref().and_then(|t| t.loading.logo.as_ref().map(|l| (t.id.as_str(), l, t.loading.progress))); + let (logo, lw, lh, logo_is_bar) = match theme_logo { + Some((id, l, mode)) => { + let lw = (560.0 * s).min(width * 0.6).min(height * 0.4 * l.aspect()).round().max(1.0) as u32; + let lh = (lw as f32 / l.aspect()).round().max(1.0) as u32; + // (the progress in hundredths: a new picture of the logo now and then, not + // every frame) + let bar = mode != crate::themes::Progress::Bar; + let q = if bar { (p * 100.0).round() / 100.0 } else { 1.0 }; + let key = (format!("\u{0}theme-logo {id} {mode:?} {q}"), lw, [255, 255, 255, ka]); + let tex = self.text.cached(r, scene, key, (lw, lh), || fade(l.pixels(lw, lh, bar.then_some((mode, q))).unwrap_or_else(|| vec![0; (lw * lh * 4) as usize]))); + (tex, lw, lh, bar) + } + None => { + let lw = (560.0 * s).min(width * 0.6).round() as u32; + let lh = (lw as f32 * 303.0 / 1142.0).round() as u32; + let tex = self.text.cached(r, scene, ("\u{0}wordmark".to_string(), lw, [255, 255, 255, ka]), (lw, lh), || { + fade(omsi_ui::icons::rasterize_rgba(WORDMARK, lw, lh).unwrap_or_else(|| vec![0; (lw * lh * 4) as usize])) + }); + (tex, lw, lh, false) + } + }; let lx = ((width - lw as f32) * 0.5).round(); let ly = (height * 0.42 - lh as f32 * 0.5).round(); scene.overlays.push((logo, [lx, ly, lx + lw as f32, ly + lh as f32])); // the map's name under it let tpx = (24.0 * s).round() as u32; let title = clip_to(&self.text, title, tpx as f32, width - 64.0 * s); - let t = self.text.label_op(r, scene, &title, tpx, [184, 184, 184, 0], k); + let t = self.text.label_op(r, scene, &title, tpx, with(look.text, 0), k); let tx = ((width - t.w as f32) * 0.5).round(); let ty = (ly + lh as f32 + 18.0 * s).round(); scene.overlays.push((t.tex, [tx, ty, tx + t.w as f32, ty + t.h as f32])); - // the bar along the bottom: square, the track and the striped fill + // the bar along the bottom: square, the track and the striped fill (none where the + // logo is the bar) let margin = (48.0 * s).round(); let bw = (width - margin * 2.0).min(1100.0 * s).round(); let bx = ((width - bw) * 0.5).round(); let bh = (14.0 * s).round().max(6.0); let by = (height - margin - bh).round(); - let track = self.text.solid(r, scene, [42, 42, 42, a(255.0)]); - scene.overlays.push((track, [bx, by, bx + bw, by + bh])); - let p = progress.clamp(0.0, 1.0); - let fill_w = (bw * p).round() as u32; - if fill_w > 0 { - // (tiles of one period with the stripes moved on, a cut one at the end: the - // overlays cannot move a texture's pixels, so the moving is in which tile is drawn) - let period = (bh as u32 * 2).max(8); - let phase = ((clock * 40.0 * s) as u32) % period; - let (h, fa) = (bh as u32, a(255.0)); - let mut x = 0u32; - while x < fill_w { - let w = period.min(fill_w - x); - let key = (format!("\u{0}stripes{period}x{h}p{phase}"), w, [232, 160, 48, fa]); - let tex = self.text.cached(r, scene, key, (w, h), || stripe_pixels(w, h, period, phase, fa)); - scene.overlays.push((tex, [bx + x as f32, by, bx + (x + w) as f32, by + bh])); - x += w; + if !logo_is_bar { + let track = self.text.solid(r, scene, with(look.track, a(255.0))); + scene.overlays.push((track, [bx, by, bx + bw, by + bh])); + let fill_w = (bw * p).round() as u32; + if fill_w > 0 { + // (tiles of one period with the stripes moved on, a cut one at the end: the + // overlays cannot move a texture's pixels, so the moving is in which tile is drawn) + let period = (bh as u32 * 2).max(8); + let phase = ((clock * 40.0 * s) as u32) % period; + let (h, fa) = (bh as u32, a(255.0)); + let (dark, light) = look.stripes; + let mut x = 0u32; + while x < fill_w { + let w = period.min(fill_w - x); + let key = (format!("\u{0}stripes{period}x{h}p{phase}{light:?}"), w, with(dark, fa)); + let tex = self.text.cached(r, scene, key, (w, h), || stripe_pixels(w, h, period, phase, fa, dark, light)); + scene.overlays.push((tex, [bx + x as f32, by, bx + (x + w) as f32, by + bh])); + x += w; + } } } // over the bar: a tip on the left, how far on the right let small = (11.0 * s).round() as u32 | BOLD; let tip_px = (14.0 * s).round() as u32; - let pct = self.text.label_op(r, scene, &format!("{:.0} %", p * 100.0), (15.0 * s).round() as u32 | BOLD, [237, 237, 237, 0], k); + let pct = self.text.label_op(r, scene, &format!("{:.0} %", p * 100.0), (15.0 * s).round() as u32 | BOLD, with(look.ink, 0), k); let row_y = by - 12.0 * s; scene.overlays.push((pct.tex, [bx + bw - pct.w as f32, row_y - pct.h as f32, bx + bw, row_y])); let (tip, tk) = tip; let tip = clip_to(&self.text, &omsi_ui::tr(tip), tip_px as f32, bw - pct.w as f32 - 40.0 * s); - let tl = self.text.label_op(r, scene, &tip, tip_px, [184, 184, 184, 0], k * tk); + let tl = self.text.label_op(r, scene, &tip, tip_px, with(look.text, 0), k * tk); scene.overlays.push((tl.tex, [bx, row_y - tl.h as f32, bx + tl.w as f32, row_y])); - let head = self.text.label_op(r, scene, &omsi_ui::tr("Tips").to_uppercase(), small, [232, 160, 48, 0], k); + let head = self.text.label_op(r, scene, &omsi_ui::tr("Tips").to_uppercase(), small, with(look.accent, 0), k); let hy = row_y - tl.h as f32 - head.h as f32 + 2.0 * s; scene.overlays.push((head.tex, [bx, hy, bx + head.w as f32, hy + head.h as f32])); } diff --git a/crates/omsi-app/src/ui/menu.rs b/crates/omsi-app/src/ui/menu.rs index f902b9990..1aca895e8 100644 --- a/crates/omsi-app/src/ui/menu.rs +++ b/crates/omsi-app/src/ui/menu.rs @@ -5,17 +5,37 @@ use super::*; // --------------------------------------------------------------------------------------- // the game menu and its lists: flat near-black greys and one amber accent, as the launcher +/// openOMSI's colour `c`, or the interface theme's (`themes`) in its place. +fn own(c: [u8; 4], themed: impl Fn(omsi_ui::theme::Palette) -> omsi_ui::Color) -> [u8; 4] { + let p = omsi_ui::theme::th(); + if p == omsi_ui::theme::Palette::OPENOMSI { + c + } else { + omsi_ui::theme::rgba8(themed(p)) + } +} + /// How long a fade of the menu takes (a line lit, a switch turned over): a short moment. pub(super) const FADE_SECS: f32 = 0.15; pub(super) const BAR_SECS: f32 = 0.19; // (the launcher's values, see `launcher/theme.rs`: neutral greys, no tint) -pub(super) const PANEL: [u8; 4] = [22, 22, 22, 255]; -pub(super) const PANEL_ALT: [u8; 4] = [31, 31, 31, 255]; +pub(super) fn panel() -> [u8; 4] { + own([22, 22, 22, 255], |p| p.panel) +} +pub(super) fn panel_alt() -> [u8; 4] { + own([31, 31, 31, 255], |p| p.field) +} pub(super) const BORDER: [u8; 4] = [255, 255, 255, 15]; -pub(super) const ACCENT: [u8; 4] = [232, 160, 48, 255]; -pub(super) const ACCENT_SOFT: [u8; 4] = [232, 160, 48, 34]; -pub(super) const DANGER: [u8; 4] = [222, 78, 68, 255]; +pub(super) fn accent() -> [u8; 4] { + own([232, 160, 48, 255], |p| p.accent) +} +pub(super) fn accent_soft() -> [u8; 4] { + fade(self::accent(), 34.0 / 255.0) +} +pub(super) fn danger() -> [u8; 4] { + own([222, 78, 68, 255], |p| p.danger) +} /// A line under the mouse, and the line chosen: the launcher's `HOVER` and `SELECTED`, as /// solid greys. (They were white at 6 % and 9 %, which the overlays blend in linear light: /// on the 22-grey card that came out 73 and 92, twice as light as the launcher's 38 and 44.) @@ -36,13 +56,19 @@ pub(super) const THUMB_HOT: [u8; 4] = [255, 255, 255, 90]; /// The small capitals over a title (the launcher's section headings: 11 px, bold, dim). pub(super) const DIM: [u8; 4] = [142, 142, 142, 0]; /// The accent's fill under the mouse, and the ink on it (the launcher's primary button). -pub(super) const ACCENT_HOT: [u8; 4] = [246, 182, 84, 255]; -pub(super) const ON_ACCENT: [u8; 4] = [18, 14, 8, 0]; +pub(super) fn accent_hot() -> [u8; 4] { + own([246, 182, 84, 255], |p| p.accent_hover) +} +pub(super) fn on_accent() -> [u8; 4] { + txt(own([18, 14, 8, 0], |p| p.accent_ink)) +} /// Text colours (alpha 0: no outline on the flat panel). pub(super) const WHITE: [u8; 4] = [236, 236, 236, 0]; pub(super) const SOFT: [u8; 4] = [200, 200, 200, 0]; pub(super) const MUTED: [u8; 4] = [142, 142, 142, 0]; -pub(super) const AMBER: [u8; 4] = [255, 200, 110, 0]; +pub(super) fn amber() -> [u8; 4] { + txt(own([255, 200, 110, 0], |p| p.accent.lighten(0.35))) +} /// The ink of a greyed-out line and its small hint. pub(super) const OFF_INK: [u8; 4] = [96, 96, 96, 0]; @@ -200,7 +226,7 @@ impl Ui { let full = (rect[3] - rect[1] - 20.0 * s).max(10.0 * s); let bh = full * (0.4 + 0.6 * k); let by = (rect[1] + rect[3]) * 0.5 - bh * 0.5; - self.text.rounded(r, scene, [rect[0], by, rect[0] + 2.0 * s, by + bh], 1.0 * s, fade(if danger { DANGER } else { ACCENT }, k)); + self.text.rounded(r, scene, [rect[0], by, rect[0] + 2.0 * s, by + bh], 1.0 * s, fade(if danger { self::danger() } else { self::accent() }, k)); } /// The header of the card at (`x`, `y`) of `w` wide: what the list is of, small and in @@ -342,7 +368,7 @@ impl Ui { // the card: a soft shadow, a 1 px hairline round it, the flat panel self.text.shadow(r, scene, [x, y, x + w, y + h], radius, 28.0 * s, 10.0 * s, 110); self.text.rounded(r, scene, [x - 1.0, y - 1.0, x + w + 1.0, y + h + 1.0], radius + 1.0, BORDER); - self.text.rounded(r, scene, [x, y, x + w, y + h], radius, PANEL); + self.text.rounded(r, scene, [x, y, x + w, y + h], radius, self::panel()); // the header: what the list is of (or the game's name), and the title let (title, sub): (String, String) = match f.menu_head.as_ref() { Some((t, u)) => (t.clone(), u.clone()), @@ -447,18 +473,18 @@ impl Ui { MenuKind::Lines => { let parsed = label.strip_prefix(line_pre.as_str()).and_then(|rest| rest.rsplit_once(" (")).map(|(n, t)| (n, t.trim_end_matches(')'))); if let Some((name, info)) = parsed { - let bl = self.text.label(r, scene, name, (15.0 * s) as u32, ON_ACCENT); + let bl = self.text.label(r, scene, name, (15.0 * s) as u32, self::on_accent()); let (bw, bh) = (sign_w, 28.0 * s); let bx = lx; - let sign = mix(mix(ACCENT, [150, 104, 30, 255], 0.30), ACCENT_HOT, glow); + let sign = mix(mix(self::accent(), [150, 104, 30, 255], 0.30), self::accent_hot(), glow); self.text.rounded(r, scene, [bx, cy - bh * 0.5, bx + bw, cy + bh * 0.5], 7.0 * s, sign); let (lx0, ly0) = (bx + (bw - bl.w as f32) * 0.5, cy - bl.h as f32 * 0.5); scene.overlays.push((bl.tex, [lx0, ly0, lx0 + bl.w as f32, ly0 + bl.h as f32])); let tx = bx + bw + 14.0 * s; let d = 22.0 * s; - self.text.rounded(r, scene, [rx - d, cy - d * 0.5, rx, cy + d * 0.5], d * 0.5, fade(ACCENT, 0.10 + 0.30 * glow)); + self.text.rounded(r, scene, [rx - d, cy - d * 0.5, rx, cy + d * 0.5], d * 0.5, fade(self::accent(), 0.10 + 0.30 * glow)); let aw = self.text.width("›", (px + 2) as f32); - self.put(r, scene, "›", px + 2, mix(MUTED, ACCENT_HOT, glow), rx - d * 0.5 - aw * 0.5, cy - 1.0 * s); + self.put(r, scene, "›", px + 2, mix(MUTED, self::accent_hot(), glow), rx - d * 0.5 - aw * 0.5, cy - 1.0 * s); let info = clip_to(&self.text, info, px as f32, rx - d - 12.0 * s - tx); self.put(r, scene, &info, px, ink, tx, cy); done = true; @@ -466,7 +492,7 @@ impl Ui { } MenuKind::List if code_w > 0.0 => { if let Some((code, name)) = code_and_name(label) { - self.put_right(r, scene, code, px, mix(MUTED, ACCENT_HOT, glow), lx + code_w, cy); + self.put_right(r, scene, code, px, mix(MUTED, self::accent_hot(), glow), lx + code_w, cy); let nx = lx + code_w + 12.0 * s; let name = clip_to(&self.text, name, px as f32, rx - nx); self.put(r, scene, &name, px, ink, nx, cy); @@ -552,7 +578,7 @@ impl Ui { let (px0, py0) = (list_r + 4.0 * s, y + header_h); let (px1, py1) = (x + w - pad, y + h - pad); self.text.rounded(r, scene, [px0 - 1.0, py0 - 1.0, px1 + 1.0, py1 + 1.0], CARD_R * s + 1.0, BORDER); - self.text.rounded(r, scene, [px0, py0, px1, py1], CARD_R * s, PANEL_ALT); + self.text.rounded(r, scene, [px0, py0, px1, py1], CARD_R * s, self::panel_alt()); let pad = tin; let inner = px1 - px0 - pad * 2.0; let mut cy = py0 + 28.0 * s; @@ -577,8 +603,8 @@ impl Ui { for j in 0..2usize { let rect = if j == 0 { [left, by, left + bw, by + bh] } else { [right - bw, by, right, by + bh] }; let a = self.easeq((14, "time", j + base), if over(rect) { 1.0 } else { 0.0 }, 1.0 / FADE_SECS); - self.text.rounded(r, scene, rect, ROW_R * s, mix([232, 160, 48, 60], ACCENT, a * 0.7)); - let col = mix(ACCENT_HOT, [18, 14, 8, 255], a); + self.text.rounded(r, scene, rect, ROW_R * s, mix([232, 160, 48, 60], self::accent(), a * 0.7)); + let col = mix(self::accent_hot(), [18, 14, 8, 255], a); let (cx, cy) = ((rect[0] + rect[2]) * 0.5, (rect[1] + rect[3]) * 0.5); // (a real arrow: a shaft and a head of stacked strips) let dir = if j == 0 { -1.0 } else { 1.0 }; @@ -600,7 +626,7 @@ impl Ui { let mid_r = right - bw - 10.0 * s; let time = clip_to(&self.text, time, fs * s, mid_r - mid_l); let tw = self.text.width(&time, fs * s); - self.put(r, scene, &time, (fs * s) as u32, if base == 0 { AMBER } else { WHITE }, mid_l + (mid_r - mid_l - tw) * 0.5, by + bh * 0.5); + self.put(r, scene, &time, (fs * s) as u32, if base == 0 { self::amber() } else { WHITE }, mid_l + (mid_r - mid_l - tw) * 0.5, by + bh * 0.5); top = by + bh + 8.0 * s; } let n = p.rows.len(); @@ -636,17 +662,17 @@ impl Ui { } if a_on > 0.0 { self.text.rounded(r, scene, rect, ROW_R * s, fade(SELECTED, a_on)); - self.text.rounded(r, scene, [rect[0], rect[1] + 7.0 * s, rect[0] + 2.0 * s, rect[3] - 7.0 * s], 1.0 * s, fade(ACCENT, a_on)); + self.text.rounded(r, scene, [rect[0], rect[1] + 7.0 * s, rect[0] + 2.0 * s, rect[3] - 7.0 * s], 1.0 * s, fade(self::accent(), a_on)); } - self.put_right(r, scene, when, rpx, AMBER, px1 - pad, ry); + self.put_right(r, scene, when, rpx, self::amber(), px1 - pad, ry); let what = clip_to(&self.text, what, rpx as f32, inner - time_w - 14.0 * s); self.put(r, scene, &what, rpx, mix(SOFT, WHITE, a_on), px0 + pad, ry); self.menu_pane.push(rect); } // the button that starts the trip let a_go = self.easeq((13, "go", 0), if over(go) { 1.0 } else { 0.0 }, 1.0 / FADE_SECS); - self.text.rounded(r, scene, go, ROW_R * s, mix(ACCENT, ACCENT_HOT, a_go)); - let l = self.text.label(r, scene, button, (14.0 * s) as u32 | BOLD, ON_ACCENT); + self.text.rounded(r, scene, go, ROW_R * s, mix(self::accent(), self::accent_hot(), a_go)); + let l = self.text.label(r, scene, button, (14.0 * s) as u32 | BOLD, self::on_accent()); let (gx, gy) = (go[0] + (go[2] - go[0] - l.w as f32) * 0.5, (go[1] + go[3]) * 0.5 - l.h as f32 * 0.5); scene.overlays.push((l.tex, [gx, gy, gx + l.w as f32, gy + l.h as f32])); self.menu_pane_go = Some(go); @@ -660,13 +686,13 @@ impl Ui { let tile_w = p.rows.iter().take(shown).filter_map(|row| row.0.strip_prefix(tp.as_str()).and_then(|x| x.split_once(" › ").map(|(n, _)| n).or(Some(x)))).map(|n| self.text.width(n.trim(), rpx as f32) + 16.0 * s).fold(30.0 * s, f32::max); for (i, (what, when)) in p.rows.iter().take(shown).enumerate() { let ry = top + lh * i as f32 + lh * 0.5; - self.put_right(r, scene, when, rpx, AMBER, px1 - pad, ry); + self.put_right(r, scene, when, rpx, self::amber(), px1 - pad, ry); if let Some(rest) = what.strip_prefix(tp.as_str()) { let (num, dest) = rest.split_once(" › ").unwrap_or((rest, "")); let th = lh - 6.0 * s; - self.text.rounded(r, scene, [px0 + pad, ry - th * 0.5, px0 + pad + tile_w, ry + th * 0.5], 5.0 * s, ACCENT_SOFT); + self.text.rounded(r, scene, [px0 + pad, ry - th * 0.5, px0 + pad + tile_w, ry + th * 0.5], 5.0 * s, self::accent_soft()); let nw = self.text.width(num.trim(), rpx as f32); - self.put(r, scene, num.trim(), rpx, txt(ACCENT), px0 + pad + (tile_w - nw) * 0.5, ry); + self.put(r, scene, num.trim(), rpx, txt(self::accent()), px0 + pad + (tile_w - nw) * 0.5, ry); let dx = px0 + pad + tile_w + 10.0 * s; let dest = clip_to(&self.text, dest, rpx as f32, px1 - pad - time_w - 14.0 * s - dx); self.put(r, scene, &dest, rpx, SOFT, dx, ry); @@ -736,11 +762,11 @@ impl Ui { self.put(r, scene, crate::startup::VERSION, (12.0 * s) as u32, MUTED, bx, 54.0 * s); // what state the game is in, a small amber tag beside the name let tag = if f.paused { "PAUSED" } else { "MENU" }; - let tl = self.text.label(r, scene, tag, (10.0 * s) as u32 | BOLD, txt(ACCENT)); + let tl = self.text.label(r, scene, tag, (10.0 * s) as u32 | BOLD, txt(self::accent())); let (tw, th) = (tl.w as f32 + 12.0 * s, tl.h as f32 + 4.0 * s); let tx = (bx + name_w + 10.0 * s).min(rail_w - tw - 12.0 * s); let ty = 34.0 * s - th * 0.5; - self.text.rounded(r, scene, [tx, ty, tx + tw, ty + th], 4.0 * s, ACCENT_SOFT); + self.text.rounded(r, scene, [tx, ty, tx + tw, ty + th], 4.0 * s, self::accent_soft()); scene.overlays.push((tl.tex, [tx + 6.0 * s, ty + 2.0 * s, tx + 6.0 * s + tl.w as f32, ty + 2.0 * s + tl.h as f32])); // the lines: as many as fit at the launcher's 42 px pitch (down to 32), the rest // scrolled to @@ -789,7 +815,7 @@ impl Ui { let cy = (rect[1] + rect[3]) * 0.5; let (ink, icon_ink) = if primary { // the launcher's primary button: amber, dark bold text, lighter under the mouse - self.text.rounded(r, scene, rect, 6.0 * s, mix(ACCENT, ACCENT_HOT, glow)); + self.text.rounded(r, scene, rect, 6.0 * s, mix(self::accent(), self::accent_hot(), glow)); ([18, 14, 8, 0], [18, 14, 8, 255]) } else { if glow > 0.0 { @@ -887,7 +913,7 @@ impl Ui { let radius = CARD_R * s; self.text.shadow(r, scene, [x, y, x + w, y + h], radius, 28.0 * s, 10.0 * s, 110); self.text.rounded(r, scene, [x - 1.0, y - 1.0, x + w + 1.0, y + h + 1.0], radius + 1.0, BORDER); - self.text.rounded(r, scene, [x, y, x + w, y + h], radius, PANEL); + self.text.rounded(r, scene, [x, y, x + w, y + h], radius, self::panel()); // the header: the game's name small, the title large let (title, sub): (String, String) = match f.menu_head.as_ref() { Some((t, u)) => (t.clone(), u.clone()), @@ -899,7 +925,7 @@ impl Ui { if side_w > 0.0 { let (sx0, sx1) = (x + pad, x + side_w); self.text.rounded(r, scene, [sx0 - 1.0, y + header_h - 1.0, sx1 + 1.0, y + h - pad + 1.0], CARD_R * s + 1.0, BORDER); - self.text.rounded(r, scene, [sx0, y + header_h, sx1, y + h - pad], CARD_R * s, PANEL_ALT); + self.text.rounded(r, scene, [sx0, y + header_h, sx1, y + h - pad], CARD_R * s, self::panel_alt()); let inset = 6.0 * s; let bottom = y + h - pad - inset; let pages_top = y + header_h + inset; @@ -1002,7 +1028,7 @@ impl Ui { // (the knob slides over and the track changes its colour) let t = self.ease((5, id, k), if on { 1.0 } else { 0.0 }, 1.0 / FADE_SECS); let tq = quant(t); - self.text.rounded(r, scene, track, th * 0.5, mix(TRACK_OFF, ACCENT, tq)); + self.text.rounded(r, scene, track, th * 0.5, mix(TRACK_OFF, self::accent(), tq)); let kn = th - 6.0 * s; let kx = tx + 3.0 * s + (tw - kn - 6.0 * s) * t; self.text.rounded(r, scene, [kx, cy - kn * 0.5, kx + kn, cy + kn * 0.5], kn * 0.5, KNOB); @@ -1021,7 +1047,7 @@ impl Ui { let fr = self.ease((6, id, k), frac.unwrap_or(0.0).clamp(0.0, 1.0), 2.0 / FADE_SECS); let fx = x0 + tw * fr; if fx - x0 >= 1.0 { - self.text.rounded(r, scene, [x0, cy - th * 0.5, fx, cy + th * 0.5], th * 0.5, ACCENT); + self.text.rounded(r, scene, [x0, cy - th * 0.5, fx, cy + th * 0.5], th * 0.5, self::accent()); } let kn = (13.0 + 2.0 * a) * s; self.text.rounded(r, scene, [fx - kn * 0.5, cy - kn * 0.5, fx + kn * 0.5, cy + kn * 0.5], kn * 0.5, KNOB); @@ -1051,13 +1077,13 @@ impl Ui { rx - cw } // a value being typed: lit in the accent - "E" => self.chip(r, scene, value, (13.0 * s) as u32, AMBER, ACCENT_SOFT, false, rx, cy, s), + "E" => self.chip(r, scene, value, (13.0 * s) as u32, self::amber(), self::accent_soft(), false, rx, cy, s), // a button _ => { if value.is_empty() { rx } else { - self.chip(r, scene, value, (13.0 * s) as u32, mix(SOFT, AMBER, a), mix(CHIP, ACCENT_SOFT, a), false, rx, cy, s) + self.chip(r, scene, value, (13.0 * s) as u32, mix(SOFT, self::amber(), a), mix(CHIP, self::accent_soft(), a), false, rx, cy, s) } } }; @@ -1085,7 +1111,7 @@ impl Ui { let card = [rect[0] - m, rect[1] - m, rect[2] + m, rect[3] + m]; self.text.shadow(r, scene, card, CARD_R * s, 18.0 * s, 6.0 * s, 110); self.text.rounded(r, scene, [card[0] - 1.0, card[1] - 1.0, card[2] + 1.0, card[3] + 1.0], CARD_R * s + 1.0, BORDER); - self.text.rounded(r, scene, card, CARD_R * s, PANEL); + self.text.rounded(r, scene, card, CARD_R * s, self::panel()); scene.overlays.extend(line); return; } @@ -1114,7 +1140,7 @@ impl Ui { self.text.shadow(r, scene, panel, rad, 18.0 * s, 6.0 * s, 150); // (the launcher's popup: the field's grey, a hairline round it) self.text.rounded(r, scene, [panel[0] - 1.0, panel[1] - 1.0, panel[2] + 1.0, panel[3] + 1.0], rad + 1.0, BORDER); - self.text.rounded(r, scene, panel, rad, PANEL_ALT); + self.text.rounded(r, scene, panel, rad, self::panel_alt()); let more = dd.items.len() > n_vis; let dpx = (14.0 * s) as u32; let tin = TEXT_IN * s; diff --git a/crates/omsi-launcher-core/src/lib.rs b/crates/omsi-launcher-core/src/lib.rs index af1286fd9..281e6a2e4 100644 --- a/crates/omsi-launcher-core/src/lib.rs +++ b/crates/omsi-launcher-core/src/lib.rs @@ -2163,6 +2163,8 @@ pub fn settings_from_text_on(text: Option<&str>, phone: bool) -> Value { v["fsr_sharpness"] = json!(0.8); v["street_light"] = json!("off"); v["pause_quick_actions"] = json!("default"); + // (the interface theme's id: "openomsi" is openOMSI's own look) + v["ui_theme"] = json!("openomsi"); v["puddles"] = json!("high"); // (apart: the literal above is at the json! macro's recursion limit) v["vehicle_smoke"] = json!("all"); if phone { @@ -2284,7 +2286,7 @@ pub fn settings_from_text_on(text: Option<&str>, phone: bool) -> Value { let (k, val) = (setting_key(k), val.trim()); let b = |x: &str| matches!(x.to_ascii_lowercase().as_str(), "1" | "true" | "on" | "yes"); match k.as_str() { - "pause_quick_actions" => v[&k] = json!(val), + "pause_quick_actions" | "ui_theme" => v[&k] = json!(val), "anisotropy" => v[&k] = json!(val.parse::().unwrap_or(8).clamp(1, 16)), "msaa" | "shadow_size" => v[&k] = json!(val.parse::().unwrap_or(0)), "ui_opacity" | "volume" | "vol_ai" | "vol_scenery" | "vol_ambient" | "min_obj_size" | "radio_speaker" | "radio_hiss" | "radio_dropouts" => v[&k] = json!(val.parse::().unwrap_or(0.0)), @@ -2762,6 +2764,7 @@ pub fn settings_to_text(v: &Value, old: Option<&str>) -> String { )); } text.push_str(&format!("pause_quick_actions={}\n", v.get("pause_quick_actions").and_then(|x| x.as_str()).unwrap_or("default").replace(['\r', '\n'], ""))); + text.push_str(&format!("ui_theme={}\n", v.get("ui_theme").and_then(|x| x.as_str()).filter(|x| !x.trim().is_empty()).unwrap_or("openomsi").replace(['\r', '\n'], ""))); text.push_str(&format!("mouse_pedals={}\n", b("mouse_pedals", true))); text.push_str(&format!("key_pedal_speed={}\n", f("key_pedal_speed", 1.0).clamp(0.25, 2.0))); text.push_str(&format!("touch_pedal_length={}\ntouch_pedal_deadzone={}\n", f("touch_pedal_length", 1.0).clamp(0.8, 1.6), f("touch_pedal_deadzone", 0.15).clamp(0.0, 0.4))); diff --git a/crates/omsi-plugin-ui/src/lib.rs b/crates/omsi-plugin-ui/src/lib.rs index 19644ecbb..d01cdc506 100644 --- a/crates/omsi-plugin-ui/src/lib.rs +++ b/crates/omsi-plugin-ui/src/lib.rs @@ -30,10 +30,22 @@ mod widgets; // --- the game's look: the notifications' card, the menus' amber accent ------------------ -const CARD: Color = Color::rgba(14, 16, 20, 225.0 / 255.0); +/// The card under a notification or a panel: openOMSI's near black, or the interface +/// theme's surface (`omsi_ui::theme`). +fn card() -> Color { + let p = omsi_ui::theme::th(); + if p == omsi_ui::theme::Palette::OPENOMSI { + Color::rgba(14, 16, 20, 225.0 / 255.0) + } else { + p.surface.alpha(225.0 / 255.0) + } +} const INK: Color = Color::rgba(236, 236, 236, 1.0); const DARK_INK: Color = Color::rgba(18, 14, 8, 1.0); -const ACCENT: Color = Color::rgba(232, 160, 48, 1.0); +/// The accent: openOMSI's amber, or the interface theme's. +fn accent() -> Color { + omsi_ui::theme::th().accent +} // (solid greys, the menus' own: the pipeline blends in linear light, where a tenth of white // over the dark card came out a light grey) const TRACK: Color = Color::rgba(62, 62, 62, 1.0); @@ -500,7 +512,7 @@ impl Layout<'_> { self.rounded( Rect::new(track.x, track.y, fill, *height), height * 0.5, - e.color.map(color).unwrap_or(ACCENT), + e.color.map(color).unwrap_or_else(accent), None, ); } @@ -567,7 +579,7 @@ impl Layout<'_> { self.rounded(b, TILE_R, fill, Some(hit)); let (ink, mark) = match e.color { Some(_) => (ink_on(fill), ink_on(fill)), - None => (INK, ACCENT), + None => (INK, accent()), }; let room = (b.w - 2.0 * TILE_PAD).max(1.0); let widest = text @@ -653,7 +665,7 @@ pub fn layout_typing(p: &Panel, fonts: &Fonts, backdrop: f32, typing: Option<&st out.background = p .background .map(color) - .unwrap_or(CARD.alpha(backdrop.min(1.0 / CARD.0[3]))); + .unwrap_or(card().alpha(backdrop.min(1.0 / card().0[3]))); out.accent = p.accent.map(color); out } @@ -661,7 +673,7 @@ pub fn layout_typing(p: &Panel, fonts: &Fonts, backdrop: f32, typing: Option<&st /// A notification as a panel: the stripe in its colour, the icon and the title in a row /// above the text. pub fn toast_panel(t: &Toast) -> Panel { - let accent = t.color.unwrap_or(Rgba([232, 160, 48, 255])); + let accent = t.color.unwrap_or_else(|| Rgba(omsi_ui::theme::rgba8(accent()))); let element = |kind: Kind, grow: bool, c: Option| Element { id: None, color: c, diff --git a/crates/omsi-plugin-ui/src/nav.rs b/crates/omsi-plugin-ui/src/nav.rs index bd94aa8c4..87afddfee 100644 --- a/crates/omsi-plugin-ui/src/nav.rs +++ b/crates/omsi-plugin-ui/src/nav.rs @@ -2,7 +2,7 @@ //! with a stripe, for a note or an advice - and stop lists, the stops on a rail with their //! times, as the navigator's duty board drew them; and a bar's parts and a badge's weight. -use super::{color, ink_on, Item, Layout, ACCENT, BADGE_H, BADGE_PAD, BADGE_PX}; +use super::{color, ink_on, Item, Layout, accent, BADGE_H, BADGE_PAD, BADGE_PX}; use glam::Vec2; use omsi_plugin::ui::{Element, Kind, Rgba, Stop, StopState}; use omsi_ui::{Color, Rect, Weight}; @@ -52,7 +52,7 @@ impl Layout<'_> { pub(crate) fn nav_emit(&mut self, e: &Element, r: Rect) { match &e.kind { Kind::Box { children, gap } => { - let tint = e.color.map(color).unwrap_or(ACCENT); + let tint = e.color.map(color).unwrap_or_else(accent); self.rounded(r, BOX_R, tint.alpha(BOX_TINT), None); self.rounded(Rect::new(r.x, r.y, BOX_STRIPE, r.h), BOX_STRIPE * 0.5, tint, None); let x = r.x + BOX_STRIPE + BOX_PAD.0; @@ -68,7 +68,7 @@ impl Layout<'_> { } } Kind::Stops { stops, size } => { - let rail = e.color.map(color).unwrap_or(ACCENT); + let rail = e.color.map(color).unwrap_or_else(accent); let row = STOP_ROW * size / 13.0; for (i, s) in stops.iter().enumerate() { let rr = Rect::new(r.x, r.y + i as f32 * row, r.w, row); @@ -106,7 +106,7 @@ impl Layout<'_> { pub(crate) fn badge(&mut self, e: &Element, r: Rect, text: &str, text_color: Option, wt: Weight) { let w = self.natural(e).min(r.w); let b = Rect::new(r.x, r.y + (r.h - BADGE_H) * 0.5, w, BADGE_H); - let fill = e.color.map(color).unwrap_or(ACCENT); + let fill = e.color.map(color).unwrap_or_else(accent); self.rounded(b, BADGE_H * 0.5, fill, None); let t = self.fit(text, BADGE_PX, wt, w - 2.0 * BADGE_PAD); let tw = self.width(&t, BADGE_PX, wt); diff --git a/crates/omsi-plugin-ui/src/widgets.rs b/crates/omsi-plugin-ui/src/widgets.rs index 95e6cffbe..f4230771f 100644 --- a/crates/omsi-plugin-ui/src/widgets.rs +++ b/crates/omsi-plugin-ui/src/widgets.rs @@ -1,7 +1,7 @@ //! The panels' controls and pictures (`omsi.ui` version 2): checkboxes, sliders, text //! fields, tabs, charts, tables and images - their sizes and what they are drawn from. -use super::{color, ink_on, Hit, HitKind, Item, Layout, ACCENT, BUTTON, DIVIDER, INK, LINE, TRACK}; +use super::{color, ink_on, Hit, HitKind, Item, Layout, accent, BUTTON, DIVIDER, INK, LINE, TRACK}; use glam::Vec2; use hashbrown::HashMap; use omsi_plugin::ui::{Align, ChartStyle, Element, Kind, Rgba}; @@ -60,7 +60,7 @@ impl Layout<'_> { } pub fn widget_emit(&mut self, e: &Element, r: Rect) { - let accent = e.color.map(color).unwrap_or(ACCENT); + let accent = e.color.map(color).unwrap_or_else(accent); match &e.kind { Kind::Image { path, width, height } => { let k = (r.w / width).min(1.0); diff --git a/crates/omsi-plugin/src/api/fns/game.rs b/crates/omsi-plugin/src/api/fns/game.rs index 92d569520..013e703a1 100644 --- a/crates/omsi-plugin/src/api/fns/game.rs +++ b/crates/omsi-plugin/src/api/fns/game.rs @@ -7,6 +7,7 @@ use crate::api::{ApiError, ApiFn, Ctx, Value}; const NEW: &str = crate::api::VERSION; const V36: &str = "0.3.36"; +const V42: &str = "0.3.42"; /// Longest `lan.send` text: the session's command messages carry 160 characters, and the /// plugin's name goes with it. pub const LAN_MAX: usize = 120; @@ -144,6 +145,18 @@ pub static FNS: &[ApiFn] = &[ let r = s.ui().set_menu_item(owner, &id, &label, icon); Ok::<_, ApiError>(super::core::ok_or_reason(r)) }), + def!("ui.themes", "ui", [], "list of tables", "The game's interface themes: each `{ id, name }`, openOMSI's own look first (its id `\"\"`). A theme is a folder with a `theme.json` (`Themes//`, or a plugin's own folder: see docs/THEMES.md).", V42, None, false, |c, a| { + let s = c.state(); + let list: Vec = s.ui().themes().iter().map(|t| Value::map([("id", Value::from(t.id.clone())), ("name", Value::from(t.name.clone()))])).collect(); + Value::List(list) + }), + def!("ui.theme", "ui", [], "string", "The id of the interface theme in use (`\"\"`: openOMSI's own look).", V42, None, false, |c, a| c.state().ui().theme().to_string()), + def!("ui.set_theme", "ui", [p("id", "string")], "true, or false and the reason", "Switches the game's interface to theme `id` (`\"\"` or `\"openomsi\"`: openOMSI's own look), as if the player chose it in Options › Interface: it is kept for the next start. Ask the player first; they can switch back there.", V42, Ui, true, |c, a| { + let id = a.str(0)?; + let s = c.state(); + let r = s.ui().ask_theme(&id); + Ok::<_, ApiError>(super::core::ok_or_reason(r)) + }), def!("ui.remove_menu_item", "ui", [p("id", "string")], "boolean", "Takes the plugin's line `id` out of the pause menu; `true` when there was one.", V36, Ui, false, |c, a| { let id = a.str(0)?; let s = c.state(); diff --git a/crates/omsi-plugin/src/ui.rs b/crates/omsi-plugin/src/ui.rs index 10c8c43b4..5153df312 100644 --- a/crates/omsi-plugin/src/ui.rs +++ b/crates/omsi-plugin/src/ui.rs @@ -15,6 +15,8 @@ use std::rc::Rc; mod menu; pub use menu::{MenuItem, MAX_MENU_ITEMS, MAX_MENU_LABEL}; +mod theme; +pub use theme::ThemeInfo; /// `omsi.ui.version`: raised when something is added a plugin may want to test for. /// 2: images, checkboxes, sliders, text fields, tabs, charts, tables, draggable panels. @@ -22,7 +24,8 @@ pub use menu::{MenuItem, MAX_MENU_ITEMS, MAX_MENU_LABEL}; /// 4: boxes, stop lists, bars of several parts, a badge's weight, a table's column /// alignments and coloured cells. /// 5: panels that take a click without the mouse (`grab`). -pub const VERSION: i64 = 5; +/// 6: the interface themes (`ui.themes`, `ui.theme`, `ui.set_theme`). +pub const VERSION: i64 = 6; /// Most panels of one plugin. pub const MAX_PANELS: usize = 16; /// Most elements of one panel, those in rows included. @@ -345,6 +348,10 @@ pub struct UiState { menu_picks: Vec<(u64, String)>, /// The navigator's board (None: the navigator shows none). dock: Option, + /// The game's interface themes, the one in use, and the one a plugin asked for. + themes: Vec, + theme_now: String, + theme_ask: Option, } /// The navigator's board as the plugins see it (`ui.dock`), in the panel's pixels. @@ -363,7 +370,7 @@ pub struct Dock { impl Default for UiState { fn default() -> Self { // (until the game has drawn a frame: a 1080p screen) - UiState { panels: Vec::new(), toasts: Vec::new(), clicks: Vec::new(), changes: Vec::new(), focus: None, shown: true, screen: [1920.0, 1080.0, 1.0], serial: 0, typing: None, menu: Vec::new(), menu_picks: Vec::new(), dock: None } + UiState { panels: Vec::new(), toasts: Vec::new(), clicks: Vec::new(), changes: Vec::new(), focus: None, shown: true, screen: [1920.0, 1080.0, 1.0], serial: 0, typing: None, menu: Vec::new(), menu_picks: Vec::new(), dock: None, themes: Vec::new(), theme_now: String::new(), theme_ask: None } } } diff --git a/crates/omsi-plugin/src/ui/theme.rs b/crates/omsi-plugin/src/ui/theme.rs new file mode 100644 index 000000000..65a98a1d3 --- /dev/null +++ b/crates/omsi-plugin/src/ui/theme.rs @@ -0,0 +1,72 @@ +//! The interface themes the game has (`ui.themes`, `ui.theme`), and a plugin's wish to use one +//! (`ui.set_theme`): the game tells the plugins its themes each frame, and takes a wish after +//! their frame (it becomes the player's setting, as if chosen in Options › Interface). + +use super::UiState; + +/// One of the game's interface themes: its id (the setting's value; "" openOMSI's own look) +/// and its name. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThemeInfo { + pub id: String, + pub name: String, +} + +impl UiState { + /// The game's themes and the one in use (`ui.themes`, `ui.theme`). + pub fn set_themes(&mut self, themes: Vec, now: &str) { + self.themes = themes; + if self.theme_now != now { + self.theme_now = now.to_string(); + } + } + + pub fn themes(&self) -> &[ThemeInfo] { + &self.themes + } + + /// The id of the theme in use ("" openOMSI's own look). + pub fn theme(&self) -> &str { + &self.theme_now + } + + /// A plugin asks for theme `id` ("" or "openomsi": openOMSI's own look). Refused when the + /// game has no theme of that id. + pub fn ask_theme(&mut self, id: &str) -> Result<(), String> { + let id = id.trim(); + let id = if id.eq_ignore_ascii_case("openomsi") { "" } else { id }; + let Some(t) = self.themes.iter().find(|t| t.id.eq_ignore_ascii_case(id)) else { + return Err(format!("no theme \"{id}\" (ui.themes() lists them)")); + }; + self.theme_ask = Some(t.id.clone()); + Ok(()) + } + + /// The theme a plugin asked for since the game last looked. + pub fn take_theme_ask(&mut self) -> Option { + self.theme_ask.take() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_plugin_asks_for_a_theme_the_game_has() { + let mut ui = UiState::default(); + assert!(ui.ask_theme("omsihub").is_err(), "the game told of none yet"); + let themes = vec![ThemeInfo { id: String::new(), name: "openOMSI".into() }, ThemeInfo { id: "OmsiHub".into(), name: "Omsi-Hub".into() }]; + ui.set_themes(themes.clone(), ""); + assert_eq!((ui.themes(), ui.theme()), (&themes[..], "")); + // (any case; the game's spelling is kept) + ui.ask_theme(" omsihub ").unwrap(); + assert_eq!(ui.take_theme_ask().as_deref(), Some("OmsiHub")); + assert_eq!(ui.take_theme_ask(), None); + // (openOMSI's own look, by either name) + ui.ask_theme("openOMSI").unwrap(); + assert_eq!(ui.take_theme_ask().as_deref(), Some("")); + assert!(ui.ask_theme("sparkle").unwrap_err().contains("sparkle")); + assert_eq!(ui.take_theme_ask(), None); + } +} diff --git a/crates/omsi-ui/src/icons.rs b/crates/omsi-ui/src/icons.rs index f9e88b2df..c78bd5e3a 100644 --- a/crates/omsi-ui/src/icons.rs +++ b/crates/omsi-ui/src/icons.rs @@ -40,8 +40,135 @@ pub fn rasterize_rgba(src: &str, w: u32, h: u32) -> Option> { }).collect()) } +/// The size an SVG is drawn at (its width and height in its own units); None when it cannot +/// be read. +pub fn svg_size(src: &str) -> Option<(f32, f32)> { + let tree = resvg::usvg::Tree::from_str(src, &resvg::usvg::Options::default()).ok()?; + let s = tree.size(); + Some((s.width(), s.height())) +} + +/// The length of the path `id` of an SVG (in its own units), as far as its stroke goes: +/// what a theme's loading logo is drawn along (`progress_svg`). None when there is no such path. +pub fn path_length(src: &str, id: &str) -> Option { + use resvg::tiny_skia::PathSegment; + let tree = resvg::usvg::Tree::from_str(src, &resvg::usvg::Options::default()).ok()?; + let resvg::usvg::Node::Path(path) = tree.node_by_id(id)? else { return None }; + let (mut len, mut at, mut start) = (0.0f32, (0.0f32, 0.0f32), (0.0f32, 0.0f32)); + let dist = |a: (f32, f32), b: (f32, f32)| ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt(); + // (a curve in 32 straight pieces: a tenth of a per cent off for the arcs of a logo) + let mut curve = |at: &mut (f32, f32), f: &dyn Fn(f32) -> (f32, f32)| { + for k in 1..=32 { + let p = f(k as f32 / 32.0); + len += dist(*at, p); + *at = p; + } + }; + for seg in path.data().segments() { + match seg { + PathSegment::MoveTo(p) => { + at = (p.x, p.y); + start = at; + } + PathSegment::LineTo(p) => { + curve(&mut at, &|_| (p.x, p.y)); + } + PathSegment::QuadTo(c, p) => { + let a = at; + curve(&mut at, &|t| { + let u = 1.0 - t; + (u * u * a.0 + 2.0 * u * t * c.x + t * t * p.x, u * u * a.1 + 2.0 * u * t * c.y + t * t * p.y) + }); + } + PathSegment::CubicTo(c1, c2, p) => { + let a = at; + curve(&mut at, &|t| { + let u = 1.0 - t; + let (k0, k1, k2, k3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + (k0 * a.0 + k1 * c1.x + k2 * c2.x + k3 * p.x, k0 * a.1 + k1 * c1.y + k2 * c2.y + k3 * p.y) + }); + } + PathSegment::Close => { + let s = start; + curve(&mut at, &|_| s); + } + } + } + (len > 0.0).then_some(len) +} + +/// The SVG `src` with its path `id` (of length `len`, `path_length`) drawn only along its first +/// `frac` (0 to 1) - a dash as long as that, the rest a gap - and, when `track` is above 0, the +/// whole of it once more under that at the opacity `track`: a loading bar in the shape of a +/// logo. The path must be one element closed in itself (``) without a dash of its own. +pub fn progress_svg(src: &str, id: &str, len: f32, frac: f32, track: f32) -> String { + let Some(at) = [format!("id=\"{id}\""), format!("id='{id}'")].iter().find_map(|a| src.find(a.as_str()).map(|i| (i, a.len()))) else { + return src.to_string(); + }; + let frac = frac.clamp(0.0, 1.0); + let dash = if frac <= 0.0005 { " stroke-opacity=\"0\"".to_string() } else { format!(" stroke-dasharray=\"{:.3} {:.3}\"", len * frac, len * 2.0) }; + let mut out = String::with_capacity(src.len() + 256); + let open = src[..at.0].rfind('<'); + let close = src[at.0..].find("/>").map(|k| at.0 + k + 2); + match (open, close) { + (Some(o), Some(c)) if track > 0.0 && !src[at.0..c].contains('<') => { + // the track: the element as it is, faint, the dashed one after it + let element = &src[o..c]; + let id_in = at.0 - o; + out.push_str(&src[..o]); + out.push_str(&element[..id_in]); + out.push_str(&format!("stroke-opacity=\"{track:.3}\"")); + out.push_str(&element[id_in + at.1..]); + out.push_str(&element[..id_in + at.1]); + out.push_str(&dash); + out.push_str(&element[id_in + at.1..]); + out.push_str(&src[c..]); + } + _ => { + out.push_str(&src[..at.0 + at.1]); + out.push_str(&dash); + out.push_str(&src[at.0 + at.1..]); + } + } + out +} + #[cfg(test)] mod tests { + /// A ring round a box and a line under a word, as a logo has it: its length, and drawn + /// along a part of it. + const LOGO: &str = r##""##; + + #[test] + fn a_logo_is_a_loading_bar_along_its_path() { + // three quarters of a circle of radius 80 and a line of 200 + let len = super::path_length(LOGO, "progress").unwrap(); + let want = 2.0 * std::f32::consts::PI * 80.0 * 0.75 + 200.0; + assert!((len - want).abs() < want * 0.002, "{len} vs {want}"); + assert!(super::path_length(LOGO, "nothing").is_none()); + let blue = |src: &str| { + let px = super::rasterize_rgba(src, 400, 200).unwrap(); + // (the end of the line, right; the start of the ring, top) + let at = |x: usize, y: usize| px[(y * 400 + x) * 4..(y * 400 + x) * 4 + 4].to_vec(); + (at(370, 100), at(100, 20)) + }; + // nothing drawn yet, a faint track + let (end, start) = blue(&super::progress_svg(LOGO, "progress", len, 0.0, 0.2)); + assert!(end[3] > 20 && end[3] < 90, "{end:?}"); + assert!(start[3] < 90, "{start:?}"); + // half: the ring drawn, the line's end not yet + let (end, start) = blue(&super::progress_svg(LOGO, "progress", len, 0.5, 0.2)); + assert!(start[3] > 200 && start[2] > 200, "{start:?}"); + assert!(end[3] < 90, "{end:?}"); + // all of it + let (end, _) = blue(&super::progress_svg(LOGO, "progress", len, 1.0, 0.0)); + assert!(end[3] > 200, "{end:?}"); + // (without a track the element is not doubled; an SVG without the id stays as it is) + assert_eq!(super::progress_svg(LOGO, "progress", len, 1.0, 0.0).matches(", tooltip: Option<(String, Vec2)>, sliders: Vec, /// (tests) Where each clickable widget was this frame, by id. @@ -253,6 +256,7 @@ impl Ui { clipboard_in: None, over_ui: false, flat: false, + backdrop: None, tooltip: None, sliders: Vec::new(), #[cfg(any(test, feature = "test-support"))] @@ -409,10 +413,15 @@ impl Ui { /// A picture of texture `tex` (a render target) filling `r`, its corners rounded. pub fn image(&mut self, r: Rect, tex: usize, radius: f32) { + self.image_part(r, tex, radius, [0.0, 0.0, 1.0, 1.0]); + } + + /// The part `uv` (left, top, right, bottom, 0 to 1) of texture `tex` filling `r`. + pub fn image_part(&mut self, r: Rect, tex: usize, radius: f32, uv: [f32; 4]) { let clip = intersect(self.clip_now(), r); self.push_layer(clip, radius); self.layers.last_mut().unwrap().2 = tex; - let sprite = crate::Sprite { uv: [0.0, 0.0, 1.0, 1.0], w: r.w, h: r.h, ascent: 0.0 }; + let sprite = crate::Sprite { uv, w: r.w, h: r.h, ascent: 0.0 }; self.p().sprite(sprite, Vec2::new(r.x, r.y), Vec2::new(r.w, r.h), Color::WHITE); let (c, rad) = self.clip_stack.last().copied().unwrap_or((Rect::new(0.0, 0.0, self.size.x, self.size.y), 0.0)); self.push_layer(c, rad); @@ -564,9 +573,32 @@ impl Ui { /// A panel: flat, dark, a hairline edge (none in the `flat` look). pub fn panel(&mut self, r: Rect) { self.solid(r); - let fill = if self.flat { SURFACE } else { PANEL }; - self.p().rounded(r, RADIUS, fill); - self.border(r, RADIUS, 1.0, EDGE); + let fill = if self.flat { th().surface } else { th().panel }; + self.surface(r, th().radius, fill); + if th().glass.is_none() { + self.border(r, th().radius, 1.0, th().edge); + } + } + + /// A surface of `fill`: solid - or, in a glass theme, a pane of frosted glass: the + /// picture behind it blurred (`backdrop`), `fill` over it at the glass's opacity, a sheen + /// at the top and a light edge round it. + pub fn surface(&mut self, r: Rect, radius: f32, fill: Color) { + let Some(g) = th().glass else { + self.p().rounded(r, radius, fill); + return; + }; + if r.w <= 0.0 || r.h <= 0.0 { + return; + } + if let Some(tex) = self.backdrop { + let (w, h) = (self.size.x.max(1.0), self.size.y.max(1.0)); + self.image_part(r, tex, radius, [r.x / w, r.y / h, r.right() / w, r.bottom() / h]); + } + self.p().rounded(r, radius, fill.alpha(g.opacity)); + let sheen = Rect::new(r.x, r.y, r.w, (r.h * 0.45).min(140.0)); + self.p().rounded_gradient(sheen, radius, Color::WHITE.alpha(g.highlight), Color::WHITE.alpha(0.0)); + self.p().rounded_border(r, radius, 1.0, Color::WHITE.alpha(g.edge)); } /// A hairline round a rounded rectangle, unless the look is `flat`. @@ -578,18 +610,18 @@ impl Ui { /// A field's fill and its fill under the mouse. pub fn field_fill(&self) -> (Color, Color) { - if self.flat { (SURFACE_2, SURFACE_3) } else { (FIELD, HOVER) } + if self.flat { (th().surface_2, th().surface_3) } else { (th().field, th().hover) } } /// A section heading inside a panel: an accent tick and the title in capitals. pub fn heading(&mut self, r: Rect, title: &str, icon: Option<&str>) -> Rect { let _ = icon; - self.text(&crate::tr(title).to_uppercase(), Vec2::new(r.x, r.y + 14.0), 11.0, Weight::Bold, TEXT_DIM, Align::Left); + self.text(&crate::tr(title).to_uppercase(), Vec2::new(r.x, r.y + 14.0), 11.0, Weight::Bold, th().text_dim, Align::Left); Rect::new(r.x, r.y + 26.0, r.w, (r.h - 26.0).max(0.0)) } pub fn label(&mut self, r: Rect, text: &str) { - self.text_in(text, r, 13.0, Weight::Medium, TEXT_DIM, Align::Left); + self.text_in(text, r, 13.0, Weight::Medium, th().text_dim, Align::Left); } // --- controls ------------------------------------------------------------------------ @@ -601,13 +633,13 @@ impl Ui { let t = self.anim(id, if h { 1.0 } else { 0.0 }, 0.06); let rr = if held { r.inset(0.5) } else { r }; let (fill, text_c, edge) = match kind { - ButtonKind::Primary if self.flat => (ACCENT.mix(ACCENT_HOVER, t), ACCENT_INK, Color::CLEAR), - ButtonKind::Primary => (ACCENT.lighten(0.08 * t), Color::rgba(18, 14, 8, 1.0), Color::CLEAR), - ButtonKind::Danger if self.flat => (SURFACE_2.mix(Color::rgba(58, 34, 32, 1.0), t), DANGER_2, Color::CLEAR), - ButtonKind::Danger => (FIELD.mix(HOVER, t), DANGER, DANGER.alpha(0.35 + 0.3 * t)), - ButtonKind::Normal if self.flat => (SURFACE_2.mix(SURFACE_3, t), INK, Color::CLEAR), - ButtonKind::Normal => (FIELD.mix(HOVER, t), TEXT, EDGE), - ButtonKind::Ghost => (Color::WHITE.alpha(0.05 * t), if h { TEXT } else { TEXT_DIM }, Color::CLEAR), + ButtonKind::Primary if self.flat => (th().accent.mix(th().accent_hover, t), th().accent_ink, Color::CLEAR), + ButtonKind::Primary => (th().accent.lighten(0.08 * t), Color::rgba(18, 14, 8, 1.0), Color::CLEAR), + ButtonKind::Danger if self.flat => (th().surface_2.mix(Color::rgba(58, 34, 32, 1.0), t), th().danger_2, Color::CLEAR), + ButtonKind::Danger => (th().field.mix(th().hover, t), th().danger, th().danger.alpha(0.35 + 0.3 * t)), + ButtonKind::Normal if self.flat => (th().surface_2.mix(th().surface_3, t), th().ink, Color::CLEAR), + ButtonKind::Normal => (th().field.mix(th().hover, t), th().text, th().edge), + ButtonKind::Ghost => (Color::WHITE.alpha(0.05 * t), if h { th().text } else { th().text_dim }, Color::CLEAR), }; let rad = 6.0; self.p().rounded(rr, rad, fill); @@ -634,7 +666,7 @@ impl Ui { let (h, _, clicked) = self.interact(id, rect); let t = self.anim(id, if h { 1.0 } else { 0.0 }, 0.08); self.p().circle(c, r, Color::WHITE.alpha(0.06 * t)); - self.icon(icon, c, r * 1.1, if h { TEXT } else { TEXT_DIM }); + self.icon(icon, c, r * 1.1, if h { th().text } else { th().text_dim }); if !tip.is_empty() { self.tooltip(rect, tip); } @@ -650,12 +682,12 @@ impl Ui { } let on = self.anim(id, if *value { 1.0 } else { 0.0 }, 0.07); let tw = 34.0; - let th = 18.0; - let track = Rect::new(r.right() - tw, r.y + (r.h - th) * 0.5, tw, th); - self.p().rounded(track, th * 0.5, Color::rgba(62, 62, 62, 1.0).mix(ACCENT, on)); - let kx = track.x + th * 0.5 + (tw - th) * on; - self.p().circle(Vec2::new(kx, track.center().y), th * 0.5 - 3.0, Color::rgba(240, 240, 240, 1.0)); - self.text_in(label, Rect::new(r.x, r.y, r.w - tw - 10.0, r.h), 13.0, Weight::Regular, if h { TEXT } else { TEXT_SOFT }, Align::Left); + let track_h = 18.0; + let track = Rect::new(r.right() - tw, r.y + (r.h - track_h) * 0.5, tw, track_h); + self.p().rounded(track, track_h * 0.5, Color::rgba(62, 62, 62, 1.0).mix(th().accent, on)); + let kx = track.x + track_h * 0.5 + (tw - track_h) * on; + self.p().circle(Vec2::new(kx, track.center().y), track_h * 0.5 - 3.0, Color::rgba(240, 240, 240, 1.0)); + self.text_in(label, Rect::new(r.x, r.y, r.w - tw - 10.0, r.h), 13.0, Weight::Regular, if h { th().text } else { th().text_soft }, Align::Left); clicked } @@ -697,17 +729,17 @@ impl Ui { let frac = ((*value - min) / (max - min).max(1e-6)).clamp(0.0, 1.0); let shown = self.anim(id ^ 3, frac, 0.06); if !label.is_empty() { - self.text_in(label, Rect::new(r.x, r.y, label_w - 8.0, r.h), 13.0, Weight::Regular, TEXT_SOFT, Align::Left); + self.text_in(label, Rect::new(r.x, r.y, label_w - 8.0, r.h), 13.0, Weight::Regular, th().text_soft, Align::Left); } let cy = track_r.center().y; - let th = 4.0; - let track = Rect::new(track_r.x, cy - th * 0.5, track_r.w, th); - self.p().rounded(track, th * 0.5, Color::rgba(58, 58, 58, 1.0)); - self.p().rounded(Rect::new(track.x, track.y, track.w * shown, th), th * 0.5, ACCENT); + let track_h = 4.0; + let track = Rect::new(track_r.x, cy - track_h * 0.5, track_r.w, track_h); + self.p().rounded(track, track_h * 0.5, Color::rgba(58, 58, 58, 1.0)); + self.p().rounded(Rect::new(track.x, track.y, track.w * shown, track_h), track_h * 0.5, th().accent); let kc = Vec2::new(track.x + track.w * shown, cy); self.p().circle(kc, if h || held { 7.5 } else { 6.5 }, Color::rgba(240, 240, 240, 1.0)); let txt = fmt(*value); - self.text_in(&txt, Rect::new(r.right() - val_w + 8.0, r.y, val_w - 8.0, r.h), 12.5, Weight::Medium, TEXT, Align::Right); + self.text_in(&txt, Rect::new(r.right() - val_w + 8.0, r.y, val_w - 8.0, r.h), 12.5, Weight::Medium, th().text, Align::Right); *value != before } @@ -715,7 +747,7 @@ impl Ui { pub fn segmented(&mut self, name: &str, r: Rect, selected: &mut usize, labels: &[&str]) -> bool { let n = labels.len().max(1); let id = id_of(name); - let (track, knob_c) = if self.flat { (SURFACE, SURFACE_3) } else { (FIELD, Color::rgba(62, 62, 62, 1.0)) }; + let (track, knob_c) = if self.flat { (th().surface, th().surface_3) } else { (th().field, Color::rgba(62, 62, 62, 1.0)) }; self.p().rounded(r, 6.0, track); let w = r.w / n as f32; let at = self.anim(id, *selected as f32, 0.07); @@ -729,7 +761,7 @@ impl Ui { *selected = k; changed = true; } - let c = if *selected == k { TEXT } else if h { TEXT_SOFT } else { TEXT_DIM }; + let c = if *selected == k { th().text } else if h { th().text_soft } else { th().text_dim }; self.text_in(l, cell.pad(4.0, 0.0), 12.5, Weight::Medium, c, Align::Center); } changed @@ -753,11 +785,11 @@ impl Ui { let t = self.anim(id, if h || open { 1.0 } else { 0.0 }, 0.08); let (field, field_hot) = self.field_fill(); self.p().rounded(r, 6.0, field.mix(field_hot, t)); - self.border(r, 6.0, 1.0, if open { ACCENT.alpha(0.7) } else { EDGE }); + self.border(r, 6.0, 1.0, if open { th().accent.alpha(0.7) } else { th().edge }); let txt = options.get(*selected).cloned().unwrap_or_default(); - self.text_in(&txt, Rect::new(r.x + 12.0, r.y, r.w - 40.0, r.h), 13.0, Weight::Regular, TEXT, Align::Left); + self.text_in(&txt, Rect::new(r.x + 12.0, r.y, r.w - 40.0, r.h), 13.0, Weight::Regular, th().text, Align::Left); let rot = self.anim(id ^ 9, if open { 1.0 } else { 0.0 }, 0.08); - self.icon(if rot > 0.5 { "expand_less" } else { "expand_more" }, Vec2::new(r.right() - 18.0, r.center().y), 20.0, if h { TEXT } else { TEXT_DIM }); + self.icon(if rot > 0.5 { "expand_less" } else { "expand_more" }, Vec2::new(r.right() - 18.0, r.center().y), 20.0, if h { th().text } else { th().text_dim }); if clicked { // (a phone's tap can come twice - as a touch and as the mouse click made of it: // the second one closed the list it had just opened) @@ -842,10 +874,10 @@ impl Ui { let t = self.anim(id, if focused { 1.0 } else if h { 0.5 } else { 0.0 }, 0.08); let (field, field_hot) = self.field_fill(); self.p().rounded(r, 6.0, field.mix(field_hot, t * 0.5)); - self.border(r, 6.0, 1.0, if focused { ACCENT.alpha(0.7) } else { EDGE }); + self.border(r, 6.0, 1.0, if focused { th().accent.alpha(0.7) } else { th().edge }); let mut x = r.x + 12.0; if let Some(i) = icon { - self.icon(i, Vec2::new(x + 8.0, r.center().y), 18.0, TEXT_DIM); + self.icon(i, Vec2::new(x + 8.0, r.center().y), 18.0, th().text_dim); x += 24.0; } let inner = Rect::new(x, r.y, r.right() - x - 10.0, r.h); @@ -896,7 +928,7 @@ impl Ui { } } if value.is_empty() && !focused { - self.text_in(placeholder, inner, px, Weight::Regular, TEXT_FAINT, Align::Left); + self.text_in(placeholder, inner, px, Weight::Regular, th().text_faint, Align::Left); } else { let (caret, moved) = self.caret.get(&id).copied().unwrap_or((0, 0.0)); let upto: String = value.chars().take(caret).collect(); @@ -914,25 +946,25 @@ impl Ui { self.p().rect( Rect::new(inner.x + sx, r.y + 5.0, sw, r.h - 10.0), - ACCENT.alpha(0.35), + th().accent.alpha(0.35), ); } } // keep the caret in view let shift = (cw - inner.w + 4.0).max(0.0); - self.text_in(value, Rect::new(inner.x - shift, inner.y, inner.w + shift + 2000.0, inner.h), px, Weight::Regular, TEXT, Align::Left); + self.text_in(value, Rect::new(inner.x - shift, inner.y, inner.w + shift + 2000.0, inner.h), px, Weight::Regular, th().text, Align::Left); if focused && self.selection.get(&id).is_none_or(|&(a, b)| a == b) && ((self.time - moved) % 1.0) < 0.55 { self.p().rect( Rect::new(inner.x - shift + cw + 0.5, r.center().y -8.5, 1.5, 17.0), - ACCENT, + th().accent, ); } } if value.is_empty() { - self.text_in(placeholder, inner, px, Weight::Regular, TEXT_FAINT, Align::Left); + self.text_in(placeholder, inner, px, Weight::Regular, th().text_faint, Align::Left); } self.pop_clip(); *value != before @@ -943,7 +975,7 @@ impl Ui { let before = *minutes; let fill = self.field_fill().0; self.p().rounded(r, 6.0, fill); - self.border(r, 6.0, 1.0, EDGE); + self.border(r, 6.0, 1.0, th().edge); let half = (r.w - 16.0) * 0.5; for (k, (unit, step)) in [(60, 60), (1, 5)].iter().enumerate() { let cell = Rect::new(r.x + k as f32 * (half + 16.0), r.y, half, r.h); @@ -954,13 +986,13 @@ impl Ui { self.wheel_taken = true; } let v = if *unit == 60 { minutes.rem_euclid(1440) / 60 } else { minutes.rem_euclid(60) }; - self.text_in(&format!("{v:02}"), Rect::new(cell.x + 8.0, cell.y, cell.w - 30.0, cell.h), 17.0, Weight::Medium, TEXT, Align::Center); + self.text_in(&format!("{v:02}"), Rect::new(cell.x + 8.0, cell.y, cell.w - 30.0, cell.h), 17.0, Weight::Medium, th().text, Align::Center); let up = Rect::new(cell.right() - 24.0, cell.y + 3.0, 20.0, cell.h * 0.5 - 3.0); let down = Rect::new(cell.right() - 24.0, cell.center().y, 20.0, cell.h * 0.5 - 3.0); let (hu, _, cu) = self.interact(id ^ 1, up); let (hd, _, cd) = self.interact(id ^ 2, down); - self.icon("expand_less", up.center(), 16.0, if hu { TEXT } else { TEXT_FAINT }); - self.icon("expand_more", down.center(), 16.0, if hd { TEXT } else { TEXT_FAINT }); + self.icon("expand_less", up.center(), 16.0, if hu { th().text } else { th().text_faint }); + self.icon("expand_more", down.center(), 16.0, if hd { th().text } else { th().text_faint }); if cu { *minutes += step; } @@ -968,7 +1000,7 @@ impl Ui { *minutes -= step; } } - self.text_in(":", Rect::new(r.x + half, r.y, 16.0, r.h - 2.0), 17.0, Weight::Medium, TEXT_DIM, Align::Center); + self.text_in(":", Rect::new(r.x + half, r.y, 16.0, r.h - 2.0), 17.0, Weight::Medium, th().text_dim, Align::Center); *minutes = minutes.rem_euclid(1440); *minutes != before } @@ -989,12 +1021,12 @@ impl Ui { let t = self.anim(id, if h || open { 1.0 } else { 0.0 }, 0.08); let (field, field_hot) = self.field_fill(); self.p().rounded(r, 6.0, field.mix(field_hot, t)); - self.border(r, 6.0, 1.0, if open { ACCENT.alpha(0.7) } else { EDGE }); - self.icon("calendar_month", Vec2::new(r.x + 20.0, r.center().y), 17.0, TEXT_DIM); + self.border(r, 6.0, 1.0, if open { th().accent.alpha(0.7) } else { th().edge }); + self.icon("calendar_month", Vec2::new(r.x + 20.0, r.center().y), 17.0, th().text_dim); let (y, m, d) = parse_date(date); let shown = format!("{} {} {}", d, MONTHS[(m as usize).clamp(1, 12) - 1], y); - self.text_in(&shown, Rect::new(r.x + 38.0, r.y, r.w - 60.0, r.h), 13.0, Weight::Regular, TEXT, Align::Left); - self.icon("expand_more", Vec2::new(r.right() - 18.0, r.center().y), 20.0, TEXT_DIM); + self.text_in(&shown, Rect::new(r.x + 38.0, r.y, r.w - 60.0, r.h), 13.0, Weight::Regular, th().text, Align::Left); + self.icon("expand_more", Vec2::new(r.right() - 18.0, r.center().y), 20.0, th().text_dim); if clicked { if open { self.date_popup = None; @@ -1010,7 +1042,7 @@ impl Ui { pub fn progress(&mut self, r: Rect, frac: f32, busy: bool) { self.p().rounded(r, r.h * 0.5, Color::rgba(50, 50, 50, 1.0)); let w = (r.w * frac.clamp(0.0, 1.0)).max(r.h); - self.p().rounded(Rect::new(r.x, r.y, w, r.h), r.h * 0.5, ACCENT); + self.p().rounded(Rect::new(r.x, r.y, w, r.h), r.h * 0.5, th().accent); if busy { let x = r.x + ((self.time * 0.6) % 1.0) * (w + 60.0) - 60.0; self.push_clip(Rect::new(r.x, r.y, w, r.h), r.h * 0.5); @@ -1083,10 +1115,10 @@ impl Ui { let t = self.anim(id, if h { 1.0 } else { 0.0 }, 0.08); let s = self.anim(id ^ 4, if selected { 1.0 } else { 0.0 }, 0.1); if s > 0.01 { - self.p().rounded(r, 6.0, SELECTED.alpha(s)); - self.p().rounded(Rect::new(r.x, r.y + 8.0, 2.0, r.h - 16.0), 1.0, ACCENT.alpha(s)); + self.p().rounded(r, 6.0, th().selected.alpha(s)); + self.p().rounded(Rect::new(r.x, r.y + 8.0, 2.0, r.h - 16.0), 1.0, th().accent.alpha(s)); } else if t > 0.01 { - self.p().rounded(r, 6.0, HOVER.alpha(t)); + self.p().rounded(r, 6.0, th().hover.alpha(t)); } clicked } @@ -1124,11 +1156,11 @@ impl Ui { (self.size.y - h - 8.0).max(8.0) }; let r = Rect::new(x, y, w, h); - let fill = if self.flat { SURFACE_3 } else { Color::rgba(34, 34, 34, 1.0) }; + let fill = if self.flat { th().surface_3 } else { Color::rgba(34, 34, 34, 1.0) }; self.p().rounded(r, 6.0, fill); self.border(r, 7.0, 1.0, Color::WHITE.alpha(0.1)); self.push_clip(Rect::new(r.x + 10.0, r.y + 4.0, text_w, (h - 8.0).max(0.0)), 2.0); - self.paragraph(&t, Vec2::new(r.x + 10.0, r.y + 4.0), text_w, 12.5, Weight::Medium, TEXT_SOFT); + self.paragraph(&t, Vec2::new(r.x + 10.0, r.y + 4.0), text_w, 12.5, Weight::Medium, th().text_soft); self.pop_clip(); } let mut layers = Vec::new(); @@ -1185,7 +1217,7 @@ impl Ui { p.opened = (p.opened + self.dt / 0.3).min(1.0); let e = 1.0 - (1.0 - p.opened).powi(3); let rr = Rect::new(r.x, r.y - 6.0 * (1.0 - e), r.w, r.h); - let fill = if self.flat { SURFACE_2.alpha(e) } else { Color::rgba(28, 28, 28, e) }; + let fill = if self.flat { th().surface_2.alpha(e) } else { Color::rgba(28, 28, 28, e) }; self.p().rounded(rr, 8.0, fill); self.border(rr, 8.0, 1.0, Color::WHITE.alpha(0.1 * e)); let row = 34.0; @@ -1218,11 +1250,11 @@ impl Ui { self.push_clip(rr.inset(4.0), 8.0); if head > 0.0 { let y = rr.y + 4.0 - p.scroll; - self.icon("search", Vec2::new(rr.x + 22.0, y + row * 0.5), 16.0, TEXT_DIM); + self.icon("search", Vec2::new(rr.x + 22.0, y + row * 0.5), 16.0, th().text_dim); let caret = if (self.time * 2.0) as i64 % 2 == 0 { "|" } else { "" }; - self.text_in(&format!("{}{caret}", p.query), Rect::new(rr.x + 38.0, y, rr.w - 60.0, row), 13.0, Weight::Medium, TEXT, Align::Left); + self.text_in(&format!("{}{caret}", p.query), Rect::new(rr.x + 38.0, y, rr.w - 60.0, row), 13.0, Weight::Medium, th().text, Align::Left); if shown.is_empty() { - self.text_in("Nothing found", Rect::new(rr.x + 14.0, y + row, rr.w - 28.0, row), 13.0, Weight::Regular, TEXT_DIM, Align::Left); + self.text_in("Nothing found", Rect::new(rr.x + 14.0, y + row, rr.w - 28.0, row), 13.0, Weight::Regular, th().text_dim, Align::Left); } } for (n, &k) in shown.iter().enumerate() { @@ -1234,12 +1266,12 @@ impl Ui { let cell = Rect::new(rr.x + 4.0, y, rr.w - 8.0, row); let h = cell.contains(self.input.mouse) && rr.contains(self.input.mouse) && !dragging && !(max > 0.0 && track.contains(self.input.mouse)); if k == p.selected { - self.p().rounded(cell, 5.0, SELECTED); - self.icon("check", Vec2::new(cell.right() - 16.0, cell.center().y), 15.0, ACCENT); + self.p().rounded(cell, 5.0, th().selected); + self.icon("check", Vec2::new(cell.right() - 16.0, cell.center().y), 15.0, th().accent); } else if h { - self.p().rounded(cell, 5.0, HOVER); + self.p().rounded(cell, 5.0, th().hover); } - self.text_in(o, Rect::new(cell.x + 10.0, cell.y, cell.w - 36.0, cell.h), 13.0, Weight::Regular, TEXT, Align::Left); + self.text_in(o, Rect::new(cell.x + 10.0, cell.y, cell.w - 36.0, cell.h), 13.0, Weight::Regular, th().text, Align::Left); if h { self.cursor = winit::window::CursorIcon::Pointer; if self.input.released && !fresh { @@ -1265,14 +1297,14 @@ impl Ui { let r = date_rect(&p, self.size); p.opened = (p.opened + self.dt / 0.12).min(1.0); let e = 1.0 - (1.0 - p.opened).powi(3); - let fill = if self.flat { SURFACE_2.alpha(e) } else { Color::rgba(28, 28, 28, e) }; + let fill = if self.flat { th().surface_2.alpha(e) } else { Color::rgba(28, 28, 28, e) }; self.p().rounded(r, 8.0, fill); self.border(r, 8.0, 1.0, Color::WHITE.alpha(0.1 * e)); let m = self.input.mouse; let click = self.input.released; // month header with arrows let head = Rect::new(r.x + 8.0, r.y + 8.0, r.w - 16.0, 30.0); - self.text_in(&format!("{} {}", MONTHS_LONG[p.month as usize - 1], p.year), head, 14.0, Weight::Bold, TEXT, Align::Center); + self.text_in(&format!("{} {}", MONTHS_LONG[p.month as usize - 1], p.year), head, 14.0, Weight::Bold, th().text, Align::Center); let prev = Rect::new(head.x, head.y, 30.0, 30.0); let next = Rect::new(head.right() - 30.0, head.y, 30.0, 30.0); let py = Rect::new(head.x + 30.0, head.y, 30.0, 30.0); @@ -1283,7 +1315,7 @@ impl Ui { self.p().rounded(b, 6.0, Color::WHITE.alpha(0.08)); self.cursor = winit::window::CursorIcon::Pointer; } - self.icon(icon, b.center(), 20.0, if h { ACCENT } else { TEXT_DIM }); + self.icon(icon, b.center(), 20.0, if h { th().accent } else { th().text_dim }); } if click && prev.contains(m) { if p.month == 1 { @@ -1309,7 +1341,7 @@ impl Ui { } let cw = (r.w - 16.0) / 7.0; for (k, d) in ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"].iter().enumerate() { - self.text_in(d, Rect::new(r.x + 8.0 + cw * k as f32, r.y + 42.0, cw, 18.0), 11.0, Weight::Bold, if k >= 5 { ACCENT.alpha(0.8) } else { TEXT_FAINT }, Align::Center); + self.text_in(d, Rect::new(r.x + 8.0 + cw * k as f32, r.y + 42.0, cw, 18.0), 11.0, Weight::Bold, if k >= 5 { th().accent.alpha(0.8) } else { th().text_faint }, Align::Center); } let first = weekday(p.year, p.month, 1); let days = days_in_month(p.year, p.month); @@ -1321,11 +1353,11 @@ impl Ui { let h = cell.contains(m); let is_chosen = chosen == Some((p.year, p.month, d)); if is_chosen { - self.p().rounded(cell, 7.0, ACCENT); + self.p().rounded(cell, 7.0, th().accent); } else if h { self.p().rounded(cell, 7.0, Color::WHITE.alpha(0.09)); } - self.text_in(&d.to_string(), cell, 12.5, Weight::Medium, if is_chosen { Color::rgba(20, 16, 8, 1.0) } else { TEXT }, Align::Center); + self.text_in(&d.to_string(), cell, 12.5, Weight::Medium, if is_chosen { Color::rgba(20, 16, 8, 1.0) } else { th().text }, Align::Center); if h { self.cursor = winit::window::CursorIcon::Pointer; if click { diff --git a/crates/omsi-ui/src/theme.rs b/crates/omsi-ui/src/theme.rs index eba130418..b482c1004 100644 --- a/crates/omsi-ui/src/theme.rs +++ b/crates/omsi-ui/src/theme.rs @@ -1,42 +1,211 @@ //! The launcher's look: flat and dark (neutral greys, no tint), one amber accent used //! sparingly - after the calm menus of Euro Truck Simulator 2. +//! +//! The game's screens read their colours from the palette in force ([`th`]): openOMSI's own +//! ([`Palette::OPENOMSI`]) unless the player chose an interface theme (`Themes//theme.json`, +//! docs/THEMES.md), which puts its own in force with [`set`]. A theme may also make the +//! panels frosted glass over the game's picture ([`Glass`]). + +use std::cell::Cell; +use std::sync::RwLock; use crate::Color; -pub const ACCENT: Color = Color::rgba(232, 160, 48, 1.0); -pub const ACCENT_2: Color = Color::rgba(96, 160, 232, 1.0); -pub const DANGER: Color = Color::rgba(222, 78, 68, 1.0); -pub const OK: Color = Color::rgba(104, 190, 118, 1.0); -pub const WARN: Color = Color::rgba(232, 170, 70, 1.0); - -pub const TEXT: Color = Color::rgba(236, 236, 236, 1.0); -pub const TEXT_SOFT: Color = Color::rgba(200, 200, 200, 1.0); -pub const TEXT_DIM: Color = Color::rgba(142, 142, 142, 1.0); -pub const TEXT_FAINT: Color = Color::rgba(96, 96, 96, 1.0); - -/// Rail, panels, fields. -pub const RAIL: Color = Color::rgba(18, 18, 18, 1.0); -pub const PANEL: Color = Color::rgba(22, 22, 22, 1.0); -pub const FIELD: Color = Color::rgba(31, 31, 31, 1.0); -pub const HOVER: Color = Color::rgba(38, 38, 38, 1.0); -pub const SELECTED: Color = Color::rgba(44, 44, 44, 1.0); -pub const EDGE: Color = Color::rgba(255, 255, 255, 0.06); - -/// The desktop launcher's calm look (`launcher/ui/styles.css`), which the game's own screens -/// over the picture follow (`Ui::flat`): graphite surfaces one step lighter each, no edges. -pub const BG: Color = Color::rgba(18, 18, 18, 1.0); -pub const SIDEBAR: Color = Color::rgba(23, 23, 23, 1.0); -pub const SURFACE: Color = Color::rgba(28, 28, 28, 1.0); -pub const SURFACE_2: Color = Color::rgba(37, 37, 37, 1.0); -pub const SURFACE_3: Color = Color::rgba(47, 47, 47, 1.0); -pub const INK: Color = Color::rgba(237, 237, 237, 1.0); -pub const INK_2: Color = Color::rgba(184, 184, 184, 1.0); -pub const INK_MUTED: Color = Color::rgba(138, 138, 138, 1.0); -pub const ACCENT_HOVER: Color = Color::rgba(240, 173, 69, 1.0); -pub const ACCENT_INK: Color = Color::rgba(26, 18, 6, 1.0); -pub const DANGER_2: Color = Color::rgba(229, 83, 75, 1.0); - -pub const RADIUS: f32 = 8.0; +/// Panels of frosted glass: the game's picture shows through them blurred, under a tint of +/// the panel's own colour, with a light edge and a sheen at the top. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Glass { + /// How much of the panel's colour lies over the blurred picture (0 to 1). + pub opacity: f32, + /// How far the picture is blurred, in points (the radius of the blur). + pub blur: f32, + /// The opacity of the light edge round a panel (0 to 1). + pub edge: f32, + /// The opacity of the sheen at the top of a panel (0 to 1). + pub highlight: f32, +} + +impl Glass { + pub const DEFAULT: Glass = Glass { opacity: 0.6, blur: 24.0, edge: 0.16, highlight: 0.10 }; +} + +/// The colours of the interface, and the corners of its panels. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Palette { + pub accent: Color, + pub accent_2: Color, + pub danger: Color, + pub ok: Color, + pub warn: Color, + pub text: Color, + pub text_soft: Color, + pub text_dim: Color, + pub text_faint: Color, + /// Rail, panels, fields. + pub rail: Color, + pub panel: Color, + pub field: Color, + pub hover: Color, + pub selected: Color, + pub edge: Color, + /// The desktop launcher's calm look (`launcher/ui/styles.css`), which the game's own + /// screens over the picture follow (`Ui::flat`): graphite surfaces one step lighter each, + /// no edges. + pub bg: Color, + pub sidebar: Color, + pub surface: Color, + pub surface_2: Color, + pub surface_3: Color, + pub ink: Color, + pub ink_2: Color, + pub ink_muted: Color, + pub accent_hover: Color, + pub accent_ink: Color, + pub danger_2: Color, + /// The corners of a panel (points). + pub radius: f32, + /// Panels of frosted glass rather than solid ones. + pub glass: Option, +} + +impl Palette { + pub const OPENOMSI: Palette = Palette { + accent: Color::rgba(232, 160, 48, 1.0), + accent_2: Color::rgba(96, 160, 232, 1.0), + danger: Color::rgba(222, 78, 68, 1.0), + ok: Color::rgba(104, 190, 118, 1.0), + warn: Color::rgba(232, 170, 70, 1.0), + text: Color::rgba(236, 236, 236, 1.0), + text_soft: Color::rgba(200, 200, 200, 1.0), + text_dim: Color::rgba(142, 142, 142, 1.0), + text_faint: Color::rgba(96, 96, 96, 1.0), + rail: Color::rgba(18, 18, 18, 1.0), + panel: Color::rgba(22, 22, 22, 1.0), + field: Color::rgba(31, 31, 31, 1.0), + hover: Color::rgba(38, 38, 38, 1.0), + selected: Color::rgba(44, 44, 44, 1.0), + edge: Color::rgba(255, 255, 255, 0.06), + bg: Color::rgba(18, 18, 18, 1.0), + sidebar: Color::rgba(23, 23, 23, 1.0), + surface: Color::rgba(28, 28, 28, 1.0), + surface_2: Color::rgba(37, 37, 37, 1.0), + surface_3: Color::rgba(47, 47, 47, 1.0), + ink: Color::rgba(237, 237, 237, 1.0), + ink_2: Color::rgba(184, 184, 184, 1.0), + ink_muted: Color::rgba(138, 138, 138, 1.0), + accent_hover: Color::rgba(240, 173, 69, 1.0), + accent_ink: Color::rgba(26, 18, 6, 1.0), + danger_2: Color::rgba(229, 83, 75, 1.0), + radius: 8.0, + glass: None, + }; + + /// The colour of `name` (the field's name, as a theme file has it), to set it. + pub fn color_mut(&mut self, name: &str) -> Option<&mut Color> { + Some(match name { + "accent" => &mut self.accent, + "accent_2" => &mut self.accent_2, + "danger" => &mut self.danger, + "ok" => &mut self.ok, + "warn" => &mut self.warn, + "text" => &mut self.text, + "text_soft" => &mut self.text_soft, + "text_dim" => &mut self.text_dim, + "text_faint" => &mut self.text_faint, + "rail" => &mut self.rail, + "panel" => &mut self.panel, + "field" => &mut self.field, + "hover" => &mut self.hover, + "selected" => &mut self.selected, + "edge" => &mut self.edge, + "bg" | "background" => &mut self.bg, + "sidebar" => &mut self.sidebar, + "surface" => &mut self.surface, + "surface_2" => &mut self.surface_2, + "surface_3" => &mut self.surface_3, + "ink" => &mut self.ink, + "ink_2" => &mut self.ink_2, + "ink_muted" => &mut self.ink_muted, + "accent_hover" => &mut self.accent_hover, + "accent_ink" => &mut self.accent_ink, + "danger_2" => &mut self.danger_2, + _ => return None, + }) + } + + /// A palette with the colours `given` (by name) on openOMSI's; a colour the theme leaves + /// out follows the one it goes with there (the panels' greys the surfaces', the texts the + /// inks', the darker red the red), so that a theme of a few colours is whole. Names it + /// does not know are returned. + pub fn with_colors<'a>(given: impl IntoIterator) -> (Palette, Vec<&'a str>) { + let mut p = Palette::OPENOMSI; + let mut set: Vec<&str> = Vec::new(); + let mut unknown = Vec::new(); + for (name, c) in given { + match p.color_mut(name) { + Some(slot) => { + *slot = c; + set.push(if name == "background" { "bg" } else { name }); + } + None => unknown.push(name), + } + } + // (what follows what when it was not given itself) + const FOLLOWS: [(&str, &str); 13] = [ + ("rail", "bg"), + ("sidebar", "bg"), + ("panel", "surface"), + ("field", "surface_2"), + ("hover", "surface_3"), + ("selected", "surface_3"), + ("text", "ink"), + ("text_soft", "ink_2"), + ("text_dim", "ink_muted"), + ("text_faint", "ink_muted"), + ("danger_2", "danger"), + ("accent_hover", "accent"), + ("warn", "accent"), + ]; + for (to, from) in FOLLOWS { + if !set.contains(&to) && set.contains(&from) { + let mut c = *p.color_mut(from).unwrap(); + match to { + "accent_hover" => c = c.lighten(0.12), + "text_faint" => c = c.darken(0.3), + _ => {} + } + *p.color_mut(to).unwrap() = c; + } + } + (p, unknown) + } +} + +static PALETTE: RwLock = RwLock::new(Palette::OPENOMSI); + +thread_local! { + /// A palette for this thread alone (`with_palette`: the tests of a theme). + static LOCAL: Cell> = const { Cell::new(None) }; +} + +/// The palette in force. +pub fn th() -> Palette { + LOCAL.with(|l| l.get()).unwrap_or_else(|| *PALETTE.read().unwrap_or_else(|e| e.into_inner())) +} + +/// Puts `p` in force for every screen drawn after. +pub fn set(p: Palette) { + *PALETTE.write().unwrap_or_else(|e| e.into_inner()) = p; +} + +/// `f` with `p` in force on this thread only. +pub fn with_palette(p: Palette, f: impl FnOnce() -> R) -> R { + let before = LOCAL.with(|l| l.replace(Some(p))); + let out = f(); + LOCAL.with(|l| l.set(before)); + out +} + /// Height of a control row. pub const ROW: f32 = 36.0; pub const GAP: f32 = 12.0; @@ -50,6 +219,28 @@ pub fn backdrop(opacity: f32) -> f32 { (opacity / 0.85).clamp(0.3, 1.3) } +/// A colour of a theme file: `#rgb`, `#rrggbb` or `#rrggbbaa`. +pub fn parse_hex(s: &str) -> Option { + let h = s.trim().strip_prefix('#')?; + if !h.is_ascii() { + return None; + } + let digit = |i: usize, n: usize| u8::from_str_radix(h.get(i..i + n)?, 16).ok(); + let (r, g, b, a) = match h.len() { + 3 => (digit(0, 1)? * 17, digit(1, 1)? * 17, digit(2, 1)? * 17, 255), + 6 => (digit(0, 2)?, digit(2, 2)?, digit(4, 2)?, 255), + 8 => (digit(0, 2)?, digit(2, 2)?, digit(4, 2)?, digit(6, 2)?), + _ => return None, + }; + Some(Color::rgba(r, g, b, a as f32 / 255.0)) +} + +/// A colour as the 8-bit RGBA the game's own overlays take. +pub fn rgba8(c: Color) -> [u8; 4] { + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + [q(c.0[0]), q(c.0[1]), q(c.0[2]), q(c.0[3])] +} + #[cfg(test)] mod tests { use super::*; @@ -62,4 +253,45 @@ mod tests { assert_eq!(backdrop(0.2), 0.3); assert!(backdrop(1.0) > 1.0); } + + #[test] + fn a_theme_colour_is_read_from_hex() { + assert_eq!(parse_hex("#f58620"), Some(Color::rgba(0xf5, 0x86, 0x20, 1.0))); + assert_eq!(parse_hex(" #fff "), Some(Color::rgba(255, 255, 255, 1.0))); + assert_eq!(parse_hex("#0a142e80"), Some(Color::rgba(10, 20, 46, 128.0 / 255.0))); + for bad in ["f58620", "#f5862", "#ggg", "", "#", "#ééé"] { + assert_eq!(parse_hex(bad), None, "{bad}"); + } + assert_eq!(rgba8(Color::rgba(245, 134, 32, 1.0)), [245, 134, 32, 255]); + } + + /// A theme of a few colours is whole: what it leaves out follows what it gives, the rest + /// stays openOMSI's. + #[test] + fn a_colour_left_out_follows_the_one_it_goes_with() { + let navy = Color::rgba(10, 20, 46, 1.0); + let orange = Color::rgba(245, 134, 32, 1.0); + let (p, unknown) = Palette::with_colors([("background", navy), ("accent", orange), ("ink", Color::WHITE), ("sparkle", navy)]); + assert_eq!(unknown, ["sparkle"]); + assert_eq!((p.bg, p.rail, p.sidebar), (navy, navy, navy)); + assert_eq!((p.accent, p.warn), (orange, orange)); + assert_ne!(p.accent_hover, orange, "a lighter orange under the mouse"); + assert_eq!(p.text, Color::WHITE); + assert_eq!(p.surface, Palette::OPENOMSI.surface); + // (given itself, a colour stays as given) + let (p, _) = Palette::with_colors([("background", navy), ("sidebar", orange)]); + assert_eq!((p.rail, p.sidebar), (navy, orange)); + } + + /// A palette set for one thread is that thread's: the others keep the one in force. + #[test] + fn a_palette_for_a_thread_stays_on_it() { + let red = Palette { accent: Color::rgba(255, 0, 0, 1.0), ..Palette::OPENOMSI }; + with_palette(red, || { + assert_eq!(th().accent, red.accent); + let other = std::thread::spawn(|| th().accent).join().unwrap(); + assert_eq!(other, Palette::OPENOMSI.accent); + }); + assert_eq!(th().accent, Palette::OPENOMSI.accent); + } } diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 67af417c5..b11ddd9aa 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -316,7 +316,7 @@ end) F10 gives the panels the mouse; a click on the button sounds the horn. The whole example is [`docs/examples/plugins/trip_panel.lua`](examples/plugins/trip_panel.lua). -`omsi.ui.version` is `5` (`4` before `grab`, `3` before boxes, stop lists and the other +`omsi.ui.version` is `6` (`5` before the interface themes, `4` before `grab`, `3` before boxes, stop lists and the other elements and keys marked 4 below, `2` before the navigator's board, `1` before the controls): test `if omsi.ui and omsi.ui.version >= 2` in a plugin that should also run in an older openOMSI. The panels are the plugin's own: one plugin cannot change or remove another's, and when the @@ -431,6 +431,24 @@ The icon is one of the game's Material Symbols; without one, or with one the gam have, the line gets the plugins' own icon. The lines go when the plugin stops (`ui.remove_menu_item` takes one out before that). +#### An interface theme + +A plugin can bring a look for the game's own screens - the colours of the menus, their panels as +frosted glass over the picture, the loading screen with the plugin's logo, which may itself be the +loading bar: a `theme.json` in the plugin's folder makes it a theme of the plugin's name (see +[THEMES.md](THEMES.md)). The player chooses it in Options › Interface; the plugin may switch to +it, once and after asking, with `ui.set_theme`: + +```lua +if omsi.ui.set_theme and not omsi.data.theme_offered then + omsi.data.theme_offered = true + if omsi.ui.theme() == "" then omsi.ui.set_theme("myplugin") end +end +``` + +The choice is the player's setting from then on, kept for the next start; `ui.themes()` lists +the themes there are. + ### Example plugins Complete plugins to start from (each runs in the tests against a stand-in game): @@ -834,7 +852,10 @@ On macOS, helpers extracted from ZIP files may need their executable bit restore | `omsi.ui.screen()` | width, height, scale | The screen in the panels' pixels, and how many of the screen's own pixels one of them is. | 0.2.21 | | `omsi.ui.set(id, panel)` | true, or false and the reason | Creates the panel `id` or replaces it; a table that is not right gives `false` and where (`"children[2].size: a number is expected"`). The same table again changes nothing. *Permission: `ui`.* | 0.2.21 | | `omsi.ui.set_menu_item(id, label, [icon])` | true, or false and the reason | Puts a line in the game's pause menu, or changes the plugin's line `id`: its label (40 characters at most, shown as it is) and a Material Symbols icon of the game's (`"departure_board"`; the plugins' own icon without one). Choosing it closes the menu and sends `menu_item` with `id` - to open the plugin's panel, say. A plugin has 3 lines at most; they go when it stops. *Permission: `ui`.* | 0.3.36 | +| `omsi.ui.set_theme(id)` | true, or false and the reason | Switches the game's interface to theme `id` (`""` or `"openomsi"`: openOMSI's own look), as if the player chose it in Options › Interface: it is kept for the next start. Ask the player first; they can switch back there. *Permission: `ui`.* | 0.3.42 | | `omsi.ui.show(panel, [on])` | boolean | Shows a panel (or hides it with `false`; it is kept); `false` when there is none. *Permission: `ui`.* | 0.2.22 | +| `omsi.ui.theme()` | string | The id of the interface theme in use (`""`: openOMSI's own look). | 0.3.42 | +| `omsi.ui.themes()` | list of tables | The game's interface themes: each `{ id, name }`, openOMSI's own look first (its id `""`). A theme is a folder with a `theme.json` (`Themes//`, or a plugin's own folder: see docs/THEMES.md). | 0.3.42 | | `omsi.ui.toast(text, [opts])` | true, or false and the reason | A notification card at the top right, newest at the top; it goes after `opts.seconds` (1 to 60, default 5). `opts`: `title`, `icon`, `color`. A plugin shows 8 at most: a ninth makes its oldest go. *Permission: `ui`.* | 0.2.21 | | `omsi.ui.toggle(panel)` | boolean | Shows a hidden panel or hides a shown one; whether it shows now. *Permission: `ui`.* | 0.2.22 | | `omsi.ui.typing()` | boolean | Whether the player types into a text field of a plugin now (the keys then go to the field, not to the bus). | 0.2.22 | diff --git a/docs/THEMES.md b/docs/THEMES.md new file mode 100644 index 000000000..4d6dfd592 --- /dev/null +++ b/docs/THEMES.md @@ -0,0 +1,98 @@ +# Interface themes + +An interface theme gives the game's own screens a look of their own: the colours of the pause +menu, its settings windows and lists, the photo mode, the developer tools, the plugins' panels +and notifications; the panels as frosted glass over the game's picture; the logo at the top of +the pause menu; and the loading screen, whose logo can itself be the loading bar. The HUD, the +navigator and the cockpit keep their own look (the navigator follows its light or dark map). + +The player chooses a theme in **Options › Interface › Interface theme**; openOMSI's own look is +the first entry. The choice is kept in the settings file (`ui_theme`) and is in force from the +loading screen of the next start on - a theme that cannot be read leaves openOMSI's own look, with +a line in `game.log` saying why. + +## Where themes live + +A theme is a folder with a `theme.json`; its id is the folder's name (letters, digits, `-`, `_` +and `.`): + +| Folder | | +| --- | --- | +| `Themes//` in a content folder | a theme of its own (a mod) | +| `plugins//` | a plugin's theme, its id the plugin's name | + +The content folders' `Themes` come first; of two themes of one id (in any case) the first is +used. A content folder another player's game sent in a LAN session brings no theme. + +## theme.json + +```json +{ + "name": "Night shift", + "title": "Night shift", + "colors": { + "accent": "#2a75f7", + "background": "#0a142e", + "surface": "#141a26", + "ink": "#e8ebf2" + }, + "radius": 12, + "glass": { "opacity": 0.55, "blur": 26, "edge": 0.14, "highlight": 0.09 }, + "loading": { + "background": "#0a142e", + "text": "#c4cad6", + "logo": "logo.svg", + "progress": "stroke" + }, + "logo": "icon.svg" +} +``` + +Everything is optional; what a theme leaves out stays openOMSI's. + +| Key | | +| --- | --- | +| `name` | The theme's name in the options (40 characters at most; without it, the id). | +| `title` | The name at the top of the pause menu, beside `logo`, over openOMSI's version. | +| `colors` | Colours by name, as `"#rrggbb"`, `"#rrggbbaa"` or `[r, g, b]` / `[r, g, b, a]` (0 to 255; the opacity 0 to 1). | +| `radius` | The corners of the panels (0 to 24 points; openOMSI's are 8). | +| `glass` | `true`, or the glass's `opacity` (how much of a panel's colour lies over the blurred picture, 0 to 1), `blur` (its radius in points, 0 to 64), `edge` and `highlight` (the light edge round a panel and the sheen at its top, 0 to 1). | +| `loading` | The loading screen: `background`, `text` (the map's name and the tips), `logo` and `progress`. | +| `logo` | The logo at the top of the pause menu (drawn 32 points square). | + +**Colours.** The names are those of the game's palette: `accent`, `accent_hover`, `accent_ink` +(text on the accent), `accent_2`, `background`, `sidebar`, `surface`, `surface_2`, `surface_3` (the +surfaces, a step lighter each), `ink`, `ink_2`, `ink_muted` (the texts), `danger`, `danger_2`, +`ok`, `warn`, and the older screens' `rail`, `panel`, `field`, `hover`, `selected`, `edge`, `text`, +`text_soft`, `text_dim`, `text_faint`. A colour left out follows the one it goes with when that +is given (`sidebar` the background, `panel` the surface, `text` the ink, `accent_hover` a lighter +accent ...), so a theme of a few colours is whole. + +**Logos** are a file in the theme's folder: an SVG (drawn sharp at any size) or a picture (PNG, +TGA, BMP, JPEG, DDS; 4096 pixels at most). Nothing outside the folder is read. + +**The loading bar.** `loading.progress` says how the loading screen shows how far it is: + +- `"bar"` (the default): openOMSI's striped bar along the bottom, in the theme's accent; +- `"stroke"`: the logo is the bar - its path with `id="progress"` (one `` without a + dash of its own: a ring, a line) is drawn along as far as the loading is, over a faint track of + the whole path; +- `"fill"`: the logo is filled from the left as far as the loading is, faint and grey beyond. + +A logo without a path `progress` is filled instead of stroked. + +## Glass + +With `glass`, the pause menu's rail, its cards and panels, and the settings windows are panes +of frosted glass: the game's picture behind them blurred, the panel's colour over it at the +glass's opacity, a light edge and a sheen at the top. The picture is taken (small, blurred on +the processor) when the menu opens, and again every second and a half while the game goes on +behind it (a LAN session, which does not pause). In a headset the menu takes the theme's colours +but no glass. + +## For plugins + +`omsi.ui.themes()` lists the themes, `omsi.ui.theme()` says the one in use (`""`: openOMSI's +own) and `omsi.ui.set_theme(id)` switches to one, as if the player chose it (see +[PLUGINS.md](PLUGINS.md#an-interface-theme)). A plugin should do that once at most, and only +over openOMSI's own look: the player can always switch back in the options. diff --git a/docs/plugin-api.json b/docs/plugin-api.json index 88bc7d32c..bc8cec9f2 100644 --- a/docs/plugin-api.json +++ b/docs/plugin-api.json @@ -3571,6 +3571,22 @@ "multi": true, "permission": "ui" }, + { + "name": "ui.set_theme", + "group": "ui", + "since": "0.3.42", + "doc": "Switches the game's interface to theme `id` (`\"\"` or `\"openomsi\"`: openOMSI's own look), as if the player chose it in Options › Interface: it is kept for the next start. Ask the player first; they can switch back there.", + "params": [ + { + "name": "id", + "type": "string", + "optional": false + } + ], + "returns": "true, or false and the reason", + "multi": true, + "permission": "ui" + }, { "name": "ui.show", "group": "ui", @@ -3592,6 +3608,26 @@ "multi": false, "permission": "ui" }, + { + "name": "ui.theme", + "group": "ui", + "since": "0.3.42", + "doc": "The id of the interface theme in use (`\"\"`: openOMSI's own look).", + "params": [], + "returns": "string", + "multi": false, + "permission": null + }, + { + "name": "ui.themes", + "group": "ui", + "since": "0.3.42", + "doc": "The game's interface themes: each `{ id, name }`, openOMSI's own look first (its id `\"\"`). A theme is a folder with a `theme.json` (`Themes//`, or a plugin's own folder: see docs/THEMES.md).", + "params": [], + "returns": "list of tables", + "multi": false, + "permission": null + }, { "name": "ui.toast", "group": "ui", From a6403e8625d88def91b23035d29055964db672f7 Mon Sep 17 00:00:00 2001 From: Luc Ruigrok <64922122+Luc-nbr@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:21:44 +0100 Subject: [PATCH 02/11] feat(ui): a theme's loading logo brings its parts in as the loading reaches them An element of an SVG loading logo with an id and data-at (0 to 1 along the progress) stays hidden until the loading is that far, then pops up from its middle with a little overshoot; with data-blink it flashes twice (a light), data-delay waits a moment after (icons::marks, icons::staged_svg, Stage::of). The loading screen times each mark by its own clock and draws a new picture of the logo only when a mark's stage changes (in twentieths of its size). --- CHANGELOG.md | 3 +- crates/omsi-app/src/themes.rs | 37 +++++--- crates/omsi-app/src/ui.rs | 27 +++++- crates/omsi-ui/src/icons.rs | 153 ++++++++++++++++++++++++++++++++++ docs/THEMES.md | 13 +++ 5 files changed, 216 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b42df485..46d832c91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ promoted to stable releases (see [docs/VERSIONING.md](docs/VERSIONING.md)); the glass over the game's picture (blurred when the menu opens, and every second and a half while the game goes on behind it); the logo and the name at the top of the pause menu; and the loading screen's colours and logo, which can itself be the loading bar - its path - `id="progress"` drawn along, or the logo filled from the left, as far as the loading is. + `id="progress"` drawn along, or the logo filled from the left, as far as the loading is - and + parts of the logo marked `data-at` pop up (or, `data-blink`, flash) as the loading reaches them. openOMSI's own look is unchanged and stays the default. Plugins see the themes with `ui.themes` and `ui.theme` and can switch to one with `ui.set_theme` (`omsi.ui.version` 6). - **LAN protocol 15**: a game of this version cannot join a session of an older one, nor the diff --git a/crates/omsi-app/src/themes.rs b/crates/omsi-app/src/themes.rs index 3246777a4..dd7160150 100644 --- a/crates/omsi-app/src/themes.rs +++ b/crates/omsi-app/src/themes.rs @@ -10,6 +10,7 @@ use std::path::{Component, Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; +use omsi_ui::icons::{Mark, Stage}; use omsi_ui::theme::{self, Glass, Palette}; use omsi_ui::Color; @@ -34,11 +35,11 @@ pub(crate) enum Progress { Fill, } -/// A logo of a theme: an SVG (its source, its size and the length of its path `progress` -/// when it has one) or a picture. +/// A logo of a theme: an SVG (its source, its size, the length of its path `progress` when it +/// has one, and its marks: what comes in as the loading reaches it) or a picture. #[derive(Clone, Debug, PartialEq)] pub(crate) enum Logo { - Svg { src: String, size: (f32, f32), progress_len: Option }, + Svg { src: String, size: (f32, f32), progress_len: Option, marks: Vec }, Picture { w: u32, h: u32, rgba: Arc> }, } @@ -56,7 +57,7 @@ impl Logo { pub fn square_pixels(&self, n: u32) -> Option> { let a = self.aspect(); let (w, h) = if a >= 1.0 { (n, ((n as f32 / a).round() as u32).max(1)) } else { (((n as f32 * a).round() as u32).max(1), n) }; - let px = self.pixels(w, h, None)?; + let px = self.pixels(w, h, None, &|_| Stage::Shown)?; let (x0, y0) = ((n - w) / 2, (n - h) / 2); let mut out = vec![0u8; (n * n * 4) as usize]; for y in 0..h { @@ -67,14 +68,23 @@ impl Logo { Some(out) } + /// The marks of an SVG logo (`data-at`: what comes in as the loading reaches it). + pub fn marks(&self) -> &[Mark] { + match self { + Logo::Svg { marks, .. } => marks, + Logo::Picture { .. } => &[], + } + } + /// Its pixels at `w` x `h` (straight RGBA) - with the loading's progress `frac` shown as - /// `progress` has it (none: the whole logo). - pub fn pixels(&self, w: u32, h: u32, progress: Option<(Progress, f32)>) -> Option> { - if let (Logo::Svg { src, progress_len: Some(len), .. }, Some((Progress::Stroke, frac))) = (self, progress) { - return omsi_ui::icons::rasterize_rgba(&omsi_ui::icons::progress_svg(src, "progress", *len, frac, TRACK), w, h); + /// `progress` has it (none: the whole logo), its marks as `stage` has them. + pub fn pixels(&self, w: u32, h: u32, progress: Option<(Progress, f32)>, stage: &dyn Fn(&Mark) -> Stage) -> Option> { + let staged = |src: &str, marks: &[Mark]| if marks.is_empty() { src.to_string() } else { omsi_ui::icons::staged_svg(src, marks, stage) }; + if let (Logo::Svg { src, progress_len: Some(len), marks, .. }, Some((Progress::Stroke, frac))) = (self, progress) { + return omsi_ui::icons::rasterize_rgba(&omsi_ui::icons::progress_svg(&staged(src, marks), "progress", *len, frac, TRACK), w, h); } let mut px = match self { - Logo::Svg { src, .. } => omsi_ui::icons::rasterize_rgba(src, w, h)?, + Logo::Svg { src, marks, .. } => omsi_ui::icons::rasterize_rgba(&staged(src, marks), w, h)?, Logo::Picture { w: pw, h: ph, rgba } => { let img = image::RgbaImage::from_raw(*pw, *ph, rgba.to_vec())?; image::imageops::resize(&img, w, h, image::imageops::FilterType::Triangle).into_raw() @@ -233,7 +243,8 @@ fn read_logo(dir: &Path, file: &str) -> Result { let src = std::fs::read_to_string(&path).map_err(|e| format!("{file}: {e}"))?; let size = omsi_ui::icons::svg_size(&src).filter(|s| s.0 > 0.0 && s.1 > 0.0).ok_or(format!("{file}: not an SVG that can be drawn"))?; let progress_len = omsi_ui::icons::path_length(&src, "progress"); - return Ok(Logo::Svg { src, size, progress_len }); + let marks = omsi_ui::icons::marks(&src); + return Ok(Logo::Svg { src, size, progress_len, marks }); } let img = omsi_texture::decode_file(&path).map_err(|e| format!("{file}: {e}"))?; if img.width == 0 || img.height == 0 || img.width > 4096 || img.height > 4096 { @@ -456,12 +467,12 @@ mod tests { let logo = t.loading.logo.as_ref().unwrap(); assert_eq!(logo.aspect(), 2.0); // the loading bar: drawn along the path as far as the loading is - let half = logo.pixels(200, 100, Some((Progress::Stroke, 0.5))).unwrap(); - let whole = logo.pixels(200, 100, None).unwrap(); + let half = logo.pixels(200, 100, Some((Progress::Stroke, 0.5)), &|_| Stage::Shown).unwrap(); + let whole = logo.pixels(200, 100, None, &|_| Stage::Shown).unwrap(); let ink = |px: &[u8]| px.chunks(4).filter(|p| p[3] > 128).count(); assert!(ink(&half) > ink(&whole) / 4 && ink(&half) < ink(&whole), "{} of {}", ink(&half), ink(&whole)); // filled from the left: the right half faint - let fill = logo.pixels(200, 100, Some((Progress::Fill, 0.5))).unwrap(); + let fill = logo.pixels(200, 100, Some((Progress::Fill, 0.5)), &|_| Stage::Shown).unwrap(); assert!(fill.chunks(4).enumerate().filter(|(i, _)| i % 200 >= 100).all(|(_, p)| p[3] <= 50)); assert!(t.logo.is_some()); } diff --git a/crates/omsi-app/src/ui.rs b/crates/omsi-app/src/ui.rs index 5ffa9574c..1fddd3b62 100644 --- a/crates/omsi-app/src/ui.rs +++ b/crates/omsi-app/src/ui.rs @@ -457,6 +457,9 @@ pub struct Ui { hud: hud::Hud, grips: [Option<([f32; 4], f32, f32)>; 3], caps: [f32; 3], + /// When the loading reached each mark of a theme's loading logo (by the loading screen's + /// clock): they pop in from then (`omsi_ui::icons::Stage`). + logo_marks: hashbrown::HashMap, } impl Ui { @@ -514,7 +517,7 @@ impl Ui { } } pub fn new() -> Option { - Some(Ui { origin_x: 0.0, text: TextCache::new()?, chat: ChatWidget::default(), menu_rects: Vec::new(), menu_arrows: Vec::new(), menu_scroll_thumb: None, menu_scroll_track: None, menu_ctl: Vec::new(), dd_rects: Vec::new(), dd_top: 0, dd_rows: 8, dd_scroll: None, menu_side: Vec::new(), menu_pane: Vec::new(), menu_pane_start: 0, menu_pane_go: None, menu_pane_box: None, menu_pane_scroll: None, menu_time: Vec::new(), anim: Default::default(), anim_dt: 0.0, menu_overlay_range: 0..0, vr_cursor_overlay: None, vr_tooltip_overlay: None, menu_start: 0, menu_rows: 0, menu_row_h: 1.0, images: Default::default(), info_rect: None, perf: Default::default(), mz: Default::default(), hud: hud::Hud::new(false), grips: [None; 3], caps: [f32::MAX; 3] }) + Some(Ui { origin_x: 0.0, text: TextCache::new()?, chat: ChatWidget::default(), menu_rects: Vec::new(), menu_arrows: Vec::new(), menu_scroll_thumb: None, menu_scroll_track: None, menu_ctl: Vec::new(), dd_rects: Vec::new(), dd_top: 0, dd_rows: 8, dd_scroll: None, menu_side: Vec::new(), menu_pane: Vec::new(), menu_pane_start: 0, menu_pane_go: None, menu_pane_box: None, menu_pane_scroll: None, menu_time: Vec::new(), anim: Default::default(), anim_dt: 0.0, menu_overlay_range: 0..0, vr_cursor_overlay: None, vr_tooltip_overlay: None, menu_start: 0, menu_rows: 0, menu_row_h: 1.0, images: Default::default(), info_rect: None, perf: Default::default(), mz: Default::default(), hud: hud::Hud::new(false), grips: [None; 3], caps: [f32::MAX; 3], logo_marks: Default::default() }) } /// Draw the frame's interface: its overlays go after the HUD's in `scene.overlays`. @@ -1242,6 +1245,8 @@ struct LoadingLook { stripes: ([u8; 3], [u8; 3]), } +use omsi_ui::icons::Stage; + impl LoadingLook { fn of(theme: Option<&crate::themes::Theme>) -> LoadingLook { let rgb = |c: omsi_ui::Color| { @@ -1303,8 +1308,24 @@ impl Ui { // every frame) let bar = mode != crate::themes::Progress::Bar; let q = if bar { (p * 100.0).round() / 100.0 } else { 1.0 }; - let key = (format!("\u{0}theme-logo {id} {mode:?} {q}"), lw, [255, 255, 255, ka]); - let tex = self.text.cached(r, scene, key, (lw, lh), || fade(l.pixels(lw, lh, bar.then_some((mode, q))).unwrap_or_else(|| vec![0; (lw * lh * 4) as usize]))); + // its marks pop in as the loading reaches them (in twentieths of their size: a + // new picture of the logo a few times a pop) + let marks = l.marks(); + self.logo_marks.retain(|m, _| marks.iter().any(|k| k.id == *m && p >= k.at)); + for m in marks.iter().filter(|m| p >= m.at) { + self.logo_marks.entry(m.id.clone()).or_insert(clock); + } + let stages: Vec = marks + .iter() + .map(|m| match Stage::of(m, self.logo_marks.get(&m.id).map(|t| clock - t)) { + Stage::Scale(x) => Stage::Scale((x * 20.0).round() / 20.0), + s => s, + }) + .collect(); + let staged: String = stages.iter().map(|s| match s { Stage::Hidden => "h".to_string(), Stage::Shown => "s".to_string(), Stage::Scale(x) => format!("{x}") }).collect::>().join(","); + let key = (format!("\u{0}theme-logo {id} {mode:?} {q} {staged}"), lw, [255, 255, 255, ka]); + let stage = |m: &omsi_ui::icons::Mark| marks.iter().position(|k| k.id == m.id).map_or(Stage::Shown, |i| stages[i]); + let tex = self.text.cached(r, scene, key, (lw, lh), || fade(l.pixels(lw, lh, bar.then_some((mode, q)), &stage).unwrap_or_else(|| vec![0; (lw * lh * 4) as usize]))); (tex, lw, lh, bar) } None => { diff --git a/crates/omsi-ui/src/icons.rs b/crates/omsi-ui/src/icons.rs index c78bd5e3a..c036a8f08 100644 --- a/crates/omsi-ui/src/icons.rs +++ b/crates/omsi-ui/src/icons.rs @@ -133,8 +133,161 @@ pub fn progress_svg(src: &str, id: &str, len: f32, frac: f32, track: f32) -> Str out } +/// An element of a loading logo that comes in when the loading reaches it: a top-level element +/// of the SVG with an `id` and `data-at="0.4"` (how far along the progress, 0 to 1). It pops in +/// - or, with `data-blink`, flashes twice, a light - `data-delay` seconds after that. +#[derive(Clone, Debug, PartialEq)] +pub struct Mark { + pub id: String, + pub at: f32, + pub delay: f32, + pub blink: bool, + /// The middle of the element (in the SVG's units): what it grows from. + pub center: (f32, f32), +} + +/// How a mark is drawn now. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Stage { + Hidden, + /// Growing (or shrinking back from a little more than its size, 0 to about 1.1). + Scale(f32), + Shown, +} + +impl Stage { + /// The stage of `mark` `t` seconds after the loading reached it (negative: not yet): popping + /// in over 0.35 s with a little overshoot, or two flashes of 0.14 s. + pub fn of(mark: &Mark, t: Option) -> Stage { + let Some(t) = t.map(|t| t - mark.delay) else { return Stage::Hidden }; + if t < 0.0 { + return Stage::Hidden; + } + if mark.blink { + return if t < 0.56 && t % 0.28 < 0.14 { Stage::Shown } else { Stage::Hidden }; + } + const POP: f32 = 0.35; + if t >= POP { + return Stage::Shown; + } + // (ease out with a little overshoot, as a bubble that pops up) + let x = t / POP - 1.0; + let (c1, c3) = (1.70158f32, 2.70158f32); + Stage::Scale((1.0 + c3 * x * x * x + c1 * x * x).max(0.0)) + } +} + +/// The marks of an SVG (`data-at`), in the order they stand in it. +pub fn marks(src: &str) -> Vec { + let Ok(tree) = resvg::usvg::Tree::from_str(src, &resvg::usvg::Options::default()) else { return Vec::new() }; + let attr = |tag: &str, name: &str| -> Option { + let k = tag.find(&format!(" {name}=\""))? + name.len() + 3; + Some(tag[k..k + tag[k..].find('"')?].to_string()) + }; + let mut out = Vec::new(); + let mut from = 0; + while let Some(k) = src[from..].find("data-at=\"") { + let at_attr = from + k; + from = at_attr + 9; + let Some(open) = src[..at_attr].rfind('<') else { continue }; + let Some(end) = src[at_attr..].find('>') else { continue }; + let tag = &src[open..at_attr + end]; + let (Some(id), Some(at)) = (attr(tag, "id"), attr(tag, "data-at").and_then(|a| a.parse::().ok())) else { continue }; + let Some(node) = tree.node_by_id(&id) else { continue }; + let b = node.abs_bounding_box(); + out.push(Mark { + id, + at: at.clamp(0.0, 1.0), + delay: attr(tag, "data-delay").and_then(|d| d.parse::().ok()).unwrap_or(0.0).clamp(0.0, 10.0), + blink: attr(tag, "data-blink").is_some(), + center: (b.x() + b.width() * 0.5, b.y() + b.height() * 0.5), + }); + } + out +} + +/// Where the element `id` lies in `src`: from its `<` to the end of its closing tag. +fn element_span(src: &str, id: &str) -> Option<(usize, usize)> { + let at = src.find(&format!(" id=\"{id}\""))?; + let open = src[..at].rfind('<')?; + let name: String = src[open + 1..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == ':').collect(); + let gt = at + src[at..].find('>')?; + if src[..gt].ends_with('/') { + return Some((open, gt + 1)); + } + // (the same element nested inside it: counted) + let (opening, closing) = (format!("<{name}"), format!("")); + let (mut depth, mut i) = (1, gt + 1); + while depth > 0 { + let next_close = i + src[i..].find(&closing)?; + let next_open = src[i..next_close].match_indices(&opening).map(|(k, _)| i + k).find(|&k| src[k + opening.len()..].starts_with([' ', '>', '/'])); + match next_open { + Some(k) => { + let tag_end = k + src[k..].find('>')?; + if !src[..tag_end].ends_with('/') { + depth += 1; + } + i = tag_end + 1; + } + None => { + depth -= 1; + i = next_close + closing.len(); + } + } + } + Some((open, i)) +} + +/// The SVG `src` with its marks drawn as `stage` says: hidden, growing from their middle, or as +/// they are. +pub fn staged_svg(src: &str, marks: &[Mark], stage: impl Fn(&Mark) -> Stage) -> String { + let mut out = src.to_string(); + for m in marks { + let wrap = match stage(m) { + Stage::Shown => continue, + Stage::Hidden => "".to_string(), + Stage::Scale(s) => { + let (cx, cy) = m.center; + format!("", -cx, -cy) + } + }; + if let Some((a, b)) = element_span(&out, &m.id) { + out = format!("{}{wrap}{}{}", &out[..a], &out[a..b], &out[b..]); + } + } + out +} + #[cfg(test)] mod tests { + /// A logo whose stops come in as the loading reaches them, and a light that flashes. + #[test] + fn marks_come_in_when_the_loading_reaches_them() { + let src = r##""##; + let m = super::marks(src); + assert_eq!(m.len(), 2); + assert_eq!((m[0].id.as_str(), m[0].at, m[0].blink, m[0].center), ("stop", 0.5, false, (100.0, 50.0))); + assert_eq!((m[1].id.as_str(), m[1].delay, m[1].blink), ("lamp", 0.35, true)); + // popping in: from nothing, a little over its size, then as it is + use super::Stage; + assert_eq!(Stage::of(&m[0], None), Stage::Hidden); + assert_eq!(Stage::of(&m[0], Some(0.0)), Stage::Scale(0.0)); + let Stage::Scale(over) = Stage::of(&m[0], Some(0.25)) else { panic!() }; + assert!(over > 1.0 && over < 1.15, "{over}"); + assert_eq!(Stage::of(&m[0], Some(1.0)), Stage::Shown); + // the light: after its delay, two flashes, then dark + let lamp = |t: f32| Stage::of(&m[1], Some(t)); + assert_eq!([lamp(0.1), lamp(0.4), lamp(0.55), lamp(0.68), lamp(0.83), lamp(2.0)], [Stage::Hidden, Stage::Shown, Stage::Hidden, Stage::Shown, Stage::Hidden, Stage::Hidden]); + // drawn: the whole group hidden or grown, nested groups and all + let ink = |svg: &str| super::rasterize_rgba(svg, 200, 100).unwrap().chunks(4).filter(|p| p[3] > 128).count(); + let all = ink(&super::staged_svg(src, &m, |_| Stage::Shown)); + let none = ink(&super::staged_svg(src, &m, |_| Stage::Hidden)); + let half = ink(&super::staged_svg(src, &m, |_| Stage::Scale(0.5))); + assert_eq!(none, 0); + assert!(half > all / 6 && half < all / 3, "{half} of {all}"); + assert!(super::staged_svg(src, &m, |_| Stage::Hidden).matches("").count() == 4); + } + /// A ring round a box and a line under a word, as a logo has it: its length, and drawn /// along a part of it. const LOGO: &str = r##""##; diff --git a/docs/THEMES.md b/docs/THEMES.md index 4d6dfd592..e16bbad7c 100644 --- a/docs/THEMES.md +++ b/docs/THEMES.md @@ -81,6 +81,19 @@ TGA, BMP, JPEG, DDS; 4096 pixels at most). Nothing outside the folder is read. A logo without a path `progress` is filled instead of stroked. +**Things that come in.** An element at the top level of an SVG loading logo with an `id` and +`data-at="0.4"` stays hidden until the loading is that far (0 to 1, along the path `progress` +when the logo is stroked), then pops up from its middle with a little overshoot. With +`data-blink` it flashes twice instead (a light), and `data-delay="0.4"` waits that many seconds +after the loading reached it: + +```svg + + + + +``` + ## Glass With `glass`, the pause menu's rail, its cards and panels, and the settings windows are panes From 4782a89ad5dc43fcc9dbcd7aa689b07b8ea35657 Mon Sep 17 00:00:00 2001 From: Luc Ruigrok <64922122+Luc-nbr@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:43:14 +0100 Subject: [PATCH 03/11] i18n: the interface theme's row in every language "Interface theme" and its description in app.yml, in the 29 languages the Interface page's other rows have, with a test that the row is drawn in the player's language. --- crates/omsi-app/locales/app.yml | 60 +++++++++++++++++++++++++++++++++ crates/omsi-app/src/themes.rs | 11 ++++++ 2 files changed, 71 insertions(+) diff --git a/crates/omsi-app/locales/app.yml b/crates/omsi-app/locales/app.yml index 6e67a6cb8..e0fb337e3 100644 --- a/crates/omsi-app/locales/app.yml +++ b/crates/omsi-app/locales/app.yml @@ -52235,6 +52235,66 @@ _version: 2 sv: "Hastigheter, avstånd och temperaturer i spelets texter" sr: "Brzine, udaljenosti i temperature u sopstvenim tekstovima igre" ka: "სიჩქარეები, მანძილები და ტემპერატურები თამაშის ტექსტებში" +"Interface theme": + ca: "Tema de la interfície" + ru: "Тема интерфейса" + de: "Design der Oberfläche" + fr: "Thème de l'interface" + uk: "Тема інтерфейсу" + be: "Тэма інтэрфейсу" + kk: "Интерфейс тақырыбы" + pl: "Motyw interfejsu" + cs: "Motiv rozhraní" + hu: "A felület témája" + es: "Tema de la interfaz" + pt: "Tema da interface" + pt-pt: "Tema da interface" + it: "Tema dell'interfaccia" + nl: "Thema van de interface" + tr: "Arayüz teması" + ja: "インターフェースのテーマ" + zh: "界面主题" + hi: "इंटरफ़ेस थीम" + zh-tw: "介面主題" + ko: "인터페이스 테마" + th: "ธีมของอินเทอร์เฟซ" + vi: "Chủ đề giao diện" + id: "Tema antarmuka" + ms: "Tema antara muka" + tl: "Tema ng interface" + sv: "Gränssnittets tema" + sr: "Tema interfejsa" + ka: "ინტერფეისის თემა" +"The look of the menus, the panels and the loading screen: openOMSI's own, or one a mod or plugin brings": + ca: "L'aspecte dels menús, dels panells i de la pantalla de càrrega: el d'openOMSI, o un que porti un mod o un connector" + ru: "Вид меню, панелей и экрана загрузки: собственный вид openOMSI или тема из мода или плагина" + de: "Das Aussehen der Menüs, der Tafeln und des Ladebildschirms: das von openOMSI oder eines, das ein Mod oder Plugin mitbringt" + fr: "L'apparence des menus, des panneaux et de l'écran de chargement : celle d'openOMSI, ou celle qu'apporte un mod ou un plugin" + uk: "Вигляд меню, панелей і екрана завантаження: власний вигляд openOMSI або тема з мода чи плагіна" + be: "Выгляд меню, панэляў і экрана загрузкі: уласны выгляд openOMSI або тэма з мода ці плагіна" + kk: "Мәзірлердің, панельдердің және жүктеу экранының көрінісі: openOMSI-дің өз көрінісі немесе мод не плагин әкелген тақырып" + pl: "Wygląd menu, paneli i ekranu ładowania: własny wygląd openOMSI albo motyw z moda lub wtyczki" + cs: "Vzhled nabídek, panelů a načítací obrazovky: vlastní vzhled openOMSI, nebo motiv z modu či pluginu" + hu: "A menük, a panelek és a betöltőképernyő megjelenése: az openOMSI sajátja, vagy egy mod vagy bővítmény által hozott téma" + es: "El aspecto de los menús, los paneles y la pantalla de carga: el propio de openOMSI o uno que traiga un mod o un plugin" + pt: "A aparência dos menus, dos painéis e da tela de carregamento: a do próprio openOMSI ou uma trazida por um mod ou plugin" + pt-pt: "O aspeto dos menus, dos painéis e do ecrã de carregamento: o do próprio openOMSI ou um trazido por um mod ou plugin" + it: "L'aspetto dei menu, dei pannelli e della schermata di caricamento: quello di openOMSI o uno portato da una mod o da un plugin" + nl: "Hoe de menu's, de panelen en het laadscherm eruitzien: zoals in openOMSI zelf, of een look die een mod of plugin meebrengt" + tr: "Menülerin, panellerin ve yükleme ekranının görünümü: openOMSI'nin kendi görünümü ya da bir mod veya eklentinin getirdiği bir tema" + ja: "メニュー、パネル、ロード画面の見た目:openOMSI 標準のもの、または Mod やプラグインが提供するもの" + zh: "菜单、面板和加载画面的外观:openOMSI 自带的,或由模组或插件提供的" + hi: "मेनू, पैनल और लोडिंग स्क्रीन का रूप: openOMSI का अपना, या किसी मॉड या प्लगइन द्वारा लाया गया" + zh-tw: "選單、面板和載入畫面的外觀:openOMSI 內建的,或由模組或外掛提供的" + ko: "메뉴, 패널, 로딩 화면의 모양: openOMSI 기본 모양 또는 모드나 플러그인이 제공하는 테마" + th: "หน้าตาของเมนู แผง และหน้าจอโหลด: แบบของ openOMSI เอง หรือแบบที่ม็อดหรือปลั๊กอินนำมา" + vi: "Giao diện của menu, bảng điều khiển và màn hình tải: của chính openOMSI, hoặc do một mod hay plugin mang lại" + id: "Tampilan menu, panel, dan layar pemuatan: bawaan openOMSI, atau yang dibawa oleh mod atau plugin" + ms: "Rupa menu, panel dan skrin pemuatan: rupa openOMSI sendiri, atau yang dibawa oleh mod atau pemalam" + tl: "Ang itsura ng mga menu, mga panel at ng loading screen: ang sariling itsura ng openOMSI, o isang dala ng mod o plugin" + sv: "Utseendet på menyerna, panelerna och laddningsskärmen: openOMSI:s eget, eller ett som en mod eller ett tillägg har med sig" + sr: "Izgled menija, panela i ekrana za učitavanje: sopstveni izgled openOMSI-ja ili tema koju donosi mod ili dodatak" + ka: "მენიუების, პანელების და ჩატვირთვის ეკრანის იერსახე: openOMSI-ის საკუთარი, ან მოდის ან დანამატის მიერ მოტანილი" "Metric (km/h, km, °C)": ca: "Mètric (km/h, km, °C)" ru: "Метрические (км/ч, км, °C)" diff --git a/crates/omsi-app/src/themes.rs b/crates/omsi-app/src/themes.rs index dd7160150..dd7c2c18a 100644 --- a/crates/omsi-app/src/themes.rs +++ b/crates/omsi-app/src/themes.rs @@ -541,4 +541,15 @@ mod tests { assert!((1..w).all(|x| at(x) <= at(x - 1)), "it falls evenly"); assert!(px.chunks(4).all(|p| p[3] == 255)); } + + /// The options' row for the theme is in the player's language. + #[test] + fn the_theme_row_is_translated() { + let desc = "The look of the menus, the panels and the loading screen: openOMSI's own, or one a mod or plugin brings"; + crate::with_test_language("nl", || { + assert_eq!(omsi_ui::tr("Interface theme"), "Thema van de interface"); + assert!(omsi_ui::tr(desc).starts_with("Hoe de menu's")); + }); + crate::with_test_language("de", || assert_eq!(omsi_ui::tr("Interface theme"), "Design der Oberfläche")); + } } From a878c347d2b8cf866774668214c3bd85805f70e0 Mon Sep 17 00:00:00 2001 From: Luc Ruigrok <64922122+Luc-nbr@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:52:09 +0100 Subject: [PATCH 04/11] fix(ui): the glass works in the running game; its controls solid, a theme's wordmark on the rail Tried in the game (Berlin-Spandau, DX12): - The window is made before the renderer (SurfaceState::early), so its picture could not be copied from and the glass showed the picture unblurred: adopt() now asks for COPY_SRC once the renderer's adapter says the window allows it. - The glass's picture is taken when the menu opens, then at most 30 times a second while the game goes on behind it (Shell::glass_due), and not again while it is paused: with it taken every frame the glass cost 1.1 ms a frame in a LAN session, now 0.24 ms (p50; p99 and the slow frames as with openOMSI's own look). - What is clicked stays solid on the glass - the session's buttons (End the session with a hairline of the red), the driver's chip, the action cards on the home and category pages - so that it reads as a control; the glass is the rail's and the panels'. - A theme's logo that is a wordmark (1.6 times as wide as high or more) is drawn across the top of the rail in place of the icon and the name, openOMSI's version under it. --- .../omsi-app/src/app_events/frame/present.rs | 10 ++-- crates/omsi-app/src/shell/mod.rs | 46 ++++++++++++++++--- .../omsi-app/src/shell/pause/navigation_ui.rs | 30 ++++++++---- crates/omsi-render/src/surface.rs | 7 +++ docs/THEMES.md | 6 ++- 5 files changed, 79 insertions(+), 20 deletions(-) diff --git a/crates/omsi-app/src/app_events/frame/present.rs b/crates/omsi-app/src/app_events/frame/present.rs index d1e74b978..405753b0f 100644 --- a/crates/omsi-app/src/app_events/frame/present.rs +++ b/crates/omsi-app/src/app_events/frame/present.rs @@ -351,10 +351,14 @@ impl App { } // a glass theme's pause menu: the picture under it, as the scene left it, blurred on the // graphics card for its panels (where the window's picture can be copied) - if let Some(glass) = omsi_ui::theme::th().glass.filter(|_| self.menus.game_menu.is_some() && !photo && !mirrored) { - if let Some(picture) = frame.as_ref().and_then(|f| s.picture(f)) { - self.shell.capture_backdrop(r, &picture, glass.blur * self.shell.scale); + match omsi_ui::theme::th().glass.filter(|_| self.menus.game_menu.is_some() && !photo && !mirrored) { + Some(glass) if self.shell.glass_due(self.paused) => { + if let Some(picture) = frame.as_ref().and_then(|f| s.picture(f)) { + self.shell.capture_backdrop(r, &picture, glass.blur * self.shell.scale); + } } + Some(_) => {} + None => self.shell.glass_closed(), } // the pause menu and the photo mode's panel self.shell.render(r, &view, s.config.width, s.config.height); diff --git a/crates/omsi-app/src/shell/mod.rs b/crates/omsi-app/src/shell/mod.rs index 5040ecaf3..dfab4d90b 100644 --- a/crates/omsi-app/src/shell/mod.rs +++ b/crates/omsi-app/src/shell/mod.rs @@ -74,8 +74,13 @@ pub(crate) struct Shell { /// beside it (none: "openOMSI"). logo_of: Option, pub(crate) logo_title: Option, + /// A theme's logo that is a wordmark (wider than `WIDE` times its height): its width over + /// its height. The rail then shows it across its top in place of the icon and the name. + pub(crate) logo_wide: Option, /// A glass theme's picture behind the screens (`capture_backdrop`). glass: Option, + /// When the glass's picture was last taken (none: it is to be taken; see `glass_due`). + glass_taken: Option, /// Physical pixels per point of the last frame. pub(crate) scale: f32, /// The mouse was over a panel of the screens in the last frame. @@ -106,13 +111,15 @@ struct GlassPicture { /// The vertex buffer of the glass's passes (0 is the screens'). const GLASS_BUFFER: usize = 1; +/// How often a second the glass's picture is taken while the game goes on behind the menu. +const GLASS_RATE: f32 = 30.0; impl Shell { pub(crate) fn new() -> Shell { // (the desktop launcher's calm look: graphite surfaces, no edges) let mut ui = Ui::new(); ui.flat = true; - Shell { process_prompt: None, process_dialog: Default::default(), ui, pause_category: Default::default(), quick_edit: false, pause_hidden: true, quick_focus: 0, gpu: None, shot_gpu: None, drawn: None, actions: Vec::new(), picture: None, picture_tex: None, logo_tex: None, shot_logo: None, logo_of: None, logo_title: None, glass: None, scale: 1.0, over_ui: false, last_sel: None, dd_anchor: None, opaque: false } + Shell { process_prompt: None, process_dialog: Default::default(), ui, pause_category: Default::default(), quick_edit: false, pause_hidden: true, quick_focus: 0, gpu: None, shot_gpu: None, drawn: None, actions: Vec::new(), picture: None, picture_tex: None, logo_tex: None, shot_logo: None, logo_of: None, logo_title: None, logo_wide: None, glass: None, glass_taken: None, scale: 1.0, over_ui: false, last_sel: None, dd_anchor: None, opaque: false } } /// A frame of the screens begins: the window's size in physical pixels and how many of @@ -128,7 +135,9 @@ impl Shell { self.ui.input.touch = crate::platform::touch_controls(); self.ui.begin(Vec2::new(width / self.scale, height / self.scale), self.scale, dt); self.ui.backdrop = self.glass.as_ref().and_then(|g| g.passes.last()).map(|p| p.1); - self.logo_title = crate::themes::current().and_then(|t| t.title.clone()); + let theme = crate::themes::current(); + self.logo_title = theme.as_ref().and_then(|t| t.title.clone()); + self.logo_wide = theme.as_ref().and_then(|t| t.logo.as_ref()).map(|l| l.aspect()).filter(|a| *a >= WIDE); } /// The frame of the screens is drawn: its layers kept for `render`. @@ -214,7 +223,23 @@ impl Shell { /// to twice that, each pass four taps a texel of the picture before apart, averaged (a box /// of four at each step). Nothing is read back and nothing waits for the graphics card: a /// copy and three passes into pictures of a sixteenth of the window and less. + /// Whether the glass's picture is to be taken this frame: when the menu has just opened, + /// and while the game goes on behind it (not `paused`) at most `GLASS_RATE` times a second + /// - a paused picture does not change, and the blur hides a frame's movement anyway. + pub(crate) fn glass_due(&self, paused: bool) -> bool { + match self.glass_taken { + None => true, + Some(t) => !paused && t.elapsed().as_secs_f32() >= 1.0 / GLASS_RATE, + } + } + + /// The menu with the glass is closed: its picture is taken anew when it opens again. + pub(crate) fn glass_closed(&mut self) { + self.glass_taken = None; + } + pub(crate) fn capture_backdrop(&mut self, r: &Renderer, src: &wgpu::Texture, blur: f32) { + self.glass_taken = Some(std::time::Instant::now()); self.ensure_gpu(r); let Some((gpu, format)) = self.gpu.as_mut() else { return }; let format = *format; @@ -289,11 +314,16 @@ impl Shell { self.logo_of = logo_of; } if self.logo_tex.is_none() { - let px = match theme.as_ref().and_then(|t| t.logo.as_ref()) { - Some(logo) => logo.square_pixels(LOGO_PX), - None => logo_pixels(), + // (a wordmark at its own width, for the rail's top; an icon square) + let (px, w, h) = match theme.as_ref().and_then(|t| t.logo.as_ref()) { + Some(logo) if logo.aspect() >= WIDE => { + let h = (LOGO_WIDE_PX as f32 / logo.aspect()).round().max(1.0) as u32; + (logo.pixels(LOGO_WIDE_PX, h, None, &|_| omsi_ui::icons::Stage::Shown), LOGO_WIDE_PX, h) + } + Some(logo) => (logo.square_pixels(LOGO_PX), LOGO_PX, LOGO_PX), + None => (logo_pixels(), LOGO_PX, LOGO_PX), }; - self.logo_tex = px.map(|px| gpu.add_image(&r.device, &r.queue, LOGO_PX, LOGO_PX, &px)); + self.logo_tex = px.map(|px| gpu.add_image(&r.device, &r.queue, w, h, &px)); } if let Some((pw, ph, px)) = self.picture.take() { match self.picture_tex { @@ -342,6 +372,10 @@ const LOGO: &str = include_str!("../../../../launcher/ui/logo.svg"); // (drawn 32 interface pixels wide, twice that on a Retina screen: a texture of about that // size, since a much larger one squeezed without mipmaps came out blurred and jagged) const LOGO_PX: u32 = 64; +/// A theme's wordmark: as wide as this in the texture (twice the rail's width it is drawn at, +/// for a Retina screen), and how much wider than high a logo is to be one. +const LOGO_WIDE_PX: u32 = 480; +const WIDE: f32 = 1.6; fn logo_pixels() -> Option> { omsi_ui::icons::rasterize_rgba(LOGO, LOGO_PX, LOGO_PX) diff --git a/crates/omsi-app/src/shell/pause/navigation_ui.rs b/crates/omsi-app/src/shell/pause/navigation_ui.rs index b4c768724..59397a8aa 100644 --- a/crates/omsi-app/src/shell/pause/navigation_ui.rs +++ b/crates/omsi-app/src/shell/pause/navigation_ui.rs @@ -28,10 +28,11 @@ fn session_action(sh: &mut Shell, v: &PauseView, id: &str, rect: Rect, kind: But let primary = kind == ButtonKind::Primary; let fill = if primary { th().accent.mix(th().accent_hover, t) } else { th().surface_2.mix(th().danger_2.alpha(0.15), t) }; let ink = if primary { th().accent_ink } else { th().danger_2 }; - if primary { - ui.p().rounded(rect, corner(12.0), fill); - } else { - ui.surface(rect, corner(12.0), fill); + // (a button is solid in every look, glass or not: the glass is the panels') + ui.p().rounded(rect, corner(12.0), fill); + if !primary && th().glass.is_some() { + // (on glass a hairline of the red sets it off, as the add-ons' own danger buttons) + ui.p().rounded_border(rect, corner(12.0), 1.0, th().danger_2.alpha(0.35 + 0.25 * t)); } let width = ui.width(&label, 18.0, Weight::Bold); let x = rect.center().x - (width + 32.0) * 0.5; @@ -56,11 +57,18 @@ pub(super) fn rail(sh: &mut Shell, v: &PauseView) { } else { ui.p().rect(r, th().sidebar); } - if let Some(tex) = sh.logo_tex { + // the game's name and version: openOMSI's icon and name; a theme's wordmark across the top, + // or its icon and name, over openOMSI's version + if let (Some(tex), Some(aspect)) = (sh.logo_tex, sh.logo_wide) { + // (as wide as the rail's buttons, at most 52 high: the version under it before Resume) + let h = 52.0f32.min((RAIL_W - 32.0) / aspect); + ui.image(Rect::new(16.0, 10.0, h * aspect, h), tex, 0.0); + ui.text_in(&format!("openOMSI {}", crate::startup::VERSION), Rect::new(18.0, 10.0 + h, RAIL_W - 36.0, 13.0), 9.5, Weight::Regular, th().ink_muted, Align::Left); + } else if let Some(tex) = sh.logo_tex { ui.image(Rect::new(18.0, 18.0, 32.0, 32.0), tex, 7.0); } - // the game's name and version; a theme's name over openOMSI's version - match sh.logo_title.as_deref() { + match sh.logo_title.as_deref().filter(|_| sh.logo_wide.is_none()) { + _ if sh.logo_wide.is_some() => {} Some(title) => { ui.text_in(title, Rect::new(60.0, 17.0, RAIL_W - 72.0, 22.0), 18.0, Weight::Bold, th().ink, Align::Left); ui.text_in(&format!("openOMSI {}", crate::startup::VERSION), Rect::new(60.0, 38.0, RAIL_W - 72.0, 18.0), 11.0, Weight::Regular, th().ink_muted, Align::Left); @@ -105,7 +113,7 @@ pub(super) fn rail(sh: &mut Shell, v: &PauseView) { fn driver(sh: &mut Shell, v: &PauseView, height: f32) { let ui = &mut sh.ui; let chip = Rect::new(16.0, height - 66.0, RAIL_W - 32.0, 52.0); - ui.surface(chip, corner(8.0), th().surface); + ui.p().rounded(chip, corner(8.0), th().surface); let av = Vec2::new(chip.x + 24.0, chip.center().y); ui.p().circle(av, 16.0, th().surface_3); let initial = v.summary.driver.chars().next().map(|c| c.to_uppercase().to_string()).unwrap_or_else(|| "?".into()); @@ -285,7 +293,11 @@ fn action_card(ui: &mut Ui, v: &PauseView, index: usize, rect: Rect, keyboard: b let (hovered, _, clicked) = ui.interact(id_of(&format!("pause-card-{index}-{id}")), rect); let t = ui.anim(id_of(&format!("pause-card-hover-{index}-{id}")), if hovered && !disabled { 1.0 } else { 0.0 }, 0.08); let fill = if keyboard { th().surface.mix(th().accent, 0.12) } else { th().surface.mix(th().surface_2, t) }; - ui.surface(rect, corner(18.0), fill); + // (a card is a button: solid in every look - the glass is the panels' and the rail's) + ui.p().rounded(rect, corner(18.0), fill); + if th().glass.is_some() && !keyboard { + ui.p().rounded_border(rect, corner(18.0), 1.0, omsi_ui::Color::WHITE.alpha(0.08 + 0.06 * t)); + } if keyboard { ui.p().rounded_border(rect, corner(18.0), 2.0, th().accent); } diff --git a/crates/omsi-render/src/surface.rs b/crates/omsi-render/src/surface.rs index cff34c9bc..5e5f41ace 100644 --- a/crates/omsi-render/src/surface.rs +++ b/crates/omsi-render/src/surface.rs @@ -135,6 +135,13 @@ impl<'w> SurfaceState<'w> { return false; } self.lost = renderer.errors.device_lost.clone(); + // (made before the renderer, the window's picture could not be copied from yet: it can + // now where the window allows it - the glass behind the game's screens, see `picture`) + let copy = renderer.adapter.as_ref().is_some_and(|a| self.surface.get_capabilities(a).usages.contains(wgpu::TextureUsages::COPY_SRC)); + if copy && !self.config.usage.contains(wgpu::TextureUsages::COPY_SRC) { + self.config.usage |= wgpu::TextureUsages::COPY_SRC; + self.surface.configure(device, &self.config); + } true } diff --git a/docs/THEMES.md b/docs/THEMES.md index e3aadb9fe..e30113575 100644 --- a/docs/THEMES.md +++ b/docs/THEMES.md @@ -58,7 +58,7 @@ Everything is optional; what a theme leaves out stays openOMSI's. | `radius` | The corners of the panels (0 to 24 points; openOMSI's are 8). | | `glass` | `true`, or the glass's `opacity` (how much of a panel's colour lies over the blurred picture, 0 to 1), `blur` (its radius in points, 0 to 64), `edge` and `highlight` (the light edge round a panel and the sheen at its top, 0 to 1). | | `loading` | The loading screen: `background`, `text` (the map's name and the tips), `logo` and `progress`. | -| `logo` | The logo at the top of the pause menu (drawn 32 points square). | +| `logo` | The logo at the top of the pause menu: an icon (drawn 32 points square, with `title` beside it), or a wordmark - a logo at least 1.6 times as wide as high, drawn across the top of the rail in place of the icon and the name. | **Colours.** The names are those of the game's palette: `accent`, `accent_hover`, `accent_ink` (text on the accent), `accent_2`, `background`, `sidebar`, `surface`, `surface_2`, `surface_3` (the @@ -96,13 +96,15 @@ after the loading reached it: ## Glass -With `glass`, the pause menu's rail, its cards and panels, and the settings windows are panes +With `glass`, the pause menu's rail, the panels of its pages and lists, and the settings windows are panes of frosted glass: the game's picture behind them blurred, the panel's colour over it at the glass's opacity, a light edge and a sheen at the top. The picture is the frame the game has just drawn, copied and blurred on the graphics card every frame the menu is open (nothing is read back, nothing waits for the graphics card), so it moves where the game goes on behind the menu (a LAN session, which does not pause). Where the window's picture cannot be copied, the panels are see-through without the blur. In a headset the menu takes the theme's colours but no glass. +What is clicked - the buttons, the action cards, the fields - stays solid in the theme's colours +on the glass, so that it reads as a control and not as a pane. ## For plugins From 591a6aa2292a240263e0eeaf7025f9c55a044689 Mon Sep 17 00:00:00 2001 From: Luc Ruigrok <64922122+Luc-nbr@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:54:08 +0100 Subject: [PATCH 05/11] fix(ui): a loading logo's mark grows where it stands in an SVG whose view box does not start at 0 usvg puts the view box on a group under the root, so the box of a mark is the canvas's: its middle is taken back through the element's parent's transform (its own undone), and a stop of the Omsi-Hub logo popped in a little above its line. Tested with a moved view box: the middle in the SVG's units, and a mark half grown has its ink centred where the whole one has. --- crates/omsi-ui/src/icons.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/omsi-ui/src/icons.rs b/crates/omsi-ui/src/icons.rs index c036a8f08..115373035 100644 --- a/crates/omsi-ui/src/icons.rs +++ b/crates/omsi-ui/src/icons.rs @@ -195,12 +195,24 @@ pub fn marks(src: &str) -> Vec { let (Some(id), Some(at)) = (attr(tag, "id"), attr(tag, "data-at").and_then(|a| a.parse::().ok())) else { continue }; let Some(node) = tree.node_by_id(&id) else { continue }; let b = node.abs_bounding_box(); + // (the box is the canvas's, the view box applied: back into the SVG's own units, where + // the element is grown round its middle) + // (the element's place on the canvas is its parent's transform after its own: the + // parent's, undone, gives the middle where the wrapper round it stands) + let mut c = resvg::tiny_skia::Point::from_xy(b.x() + b.width() * 0.5, b.y() + b.height() * 0.5); + let own = match node { + resvg::usvg::Node::Group(g) => g.transform(), + _ => resvg::tiny_skia::Transform::identity(), + }; + if let Some(back) = own.invert().and_then(|undo| node.abs_transform().pre_concat(undo).invert()) { + back.map_point(&mut c); + } out.push(Mark { id, at: at.clamp(0.0, 1.0), delay: attr(tag, "data-delay").and_then(|d| d.parse::().ok()).unwrap_or(0.0).clamp(0.0, 10.0), blink: attr(tag, "data-blink").is_some(), - center: (b.x() + b.width() * 0.5, b.y() + b.height() * 0.5), + center: (c.x, c.y), }); } out @@ -286,6 +298,19 @@ mod tests { assert_eq!(none, 0); assert!(half > all / 6 && half < all / 3, "{half} of {all}"); assert!(super::staged_svg(src, &m, |_| Stage::Hidden).matches("").count() == 4); + // (a view box that does not start at 0, as a logo cut to its ink has it: the middle in + // the SVG's own units, so that a mark grows where it stands) + let moved = r##""##; + let m = super::marks(moved); + let (cx, cy) = m[0].center; + assert!((cx - 190.0).abs() < 0.01 && (cy - 104.0).abs() < 0.01, "{:?}", m[0].center); + let ink_at = |svg: &str| { + let px = super::rasterize_rgba(svg, 200, 100).unwrap(); + let (n, sx, sy) = px.chunks(4).enumerate().filter(|(_, p)| p[3] > 128).fold((0.0f32, 0.0f32, 0.0f32), |(n, x, y), (i, _)| (n + 1.0, x + (i % 200) as f32, y + (i / 200) as f32)); + (sx / n, sy / n) + }; + let (whole, half) = (ink_at(moved), ink_at(&super::staged_svg(moved, &m, |_| Stage::Scale(0.5)))); + assert!((whole.0 - half.0).abs() < 1.0 && (whole.1 - half.1).abs() < 1.0, "{whole:?} {half:?}"); } /// A ring round a box and a line under a word, as a logo has it: its length, and drawn From 99f12f679022a68efdc849fcf7587dc74a9d66c8 Mon Sep 17 00:00:00 2001 From: "Isaac S." <107325320+isaacsa2@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:18:28 -0300 Subject: [PATCH 06/11] fix(ui): use the theme accent in the merged quick-menu tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapt square.rs from main to the theme branch’s runtime accent function. This fixes E0432 after merging main without changing the tile layout or behaviour. Signed-off-by: Isaac S. <107325320+isaacsa2@users.noreply.github.com> --- crates/omsi-plugin-ui/src/square.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/omsi-plugin-ui/src/square.rs b/crates/omsi-plugin-ui/src/square.rs index 9f846854d..19fc7358d 100644 --- a/crates/omsi-plugin-ui/src/square.rs +++ b/crates/omsi-plugin-ui/src/square.rs @@ -2,7 +2,7 @@ //! a square, the icon white on grey or on the tile's own colour, ringed in the accent when it //! has the keys (`focus`), greyed and taking no clicks when it is `off`. -use super::{color, Hit, HitKind, Item, Layout, ACCENT, INK, TILE}; +use super::{color, Hit, HitKind, Item, Layout, accent, INK, TILE}; use omsi_plugin::ui::Element; use omsi_ui::{Color, Rect}; @@ -22,7 +22,7 @@ impl Layout<'_> { let b = Rect::new(r.x, r.y + (r.h - side) * 0.5, r.w, side); if focus { let ring = Rect::new(b.x - RING, b.y - RING, b.w + 2.0 * RING, b.h + 2.0 * RING); - self.rounded(ring, SQUARE_R + RING, ACCENT, None); + self.rounded(ring, SQUARE_R + RING, accent(), None); } let hit = (!off).then(|| { self.out.hits.push(Hit { r: b, element: e.id.clone(), kind: HitKind::Click }); From d7d6f056bee3b16496d204305c0445d01de84e21 Mon Sep 17 00:00:00 2001 From: Luc Ruigrok <64922122+Luc-nbr@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:08:22 +0100 Subject: [PATCH 07/11] fix(plugins): a plugin's main chunk already knows the interface themes The game told the plugins its themes only from their first frame, after it had loaded them: `ui.themes()` was empty and `ui.set_theme` refused every theme while a plugin's main chunk ran, which is where docs/PLUGINS.md offers a plugin's theme (Omsi-Hub's plugin logged "no theme \"omsihub\"" and never offered it again). The game now tells the themes and the one in use before it loads the plugins (`omsi_plugin::ui::themes_for_load`, read by a new `UiState`), and each frame as before; a test for it. --- .../src/app_events/frame/integrations.rs | 14 ++++++-- crates/omsi-plugin/src/ui.rs | 5 +-- crates/omsi-plugin/src/ui/theme.rs | 35 +++++++++++++++++-- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/crates/omsi-app/src/app_events/frame/integrations.rs b/crates/omsi-app/src/app_events/frame/integrations.rs index 288b722c2..93e8102f4 100644 --- a/crates/omsi-app/src/app_events/frame/integrations.rs +++ b/crates/omsi-app/src/app_events/frame/integrations.rs @@ -43,7 +43,11 @@ impl App { steam.client.run_callbacks(); } // the plugins' frame, with the bus's scripts done - let mut plugins = self.integrations.plugins.take().unwrap_or_else(crate::plugins::load); + // (their main chunk already knows the interface themes: `ui.themes`, `ui.theme`) + let mut plugins = self.integrations.plugins.take().unwrap_or_else(|| { + omsi_plugin::ui::themes_for_load(theme_infos(), &self.settings.ui_theme_pack); + crate::plugins::load() + }); self.sync_vehicle_scripts(&mut plugins); if !self.paused && !omsi_cfg::flags::OMSI_NO_PLUGINS.is_set() { crate::plugin_io::object::drive(self, &mut plugins, dt); @@ -58,8 +62,7 @@ impl App { // the interface themes there are, and the one in use (`ui.themes`, `ui.theme`) let mut ui = plugins.ui.borrow_mut(); if ui.themes().is_empty() { - let list = crate::themes::choices().iter().map(|(id, name)| omsi_plugin::ui::ThemeInfo { id: id.clone(), name: name.clone() }).collect(); - ui.set_themes(list, &self.settings.ui_theme_pack); + ui.set_themes(theme_infos(), &self.settings.ui_theme_pack); } else if ui.theme() != self.settings.ui_theme_pack { let list = ui.themes().to_vec(); ui.set_themes(list, &self.settings.ui_theme_pack); @@ -162,3 +165,8 @@ impl App { } } } + +/// The game's interface themes as the plugins see them (`ui.themes`). +fn theme_infos() -> Vec { + crate::themes::choices().iter().map(|(id, name)| omsi_plugin::ui::ThemeInfo { id: id.clone(), name: name.clone() }).collect() +} diff --git a/crates/omsi-plugin/src/ui.rs b/crates/omsi-plugin/src/ui.rs index f9d36196f..c4c67b3c4 100644 --- a/crates/omsi-plugin/src/ui.rs +++ b/crates/omsi-plugin/src/ui.rs @@ -16,7 +16,7 @@ use std::rc::Rc; mod menu; pub use menu::{MenuItem, MAX_MENU_ITEMS, MAX_MENU_LABEL}; mod theme; -pub use theme::ThemeInfo; +pub use theme::{themes_for_load, ThemeInfo}; mod dock; pub use dock::Dock; @@ -367,8 +367,9 @@ pub struct UiState { impl Default for UiState { fn default() -> Self { + let (themes, theme_now) = theme::for_load(); // (until the game has drawn a frame: a 1080p screen) - UiState { panels: Vec::new(), toasts: Vec::new(), clicks: Vec::new(), changes: Vec::new(), focus: None, shown: true, screen: [1920.0, 1080.0, 1.0], serial: 0, typing: None, menu: Vec::new(), menu_picks: Vec::new(), dock: None, themes: Vec::new(), theme_now: String::new(), theme_ask: None } + UiState { panels: Vec::new(), toasts: Vec::new(), clicks: Vec::new(), changes: Vec::new(), focus: None, shown: true, screen: [1920.0, 1080.0, 1.0], serial: 0, typing: None, menu: Vec::new(), menu_picks: Vec::new(), dock: None, themes, theme_now, theme_ask: None } } } diff --git a/crates/omsi-plugin/src/ui/theme.rs b/crates/omsi-plugin/src/ui/theme.rs index 65a98a1d3..cbbc7ba91 100644 --- a/crates/omsi-plugin/src/ui/theme.rs +++ b/crates/omsi-plugin/src/ui/theme.rs @@ -1,8 +1,27 @@ //! The interface themes the game has (`ui.themes`, `ui.theme`), and a plugin's wish to use one -//! (`ui.set_theme`): the game tells the plugins its themes each frame, and takes a wish after -//! their frame (it becomes the player's setting, as if chosen in Options › Interface). +//! (`ui.set_theme`): the game tells the plugins its themes before it loads them and each frame, +//! and takes a wish after their frame (it becomes the player's setting, as if chosen in +//! Options › Interface). use super::UiState; +use std::cell::RefCell; + +thread_local! { + /// The game's themes and the one in use, for the plugins it loads next: their main chunk + /// already sees them (`ui.themes`, `ui.theme`, `ui.set_theme`), before the first frame. + static FOR_LOAD: RefCell<(Vec, String)> = const { RefCell::new((Vec::new(), String::new())) }; +} + +/// The game's themes and the one in use ("" openOMSI's own look), told before it loads the +/// plugins on this thread. +pub fn themes_for_load(themes: Vec, now: &str) { + FOR_LOAD.with(|f| *f.borrow_mut() = (themes, now.to_string())); +} + +/// What the game told before it loads the plugins (none: no themes yet). +pub(super) fn for_load() -> (Vec, String) { + FOR_LOAD.with(|f| f.borrow().clone()) +} /// One of the game's interface themes: its id (the setting's value; "" openOMSI's own look) /// and its name. @@ -69,4 +88,16 @@ mod tests { assert!(ui.ask_theme("sparkle").unwrap_err().contains("sparkle")); assert_eq!(ui.take_theme_ask(), None); } + + /// A plugin's main chunk, run as the game loads it, already knows the themes. + #[test] + fn the_themes_are_known_as_the_plugins_load() { + let themes = vec![ThemeInfo { id: String::new(), name: "openOMSI".into() }, ThemeInfo { id: "omsihub".into(), name: "Omsi-Hub".into() }]; + themes_for_load(themes.clone(), ""); + let mut ui = UiState::default(); + assert_eq!((ui.themes(), ui.theme()), (&themes[..], "")); + ui.ask_theme("omsihub").unwrap(); + assert_eq!(ui.take_theme_ask().as_deref(), Some("omsihub")); + themes_for_load(Vec::new(), ""); + } } From 7bb6739d530d2446669c7369b2021c8a20dc30db Mon Sep 17 00:00:00 2001 From: Luc Ruigrok <64922122+Luc-nbr@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:28:42 +0100 Subject: [PATCH 08/11] fix(ui): a theme's SVG logo reads no file outside itself usvg's own options read any file an `` names, a path absolute or relative to the working directory, and whole, so a theme's logo could read files outside its folder (and on Linux `/dev/zero` forever, at every start, the setting being saved). Every SVG is now read with one set of options (`icons::tree`) whose `` is only a `data:` URL, which stays inside the 4 MB file: `rasterize`, `rasterize_rgba`, `svg_size`, `path_length` and `marks`. A test with an `` to a file outside it, which usvg's own options do read. --- crates/omsi-ui/src/icons.rs | 53 ++++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/crates/omsi-ui/src/icons.rs b/crates/omsi-ui/src/icons.rs index 115373035..94fd62e4a 100644 --- a/crates/omsi-ui/src/icons.rs +++ b/crates/omsi-ui/src/icons.rs @@ -12,11 +12,25 @@ pub fn names() -> impl Iterator { ICONS.iter().map(|(n, _)| *n) } +/// An SVG read as a tree. An `` is taken only from inside the SVG (a `data:` URL): a +/// theme's logo comes from a mod, and a file name or path in it would let it read any file +/// on the player's machine (`/dev/zero` forever), not only its theme's folder. +fn tree(src: &str) -> Option { + use resvg::usvg::{ImageHrefResolver, Options}; + let opt = Options { + image_href_resolver: ImageHrefResolver { + resolve_data: ImageHrefResolver::default_data_resolver(), + resolve_string: Box::new(|_, _| None), + }, + ..Options::default() + }; + resvg::usvg::Tree::from_str(src, &opt).ok() +} + /// An icon as an anti-aliased alpha mask of `size` x `size` pixels, with the SVG's margins. pub fn rasterize(name: &str, size: u32) -> Option> { let src = svg(name)?; - let opt = resvg::usvg::Options::default(); - let tree = resvg::usvg::Tree::from_str(src, &opt).ok()?; + let tree = tree(src)?; let mut pix = resvg::tiny_skia::Pixmap::new(size, size)?; let s = tree.size(); let k = size as f32 / s.width().max(s.height()); @@ -27,8 +41,7 @@ pub fn rasterize(name: &str, size: u32) -> Option> { /// Any SVG (a logo) in its own colours, fitted into `w` x `h` pixels and centred: straight /// (not premultiplied) RGBA, as the interface's textures are. pub fn rasterize_rgba(src: &str, w: u32, h: u32) -> Option> { - let opt = resvg::usvg::Options::default(); - let tree = resvg::usvg::Tree::from_str(src, &opt).ok()?; + let tree = tree(src)?; let mut pix = resvg::tiny_skia::Pixmap::new(w, h)?; let s = tree.size(); let k = (w as f32 / s.width()).min(h as f32 / s.height()); @@ -43,7 +56,7 @@ pub fn rasterize_rgba(src: &str, w: u32, h: u32) -> Option> { /// The size an SVG is drawn at (its width and height in its own units); None when it cannot /// be read. pub fn svg_size(src: &str) -> Option<(f32, f32)> { - let tree = resvg::usvg::Tree::from_str(src, &resvg::usvg::Options::default()).ok()?; + let tree = tree(src)?; let s = tree.size(); Some((s.width(), s.height())) } @@ -52,7 +65,7 @@ pub fn svg_size(src: &str) -> Option<(f32, f32)> { /// what a theme's loading logo is drawn along (`progress_svg`). None when there is no such path. pub fn path_length(src: &str, id: &str) -> Option { use resvg::tiny_skia::PathSegment; - let tree = resvg::usvg::Tree::from_str(src, &resvg::usvg::Options::default()).ok()?; + let tree = tree(src)?; let resvg::usvg::Node::Path(path) = tree.node_by_id(id)? else { return None }; let (mut len, mut at, mut start) = (0.0f32, (0.0f32, 0.0f32), (0.0f32, 0.0f32)); let dist = |a: (f32, f32), b: (f32, f32)| ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt(); @@ -179,7 +192,7 @@ impl Stage { /// The marks of an SVG (`data-at`), in the order they stand in it. pub fn marks(src: &str) -> Vec { - let Ok(tree) = resvg::usvg::Tree::from_str(src, &resvg::usvg::Options::default()) else { return Vec::new() }; + let Some(tree) = tree(src) else { return Vec::new() }; let attr = |tag: &str, name: &str| -> Option { let k = tag.find(&format!(" {name}=\""))? + name.len() + 3; Some(tag[k..k + tag[k..].find('"')?].to_string()) @@ -367,4 +380,30 @@ mod tests { assert!(covered > 150 && covered < 900, "{covered}"); assert!(super::rasterize("no_such_icon", 32).is_none()); } + + /// An `` of a logo that names a file outside it is left out (usvg's own options + /// would read any file); one inside it (`data:`) is drawn. + #[test] + fn a_logo_reads_no_file_of_its_own_accord() { + fn images(g: &resvg::usvg::Group) -> usize { + g.children().iter().map(|n| match n { + resvg::usvg::Node::Image(_) => 1, + resvg::usvg::Node::Group(g) => images(g), + _ => 0, + }).sum() + } + let outside = std::env::temp_dir().join(format!("omsi-ui-outside-{}.svg", std::process::id())); + std::fs::write(&outside, r#""#).unwrap(); + let href = outside.to_string_lossy().replace('\\', "/"); + let src = format!(r#""#); + let loose = resvg::usvg::Tree::from_str(&src, &resvg::usvg::Options::default()).unwrap(); + assert_eq!(images(loose.root()), 1, "usvg's own options read the file: the test reaches it"); + assert_eq!(images(super::tree(&src).unwrap().root()), 0); + assert!(super::svg_size(&src).is_some()); + std::fs::remove_file(&outside).ok(); + + let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + let src = format!(r#""#); + assert_eq!(images(super::tree(&src).unwrap().root()), 1); + } } From bb8747e89b5d6618ff1cde82b9b4cbb31adb1622 Mon Sep 17 00:00:00 2001 From: Luc Ruigrok <64922122+Luc-nbr@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:46:10 +0100 Subject: [PATCH 09/11] perf(render): the window is copied from only while a glass theme is in force The swapchain asked for COPY_SRC for every player wherever the window allowed it, though only a glass theme reads the window's picture (the blurred glass behind the game's screens). On DX12 that costs nothing measurable, but a mobile GPU may give up framebuffer compression for a swapchain that can be copied from. `SurfaceState::set_glass` asks for COPY_SRC only while a glass theme is in force and reconfigures the window when that changes; the frame calls it after the theme is synced. Without a glass theme the window is configured as before the themes. --- .../omsi-app/src/app_events/frame/present.rs | 3 ++ crates/omsi-render/src/surface.rs | 36 ++++++++++++------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/crates/omsi-app/src/app_events/frame/present.rs b/crates/omsi-app/src/app_events/frame/present.rs index ee583e669..d397e4e10 100644 --- a/crates/omsi-app/src/app_events/frame/present.rs +++ b/crates/omsi-app/src/app_events/frame/present.rs @@ -47,6 +47,9 @@ impl App { let mut finish = false; let mut reconfigure = false; crate::themes::sync(&self.settings.ui_theme_pack, self.settings.is_light_theme()); + if let (Some(s), Some(r)) = (self.gfx.surface.as_mut(), self.renderer.as_ref()) { + s.set_glass(r, omsi_ui::theme::th().glass.is_some()); + } let shot = self.perf.shot.take(); if let Some(s) = self.gfx.surface.as_ref() { let (w, h) = (s.config.width, s.config.height); diff --git a/crates/omsi-render/src/surface.rs b/crates/omsi-render/src/surface.rs index 5e5f41ace..cbe6019c1 100644 --- a/crates/omsi-render/src/surface.rs +++ b/crates/omsi-render/src/surface.rs @@ -11,6 +11,8 @@ pub struct SurfaceState<'w> { pub(crate) lost: Arc>>, /// On ANGLE: the frames' sRGB stand-in, encoded into the plain window (see there). pub(crate) encode: Option, + /// A glass theme is in force: the window's picture is copied from (`set_glass`). + glass: bool, } impl Drop for SurfaceState<'_> { @@ -82,11 +84,9 @@ impl<'w> SurfaceState<'w> { let surface = instance.create_surface(window).context("create_surface")?; let encode = srgb_encode::SrgbEncode::wanted(renderer, renderer.format()); let modes = listed_modes(&surface, renderer); - // (copied from as well where the window allows it: the glass behind the game's screens, - // see `picture`) - let copy = renderer.adapter.as_ref().is_some_and(|a| surface.get_capabilities(a).usages.contains(wgpu::TextureUsages::COPY_SRC)); + // (copied from only for a glass theme: `set_glass`) let mut config = wgpu::SurfaceConfiguration { - usage: if copy { wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC } else { wgpu::TextureUsages::RENDER_ATTACHMENT }, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, format: encode.as_ref().map_or(renderer.format(), |e| e.window_format()), width: width.max(1), height: height.max(1), @@ -103,7 +103,7 @@ impl<'w> SurfaceState<'w> { config.width = width.max(1); config.height = height.max(1); surface.configure(&renderer.device, &config); - Ok(SurfaceState { surface: std::mem::ManuallyDrop::new(surface), config, lost: renderer.errors.device_lost.clone(), encode }) + Ok(SurfaceState { surface: std::mem::ManuallyDrop::new(surface), config, lost: renderer.errors.device_lost.clone(), encode, glass: false }) } /// The window's surface on a device whose renderer is still being built ([`Opened`]'s @@ -123,7 +123,7 @@ impl<'w> SurfaceState<'w> { view_formats: vec![], }; surface.configure(device, &config); - Ok(SurfaceState { surface: std::mem::ManuallyDrop::new(surface), config, lost: Default::default(), encode: None }) + Ok(SurfaceState { surface: std::mem::ManuallyDrop::new(surface), config, lost: Default::default(), encode: None, glass: false }) } /// The renderer built for the device this surface was made on ([`Self::early`]) takes @@ -135,16 +135,26 @@ impl<'w> SurfaceState<'w> { return false; } self.lost = renderer.errors.device_lost.clone(); - // (made before the renderer, the window's picture could not be copied from yet: it can - // now where the window allows it - the glass behind the game's screens, see `picture`) - let copy = renderer.adapter.as_ref().is_some_and(|a| self.surface.get_capabilities(a).usages.contains(wgpu::TextureUsages::COPY_SRC)); - if copy && !self.config.usage.contains(wgpu::TextureUsages::COPY_SRC) { - self.config.usage |= wgpu::TextureUsages::COPY_SRC; - self.surface.configure(device, &self.config); - } true } + /// Whether a glass theme is in force: only then is the window's picture copied from (the + /// glass behind the game's screens, see `picture`), where the window allows it - a window + /// that may be copied from can cost a mobile GPU its framebuffer compression. Reconfigures + /// the window when that changes; cheap to call every frame. + pub fn set_glass(&mut self, renderer: &Renderer, on: bool) { + if self.glass == on { + return; + } + self.glass = on; + let copy = on && renderer.adapter.as_ref().is_some_and(|a| self.surface.get_capabilities(a).usages.contains(wgpu::TextureUsages::COPY_SRC)); + let usage = if copy { wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC } else { wgpu::TextureUsages::RENDER_ATTACHMENT }; + if self.config.usage != usage { + self.config.usage = usage; + self.surface.configure(&renderer.device, &self.config); + } + } + /// The window's next picture. pub fn acquire(&self) -> wgpu::CurrentSurfaceTexture { self.surface.get_current_texture() From d2d51beb9de58a341f014846eff021d7de0989d0 Mon Sep 17 00:00:00 2001 From: Luc Ruigrok <64922122+Luc-nbr@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:46:11 +0100 Subject: [PATCH 10/11] fix(themes): a picture logo's size is read from its header before it is decoded A picture logo was decoded whole before its 4096 px limit was checked, and the decoder allows up to 16384 px: a 4 MB file of one colour could make the game decode about 1 GB. Its size is now read from its header first (`image::ImageReader::into_dimensions`, the format guessed from its content as OMSI's misnamed files need), and one larger than 4096 px is refused undecoded; the decoded size is still checked after. A test with a 5000 px PNG. --- crates/omsi-app/src/themes.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/omsi-app/src/themes.rs b/crates/omsi-app/src/themes.rs index 2ce2d08b6..082d54340 100644 --- a/crates/omsi-app/src/themes.rs +++ b/crates/omsi-app/src/themes.rs @@ -18,6 +18,8 @@ use omsi_ui::Color; pub(crate) const OWN: &str = "openomsi"; /// The longest theme file, SVG logo or picture that is read (bytes). const MAX_FILE: u64 = 4 << 20; +/// The widest and highest picture logo (pixels). +const MAX_PICTURE: u32 = 4096; /// The longest name and title of a theme (characters). const MAX_NAME: usize = 40; /// The track under a logo drawn along its path: the whole path, this faint. @@ -264,9 +266,16 @@ fn read_logo(dir: &Path, file: &str) -> Result { let marks = omsi_ui::icons::marks(&src); return Ok(Logo::Svg { src, size, progress_len, marks }); } + let fits = |w: u32, h: u32| (1..=MAX_PICTURE).contains(&w) && (1..=MAX_PICTURE).contains(&h); + let too_large = || format!("{file}: 1 to {MAX_PICTURE} pixels wide and high is expected"); + // (its size from its header first: a small file can hold a picture far larger than that) + let header = image::ImageReader::open(&path).ok().and_then(|r| r.with_guessed_format().ok()).and_then(|r| r.into_dimensions().ok()); + if header.is_some_and(|(w, h)| !fits(w, h)) { + return Err(too_large()); + } let img = omsi_texture::decode_file(&path).map_err(|e| format!("{file}: {e}"))?; - if img.width == 0 || img.height == 0 || img.width > 4096 || img.height > 4096 { - return Err(format!("{file}: 1 to 4096 pixels wide and high is expected")); + if !fits(img.width, img.height) { + return Err(too_large()); } Ok(Logo::Picture { w: img.width, h: img.height, rgba: Arc::new(img.rgba) }) } @@ -446,6 +455,9 @@ mod tests { assert!(bad(r#"{"logo": "../../secret.svg"}"#).contains("theme's folder")); assert!(bad(r#"{"logo": "/etc/x.svg"}"#).contains("theme's folder")); assert!(bad(r#"{"logo": "missing.svg"}"#).contains("missing.svg")); + // (a picture too large is told by its header, before it is decoded) + image::RgbaImage::new(5000, 1).save(dir.join("wide.png")).unwrap(); + assert!(bad(r#"{"logo": "wide.png"}"#).contains("4096")); // (a stroke without the path to draw along: filled; no name: the id) std::fs::write(dir.join("plain.svg"), r#""#).unwrap(); let t = parse("x", &dir, r#"{"loading": {"logo": "plain.svg", "progress": "stroke"}, "glass": true}"#).unwrap(); From 1ff7e58a56ff9fb73cd91946af27a20566a39879 Mon Sep 17 00:00:00 2001 From: Luc Ruigrok <64922122+Luc-nbr@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:51:35 +0100 Subject: [PATCH 11/11] fix(ui): the game menu's inks and fills follow the palette in force, so Light is no longer white on white The menu's texts, lines, selection, chips, tracks, scroll thumb, rail and its edge now come from the active palette (Light or a theme pack over it) as its panels already did; openOMSI's own look keeps its exact colours. Tests: the own look unchanged, and in Light (and a pack over it) the title, lines and hints stand out from the panel, the rail and the lit/chosen lines. --- crates/omsi-app/src/ui/menu.rs | 256 ++++++++++++++++++++++----------- 1 file changed, 171 insertions(+), 85 deletions(-) diff --git a/crates/omsi-app/src/ui/menu.rs b/crates/omsi-app/src/ui/menu.rs index 1aca895e8..c533702c8 100644 --- a/crates/omsi-app/src/ui/menu.rs +++ b/crates/omsi-app/src/ui/menu.rs @@ -5,7 +5,8 @@ use super::*; // --------------------------------------------------------------------------------------- // the game menu and its lists: flat near-black greys and one amber accent, as the launcher -/// openOMSI's colour `c`, or the interface theme's (`themes`) in its place. +/// openOMSI's colour `c`, or the palette's in force (Light, a theme pack: `themes`) in its +/// place - for the inks as for the fills, so a text and what it is drawn on share one look. fn own(c: [u8; 4], themed: impl Fn(omsi_ui::theme::Palette) -> omsi_ui::Color) -> [u8; 4] { let p = omsi_ui::theme::th(); if p == omsi_ui::theme::Palette::OPENOMSI { @@ -26,7 +27,9 @@ pub(super) fn panel() -> [u8; 4] { pub(super) fn panel_alt() -> [u8; 4] { own([31, 31, 31, 255], |p| p.field) } -pub(super) const BORDER: [u8; 4] = [255, 255, 255, 15]; +pub(super) fn border() -> [u8; 4] { + own([255, 255, 255, 15], |p| p.edge) +} pub(super) fn accent() -> [u8; 4] { own([232, 160, 48, 255], |p| p.accent) } @@ -39,22 +42,41 @@ pub(super) fn danger() -> [u8; 4] { /// A line under the mouse, and the line chosen: the launcher's `HOVER` and `SELECTED`, as /// solid greys. (They were white at 6 % and 9 %, which the overlays blend in linear light: /// on the 22-grey card that came out 73 and 92, twice as light as the launcher's 38 and 44.) -pub(super) const LIT: [u8; 4] = [38, 38, 38, 255]; -pub(super) const SELECTED: [u8; 4] = [44, 44, 44, 255]; +pub(super) fn lit() -> [u8; 4] { + own([38, 38, 38, 255], |p| p.hover) +} +pub(super) fn selected() -> [u8; 4] { + own([44, 44, 44, 255], |p| p.selected) +} /// "End the session" under the mouse: the danger colour at 14 % on the card, solid. -pub(super) const LIT_DANGER: [u8; 4] = [50, 30, 28, 255]; +pub(super) fn lit_danger() -> [u8; 4] { + own([50, 30, 28, 255], |p| p.panel.mix(p.danger, 0.14)) +} /// A stepper's or a button's field: the launcher's `FIELD`. -pub(super) const CHIP: [u8; 4] = [31, 31, 31, 255]; -/// A switch's track when off, a slider's empty track, their knob (the launcher's). -pub(super) const TRACK_OFF: [u8; 4] = [62, 62, 62, 255]; -pub(super) const SLIDER_TRACK: [u8; 4] = [58, 58, 58, 255]; +pub(super) fn chip_fill() -> [u8; 4] { + own([31, 31, 31, 255], |p| p.field) +} +/// A switch's track when off, a slider's empty track, their knob (the launcher's; a theme's +/// tracks are its text a little over its panel, as openOMSI's greys are on its own). +pub(super) fn track_off() -> [u8; 4] { + own([62, 62, 62, 255], |p| p.panel.mix(p.text, 0.19)) +} +pub(super) fn slider_track() -> [u8; 4] { + own([58, 58, 58, 255], |p| p.panel.mix(p.text, 0.17)) +} pub(super) const KNOB: [u8; 4] = [240, 240, 240, 255]; /// A scroll bar's thumb, idle and with the mouse over its list (the launcher's white at /// 10 % and 35 %; no track, no accent). -pub(super) const THUMB: [u8; 4] = [255, 255, 255, 26]; -pub(super) const THUMB_HOT: [u8; 4] = [255, 255, 255, 90]; +pub(super) fn thumb() -> [u8; 4] { + own([255, 255, 255, 26], |p| p.text.alpha(0.10)) +} +pub(super) fn thumb_hot() -> [u8; 4] { + own([255, 255, 255, 90], |p| p.text.alpha(0.35)) +} /// The small capitals over a title (the launcher's section headings: 11 px, bold, dim). -pub(super) const DIM: [u8; 4] = [142, 142, 142, 0]; +pub(super) fn dim() -> [u8; 4] { + txt(own([142, 142, 142, 0], |p| p.text_dim)) +} /// The accent's fill under the mouse, and the ink on it (the launcher's primary button). pub(super) fn accent_hot() -> [u8; 4] { own([246, 182, 84, 255], |p| p.accent_hover) @@ -63,16 +85,32 @@ pub(super) fn on_accent() -> [u8; 4] { txt(own([18, 14, 8, 0], |p| p.accent_ink)) } /// Text colours (alpha 0: no outline on the flat panel). -pub(super) const WHITE: [u8; 4] = [236, 236, 236, 0]; -pub(super) const SOFT: [u8; 4] = [200, 200, 200, 0]; -pub(super) const MUTED: [u8; 4] = [142, 142, 142, 0]; +pub(super) fn white() -> [u8; 4] { + txt(own([236, 236, 236, 0], |p| p.text)) +} +pub(super) fn soft() -> [u8; 4] { + txt(own([200, 200, 200, 0], |p| p.text_soft)) +} +pub(super) fn muted() -> [u8; 4] { + txt(own([142, 142, 142, 0], |p| p.text_dim)) +} +/// The accent as a text: lighter on a dark panel, darker on a light one. pub(super) fn amber() -> [u8; 4] { - txt(own([255, 200, 110, 0], |p| p.accent.lighten(0.35))) + txt(own([255, 200, 110, 0], |p| if bright(p.panel) { p.accent.darken(0.2) } else { p.accent.lighten(0.35) })) +} + +/// Whether `c` is a light colour (the panel of a light look). +fn bright(c: omsi_ui::Color) -> bool { + c.0[0] + c.0[1] + c.0[2] > 1.5 } /// The ink of a greyed-out line and its small hint. -pub(super) const OFF_INK: [u8; 4] = [96, 96, 96, 0]; -pub(super) const OFF_HINT: [u8; 4] = [96, 96, 96, 0]; +pub(super) fn off_ink() -> [u8; 4] { + txt(own([96, 96, 96, 0], |p| p.text_faint)) +} +pub(super) fn off_hint() -> [u8; 4] { + self::off_ink() +} /// The card's radius, a line's, and the inset of lines from the card's edge and of their /// text from the line's edge (all times the scale). @@ -217,7 +255,7 @@ impl Ui { let t0 = track[1] + (th - len) * (first as f32 / (n - shown as f32).max(1.0)).clamp(0.0, 1.0); let thumb = [track[0], t0, track[2], t0 + len]; let a = self.easeq((16, "thumb", track[0] as usize), if hot { 1.0 } else { 0.0 }, 1.0 / FADE_SECS); - self.text.rounded(r, scene, thumb, 2.0 * s, mix(THUMB, THUMB_HOT, a)); + self.text.rounded(r, scene, thumb, 2.0 * s, mix(self::thumb(), self::thumb_hot(), a)); thumb } @@ -239,9 +277,9 @@ impl Ui { let eyebrow = if sub.is_empty() { "OPENOMSI".to_string() } else { sub.to_uppercase() }; let room = w - (PAD + TEXT_IN) * 2.0 * s; let eyebrow = clip_bold(&self.text, &eyebrow, 11.0 * s, room); - let e = self.text.label(r, scene, &eyebrow, (11.0 * s) as u32 | BOLD, DIM); + let e = self.text.label(r, scene, &eyebrow, (11.0 * s) as u32 | BOLD, self::dim()); let title = clip_bold(&self.text, title, 22.0 * s, room); - let t = self.text.label(r, scene, &title, (22.0 * s) as u32 | BOLD, WHITE); + let t = self.text.label(r, scene, &title, (22.0 * s) as u32 | BOLD, self::white()); let band = header_h - 6.0 * s; let top = y + (band - (e.h as f32 + t.h as f32 - 2.0 * s)) * 0.5; scene.overlays.push((e.tex, [left, top, left + e.w as f32, top + e.h as f32])); @@ -258,7 +296,7 @@ impl Ui { let x0 = right - cw; if cap { // (a key cap: a fine light edge round it) - self.text.rounded(r, scene, [x0 - 1.0, cy - ch * 0.5 - 1.0, right + 1.0, cy + ch * 0.5 + 1.0], 5.0 * s, [96, 96, 96, 255]); + self.text.rounded(r, scene, [x0 - 1.0, cy - ch * 0.5 - 1.0, right + 1.0, cy + ch * 0.5 + 1.0], 5.0 * s, own([96, 96, 96, 255], |p| p.text_faint)); } self.text.rounded(r, scene, [x0, cy - ch * 0.5, right, cy + ch * 0.5], if cap { 4.0 * s } else { ROW_R * s }, fill); let (tx, ty) = (x0 + 9.0 * s, cy - l.h as f32 * 0.5); @@ -367,7 +405,7 @@ impl Ui { let radius = CARD_R * s; // the card: a soft shadow, a 1 px hairline round it, the flat panel self.text.shadow(r, scene, [x, y, x + w, y + h], radius, 28.0 * s, 10.0 * s, 110); - self.text.rounded(r, scene, [x - 1.0, y - 1.0, x + w + 1.0, y + h + 1.0], radius + 1.0, BORDER); + self.text.rounded(r, scene, [x - 1.0, y - 1.0, x + w + 1.0, y + h + 1.0], radius + 1.0, self::border()); self.text.rounded(r, scene, [x, y, x + w, y + h], radius, self::panel()); // the header: what the list is of (or the game's name), and the title let (title, sub): (String, String) = match f.menu_head.as_ref() { @@ -446,22 +484,22 @@ impl Ui { let a_act = self.easeq((14, id, k), if active { 1.0 } else { 0.0 }, 1.0 / FADE_SECS); let bar = self.easeq((8, id, k), if lit || active { 1.0 } else { 0.0 }, 1.0 / BAR_SECS); if a_act > 0.0 { - self.text.rounded(r, scene, rect, ROW_R * s, fade(SELECTED, a_act)); + self.text.rounded(r, scene, rect, ROW_R * s, fade(self::selected(), a_act)); } if glow > 0.0 { - self.text.rounded(r, scene, rect, ROW_R * s, fade(if danger { LIT_DANGER } else { LIT }, glow)); + self.text.rounded(r, scene, rect, ROW_R * s, fade(if danger { self::lit_danger() } else { self::lit() }, glow)); } if bar > 0.0 { // the accent bar at the left edge of the lit line, growing from its middle self.accent_bar(r, scene, rect, bar, danger, s); } let ink = if off { - OFF_INK + self::off_ink() } else { if danger { - mix([232, 138, 128, 0], [255, 176, 166, 0], glow) + mix(txt(own([232, 138, 128, 0], |p| p.danger)), txt(own([255, 176, 166, 0], |p| p.danger_2)), glow) } else { - mix(SOFT, WHITE, glow.max(a_act)) + mix(self::soft(), self::white(), glow.max(a_act)) } }; let cy = (rect[1] + rect[3]) * 0.5; @@ -484,7 +522,7 @@ impl Ui { let d = 22.0 * s; self.text.rounded(r, scene, [rx - d, cy - d * 0.5, rx, cy + d * 0.5], d * 0.5, fade(self::accent(), 0.10 + 0.30 * glow)); let aw = self.text.width("›", (px + 2) as f32); - self.put(r, scene, "›", px + 2, mix(MUTED, self::accent_hot(), glow), rx - d * 0.5 - aw * 0.5, cy - 1.0 * s); + self.put(r, scene, "›", px + 2, mix(self::muted(), self::accent_hot(), glow), rx - d * 0.5 - aw * 0.5, cy - 1.0 * s); let info = clip_to(&self.text, info, px as f32, rx - d - 12.0 * s - tx); self.put(r, scene, &info, px, ink, tx, cy); done = true; @@ -492,7 +530,7 @@ impl Ui { } MenuKind::List if code_w > 0.0 => { if let Some((code, name)) = code_and_name(label) { - self.put_right(r, scene, code, px, mix(MUTED, self::accent_hot(), glow), lx + code_w, cy); + self.put_right(r, scene, code, px, mix(self::muted(), self::accent_hot(), glow), lx + code_w, cy); let nx = lx + code_w + 12.0 * s; let name = clip_to(&self.text, name, px as f32, rx - nx); self.put(r, scene, &name, px, ink, nx, cy); @@ -516,23 +554,23 @@ impl Ui { if is_back { let plain = matches!(kind, MenuKind::Lines | MenuKind::Tours); if plain { - self.text.rounded(r, scene, rect, ROW_R * s, fade(LIT, 0.55)); + self.text.rounded(r, scene, rect, ROW_R * s, fade(self::lit(), 0.55)); } - let idle = if plain { SOFT } else { MUTED }; - self.put(r, scene, &format!("‹ {text}"), px, if lit { WHITE } else { idle }, lx, cy); + let idle = if plain { self::soft() } else { self::muted() }; + self.put(r, scene, &format!("‹ {text}"), px, if lit { self::white() } else { idle }, lx, cy); } else { if off { // (why the line cannot be chosen) - let cw = self.put_right(r, scene, "No active route", (12.0 * s) as u32, OFF_HINT, rx, cy); + let cw = self.put_right(r, scene, "No active route", (12.0 * s) as u32, self::off_hint(), rx, cy); avail -= cw + 10.0 * s; } if more { // (the line opens another list) - let cw = self.put_right(r, scene, "›", px + 6, if lit { WHITE } else { MUTED }, rx, cy); + let cw = self.put_right(r, scene, "›", px + 6, if lit { self::white() } else { self::muted() }, rx, cy); avail -= cw + 10.0 * s; } else if id == "resume" && keys { // ("Resume" shows the key that does the same) - let left = self.chip(r, scene, "Esc", (12.0 * s) as u32, WHITE, [52, 52, 52, 255], true, rx, cy, s); + let left = self.chip(r, scene, "Esc", (12.0 * s) as u32, self::white(), own([52, 52, 52, 255], |p| p.surface_3), true, rx, cy, s); avail = left - 10.0 * s - lx; } let text = clip_to(&self.text, text, px as f32, avail); @@ -548,12 +586,12 @@ impl Ui { let fr = [x + pad, y + h - pad - 36.0 * s, right, y + h - pad]; let lit = over(fr) || (sel == bk && f.menu_kbd && !any_hovered); let glow = self.easeq((7, "back", bk), if lit { 1.0 } else { 0.0 }, 1.0 / FADE_SECS); - self.text.rounded(r, scene, fr, ROW_R * s, fade(LIT, 0.55 + 0.45 * glow)); + self.text.rounded(r, scene, fr, ROW_R * s, fade(self::lit(), 0.55 + 0.45 * glow)); if glow > 0.0 { self.accent_bar(r, scene, fr, glow, false, s); } let (text, _) = strip_more(back_txt.as_str()); - self.put(r, scene, &format!("‹ {text}"), px, mix(SOFT, WHITE, glow), fr[0] + tin, (fr[1] + fr[3]) * 0.5); + self.put(r, scene, &format!("‹ {text}"), px, mix(self::soft(), self::white(), glow), fr[0] + tin, (fr[1] + fr[3]) * 0.5); while self.menu_rects.len() < bk - start { self.menu_rects.push([-1.0e9; 4]); } @@ -577,16 +615,16 @@ impl Ui { let over = |rect: [f32; 4]| f.cursor.0 >= rect[0] && f.cursor.0 <= rect[2] && f.cursor.1 >= rect[1] && f.cursor.1 <= rect[3]; let (px0, py0) = (list_r + 4.0 * s, y + header_h); let (px1, py1) = (x + w - pad, y + h - pad); - self.text.rounded(r, scene, [px0 - 1.0, py0 - 1.0, px1 + 1.0, py1 + 1.0], CARD_R * s + 1.0, BORDER); + self.text.rounded(r, scene, [px0 - 1.0, py0 - 1.0, px1 + 1.0, py1 + 1.0], CARD_R * s + 1.0, self::border()); self.text.rounded(r, scene, [px0, py0, px1, py1], CARD_R * s, self::panel_alt()); let pad = tin; let inner = px1 - px0 - pad * 2.0; let mut cy = py0 + 28.0 * s; let head = clip_to(&self.text, &p.title, 16.0 * s, inner); - self.put(r, scene, &head, (16.0 * s) as u32, WHITE, px0 + pad, cy); + self.put(r, scene, &head, (16.0 * s) as u32, self::white(), px0 + pad, cy); cy += 22.0 * s; let meta = clip_to(&self.text, &p.meta, 11.0 * s, inner); - self.put(r, scene, &meta, (11.0 * s) as u32, MUTED, px0 + pad, cy); + self.put(r, scene, &meta, (11.0 * s) as u32, self::muted(), px0 + pad, cy); cy += 18.0 * s; scene.overlays.push((sep, [px0 + pad, cy.round(), px1 - pad, cy.round() + 1.0])); let rpx = (13.0 * s) as u32; @@ -603,7 +641,7 @@ impl Ui { for j in 0..2usize { let rect = if j == 0 { [left, by, left + bw, by + bh] } else { [right - bw, by, right, by + bh] }; let a = self.easeq((14, "time", j + base), if over(rect) { 1.0 } else { 0.0 }, 1.0 / FADE_SECS); - self.text.rounded(r, scene, rect, ROW_R * s, mix([232, 160, 48, 60], self::accent(), a * 0.7)); + self.text.rounded(r, scene, rect, ROW_R * s, mix(fade(self::accent(), 60.0 / 255.0), self::accent(), a * 0.7)); let col = mix(self::accent_hot(), [18, 14, 8, 255], a); let (cx, cy) = ((rect[0] + rect[2]) * 0.5, (rect[1] + rect[3]) * 0.5); // (a real arrow: a shaft and a head of stacked strips) @@ -626,7 +664,7 @@ impl Ui { let mid_r = right - bw - 10.0 * s; let time = clip_to(&self.text, time, fs * s, mid_r - mid_l); let tw = self.text.width(&time, fs * s); - self.put(r, scene, &time, (fs * s) as u32, if base == 0 { self::amber() } else { WHITE }, mid_l + (mid_r - mid_l - tw) * 0.5, by + bh * 0.5); + self.put(r, scene, &time, (fs * s) as u32, if base == 0 { self::amber() } else { self::white() }, mid_l + (mid_r - mid_l - tw) * 0.5, by + bh * 0.5); top = by + bh + 8.0 * s; } let n = p.rows.len(); @@ -658,15 +696,15 @@ impl Ui { let a_on = self.easeq((11, "stop", i), if on { 1.0 } else { 0.0 }, 1.0 / FADE_SECS); let a_hov = self.easeq((12, "stop", i), if hov { 1.0 } else { 0.0 }, 1.0 / FADE_SECS); if a_hov > 0.0 { - self.text.rounded(r, scene, rect, ROW_R * s, fade(LIT, a_hov)); + self.text.rounded(r, scene, rect, ROW_R * s, fade(self::lit(), a_hov)); } if a_on > 0.0 { - self.text.rounded(r, scene, rect, ROW_R * s, fade(SELECTED, a_on)); + self.text.rounded(r, scene, rect, ROW_R * s, fade(self::selected(), a_on)); self.text.rounded(r, scene, [rect[0], rect[1] + 7.0 * s, rect[0] + 2.0 * s, rect[3] - 7.0 * s], 1.0 * s, fade(self::accent(), a_on)); } self.put_right(r, scene, when, rpx, self::amber(), px1 - pad, ry); let what = clip_to(&self.text, what, rpx as f32, inner - time_w - 14.0 * s); - self.put(r, scene, &what, rpx, mix(SOFT, WHITE, a_on), px0 + pad, ry); + self.put(r, scene, &what, rpx, mix(self::soft(), self::white(), a_on), px0 + pad, ry); self.menu_pane.push(rect); } // the button that starts the trip @@ -695,23 +733,27 @@ impl Ui { self.put(r, scene, num.trim(), rpx, txt(self::accent()), px0 + pad + (tile_w - nw) * 0.5, ry); let dx = px0 + pad + tile_w + 10.0 * s; let dest = clip_to(&self.text, dest, rpx as f32, px1 - pad - time_w - 14.0 * s - dx); - self.put(r, scene, &dest, rpx, SOFT, dx, ry); + self.put(r, scene, &dest, rpx, self::soft(), dx, ry); } else { let what = clip_to(&self.text, what, rpx as f32, inner - time_w - 14.0 * s); - self.put(r, scene, &what, rpx, SOFT, px0 + pad, ry); + self.put(r, scene, &what, rpx, self::soft(), px0 + pad, ry); } } if n > shown { let ry = top + lh * shown as f32 + lh * 0.5; - self.put(r, scene, &omsi_ui::tr("+{} more").replacen("{}", &(n - shown).to_string(), 1), (12.0 * s) as u32, MUTED, px0 + pad, ry); + self.put(r, scene, &omsi_ui::tr("+{} more").replacen("{}", &(n - shown).to_string(), 1), (12.0 * s) as u32, self::muted(), px0 + pad, ry); } } } } /// The launcher's rail and its separator line (`launcher/theme.rs` `RAIL`, `EDGE`). -pub(super) const RAIL: [u8; 4] = [18, 18, 18, 255]; -pub(super) const EDGE: [u8; 4] = [255, 255, 255, 15]; +pub(super) fn rail() -> [u8; 4] { + own([18, 18, 18, 255], |p| p.rail) +} +pub(super) fn edge() -> [u8; 4] { + own([255, 255, 255, 15], |p| p.edge) +} /// The Material icon of a line of the game menu (the launcher's icon set). pub(super) fn game_menu_icon(id: &str) -> &'static str { @@ -754,12 +796,12 @@ impl Ui { let rail_w = (264.0 * s + cut).min(f.width * 0.86).round(); // the rail: flat, a hairline at its edge, a soft shadow over the picture self.text.shadow(r, scene, [-40.0 * s, -40.0 * s, rail_w, f.height + 40.0 * s], 0.0, 30.0 * s, 0.0, 120); - self.text.rounded(r, scene, [0.0, 0.0, rail_w, f.height], 0.0, RAIL); - self.text.rounded(r, scene, [rail_w - 1.0, 0.0, rail_w, f.height], 0.0, EDGE); + self.text.rounded(r, scene, [0.0, 0.0, rail_w, f.height], 0.0, self::rail()); + self.text.rounded(r, scene, [rail_w - 1.0, 0.0, rail_w, f.height], 0.0, self::edge()); // the game's name and version, as at the top of the launcher's rail let bx = 24.0 * s + cut; - let name_w = self.put(r, scene, "openOMSI", (20.0 * s) as u32 | BOLD, WHITE, bx, 34.0 * s); - self.put(r, scene, crate::startup::VERSION, (12.0 * s) as u32, MUTED, bx, 54.0 * s); + let name_w = self.put(r, scene, "openOMSI", (20.0 * s) as u32 | BOLD, self::white(), bx, 34.0 * s); + self.put(r, scene, crate::startup::VERSION, (12.0 * s) as u32, self::muted(), bx, 54.0 * s); // what state the game is in, a small amber tag beside the name let tag = if f.paused { "PAUSED" } else { "MENU" }; let tl = self.text.label(r, scene, tag, (10.0 * s) as u32 | BOLD, txt(self::accent())); @@ -809,7 +851,7 @@ impl Ui { // a hairline between the groups: the everyday lines, saving, the way out if k > start && matches!(id, "save" | "admin" | "quit") { let sy = (ry - 2.0 * s).round(); - self.text.rounded(r, scene, [x0 + 8.0 * s, sy, x1 - 8.0 * s, sy + 1.0], 0.0, EDGE); + self.text.rounded(r, scene, [x0 + 8.0 * s, sy, x1 - 8.0 * s, sy + 1.0], 0.0, self::edge()); } let glow = self.easeq((7, id, k), if lit { 1.0 } else { 0.0 }, 1.0 / FADE_SECS); let cy = (rect[1] + rect[3]) * 0.5; @@ -819,16 +861,16 @@ impl Ui { ([18, 14, 8, 0], [18, 14, 8, 255]) } else { if glow > 0.0 { - let fill = if danger { LIT_DANGER } else if chosen { SELECTED } else { LIT }; + let fill = if danger { self::lit_danger() } else if chosen { self::selected() } else { self::lit() }; self.text.rounded(r, scene, rect, 6.0 * s, fade(fill, glow)); self.accent_bar(r, scene, rect, glow, danger, s); } let c = if off { - OFF_INK + self::off_ink() } else if danger { - mix([222, 110, 100, 0], [255, 176, 166, 0], glow) + mix(txt(own([222, 110, 100, 0], |p| p.danger)), txt(own([255, 176, 166, 0], |p| p.danger_2)), glow) } else { - mix(MUTED, WHITE, glow) + mix(self::muted(), self::white(), glow) }; (c, [c[0], c[1], c[2], 255]) }; @@ -850,7 +892,7 @@ impl Ui { let left = self.chip(r, scene, "Esc", (11.0 * s) as u32, [18, 14, 8, 0], [255, 214, 150, 255], false, right + 4.0 * s, cy, s); right = left - 6.0 * s; } else if off { - let cw = self.put_right(r, scene, "No active route", (11.0 * s) as u32, OFF_HINT, right, cy); + let cw = self.put_right(r, scene, "No active route", (11.0 * s) as u32, self::off_hint(), right, cy); right -= cw + 8.0 * s; } let size = if primary { (14.0 * s).round() as u32 | BOLD } else { px }; @@ -862,7 +904,7 @@ impl Ui { if keys { let hint = omsi_ui::tr("Esc resumes | P pauses").into_owned(); let hint = clip_to(&self.text, &hint, 11.0 * s, rail_w - bx * 2.0); - self.put(r, scene, &hint, (11.0 * s) as u32, OFF_INK, bx, f.height - 20.0 * s); + self.put(r, scene, &hint, (11.0 * s) as u32, self::off_ink(), bx, f.height - 20.0 * s); } } } @@ -912,7 +954,7 @@ impl Ui { let y = ((f.height - h) * 0.5).round(); let radius = CARD_R * s; self.text.shadow(r, scene, [x, y, x + w, y + h], radius, 28.0 * s, 10.0 * s, 110); - self.text.rounded(r, scene, [x - 1.0, y - 1.0, x + w + 1.0, y + h + 1.0], radius + 1.0, BORDER); + self.text.rounded(r, scene, [x - 1.0, y - 1.0, x + w + 1.0, y + h + 1.0], radius + 1.0, self::border()); self.text.rounded(r, scene, [x, y, x + w, y + h], radius, self::panel()); // the header: the game's name small, the title large let (title, sub): (String, String) = match f.menu_head.as_ref() { @@ -924,7 +966,7 @@ impl Ui { // the sidebar: the pages, and the way back at its foot if side_w > 0.0 { let (sx0, sx1) = (x + pad, x + side_w); - self.text.rounded(r, scene, [sx0 - 1.0, y + header_h - 1.0, sx1 + 1.0, y + h - pad + 1.0], CARD_R * s + 1.0, BORDER); + self.text.rounded(r, scene, [sx0 - 1.0, y + header_h - 1.0, sx1 + 1.0, y + h - pad + 1.0], CARD_R * s + 1.0, self::border()); self.text.rounded(r, scene, [sx0, y + header_h, sx1, y + h - pad], CARD_R * s, self::panel_alt()); let inset = 6.0 * s; let bottom = y + h - pad - inset; @@ -946,15 +988,15 @@ impl Ui { let a_hov = self.easeq((2, title.as_str(), 0), if hov { 1.0 } else { 0.0 }, 1.0 / FADE_SECS); let a_bar = self.easeq((9, title.as_str(), 0), if on { 1.0 } else { 0.0 }, 1.0 / BAR_SECS); if a_hov > 0.0 { - self.text.rounded(r, scene, rect, ROW_R * s, fade(LIT, a_hov)); + self.text.rounded(r, scene, rect, ROW_R * s, fade(self::lit(), a_hov)); } if a_on > 0.0 { - self.text.rounded(r, scene, rect, ROW_R * s, fade(SELECTED, a_on)); + self.text.rounded(r, scene, rect, ROW_R * s, fade(self::selected(), a_on)); } if a_bar > 0.0 { self.accent_bar(r, scene, rect, a_bar, false, s); } - let ink = mix(mix(MUTED, SOFT, a_hov), WHITE, a_on); + let ink = mix(mix(self::muted(), self::soft(), a_hov), self::white(), a_on); let text = clip_to(&self.text, title, spx as f32, rect[2] - rect[0] - tin * 2.0); self.put(r, scene, &text, spx, ink, rect[0] + tin, (rect[1] + rect[3]) * 0.5); self.menu_side.push(rect); @@ -962,10 +1004,10 @@ impl Ui { let rect = [sx0 + inset, bottom - 38.0 * s, sx1 - inset, bottom]; let a_back = self.easeq((3, "back", 0), if over(rect) { 1.0 } else { 0.0 }, 1.0 / FADE_SECS); if a_back > 0.0 { - self.text.rounded(r, scene, rect, ROW_R * s, fade(LIT, a_back)); + self.text.rounded(r, scene, rect, ROW_R * s, fade(self::lit(), a_back)); } let back = format!("‹ {}", omsi_ui::tr("Back")); - self.put(r, scene, &back, spx, mix(MUTED, WHITE, a_back), rect[0] + tin, (rect[1] + rect[3]) * 0.5); + self.put(r, scene, &back, spx, mix(self::muted(), self::white(), a_back), rect[0] + tin, (rect[1] + rect[3]) * 0.5); self.menu_side.push(rect); } // the scroll bar of a long page @@ -995,7 +1037,7 @@ impl Ui { let a = self.easeq((4, id, k), if lit { 1.0 } else { 0.0 }, 1.0 / FADE_SECS); let a_bar = self.easeq((10, id, k), if lit { 1.0 } else { 0.0 }, 1.0 / BAR_SECS); if a > 0.0 { - self.text.rounded(r, scene, rect, ROW_R * s, fade(LIT, a)); + self.text.rounded(r, scene, rect, ROW_R * s, fade(self::lit(), a)); } if a_bar > 0.0 { self.accent_bar(r, scene, rect, a_bar, false, s); @@ -1005,7 +1047,7 @@ impl Ui { scene.overlays.push((sep, [rect[0] + tin, sy, rect[2] - tin, sy + 1.0])); } prev_a = a; - let ink = mix(SOFT, WHITE, a); + let ink = mix(self::soft(), self::white(), a); let cy = (rect[1] + rect[3]) * 0.5; let nx = rect[0] + tin; let rx = rect[2] - tin; @@ -1028,7 +1070,7 @@ impl Ui { // (the knob slides over and the track changes its colour) let t = self.ease((5, id, k), if on { 1.0 } else { 0.0 }, 1.0 / FADE_SECS); let tq = quant(t); - self.text.rounded(r, scene, track, th * 0.5, mix(TRACK_OFF, self::accent(), tq)); + self.text.rounded(r, scene, track, th * 0.5, mix(self::track_off(), self::accent(), tq)); let kn = th - 6.0 * s; let kx = tx + 3.0 * s + (tw - kn - 6.0 * s) * t; self.text.rounded(r, scene, [kx, cy - kn * 0.5, kx + kn, cy + kn * 0.5], kn * 0.5, KNOB); @@ -1037,12 +1079,12 @@ impl Ui { // a slider: the track with its knob, the value right of it "v" => { let vw = 64.0 * s; - self.put_right(r, scene, value, (14.0 * s) as u32, mix(SOFT, WHITE, a), rx, cy); + self.put_right(r, scene, value, (14.0 * s) as u32, mix(self::soft(), self::white(), a), rx, cy); let tw = (200.0 * s).min((rx - nx) * 0.45); let x1 = rx - vw - 10.0 * s; let x0 = x1 - tw; let th = 4.0 * s; - self.text.rounded(r, scene, [x0, cy - th * 0.5, x1, cy + th * 0.5], th * 0.5, SLIDER_TRACK); + self.text.rounded(r, scene, [x0, cy - th * 0.5, x1, cy + th * 0.5], th * 0.5, self::slider_track()); // (the knob glides to its place and grows a little under the mouse) let fr = self.ease((6, id, k), frac.unwrap_or(0.0).clamp(0.0, 1.0), 2.0 / FADE_SECS); let fx = x0 + tw * fr; @@ -1057,23 +1099,23 @@ impl Ui { // a stepper: the value between two arrows, the left half steps back "c" => { let text = format!("‹ {value} ›"); - let l = self.chip(r, scene, &text, (13.0 * s) as u32, mix(SOFT, WHITE, a), mix(CHIP, SELECTED, a), false, rx, cy, s); + let l = self.chip(r, scene, &text, (13.0 * s) as u32, mix(self::soft(), self::white(), a), mix(self::chip_fill(), self::selected(), a), false, rx, cy, s); ctl = Some([l, rect[1], rx, rect[3]]); l } // opens another list "o" => { - let cw = self.put_right(r, scene, "›", px + 6, mix(MUTED, WHITE, a), rx, cy); + let cw = self.put_right(r, scene, "›", px + 6, mix(self::muted(), self::white(), a), rx, cy); if value.is_empty() { rx - cw } else { // (the value now, as the stepper showed it) - let vw = self.put_right(r, scene, value, (14.0 * s) as u32, SOFT, rx - cw - 8.0 * s, cy); + let vw = self.put_right(r, scene, value, (14.0 * s) as u32, self::soft(), rx - cw - 8.0 * s, cy); rx - cw - 8.0 * s - vw } } "i" => { - let cw = self.put_right(r, scene, value, (14.0 * s) as u32, SOFT, rx, cy); + let cw = self.put_right(r, scene, value, (14.0 * s) as u32, self::soft(), rx, cy); rx - cw } // a value being typed: lit in the accent @@ -1083,7 +1125,7 @@ impl Ui { if value.is_empty() { rx } else { - self.chip(r, scene, value, (13.0 * s) as u32, mix(SOFT, self::amber(), a), mix(CHIP, self::accent_soft(), a), false, rx, cy, s) + self.chip(r, scene, value, (13.0 * s) as u32, mix(self::soft(), self::amber(), a), mix(self::chip_fill(), self::accent_soft(), a), false, rx, cy, s) } } }; @@ -1096,7 +1138,7 @@ impl Ui { self.put(r, scene, &n, px, ink, nx, cy - 9.0 * s); let dpx = (12.0 * s) as u32; let d = clip_to(&self.text, desc, dpx as f32, avail); - self.put(r, scene, &d, dpx, MUTED, nx, cy + 10.0 * s); + self.put(r, scene, &d, dpx, self::muted(), nx, cy + 10.0 * s); } if f.menu_peek == Some(k) { peek = Some((row_from..scene.overlays.len(), rect)); @@ -1110,7 +1152,7 @@ impl Ui { let m = 6.0 * s; let card = [rect[0] - m, rect[1] - m, rect[2] + m, rect[3] + m]; self.text.shadow(r, scene, card, CARD_R * s, 18.0 * s, 6.0 * s, 110); - self.text.rounded(r, scene, [card[0] - 1.0, card[1] - 1.0, card[2] + 1.0, card[3] + 1.0], CARD_R * s + 1.0, BORDER); + self.text.rounded(r, scene, [card[0] - 1.0, card[1] - 1.0, card[2] + 1.0, card[3] + 1.0], CARD_R * s + 1.0, self::border()); self.text.rounded(r, scene, card, CARD_R * s, self::panel()); scene.overlays.extend(line); return; @@ -1139,7 +1181,7 @@ impl Ui { let rad = (CARD_R * s).min(10.0 * s); self.text.shadow(r, scene, panel, rad, 18.0 * s, 6.0 * s, 150); // (the launcher's popup: the field's grey, a hairline round it) - self.text.rounded(r, scene, [panel[0] - 1.0, panel[1] - 1.0, panel[2] + 1.0, panel[3] + 1.0], rad + 1.0, BORDER); + self.text.rounded(r, scene, [panel[0] - 1.0, panel[1] - 1.0, panel[2] + 1.0, panel[3] + 1.0], rad + 1.0, self::border()); self.text.rounded(r, scene, panel, rad, self::panel_alt()); let more = dd.items.len() > n_vis; let dpx = (14.0 * s) as u32; @@ -1151,16 +1193,16 @@ impl Ui { let hot = over(rect) || (idx == dd.sel && f.menu_kbd && !over(panel)); let a = self.easeq((15, "dropdown", idx), if hot { 1.0 } else { 0.0 }, 1.0 / FADE_SECS); if cur { - self.text.rounded(r, scene, rect, ROW_R * s, LIT); + self.text.rounded(r, scene, rect, ROW_R * s, self::lit()); } if a > 0.0 { - self.text.rounded(r, scene, rect, ROW_R * s, fade(SELECTED, a)); + self.text.rounded(r, scene, rect, ROW_R * s, fade(self::selected(), a)); } if cur { self.accent_bar(r, scene, rect, 1.0, false, s); } let text = clip_to(&self.text, dd.items[idx], dpx as f32, rect[2] - rect[0] - tin * 2.0); - self.put(r, scene, &text, dpx, if cur { WHITE } else { mix(SOFT, WHITE, a) }, rect[0] + tin, (rect[1] + rect[3]) * 0.5); + self.put(r, scene, &text, dpx, if cur { self::white() } else { mix(self::soft(), self::white(), a) }, rect[0] + tin, (rect[1] + rect[3]) * 0.5); self.dd_rects.push(rect); } if more { @@ -1171,3 +1213,47 @@ impl Ui { } } } + +#[cfg(test)] +mod tests { + use super::*; + use omsi_ui::theme::{with_palette, Palette}; + + /// How far apart two colours are in brightness (0 to 255). + fn apart(a: [u8; 4], b: [u8; 4]) -> i32 { + let l = |c: [u8; 4]| (c[0] as i32 + c[1] as i32 + c[2] as i32) / 3; + (l(a) - l(b)).abs() + } + + /// openOMSI's own look is drawn with the colours it always had. + #[test] + fn the_own_look_keeps_its_colours() { + with_palette(Palette::OPENOMSI, || { + assert_eq!(self::white(), [236, 236, 236, 0]); + assert_eq!(self::selected(), [44, 44, 44, 255]); + assert_eq!(self::rail(), [18, 18, 18, 255]); + assert_eq!(self::amber(), [255, 200, 110, 0]); + }); + } + + /// In Light, and in a theme over it that keeps its panels, the title, the lines and the + /// chosen line stand out from what they are drawn on (they were near-white on near-white). + #[test] + fn the_inks_stand_out_in_the_light_look() { + let mut pack = Palette::LIGHT; + pack.accent = omsi_ui::Color::rgba(40, 120, 200, 1.0); + for p in [Palette::LIGHT, pack] { + with_palette(p, || { + for ground in [self::panel(), self::panel_alt(), self::rail(), self::lit(), self::selected()] { + assert!(apart(self::white(), ground) > 150, "title on {ground:?}"); + assert!(apart(self::soft(), ground) > 100, "line on {ground:?}"); + assert!(apart(self::muted(), ground) > 70, "hint on {ground:?}"); + } + assert!(apart(self::amber(), self::panel()) > 70); + assert!(apart(self::off_ink(), self::panel()) > 50); + assert_ne!(self::selected(), self::panel()); + assert_ne!(self::lit(), self::panel()); + }); + } + } +}