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.
This commit is contained in:
l0ng-ai
2026-10-23 15:40:41 +08:00
parent 63a03d1c08
commit bf06ca7832
8 changed files with 166 additions and 23 deletions
+52
View File
@@ -5,9 +5,61 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenIDEA</title>
<script src="/boot.js"></script>
<style>
/* Start-up splash over the page until the window's launch project is open (platform/splash.ts).
Tokens aren't loaded yet, so the colours mirror --bg-frame / --fg-muted / --accent. */
html, body, .splash { background: #e9eaee; }
:root[data-theme="dark"] :is(body, .splash) { background: #26282c; }
:root:is([data-theme="dark-hc"], [data-theme="dark"][data-contrast="high"]) :is(body, .splash) { background: #1a1b1e; }
.splash {
position: fixed;
inset: 0;
z-index: 2147483647;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding-bottom: 38px;
box-sizing: border-box;
font: 500 15px/1.2 "Inter Variable", Inter, system-ui, -apple-system, sans-serif;
color: #6c707e;
-webkit-user-select: none;
user-select: none;
cursor: default;
animation: splash-in 240ms ease-out 120ms both;
transition: opacity 160ms ease-out;
}
.splash.splash-out { opacity: 0; pointer-events: none; }
:root[data-theme^="dark"] .splash { color: #8c8f95; }
.splash svg { width: 56px; height: 56px; }
.splash-bar { width: 120px; height: 2px; border-radius: 1px; overflow: hidden; background: rgb(127 127 127 / 0.18); }
.splash-bar::after {
content: "";
display: block;
width: 40%;
height: 100%;
background: #3871e1;
animation: splash-bar 1.1s ease-in-out infinite;
}
@keyframes splash-in { from { opacity: 0; } }
@keyframes splash-bar { from { transform: translateX(-100%); } to { transform: translateX(250%); } }
@media (prefers-reduced-motion: reduce) {
.splash, .splash-bar::after { animation: none; }
}
</style>
</head>
<body>
<div id="root"></div>
<div id="splash" class="splash" data-tauri-drag-region>
<svg viewBox="0 0 64 64" aria-hidden="true">
<rect width="64" height="64" rx="15" fill="#0E0E10" />
<circle cx="26" cy="32" r="11" fill="none" stroke="#FFFFFF" stroke-width="8" />
<rect x="43" y="18" width="8" height="28" rx="1.5" fill="#FF4F1A" />
</svg>
<span>OpenIDEA</span>
<div class="splash-bar" role="progressbar" aria-label="Loading"></div>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6 -2
View File
@@ -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 {
+71 -20
View File
@@ -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<WebviewWindow> {
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<PathBuf>) -> IdeResult<WebviewWindow> {
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<WindowConfig> {
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<WebviewWindow> {
let appearance = app.try_state::<AppState>().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<WebviewWindow> {
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<PathBuf>) -> IdeResult<WebviewWindow> {
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<PathBuf>) -> IdeResult<Web
config.center = false;
}
app.state::<WindowsState>().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<String>, 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!(
+1
View File
@@ -13,6 +13,7 @@
"windows": [
{
"label": "main",
"create": false,
"title": "OpenIDEA",
"width": 1440,
"height": 900,
+1
View File
@@ -3,6 +3,7 @@
"windows": [
{
"label": "main",
"create": false,
"title": "OpenIDEA",
"width": 1440,
"height": 900,
+4 -1
View File
@@ -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();
+2
View File
@@ -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(
<React.StrictMode>{settingsParent !== null ? <SettingsWindow /> : <Workbench />}</React.StrictMode>,
);
hideSplash();
// Views split out of the start-up bundle load in the background once the UI is up.
if (settingsParent === null) prefetchLazyComponents();
+29
View File
@@ -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<unknown>[] = [];
/** 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<unknown>): 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);
}),
),
);
}