From bf06ca7832473db33c456e7db1a0f820e66e2dd5 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:40:15 +0800 Subject: [PATCH] feat(app): Show a themed splash until the launch project opens Project windows are created in setup with the saved theme's frame colour, and the splash covers the first render while the launch project opens, so a window no longer flashes white or the Welcome screen. Single-instance is now release-only so a dev build can run beside an installed release. --- index.html | 52 ++++++++++++++++++ src-tauri/src/lib.rs | 8 ++- src-tauri/src/windows.rs | 91 ++++++++++++++++++++++++------- src-tauri/tauri.conf.json | 1 + src-tauri/tauri.windows.conf.json | 1 + src/features/app/windows.ts | 5 +- src/main.tsx | 2 + src/platform/splash.ts | 29 ++++++++++ 8 files changed, 166 insertions(+), 23 deletions(-) create mode 100644 src/platform/splash.ts diff --git a/index.html b/index.html index 095dfe42..6cd9d566 100644 --- a/index.html +++ b/index.html @@ -5,9 +5,61 @@ OpenIDEA +
+
+ + OpenIDEA +
+
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d42cc32c..2701f130 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -50,9 +50,12 @@ pub fn run() { #[cfg(debug_assertions)] export_bindings(&builder); + let app = tauri::Builder::default(); // Single instance first: a second launch focuses this one and opens its path argument. - let app = tauri::Builder::default() - .plugin(tauri_plugin_single_instance::init(windows::on_second_instance)) + // Release only, so a dev build can run beside an installed release (same identifier). + #[cfg(not(debug_assertions))] + let app = app.plugin(tauri_plugin_single_instance::init(windows::on_second_instance)); + let app = app .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin( @@ -84,6 +87,7 @@ pub fn run() { app.manage(windows); app.manage(menu_state); app.on_menu_event(on_menu_event); + windows::create_main_window(app.handle())?; Ok(()) }) .on_window_event(|window, event| match event { diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 6418f3aa..de8ff3ad 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -11,6 +11,7 @@ //! [`Launch::settings_page`] tells it which page to show. use crate::state::AppState; +use ide_core::settings::AppearanceSettings; use ide_core::{IdeError, IdeResult}; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; @@ -18,7 +19,8 @@ use specta::Type; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; -use tauri::{AppHandle, Emitter, Manager, WebviewWindow, WebviewWindowBuilder}; +use tauri::utils::config::{Color, WindowConfig}; +use tauri::{AppHandle, Emitter, Manager, Theme, WebviewWindow, WebviewWindowBuilder}; /// Tells a window that a pending launch is waiting for it. pub const LAUNCH_EVENT: &str = "openidea://launch"; @@ -195,19 +197,66 @@ pub fn target_window(app: &AppHandle) -> Option { sorted_labels(windows.keys().map(String::as_str)).first().and_then(|l| windows.get(l)).cloned() } -/// Creates a new project window; `project` is opened by its frontend on load. -pub fn create_window(app: &AppHandle, project: Option) -> IdeResult { - let windows = app.webview_windows(); - let label = next_label(windows.keys().map(String::as_str)); - let mut config = app - .config() +fn window_config(app: &AppHandle) -> IdeResult { + app.config() .app .windows .iter() .find(|w| w.label == MAIN) .or_else(|| app.config().app.windows.first()) .cloned() - .ok_or_else(|| IdeError::internal("no window configuration"))?; + .ok_or_else(|| IdeError::internal("no window configuration")) +} + +/// The theme's `--bg-frame` (tokens.css), mirroring `resolveThemeId` in theme.ts. +pub fn frame_color(appearance: &AppearanceSettings, os_dark: bool) -> Color { + const LIGHT: Color = Color(0xe9, 0xea, 0xee, 0xff); + const DARK: Color = Color(0x26, 0x28, 0x2c, 0xff); + const DARK_HC: Color = Color(0x1a, 0x1b, 0x1e, 0xff); + let sync = appearance.sync_with_os || appearance.theme == "system"; + let theme = match (sync, os_dark) { + (false, _) => &appearance.theme, + (true, true) => &appearance.preferred_dark_theme, + (true, false) => &appearance.preferred_light_theme, + }; + match theme.as_str() { + "dark-hc" => DARK_HC, + "dark" => DARK, + "light" => LIGHT, + _ if sync && os_dark => DARK, + _ => LIGHT, + } +} + +/// Builds a project window painted in the saved theme's frame colour, so it +/// doesn't flash the light default before its page (and splash) loads. +fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult { + let appearance = app.try_state::().map(|s| s.global_settings.effective().appearance); + if let Some(a) = &appearance { + config.background_color = Some(frame_color(a, false)); + } + let window = WebviewWindowBuilder::from_config(app, &config) + .and_then(|b| b.build()) + .map_err(|e| IdeError::internal(format!("cannot create window: {e}")))?; + // "Sync with OS" needs the OS mode, known once the window exists. + if let Some(a) = appearance.filter(|a| a.sync_with_os || a.theme == "system") + && window.theme().is_ok_and(|t| t == Theme::Dark) + { + let _ = window.set_background_color(Some(frame_color(&a, true))); + } + Ok(window) +} + +/// The first window (`create: false` in tauri.conf.json so it can take the theme's colour). +pub fn create_main_window(app: &AppHandle) -> IdeResult { + build_project_window(app, window_config(app)?) +} + +/// Creates a new project window; `project` is opened by its frontend on load. +pub fn create_window(app: &AppHandle, project: Option) -> IdeResult { + let windows = app.webview_windows(); + let label = next_label(windows.keys().map(String::as_str)); + let mut config = window_config(app)?; config.label = label.clone(); if let Some(name) = project.as_deref().and_then(Path::file_name) { config.title = name.to_string_lossy().into_owned(); @@ -223,9 +272,7 @@ pub fn create_window(app: &AppHandle, project: Option) -> IdeResult().set_launch(&label, Launch { project: project.map(Into::into), settings_page: None }); - let window = WebviewWindowBuilder::from_config(app, &config) - .and_then(|b| b.build()) - .map_err(|e| IdeError::internal(format!("cannot create window: {e}")))?; + let window = build_project_window(app, config)?; focus(&window); Ok(window) } @@ -300,6 +347,7 @@ pub fn open_path(app: &AppHandle, path: &Path) { } /// Second instance launched: focus this one and open its path argument. +#[cfg_attr(debug_assertions, allow(dead_code))] pub fn on_second_instance(app: &AppHandle, args: Vec, cwd: String) { match cli_path(&args, Path::new(&cwd)) { Some(path) => open_path(app, &path), @@ -342,15 +390,7 @@ pub fn open_settings_window( return Ok(()); } let parent = app.get_webview_window(&parent_label).ok_or_else(|| IdeError::not_found("no parent window"))?; - let mut config = app - .config() - .app - .windows - .iter() - .find(|w| w.label == MAIN) - .or_else(|| app.config().app.windows.first()) - .cloned() - .ok_or_else(|| IdeError::internal("no window configuration"))?; + let mut config = window_config(app)?; config.label = label.clone(); config.title = "Settings".into(); // Frameless on Windows like project windows (tauri.windows.conf.json): the page draws @@ -398,6 +438,17 @@ mod tests { v.iter().map(|x| x.to_string()).collect() } + #[test] + fn frame_color_follows_theme_and_os_mode() { + let mut a = AppearanceSettings::default(); + assert_eq!(frame_color(&a, true), Color(0xe9, 0xea, 0xee, 0xff)); + a.theme = "dark-hc".into(); + assert_eq!(frame_color(&a, false), Color(0x1a, 0x1b, 0x1e, 0xff)); + a.sync_with_os = true; + assert_eq!(frame_color(&a, false), Color(0xe9, 0xea, 0xee, 0xff)); + assert_eq!(frame_color(&a, true), Color(0x26, 0x28, 0x2c, 0xff)); + } + #[test] fn labels_sort_main_first_then_numerically() { assert_eq!( diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index acd4997d..1da4f908 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -13,6 +13,7 @@ "windows": [ { "label": "main", + "create": false, "title": "OpenIDEA", "width": 1440, "height": 900, diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json index 7e73e425..d3fb6d74 100644 --- a/src-tauri/tauri.windows.conf.json +++ b/src-tauri/tauri.windows.conf.json @@ -3,6 +3,7 @@ "windows": [ { "label": "main", + "create": false, "title": "OpenIDEA", "width": 1440, "height": 900, diff --git a/src/features/app/windows.ts b/src/features/app/windows.ts index c4cadfd4..7557653d 100644 --- a/src/features/app/windows.ts +++ b/src/features/app/windows.ts @@ -7,6 +7,7 @@ import type { Action } from "@/platform/actions"; import { t } from "@/platform/i18n"; import { call, commands, isTauri } from "@/platform/ipc"; import { basename, confirmLeaveProject, openProject, provideStartupLaunch, useProject } from "@/platform/project"; +import { holdSplash } from "@/platform/splash"; import { notifications } from "@/workbench/contributions"; import { ask } from "./prompt"; @@ -195,7 +196,9 @@ export function installLaunchHandling(): () => void { if (!isTauri()) return () => {}; let disposed = false; let unlisten: (() => void) | undefined; - provideStartupLaunch(consumeLaunch()); + const launch = consumeLaunch(); + provideStartupLaunch(launch); + holdSplash(launch); void import("@tauri-apps/api/webviewWindow").then(async ({ getCurrentWebviewWindow }) => { const off = await getCurrentWebviewWindow().listen(LAUNCH_EVENT, () => void consumeLaunch()); if (disposed) off(); diff --git a/src/main.tsx b/src/main.tsx index 246beeff..df819916 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,6 +6,7 @@ import { activateSecondaryWindow, activateWorkbench } from "./workbench/activate import { activateFeatures, activateSettingsWindowFeatures, SettingsWindow, settingsWindowParent } from "./features"; import { installKeyboardDispatcher } from "./platform/keymap"; import { isTauri } from "./platform/ipc"; +import { hideSplash } from "./platform/splash"; import { prefetchLazyComponents } from "./ui"; if (import.meta.env.DEV && isTauri()) { @@ -42,5 +43,6 @@ if (settingsParent !== null) { ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( {settingsParent !== null ? : }, ); +hideSplash(); // Views split out of the start-up bundle load in the background once the UI is up. if (settingsParent === null) prefetchLazyComponents(); diff --git a/src/platform/splash.ts b/src/platform/splash.ts new file mode 100644 index 00000000..d879d159 --- /dev/null +++ b/src/platform/splash.ts @@ -0,0 +1,29 @@ +/** + * The start-up splash (`#splash` in index.html). It covers the first render and + * stays up while start-up work holds it (opening the window's launch project), + * so a project window never flashes the Welcome screen first. + */ +const holds: Promise[] = []; +/** Never keep the splash longer than this; a slow open shows its own progress. */ +const MAX_WAIT_MS = 4000; + +/** Keeps the splash up until `work` settles. Call before `hideSplash()`. */ +export function holdSplash(work: Promise): void { + holds.push(work.catch(() => {})); +} + +/** Fades the splash out once every hold has settled and the page has painted. */ +export function hideSplash(): void { + const el = document.getElementById("splash"); + if (!el) return; + const timeout = new Promise((resolve) => setTimeout(resolve, MAX_WAIT_MS)); + void Promise.race([Promise.all(holds), timeout]).then(() => + // Two frames: let React commit the state the holds produced before uncovering it. + requestAnimationFrame(() => + requestAnimationFrame(() => { + el.classList.add("splash-out"); + setTimeout(() => el.remove(), 200); + }), + ), + ); +}