diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ec704056..aa9c5ba81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,6 +137,18 @@ promoted to stable releases (see [docs/VERSIONING.md](docs/VERSIONING.md)); the (`ailists_#user.cfg` and `car_use/!openomsi-ai-lists-*.ocu` of the map's folder, the format OMSI itself reads), a group or a line can be reset on its own, and *Reset the map* deletes them, which gives the map's defaults back. Translated into every language of the launcher. +- **Interface themes**: a mod or a plugin can give the game's own screens a look of their own, + chosen in Options › Interface › Theme pack ([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 on the graphics card every frame the menu is open); 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 - 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` 7). - **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/locales/app.yml b/crates/omsi-app/locales/app.yml index 9d96609cb..b304bbc82 100644 --- a/crates/omsi-app/locales/app.yml +++ b/crates/omsi-app/locales/app.yml @@ -52894,6 +52894,66 @@ _version: 2 sv: "Hastigheter, avstånd och temperaturer i spelets texter" sr: "Brzine, udaljenosti i temperature u sopstvenim tekstovima igre" ka: "სიჩქარეები, მანძილები და ტემპერატურები თამაშის ტექსტებში" +"Theme pack": + ca: "Paquet de tema" + ru: "Пакет оформления" + de: "Designpaket" + fr: "Pack de thème" + uk: "Пакет оформлення" + be: "Пакет афармлення" + kk: "Безендіру жинағы" + pl: "Pakiet motywu" + cs: "Balíček motivu" + hu: "Témacsomag" + es: "Paquete de tema" + pt: "Pacote de tema" + pt-pt: "Pacote de tema" + it: "Pacchetto tema" + nl: "Themapakket" + tr: "Tema paketi" + ja: "テーマパック" + zh: "主题包" + hi: "थीम पैक" + zh-tw: "主題包" + ko: "테마 팩" + th: "แพ็กธีม" + vi: "Gói giao diện" + id: "Paket tema" + ms: "Pek tema" + tl: "Theme pack" + sv: "Temapaket" + sr: "Paket teme" + 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/app.rs b/crates/omsi-app/src/app.rs index 8ac41d7d2..7a0e9b320 100644 --- a/crates/omsi-app/src/app.rs +++ b/crates/omsi-app/src/app.rs @@ -834,6 +834,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_pack, self.settings.is_light_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_events/frame/integrations.rs b/crates/omsi-app/src/app_events/frame/integrations.rs index 556a626d4..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); @@ -54,6 +58,16 @@ 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() { + 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); + } + } 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); @@ -101,7 +115,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_pack) { + 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_pack"] = 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()); @@ -143,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-app/src/app_events/frame/interface.rs b/crates/omsi-app/src/app_events/frame/interface.rs index 2e3e33123..c23e4cf9c 100644 --- a/crates/omsi-app/src/app_events/frame/interface.rs +++ b/crates/omsi-app/src/app_events/frame/interface.rs @@ -400,7 +400,7 @@ impl App { cursor: self.input.cursor, dt, backdrop: ui::backdrop(settings.ui_opacity), - light: settings.is_light_theme(), + palette: crate::themes::palette(settings.is_light_theme()), navigator: self.menus.navigator.as_ref().and_then(|n| n.screen_rect()), dock: self.menus.navigator.as_ref().and_then(|n| n.dock_slot).map(|(r, scale, placed, frame)| crate::plugin_ui::Dock { width: r[2], room: r[3], scale, at: placed.then(|| glam::Vec2::new(r[0], r[1])), frame }), }; diff --git a/crates/omsi-app/src/app_events/frame/present.rs b/crates/omsi-app/src/app_events/frame/present.rs index 2ebfb8d31..d397e4e10 100644 --- a/crates/omsi-app/src/app_events/frame/present.rs +++ b/crates/omsi-app/src/app_events/frame/present.rs @@ -46,6 +46,10 @@ 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); @@ -348,6 +352,17 @@ impl App { lighting, ); } + // 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) + 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); // the on-screen controls over the picture (a phone) diff --git a/crates/omsi-app/src/app_impl/shell_link.rs b/crates/omsi-app/src/app_impl/shell_link.rs index 91396393c..bc4913075 100644 --- a/crates/omsi-app/src/app_impl/shell_link.rs +++ b/crates/omsi-app/src/app_impl/shell_link.rs @@ -96,7 +96,7 @@ impl App { keys: !crate::platform::touch_controls(), summary: self.pause_summary(), }; - self.shell.set_theme(self.settings.is_light_theme()); + crate::themes::sync(&self.settings.ui_theme_pack, self.settings.is_light_theme()); self.shell.begin(w, h, scale, dt); crate::shell::pause::draw(&mut self.shell, &view); self.shell.finish(); diff --git a/crates/omsi-app/src/devtools/mod.rs b/crates/omsi-app/src/devtools/mod.rs index fccf01491..8fce62650 100644 --- a/crates/omsi-app/src/devtools/mod.rs +++ b/crates/omsi-app/src/devtools/mod.rs @@ -339,7 +339,7 @@ impl App { self.devtools = Some(Box::new(d)); } self.shell.opaque = false; - self.shell.set_theme(self.settings.is_light_theme()); + crate::themes::sync(&self.settings.ui_theme_pack, self.settings.is_light_theme()); self.shell.begin(w, h, scale, dt); let size = Vec2::new(w, h) / self.shell.scale; let requests = { diff --git a/crates/omsi-app/src/devtools/panel.rs b/crates/omsi-app/src/devtools/panel.rs index a838f209e..13c4e887d 100644 --- a/crates/omsi-app/src/devtools/panel.rs +++ b/crates/omsi-app/src/devtools/panel.rs @@ -19,17 +19,16 @@ const LISTED: usize = 200; /// The panel; what it asks of the game. pub(crate) fn draw(ui: &mut Ui, d: &mut DevTools, mut p: Option<&mut Player>, size: Vec2) -> Vec { - let theme = ui.theme; let mut out = Vec::new(); 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, theme.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, theme.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); @@ -68,7 +67,6 @@ fn value_of(p: Option<&Player>, name: &str, string: bool) -> String { /// The watch list (click a line to edit it), the chosen variable's value, and the search /// through all of them. fn variables(ui: &mut Ui, d: &mut DevTools, mut p: Option<&mut Player>, r: Rect) { - let theme = ui.theme; let mut y = r.y; ui.heading(Rect::new(r.x, y, r.w, 26.0), "Watched", None); y += 26.0; @@ -79,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, theme.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() { @@ -91,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, theme.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() { theme.ACCENT } else { theme.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); } @@ -154,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, theme.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, theme.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 }); @@ -228,12 +226,11 @@ pub(crate) fn adjust_mirror(p: &mut Player, i: usize, shift: [f32; 3], turn: [f3 } fn mirrors(ui: &mut Ui, d: &mut DevTools, p: Option<&mut Player>, r: Rect, out: &mut Vec) { - let theme = ui.theme; let Some(p) = p else { return }; 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, theme.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); @@ -247,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, theme.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; @@ -286,14 +283,13 @@ 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, theme.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; } } } fn files(ui: &mut Ui, d: &mut DevTools, r: Rect, out: &mut Vec) { - let theme = ui.theme; let mut y = r.y; let auto_reload = omsi_ui::tr("Read a file again when it changes"); ui.toggle("dt-auto", Rect::new(r.x, y, r.w, ROW - 6.0), &mut d.auto_reload, &auto_reload); @@ -319,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, theme.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() { @@ -338,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, theme.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 651f2d860..6ff8069d1 100644 --- a/crates/omsi-app/src/game_lists/dropdowns.rs +++ b/crates/omsi-app/src/game_lists/dropdowns.rs @@ -302,6 +302,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_pack" => crate::themes::choices().iter().map(|(id, name)| (if id.is_empty() { crate::themes::OWN } else { id.as_str() }, name.as_str())).collect(), "ui_theme" => vec![("dark", "Dark"), ("light", "Light theme")], "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")], diff --git a/crates/omsi-app/src/game_lists/menu_pages.rs b/crates/omsi-app/src/game_lists/menu_pages.rs index 96ef6640e..1ec4b8495 100644 --- a/crates/omsi-app/src/game_lists/menu_pages.rs +++ b/crates/omsi-app/src/game_lists/menu_pages.rs @@ -262,6 +262,7 @@ pub(super) fn options_pages(app: &App) -> Vec { pick("language", "Language", "The language of the game's interface"), pick("ui_theme", "Interface theme", "The colours used by the game's interface, map and pause menu"), pick("units", "Units", "Speeds, distances and temperatures in the game's own texts"), + pick("ui_theme_pack", "Theme pack", "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/game_panels/dest_form.rs b/crates/omsi-app/src/game_panels/dest_form.rs index 87552775c..7ae026abc 100644 --- a/crates/omsi-app/src/game_panels/dest_form.rs +++ b/crates/omsi-app/src/game_panels/dest_form.rs @@ -8,6 +8,7 @@ use super::{button, card, grow_row, input, note, text, title}; use omsi_plugin::api::Value; use omsi_plugin::ui::{Anchor, Panel, Weight}; +use omsi_ui::theme::Palette; /// What is typed into the form. #[derive(Debug, Clone, Default, PartialEq)] @@ -18,7 +19,7 @@ pub(crate) struct DestForm { } impl DestForm { - pub(crate) fn panel(&self, light: bool) -> Panel { + pub(crate) fn panel(&self, pal: Palette) -> Panel { let help = self.said.as_deref().map(str::to_owned).unwrap_or_else(|| omsi_ui::tr("A destination of the depot file is found by its name; any other text is shown as it is typed.").into_owned()); let children = vec![ title(&omsi_ui::tr("Destination display")), @@ -30,9 +31,9 @@ impl DestForm { // (the depot file's destinations to pick from, as OMSI's form lists them, #2219) grow_row(vec![button("list", &omsi_ui::tr("Choose from the depot file"), Some("view_list"))]), grow_row(vec![button("clear", &omsi_ui::tr("Clear display"), Some("delete")), button("select", &omsi_ui::tr("Select"), Some("check")), button("close", &omsi_ui::tr("Close"), Some("close"))]), - note(&help, light), + note(&help, pal), ]; - card(Anchor::Center, 0.0, 0.0, 420.0, 236, light, children) + card(Anchor::Center, 0.0, 0.0, 420.0, 236, pal, children) } } @@ -162,7 +163,7 @@ mod tests { assert_eq!(omsi_ui::tr("<1 min"), "<1 分鐘"); assert_eq!(omsi_ui::tr("Nothing to couple: back up to a trailer's coupling (within 2.5 m, in line)"), "沒有可連接的物件:請倒車至拖車掛鉤處(2.5 米以內,成一直線)"); - let panel = format!("{:?}", DestForm::default().panel(false)); + let panel = format!("{:?}", DestForm::default().panel(Palette::OPENOMSI)); for text in ["目的地顯示", "路線編號", "目的地(兩行)", "第一行", "第二行(可留空)", "從車廠檔案選擇", "清除顯示", "選擇", "關閉", "車庫檔中的目的地依名稱尋找;其他文字依輸入內容顯示。"] { assert!(panel.contains(text), "destination form is missing translated text: {text}"); } diff --git a/crates/omsi-app/src/game_panels/mod.rs b/crates/omsi-app/src/game_panels/mod.rs index 17c87245e..cccbd2e68 100644 --- a/crates/omsi-app/src/game_panels/mod.rs +++ b/crates/omsi-app/src/game_panels/mod.rs @@ -10,6 +10,7 @@ //! on a panel goes to it, a click beside the quick menu closes it. use omsi_plugin::ui::{Align, Element, Kind, Panel, Rgba, RowAlign, UiState, Weight}; +use omsi_ui::theme::Palette; mod dest_form; mod move_here; @@ -68,8 +69,8 @@ pub(super) fn title(t: &str) -> Element { text(t, 17.0, Weight::Bold) } -pub(super) fn note(t: &str, light: bool) -> Element { - Element { color: Some(theme_color(theme(light).TEXT_DIM)), ..text(t, 12.0, Weight::Regular) } +pub(super) fn note(t: &str, pal: Palette) -> Element { + Element { color: Some(theme_color(pal.text_dim)), ..text(t, 12.0, Weight::Regular) } } pub(super) fn button(id: &str, t: &str, icon: Option<&str>) -> Element { @@ -105,22 +106,15 @@ pub(super) fn divider() -> Element { el(None, Kind::Divider) } -pub(super) fn theme(light: bool) -> omsi_ui::theme::Theme { - omsi_ui::theme::palette(light) -} - pub(super) fn theme_color(c: omsi_ui::Color) -> Rgba { Rgba(c.0.map(|v| (v * 255.0 + 0.5) as u8)) } -/// 遊戲面板背景;`alpha` 控制透出後方畫面的程度。 -pub(super) fn card(anchor: omsi_plugin::ui::Anchor, x: f32, y: f32, width: f32, alpha: u8, light: bool, children: Vec) -> Panel { - let background = if light { - let c = theme_color(theme(true).PANEL); - Rgba([c.0[0], c.0[1], c.0[2], alpha]) - } else { - Rgba([14, 16, 20, alpha]) - }; +/// The game's card: the colour of the panels (openOMSI's own near black in its dark look), a +/// little see-through over the picture (`alpha`). +pub(super) fn card(anchor: omsi_plugin::ui::Anchor, x: f32, y: f32, width: f32, alpha: u8, pal: Palette, children: Vec) -> Panel { + let [r, g, b, _] = if pal == Palette::OPENOMSI { [14, 16, 20, 255] } else { theme_color(pal.panel).0 }; + let background = Rgba([r, g, b, alpha]); Panel { anchor, x, y, width, padding: 14.0, gap: 8.0, background: Some(background), radius: 12.0, accent: None, visible: true, clickable: false, draggable: false, grab: false, frame: false, children } } @@ -163,7 +157,7 @@ impl crate::App { j.take_summary(); } } - let light = self.settings.is_light_theme(); + let pal = crate::themes::palette(self.settings.is_light_theme()); // (the quick menu's tile under the mouse is the one chosen, its name shown) let gp = &mut self.menus.game_panels; if gp.quick.is_some() && gp.ui.focused() { @@ -175,11 +169,11 @@ impl crate::App { } } let panels: Vec<(&str, Option)> = vec![ - ("view", self.menus.game_panels.view.as_ref().map(|v| v.panel(self, light))), - ("trip", self.menus.game_panels.trip.as_ref().map(|t| t.panel(light))), - ("dest", self.menus.game_panels.dest.as_ref().map(|d| d.panel(light))), + ("view", self.menus.game_panels.view.as_ref().map(|v| v.panel(self, pal))), + ("trip", self.menus.game_panels.trip.as_ref().map(|t| t.panel(pal))), + ("dest", self.menus.game_panels.dest.as_ref().map(|d| d.panel(pal))), ("quick", self.menus.game_panels.quick.as_ref().map(|q| q.panel(self))), - ("move", self.menus.game_panels.move_to.map(|at| move_here::panel(at, light))), + ("move", self.menus.game_panels.move_to.map(|at| move_here::panel(at, pal))), ]; let gp = &mut self.menus.game_panels; for (id, p) in panels { @@ -208,7 +202,7 @@ impl crate::App { cursor: self.input.cursor, dt, backdrop: crate::ui::backdrop(self.settings.ui_opacity), - light, + palette: pal, navigator: None, dock: None, }; diff --git a/crates/omsi-app/src/game_panels/move_here.rs b/crates/omsi-app/src/game_panels/move_here.rs index 4ba060e71..f37b14df2 100644 --- a/crates/omsi-app/src/game_panels/move_here.rs +++ b/crates/omsi-app/src/game_panels/move_here.rs @@ -4,18 +4,19 @@ use super::{button, card, grow_row, note, title}; use omsi_plugin::ui::{Anchor, Panel}; +use omsi_ui::theme::Palette; -pub(crate) fn panel(at: glam::DVec3, light: bool) -> Panel { +pub(crate) fn panel(at: glam::DVec3, pal: Palette) -> Panel { card( Anchor::Center, 0.0, 0.0, 360.0, 236, - light, + pal, vec![ title("Move the vehicle here?"), - note(&format!("x {:.0} y {:.0}", at.x, at.y), light), + note(&format!("x {:.0} y {:.0}", at.x, at.y), pal), grow_row(vec![button("yes", "Move here", Some("near_me")), button("no", "Cancel", Some("close"))]), ], ) diff --git a/crates/omsi-app/src/game_panels/preview.rs b/crates/omsi-app/src/game_panels/preview.rs index 0e905c164..f74b45c4d 100644 --- a/crates/omsi-app/src/game_panels/preview.rs +++ b/crates/omsi-app/src/game_panels/preview.rs @@ -98,13 +98,13 @@ fn summary() -> crate::journey::TripSummary { #[ignore] fn preview() { let state = quick_menu::TileState { off: vec!["map"], lit: vec!["arrows", "duty"] }; - picture("game-panels-quick.png", &[(quick_menu::QuickMenu { focus: 7, ..Default::default() }.panel_of(&state, false), Some("refuel"))]); + picture("game-panels-quick.png", &[(quick_menu::QuickMenu { focus: 7, ..Default::default() }.panel_of(&state, Palette::OPENOMSI), Some("refuel"))]); let page2 = quick_menu::QuickMenu { page: 1, focus: 2, ..Default::default() }; - picture("game-panels-quick-2.png", &[(page2.panel_of(&quick_menu::TileState { off: vec!["skipstop", "endduty", "players", "admin", "tobus"], lit: vec![] }, false), None)]); - picture("game-panels-trip.png", &[(trip_summary::TripPanel::new(summary()).panel(false), Some("save"))]); + picture("game-panels-quick-2.png", &[(page2.panel_of(&quick_menu::TileState { off: vec!["skipstop", "endduty", "players", "admin", "tobus"], lit: vec![] }, Palette::OPENOMSI), None)]); + picture("game-panels-trip.png", &[(trip_summary::TripPanel::new(summary()).panel(Palette::OPENOMSI), Some("save"))]); let form = dest_form::DestForm { route: "137".into(), lines: ["Sonderfahrt".into(), "Betriebshof".into()], ..Default::default() }; - picture("game-panels-dest.png", &[(form.panel(false), Some("select"))]); + picture("game-panels-dest.png", &[(form.panel(Palette::OPENOMSI), Some("select"))]); let values = view_editor::ViewValues { seat: [0.05, -0.12, 0.03], pitch: -4.0, yaw: 6.5, fov: Some(62.0), mirrors: vec![[2.5, -1.0], [-3.0, 0.5]] }; - picture("game-panels-view.png", &[(view_editor::ViewEditor::default().panel_of(&values, false), None)]); - picture("game-panels-move.png", &[(move_here::panel(glam::DVec3::new(-1948.9, -174.7, 8.8), false), Some("yes"))]); + picture("game-panels-view.png", &[(view_editor::ViewEditor::default().panel_of(&values, Palette::OPENOMSI), None)]); + picture("game-panels-move.png", &[(move_here::panel(glam::DVec3::new(-1948.9, -174.7, 8.8), Palette::OPENOMSI), Some("yes"))]); } diff --git a/crates/omsi-app/src/game_panels/quick_menu.rs b/crates/omsi-app/src/game_panels/quick_menu.rs index 7cb13f4eb..17270babe 100644 --- a/crates/omsi-app/src/game_panels/quick_menu.rs +++ b/crates/omsi-app/src/game_panels/quick_menu.rs @@ -13,7 +13,8 @@ //! greyed and takes no clicks. The statistics tile shows the last trip's summary again, or //! the personnel file when there is none. -use super::{card, el, note, text, theme, theme_color}; +use super::{card, el, note, text, theme_color}; +use omsi_ui::theme::Palette; use omsi_plugin::ui::{Align, Anchor, Element, Kind, Panel, Rgba, RowAlign, Weight}; use omsi_ui::tr; @@ -110,7 +111,7 @@ impl TileState { impl QuickMenu { pub(crate) fn panel(&self, app: &crate::App) -> Panel { - self.panel_of(&TileState::of(app), app.settings.is_light_theme()) + self.panel_of(&TileState::of(app), crate::themes::palette(app.settings.is_light_theme())) } /// The tile chosen: its id. @@ -119,8 +120,8 @@ impl QuickMenu { } /// The page shown, with the tiles as `state` has them. - pub(crate) fn panel_of(&self, state: &TileState, light: bool) -> Panel { - let muted = theme_color(theme(light).TEXT_DIM); + pub(crate) fn panel_of(&self, state: &TileState, pal: Palette) -> Panel { + let muted = theme_color(pal.text_dim); let tiles = page_tiles(self.page); let mut children: Vec = tiles .chunks(COLUMNS) @@ -155,8 +156,8 @@ impl QuickMenu { foot.push(el(Some("next"), Kind::Button { text: String::new(), icon: Some("chevron_right".into()) })); } children.push(el(None, Kind::Row { children: foot, gap: 10.0, align: RowAlign::Start })); - children.push(note(&tr("Alt or Esc closes the menu; the game goes on."), light)); - Panel { padding: 14.0, gap: 8.0, ..card(Anchor::BottomRight, 32.0, 32.0, WIDTH, 215, light, children) } + children.push(note(&tr("Alt or Esc closes the menu; the game goes on."), pal)); + Panel { padding: 14.0, gap: 8.0, ..card(Anchor::BottomRight, 32.0, 32.0, WIDTH, 215, pal, children) } } /// The next page (after the last, the first), its first tile chosen. @@ -343,7 +344,7 @@ mod tests { #[test] fn the_panel_shows_the_chosen_tiles_name_and_the_lit_and_greyed_ones() { let q = QuickMenu { focus: 7, ..Default::default() }; - let p = q.panel_of(&TileState { off: vec!["map"], lit: vec!["arrows"] }, false); + let p = q.panel_of(&TileState { off: vec!["map"], lit: vec!["arrows"] }, Palette::OPENOMSI); let tiles: Vec<&Element> = p.children.iter().take(3).flat_map(|r| match &r.kind { Kind::Row { children, .. } => children.iter().collect::>(), _ => vec![], diff --git a/crates/omsi-app/src/game_panels/trip_summary.rs b/crates/omsi-app/src/game_panels/trip_summary.rs index e18577730..4c8cece83 100644 --- a/crates/omsi-app/src/game_panels/trip_summary.rs +++ b/crates/omsi-app/src/game_panels/trip_summary.rs @@ -3,7 +3,8 @@ //! in the Journeys folder beside the journey's log. Settings → Gameplay → "Trip summary" //! turns it off; the quick menu's statistics tile shows the last one again. -use super::{button, card, divider, el, grow_row, note, row, text, theme, theme_color, title}; +use super::{button, card, divider, el, grow_row, note, row, text, theme_color, title}; +use omsi_ui::theme::Palette; use crate::journey::{hms, TripSummary}; use omsi_plugin::ui::{Anchor, Element, Kind, Panel, Rgba, RowAlign, Weight}; use omsi_ui::tr; @@ -12,19 +13,19 @@ fn icon(name: &str, size: f32, color: Rgba) -> Element { Element { color: Some(color), ..el(None, Kind::Icon { name: name.to_string(), size }) } } -fn label(t: &str, light: bool) -> Element { - Element { color: Some(theme_color(theme(light).TEXT_DIM)), ..text(&tr(t), 12.0, Weight::Medium) } +fn label(t: &str, pal: Palette) -> Element { + Element { color: Some(theme_color(pal.text_dim)), ..text(&tr(t), 12.0, Weight::Medium) } } -fn grade(percent: Option, light: bool) -> Rgba { - let colors = theme(light); - let Some(percent) = percent else { return theme_color(colors.TEXT_DIM) }; +fn grade(percent: Option, pal: Palette) -> Rgba { + let colors = pal; + let Some(percent) = percent else { return theme_color(colors.text_dim) }; if percent >= 80.0 { - theme_color(colors.OK) + theme_color(colors.ok) } else if percent >= 50.0 { - theme_color(colors.WARN) + theme_color(colors.warn) } else { - theme_color(colors.DANGER) + theme_color(colors.danger) } } @@ -42,10 +43,10 @@ fn head(lines: &str) -> String { lines.lines().map(line).collect::>().join("\n") } -fn stars(lit: u8, light: bool) -> Element { - let colors = theme(light); - let accent = theme_color(colors.ACCENT); - let unlit = theme_color(colors.TEXT_FAINT); +fn stars(lit: u8, pal: Palette) -> Element { + let colors = pal; + let accent = theme_color(colors.accent); + let unlit = theme_color(colors.text_faint); let children = (0..5).map(|k| icon("star", 18.0, if k < lit { accent } else { unlit })).collect(); el(None, Kind::Row { children, gap: 2.0, align: RowAlign::End }) } @@ -61,13 +62,13 @@ impl TripPanel { TripPanel { summary, said: None } } - pub(crate) fn panel(&self, light: bool) -> Panel { + pub(crate) fn panel(&self, pal: Palette) -> Panel { let s = &self.summary; - let accent = theme_color(theme(light).ACCENT); + let accent = theme_color(pal.accent); let mut children = vec![ - row(vec![icon("directions_bus", 22.0, accent), Element { grow: true, ..title(&tr("Trip summary")) }, stars(s.stars(), light)], RowAlign::Start), + row(vec![icon("directions_bus", 22.0, accent), Element { grow: true, ..title(&tr("Trip summary")) }, stars(s.stars(), pal)], RowAlign::Start), text(&tr(&s.title), 14.0, Weight::Medium), - note(&head(&s.head), light), + note(&head(&s.head), pal), ]; if let Some(mood) = s.mood() { children.push(row(vec![icon("forum", 18.0, accent), text(&tr(mood), 14.0, Weight::Medium)], RowAlign::Start)); @@ -84,17 +85,17 @@ impl TripPanel { tiles.push(("groups", r.passengers.to_string(), "passengers")); } children.push(grow_row(tiles.iter().map(|(i, v, _)| row(vec![icon(i, 18.0, accent), text(v, 17.0, Weight::Bold)], RowAlign::Start)).collect())); - children.push(grow_row(tiles.iter().map(|(_, _, l)| label(l, light)).collect())); + children.push(grow_row(tiles.iter().map(|(_, _, l)| label(l, pal)).collect())); if let Some(r) = s.rating { children.push(divider()); let gauges = [("speed", Some(r.driving), "driving"), ("airport_shuttle", r.comfort_rated(), "comfort"), ("receipt_long", r.tickets_rated(), "ticket selling")]; children.push(columns( gauges .iter() - .map(|(i, p, l)| row(vec![icon(i, 16.0, accent), Element { grow: true, ..label(l, light) }, Element { color: Some(grade(*p, light)), ..text(&p.map(|p| format!("{p:.0} %")).unwrap_or_else(|| "-".into()), 13.0, Weight::Bold) }], RowAlign::Start)) + .map(|(i, p, l)| row(vec![icon(i, 16.0, accent), Element { grow: true, ..label(l, pal) }, Element { color: Some(grade(*p, pal)), ..text(&p.map(|p| format!("{p:.0} %")).unwrap_or_else(|| "-".into()), 13.0, Weight::Bold) }], RowAlign::Start)) .collect(), )); - children.push(columns(gauges.iter().map(|(_, p, _)| Element { color: Some(grade(*p, light)), ..el(None, Kind::Bar { value: p.map_or(0.0, |p| (p / 100.0).clamp(0.0, 1.0) as f32), height: 6.0, background: None, segments: Vec::new() }) }).collect())); + children.push(columns(gauges.iter().map(|(_, p, _)| Element { color: Some(grade(*p, pal)), ..el(None, Kind::Bar { value: p.map_or(0.0, |p| (p / 100.0).clamp(0.0, 1.0) as f32), height: 6.0, background: None, segments: Vec::new() }) }).collect())); } children.push(divider()); let rows: Vec> = s.rows.iter().map(|r| r.to_vec()).collect(); @@ -111,10 +112,10 @@ impl TripPanel { )); children.push(grow_row(vec![button("copy", &tr("Copy"), Some("content_copy")), button("save", &tr("Save"), Some("save")), button("close", &tr("Close"), Some("close"))])); if let Some(said) = &self.said { - children.push(note(said, light)); + children.push(note(said, pal)); } - children.push(row(vec![note(&tr("Settings → Gameplay → Trip summary turns it off."), light)], RowAlign::Start)); - card(Anchor::Center, 0.0, 0.0, 620.0, 236, light, children) + children.push(row(vec![note(&tr("Settings → Gameplay → Trip summary turns it off."), pal)], RowAlign::Start)); + card(Anchor::Center, 0.0, 0.0, 620.0, 236, pal, children) } /// About how much room the card takes at scale 1 (width, height), so that a small screen diff --git a/crates/omsi-app/src/game_panels/view_editor.rs b/crates/omsi-app/src/game_panels/view_editor.rs index fb50387f2..41786b17c 100644 --- a/crates/omsi-app/src/game_panels/view_editor.rs +++ b/crates/omsi-app/src/game_panels/view_editor.rs @@ -11,6 +11,7 @@ use super::{button, card, divider, grow_row, note, slider_row, title}; use omsi_plugin::api::Value; use omsi_plugin::ui::{Anchor, Panel}; +use omsi_ui::theme::Palette; #[derive(Default)] pub(crate) struct ViewEditor { @@ -36,17 +37,17 @@ pub(crate) struct ViewValues { } impl ViewEditor { - pub(crate) fn panel(&self, app: &crate::App, light: bool) -> Panel { + pub(crate) fn panel(&self, app: &crate::App, pal: Palette) -> Panel { let mirrors = app.player.as_ref().map(|p| (0..p.vehicle.ty.def.cameras_reflexion.len()).map(|i| p.mirror_offsets.get(i).copied().unwrap_or([0.0; 2])).collect()).unwrap_or_default(); - self.panel_of(&ViewValues { seat: app.settings.seat, pitch: app.settings.seat_pitch_deg, yaw: app.cam.view_yaw, fov: app.cam.view_fov, mirrors }, light) + self.panel_of(&ViewValues { seat: app.settings.seat, pitch: app.settings.seat_pitch_deg, yaw: app.cam.view_yaw, fov: app.cam.view_fov, mirrors }, pal) } - pub(crate) fn panel_of(&self, v: &ViewValues, light: bool) -> Panel { + pub(crate) fn panel_of(&self, v: &ViewValues, pal: Palette) -> Panel { let cm = |v: f32| format!("{:+.0} cm", v * 100.0); let deg = |v: f32| format!("{v:+.1}°"); let mut c = vec![ title("View editor"), - note("The driver's view of this vehicle; the cab behind shows each change.", light), + note("The driver's view of this vehicle; the cab behind shows each change.", pal), slider_row("seat0", "Eye left / right", &cm(v.seat[0]), v.seat[0], SEAT, 0.01), slider_row("seat1", "Eye forward / back", &cm(v.seat[1]), v.seat[1], SEAT, 0.01), slider_row("seat2", "Eye up / down", &cm(v.seat[2]), v.seat[2], SEAT, 0.01), @@ -65,10 +66,10 @@ impl ViewEditor { } c.push(grow_row(vec![button("save", "Save for this vehicle", Some("save")), button("reset", "Reset", Some("restart_alt")), button("close", "Close", Some("close"))])); if let Some(said) = &self.said { - c.push(note(said, light)); + c.push(note(said, pal)); } // (at the side, see-through: the view it changes is the picture behind it) - card(Anchor::Right, -12.0, 0.0, 440.0, 170, light, c) + card(Anchor::Right, -12.0, 0.0, 440.0, 170, pal, c) } } diff --git a/crates/omsi-app/src/lib.rs b/crates/omsi-app/src/lib.rs index abb0e5f28..dcf31140a 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; diff --git a/crates/omsi-app/src/photo/panel.rs b/crates/omsi-app/src/photo/panel.rs index 1f02904f0..fa306edb8 100644 --- a/crates/omsi-app/src/photo/panel.rs +++ b/crates/omsi-app/src/photo/panel.rs @@ -96,9 +96,8 @@ 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) { - let theme = ui.theme; 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, theme.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. @@ -113,7 +112,6 @@ fn page(ui: &mut Ui, ph: &mut Photo, info: &Info, x: f32, y: f32, w: f32, acts: /// The advanced mode's pages: a camera's body, the exposure, the lens, the colour, the /// darkroom and the scene. fn pro_page(ui: &mut Ui, ph: &mut Photo, info: &Info, x: f32, mut y: f32, w: f32, acts: &mut Vec) -> f32 { - let theme = ui.theme; let row = |y: &mut f32| { let r = Rect::new(x, *y, w, ROW - 2.0); *y += ROW + 4.0; @@ -187,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)], theme.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 { @@ -213,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.", theme.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.", theme.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); @@ -259,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.", theme.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); @@ -269,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.", theme.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); @@ -324,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, theme.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() { @@ -336,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.", theme.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,25 +343,23 @@ fn pro_page(ui: &mut Ui, ph: &mut Photo, info: &Info, x: f32, mut y: f32, w: f32 /// The exposure meter: a scale from -3 to +3 stops, the needle where the exposure is. fn meter_scale(ui: &mut Ui, r: Rect, offset: f32) { - let theme = ui.theme; 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, theme.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), theme.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 { theme.OK } else if offset.abs() < 1.5 { theme.WARN } else { theme.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); } /// The simple mode's rows of the open page, from `y` down; returns where they end. fn simple_page(ui: &mut Ui, ph: &mut Photo, info: &Info, x: f32, mut y: f32, w: f32, acts: &mut Vec) -> f32 { - let theme = ui.theme; let row = |y: &mut f32| { let r = Rect::new(x, *y, w, ROW - 2.0); *y += ROW + 4.0; @@ -419,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, theme.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(); @@ -431,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, theme.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])); @@ -446,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, theme.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(); @@ -486,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, theme.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() { @@ -498,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, theme.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; } } @@ -508,10 +504,9 @@ fn simple_page(ui: &mut Ui, ph: &mut Photo, info: &Info, x: f32, mut y: f32, w: /// The spirit level: a line through the middle of the frame along the horizon as the camera /// rolls, green when level; the pitch beside it. fn level(ui: &mut Ui, f: Rect, roll: f32, pitch: f32) { - let theme = ui.theme; let m = f.center(); let ok = roll.abs() < 0.3; - let c = if ok { theme.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 { @@ -527,8 +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 theme = ui.theme; - let c = theme.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); @@ -539,7 +533,6 @@ fn focus_mark(ui: &mut Ui, at: Vec2, a: f32) { /// The developed photo's histogram: red, green and blue over each other, the luma behind. fn histogram(ui: &mut Ui, r: Rect, h: &[[u32; 64]; 4]) { - let theme = ui.theme; ui.p().rounded(r, 8.0, Color::BLACK.alpha(0.55)); let inner = Rect::new(r.x + 8.0, r.y + 8.0, r.w - 16.0, r.h - 16.0); let max = h.iter().flat_map(|c| c.iter().skip(1).take(62)).copied().max().unwrap_or(1).max(1) as f32; @@ -554,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), theme.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)); @@ -591,7 +584,6 @@ fn guides(ui: &mut Ui, f: Rect, kind: usize) { } pub(crate) fn draw(sh: &mut Shell, ph: &mut Photo, info: &Info) { - let theme = sh.ui.theme; let mut acts: Vec = Vec::new(); let size = sh.ui.size; let sh_scale = sh.scale; @@ -636,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 { theme.SURFACE_3.alpha(a) } else { theme.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", theme.OK) } else if failed { ("error", theme.DANGER) } else { ("info", theme.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, theme.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, theme.TEXT_DIM.alpha(a), Align::Left); - ui.icon("folder_open", Vec2::new(r.right() - 22.0, r.y + 39.0), 16.0, if hover { theme.ACCENT.alpha(a) } else { theme.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)); } @@ -680,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, theme.ACCENT); - ui.text("Photo mode", Vec2::new(inner.x + 32.0, inner.y + 21.0), 18.0, Weight::Bold, theme.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 { @@ -693,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, theme.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; @@ -720,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, theme.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); @@ -749,7 +741,7 @@ pub(super) fn photo_panel(cx: &mut super::PhotoCx, dt: f32) { }; let Some(ph) = cx.ui.photo.as_mut() else { return }; cx.menus.shell.opaque = ph.render.showing_photo() && cx.menus.shell.picture_tex.is_some(); - cx.menus.shell.set_theme(cx.common.settings.is_light_theme()); + crate::themes::sync(&cx.common.settings.ui_theme_pack, cx.common.settings.is_light_theme()); cx.menus.shell.begin(w, h, scale, dt); draw(&mut *cx.menus.shell, ph, &info); cx.menus.shell.finish(); 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 99723b410..6627a63ee 100644 --- a/crates/omsi-app/src/settings.rs +++ b/crates/omsi-app/src/settings.rs @@ -241,6 +241,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_pack: 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, @@ -548,7 +550,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, lamp_shadows: true, navigator: true, ui_opacity: 0.85, ui_theme: "dark".into(), 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_classic_2d: false, 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(), ray_tracing_on: false, light_shadows: "street".into(), cloud_shadows: false, light_glow: false, 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, cab_wheel_rotation: 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, lamp_shadows: true, navigator: true, ui_opacity: 0.85, ui_theme: "dark".into(), notes: true, ui_scale: 1.0, pause_quick_actions: "default".into(), ui_theme_pack: 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_classic_2d: false, 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(), ray_tracing_on: false, light_shadows: "street".into(), cloud_shadows: false, light_glow: false, 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, cab_wheel_rotation: 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] } } } @@ -603,11 +605,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_pack" => self.ui_theme_pack = 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; @@ -879,7 +882,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_pack" => 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), @@ -984,7 +987,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={}\nnav_classic_2d={}\n", self.pause_quick_actions, self.nav_classic_2d as u8)); + text.push_str(&format!("pause_quick_actions={}\nnav_classic_2d={}\nui_theme_pack={}\n", self.pause_quick_actions, self.nav_classic_2d as u8, if self.ui_theme_pack.is_empty() { crate::themes::OWN } else { &self.ui_theme_pack })); 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 e49abdd34..dfab4d90b 100644 --- a/crates/omsi-app/src/shell/mod.rs +++ b/crates/omsi-app/src/shell/mod.rs @@ -70,6 +70,17 @@ 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, + /// 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. @@ -88,16 +99,27 @@ struct Drawn { ranges: Vec<(Range, usize)>, } +/// A glass theme's picture behind the screens: the window's picture copied on the graphics card +/// and blurred there in three small passes, never read back (`Shell::capture_backdrop`). +struct GlassPicture { + /// The copy, and its number among the screens' textures. + copy: (wgpu::Texture, usize), + /// Each pass's picture and its number: a quarter of the size, `factor`th, twice that. + passes: Vec<(wgpu::Texture, usize)>, + factor: u32, +} + +/// 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, scale: 1.0, over_ui: false, last_sel: None, dd_anchor: None, opaque: false } - } - - pub(crate) fn set_theme(&mut self, light: bool) { - self.ui.set_theme(light); + 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 @@ -112,6 +134,10 @@ 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.glass.as_ref().and_then(|g| g.passes.last()).map(|p| p.1); + 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`. @@ -178,19 +204,126 @@ impl Shell { gpu.upload_atlas(&r.queue, &mut ui.atlas); } - /// The screens over the window's picture (`view`, `w` x `h` pixels). - pub(crate) fn render(&mut self, r: &Renderer, view: &wgpu::TextureView, w: u32, h: u32) { - let Some(d) = self.drawn.as_ref() else { return }; + /// The screens' graphics, made for the renderer's format (anew when it changed: the + /// textures on the old ones are made again). + fn ensure_gpu(&mut self, r: &Renderer) { let format = r.format(); if self.gpu.as_ref().map(|g| g.1 != format).unwrap_or(true) { self.gpu = Some((Gpu::new(&r.device, format, 1, self.ui.atlas.size), format)); self.ui.atlas.mark_all_dirty(); self.picture_tex = None; self.logo_tex = None; + self.glass = None; + } + } + + /// A glass theme's picture behind the screens, from the window's picture `src` as the + /// scene left it (before the screens are drawn over it): copied on the graphics card and + /// blurred there by about `blur` pixels - down to a quarter, then to a `factor`th, then up + /// 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; + let size = wgpu::Extent3d { depth_or_array_layers: 1, ..src.size() }; + let (w, h) = (size.width.max(1), size.height.max(1)); + let factor = ((blur * 0.5).round().max(1.0) as u32).next_power_of_two().clamp(8, 32); + let stale = self.glass.as_ref().is_none_or(|g| g.copy.0.size() != size || g.copy.0.format() != src.format() || g.factor != factor); + if stale { + if let Some(old) = self.glass.take() { + gpu.free(old.copy.1); + for p in old.passes { + gpu.free(p.1); + } + } + let make = |label: &str, (tw, th): (u32, u32), format: wgpu::TextureFormat, usage: wgpu::TextureUsages| { + r.device.create_texture(&wgpu::TextureDescriptor { label: Some(label), size: wgpu::Extent3d { width: tw.max(1), height: th.max(1), depth_or_array_layers: 1 }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format, usage, view_formats: &[] }) + }; + let copy = make("glass: the picture", (w, h), src.format(), wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING); + let copy_id = gpu.add_view(&r.device, ©.create_view(&Default::default()), (w, h)); + let passes = [4, factor, factor / 2] + .into_iter() + .map(|k| { + let s = ((w / k).max(1), (h / k).max(1)); + let t = make("glass: a pass", s, format, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING); + let id = gpu.add_view(&r.device, &t.create_view(&Default::default()), s); + (t, id) + }) + .collect(); + self.glass = Some(GlassPicture { copy: (copy, copy_id), passes, factor }); + } + let Some(g) = self.glass.as_ref() else { return }; + let mut enc = r.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("glass: copy") }); + enc.copy_texture_to_texture(src.as_image_copy(), g.copy.0.as_image_copy(), size); + r.queue.submit([enc.finish()]); + let mut from = (g.copy.1, w, h); + // (a texel apart going down, half a texel coming up) + for ((t, id), apart) in g.passes.iter().zip([1.0f32, 1.0, 0.5]) { + let (pw, ph) = (t.width(), t.height()); + let mut p = omsi_ui::Painter::new(); + let (du, dv) = (apart / from.1 as f32, apart / from.2 as f32); + // (each tap over the ones before at 1/n of its weight: their mean) + for (n, (ox, oy)) in [(-1.0f32, -1.0f32), (1.0, -1.0), (-1.0, 1.0), (1.0, 1.0)].into_iter().enumerate() { + let s = omsi_ui::Sprite { uv: [ox * du, oy * dv, 1.0 + ox * du, 1.0 + oy * dv], w: pw as f32, h: ph as f32, ascent: 0.0 }; + p.sprite(s, Vec2::ZERO, Vec2::new(pw as f32, ph as f32), omsi_ui::Color([1.0, 1.0, 1.0, 1.0 / (n as f32 + 1.0)])); + } + // (one pass a submission: the layers' uniforms and the vertices are written for it) + gpu.upload(&r.device, &r.queue, GLASS_BUFFER, &p.verts); + let layer = Layer::flat([0.0, 0.0, pw as f32, ph as f32], 0.0, 1.0); + let view = t.create_view(&Default::default()); + let mut enc = r.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("glass: a pass") }); + gpu.render(&r.device, &r.queue, &mut enc, &view, (pw, ph), Some(wgpu::Color::TRANSPARENT), &[layer], &[Draw { buffer: GLASS_BUFFER, range: 0..p.len(), layer: 0, texture: from.0 }]); + r.queue.submit([enc.finish()]); + from = (*id, pw, ph); + } + } + + /// The screens over the window's picture (`view`, `w` x `h` pixels). + pub(crate) fn render(&mut self, r: &Renderer, view: &wgpu::TextureView, w: u32, h: u32) { + if self.drawn.is_none() { + return; } + self.ensure_gpu(r); + let Some(d) = self.drawn.as_ref() else { return }; 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)); + // (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, w, h, &px)); } if let Some((pw, ph, px)) = self.picture.take() { match self.picture_tex { @@ -239,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.rs b/crates/omsi-app/src/shell/pause.rs index b48b096a8..263ecfb54 100644 --- a/crates/omsi-app/src/shell/pause.rs +++ b/crates/omsi-app/src/shell/pause.rs @@ -23,6 +23,13 @@ use omsi_ui::theme::*; use omsi_ui::kit::{id_of, ButtonKind, Ui}; use crate::ui::{DropdownView, MenuKind, Preview}; +/// The paused game, dimmed, behind a category page; a list or a settings window has the +/// background solid behind it. In a glass theme the picture is dimmed only a little behind the +/// rail alone, and blurred under some of the background behind a page. +const PAGE_VEIL: f32 = 0.72; +const HOME_VEIL: f32 = 0.85; +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. @@ -107,14 +114,24 @@ fn plain(label: &str) -> (&str, bool) { } pub(crate) fn draw(sh: &mut Shell, v: &PauseView) { - let theme = sh.ui.theme; let size = sh.ui.size; // Drawing a submenu must not change the category that opened it. let sidebar = size.x >= if v.list.is_some() { NARROW } else { 700.0 }; 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() { theme.BG } else { theme.BG.alpha(0.72) }); + let bg = th().bg; + let solid = !(sh.pause_hidden && v.list.is_none()); + match (th().glass, sh.ui.backdrop) { + (Some(_), Some(tex)) if solid => { + 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)); + } + // (the blurred picture not there yet: as good as solid) + (Some(_), _) => sh.ui.p().rect(page, bg.alpha(if solid { HOME_VEIL } else { GLASS_HOME_VEIL })), + (None, _) => sh.ui.p().rect(page, if v.list.is_some() { bg } else { bg.alpha(PAGE_VEIL) }), + } if let Some(list) = v.list.as_ref() { list_page(sh, v, list, page); } else { @@ -129,7 +146,6 @@ pub(crate) fn draw(sh: &mut Shell, v: &PauseView) { /// line, the others muted. Returns the one clicked, and how many rows they took (a narrow /// screen has them on a second row: the last ones were cut off and could not be reached). fn tabs(ui: &mut Ui, at: Vec2, titles: &[String], chosen: usize, max_w: f32) -> (Option, usize) { - let theme = ui.theme; let mut x = at.x; let mut row = 0; let mut out = None; @@ -142,9 +158,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 { theme.INK } else { theme.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), theme.ACCENT); + ui.p().rect(Rect::new(r.x, r.bottom() - 2.0, r.w, 2.0), th().accent); } if clicked && !on { out = Some(i); @@ -156,7 +172,6 @@ fn tabs(ui: &mut Ui, at: Vec2, titles: &[String], chosen: usize, max_w: f32) -> /// An open list or settings window as a launcher page right of the rail. fn list_page(sh: &mut Shell, v: &PauseView, list: &ListView, page: Rect) { - let theme = sh.ui.theme; let margin = if page.w < NARROW { 14.0 } else { 32.0 }; let w = (page.w - margin * 2.0).min(1400.0); let area = Rect::new(page.x + margin + ((page.w - margin * 2.0) - w).max(0.0) * 0.5, 28.0, w, page.h - 28.0 - 24.0); @@ -172,10 +187,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, theme.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, theme.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 @@ -263,7 +278,6 @@ fn row_h(id: &str, label: &str) -> f32 { /// The rows of a settings window in the launcher's panels: two columns side by side where /// the page is wide enough, the headings of the list as the panels' section titles. fn settings_rows(sh: &mut Shell, v: &PauseView, list: &ListView, body: Rect, anchor: &mut Option) { - let theme = sh.ui.theme; let items = &list.items; // The groups retain original indices; the settings layout stays unchanged. let shown: Vec = settings_groups(items, list.tabs.is_none()).into_iter().flatten().collect(); @@ -309,8 +323,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, theme.ACCENT); - ui.text_in(t, Rect::new(inner.x + 12.0, y + 4.0, inner.w - 12.0, 28.0), 15.0, Weight::Bold, theme.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; } @@ -356,17 +370,16 @@ fn settings_groups(items: &[(&str, &str)], hide_back: bool) -> Vec> { /// One row of a settings window: its name (and description) on the left, its control on /// the right as the launcher draws that control. fn settings_row(ui: &mut Ui, k: usize, label: &str, r: Rect, kbd: bool, dd_open: bool, dd_anchor: &mut Option) -> Option { - let theme = ui.theme; let row = parse_row(label); let id = id_of(&format!("pause-row-{k}-{}", row.name)); let hov = ui.hover(r); 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, theme.SURFACE.mix(theme.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, theme.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); @@ -377,12 +390,12 @@ fn settings_row(ui: &mut Ui, k: usize, label: &str, r: Rect, kbd: bool, dd_open: }; let name: Cow<'_, str> = omsi_ui::tr(row.name); let desc: Cow<'_, str> = omsi_ui::tr(row.desc); - let ink = theme.TEXT_SOFT.mix(theme.TEXT, t); + let ink = th().text_soft.mix(th().text, t); if row.desc.is_empty() { ui.text_in(name.as_ref(), Rect::new(lx, r.y, name_w, r.h), 13.0, Weight::Regular, ink, Align::Left); } else { ui.text_in(name.as_ref(), Rect::new(lx, r.y + 6.0, name_w, 20.0), 13.0, Weight::Medium, ink, Align::Left); - ui.text_in(desc.as_ref(), Rect::new(lx, r.y + 25.0, name_w, 17.0), 11.5, Weight::Regular, theme.TEXT_DIM, Align::Left); + ui.text_in(desc.as_ref(), 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(desc.as_ref(), 11.5, Weight::Regular) > name_w { ui.tooltip(Rect::new(lx, r.y, name_w, r.h), desc.as_ref()); @@ -406,17 +419,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, theme.SURFACE_2); - ui.text_in(row.value, ctl.pad(36.0, 0.0), 13.0, Weight::Regular, theme.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, theme.SURFACE_3); + ui.p().rounded(half.inset(3.0), 5.0, th().surface_3); } - ui.icon(icon, half.center(), 18.0, if h { theme.ACCENT } else { theme.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)); } @@ -428,16 +441,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, theme.SURFACE_2.mix(theme.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() { theme.TEXT_SOFT.mix(theme.TEXT, a) } else { theme.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() { theme.TEXT_DIM.mix(theme.ACCENT, a) } else { theme.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); } @@ -445,14 +458,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, theme.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, theme.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, theme.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 @@ -477,13 +490,12 @@ fn dropdown_rect(a: Rect, n: usize, size: Vec2) -> Rect { /// The open drop-down as the launcher's: a list over everything, the value in force ticked. fn dropdown(sh: &mut Shell, d: &DropdownView, a: Rect, kbd: bool) { - let theme = sh.ui.theme; let rr = dropdown_rect(a, d.items.len(), sh.ui.size); let ui = &mut sh.ui; // (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, theme.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; @@ -512,12 +524,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, theme.SURFACE_3); - ui.icon("check", Vec2::new(cell.right() - 16.0, cell.center().y), 15.0, theme.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, theme.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, theme.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); } @@ -552,7 +564,6 @@ fn plain_list(sh: &mut Shell, v: &PauseView, list: &ListView, body: Rect) { /// The rows of a list in `r`, scrolled; `tours`: the chosen one stays marked. fn list_rows(sh: &mut Shell, v: &PauseView, list: &ListView, r: Rect, keep_marked: bool) { - let theme = sh.ui.theme; let items = &list.items; let back_txt = omsi_ui::tr("Back").into_owned(); let shown: Vec = (0..items.len()).filter(|&k| !(items[k].0 == "back" && plain(items[k].1).0 == back_txt)).collect(); @@ -582,23 +593,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 { theme.TEXT } else { theme.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, theme.SURFACE_2); - ui.icon("search", Vec2::new(field.x + 16.0, cy), 16.0, theme.TEXT_DIM); - ui.text_in(label, Rect::new(field.x + 34.0, field.y, field.w - 44.0, field.h), 13.0, Weight::Regular, theme.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, theme.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, theme.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, theme.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); @@ -606,10 +617,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, theme.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, theme.TEXT_DIM); + ui.icon("chevron_right", Vec2::new(row.right() - 16.0, cy), 16.0, th().text_dim); } } y += LIST_ROW; @@ -656,13 +667,12 @@ fn timetable_lists(sh: &mut Shell, v: &PauseView, list: &ListView, body: Rect) { /// The timetable beside the lines or tours: its title, the facts, the time with arrows, /// the stops (one chosen to start from) and the button that starts the trip. fn preview(sh: &mut Shell, p: &Preview, list: &ListView, r: Rect) { - let theme = sh.ui.theme; let mut acts = Vec::new(); 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, theme.TEXT, Align::Left); - ui.text_in(&p.meta, Rect::new(inner.x, inner.y + 26.0, inner.w, 18.0), 12.0, Weight::Regular, theme.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() { @@ -675,7 +685,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, theme.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; @@ -694,8 +704,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, theme.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 { theme.TEXT } else { theme.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 }); @@ -715,18 +725,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, theme.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, theme.ACCENT.alpha(0.16)); - ui.text_in(num.trim(), tile, 12.5, Weight::Bold, theme.ACCENT, Align::Center); - ui.text_in(dest, Rect::new(tile.right() + 10.0, y, view.w - tw - 100.0, lh), 12.5, Weight::Regular, theme.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, theme.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 38654e29b..59397a8aa 100644 --- a/crates/omsi-app/src/shell/pause/navigation_ui.rs +++ b/crates/omsi-app/src/shell/pause/navigation_ui.rs @@ -23,19 +23,23 @@ fn session_action(sh: &mut Shell, v: &PauseView, id: &str, rect: Rect, kind: But let Some(k) = v.rail.iter().position(|line| line.0 == id) else { return }; let label = omsi_ui::tr(plain(&v.rail[k].1).0); let ui = &mut sh.ui; - let theme = ui.theme; 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 { theme.ACCENT.mix(theme.ACCENT_HOVER, t) } else { theme.SURFACE_2.mix(theme.DANGER_2.alpha(0.15), t) }; - let ink = if primary { theme.ACCENT_INK } else { theme.DANGER_2 }; - ui.p().rounded(rect, 12.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 }; + // (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; 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), 14.0, 2.0, theme.ACCENT); + ui.p().rounded_border(rect.inset(-2.0), corner(14.0), 2.0, th().accent); } if clicked { activate(sh, v, k); @@ -46,16 +50,36 @@ pub(super) fn rail(sh: &mut Shell, v: &PauseView) { let size = sh.ui.size; let r = Rect::new(0.0, 0.0, RAIL_W, size.y); let ui = &mut sh.ui; - let theme = ui.theme; ui.solid(r); - ui.p().rect(r, theme.SIDEBAR); - if let Some(tex) = sh.logo_tex { + 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)), corner(14.0), th().sidebar); + } else { + ui.p().rect(r, th().sidebar); + } + // 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); } - ui.text("openOMSI", Vec2::new(60.0, 32.0), 18.0, Weight::Bold, theme.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, theme.INK_MUTED, Align::Left); + 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); + } + 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, theme.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)); @@ -88,23 +112,21 @@ pub(super) fn rail(sh: &mut Shell, v: &PauseView) { fn driver(sh: &mut Shell, v: &PauseView, height: f32) { let ui = &mut sh.ui; - let theme = ui.theme; let chip = Rect::new(16.0, height - 66.0, RAIL_W - 32.0, 52.0); - ui.p().rounded(chip, 8.0, theme.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, theme.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, theme.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, theme.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, theme.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 theme = ui.theme; 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, 10.0, if selected { theme.ACCENT.alpha(0.10) } else { theme.SURFACE_2.alpha(t) }); - let color = if selected { theme.ACCENT } else { theme.INK_2.mix(theme.INK, t) }; + ui.p().rounded(rect, corner(10.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)); @@ -112,7 +134,6 @@ fn category_button(ui: &mut Ui, category: Category, selected: bool, rect: Rect) } pub(super) fn page(sh: &mut Shell, v: &PauseView, page: Rect, compact: bool) { - let theme = sh.ui.theme; let ids = ids(v); if !Category::available(&ids).contains(&sh.pause_category) { sh.pause_category = Category::Home; @@ -125,8 +146,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, theme.INK_MUTED, Align::Left); - sh.ui.text_in(&omsi_ui::tr(if v.paused { "Paused" } else { "Menu" }), header, 11.0, Weight::Medium, theme.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 = if compact { 86.0 } else { 52.0 }; @@ -142,9 +163,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, theme.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, theme.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); @@ -176,13 +197,12 @@ 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, theme.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); } } } fn compact_categories(sh: &mut Shell, ids: &[&str], area: Rect, keyboard: bool) -> f32 { - let theme = sh.ui.theme; let mut x = 0.0; let mut y = 0.0; let mut buttons = Vec::new(); @@ -207,7 +227,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, theme.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 @@ -224,7 +244,6 @@ fn compact_categories(sh: &mut Shell, ids: &[&str], area: Rect, keyboard: bool) } fn cards(sh: &mut Shell, v: &PauseView, category: Category, area: Rect) { - let theme = sh.ui.theme; let indices = super::quick_actions::indices(category, v.rail, v.quick_actions); let cols = if area.w >= 520.0 { 2 } else { 1 }; let gap = 14.0; @@ -239,7 +258,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, - theme.INK_MUTED, + th().ink_muted, Align::Left, ); } @@ -269,27 +288,30 @@ fn cards(sh: &mut Shell, v: &PauseView, category: Category, area: Rect) { } fn action_card(ui: &mut Ui, v: &PauseView, index: usize, rect: Rect, keyboard: bool, shortcut: bool) -> bool { - let theme = ui.theme; let (id, label, plugin) = &v.rail[index]; 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 { theme.SURFACE.mix(theme.ACCENT, 0.12) } else { theme.SURFACE.mix(theme.SURFACE_2, t) }; - ui.p().rounded(rect, 18.0, fill); + let fill = if keyboard { th().surface.mix(th().accent, 0.12) } else { th().surface.mix(th().surface_2, t) }; + // (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, 18.0, 2.0, theme.ACCENT); + ui.p().rounded_border(rect, corner(18.0), 2.0, th().accent); } let danger = *id == "endduty"; let ink = if disabled { - theme.INK_MUTED + th().ink_muted } else if danger { - theme.DANGER_2 + th().danger_2 } else { - theme.INK + th().ink }; - let icon_c = if shortcut { theme.ACCENT } else { theme.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, 14.0, if shortcut { theme.ACCENT.alpha(0.10) } else { theme.SURFACE_2 }); + ui.p().rounded(tile, corner(14.0), if shortcut { th().accent.alpha(0.10) } else { th().surface_2 }); let icon = plugin .as_ref() .and_then(|p| p.icon.as_deref()) @@ -301,8 +323,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, theme.INK_MUTED, Align::Left); - ui.icon("chevron_right", Vec2::new(rect.right() - 19.0, rect.center().y), 17.0, theme.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 c8e2667e5..f544d1732 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.pause_hidden = false; 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] @@ -297,28 +297,30 @@ fn pause_navigation_uses_runtime_theme_palette() { dark.finish(); let mut light = Shell::new(); - light.ui.set_theme(true); - light.begin(1440.0, 900.0, 1.0, 1.0 / 60.0); - draw(&mut light, &v); - light.finish(); + omsi_ui::theme::with_palette(Palette::LIGHT, || { + light.begin(1440.0, 900.0, 1.0, 1.0 / 60.0); + draw(&mut light, &v); + light.finish(); + }); - assert!(dark.drawn.as_ref().unwrap().verts.iter().any(|v| v.color == DARK.SIDEBAR.0)); - assert!(light.drawn.as_ref().unwrap().verts.iter().any(|v| v.color == LIGHT.SIDEBAR.0)); + assert!(dark.drawn.as_ref().unwrap().verts.iter().any(|v| v.color == Palette::OPENOMSI.sidebar.0)); + assert!(light.drawn.as_ref().unwrap().verts.iter().any(|v| v.color == Palette::LIGHT.sidebar.0)); } #[test] fn quick_actions_editor_uses_runtime_theme_palette() { let rail = menu(); - for (light_theme, surface) in [(false, DARK.SURFACE), (true, LIGHT.SURFACE)] { + for (light_theme, surface) in [(false, Palette::OPENOMSI.surface), (true, Palette::LIGHT.surface)] { let mut sh = Shell::new(); - sh.ui.set_theme(light_theme); - sh.begin(1440.0, 900.0, 1.0, 1.0 / 60.0); - super::super::quick_actions_ui::editor( - &mut sh, - &view(&rail), - Rect::new(300.0, 120.0, 800.0, 650.0), - ); - sh.finish(); + omsi_ui::theme::with_palette(omsi_ui::theme::palette(light_theme), || { + sh.begin(1440.0, 900.0, 1.0, 1.0 / 60.0); + super::super::quick_actions_ui::editor( + &mut sh, + &view(&rail), + Rect::new(300.0, 120.0, 800.0, 650.0), + ); + sh.finish(); + }); assert!( sh.drawn 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 621850484..d6926bc14 100644 --- a/crates/omsi-app/src/shell/pause/quick_actions_ui.rs +++ b/crates/omsi-app/src/shell/pause/quick_actions_ui.rs @@ -13,7 +13,6 @@ fn label(id: &str, menu: &[crate::app_impl::MenuLine]) -> String { } pub(super) fn editor(sh: &mut Shell, v: &PauseView, area: Rect) { - let theme = sh.ui.theme; let entries = quick_actions::entries(v.rail, v.quick_actions); let chosen = quick_actions::selected(v.quick_actions); let mut edits = Vec::new(); @@ -22,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, theme.SURFACE); + sh.ui.surface(area, corner(10.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, theme.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, theme.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 { theme.ACCENT } else { theme.INK_MUTED }); - if on { ui.icon("check", check.center(), 18.0, theme.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 { theme.INK } else { theme.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, theme.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); @@ -50,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, theme.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..082d54340 --- /dev/null +++ b/crates/omsi-app/src/themes.rs @@ -0,0 +1,506 @@ +//! 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_pack`); 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::icons::{Mark, Stage}; +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 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. +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, 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, marks: Vec }, + 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, &|_| 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 { + 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) + } + + /// 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), 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, 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() + } + }; + 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, + /// The colours it gives (by name), over openOMSI's light or dark look (`palette`). + pub colors: Vec<(String, Color)>, + /// The corners of its panels (none: openOMSI's). + pub radius: Option, + pub glass: Option, + 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, +} + +impl Theme { + /// Its palette over openOMSI's light or dark look, as the player chose (`ui_theme`): its + /// colours, what it leaves out from that look, its corners and its glass. + pub fn palette(&self, light: bool) -> Palette { + let (mut p, _) = Palette::with_colors(theme::palette(light), self.colors.iter().map(|(k, c)| (k.as_str(), *c))); + p.radius = self.radius.unwrap_or(p.radius); + p.glass = self.glass; + p + } +} + +/// 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(); + let mut known = Palette::OPENOMSI; + if let Some(c) = o.get("colors") { + for (k, val) in c.as_object().ok_or("colors: an object of colours is expected")? { + let c = color_of(val).ok_or_else(|| format!("colors.{k}: \"#rrggbb\", \"#rrggbbaa\" or [r, g, b, a] is expected"))?; + if known.color_mut(k).is_some() { + colors.push((k.clone(), c)); + } else { + log::warn!("interface theme \"{id}\": colors.{k} is not known, left out"); + } + } + } + let radius = match o.get("radius") { + Some(r) => Some(r.as_f64().filter(|r| (0.0..=24.0).contains(r)).ok_or("radius: 0 to 24 is expected")? as f32), + None => None, + }; + let 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, colors, radius, glass, 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"); + 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 !fits(img.width, img.height) { + return Err(too_large()); + } + 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_pack` 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, + light: bool, + theme: Option>, +} + +static STATE: Mutex = Mutex::new(State { wanted: String::new(), light: false, theme: None }); + +/// Puts the theme `id` (the setting `ui_theme_pack`) in force over openOMSI's light or dark +/// look (`ui_theme`), when they are not already: the theme read anew when it is another, the +/// 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, light: bool) { + let mut s = STATE.lock().unwrap_or_else(|e| e.into_inner()); + if s.wanted == id && s.light == light { + return; + } + if s.wanted != id { + s.wanted = id.to_string(); + s.theme = None; + if !id.is_empty() { + match load_from(&found(), id) { + Ok(t) => { + log::info!("interface theme \"{}\" ({}): glass {}, loading {:?}", t.id, t.name, t.glass.is_some(), t.loading.progress); + s.theme = Some(Arc::new(t)); + } + Err(e) => log::warn!("interface theme \"{id}\": {e} - openOMSI's own look"), + } + } + } + s.light = light; + let palette = s.theme.as_ref().map_or(theme::palette(light), |t| t.palette(light)); + theme::set(palette); +} + +/// The palette of the theme in force over openOMSI's light or dark look: what `sync` puts in +/// force, for what is built from the settings rather than drawn (the game's own panels). +pub(crate) fn palette(light: bool) -> Palette { + current().map_or(theme::palette(light), |t| t.palette(light)) +} + +/// 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 + }) +} + +#[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(false).accent, Color::rgba(0xf5, 0x86, 0x20, 1.0)); + assert_eq!((t.palette(false).surface, t.palette(false).panel), (Color::rgba(16, 30, 62, 1.0), Color::rgba(16, 30, 62, 1.0))); + assert_eq!(t.palette(false).radius, 14.0); + assert_eq!(t.palette(false).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)), &|_| 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)), &|_| Stage::Shown).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 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(); + assert_eq!((t.name.as_str(), t.loading.progress, t.palette(false).glass), ("x", Progress::Fill, Some(Glass::DEFAULT))); + assert_eq!(t.palette(false).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(false).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 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("Theme pack"), "Themapakket"); + assert!(omsi_ui::tr(desc).starts_with("Hoe de menu's")); + }); + crate::with_test_language("de", || assert_eq!(omsi_ui::tr("Theme pack"), "Designpaket")); + } +} diff --git a/crates/omsi-app/src/ui.rs b/crates/omsi-app/src/ui.rs index be1fb38ff..e56174e13 100644 --- a/crates/omsi-app/src/ui.rs +++ b/crates/omsi-app/src/ui.rs @@ -16,6 +16,7 @@ mod menu; mod information_bar; mod perf_panel; mod text; +mod loading; pub use text::TextCache; mod mz; @@ -457,6 +458,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 +518,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`. @@ -1212,107 +1216,6 @@ fn expected_of(v: &serde_json::Value) -> Option { (!t.is_empty()).then(|| t.iter().sum::() / t.len() as f32) } -/// The openOMSI wordmark (white letters, the orange "OMSI"), for the loading screen. -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 { - 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] }; - px.extend_from_slice(&[c[0], c[1], c[2], alpha]); - } - } - px -} - -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. - #[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); - let k = (opacity.clamp(0.0, 1.0) * 16.0).round() / 16.0; - if k <= 0.0 { - return; - } - 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)]); - 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; - } - px - }); - 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 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 - 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; - } - } - // 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 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); - 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 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])); - } - - /// The loading screen alone, before the session's first picture. - #[allow(clippy::too_many_arguments)] - pub fn loading(&mut self, r: &Renderer, scene: &mut Scene, width: f32, height: f32, scale: f32, title: &str, progress: f32, clock: f32, tip: (&str, f32)) { - self.loading_screen(r, scene, width, height, scale, title, progress, 1.0, clock, tip); - self.text.end_frame(r, scene); - } -} - /// `text` broken into lines of at most `width` pixels, between words. fn wrap(tc: &TextCache, text: &str, px: f32, width: f32) -> Vec { let mut out = Vec::new(); diff --git a/crates/omsi-app/src/ui/loading.rs b/crates/omsi-app/src/ui/loading.rs new file mode 100644 index 000000000..47e4da6a7 --- /dev/null +++ b/crates/omsi-app/src/ui/loading.rs @@ -0,0 +1,188 @@ +//! The loading screen: openOMSI's wordmark and striped bar, or an interface theme's colours and +//! logo - which may itself be the bar, drawn along its path, with its marks popping in (`themes`, +//! `omsi_ui::icons`). The time it follows is `Loading`'s (see the parent module). + +use super::*; +use omsi_ui::icons::Stage; + +/// The openOMSI wordmark (white letters, the orange "OMSI"), for the loading screen. +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 `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 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) => { + // (the palette in force: the theme's over the light or dark look) + let p = omsi_ui::theme::th(); + 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); + let k = (opacity.clamp(0.0, 1.0) * 16.0).round() / 16.0; + 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 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])); + let ka = a(255.0); + 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 }; + // 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 => { + 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, 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 (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(); + 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, 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, 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, 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])); + } + + /// The loading screen alone, before the session's first picture. + #[allow(clippy::too_many_arguments)] + pub fn loading(&mut self, r: &Renderer, scene: &mut Scene, width: f32, height: f32, scale: f32, title: &str, progress: f32, clock: f32, tip: (&str, f32)) { + self.loading_screen(r, scene, width, height, scale, title, progress, 1.0, clock, tip); + self.text.end_frame(r, scene); + } +} 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 52422d8ba..61ddcc89a 100644 --- a/crates/omsi-launcher-core/src/lib.rs +++ b/crates/omsi-launcher-core/src/lib.rs @@ -2221,6 +2221,8 @@ pub fn settings_from_text_on(text: Option<&str>, phone: bool) -> Value { v["cloud_shadows"] = json!(false); v["light_glow"] = json!(false); v["pause_quick_actions"] = json!("default"); + // (the interface theme's id: "openomsi" is openOMSI's own look) + v["ui_theme_pack"] = json!("openomsi"); v["ui_theme"] = json!("dark"); v["puddles"] = json!("high"); // (apart: the literal above is at the json! macro's recursion limit) v["vehicle_smoke"] = json!("all"); @@ -2344,7 +2346,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_pack" => 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)), @@ -2840,6 +2842,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_pack={}\n", v.get("ui_theme_pack").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/input.rs b/crates/omsi-plugin-ui/src/input.rs index 715c073a9..df8e22695 100644 --- a/crates/omsi-plugin-ui/src/input.rs +++ b/crates/omsi-plugin-ui/src/input.rs @@ -124,7 +124,7 @@ mod tests { fn sized(panels: &mut PluginPanels, ui: &mut UiState, owner: u64, id: &str, src: &str, origin: Vec2, (w, h): (f32, f32), alpha: f32) { ui.set(owner, id, panel_from_source(src).unwrap()).unwrap(); let laid = Laid { w, h, background: omsi_ui::Color::rgba(20, 20, 20, alpha), ..Laid::default() }; - let card = Card { revision: 0, scale: 1.0, backdrop: 1.0, light: false, laid, tex: None, drawn: None, origin, y: None, seen: panels.frame, full_h: h, room: f32::INFINITY }; + let card = Card { revision: 0, scale: 1.0, backdrop: 1.0, pal: omsi_ui::theme::Palette::OPENOMSI, laid, tex: None, drawn: None, origin, y: None, seen: panels.frame, full_h: h, room: f32::INFINITY }; panels.cards.insert(Key::Panel(owner, id.to_string()), card); } @@ -171,7 +171,7 @@ mod tests { // screen) let p = omsi_plugin::ui::Panel { width: 1100.0, background: Some(omsi_plugin::ui::Rgba([0, 0, 0, 0])), ..panel_from_source(src).unwrap() }; let laid = Laid { flat: true, ..super::super::layout(&p, &fonts, 1.0) }; - let card = Card { revision: 0, scale: 1.0, backdrop: 1.0, light: false, laid, tex: None, drawn: None, origin: Vec2::new(0.0, 0.0), y: None, seen: panels.frame, full_h: 0.0, room: f32::INFINITY }; + let card = Card { revision: 0, scale: 1.0, backdrop: 1.0, pal: omsi_ui::theme::Palette::OPENOMSI, laid, tex: None, drawn: None, origin: Vec2::new(0.0, 0.0), y: None, seen: panels.frame, full_h: 0.0, room: f32::INFINITY }; panels.cards.insert(Key::Panel(1, "phone".into()), card); assert!(panels.grabs_at(&ui, 20.0, 10.0), "its tab"); assert!(!panels.grabs_at(&ui, 300.0, 300.0), "its map: the navigator's"); diff --git a/crates/omsi-plugin-ui/src/lib.rs b/crates/omsi-plugin-ui/src/lib.rs index 774b7e1b7..1dfcd6f13 100644 --- a/crates/omsi-plugin-ui/src/lib.rs +++ b/crates/omsi-plugin-ui/src/lib.rs @@ -20,6 +20,7 @@ use hashbrown::HashMap; use omsi_plugin::ui::{self as pui, Align, Element, Kind, Panel, Rgba, RowAlign, Toast, UiState}; use omsi_render::{Renderer, Scene, TextureId}; use omsi_ui::paint::Align as TextAlign; +use omsi_ui::theme::Palette; use omsi_ui::{Atlas, Color, Draw, Fonts, Gpu, Layer, Painter, Rect, Vertex, Weight}; pub mod input; @@ -29,59 +30,9 @@ pub use nav::{Docked, DockedMap}; mod preview; mod square; mod widgets; +mod look; +use look::*; -// --- the game's look -------------------------------------------------------------------- - -const CARD: Color = Color::rgba(14, 16, 20, 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); -// (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); -const DIVIDER: Color = Color::rgba(52, 52, 52, 1.0); -const BUTTON: Color = Color::rgba(44, 44, 44, 1.0); -const TILE: Color = Color::rgba(46, 48, 54, 0.6); -const SHADOW: Color = Color::rgba(0, 0, 0, 0.35); - -#[derive(Clone, Copy)] -struct ThemeColors { - card: Color, - ink: Color, - dark_ink: Color, - accent: Color, - track: Color, - divider: Color, - button: Color, - tile: Color, - muted: Color, - soft: Color, - faint: Color, - rail_done: Color, - tag_fill: Color, -} - -impl ThemeColors { - fn new(light: bool) -> Self { - let theme = omsi_ui::theme::palette(light); - let color = |c: omsi_ui::Color| Color(c.0); - Self { - card: if light { Color::rgba(252, 252, 252, CARD.0[3]) } else { CARD }, - ink: if light { color(theme.INK) } else { INK }, - dark_ink: DARK_INK, - accent: if light { color(theme.ACCENT) } else { ACCENT }, - track: if light { color(theme.SURFACE_3) } else { TRACK }, - divider: if light { theme.EDGE } else { DIVIDER }, - button: if light { color(theme.SURFACE_2) } else { BUTTON }, - tile: if light { color(theme.SURFACE_3) } else { TILE }, - muted: if light { color(theme.TEXT_DIM) } else { Color::rgba(142, 142, 142, 1.0) }, - soft: if light { color(theme.INK_2) } else { Color::rgba(196, 202, 214, 1.0) }, - faint: if light { color(theme.TEXT_FAINT) } else { Color::rgba(110, 118, 136, 1.0) }, - rail_done: if light { color(theme.SURFACE_3) } else { Color::rgba(74, 84, 98, 1.0) }, - tag_fill: if light { theme.EDGE } else { Color::rgba(255, 255, 255, 0.10) }, - } - } -} /// A text line's height, times its size. pub const LINE: f32 = 1.3; @@ -660,23 +611,23 @@ impl Layout<'_> { /// Lay a panel out: its size, what it is drawn from and where it takes clicks. /// `backdrop`: the opacity setting's strength for the game's own card colour (`ui::backdrop`). pub fn layout(p: &Panel, fonts: &Fonts, backdrop: f32) -> Laid { - layout_with_theme(p, fonts, backdrop, false) + layout_with_theme(p, fonts, backdrop, omsi_ui::theme::th()) } -fn layout_with_theme(p: &Panel, fonts: &Fonts, backdrop: f32, light: bool) -> Laid { - layout_typing_with_theme(p, fonts, backdrop, None, light) +fn layout_with_theme(p: &Panel, fonts: &Fonts, backdrop: f32, pal: Palette) -> Laid { + layout_typing_with_theme(p, fonts, backdrop, None, pal) } /// [`layout`], with the caret in the text field `typing` (the field's id). pub fn layout_typing(p: &Panel, fonts: &Fonts, backdrop: f32, typing: Option<&str>) -> Laid { - layout_typing_with_theme(p, fonts, backdrop, typing, false) + layout_typing_with_theme(p, fonts, backdrop, typing, omsi_ui::theme::th()) } -fn layout_typing_with_theme(p: &Panel, fonts: &Fonts, backdrop: f32, typing: Option<&str>, light: bool) -> Laid { +fn layout_typing_with_theme(p: &Panel, fonts: &Fonts, backdrop: f32, typing: Option<&str>, pal: Palette) -> Laid { let mut l = Layout { fonts, out: Laid::default(), - colors: ThemeColors::new(light), + colors: ThemeColors::of(pal), typing, }; let stripe = if p.accent.is_some() { STRIPE } else { 0.0 }; @@ -716,12 +667,12 @@ fn layout_typing_with_theme(p: &Panel, fonts: &Fonts, backdrop: f32, typing: Opt /// 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 { - toast_panel_with_theme(t, false) + toast_panel_with_theme(t, omsi_ui::theme::th()) } -fn toast_panel_with_theme(t: &Toast, light: bool) -> Panel { +fn toast_panel_with_theme(t: &Toast, pal: Palette) -> Panel { let accent = t.color.unwrap_or_else(|| { - let [r, g, b, a] = ThemeColors::new(light).accent.0; + let [r, g, b, a] = ThemeColors::of(pal).accent.0; Rgba([(r * 255.0 + 0.5) as u8, (g * 255.0 + 0.5) as u8, (b * 255.0 + 0.5) as u8, (a * 255.0 + 0.5) as u8]) }); let element = |kind: Kind, grow: bool, c: Option| Element { @@ -996,7 +947,7 @@ struct Card { revision: u64, scale: f32, backdrop: f32, - light: bool, + pal: Palette, laid: Laid, tex: Option<(TextureId, u32, u32)>, /// The hit lit when it was drawn (None: to be drawn). @@ -1031,8 +982,9 @@ pub struct PanelsFrame { pub dt: f32, /// `ui::backdrop` of the opacity setting. pub backdrop: f32, - /// 遊戲共用的淺色介面主題。 - pub light: bool, + /// The palette in force (`omsi_ui::theme::th`): openOMSI's dark or light look, a + /// theme's over it. + pub palette: Palette, /// The navigator's panel (physical x0, y0, x1, y1), which the notifications stay clear of. pub navigator: Option<[f32; 4]>, /// The navigator's board, where the panel with `anchor = "navigator"` goes (None: the @@ -1175,7 +1127,7 @@ impl PluginPanels { revision: u64::MAX, scale: 0.0, backdrop: 0.0, - light: false, + pal: Palette::OPENOMSI, laid: Laid::default(), tex: None, drawn: None, @@ -1187,13 +1139,13 @@ impl PluginPanels { }); // (the most a panel in the board may be high, in its own pixels) let room = dock.map_or(f32::INFINITY, |d| (d.room / ck).floor().max(1.0)); - if card.revision != e.revision || card.scale != ck || card.backdrop != f.backdrop || card.light != f.light || card.laid.w != width || card.room != room { + if card.revision != e.revision || card.scale != ck || card.backdrop != f.backdrop || card.pal != f.palette || card.laid.w != width || card.room != room { let caret = typing.as_ref().filter(|t| t.0 == e.owner && t.1 == e.id).map(|t| t.2.as_str()); card.laid = match dock { Some(_) => { - Laid { flat: true, ..layout_typing_with_theme(&nav::in_navigator(&e.panel, width), fonts, f.backdrop, caret, f.light) } + Laid { flat: true, ..layout_typing_with_theme(&nav::in_navigator(&e.panel, width), fonts, f.backdrop, caret, f.palette) } } - None => layout_typing_with_theme(&e.panel, fonts, f.backdrop, caret, f.light), + None => layout_typing_with_theme(&e.panel, fonts, f.backdrop, caret, f.palette), }; card.full_h = card.laid.h; card.room = room; @@ -1201,7 +1153,7 @@ impl PluginPanels { card.revision = e.revision; card.scale = ck; card.backdrop = f.backdrop; - card.light = f.light; + card.pal = f.palette; card.drawn = None; } match dock { @@ -1241,7 +1193,7 @@ impl PluginPanels { revision: 0, scale: 0.0, backdrop: 0.0, - light: false, + pal: Palette::OPENOMSI, laid: Laid::default(), tex: None, drawn: None, @@ -1251,11 +1203,11 @@ impl PluginPanels { full_h: 0.0, room: f32::INFINITY, }); - if card.scale != k || card.backdrop != f.backdrop || card.light != f.light { - card.laid = layout_with_theme(&toast_panel_with_theme(t, f.light), fonts, f.backdrop, f.light); + if card.scale != k || card.backdrop != f.backdrop || card.pal != f.palette { + card.laid = layout_with_theme(&toast_panel_with_theme(t, f.palette), fonts, f.backdrop, f.palette); card.scale = k; card.backdrop = f.backdrop; - card.light = f.light; + card.pal = f.palette; card.drawn = None; } if top + card.laid.h > screen.y { diff --git a/crates/omsi-plugin-ui/src/look.rs b/crates/omsi-plugin-ui/src/look.rs new file mode 100644 index 000000000..644409c80 --- /dev/null +++ b/crates/omsi-plugin-ui/src/look.rs @@ -0,0 +1,75 @@ +//! The look of the plugins' panels and notifications, from the palette in force +//! (`omsi_ui::theme::th`): openOMSI's dark look as it always was, its light look or an +//! interface theme's from their colours. + +use omsi_ui::theme::Palette; +use omsi_ui::Color; + +/// openOMSI's dark look: the card under a notification or a panel, its near black. +const CARD: Color = Color::rgba(14, 16, 20, 225.0 / 255.0); +pub(crate) const INK: Color = Color::rgba(236, 236, 236, 1.0); +pub(crate) const DARK_INK: Color = Color::rgba(18, 14, 8, 1.0); +// (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); +const DIVIDER: Color = Color::rgba(52, 52, 52, 1.0); +const BUTTON: Color = Color::rgba(44, 44, 44, 1.0); +const TILE: Color = Color::rgba(46, 48, 54, 0.6); +pub(crate) const SHADOW: Color = Color::rgba(0, 0, 0, 0.35); + +/// The colours a card is laid out in. +#[derive(Clone, Copy)] +pub(crate) struct ThemeColors { + pub card: Color, + pub ink: Color, + pub dark_ink: Color, + pub accent: Color, + pub track: Color, + pub divider: Color, + pub button: Color, + pub tile: Color, + pub muted: Color, + pub soft: Color, + pub faint: Color, + pub rail_done: Color, + pub tag_fill: Color, +} + +impl ThemeColors { + /// The colours of the palette `p`: openOMSI's dark look its own greys, any other (the + /// light look, a theme's) its surfaces and inks. + pub fn of(p: Palette) -> Self { + if p == Palette::OPENOMSI { + return Self { + card: CARD, + ink: INK, + dark_ink: DARK_INK, + accent: p.accent, + track: TRACK, + divider: DIVIDER, + button: BUTTON, + tile: TILE, + muted: Color::rgba(142, 142, 142, 1.0), + soft: Color::rgba(196, 202, 214, 1.0), + faint: Color::rgba(110, 118, 136, 1.0), + rail_done: Color::rgba(74, 84, 98, 1.0), + tag_fill: Color::rgba(255, 255, 255, 0.10), + }; + } + Self { + card: p.panel.alpha(CARD.0[3]), + ink: p.ink, + dark_ink: DARK_INK, + accent: p.accent, + track: p.surface_3, + divider: p.edge, + button: p.surface_2, + tile: p.surface_3, + muted: p.text_dim, + soft: p.ink_2, + faint: p.text_faint, + rail_done: p.surface_3, + tag_fill: p.edge, + } + } +} diff --git a/crates/omsi-plugin-ui/src/theme_tests.rs b/crates/omsi-plugin-ui/src/theme_tests.rs index c966f284d..997d433a1 100644 --- a/crates/omsi-plugin-ui/src/theme_tests.rs +++ b/crates/omsi-plugin-ui/src/theme_tests.rs @@ -13,12 +13,12 @@ fn light_theme_uses_shared_surface_ink_and_control_colors() { { type = "input", id = "name", placeholder = "Name" }, { type = "button", id = "ok", text = "OK" }, } }"#); - let light = layout_typing_with_theme(&p, &fonts, 1.0, None, true); + let light = layout_typing_with_theme(&p, &fonts, 1.0, None, Palette::LIGHT); assert!(light.background.0[0] > 0.95 && light.background.0[1] > 0.95 && light.background.0[2] > 0.95); assert!(light.items.iter().any(|item| matches!(item, Item::Text { color, .. } if color.0[0] < 0.2))); assert!(light.items.iter().any(|item| matches!(item, Item::Rounded { color, .. } if color.0[0] > 0.9))); - let dark = layout_typing_with_theme(&p, &fonts, 1.0, None, false); + let dark = layout_typing_with_theme(&p, &fonts, 1.0, None, Palette::OPENOMSI); assert_eq!(dark.background.0[..3], [14.0 / 255.0, 16.0 / 255.0, 20.0 / 255.0]); assert!(dark.items.iter().any(|item| matches!(item, Item::Text { color, .. } if color.0[0] > 0.9))); assert!(dark.items.iter().any(|item| matches!(item, Item::Rounded { color, .. } if color.0[0] < 0.3))); @@ -116,7 +116,7 @@ fn square_tiles_follow_the_palette_without_losing_lit_or_disabled_states() { tile("off", "map", false, true, None), ]; for light in [false, true] { - let laid = layout_with_theme(&p, &fonts, 1.0, light); + let laid = layout_with_theme(&p, &fonts, 1.0, omsi_ui::theme::palette(light)); assert!(laid.hits.iter().any(|h| h.element.as_deref() == Some("plain"))); assert!(!laid.hits.iter().any(|h| h.element.as_deref() == Some("off"))); let icons: Vec<_> = laid.items.iter().filter_map(|i| match i { @@ -125,7 +125,7 @@ fn square_tiles_follow_the_palette_without_losing_lit_or_disabled_states() { assert_eq!(icons.len(), 3); assert_eq!(icons[1], INK, "a lit tile keeps its white icon"); assert_eq!(icons[0].0[0] < 0.2, light, "the ordinary icon follows the theme"); - let accent = ThemeColors::new(light).accent; + let accent = ThemeColors::of(omsi_ui::theme::palette(light)).accent; assert!(laid.items.iter().any(|i| matches!(i, Item::Rounded { color, .. } if *color == accent))); } } diff --git a/crates/omsi-plugin/src/api/fns/game.rs b/crates/omsi-plugin/src/api/fns/game.rs index 781816f9a..fe2417132 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 0997d26de..c4c67b3c4 100644 --- a/crates/omsi-plugin/src/ui.rs +++ b/crates/omsi-plugin/src/ui.rs @@ -15,6 +15,10 @@ use std::rc::Rc; mod menu; pub use menu::{MenuItem, MAX_MENU_ITEMS, MAX_MENU_LABEL}; +mod theme; +pub use theme::{themes_for_load, ThemeInfo}; +mod dock; +pub use dock::Dock; /// `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. @@ -24,7 +28,8 @@ pub use menu::{MenuItem, MAX_MENU_ITEMS, MAX_MENU_LABEL}; /// 5: panels that take a click without the mouse (`grab`). /// 6: a navigator panel that fills the navigator's whole frame (`frame`), and the navigator's /// map as an element of it (`map`). -pub const VERSION: i64 = 6; +/// 7: the interface themes (`ui.themes`, `ui.theme`, `ui.set_theme`). +pub const VERSION: i64 = 7; /// Most panels of one plugin. pub const MAX_PANELS: usize = 16; /// Most elements of one panel, those in rows included. @@ -354,28 +359,17 @@ pub struct UiState { menu_picks: Vec<(u64, String)>, /// The navigator's board (None: the navigator shows none). dock: Option, -} - -/// The navigator's board as the plugins see it (`ui.dock`), in the panel's pixels. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Dock { - pub width: f32, - /// The most height there is room for. - pub room: f32, - /// The height the panel in it came to (more than `room`: it is cut off there; 0 before - /// it is laid out). - pub height: f32, - /// The plugin whose panel is in it. - pub owner: Option, - /// It is the navigator's whole frame (a panel with `frame = true` is in it), not the board - /// under its map. - pub frame: bool, + /// The game's interface themes, the one in use, and the one a plugin asked for. + themes: Vec, + theme_now: String, + theme_ask: Option, } 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 } + 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 } } } @@ -546,17 +540,6 @@ impl UiState { self.screen } - /// The game says where the navigator's board is and whose panel is in it, or that it - /// shows none. - pub fn set_dock(&mut self, dock: Option) { - self.dock = dock; - } - - /// `omsi.ui.dock()`: the navigator's board, None while it shows none. - pub fn dock(&self) -> Option { - self.dock - } - pub fn panels(&self) -> &[PanelEntry] { &self.panels } diff --git a/crates/omsi-plugin/src/ui/dock.rs b/crates/omsi-plugin/src/ui/dock.rs new file mode 100644 index 000000000..860665927 --- /dev/null +++ b/crates/omsi-plugin/src/ui/dock.rs @@ -0,0 +1,33 @@ +//! The navigator's board as the plugins see it (`ui.dock`): where it is and whose panel is in it, +//! as the game tells it every frame. + +use super::UiState; + +/// The navigator's board as the plugins see it (`ui.dock`), in the panel's pixels. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Dock { + pub width: f32, + /// The most height there is room for. + pub room: f32, + /// The height the panel in it came to (more than `room`: it is cut off there; 0 before + /// it is laid out). + pub height: f32, + /// The plugin whose panel is in it. + pub owner: Option, + /// It is the navigator's whole frame (a panel with `frame = true` is in it), not the board + /// under its map. + pub frame: bool, +} + +impl UiState { + /// The game says where the navigator's board is and whose panel is in it, or that it + /// shows none. + pub fn set_dock(&mut self, dock: Option) { + self.dock = dock; + } + + /// `omsi.ui.dock()`: the navigator's board, None while it shows none. + pub fn dock(&self) -> Option { + self.dock + } +} diff --git a/crates/omsi-plugin/src/ui/theme.rs b/crates/omsi-plugin/src/ui/theme.rs new file mode 100644 index 000000000..cbbc7ba91 --- /dev/null +++ b/crates/omsi-plugin/src/ui/theme.rs @@ -0,0 +1,103 @@ +//! 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 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. +#[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); + } + + /// 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(), ""); + } +} diff --git a/crates/omsi-render/src/srgb_encode.rs b/crates/omsi-render/src/srgb_encode.rs index 563409366..d771c0193 100644 --- a/crates/omsi-render/src/srgb_encode.rs +++ b/crates/omsi-render/src/srgb_encode.rs @@ -93,7 +93,8 @@ impl SrgbEncode { sample_count: 1, dimension: wgpu::TextureDimension::D2, format: self.format, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + // (copied from as well: the glass behind the game's screens, see `SurfaceState::picture`) + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let view = texture.create_view(&Default::default()); @@ -107,6 +108,11 @@ impl SrgbEncode { stand_in.as_ref().map(|(_, v, _)| v.clone()).expect("stand-in") } + /// The stand-in the frame is drawn into, when there is one. + pub(crate) fn texture(&self) -> Option { + self.stand_in.borrow().as_ref().map(|(t, _, _)| t.clone()) + } + /// Encodes the stand-in into the window's `frame`. pub(crate) fn encode(&self, device: &wgpu::Device, queue: &wgpu::Queue, frame: &wgpu::SurfaceTexture) { let stand_in = self.stand_in.borrow(); diff --git a/crates/omsi-render/src/surface.rs b/crates/omsi-render/src/surface.rs index 4d9e0159b..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,6 +84,7 @@ 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 only for a glass theme: `set_glass`) let mut config = wgpu::SurfaceConfiguration { usage: wgpu::TextureUsages::RENDER_ATTACHMENT, format: encode.as_ref().map_or(renderer.format(), |e| e.window_format()), @@ -100,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 @@ -120,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,6 +138,23 @@ impl<'w> SurfaceState<'w> { 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() @@ -148,6 +168,16 @@ impl<'w> SurfaceState<'w> { } } + /// The texture `frame` is drawn into ([`Self::view`]: the window's own, or the stand-in it is + /// encoded from), to be copied from - none where it cannot be (a window that allows no copy). + pub fn picture(&self, frame: &wgpu::SurfaceTexture) -> Option { + let t = match &self.encode { + Some(e) => e.texture()?, + None => frame.texture.clone(), + }; + t.usage().contains(wgpu::TextureUsages::COPY_SRC).then_some(t) + } + /// Shows `frame`, drawn into [`Self::view`]. pub fn present(&self, device: &wgpu::Device, queue: &wgpu::Queue, frame: wgpu::SurfaceTexture) { if let Some(e) = &self.encode { diff --git a/crates/omsi-ui/src/icons.rs b/crates/omsi-ui/src/icons.rs index f9e88b2df..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()); @@ -40,8 +53,313 @@ 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 = tree(src)?; + 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 = 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(); + // (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 +} + +/// 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 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()) + }; + 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(); + // (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: (c.x, c.y), + }); + } + 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 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 + /// 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(" 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); + } } diff --git a/crates/omsi-ui/src/kit.rs b/crates/omsi-ui/src/kit.rs index 80ada6671..0ee2cb35f 100644 --- a/crates/omsi-ui/src/kit.rs +++ b/crates/omsi-ui/src/kit.rs @@ -14,7 +14,7 @@ use hashbrown::HashMap; use crate::paint::Align; use crate::{Atlas, Color, Fonts, Layer, Painter, Rect, Vertex, Weight}; -use crate::theme::{palette, Theme, DARK, RADIUS}; +use crate::theme::*; pub type Id = u64; @@ -218,7 +218,9 @@ pub struct Ui { /// surfaces in its graphite steps (`SURFACE`, `SURFACE_2` ...) and no edge round a panel, /// a field, a button or a popup. pub flat: bool, - pub theme: Theme, + /// The game's picture behind the screens, blurred (a texture of the layers): what a glass + /// theme's panels show through them. None: they are only see-through. + pub backdrop: Option, tooltip: Option<(String, Vec2)>, sliders: Vec, /// (tests) Where each clickable widget was this frame, by id. @@ -254,7 +256,7 @@ impl Ui { clipboard_in: None, over_ui: false, flat: false, - theme: DARK, + backdrop: None, tooltip: None, sliders: Vec::new(), #[cfg(any(test, feature = "test-support"))] @@ -262,10 +264,6 @@ impl Ui { } } - pub fn set_theme(&mut self, light: bool) { - self.theme = palette(light); - } - /// Start a frame: the window's size in points and its scale. pub fn begin(&mut self, size: Vec2, scale: f32, dt: f32) { self.size = size; @@ -415,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); @@ -570,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 { self.theme.SURFACE } else { self.theme.PANEL }; - self.p().rounded(r, RADIUS, fill); - self.border(r, RADIUS, 1.0, self.theme.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`. @@ -584,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 { (self.theme.SURFACE_2, self.theme.SURFACE_3) } else { (self.theme.FIELD, self.theme.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, self.theme.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, self.theme.TEXT_DIM, Align::Left); + self.text_in(text, r, 13.0, Weight::Medium, th().text_dim, Align::Left); } // --- controls ------------------------------------------------------------------------ @@ -607,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 => (self.theme.ACCENT.mix(self.theme.ACCENT_HOVER, t), self.theme.ACCENT_INK, Color::CLEAR), - ButtonKind::Primary => (self.theme.ACCENT.lighten(0.08 * t), self.theme.ACCENT_INK, Color::CLEAR), - ButtonKind::Danger if self.flat => (self.theme.SURFACE_2.mix(self.theme.DANGER.darken(0.72), t), self.theme.DANGER_2, Color::CLEAR), - ButtonKind::Danger => (self.theme.FIELD.mix(self.theme.HOVER, t), self.theme.DANGER, self.theme.DANGER.alpha(0.35 + 0.3 * t)), - ButtonKind::Normal if self.flat => (self.theme.SURFACE_2.mix(self.theme.SURFACE_3, t), self.theme.INK, Color::CLEAR), - ButtonKind::Normal => (self.theme.FIELD.mix(self.theme.HOVER, t), self.theme.TEXT, self.theme.EDGE), - ButtonKind::Ghost => (self.theme.INK.alpha(0.05 * t), if h { self.theme.TEXT } else { self.theme.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), th().accent_ink, Color::CLEAR), + ButtonKind::Danger if self.flat => (th().surface_2.mix(th().danger.darken(0.72), 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 => (th().ink.alpha(0.05 * t), if h { th().text } else { th().text_dim }, Color::CLEAR), }; let rad = 6.0; self.p().rounded(rr, rad, fill); @@ -639,9 +665,8 @@ impl Ui { let rect = Rect::new(c.x - r, c.y - r, 2.0 * r, 2.0 * r); let (h, _, clicked) = self.interact(id, rect); let t = self.anim(id, if h { 1.0 } else { 0.0 }, 0.08); - let hover = self.theme.INK.alpha(0.06 * t); - self.p().circle(c, r, hover); - self.icon(icon, c, r * 1.1, if h { self.theme.TEXT } else { self.theme.TEXT_DIM }); + self.p().circle(c, r, th().ink.alpha(0.06 * t)); + self.icon(icon, c, r * 1.1, if h { th().text } else { th().text_dim }); if !tip.is_empty() { self.tooltip(rect, tip); } @@ -657,13 +682,13 @@ 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); - let track_c = self.theme.SURFACE_3.mix(self.theme.ACCENT, on); - let text_c = if h { self.theme.TEXT } else { self.theme.TEXT_SOFT }; - self.p().rounded(track, th * 0.5, track_c); - 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)); + let track_h = 18.0; + let track = Rect::new(r.right() - tw, r.y + (r.h - track_h) * 0.5, tw, track_h); + let track_c = th().surface_3.mix(th().accent, on); + let text_c = if h { th().text } else { th().text_soft }; + self.p().rounded(track, track_h * 0.5, track_c); + 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, text_c, Align::Left); clicked } @@ -706,19 +731,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, self.theme.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); - let track_c = self.theme.SURFACE_3; - let accent = self.theme.ACCENT; - self.p().rounded(track, th * 0.5, track_c); - 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, th().surface_3); + 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, self.theme.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 } @@ -726,7 +749,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 { (self.theme.SURFACE, self.theme.SURFACE_3) } else { (self.theme.FIELD, self.theme.SURFACE_3) }; + let (track, knob_c) = if self.flat { (th().surface, th().surface_3) } else { (th().field, th().surface_3) }; self.p().rounded(r, 6.0, track); let w = r.w / n as f32; let at = self.anim(id, *selected as f32, 0.07); @@ -740,7 +763,7 @@ impl Ui { *selected = k; changed = true; } - let c = if *selected == k { self.theme.TEXT } else if h { self.theme.TEXT_SOFT } else { self.theme.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 @@ -764,11 +787,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 { self.theme.ACCENT.alpha(0.7) } else { self.theme.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, self.theme.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 { self.theme.TEXT } else { self.theme.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) @@ -853,16 +876,16 @@ 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 { self.theme.ACCENT.alpha(0.7) } else { self.theme.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, self.theme.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); self.push_clip(inner, 0.0); let px = 13.0; - let accent = self.theme.ACCENT; + let accent = th().accent; if let Some(click_x) = click_x { let local_x = (click_x - inner.x).clamp(0.0, inner.w); @@ -908,7 +931,7 @@ impl Ui { } } if value.is_empty() && !focused { - self.text_in(placeholder, inner, px, Weight::Regular, self.theme.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(); @@ -932,7 +955,7 @@ impl Ui { } // 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, self.theme.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 @@ -944,7 +967,7 @@ impl Ui { } } if value.is_empty() { - self.text_in(placeholder, inner, px, Weight::Regular, self.theme.TEXT_FAINT, Align::Left); + self.text_in(placeholder, inner, px, Weight::Regular, th().text_faint, Align::Left); } self.pop_clip(); *value != before @@ -955,7 +978,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, self.theme.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); @@ -966,13 +989,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, self.theme.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 { self.theme.TEXT } else { self.theme.TEXT_FAINT }); - self.icon("expand_more", down.center(), 16.0, if hd { self.theme.TEXT } else { self.theme.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; } @@ -980,7 +1003,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, self.theme.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 } @@ -1001,12 +1024,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 { self.theme.ACCENT.alpha(0.7) } else { self.theme.EDGE }); - self.icon("calendar_month", Vec2::new(r.x + 20.0, r.center().y), 17.0, self.theme.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, self.theme.TEXT, Align::Left); - self.icon("expand_more", Vec2::new(r.right() - 18.0, r.center().y), 20.0, self.theme.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; @@ -1020,11 +1043,9 @@ impl Ui { /// A progress bar (0..1), animated stripes while `busy`. pub fn progress(&mut self, r: Rect, frac: f32, busy: bool) { - let track_c = self.theme.SURFACE_3; - let accent = self.theme.ACCENT; - self.p().rounded(r, r.h * 0.5, track_c); + self.p().rounded(r, r.h * 0.5, th().surface_3); 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); @@ -1073,7 +1094,7 @@ impl Ui { target = ((self.input.mouse.y - r.y - bar_h * 0.5) / (r.h - bar_h).max(1.0)) * max; } let t = self.anim(id ^ 0x77, if h || held || self.hover(r) { 1.0 } else { 0.0 }, 0.15); - let thumb = self.theme.INK.alpha(0.10 + 0.25 * t); + let thumb = th().ink.alpha(0.10 + 0.25 * t); self.p().rounded(bar, 2.0, thumb); } target = target.clamp(0.0, max); @@ -1097,14 +1118,11 @@ impl Ui { let (h, _, clicked) = self.interact(id, r); 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); - let selected_c = self.theme.SELECTED; - let accent = self.theme.ACCENT; - let hover = self.theme.HOVER; if s > 0.01 { - self.p().rounded(r, 6.0, selected_c.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 } @@ -1142,9 +1160,9 @@ impl Ui { (self.size.y - h - 8.0).max(8.0) }; let r = Rect::new(x, y, w, h); - let fill = if self.flat { self.theme.SURFACE_3 } else { self.theme.SURFACE_2 }; - let edge = self.theme.EDGE; - let text = self.theme.TEXT_SOFT; + let fill = if self.flat { th().surface_3 } else { th().surface_2 }; + let edge = th().edge; + let text = th().text_soft; self.p().rounded(r, 6.0, fill); self.border(r, 7.0, 1.0, edge); self.push_clip(Rect::new(r.x + 10.0, r.y + 4.0, text_w, (h - 8.0).max(0.0)), 2.0); @@ -1198,7 +1216,7 @@ impl Ui { fn draw_popup(&mut self) { let Some(mut p) = self.popup.take() else { return }; - let theme = self.theme; + let theme = th(); let r = popup_rect(&p, self.size); // the tap that opened the list must not also pick from it (on a small screen the // list lies under the finger) @@ -1206,9 +1224,9 @@ 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 = theme.SURFACE_2.alpha(e); + let fill = theme.surface_2.alpha(e); self.p().rounded(rr, 8.0, fill); - self.border(rr, 8.0, 1.0, theme.EDGE.alpha(e)); + self.border(rr, 8.0, 1.0, theme.edge.alpha(e)); let row = 34.0; let shown = p.shown(); // (what was typed, over the options it leaves) @@ -1239,11 +1257,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, theme.TEXT_DIM); + self.icon("search", Vec2::new(rr.x + 22.0, y + row * 0.5), 16.0, theme.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, theme.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, theme.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, theme.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, theme.text_dim, Align::Left); } } for (n, &k) in shown.iter().enumerate() { @@ -1255,12 +1273,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, theme.SELECTED); - self.icon("check", Vec2::new(cell.right() - 16.0, cell.center().y), 15.0, theme.ACCENT); + self.p().rounded(cell, 5.0, theme.selected); + self.icon("check", Vec2::new(cell.right() - 16.0, cell.center().y), 15.0, theme.accent); } else if h { - self.p().rounded(cell, 5.0, theme.HOVER); + self.p().rounded(cell, 5.0, theme.hover); } - self.text_in(o, Rect::new(cell.x + 10.0, cell.y, cell.w - 36.0, cell.h), 13.0, Weight::Regular, theme.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, theme.text, Align::Left); if h { self.cursor = winit::window::CursorIcon::Pointer; if self.input.released && !fresh { @@ -1273,7 +1291,7 @@ impl Ui { let y = rr.y + (rr.h - bar_h) * (p.scroll / max.max(1.0)); let wide = dragging || track.contains(self.input.mouse); let w = if wide { 5.0 } else { 3.0 }; - self.p().rounded(Rect::new(rr.right() - 2.0 - w, y, w, bar_h), w * 0.5, theme.INK.alpha(if wide { 0.5 } else { 0.3 })); + self.p().rounded(Rect::new(rr.right() - 2.0 - w, y, w, bar_h), w * 0.5, theme.ink.alpha(if wide { 0.5 } else { 0.3 })); } if rr.contains(self.input.mouse) { self.over_ui = true; @@ -1283,18 +1301,18 @@ impl Ui { fn draw_date_popup(&mut self) { let Some(mut p) = self.date_popup.take() else { return }; - let theme = self.theme; + let theme = th(); 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 = theme.SURFACE_2.alpha(e); + let fill = theme.surface_2.alpha(e); self.p().rounded(r, 8.0, fill); - self.border(r, 8.0, 1.0, theme.EDGE.alpha(e)); + self.border(r, 8.0, 1.0, theme.edge.alpha(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, theme.TEXT, Align::Center); + self.text_in(&format!("{} {}", MONTHS_LONG[p.month as usize - 1], p.year), head, 14.0, Weight::Bold, theme.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); @@ -1302,10 +1320,10 @@ impl Ui { for (b, icon) in [(prev, "chevron_left"), (next, "chevron_right"), (py, "expand_more"), (ny, "expand_less")] { let h = b.contains(m); if h { - self.p().rounded(b, 6.0, theme.INK.alpha(0.08)); + self.p().rounded(b, 6.0, theme.ink.alpha(0.08)); self.cursor = winit::window::CursorIcon::Pointer; } - self.icon(icon, b.center(), 20.0, if h { theme.ACCENT } else { theme.TEXT_DIM }); + self.icon(icon, b.center(), 20.0, if h { theme.accent } else { theme.text_dim }); } if click && prev.contains(m) { if p.month == 1 { @@ -1331,7 +1349,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 { theme.ACCENT.alpha(0.8) } else { theme.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 { theme.accent.alpha(0.8) } else { theme.text_faint }, Align::Center); } let first = weekday(p.year, p.month, 1); let days = days_in_month(p.year, p.month); @@ -1343,11 +1361,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, theme.ACCENT); + self.p().rounded(cell, 7.0, theme.accent); } else if h { - self.p().rounded(cell, 7.0, theme.INK.alpha(0.09)); + self.p().rounded(cell, 7.0, theme.ink.alpha(0.09)); } - self.text_in(&d.to_string(), cell, 12.5, Weight::Medium, if is_chosen { theme.ACCENT_INK } else { theme.TEXT }, Align::Center); + self.text_in(&d.to_string(), cell, 12.5, Weight::Medium, if is_chosen { theme.accent_ink } else { theme.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 b25c70233..e54e16647 100644 --- a/crates/omsi-ui/src/theme.rs +++ b/crates/omsi-ui/src/theme.rs @@ -1,135 +1,256 @@ //! 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, +//! dark ([`Palette::OPENOMSI`]) or light ([`Palette::LIGHT`]) as the player chose +//! ([`palette`]), and over it the colours of an interface theme the player chose +//! (`Themes//theme.json`, docs/THEMES.md: [`Palette::with_colors`]), put 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); +/// 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)] -#[allow(non_snake_case)] -pub struct Theme { - 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, - pub RAIL: Color, - pub PANEL: Color, - pub FIELD: Color, - pub HOVER: Color, - pub SELECTED: Color, - pub EDGE: Color, - 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, +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, + }; + + /// openOMSI's light look (the setting `ui_theme` "light"). + pub const LIGHT: Palette = Palette { + accent: Color::rgba(185, 120, 14, 1.0), + accent_2: Color::rgba(52, 116, 184, 1.0), + danger: Color::rgba(188, 54, 48, 1.0), + ok: Color::rgba(42, 132, 62, 1.0), + warn: Color::rgba(180, 112, 18, 1.0), + text: Color::rgba(32, 32, 32, 1.0), + text_soft: Color::rgba(72, 72, 72, 1.0), + text_dim: Color::rgba(104, 104, 104, 1.0), + text_faint: Color::rgba(136, 136, 136, 1.0), + rail: Color::rgba(239, 239, 239, 1.0), + panel: Color::rgba(252, 252, 252, 1.0), + field: Color::rgba(246, 246, 246, 1.0), + hover: Color::rgba(234, 234, 234, 1.0), + selected: Color::rgba(224, 224, 224, 1.0), + edge: Color::rgba(0, 0, 0, 0.12), + bg: Color::rgba(247, 247, 247, 1.0), + sidebar: Color::rgba(239, 239, 239, 1.0), + surface: Color::rgba(255, 255, 255, 1.0), + surface_2: Color::rgba(242, 242, 242, 1.0), + surface_3: Color::rgba(230, 230, 230, 1.0), + ink: Color::rgba(28, 28, 28, 1.0), + ink_2: Color::rgba(76, 76, 76, 1.0), + ink_muted: Color::rgba(112, 112, 112, 1.0), + accent_hover: Color::rgba(204, 137, 22, 1.0), + accent_ink: Color::rgba(30, 20, 4, 1.0), + danger_2: Color::rgba(203, 62, 55, 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 `base` (openOMSI's dark or light look, + /// [`palette`]); 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>(base: Palette, given: impl IntoIterator) -> (Palette, Vec<&'a str>) { + let mut p = base; + 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) + } +} + +/// openOMSI's own look, light or dark. +pub fn palette(light: bool) -> Palette { + if light { Palette::LIGHT } else { Palette::OPENOMSI } +} + +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())) } -pub const DARK: Theme = Theme { - ACCENT, - ACCENT_2, - DANGER, - OK, - WARN, - TEXT, - TEXT_SOFT, - TEXT_DIM, - TEXT_FAINT, - RAIL, - PANEL, - FIELD, - HOVER, - SELECTED, - EDGE, - BG, - SIDEBAR, - SURFACE, - SURFACE_2, - SURFACE_3, - INK, - INK_2, - INK_MUTED, - ACCENT_HOVER, - ACCENT_INK, - DANGER_2, -}; - -pub const LIGHT: Theme = Theme { - ACCENT: Color::rgba(185, 120, 14, 1.0), - ACCENT_2: Color::rgba(52, 116, 184, 1.0), - DANGER: Color::rgba(188, 54, 48, 1.0), - OK: Color::rgba(42, 132, 62, 1.0), - WARN: Color::rgba(180, 112, 18, 1.0), - TEXT: Color::rgba(32, 32, 32, 1.0), - TEXT_SOFT: Color::rgba(72, 72, 72, 1.0), - TEXT_DIM: Color::rgba(104, 104, 104, 1.0), - TEXT_FAINT: Color::rgba(136, 136, 136, 1.0), - RAIL: Color::rgba(239, 239, 239, 1.0), - PANEL: Color::rgba(252, 252, 252, 1.0), - FIELD: Color::rgba(246, 246, 246, 1.0), - HOVER: Color::rgba(234, 234, 234, 1.0), - SELECTED: Color::rgba(224, 224, 224, 1.0), - EDGE: Color::rgba(0, 0, 0, 0.12), - BG: Color::rgba(247, 247, 247, 1.0), - SIDEBAR: Color::rgba(239, 239, 239, 1.0), - SURFACE: Color::rgba(255, 255, 255, 1.0), - SURFACE_2: Color::rgba(242, 242, 242, 1.0), - SURFACE_3: Color::rgba(230, 230, 230, 1.0), - INK: Color::rgba(28, 28, 28, 1.0), - INK_2: Color::rgba(76, 76, 76, 1.0), - INK_MUTED: Color::rgba(112, 112, 112, 1.0), - ACCENT_HOVER: Color::rgba(204, 137, 22, 1.0), - ACCENT_INK: Color::rgba(30, 20, 4, 1.0), - DANGER_2: Color::rgba(203, 62, 55, 1.0), -}; - -pub fn palette(light: bool) -> Theme { - if light { LIGHT } else { DARK } +/// A corner of `r` as openOMSI draws it, moved by as much as the palette's `radius` differs from +/// openOMSI's: a theme's rounder (or squarer) corners for every surface, the openOMSI look's own +/// unchanged. +pub fn corner(r: f32) -> f32 { + (r + th().radius - Palette::OPENOMSI.radius).max(0.0) +} + +/// 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 } -pub const RADIUS: f32 = 8.0; /// Height of a control row. pub const ROW: f32 = 36.0; pub const GAP: f32 = 12.0; @@ -143,6 +264,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::*; @@ -157,16 +300,58 @@ mod tests { } #[test] - fn dark_and_light_palettes_are_distinct_but_share_opacity_rules() { - let dark = palette(false); - let light = palette(true); - - assert_eq!(dark, DARK); - assert_eq!(light, LIGHT); - assert_ne!(dark, light); - assert_ne!(dark.BG, light.BG); - assert_ne!(dark.TEXT, light.TEXT); - assert_eq!(backdrop(0.425), 0.5); - assert_eq!(backdrop(0.85), 1.0); + 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(Palette::OPENOMSI, [("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(Palette::OPENOMSI, [("background", navy), ("sidebar", orange)]); + assert_eq!((p.rail, p.sidebar), (navy, orange)); + } + + /// The light look is a palette of its own, with openOMSI's corners and no glass; a theme's + /// colours go over the look chosen, what it leaves out stays that look's. + #[test] + fn a_theme_goes_over_the_light_or_the_dark_look() { + assert_eq!(palette(false), Palette::OPENOMSI); + assert_eq!(palette(true), Palette::LIGHT); + assert_ne!(Palette::LIGHT.bg, Palette::OPENOMSI.bg); + assert_ne!(Palette::LIGHT.text, Palette::OPENOMSI.text); + assert_eq!((Palette::LIGHT.radius, Palette::LIGHT.glass), (Palette::OPENOMSI.radius, None)); + let orange = Color::rgba(245, 134, 32, 1.0); + let (p, _) = Palette::with_colors(palette(true), [("accent", orange)]); + assert_eq!((p.accent, p.warn), (orange, orange)); + assert_eq!((p.bg, p.text, p.surface), (Palette::LIGHT.bg, Palette::LIGHT.text, Palette::LIGHT.surface)); + } + + /// 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 8abd41f4f..39d212177 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -338,7 +338,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 `6` (`5` before a navigator panel in the whole frame and the `map` +`omsi.ui.version` is `7` (`6` before the interface themes, `5` before a navigator panel in the whole frame and the `map` element, `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. @@ -481,6 +481,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): @@ -900,7 +918,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..f0d472cfb --- /dev/null +++ b/docs/THEMES.md @@ -0,0 +1,119 @@ +# 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 › Theme pack**; openOMSI's own look is +the first entry. The choice is kept in the settings file (`ui_theme_pack`) 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. + +A theme goes over openOMSI's light or dark look, whichever the player chose in **Options › +Interface › Interface theme** (`ui_theme`): its colours, corners and glass on top, and what it +leaves out from that look. A theme that gives all its surfaces and inks looks the same in both; one +of a few colours (an accent alone) is a light or a dark look with its accent. + +## 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 (light or dark, as chosen). + +| 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: 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 +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. + +**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, 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 + +`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 c3b501a8e..65191af72 100644 --- a/docs/plugin-api.json +++ b/docs/plugin-api.json @@ -3801,6 +3801,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", @@ -3822,6 +3838,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",