Файли
openidea/src-tauri/src/windows.rs
T
Walter WangтаClaude 3b608355a3 feat(themes): Built-in Solarized and One light/dark themes (#85)
Adds Solarized Light, Solarized Dark, One Light and One Dark as built-in themes across the workbench, editor, terminal and Mermaid, keeping the original palettes (MIT notices in THIRD_PARTY_LICENSES.md, mapping in docs/themes.md).

- Native window colours are read from tokens.css at build time (`theme_frame.rs`) instead of a second colour table.
- Startup restores the built-in palette before first paint.
- Editor schemes, the terminal and Mermaid refresh on switches between themes of the same kind.
- The plugin packages of the same themes are removed.
- A test requires every built-in palette theme to define each token (only listed ones may inherit from the base theme), so new tokens can't silently miss them.

Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 18:53:12 +08:00

561 рядки
22 KiB
Rust
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
//! Project windows: one project per window, labelled `main` or `project-<n>`.
//!
//! A new window is built from the `main` window config (overlay title bar,
//! traffic lights, min size, background colour). What it should open is kept
//! here as a pending [`Launch`] that its frontend takes on startup
//! (`window_take_launch`), and re-taken whenever [`LAUNCH_EVENT`] fires (a
//! second app instance asking an already-loaded window to open a path).
//!
//! Settings open in a native child window `settings-<parent>` (one per project
//! window) that shares its parent's project (see [`AppState::project`]); its
//! [`Launch::settings_page`] tells it which page to show.
use crate::state::AppState;
use crate::theme_frame::builtin_frame;
use ide_core::settings::AppearanceSettings;
use ide_core::{IdeError, IdeResult};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
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";
const MAIN: &str = "main";
const PREFIX: &str = "project-";
const SETTINGS_PREFIX: &str = "settings-";
const CASCADE: f64 = 28.0;
const SETTINGS_SIZE: (f64, f64) = (980.0, 720.0);
const SETTINGS_MIN_SIZE: (f64, f64) = (640.0, 420.0);
/// What a freshly opened window should do once its frontend is up.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Launch {
/// Project folder to open.
pub project: Option<ide_core::IpcPath>,
/// Settings windows: the page to show.
pub settings_page: Option<String>,
/// Settings windows: edit the global layer only ("Settings for New Projects").
pub new_projects: bool,
}
#[derive(Default)]
pub struct WindowsState {
launches: Mutex<HashMap<String, Launch>>,
/// Most recently focused window label (menu events go there).
last_focused: Mutex<Option<String>>,
/// Set by Quit while windows close one by one.
quitting: AtomicBool,
/// Relaunch once the quit completes (after installing an update).
restart: AtomicBool,
}
impl WindowsState {
pub fn set_launch(&self, label: &str, launch: Launch) {
self.launches.lock().insert(label.to_owned(), launch);
}
pub fn take_launch(&self, label: &str) -> Option<Launch> {
self.launches.lock().remove(label)
}
pub fn focused(&self, label: &str) {
*self.last_focused.lock() = Some(label.to_owned());
}
pub fn forget(&self, label: &str) {
self.launches.lock().remove(label);
let mut last = self.last_focused.lock();
if last.as_deref() == Some(label) {
*last = None;
}
}
pub fn last_focused(&self) -> Option<String> {
self.last_focused.lock().clone()
}
pub fn set_quitting(&self, on: bool) {
self.quitting.store(on, Ordering::SeqCst);
if !on {
self.restart.store(false, Ordering::SeqCst);
}
}
pub fn set_restart(&self) {
self.restart.store(true, Ordering::SeqCst);
}
pub fn restart_requested(&self) -> bool {
self.restart.load(Ordering::SeqCst)
}
pub fn quitting(&self) -> bool {
self.quitting.load(Ordering::SeqCst)
}
}
/// The project window a settings window belongs to (`settings-main` → `main`).
pub fn settings_parent(label: &str) -> Option<&str> {
label.strip_prefix(SETTINGS_PREFIX)
}
pub fn is_settings_window(label: &str) -> bool {
settings_parent(label).is_some()
}
pub fn settings_label(parent: &str) -> String {
format!("{SETTINGS_PREFIX}{parent}")
}
/// Closes `parent`'s settings window: it shows (and would apply to) the project the parent is leaving.
pub fn close_settings_window(app: &AppHandle, parent: &str) {
if let Some(w) = app.get_webview_window(&settings_label(parent)) {
let _ = w.close();
}
}
/// Project windows (everything but settings windows), for counting and cycling.
pub fn project_window_labels(app: &AppHandle) -> Vec<String> {
sorted_labels(app.webview_windows().keys().map(String::as_str).filter(|l| !is_settings_window(l)))
}
/// Sort key: `main` first, then `project-<n>` by n, then anything else.
fn order_key(label: &str) -> (u8, u64, String) {
if label == MAIN {
(0, 0, String::new())
} else if let Some(n) = label.strip_prefix(PREFIX).and_then(|n| n.parse().ok()) {
(1, n, String::new())
} else {
(2, 0, label.to_owned())
}
}
pub fn sorted_labels<'a>(labels: impl IntoIterator<Item = &'a str>) -> Vec<String> {
let mut v: Vec<String> = labels.into_iter().map(str::to_owned).collect();
v.sort_by_key(|l| order_key(l));
v
}
/// Lowest free `project-<n>` label.
pub fn next_label<'a>(existing: impl IntoIterator<Item = &'a str>) -> String {
let used: std::collections::HashSet<&str> = existing.into_iter().collect();
(1..).map(|n| format!("{PREFIX}{n}")).find(|l| !used.contains(l.as_str())).expect("unbounded")
}
/// The window after (or before) `current` in label order, wrapping around.
pub fn cycle_target(sorted: &[String], current: &str, forward: bool) -> Option<String> {
if sorted.len() < 2 {
return None;
}
let i = sorted.iter().position(|l| l == current).unwrap_or(0);
let n = sorted.len();
let j = if forward { (i + 1) % n } else { (i + n - 1) % n };
Some(sorted[j].clone())
}
/// The project path from command-line arguments (`openidea /path/to/project`):
/// the first argument after the binary that is not a flag, resolved against `cwd`.
pub fn cli_path(args: &[String], cwd: &Path) -> Option<PathBuf> {
args.iter().skip(1).find(|a| !a.starts_with('-') && !a.is_empty() && !a.starts_with("psn_")).map(|a| {
let p = PathBuf::from(a);
if p.is_absolute() { p } else { cwd.join(p) }
})
}
pub fn focus(window: &WebviewWindow) {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
/// The window whose open project is `root` (already canonical).
pub fn find_project_window(app: &AppHandle, root: &Path) -> Option<WebviewWindow> {
let state = app.try_state::<AppState>()?;
app.webview_windows().into_iter().filter(|(label, _)| !is_settings_window(label)).find_map(|(label, w)| {
let project = state.project(&label).ok()?;
(project.root == root).then_some(w)
})
}
fn has_project(app: &AppHandle, label: &str) -> bool {
app.try_state::<AppState>().is_some_and(|s| s.project(label).is_ok())
}
/// The focused window, else the last focused, else any.
pub fn target_window(app: &AppHandle) -> Option<WebviewWindow> {
let windows = app.webview_windows();
if let Some(w) = windows.values().find(|w| w.is_focused().unwrap_or(false)) {
return Some(w.clone());
}
let last = app.state::<WindowsState>().last_focused();
if let Some(w) = last.and_then(|l| windows.get(&l)) {
return Some(w.clone());
}
sorted_labels(windows.keys().map(String::as_str)).first().and_then(|l| windows.get(l)).cloned()
}
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"))
}
/// The theme's `--bg-frame` (tokens.css), mirroring `resolveThemeId` in theme.ts.
pub fn frame_color(appearance: &AppearanceSettings, os_dark: bool) -> Color {
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,
};
builtin_frame(theme)
.or_else(|| builtin_frame(if sync && os_dark { "dark" } else { "light" }))
.unwrap_or(Color(0xe9, 0xea, 0xee, 0xff))
}
/// A `#rgb` / `#rrggbb` / `#rrggbbaa` colour sent by the UI (Tauri's own parser).
fn hex_color(hex: &str) -> Option<Color> {
serde_json::from_value(serde_json::Value::String(hex.to_owned())).ok()
}
/// 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 state = app.try_state::<AppState>();
let appearance = state.as_ref().map(|s| s.global_settings.effective().appearance);
// A plugin theme replaces the built-in one in either OS mode; the UI records its resolved frame.
let plugin_frame = state.as_ref().and_then(|s| s.plugins.startup_frame()).and_then(|c| hex_color(&c));
if let Some(color) = plugin_frame {
config.background_color = Some(color);
} else 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}")))?;
apply_zoom(app, &window);
// "Sync with OS" needs the OS mode, known once the window exists.
if let Some(a) = appearance.filter(|a| plugin_frame.is_none() && (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)
}
/// Sets the saved IDE zoom before the page loads; applied later by the frontend,
/// it would visibly scale up the splash.
fn apply_zoom(app: &AppHandle, window: &WebviewWindow) {
let Some(zoom) = app.try_state::<AppState>().map(|s| s.global_settings.effective().appearance.zoom) else {
return;
};
if zoom != 100 {
let _ = window.set_zoom(f64::from(zoom) / 100.0);
}
}
/// 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();
}
// Cascade from the window the user is looking at; window-state overrides
// this with the remembered geometry when it has one for the label.
if let Some(anchor) = target_window(app)
&& let (Ok(pos), Ok(scale)) = (anchor.outer_position(), anchor.scale_factor())
{
let pos = pos.to_logical::<f64>(scale);
config.x = Some(pos.x + CASCADE);
config.y = Some(pos.y + CASCADE);
config.center = false;
}
app.state::<WindowsState>().set_launch(&label, Launch { project: project.map(Into::into), ..Default::default() });
let window = build_project_window(app, config)?;
focus(&window);
Ok(window)
}
/// `create_window` off the event-loop thread (building a window from a
/// main-thread callback can deadlock on Windows).
pub fn spawn_window(app: &AppHandle, project: Option<PathBuf>) {
let app = app.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = create_window(&app, project) {
tracing::warn!("cannot open window: {}", e.message);
}
});
}
/// How `open_project_window` satisfied the request.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum OpenOutcome {
/// Already open in `label`; that window was focused.
Focused { label: String },
/// A new window `label` was created and opens the project itself.
Opened { label: String },
/// The caller should open the project in its own window.
Current,
}
pub fn open_project_window(app: &AppHandle, path: &Path, new_window: bool) -> IdeResult<OpenOutcome> {
let root = dunce::canonicalize(path).map_err(|e| IdeError::not_found(format!("{}: {e}", path.display())))?;
tracing::info!("open_project_window {} new_window={new_window}", root.display());
if let Some(w) = find_project_window(app, &root) {
focus(&w);
return Ok(OpenOutcome::Focused { label: w.label().to_owned() });
}
if !new_window {
return Ok(OpenOutcome::Current);
}
let window = create_window(app, Some(root))?;
Ok(OpenOutcome::Opened { label: window.label().to_owned() })
}
/// Opens `path` from the command line / a second instance: focuses the window
/// that has it, else reuses a window without a project, else makes a new one.
pub fn open_path(app: &AppHandle, path: &Path) {
let root = match dunce::canonicalize(path) {
Ok(r) if r.is_dir() => r,
Ok(r) => r.parent().map(Path::to_path_buf).unwrap_or(r),
Err(e) => {
tracing::warn!("cannot open {}: {e}", path.display());
return;
}
};
if let Some(w) = find_project_window(app, &root) {
focus(&w);
return;
}
let free = target_window(app)
.filter(|w| !has_project(app, w.label()))
.filter(|w| !is_settings_window(w.label()))
.or_else(|| {
app.webview_windows().into_values().find(|w| !is_settings_window(w.label()) && !has_project(app, w.label()))
});
match free {
Some(w) => {
app.state::<WindowsState>()
.set_launch(w.label(), Launch { project: Some(root.into()), ..Default::default() });
let _ = app.emit_to(w.label(), LAUNCH_EVENT, ());
focus(&w);
}
None => spawn_window(app, Some(root)),
}
}
/// 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),
None => {
if let Some(w) = target_window(app) {
focus(&w);
} else {
spawn_window(app, None);
}
}
}
}
pub fn cycle(app: &AppHandle, current: &str, forward: bool) {
let windows = app.webview_windows();
let sorted = project_window_labels(app);
if let Some(w) = cycle_target(&sorted, current, forward).and_then(|l| windows.get(&l)) {
focus(w);
}
}
/// Opens (or focuses) the settings window of `parent` showing `page`: a
/// native child window centred over its parent, sized like IDEA's Settings.
pub fn open_settings_window(
app: &AppHandle,
parent: &WebviewWindow,
page: Option<String>,
new_projects: bool,
background: Option<String>,
) -> IdeResult<()> {
// Asked from a settings window itself: navigate it.
let parent_label = settings_parent(parent.label()).unwrap_or(parent.label()).to_owned();
let label = settings_label(&parent_label);
let windows = app.state::<WindowsState>();
if let Some(existing) = app.get_webview_window(&label) {
if page.is_some() || new_projects {
windows.set_launch(&label, Launch { project: None, settings_page: page, new_projects });
let _ = app.emit_to(label.as_str(), LAUNCH_EVENT, ());
}
focus(&existing);
return Ok(());
}
let parent = app.get_webview_window(&parent_label).ok_or_else(|| IdeError::not_found("no parent window"))?;
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
// the title bar in the app theme instead of a system caption that follows the OS theme.
(config.width, config.height) = SETTINGS_SIZE;
(config.min_width, config.min_height) = (Some(SETTINGS_MIN_SIZE.0), Some(SETTINGS_MIN_SIZE.1));
config.center = false;
config.visible = false;
config.maximized = false;
config.fullscreen = false;
config.drag_drop_enabled = false;
// macOS shows a child window as soon as it is attached, before the page loads: paint the theme's frame colour.
if let Some(color) = background.as_deref().and_then(hex_color) {
config.background_color = Some(color);
}
windows.set_launch(&label, Launch { project: None, settings_page: page, new_projects });
let window = WebviewWindowBuilder::from_config(app, &config)
.and_then(|b| b.parent(&parent))
.and_then(|b| b.build())
.map_err(|e| IdeError::internal(format!("cannot create settings window: {e}")))?;
apply_zoom(app, &window);
// window-state restores the remembered size; the position always follows the parent.
if let (Ok(pos), Ok(size), Ok(own), Ok(scale)) =
(parent.outer_position(), parent.outer_size(), window.outer_size(), parent.scale_factor())
{
let (pos, size, own) =
(pos.to_logical::<f64>(scale), size.to_logical::<f64>(scale), own.to_logical::<f64>(scale));
let at = centered(pos.x, pos.y, size.width, size.height, own.width, own.height);
let _ = window.set_position(tauri::LogicalPosition::new(at.0, at.1));
}
focus(&window);
Ok(())
}
/// Top-left corner that centres a `w`×`h` window over the parent rectangle
/// (never above the parent's top edge, so the title bar stays reachable).
fn centered(px: f64, py: f64, pw: f64, ph: f64, w: f64, h: f64) -> (f64, f64) {
(px + (pw - w) / 2.0, py + ((ph - h) / 2.0).max(0.0))
}
#[cfg(test)]
mod tests {
use super::*;
fn s(v: &[&str]) -> Vec<String> {
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 additional_palettes_keep_native_startup_and_os_preferences_in_sync() {
for (id, color) in [
("solarized-light", Color(0xee, 0xe8, 0xd5, 0xff)),
("solarized-dark", Color(0x07, 0x36, 0x42, 0xff)),
("one-light", Color(0xea, 0xea, 0xeb, 0xff)),
("one-dark", Color(0x21, 0x25, 0x2b, 0xff)),
] {
let mut a = AppearanceSettings { theme: id.into(), ..Default::default() };
assert_eq!(frame_color(&a, false), color);
assert_eq!(frame_color(&a, true), color);
a.sync_with_os = true;
a.preferred_light_theme = id.into();
a.preferred_dark_theme = id.into();
assert_eq!(frame_color(&a, false), color);
assert_eq!(frame_color(&a, true), color);
}
}
#[test]
fn frame_colours_from_the_ui_parse_as_hex() {
for (hex, expected) in [
("#eaeaeb", Color(0xea, 0xea, 0xeb, 0xff)),
("#21252b", Color(0x21, 0x25, 0x2b, 0xff)),
("#8040bfcc", Color(0x80, 0x40, 0xbf, 0xcc)),
("#fff", Color(0xff, 0xff, 0xff, 0xff)),
] {
assert_eq!(hex_color(hex), Some(expected), "{hex}");
}
assert_eq!(hex_color("hsl(220, 13%, 15%)"), None);
}
#[test]
fn labels_sort_main_first_then_numerically() {
assert_eq!(
sorted_labels(["project-10", "project-2", "main", "x"]),
s(&["main", "project-2", "project-10", "x"])
);
}
#[test]
fn next_label_fills_gaps() {
assert_eq!(next_label(["main"]), "project-1");
assert_eq!(next_label(["main", "project-1", "project-3"]), "project-2");
}
#[test]
fn cycling_wraps_both_ways() {
let w = s(&["main", "project-1", "project-2"]);
assert_eq!(cycle_target(&w, "main", true).as_deref(), Some("project-1"));
assert_eq!(cycle_target(&w, "project-2", true).as_deref(), Some("main"));
assert_eq!(cycle_target(&w, "main", false).as_deref(), Some("project-2"));
assert_eq!(cycle_target(&s(&["main"]), "main", true), None);
}
#[test]
fn cli_path_skips_flags_and_resolves_relative() {
let cwd = Path::new("/work");
let args = s(&["openidea", "--verbose", "proj"]);
assert_eq!(cli_path(&args, cwd), Some(PathBuf::from("/work/proj")));
assert_eq!(cli_path(&s(&["openidea", "/abs/p"]), cwd), Some(PathBuf::from("/abs/p")));
assert_eq!(cli_path(&s(&["openidea"]), cwd), None);
assert_eq!(cli_path(&s(&["openidea", "-psn_0_123"]), cwd), None);
}
#[test]
fn launches_are_taken_once() {
let st = WindowsState::default();
st.set_launch("project-1", Launch { project: Some("/p".into()), ..Default::default() });
assert_eq!(st.take_launch("project-1").and_then(|l| l.project), Some("/p".into()));
assert_eq!(st.take_launch("project-1"), None);
}
#[test]
fn settings_windows_map_to_their_parent() {
assert_eq!(settings_label("project-2"), "settings-project-2");
assert_eq!(settings_parent("settings-main"), Some("main"));
assert_eq!(settings_parent("main"), None);
assert!(is_settings_window("settings-project-1"));
assert!(!is_settings_window("project-1"));
}
#[test]
fn settings_window_is_centred_over_parent() {
assert_eq!(centered(100.0, 50.0, 1440.0, 900.0, 980.0, 720.0), (330.0, 140.0));
// Taller than the parent: aligned with its top edge.
assert_eq!(centered(0.0, 50.0, 800.0, 500.0, 600.0, 720.0), (100.0, 50.0));
}
}