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); + }), + ), + ); +}