diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 0596b556e..8feb6a512 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -395,6 +395,9 @@ pub struct AppearanceSettings { pub show_tool_window_names: bool, pub show_tool_window_badges: bool, pub widescreen_tool_windows: bool, + /// View › Appearance › Toolbar; hidden leaves IDEA's compact window header. + pub show_main_toolbar: bool, + pub show_status_bar: bool, /// Status bar widgets shown (`true`) or hidden (`false`) by the user /// (status bar context menu); widgets not listed use their default. pub status_bar_widgets: BTreeMap, @@ -465,6 +468,8 @@ impl Default for AppearanceSettings { show_tool_window_names: false, show_tool_window_badges: true, widescreen_tool_windows: false, + show_main_toolbar: true, + show_status_bar: true, status_bar_widgets: BTreeMap::new(), main_toolbar: None, } diff --git a/crates/ide-workspace/src/settings.rs b/crates/ide-workspace/src/settings.rs index 7d8988b30..aaed427b4 100644 --- a/crates/ide-workspace/src/settings.rs +++ b/crates/ide-workspace/src/settings.rs @@ -643,6 +643,22 @@ mod tests { let s = store.set(SettingsScope::Global, "appearance.mainToolbar", Json::Null).unwrap(); assert_eq!(s.appearance.main_toolbar, None); } + + #[test] + fn toolbar_and_status_bar_are_shown_until_hidden() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("settings.toml"), "[appearance]\ncompactMode = true\n").unwrap(); + let store = SettingsStore::load(tmp.path(), None); + let s = store.effective(); + assert!(s.appearance.show_main_toolbar); + assert!(s.appearance.show_status_bar); + let s = store.set(SettingsScope::Global, "appearance.showMainToolbar", json!(false)).unwrap(); + assert!(!s.appearance.show_main_toolbar); + let s = store.set(SettingsScope::Global, "appearance.showStatusBar", json!(false)).unwrap(); + assert!(!s.appearance.show_status_bar); + assert!(!store.reload().appearance.show_main_toolbar); + } + #[test] fn mcp_server_is_default_off_and_cannot_be_enabled_by_a_project() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 7e79375f4..6923ae91b 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -252,16 +252,30 @@ pub fn app_cancel_quit(app: AppHandle, windows: State<'_, WindowsState>) { #[tauri::command] #[specta::specta] pub fn menu_configure( + window: WebviewWindow, app: AppHandle, menu_state: State<'_, MenuState>, state: State<'_, AppState>, config: MenuConfig, ) { + if menu::native_menu_bar() + && windows::target_window(&app).is_some_and(|target| !owns_menu_config(window.label(), target.label())) + { + return; + } if menu::store_config(&menu_state, config) { menu::install(&app, &menu_state, &state.recent.list()); } } +fn owns_menu_config(source: &str, target: &str) -> bool { + let owner = match windows::auxiliary(target) { + Some((windows::AuxKind::Settings, parent)) => parent, + _ => target, + }; + source == owner +} + /// Updates the menu after the recent-projects list changed. Only the Open Recent /// submenu is rewritten when the rest of the menu is unchanged. #[tauri::command] @@ -588,6 +602,21 @@ pub async fn app_install_update( #[cfg(test)] mod tests { + #[test] + fn menu_configuration_follows_the_active_window_or_its_settings_parent() { + for (source, target, allowed) in [ + ("main", "main", true), + ("project-1", "main", false), + ("main", "settings-main", true), + ("project-1", "settings-main", false), + ("project-1", "settings-project-1", true), + ("comparison-main-1", "comparison-main-1", true), + ("main", "comparison-main-1", false), + ] { + assert_eq!(super::owns_menu_config(source, target), allowed, "{source} → {target}"); + } + } + use super::*; fn downloading(p: Option) -> Option<(f64, Option)> { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d2f7e1d76..f4a5f7373 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -306,6 +306,7 @@ fn window_state_flags() -> tauri_plugin_window_state::StateFlags { /// Routes IDE menu items to the focused window's frontend (`executeAction`). fn on_menu_event(app: &tauri::AppHandle, event: tauri::menu::MenuEvent) { let id = event.id().as_ref(); + menu::restore_check(app, &app.state::(), id); // A keymap accelerator only draws the shortcut: its key reached the menu because the keymap declined it. #[cfg(target_os = "macos")] if !menu::fires_natively(&menu::spec(menu::Os::Mac), id) diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 6437c9ed7..1515424ea 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -22,7 +22,7 @@ use serde::{Deserialize, Serialize}; use specta::Type; use std::collections::{HashMap, HashSet}; use std::path::Path; -use tauri::menu::{IsMenuItem, Menu, MenuItem, MenuItemKind, PredefinedMenuItem, Submenu}; +use tauri::menu::{CheckMenuItem, IsMenuItem, Menu, MenuItem, MenuItemKind, PredefinedMenuItem, Submenu}; use tauri::{AppHandle, Runtime}; /// Event emitted to the focused window when an IDE menu item is clicked. @@ -143,6 +143,9 @@ pub enum Entry { accel: Option<&'static str>, /// Shortcut text after the label, for the Windows/Linux edit items without a keymap binding. hint: Option<&'static str>, + /// A toggle (IDEA's ToggleAction) drawn with a check mark; on when its id is in + /// [`MenuConfig::checked`]. + check: bool, }, Native(Native), Separator, @@ -157,19 +160,24 @@ fn sub(id: &'static str, label: &'static str, children: Vec) -> Entry { Entry::Submenu { id, label, children } } fn act(id: &'static str, label: &'static str) -> Entry { - Entry::Action { id, label, accel: None, hint: None } + Entry::Action { id, label, accel: None, hint: None, check: false } } fn accel(id: &'static str, label: &'static str, accel: &'static str) -> Entry { - Entry::Action { id, label, accel: Some(accel), hint: None } + Entry::Action { id, label, accel: Some(accel), hint: None, check: false } } fn hinted(id: &'static str, label: &'static str, hint: &'static str) -> Entry { - Entry::Action { id, label, accel: None, hint: Some(hint) } + Entry::Action { id, label, accel: None, hint: Some(hint), check: false } +} +fn toggle(id: &'static str, label: &'static str) -> Entry { + Entry::Action { id, label, accel: None, hint: None, check: true } } const SEP: Entry = Entry::Separator; /// [`Entry::Dynamic`] slots the frontend fills. pub const DYNAMIC_TOOL_WINDOWS: &str = "toolWindows"; pub const DYNAMIC_LAYOUTS: &str = "toolWindowLayouts"; +/// View › Appearance › Status Bar Widgets: every titled widget with its check mark. +pub const DYNAMIC_STATUS_BAR_WIDGETS: &str = "statusBarWidgets"; /// Tools › entries of plugins (`[[contributes.menus]] menu = "tools"`). pub const DYNAMIC_PLUGIN_TOOLS: &str = "pluginTools"; @@ -372,7 +380,7 @@ pub fn spec(os: Os) -> Vec { vec![ act("editor.toggleZen", "Enter Distraction Free Mode"), act("window.toggleFullScreen", "Toggle Full Screen"), - act("view.toggleCompactMode", "Compact Mode"), + toggle("view.toggleCompactMode", "Compact Mode"), SEP, act("view.zoomIn", "Zoom In IDE"), act("view.zoomOut", "Zoom Out IDE"), @@ -380,6 +388,11 @@ pub fn spec(os: Os) -> Vec { SEP, act("view.toggleTheme", "Toggle Light/Dark Theme"), act("toolbar.customize", "Customize Main Toolbar…"), + SEP, + toggle("view.toggleToolbar", "Toolbar"), + toggle("view.toggleToolWindowBars", "Tool Window Bars"), + toggle("view.toggleStatusBar", "Status Bar"), + sub("statusBarWidgets", "Status Bar Widgets", vec![Entry::Dynamic(DYNAMIC_STATUS_BAR_WIDGETS)]), ], ), SEP, @@ -918,6 +931,9 @@ pub struct MenuConfig { /// [`Entry::Dynamic`] slot → its items, already localised. #[serde(default)] pub dynamic: HashMap>, + /// Ids of the toggle actions currently on. + #[serde(default)] + pub checked: Vec, } /// An item of a frontend-listed menu slot. @@ -927,6 +943,9 @@ pub struct DynamicItem { pub action: String, pub arg: Option, pub label: String, + /// Drawn with a check mark in this state; `None` for a plain item. + #[serde(default)] + pub checked: Option, } impl DynamicItem { @@ -1025,6 +1044,12 @@ pub fn apply_disabled(app: &AppHandle, disabled: &HashSet let _ = i.set_enabled(enabled); } } + MenuItemKind::Check(i) => { + let enabled = item_enabled(i.id().as_ref(), disabled); + if i.is_enabled().is_ok_and(|e| e != enabled) { + let _ = i.set_enabled(enabled); + } + } MenuItemKind::Submenu(s) => { if let Ok(children) = s.items() { walk(children, disabled) @@ -1077,10 +1102,12 @@ enum RenderedItem { items: Vec, }, /// `accel` is the spec's accelerator, or else the keymap's from [`MenuConfig::accelerators`]. + /// `checked` is set for a toggle, drawn as a check item. Action { id: String, text: String, accel: Option, + checked: Option, }, /// Always disabled (`No Recent Projects`). Placeholder { @@ -1124,10 +1151,11 @@ fn render_items(entries: &[Entry], config: &MenuConfig, recent: &[RecentItem]) - title: config.label(id, label), items: render_items(children, config, recent), }), - Entry::Action { id, label, accel, hint } => out.push(RenderedItem::Action { + Entry::Action { id, label, accel, hint, check } => out.push(RenderedItem::Action { id: (*id).into(), text: config.item_text(id, label, *hint), accel: accel.or_else(|| config.accelerator(id)).map(Into::into), + checked: check.then(|| config.checked.iter().any(|c| c == id)), }), Entry::Dynamic(slot) => { for item in config.dynamic_items(slot) { @@ -1135,6 +1163,7 @@ fn render_items(entries: &[Entry], config: &MenuConfig, recent: &[RecentItem]) - id: item.item_id(), text: item.label.clone(), accel: config.dynamic_accelerator(item).map(Into::into), + checked: item.checked, }); } } @@ -1152,6 +1181,7 @@ fn render_items(entries: &[Entry], config: &MenuConfig, recent: &[RecentItem]) - id: format!("{RECENT_PREFIX}{}", item.path), text: item.label.clone(), accel: None, + checked: None, }); } } @@ -1163,7 +1193,8 @@ fn render_items(entries: &[Entry], config: &MenuConfig, recent: &[RecentItem]) - fn structure_key(item: &RenderedItem) -> (u8, &str) { match item { RenderedItem::Submenu { id, .. } => (0, *id), - RenderedItem::Action { id, .. } => (1, id.as_str()), + RenderedItem::Action { id, checked: None, .. } => (1, id.as_str()), + RenderedItem::Action { id, checked: Some(_), .. } => (5, id.as_str()), RenderedItem::Placeholder { id, .. } => (2, id.as_str()), RenderedItem::Native { kind, .. } => (3, kind.key()), RenderedItem::Separator => (4, ""), @@ -1223,10 +1254,14 @@ fn append_rendered( append_rendered(app, &child, items, disabled)?; parent.append(&child)?; } - RenderedItem::Action { id, text, accel } => { + RenderedItem::Action { id, text, accel, checked: None } => { let enabled = item_enabled(id, disabled); parent.append(&MenuItem::with_id(app, id, text, enabled, accel.as_deref())?)?; } + RenderedItem::Action { id, text, accel, checked: Some(checked) } => { + let enabled = item_enabled(id, disabled); + parent.append(&CheckMenuItem::with_id(app, id, text, enabled, *checked, accel.as_deref())?)?; + } RenderedItem::Placeholder { id, text } => { parent.append(&MenuItem::with_id(app, id, text, false, None::<&str>)?)?; } @@ -1270,7 +1305,11 @@ fn existing_matches(existing: &MenuItemKind, desired: &RenderedIt (MenuItemKind::Submenu(submenu), RenderedItem::Submenu { id, .. }) => { submenu.id().as_ref() == format!("submenu:{id}").as_str() } - (MenuItemKind::MenuItem(item), RenderedItem::Action { id, .. } | RenderedItem::Placeholder { id, .. }) => { + ( + MenuItemKind::MenuItem(item), + RenderedItem::Action { id, checked: None, .. } | RenderedItem::Placeholder { id, .. }, + ) => item.id().as_ref() == id.as_str(), + (MenuItemKind::Check(item), RenderedItem::Action { id, checked: Some(_), .. }) => { item.id().as_ref() == id.as_str() } (MenuItemKind::Predefined(_), RenderedItem::Native { .. } | RenderedItem::Separator) => true, @@ -1346,6 +1385,16 @@ fn sync_item( && (previous_text == text || item.set_text(text).is_ok()) && (previous_accel == accel || item.set_accelerator(accel.as_deref()).is_ok()) } + ( + MenuItemKind::Check(item), + RenderedItem::Action { text: previous_text, accel: previous_accel, checked: previous_checked, .. }, + RenderedItem::Action { text, accel, checked: Some(checked), .. }, + ) => { + accelerator_patchable(previous_accel.as_deref(), accel.as_deref()) + && (previous_text == text || item.set_text(text).is_ok()) + && (previous_accel == accel || item.set_accelerator(accel.as_deref()).is_ok()) + && (*previous_checked == Some(*checked) || item.set_checked(*checked).is_ok()) + } (MenuItemKind::MenuItem(item), _, RenderedItem::Placeholder { text, .. }) => item.set_text(text).is_ok(), (MenuItemKind::Predefined(item), _, RenderedItem::Native { text, .. }) => item.set_text(text).is_ok(), _ => false, @@ -1398,11 +1447,15 @@ pub enum MenuNode { children: Vec, }, /// Runs `id` with `arg`. Items with an `arg` (Open Recent) carry their final label. + /// `checked` is the state of a frontend-listed toggle (Status Bar Widgets). Spec toggles + /// leave it unset: the frontend menu reads their state from the action (`toggled`), the + /// source [`MenuConfig::checked`] is built from for the native menu. Action { id: String, label: String, hint: Option, arg: Option, + checked: Option, }, /// A disabled placeholder such as "No Recent Projects". Placeholder { @@ -1427,12 +1480,14 @@ pub fn model(spec: &[Entry], recent: &[RecentItem], config: &MenuConfig) -> Vec< label: d.label.clone(), hint: None, arg: d.arg.clone(), + checked: d.checked, })), Entry::Action { id, label, hint, .. } => out.push(MenuNode::Action { id: (*id).into(), label: (*label).into(), hint: hint.map(Into::into), arg: None, + checked: None, }), Entry::Native(_) => {} Entry::Separator => out.push(MenuNode::Separator), @@ -1444,6 +1499,7 @@ pub fn model(spec: &[Entry], recent: &[RecentItem], config: &MenuConfig) -> Vec< label: r.label.clone(), hint: None, arg: Some(r.path.clone()), + checked: None, })), } } @@ -1456,6 +1512,45 @@ pub fn native_menu_bar() -> bool { cfg!(target_os = "macos") } +/// The check state item `id` was last drawn with; `None` for a plain item. +fn rendered_check(menus: &[RenderedMenu], id: &str) -> Option { + fn find(items: &[RenderedItem], id: &str) -> Option { + items.iter().find_map(|item| match item { + RenderedItem::Submenu { items, .. } => find(items, id), + RenderedItem::Action { id: item_id, checked, .. } if item_id == id => *checked, + _ => None, + }) + } + menus.iter().find_map(|menu| find(&menu.items, id)) +} + +/// Puts a clicked check item back to the state it was drawn with. muda flips it before the +/// event arrives, but the frontend owns the state: a click it ignores (a modal Settings +/// window, a failed save) must not leave a stale mark, and one it accepts comes back as a new +/// config that sets the mark through [`sync_item`]. +pub fn restore_check(app: &AppHandle, menu_state: &MenuState, id: &str) { + fn walk(items: Vec>, id: &str, checked: bool) -> bool { + items.into_iter().any(|item| match item { + MenuItemKind::Check(i) if i.id().as_ref() == id => { + if i.is_checked().is_ok_and(|c| c != checked) { + let _ = i.set_checked(checked); + } + true + } + MenuItemKind::Submenu(s) => s.items().is_ok_and(|children| walk(children, id, checked)), + _ => false, + }) + } + let Some(checked) = menu_state.rendered.lock().as_deref().and_then(|menus| rendered_check(menus, id)) else { + return; + }; + if let Some(menu) = app.menu() + && let Ok(items) = menu.items() + { + walk(items, id, checked); + } +} + /// Installs the app menu from the current config and recent list. /// /// The first install, and a change of the top-level menu ids, calls `set_menu`. @@ -1682,6 +1777,9 @@ mod tests { "window.toggleFullScreen", "view.toggleTheme", "editor.toggleZen", + "view.toggleToolbar", + "view.toggleToolWindowBars", + "view.toggleStatusBar", "help.keymapReference", "help.showLog", "find.inFile", @@ -1761,7 +1859,8 @@ mod tests { Some(RenderedItem::Action { id: "file.save".into(), text: "Save All".into(), - accel: Some("Meta+S".into()) + accel: Some("Meta+S".into()), + checked: None, }) ); assert!(matches!(item("app.quit"), Some(RenderedItem::Action { accel: Some(a), .. }) if a == "CmdOrCtrl+Q")); @@ -1869,7 +1968,83 @@ mod tests { } fn dynamic_item(action: &str, arg: Option<&str>, label: &str) -> DynamicItem { - DynamicItem { action: action.into(), arg: arg.map(Into::into), label: label.into() } + DynamicItem { action: action.into(), arg: arg.map(Into::into), label: label.into(), checked: None } + } + + #[test] + fn appearance_toggles_are_checks_that_change_only_the_view_menu() { + let entries = spec(Os::Mac); + let base = MenuConfig::default(); + let checked = |config: &MenuConfig, id: &str| { + let items: Vec = + render_bar(&entries, config, &[]).into_iter().flat_map(|menu| menu.items).collect(); + match rendered_action(&items, id) { + Some(RenderedItem::Action { checked, .. }) => *checked, + other => panic!("{id}: {other:?}"), + } + }; + for id in ["view.toggleCompactMode", "view.toggleToolbar", "view.toggleToolWindowBars", "view.toggleStatusBar"] + { + assert_eq!(checked(&base, id), Some(false), "{id}"); + } + assert_eq!(checked(&base, "view.zoomIn"), None); + + let mut on = base.clone(); + on.checked = vec!["view.toggleToolbar".into(), "view.zoomIn".into()]; + assert_eq!(checked(&on, "view.toggleToolbar"), Some(true)); + assert_eq!(checked(&on, "view.toggleStatusBar"), Some(false)); + assert_eq!(checked(&on, "view.zoomIn"), None, "plain items stay plain"); + assert_eq!(changed_menus(&render_bar(&entries, &base, &[]), &render_bar(&entries, &on, &[])), ["view"]); + } + + #[test] + fn a_clicked_check_returns_to_its_rendered_state() { + let mut config = MenuConfig { checked: vec!["view.toggleToolbar".into()], ..MenuConfig::default() }; + config.dynamic.insert( + DYNAMIC_STATUS_BAR_WIDGETS.into(), + vec![DynamicItem { + action: "view.toggleStatusBarWidget".into(), + arg: Some("perf.memory".into()), + label: "Memory Indicator".into(), + checked: Some(false), + }], + ); + let menus = render_bar(&spec(Os::Mac), &config, &[]); + assert_eq!(rendered_check(&menus, "view.toggleToolbar"), Some(true)); + assert_eq!(rendered_check(&menus, "view.toggleStatusBar"), Some(false)); + let widget = config.dynamic[DYNAMIC_STATUS_BAR_WIDGETS][0].item_id(); + assert_eq!(rendered_check(&menus, &widget), Some(false)); + assert_eq!(rendered_check(&menus, "view.zoomIn"), None, "plain items have no state"); + assert_eq!(rendered_check(&menus, "missing"), None); + } + + #[test] + fn status_bar_widgets_are_listed_with_their_state() { + let mut config = MenuConfig::default(); + config.dynamic.insert( + DYNAMIC_STATUS_BAR_WIDGETS.into(), + vec![DynamicItem { + action: "view.toggleStatusBarWidget".into(), + arg: Some("perf.memory".into()), + label: "Memory Indicator".into(), + checked: Some(true), + }], + ); + let id = config.dynamic[DYNAMIC_STATUS_BAR_WIDGETS][0].item_id(); + let items: Vec = + render_bar(&spec(Os::Mac), &config, &[]).into_iter().flat_map(|menu| menu.items).collect(); + assert!(matches!(rendered_action(&items, &id), Some(RenderedItem::Action { checked: Some(true), .. }))); + assert_eq!( + route(&spec(Os::Mac), &id), + Some(MenuRoute { action: "view.toggleStatusBarWidget".into(), arg: Some("perf.memory".into()) }) + ); + + let spec = + vec![sub("statusBarWidgets", "Status Bar Widgets", vec![Entry::Dynamic(DYNAMIC_STATUS_BAR_WIDGETS)])]; + let MenuNode::Submenu { children, .. } = &model(&spec, &[], &config)[0] else { panic!("not a submenu") }; + assert!( + matches!(&children[0], MenuNode::Action { checked: Some(true), arg: Some(a), .. } if a == "perf.memory") + ); } #[test] @@ -1900,7 +2075,11 @@ mod tests { let spec = spec(os); let mut found = Vec::new(); slots(&spec, &mut found); - assert_eq!(found, [DYNAMIC_TOOL_WINDOWS, DYNAMIC_PLUGIN_TOOLS, DYNAMIC_LAYOUTS], "{os:?}"); + assert_eq!( + found, + [DYNAMIC_TOOL_WINDOWS, DYNAMIC_STATUS_BAR_WIDGETS, DYNAMIC_PLUGIN_TOOLS, DYNAMIC_LAYOUTS], + "{os:?}" + ); assert!(!action_ids(&spec).iter().any(|id| { id.starts_with("toolwindow.") && *id != "toolwindow.hideAll" diff --git a/src-tauri/src/titlebar.rs b/src-tauri/src/titlebar.rs index 58eb68019..9f4cc002c 100644 --- a/src-tauri/src/titlebar.rs +++ b/src-tauri/src/titlebar.rs @@ -1,7 +1,8 @@ //! Size the native title bar around the window buttons to match the web toolbar. //! //! An empty unified toolbar makes AppKit lay out and vertically centre the buttons itself, so -//! nothing moves them behind its back and they stay still during live resize. +//! nothing moves them behind its back and they stay still during live resize. A bar lower than +//! the compact toolbar (IDEA's 30px compact header) keeps the plain title bar instead. //! //! In native full screen the same toolbar is drawn as an opaque strip over the web title bar, so //! it is hidden from the moment the window starts entering full screen until it has left (#1899). @@ -20,11 +21,28 @@ use objc2_foundation::{NSNotificationCenter, NSObjectProtocol}; use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc}; use tauri::Manager; -/// Unified compact (38pt) and unified (52pt) toolbars; pick the one closer to the web bar. +/// The native chrome around the window buttons: the plain title bar (28pt), or an empty unified +/// compact (38pt) or unified (52pt) toolbar. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Chrome { + Plain, + Toolbar(NSWindowToolbarStyle), +} + +/// Midpoints between the 28, 38 and 52pt bars. +const PLAIN_MAX_HEIGHT: f64 = 33.0; const COMPACT_MAX_HEIGHT: f64 = 45.0; -fn style_for(height: f64) -> NSWindowToolbarStyle { - if height < COMPACT_MAX_HEIGHT { NSWindowToolbarStyle::UnifiedCompact } else { NSWindowToolbarStyle::Unified } +/// The chrome closest to the web bar, so the buttons sit on its centre line (IDEA's 30px compact +/// header takes the plain title bar). +fn chrome_for(height: f64) -> Chrome { + if height < PLAIN_MAX_HEIGHT { + Chrome::Plain + } else if height < COMPACT_MAX_HEIGHT { + Chrome::Toolbar(NSWindowToolbarStyle::UnifiedCompact) + } else { + Chrome::Toolbar(NSWindowToolbarStyle::Unified) + } } /// Whether a window is in, or on its way into, native full screen. `styleMask` cannot answer @@ -127,13 +145,23 @@ pub fn set_height(window: &tauri::WebviewWindow, height: f64) -> tauri::Result<( let native = &*webview.ns_window().cast::(); let state = WINDOWS .with_borrow_mut(|windows| windows.entry(label).or_insert_with(|| track(native, &app)).state.clone()); - if native.toolbar().is_none() { - let toolbar = NSToolbar::new(mtm); - native.setToolbar(Some(&toolbar)); - } - let style = style_for(height); - if native.toolbarStyle() != style { - native.setToolbarStyle(style); + match chrome_for(height) { + Chrome::Plain => { + if native.toolbar().is_some() { + native.setToolbar(None); + } + } + Chrome::Toolbar(style) => { + if native.toolbar().is_none() { + let toolbar = NSToolbar::new(mtm); + // Hidden from the start in full screen, where it would paint an opaque strip. + toolbar.setVisible(state.toolbar_visible()); + native.setToolbar(Some(&toolbar)); + } + if native.toolbarStyle() != style { + native.setToolbarStyle(style); + } + } } apply(native, &state); }) @@ -195,12 +223,22 @@ mod tests { } #[test] - fn toolbar_style_follows_the_web_bar_height() { - for (height, compact) in - [(31.9, true), (32.0, true), (40.0, true), (44.9, true), (45.0, false), (52.0, false), (120.0, false)] - { - let expected = if compact { NSWindowToolbarStyle::UnifiedCompact } else { NSWindowToolbarStyle::Unified }; - assert_eq!(style_for(height), expected, "{height}"); + fn chrome_is_the_closest_to_the_web_bar_height() { + use NSWindowToolbarStyle as Style; + for (height, expected) in [ + (28.0, Chrome::Plain), + (30.0, Chrome::Plain), + (32.0, Chrome::Plain), + (32.9, Chrome::Plain), + (33.0, Chrome::Toolbar(Style::UnifiedCompact)), + (38.0, Chrome::Toolbar(Style::UnifiedCompact)), + (40.0, Chrome::Toolbar(Style::UnifiedCompact)), + (44.9, Chrome::Toolbar(Style::UnifiedCompact)), + (45.0, Chrome::Toolbar(Style::Unified)), + (52.0, Chrome::Toolbar(Style::Unified)), + (120.0, Chrome::Toolbar(Style::Unified)), + ] { + assert_eq!(chrome_for(height), expected, "{height}"); } } } diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index c0ce87375..c8443d313 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -171,6 +171,12 @@ impl WindowsState { self.launches.lock().insert(label.to_owned(), launch); } + /// Whether the window `label` is created to open a project (local or remote). + #[cfg(target_os = "macos")] + pub fn opens_project(&self, label: &str) -> bool { + self.launches.lock().get(label).is_some_and(|l| l.project.is_some() || l.remote.is_some()) + } + pub fn take_launch(&self, label: &str) -> Option { let mut launch = self.launches.lock().remove(label)?; let mut positions = self.positions.lock(); @@ -595,7 +601,7 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult< crate::page_swipe::install(&window); apply_zoom(app, &window); #[cfg(target_os = "macos")] - align_titlebar(app, &window); + align_titlebar(app, &window, app.try_state::().is_some_and(|w| w.opens_project(window.label()))); // "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) @@ -640,15 +646,29 @@ fn apply_zoom(app: &AppHandle, window: &WebviewWindow) { /// Aligns the native window buttons before the page reports its title bar height, /// so they don't visibly move once it loads. #[cfg(target_os = "macos")] -fn align_titlebar(app: &AppHandle, window: &WebviewWindow) { - // `--h-titlebar` in tokens.css. - const HEIGHT: f64 = 40.0; - const COMPACT_HEIGHT: f64 = 32.0; +fn align_titlebar(app: &AppHandle, window: &WebviewWindow, opens_project: bool) { let Some(a) = app.try_state::().map(|s| s.global_settings.effective().appearance) else { return; }; - let height = if a.compact_mode { COMPACT_HEIGHT } else { HEIGHT }; - let _ = crate::titlebar::set_height(window, height * f64::from(a.zoom.clamp(50, 300)) / 100.0); + let _ = crate::titlebar::set_height(window, initial_titlebar_height(&a, opens_project)); +} + +/// The title bar height (AppKit points) the page will draw. Only a project window hides its +/// toolbar for View › Appearance › Toolbar, and it is known to be one when it opens a project. +#[cfg(any(target_os = "macos", test))] +fn initial_titlebar_height(a: &AppearanceSettings, opens_project: bool) -> f64 { + // `--h-titlebar` and `--h-compact-header` in tokens.css. + const HEIGHT: f64 = 40.0; + const COMPACT_HEIGHT: f64 = 32.0; + const COMPACT_HEADER_HEIGHT: f64 = 30.0; + let height = if opens_project && !a.show_main_toolbar { + COMPACT_HEADER_HEIGHT + } else if a.compact_mode { + COMPACT_HEIGHT + } else { + HEIGHT + }; + height * f64::from(a.zoom.clamp(50, 300)) / 100.0 } /// The first window (`create: false` in tauri.conf.json so it can take the theme's colour). @@ -1290,7 +1310,7 @@ pub fn open_auxiliary_window(app: &AppHandle, caller: &WebviewWindow, aux: AuxWi crate::page_swipe::install(&window); apply_zoom(app, &window); #[cfg(target_os = "macos")] - align_titlebar(app, &window); + align_titlebar(app, &window, false); // Restore runs during build; fit its size to the parent's current display before showing. #[cfg(target_os = "macos")] if let Err(e) = fit_over_parent(&window, &parent, aux.min_size) { @@ -1523,6 +1543,20 @@ fn dev_font_preferences( mod tests { use super::*; + #[test] + fn a_project_window_starts_with_the_header_its_page_draws() { + let mut a = AppearanceSettings::default(); + assert_eq!(initial_titlebar_height(&a, true), 40.0); + a.compact_mode = true; + assert_eq!(initial_titlebar_height(&a, true), 32.0); + a.show_main_toolbar = false; + assert_eq!(initial_titlebar_height(&a, true), 30.0); + // Welcome, Settings and a window whose project comes later keep the full bar. + assert_eq!(initial_titlebar_height(&a, false), 32.0); + a.zoom = 150; + assert_eq!(initial_titlebar_height(&a, true), 45.0); + } + fn s(v: &[&str]) -> Vec { v.iter().map(|x| x.to_string()).collect() } diff --git a/src/bindings.ts b/src/bindings.ts index 75503b244..85e0f2333 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1911,6 +1911,9 @@ export type AppearanceSettings = { showToolWindowNames?: boolean, showToolWindowBadges?: boolean, widescreenToolWindows?: boolean, + /** View › Appearance › Toolbar; hidden leaves IDEA's compact window header. */ + showMainToolbar?: boolean, + showStatusBar?: boolean, /** * Status bar widgets shown (`true`) or hidden (`false`) by the user * (status bar context menu); widgets not listed use their default. @@ -3548,6 +3551,8 @@ export type DynamicItem = { action: string, arg: string | null, label: string, + /** Drawn with a check mark in this state; `None` for a plain item. */ + checked?: boolean | null, }; /** Editor › General › Appearance. */ @@ -5705,6 +5710,8 @@ export type MenuConfig = { accelerators: { [key in string]: string }, /** [`Entry::Dynamic`] slot → its items, already localised. */ dynamic?: { [key in string]: DynamicItem[] }, + /** Ids of the toggle actions currently on. */ + checked?: string[], }; /** @@ -5712,8 +5719,13 @@ export type MenuConfig = { * Labels are the English defaults; the frontend localises them like the native menu. */ export type MenuNode = { kind: "submenu"; id: string; label: string; children: MenuNode[] } | -/** Runs `id` with `arg`. Items with an `arg` (Open Recent) carry their final label. */ -{ kind: "action"; id: string; label: string; hint: string | null; arg: string | null } | +/** + * Runs `id` with `arg`. Items with an `arg` (Open Recent) carry their final label. + * `checked` is the state of a frontend-listed toggle (Status Bar Widgets). Spec toggles + * leave it unset: the frontend menu reads their state from the action (`toggled`), the + * source [`MenuConfig::checked`] is built from for the native menu. + */ +{ kind: "action"; id: string; label: string; hint: string | null; arg: string | null; checked: boolean | null } | /** A disabled placeholder such as "No Recent Projects". */ { kind: "placeholder"; id: string; label: string } | { kind: "separator" }; diff --git a/src/features/app/__tests__/menuBridge.test.ts b/src/features/app/__tests__/menuBridge.test.ts index cb0330c11..a1eeb6989 100644 --- a/src/features/app/__tests__/menuBridge.test.ts +++ b/src/features/app/__tests__/menuBridge.test.ts @@ -4,7 +4,8 @@ import { _resetI18nForTests, setLocale } from "@/platform/i18n"; import { useKeymap } from "@/platform/keymap"; import * as ipc from "@/platform/ipc"; import { resetDistractionFree, setDistractionFree } from "@/workbench/distractionFree"; -import { registerToolWindow } from "@/workbench/contributions"; +import { registerStatusItem, registerToolWindow } from "@/workbench/contributions"; +import { useProject } from "@/platform/project"; import { _reloadLayouts, saveLayout } from "@/workbench/toolwindows/layouts"; import { clearContext, setContext } from "@/platform/context"; import { @@ -29,7 +30,7 @@ describe("menu state from the frontend", () => { vi.spyOn(ipc.commands, "appBuildInfo").mockRejectedValue(new Error("No build info in tests")); vi.spyOn(ipc.commands, "menuConfigure").mockResolvedValue(); vi.spyOn(ipc.commands, "menuModel").mockResolvedValue([ - { kind: "action", id: "test.menuFocus", label: "Test", hint: null, arg: null }, + { kind: "action", id: "test.menuFocus", label: "Test", hint: null, arg: null, checked: null }, ]); const pushDisabled = vi.spyOn(ipc.commands, "menuSetDisabled").mockResolvedValue(); const offAction = registerAction({ id: "test.menuFocus", title: "Test", run: () => {} }); @@ -84,11 +85,11 @@ describe("menu state from the frontend", () => { id: "x", label: "X", children: [ - { kind: "action", id: "test.menu.whenEditor", label: "B", hint: null, arg: null }, - { kind: "action", id: "test.menu.always", label: "A", hint: null, arg: null }, + { kind: "action", id: "test.menu.whenEditor", label: "B", hint: null, arg: null, checked: null }, + { kind: "action", id: "test.menu.always", label: "A", hint: null, arg: null, checked: null }, ], }, - { kind: "action", id: "not.registered", label: "C", hint: null, arg: null }, + { kind: "action", id: "not.registered", label: "C", hint: null, arg: null, checked: null }, ]); expect([...ids].sort()).toEqual(["not.registered", "test.menu.always", "test.menu.whenEditor"]); // Unregistered ids stay enabled: the action may register later (lazy features). @@ -206,3 +207,58 @@ describe("window-scoped menu labels", () => { resetDistractionFree(); }); }); + +describe("View › Appearance check marks", () => { + it("sends the toggle actions that are on", () => { + let on = true; + const offs = [ + registerAction({ id: "test.menu.toggleOn", title: "On", toggled: () => on, run: () => {} }), + registerAction({ id: "test.menu.toggleOff", title: "Off", toggled: () => false, run: () => {} }), + ]; + const checked = () => buildMenuConfig("macos").checked!.filter((id) => id.startsWith("test.menu.")); + expect(checked()).toEqual(["test.menu.toggleOn"]); + on = false; + expect(checked()).toEqual([]); + offs.forEach((d) => d()); + }); + + it("lists titled status bar widgets in bar order with their visibility", () => { + const widget = (id: string, align: "left" | "right", priority: number, title?: string, defaultVisible?: boolean) => + registerStatusItem({ id, align, priority, title, defaultVisible, component: () => null }); + const offs = [ + widget("w.encoding", "right", 2, "File Encoding"), + widget("w.caret", "right", 1, "Line:Column Number"), + widget("w.tasks", "left", 0), + widget("w.memory", "right", 3, "Memory Indicator", false), + widget("plugin.demo.w", "left", 1, "Demo Widget"), + ]; + useProject.setState({ settings: { appearance: { statusBarWidgets: { "w.caret": false } } } as never }); + expect(dynamicMenuItems().statusBarWidgets).toEqual([ + { action: "view.toggleStatusBarWidget", arg: "plugin.demo.w", label: "Demo Widget", checked: true }, + { action: "view.toggleStatusBarWidget", arg: "w.caret", label: "Line:Column Number", checked: false }, + { action: "view.toggleStatusBarWidget", arg: "w.encoding", label: "File Encoding", checked: true }, + { action: "view.toggleStatusBarWidget", arg: "w.memory", label: "Memory Indicator", checked: false }, + ]); + useProject.setState({ settings: null }); + offs.forEach((d) => d()); + }); + + it("lists only the widgets a LightEdit window's status bar has", () => { + const offs = [ + registerStatusItem({ + id: "w.caret", + align: "right", + priority: 1, + title: "Line:Column Number", + lightEdit: true, + component: () => null, + }), + registerStatusItem({ id: "w.branch", align: "right", priority: 2, title: "Git Branch", component: () => null }), + ]; + useProject.setState({ project: null, lightEdit: true }); + expect(dynamicMenuItems().statusBarWidgets!.map((i) => i.arg)).toEqual(["w.caret"]); + useProject.setState({ lightEdit: false }); + expect(dynamicMenuItems().statusBarWidgets!.map((i) => i.arg)).toEqual(["w.caret", "w.branch"]); + offs.forEach((d) => d()); + }); +}); diff --git a/src/features/app/menuBridge.ts b/src/features/app/menuBridge.ts index 6434f461c..4f9197af7 100644 --- a/src/features/app/menuBridge.ts +++ b/src/features/app/menuBridge.ts @@ -20,8 +20,10 @@ import { useKeymap, } from "@/platform/keymap"; import { useProject } from "@/platform/project"; -import { useContributions, useMenuSlotItems } from "@/workbench/contributions"; +import { useContributions, useMenuSlotItems, useStandaloneEditor } from "@/workbench/contributions"; import { isDistractionFree, useDistractionFree } from "@/workbench/distractionFree"; +import { orderStatusItems } from "@/workbench/StatusBar"; +import { statusWidgetRows } from "@/workbench/statusBarWidgets"; import { useToolWindowLayouts } from "@/workbench/toolwindows/layouts"; import { hostOs, type HostOs } from "./host"; @@ -77,10 +79,40 @@ export function buildMenuConfig(os: HostOs): MenuConfig { const accelerator = first && menuAccelerator(first); if (accelerator) accelerators[id] = accelerator; } - return { locale: useI18n.getState().locale, labels, accelerators, dynamic: dynamicMenuItems() }; + return { + locale: useI18n.getState().locale, + labels, + accelerators, + dynamic: dynamicMenuItems(), + checked: toggledActions(), + }; } -/** View › Tool Windows (every registered window, by title) and Window › Tool Window Layouts. */ +/** Toggle actions that are on: their menu items show a check mark (View › Appearance). */ +function toggledActions(): string[] { + return [...useActions.getState().actions.values()] + .filter((a) => a.toggled?.()) + .map((a) => a.id) + .sort(); +} + +/** View › Appearance › Status Bar Widgets: the status bar's context menu, in bar order. */ +function statusBarWidgetItems(): DynamicItem[] { + const { project, lightEdit, settings } = useProject.getState(); + // A LightEdit window's status bar has only the items that apply there (Workbench.tsx). + const lightEditFrame = !project && (lightEdit || useStandaloneEditor.getState().title !== null); + const items = [...useContributions.getState().statusItems.values()].filter((i) => !lightEditFrame || i.lightEdit); + const { left, right } = orderStatusItems(items); + const overrides = settings?.appearance?.statusBarWidgets; + return statusWidgetRows([...left, ...right], overrides).map((row) => ({ + action: "view.toggleStatusBarWidget", + arg: row.id, + label: row.verbatim ? row.title : tx(row.title), + checked: row.visible, + })); +} + +/** View › Tool Windows (every registered window, by title), View › Appearance › Status Bar Widgets and Window › Tool Window Layouts. */ export function dynamicMenuItems(): Record { const toolWindows = [...useContributions.getState().toolWindows.values()] .map((tw) => ({ action: `toolwindow.${tw.id}`, arg: null, label: tw.verbatim ? tw.title : tx(tw.title) })) @@ -96,7 +128,7 @@ export function dynamicMenuItems(): Record { .sort((a, b) => a.label.localeCompare(b.label)), ]), ); - return { ...slots, toolWindows, toolWindowLayouts: layouts }; + return { ...slots, toolWindows, toolWindowLayouts: layouts, statusBarWidgets: statusBarWidgetItems() }; } /** Action ids of every IDE item in a menu model. */ @@ -186,7 +218,7 @@ export function installMenuBridge(): () => void { if (s.active !== prev.active) push(); }), useContributions.subscribe((s, prev) => { - if (s.toolWindows !== prev.toolWindows) push(); + if (s.toolWindows !== prev.toolWindows || s.statusItems !== prev.statusItems) push(); }), useToolWindowLayouts.subscribe(push), useMenuSlotItems.subscribe(push), @@ -195,6 +227,9 @@ export function installMenuBridge(): () => void { // Opening / closing a project changes the Open Recent list. useProject.subscribe((s, prev) => { if (s.project?.root !== prev.project?.root) void commands.menuRefresh(); + if (s.lightEdit !== prev.lightEdit) push(); + // View › Appearance check marks and Status Bar Widgets. + if (s.settings?.appearance !== prev.settings?.appearance) push(); }), ]; return () => { diff --git a/src/features/settings/__tests__/appearanceToggles.test.ts b/src/features/settings/__tests__/appearanceToggles.test.ts new file mode 100644 index 000000000..46f86ca3c --- /dev/null +++ b/src/features/settings/__tests__/appearanceToggles.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ActionContext } from "@/platform/actions"; +import type { Settings } from "@/platform/ipc"; + +const settingsSet = vi.fn(); + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + isTauri: () => true, + commands: { ...real.commands, settingsSet: (...a: unknown[]) => settingsSet(...a) }, + }; +}); + +import { useProject } from "@/platform/project"; +import { appearanceToggleActions } from "../appearanceRuntime"; +import { validateAppearanceExtras } from "../model/behaviorSchema"; + +let appearance: Record; + +beforeEach(() => { + appearance = {}; + const settings = () => ({ appearance: { ...appearance } }) as unknown as Settings; + useProject.setState({ settings: settings() }); + settingsSet.mockReset().mockImplementation(async (_scope: string, key: string, json: string) => { + appearance[key.replace("appearance.", "")] = JSON.parse(json); + return { status: "ok", data: settings() }; + }); +}); + +afterEach(() => useProject.setState({ settings: null })); + +describe("View › Appearance toggles", () => { + it("shows the toolbar and the status bar unless settings hide them", () => { + expect(validateAppearanceExtras({})).toMatchObject({ showMainToolbar: true, showStatusBar: true }); + expect(validateAppearanceExtras({ showMainToolbar: false, showStatusBar: "no" })).toMatchObject({ + showMainToolbar: false, + showStatusBar: true, + }); + }); + + it.each([ + ["view.toggleCompactMode", "compactMode", false], + ["view.toggleToolbar", "showMainToolbar", true], + ["view.toggleToolWindowBars", "showToolWindowBars", true], + ["view.toggleStatusBar", "showStatusBar", true], + ])("%s flips appearance.%s in the global settings", async (id, key, initial) => { + const action = appearanceToggleActions().find((a) => a.id === id)!; + expect(action.toggled?.()).toBe(initial); + await action.run({ ctx: {} } as ActionContext); + expect(settingsSet).toHaveBeenCalledWith("global", `appearance.${key}`, JSON.stringify(!initial)); + expect(action.toggled?.()).toBe(!initial); + }); +}); diff --git a/src/features/settings/__tests__/pages.test.ts b/src/features/settings/__tests__/pages.test.ts index 208d96e0b..bbafc029c 100644 --- a/src/features/settings/__tests__/pages.test.ts +++ b/src/features/settings/__tests__/pages.test.ts @@ -90,3 +90,10 @@ describe("page tree", () => { ); }); }); + +it("indexes toolbar and status bar visibility in Appearance settings", () => { + const appearance = BEHAVIOR_PAGES.find((p) => p.id === "appearance")!; + const labels = specLabels(appearance); + expect(labels).toContain("Show main toolbar"); + expect(labels).toContain("Show status bar"); +}); diff --git a/src/features/settings/appearanceRuntime.ts b/src/features/settings/appearanceRuntime.ts index 311afd2b2..d7b2feffb 100644 --- a/src/features/settings/appearanceRuntime.ts +++ b/src/features/settings/appearanceRuntime.ts @@ -114,21 +114,43 @@ export function zoomActions(): Action[] { keywords: ["zoom", "scale", "100%", "accessibility"], run: () => setZoom(100), }, - { - // View › Appearance › Compact Mode (Settings › Appearance › "Compact mode"). - id: "view.toggleCompactMode", - title: "Compact Mode", - category: "View", - keywords: ["appearance", "density", "smaller"], - toggled: () => validateSettings(useProject.getState().settings).appearance.compactMode, - run: async () => { - if (!isTauri()) return; - const next = !validateSettings(useProject.getState().settings).appearance.compactMode; - useProject.setState({ - settings: await call(commands.settingsSet("global", "appearance.compactMode", JSON.stringify(next))), - }); - }, + ]; +} + +// --------------------------------------------------------------------------- +// View › Appearance toggles over boolean appearance settings (IDEA's ToggleActions) + +type AppearanceFlag = "compactMode" | "showMainToolbar" | "showToolWindowBars" | "showStatusBar"; + +const appearanceFlag = (key: AppearanceFlag) => validateSettings(useProject.getState().settings).appearance[key]; + +function appearanceToggle(id: string, title: string, key: AppearanceFlag, keywords: string[]): Action { + return { + id, + title, + category: "View", + keywords: ["appearance", ...keywords], + toggled: () => appearanceFlag(key), + run: async () => { + if (!isTauri()) return; + const next = !appearanceFlag(key); + useProject.setState({ + settings: await call(commands.settingsSet("global", `appearance.${key}`, JSON.stringify(next))), + }); }, + }; +} + +export function appearanceToggleActions(): Action[] { + return [ + appearanceToggle("view.toggleCompactMode", "Compact Mode", "compactMode", ["density", "smaller"]), + appearanceToggle("view.toggleToolbar", "Toolbar", "showMainToolbar", ["main toolbar", "header", "hide"]), + appearanceToggle("view.toggleToolWindowBars", "Tool Window Bars", "showToolWindowBars", [ + "stripes", + "tool window buttons", + "hide", + ]), + appearanceToggle("view.toggleStatusBar", "Status Bar", "showStatusBar", ["hide"]), ]; } diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts index b5d6ced14..e86d20855 100644 --- a/src/features/settings/index.ts +++ b/src/features/settings/index.ts @@ -37,7 +37,7 @@ import { settingsTitle, useSettingsDialog, } from "./model/staging"; -import { zoomActions } from "./appearanceRuntime"; +import { appearanceToggleActions, zoomActions } from "./appearanceRuntime"; import { activateFileTypes } from "./fileTypesRuntime"; import { trackRuntimeSettings } from "./runtime"; import { installActionProxies, installSettingsHost } from "./window/bridge"; @@ -123,6 +123,7 @@ export function activate(): () => void { registerOverlay({ id: "settings.dialog", component: LazySettingsDialog }), registerOverlay({ id: "settings.projectStructure", component: LazyProjectStructureDialog }), registerActions(zoomActions()), + registerActions(appearanceToggleActions()), registerActions(settingsActions((page, newProjects) => void showSettings(page, newProjects))), registerActions([ { diff --git a/src/features/settings/model/behaviorSchema.ts b/src/features/settings/model/behaviorSchema.ts index b2c08a0f9..fc81997ce 100644 --- a/src/features/settings/model/behaviorSchema.ts +++ b/src/features/settings/model/behaviorSchema.ts @@ -45,6 +45,9 @@ export interface AppearanceExtras { showToolWindowNames: boolean; showToolWindowBadges: boolean; widescreenToolWindows: boolean; + /** View › Appearance › Toolbar; hidden leaves IDEA's compact window header. */ + showMainToolbar: boolean; + showStatusBar: boolean; } export interface DateFormatValid { @@ -105,6 +108,8 @@ export const DEFAULT_APPEARANCE_EXTRAS: AppearanceExtras = { showToolWindowNames: false, showToolWindowBadges: true, widescreenToolWindows: false, + showMainToolbar: true, + showStatusBar: true, }; export const DEFAULT_GENERAL_EXTRAS: GeneralExtras = { @@ -163,6 +168,8 @@ export function validateAppearanceExtras(raw: unknown): AppearanceExtras { showToolWindowNames: b("showToolWindowNames"), showToolWindowBadges: b("showToolWindowBadges"), widescreenToolWindows: b("widescreenToolWindows"), + showMainToolbar: b("showMainToolbar"), + showStatusBar: b("showStatusBar"), }; } diff --git a/src/features/settings/pages/behavior.tsx b/src/features/settings/pages/behavior.tsx index dfd8927f6..73a0e1c2b 100644 --- a/src/features/settings/pages/behavior.tsx +++ b/src/features/settings/pages/behavior.tsx @@ -642,6 +642,8 @@ export const BEHAVIOR_PAGES: PageSpec[] = [ { title: "UI Options", options: [ + { kind: "toggle", key: "appearance.showMainToolbar", label: "Show main toolbar" }, + { kind: "toggle", key: "appearance.showStatusBar", label: "Show status bar" }, { kind: "custom", label: "Main menu", diff --git a/src/features/vcs/BranchWidget.tsx b/src/features/vcs/BranchWidget.tsx index 9a09f4547..3dfd2064a 100644 --- a/src/features/vcs/BranchWidget.tsx +++ b/src/features/vcs/BranchWidget.tsx @@ -1,6 +1,6 @@ import { branchesByRepo } from "./multiRoot"; /** Title bar branch widget (branch name + incoming / outgoing markers) + Branches popup. */ -import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type RefObject } from "react"; +import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import clsx from "clsx"; import { create } from "zustand"; import type { Branch, BranchInfo } from "@/bindings"; @@ -23,6 +23,7 @@ import { type MenuItem, T, } from "@/ui"; +import type { PopupAnchor } from "@/ui/Popup"; import { buildBranchRows, withExpanded, type BranchRow, type PopupAction } from "./branches"; import { branchPresentation, extensionPopupActions, useBranchWidgetExtensions } from "./branchWidgetExtensions"; import { branchMenu, commonBranchMenu, inRepo, tagMenu } from "./branchMenu"; @@ -68,7 +69,6 @@ export function BranchWidget() { const otherOp = useVcs((s) => (current ? (s.operations[current] ?? null) : null)); const open = useBranchesPopup((s) => s.open); const extensions = useBranchWidgetExtensions((s) => s.extensions); - const ref = useRef(null); if (!available || !selected) return null; // Another feature presents the current branch (the GitHub pull request on it). const presented = other ? null : branchPresentation(extensions); @@ -98,7 +98,7 @@ export function BranchWidget() { return ( <> - {open && useBranchesPopup.setState({ open: false })} />} ); } +export function BranchesPopupHost() { + const open = useBranchesPopup((s) => s.open); + if (!open) return null; + const anchor = document.querySelector("[data-branches-widget]") ?? { x: window.innerWidth / 2 - 160, y: 60 }; + return useBranchesPopup.setState({ open: false })} />; +} + const POPUP_ACTIONS: PopupAction[] = [ { id: "vcs.update", label: "Update Project…", icon: "git-pull", group: "sync" }, { id: "vcs.commit", label: "Commit…", icon: "commit", group: "sync" }, @@ -283,7 +289,7 @@ const caretAt = (e: KeyboardEvent, where: "start" | "end") => { return where === "end" ? input.selectionStart === input.value.length : input.selectionStart === 0; }; -function BranchesPopup({ anchor, onClose }: { anchor: RefObject; onClose: () => void }) { +function BranchesPopup({ anchor, onClose }: { anchor: PopupAnchor; onClose: () => void }) { const multi = useVcs((s) => s.roots.length > 1); const extensions = useBranchWidgetExtensions((s) => s.extensions); const actions = useMemo(() => [...POPUP_ACTIONS, ...extensionPopupActions(extensions)], [extensions]); diff --git a/src/features/vcs/__tests__/branchesPopupHost.test.tsx b/src/features/vcs/__tests__/branchesPopupHost.test.tsx new file mode 100644 index 000000000..1548d0957 --- /dev/null +++ b/src/features/vcs/__tests__/branchesPopupHost.test.tsx @@ -0,0 +1,18 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { BranchesPopupHost, showBranchesPopup, useBranchesPopup } from "../BranchWidget"; + +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { ...real, commands: { ...real.commands, vcsTags: async () => ({ status: "ok", data: [] }) } }; +}); + +afterEach(() => useBranchesPopup.setState({ open: false })); + +it("opens Git Branches without a mounted toolbar widget", () => { + render(); + act(showBranchesPopup); + expect(screen.getByRole("dialog", { name: "Git Branches" })).toBeInTheDocument(); + fireEvent.keyDown(document, { key: "Escape" }); + expect(useBranchesPopup.getState().open).toBe(false); +}); diff --git a/src/features/vcs/index.ts b/src/features/vcs/index.ts index a2a7d369f..d741b3013 100644 --- a/src/features/vcs/index.ts +++ b/src/features/vcs/index.ts @@ -24,7 +24,7 @@ import { registerLogPaneActions } from "./logPaneActions"; import { controlLabel, controlOperation, inProgress } from "./operations"; import { annotationsShown, blameSlot } from "./blame"; import { inlineBlame } from "./inlineBlame"; -import { BranchWidget, useBranchesPopup } from "./BranchWidget"; +import { BranchWidget, BranchesPopupHost, useBranchesPopup } from "./BranchWidget"; import { showConflictsDialog, takeContinueWhenResolved } from "./conflictsUi"; import { registerCompareActions } from "./compare"; import { DIFF_KIND, diffTitle, type DiffData } from "./diffInput"; @@ -238,6 +238,7 @@ export function activate(): () => void { trackMergeRebaseItem(), installHunkPopup(), registerOverlay({ id: "vcs.dialogs", component: VcsDialogHost }), + registerOverlay({ id: "vcs.branches", component: BranchesPopupHost }), activateConflictBanner(), activateConflictMarkers(), trackCommitWindow(), diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index 427911bc1..df89808e0 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -394,6 +394,10 @@ "menu.view.zoomOut": "Zoom Out IDE", "menu.view.resetZoom": "Reset IDE Zoom", "menu.toolbar.customize": "Customize Main Toolbar…", + "menu.view.toggleToolbar": "Toolbar", + "menu.view.toggleToolWindowBars": "Tool Window Bars", + "menu.view.toggleStatusBar": "Status Bar", + "menu.statusBarWidgets": "Status Bar Widgets", "menu.code.quickDefinition": "Quick Definition", "menu.code.quickTypeDefinition": "Quick Type Definition", "menu.localHistory.recentChanges": "Recent Changes", diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 41195d8eb..d630a2740 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -552,5 +552,14 @@ "No build": "No build", "Build, no error check": "Build, no error check", "Before launch build for this Java configuration": "Before launch build for this Java configuration", - "the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`": "the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`" + "the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`": "the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`", + "Toolbar": "Toolbar", + "Tool Window Bars": "Tool Window Bars", + "Status Bar": "Status Bar", + "Status Bar Widget": "Status Bar Widget", + "Show main toolbar": "Show main toolbar", + "Show status bar": "Show status bar", + "No background tasks": "No background tasks", + "Status Bar Widgets": "Status Bar Widgets", + "Background Tasks": "Background Tasks" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index e120de9b4..bbe7692cd 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -3452,7 +3452,13 @@ "Build images before starting (--build)": "启动前构建镜像 (--build)", "Up options": "Up 选项", "e.g. --force-recreate --remove-orphans": "例如 --force-recreate --remove-orphans", + "Show main toolbar": "显示主工具栏", + "Show status bar": "显示状态栏", "Compact Mode": "紧凑模式", + "Toolbar": "工具栏", + "Tool Window Bars": "工具窗口栏", + "Status Bar": "状态栏", + "Status Bar Widget": "状态栏微件", "Override File Type": "覆盖文件类型", "Revert File Type Override": "还原文件类型覆盖", "Add Content Root": "添加内容根", @@ -6896,5 +6902,7 @@ "Servers": "服务器", "Open main menu with a lone Alt press": "单独按下 Alt 打开主菜单", "When off, Alt key combinations still work but pressing Alt alone does not focus the main menu": "关闭后,Alt 组合键仍然可用,但单独按下 Alt 不会聚焦主菜单", - "the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`": "{gemfile} 的 bundle 中没有 `debug` gem。请在 Gemfile 中添加 `gem \"debug\"`,然后运行 `bundle install`" + "the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`": "{gemfile} 的 bundle 中没有 `debug` gem。请在 Gemfile 中添加 `gem \"debug\"`,然后运行 `bundle install`", + "No background tasks": "没有后台任务", + "Status Bar Widgets": "状态栏微件" } diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index 3f120c953..9afe2d366 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -394,6 +394,10 @@ "menu.view.zoomOut": "缩小 IDE", "menu.view.resetZoom": "重置 IDE 缩放", "menu.toolbar.customize": "自定义主工具栏…", + "menu.view.toggleToolbar": "工具栏", + "menu.view.toggleToolWindowBars": "工具窗口栏", + "menu.view.toggleStatusBar": "状态栏", + "menu.statusBarWidgets": "状态栏微件", "menu.code.quickDefinition": "快速定义", "menu.code.quickTypeDefinition": "快速类型定义", "menu.localHistory.recentChanges": "最近的更改", diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 6214044e3..bd689734c 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -257,6 +257,8 @@ html { /* metrics (theme JSON at 100%) */ --h-titlebar: 40px; + /* IDEA's compact header (HEADER_HEIGHT_DFM), the same in compact mode. */ + --h-compact-header: 30px; --titlebar-tool-button: 30px; /* tool window stripe button, icons only */ --stripe-icon-button: 30px; diff --git a/src/ui/Menu.tsx b/src/ui/Menu.tsx index ab74a835c..7b09dd9e0 100644 --- a/src/ui/Menu.tsx +++ b/src/ui/Menu.tsx @@ -31,6 +31,8 @@ export type MenuItem = action: string; arg?: unknown; label?: string; + /** A check mark in this state instead of the action's own `toggled` (one row per argument). */ + checked?: boolean; icon?: IconName | (string & {}); disabled?: boolean; } @@ -82,12 +84,12 @@ export function resolveMenuItems(items: MenuItem[]): Resolved[] { if (!a) return; out.push({ kind: "item", - key: item.action, + key: typeof item.arg === "string" ? `${item.action}|${item.arg}` : item.action, label: tx(item.label) ?? a.title, icon: item.icon ?? a.icon, shortcut: shortcutLabel(item.action), disabled: !!item.disabled || !isActionEnabled(a), - checked: actionToggled(a), + checked: item.checked ?? actionToggled(a), run: () => void executeActionFrom("menu", item.action, item.arg), }); return; diff --git a/src/workbench/MainMenu.interaction.test.tsx b/src/workbench/MainMenu.interaction.test.tsx index 823de7323..a9ae7ca35 100644 --- a/src/workbench/MainMenu.interaction.test.tsx +++ b/src/workbench/MainMenu.interaction.test.tsx @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { commands, type Settings } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { registerActions } from "@/platform/actions"; +import { usePlatform } from "@/platform/platformInfo"; import { MainMenu } from "./MainMenu"; let available = 500; @@ -35,13 +36,13 @@ beforeEach(() => { kind: "submenu", id: "file", label: "File", - children: [{ kind: "action", id: "test.open", label: "Open", hint: null, arg: null }], + children: [{ kind: "action", id: "test.open", label: "Open", hint: null, arg: null, checked: null }], }, { kind: "submenu", id: "edit", label: "Edit", - children: [{ kind: "action", id: "test.undo", label: "Undo", hint: null, arg: null }], + children: [{ kind: "action", id: "test.undo", label: "Undo", hint: null, arg: null, checked: null }], }, ]); dispose = registerActions([ @@ -267,3 +268,39 @@ describe("main menu presentation", () => { expect(screen.getByRole("button", { name: "Main Menu" })).toBeInTheDocument(); }); }); + +it.each(["windows", "linux"] as const)("renders toggle and widget checks in the %s main menu", async (os) => { + const platform = usePlatform.getState(); + usePlatform.setState({ os, framelessTitleBar: true, overlayTitleBar: false }); + const off = registerActions([ + { id: "view.toggleToolbar", title: "Toolbar", toggled: () => true, run: () => {} }, + { id: "view.toggleStatusBarWidget", title: "Status Bar Widget", run: () => {} }, + ]); + vi.mocked(commands.menuModel).mockResolvedValue([ + { + kind: "submenu", + id: "view", + label: "View", + children: [ + { kind: "action", id: "view.toggleToolbar", label: "Toolbar", hint: null, arg: null, checked: null }, + { + kind: "action", + id: "view.toggleStatusBarWidget", + label: "Memory Indicator", + hint: null, + arg: "memory", + checked: false, + }, + ], + }, + ]); + try { + render(); + fireEvent.click(screen.getByRole("button", { name: "Main Menu" })); + expect(await screen.findByRole("menuitemcheckbox", { name: "Toolbar" })).toHaveAttribute("aria-checked", "true"); + expect(screen.getByRole("menuitemcheckbox", { name: "Memory Indicator" })).toHaveAttribute("aria-checked", "false"); + } finally { + off(); + usePlatform.setState(platform); + } +}); diff --git a/src/workbench/MainMenu.test.ts b/src/workbench/MainMenu.test.ts index e8b52d985..7a580885d 100644 --- a/src/workbench/MainMenu.test.ts +++ b/src/workbench/MainMenu.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vitest"; +import { registerAction } from "@/platform/actions"; +import { resolveMenuItems } from "@/ui/Menu"; import { menuItems, onLoneAlt } from "./MainMenu"; describe("lone Alt opens the main menu", () => { @@ -55,7 +57,7 @@ describe("lone Alt opens the main menu", () => { describe("main menu model", () => { it("maps menu nodes to menu items, localising spec labels only", () => { const items = menuItems([ - { kind: "action", id: "file.save", label: "Save All", hint: null, arg: null }, + { kind: "action", id: "file.save", label: "Save All", hint: null, arg: null, checked: null }, { kind: "separator" }, { kind: "submenu", @@ -68,6 +70,7 @@ describe("main menu model", () => { label: "openidea — ~/openidea", hint: null, arg: "/p/openidea", + checked: null, }, { kind: "placeholder", id: "recent.none", label: "No Recent Projects" }, ], @@ -86,3 +89,20 @@ describe("main menu model", () => { expect(sub[1]).toMatchObject({ label: "No Recent Projects", disabled: true }); }); }); + +describe("check marks in the main menu", () => { + it("shows a frontend-listed item's own state, one row per argument", () => { + const off = registerAction({ id: "test.mainMenu.widget", title: "Widget", run: () => {} }); + const node = (arg: string, checked: boolean) => + ({ kind: "action", id: "test.mainMenu.widget", label: arg, hint: null, arg, checked }) as const; + const items = menuItems([node("memory", true), node("caret", false)]); + expect(items[0]).toMatchObject({ action: "test.mainMenu.widget", arg: "memory", checked: true }); + const rows = resolveMenuItems(items); + expect(rows.map((r) => [r.label, r.checked])).toEqual([ + ["memory", true], + ["caret", false], + ]); + expect(new Set(rows.map((r) => r.key)).size).toBe(2); + off(); + }); +}); diff --git a/src/workbench/MainMenu.tsx b/src/workbench/MainMenu.tsx index d8e8e9c50..cbc28a5f4 100644 --- a/src/workbench/MainMenu.tsx +++ b/src/workbench/MainMenu.tsx @@ -29,7 +29,13 @@ export function menuItems(nodes: MenuNode[]): MenuItem[] { case "submenu": return { id: n.id, label: label(n.id, n.label), submenu: menuItems(n.children) }; case "action": - return { kind: "action", action: n.id, arg: n.arg ?? undefined, label: n.arg ? n.label : label(n.id, n.label) }; + return { + kind: "action", + action: n.id, + arg: n.arg ?? undefined, + label: n.arg ? n.label : label(n.id, n.label), + ...(n.checked != null && { checked: n.checked }), + }; } }); } diff --git a/src/workbench/StatusBar.tsx b/src/workbench/StatusBar.tsx index 4bd285b43..4e18e9c29 100644 --- a/src/workbench/StatusBar.tsx +++ b/src/workbench/StatusBar.tsx @@ -1,4 +1,5 @@ -import { useMemo, useRef, useState } from "react"; +import { create } from "zustand"; +import { useMemo } from "react"; import clsx from "clsx"; import { BarButton, IconButton, Popup, ProgressBar, showContextMenu, T, tooltipAttrs } from "@/ui"; import { useContributions, useStatusInfo, type StatusItemContribution } from "./contributions"; @@ -86,11 +87,16 @@ function TaskCancel({ task: t }: { task: BackgroundTask }) { ); } +const useTaskPopup = create<{ open: boolean }>(() => ({ open: false })); + +export function showBackgroundTasks() { + useTaskPopup.setState({ open: true }); +} + /** Built-in: background task progress; click lists all running tasks. */ export function TaskStatus() { const tasks = useTasks((s) => s.tasks); - const [open, setOpen] = useState(false); - const ref = useRef(null); + const open = useTaskPopup((s) => s.open); const running = tasks.filter((t) => !t.done); // A task that is working wins over the queued ones waiting behind it. const current = running.findLast((t) => !t.queued) ?? running[running.length - 1] ?? tasks[tasks.length - 1]; @@ -99,11 +105,11 @@ export function TaskStatus() { return ( <> setOpen((v) => !v)} + onClick={() => useTaskPopup.setState({ open: !open })} className={styles.task} > @@ -116,33 +122,49 @@ export function TaskStatus() { /> {more > 0 && +{more}} - 0} - anchor={ref} - placement="top-end" - offset={6} - onClose={() => setOpen(false)} - aria-label="Background tasks" - > -
-
- Background Tasks -
- {tasks.map((t) => ( -
-
- - {t.title} - - {t.fraction != null && {Math.round(t.fraction * 100)}%} - -
- - {t.message &&
{t.message}
} -
- ))} -
-
); } + +export function BackgroundTasksHost() { + const open = useTaskPopup((s) => s.open); + const tasks = useTasks((s) => s.tasks); + const anchor = document.querySelector("[data-background-tasks]") ?? { + x: window.innerWidth - 16, + y: window.innerHeight - 16, + }; + return ( + useTaskPopup.setState({ open: false })} + aria-label="Background tasks" + > +
+
+ Background Tasks +
+ {tasks.length === 0 && ( +
+ No background tasks +
+ )} + {tasks.map((t) => ( +
+
+ + {t.title} + + {t.fraction != null && {Math.round(t.fraction * 100)}%} + +
+ + {t.message &&
{t.message}
} +
+ ))} +
+
+ ); +} diff --git a/src/workbench/TitleBar.module.css b/src/workbench/TitleBar.module.css index 834b1baf4..8c334e63e 100644 --- a/src/workbench/TitleBar.module.css +++ b/src/workbench/TitleBar.module.css @@ -30,6 +30,26 @@ background: var(--bg-dialog, var(--bg-elev)); box-shadow: none; } +/* IDEA's compact header (Distraction Free, toolbar hidden): the window title, centred. */ +.compactHeader { + --h-titlebar: var(--h-compact-header); +} +.overlay.compactHeader { + padding-right: max(82px, calc(82px / var(--titlebar-zoom, 1))); +} +.overlayFullscreen.compactHeader { + padding-right: 10px; +} +.compactTitle { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + font-size: var(--fs-ui); + color: var(--fg-secondary); + cursor: default; +} .windowTitle { min-width: 0; overflow: hidden; diff --git a/src/workbench/TitleBar.test.tsx b/src/workbench/TitleBar.test.tsx index c8c802d89..a79b9b759 100644 --- a/src/workbench/TitleBar.test.tsx +++ b/src/workbench/TitleBar.test.tsx @@ -163,3 +163,24 @@ describe("merged title bar menu", () => { }, ); }); + +describe("compact window header", () => { + it("shows the window title and follows it, without toolbar widgets", async () => { + document.title = "demo – src/main.rs"; + render(); + const bar = document.querySelector("header[data-titlebar='compact']")!; + expect(bar.textContent).toBe("demo – src/main.rs"); + expect(bar.querySelector("button")).toBeNull(); + await act(async () => { + document.title = "demo – Cargo.toml"; + await Promise.resolve(); + }); + expect(bar.textContent).toBe("demo – Cargo.toml"); + }); + + it("prefers an explicit title (LightEdit)", () => { + document.title = "ignored"; + render(); + expect(document.querySelector("header[data-titlebar='compact']")!.textContent).toBe("notes.txt"); + }); +}); diff --git a/src/workbench/TitleBar.tsx b/src/workbench/TitleBar.tsx index 7340163d4..e88b337ce 100644 --- a/src/workbench/TitleBar.tsx +++ b/src/workbench/TitleBar.tsx @@ -29,6 +29,7 @@ import { useContributedThemes } from "@/platform/themes"; import { currentThemeChoice, useThemeOverride } from "./theme"; import styles from "./TitleBar.module.css"; import { useNativeTitlebar } from "./nativeTitlebar"; +import { useDocumentTitle } from "./documentTitle"; import { windowDragRegion } from "./window-drag"; export function orderTitleItems(items: Iterable) { @@ -271,21 +272,35 @@ function ToolbarEntry({ id, items }: { id: string; items: ReadonlyMap + {title ?? documentTitle} + + ); +} + /** * 40px main toolbar. macOS: native traffic lights overlay its left edge. * Windows and Linux: the window is frameless, so the bar carries IDEA's main menu (☰) and * the caption buttons. `dialog`: a dialog window's bar (Settings), close button only. * `lightEdit`: a LightEdit window's bar, its title and the menu but no toolbar widgets. + * `compact`: IDEA's compact header (Distraction Free, View › Appearance › Toolbar off). + * `bare`: the window frame only, while a project opens. */ export function TitleBar({ minimal = false, bare = false, + compact = false, dialog = false, lightEdit = false, title, }: { minimal?: boolean; bare?: boolean; + compact?: boolean; dialog?: boolean; lightEdit?: boolean; title?: string; @@ -303,18 +318,19 @@ export function TitleBar({ overlay && fullscreen && styles.overlayFullscreen, frameless && styles.frameless, ); - // Distraction Free Mode: the window frame only. Where the title bar carries the - // main menu (Windows, Linux) it stays, or nothing would lead back out of the mode. - if (bare) + // Where the title bar carries the main menu (Windows, Linux) it stays, or nothing + // would lead back out of Distraction Free Mode or bring the toolbar back. + if (bare || compact) return ( -
- {frameless && ( - <> - -
- - - )} +
+ {frameless && } + {compact ? : frameless &&
} + {frameless && }
); const entry = (id: string) => ; diff --git a/src/workbench/Workbench.tsx b/src/workbench/Workbench.tsx index 67a50b57b..dad58aaa4 100644 --- a/src/workbench/Workbench.tsx +++ b/src/workbench/Workbench.tsx @@ -23,19 +23,25 @@ import { WindowResizeHandles } from "./WindowResizeHandles"; import { useContributions, useStandaloneEditor } from "./contributions"; import styles from "./Workbench.module.css"; +/** View › Appearance switches (`appearance.showMainToolbar`, `appearance.showStatusBar`), shown by default. */ +function useAppearanceFlag(key: "showMainToolbar" | "showStatusBar"): boolean { + return useProject((s) => s.settings?.appearance?.[key] !== false); +} + /** LightEdit: editor tabs only; no tool windows, toolbar widgets or project status items. */ function LightEditFrame({ title }: { title?: string }) { const path = useEditorRegistry((s) => s.active?.ctx.path); const distractionFree = useDistractionFree((s) => s.active); + const statusBar = useAppearanceFlag("showStatusBar"); return ( <> - +
- {!distractionFree && } + {!distractionFree && statusBar && } ); } @@ -70,6 +76,8 @@ export function Workbench() { const openingPath = useProject((s) => s.openingPath); const root = project?.root ?? null; const distractionFree = useDistractionFree((s) => s.active); + const mainToolbar = useAppearanceFlag("showMainToolbar"); + const statusBar = useAppearanceFlag("showStatusBar"); const frame = project ? styles.framed : lightEdit || standaloneTitle ? styles.lightEditFrame : undefined; return ( @@ -80,14 +88,13 @@ export function Workbench() {
{project ? ( <> - {/* Distraction Free Mode keeps the bar for the traffic lights and window dragging only. */} - + {!distractionFree && } {[...banners.values()].map(({ id, component: Banner }) => ( ))} } /> - {!distractionFree && } + {!distractionFree && statusBar && } ) : lightEdit || standaloneTitle ? ( diff --git a/src/workbench/activate.ts b/src/workbench/activate.ts index 2cc8abd9e..5e88dcac4 100644 --- a/src/workbench/activate.ts +++ b/src/workbench/activate.ts @@ -25,7 +25,7 @@ import { import { loadPlatformInfo } from "@/platform/platformInfo"; import { registerShellActions } from "./shellActions"; import { MainToolbarDialogHost } from "./MainToolbarDialog"; -import { StatusInfo, TaskStatus } from "./StatusBar"; +import { BackgroundTasksHost, StatusInfo, TaskStatus } from "./StatusBar"; import { installTaskEvents } from "./tasks"; import { installToolWindowActions } from "./toolwindows/actions"; import { @@ -67,6 +67,7 @@ export function activateWorkbench(): () => void { installBrowserGuards(), installFileDrop(), registerOverlay({ id: "workbench.customizeToolbar", component: MainToolbarDialogHost }), + registerOverlay({ id: "workbench.backgroundTasks", component: BackgroundTasksHost }), registerStatusItem({ id: "workbench.tasks", align: "right", priority: 0, component: TaskStatus, lightEdit: true }), registerStatusItem({ id: "workbench.info", align: "left", priority: 5, component: StatusInfo, lightEdit: true }), registerToolWindow({ diff --git a/src/workbench/distractionFree.test.tsx b/src/workbench/distractionFree.test.tsx index b38bafbd7..421799fed 100644 --- a/src/workbench/distractionFree.test.tsx +++ b/src/workbench/distractionFree.test.tsx @@ -3,6 +3,7 @@ import { act, render, screen } from "@testing-library/react"; import { executeAction, getAction } from "@/platform/actions"; import { getContext } from "@/platform/context"; import { bindingsFor, installKeyboardDispatcher, useKeymap } from "@/platform/keymap"; +import type { Settings } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { registerStatusItem, registerToolWindow } from "./contributions"; import { clearNotifications, useNotifications } from "./notificationStore"; @@ -216,7 +217,7 @@ describe("distraction free shell", () => { expect(screen.queryByText("status-x")).toBeNull(); expect(screen.queryByRole("button", { name: "Project" })).toBeNull(); expect(screen.queryByRole("button", { name: "Search Everywhere" })).toBeNull(); - const bar = document.querySelector("header[data-titlebar='bare']"); + const bar = document.querySelector("header[data-titlebar='compact']"); // macOS uses its own drag region attribute (window-drag.ts); jsdom reports the host OS. expect(bar?.hasAttribute("data-tauri-drag-region") || bar?.hasAttribute("data-window-drag-region")).toBe(true); @@ -226,4 +227,33 @@ describe("distraction free shell", () => { unmount(); useProject.setState({ project: null }); }); + + it("View › Appearance hides the main toolbar and the status bar on their own", async () => { + const { Workbench } = await import("./Workbench"); + disposers.push( + registerStatusItem({ id: "t.status", align: "left", priority: 0, component: () => status-x }), + ); + const appearance = (a: Record) => + act(() => useProject.setState({ settings: { appearance: a } as unknown as Settings })); + act(() => + useProject.setState({ + project: { root: "/work/df", name: "df-demo", trusted: true, isGit: false, settings: {} }, + }), + ); + document.title = "df-demo – main.rs"; + const { unmount } = render(); + expect(await screen.findByText("df-demo")).toBeInTheDocument(); + + appearance({ showMainToolbar: false }); + expect(screen.queryByText("df-demo")).toBeNull(); + expect(document.querySelector("header[data-titlebar='compact']")?.textContent).toBe("df-demo – main.rs"); + expect(screen.getByText("status-x")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Project" })).toBeInTheDocument(); + + appearance({ showStatusBar: false }); + expect(screen.getByText("df-demo")).toBeInTheDocument(); + expect(screen.queryByText("status-x")).toBeNull(); + unmount(); + useProject.setState({ project: null, settings: null }); + }); }); diff --git a/src/workbench/documentTitle.ts b/src/workbench/documentTitle.ts new file mode 100644 index 000000000..eae540dd9 --- /dev/null +++ b/src/workbench/documentTitle.ts @@ -0,0 +1,12 @@ +import { useSyncExternalStore } from "react"; + +function subscribe(changed: () => void): () => void { + const observer = new MutationObserver(changed); + observer.observe(document.head, { subtree: true, childList: true, characterData: true }); + return () => observer.disconnect(); +} + +/** The window title as `features/app/title.ts` writes it to `document.title`. */ +export function useDocumentTitle(): string { + return useSyncExternalStore(subscribe, () => document.title); +} diff --git a/src/workbench/nativeTitlebar.test.tsx b/src/workbench/nativeTitlebar.test.tsx index 8e7547c03..376d30c43 100644 --- a/src/workbench/nativeTitlebar.test.tsx +++ b/src/workbench/nativeTitlebar.test.tsx @@ -95,6 +95,18 @@ describe("native title bar alignment", () => { expect(disconnect).toHaveBeenCalledTimes(2); }); + it("hands the window buttons back to the remaining bar when another bar goes away", () => { + zoom(100); + renderHook(() => useNativeTitlebar(titlebar(30).bar)); + expect(setHeight).toHaveBeenLastCalledWith(30); + // The project-opening screen covers the frame with its own 40px bar. + const opening = renderHook(() => useNativeTitlebar(titlebar(40).bar)); + expect(setHeight).toHaveBeenLastCalledWith(40); + opening.unmount(); + expect(setHeight).toHaveBeenLastCalledWith(30); + expect(setHeight).toHaveBeenCalledTimes(3); + }); + it("leaves windows without native overlay buttons alone", () => { usePlatform.setState({ overlayTitleBar: false }); const { bar } = titlebar(40); diff --git a/src/workbench/nativeTitlebar.ts b/src/workbench/nativeTitlebar.ts index e655ce406..31e3aca80 100644 --- a/src/workbench/nativeTitlebar.ts +++ b/src/workbench/nativeTitlebar.ts @@ -4,6 +4,13 @@ import { usePlatform } from "@/platform/platformInfo"; import { useProject } from "@/platform/project"; import { clampZoom } from "@/platform/zoom"; +/** + * Mounted bars, newest last, each with a resend that ignores what it sent before. The window + * has one set of buttons: when a bar covering another goes away (the project-opening screen + * over the frame), the one left re-aligns them to its own height. + */ +const mounted: Array<() => void> = []; + export function useNativeTitlebar(bar: HTMLElement | null) { const overlay = usePlatform((s) => s.overlayTitleBar); const zoom = useProject((s) => (s.settings ? clampZoom(s.settings.appearance?.zoom) : null)); @@ -25,9 +32,16 @@ export function useNativeTitlebar(bar: HTMLElement | null) { update(); const observer = new ResizeObserver(update); observer.observe(bar); + const resend = () => { + lastHeight.current = null; + update(); + }; + mounted.push(resend); return () => { observer.disconnect(); bar.style.removeProperty("--titlebar-zoom"); + mounted.splice(mounted.indexOf(resend), 1); + mounted.at(-1)?.(); }; }, [bar, overlay, zoom]); } diff --git a/src/workbench/shellActions.ts b/src/workbench/shellActions.ts index f12a5c770..f38983f9f 100644 --- a/src/workbench/shellActions.ts +++ b/src/workbench/shellActions.ts @@ -1,3 +1,4 @@ +import { showBackgroundTasks } from "./StatusBar"; import { executeAction, getAction, registerActions } from "@/platform/actions"; import { isTauri } from "@/platform/ipc"; import { closeProject, confirmLeaveProject, openProject, useProject } from "@/platform/project"; @@ -10,6 +11,7 @@ import { activateToolWindow } from "./toolwindows/store"; import { toggleTheme } from "./theme"; import { cancelTask, canCancel, useTasks } from "./tasks"; import { maybeEditorService, showStatusInfo } from "./contributions"; +import { toggleStatusWidget } from "./statusBarWidgets"; export async function openProjectDialog() { if (!isTauri()) { @@ -105,6 +107,21 @@ export function registerShellActions(): () => void { keywords: ["toolbar", "widgets", "title bar"], run: showCustomizeToolbar, }, + { + // View › Appearance › Status Bar Widgets: the argument is the widget id. + id: "view.toggleStatusBarWidget", + title: "Status Bar Widget", + category: "View", + hidden: true, + run: ({ arg }) => (typeof arg === "string" ? toggleStatusWidget(arg) : undefined), + }, + { + id: "view.backgroundTasks", + title: "Background Tasks", + category: "View", + keywords: ["progress", "running", "processes"], + run: showBackgroundTasks, + }, { id: "view.notifications", title: "Notifications", diff --git a/src/workbench/statusBarWidgets.test.tsx b/src/workbench/statusBarWidgets.test.tsx index b0027b5d1..e1edeef2b 100644 --- a/src/workbench/statusBarWidgets.test.tsx +++ b/src/workbench/statusBarWidgets.test.tsx @@ -17,6 +17,8 @@ import { ContextMenuHost } from "@/ui"; import { registerStatusItem, type StatusItemContribution } from "./contributions"; import { StatusBar } from "./StatusBar"; import { isStatusItemVisible, statusBarMenu } from "./statusBarWidgets"; +import { executeAction } from "@/platform/actions"; +import { registerShellActions } from "./shellActions"; const item = (id: string, extra: Partial = {}): StatusItemContribution => ({ id, @@ -103,4 +105,25 @@ describe("status bar widgets", () => { ); expect(await screen.findByText("w.memory")).toBeTruthy(); }); + + it("toggles one widget by id from View › Appearance › Status Bar Widgets", async () => { + disposers.push( + registerShellActions(), + registerStatusItem(item("w.caret", { title: "Line:Column Number" })), + registerStatusItem(item("w.memory", { title: "Memory Indicator", defaultVisible: false })), + registerStatusItem(item("w.tasks")), + ); + await act(() => executeAction("view.toggleStatusBarWidget", "w.memory")); + expect(settingsSet).toHaveBeenLastCalledWith( + "global", + "appearance.statusBarWidgets", + JSON.stringify({ "w.memory": true }), + ); + await act(() => executeAction("view.toggleStatusBarWidget", "w.caret")); + expect(widgets).toEqual({ "w.memory": true, "w.caret": false }); + // Untitled or unknown widgets cannot be hidden. + await act(() => executeAction("view.toggleStatusBarWidget", "w.tasks")); + await act(() => executeAction("view.toggleStatusBarWidget", "w.gone")); + expect(settingsSet).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/workbench/statusBarWidgets.ts b/src/workbench/statusBarWidgets.ts index 078b77380..601881aa3 100644 --- a/src/workbench/statusBarWidgets.ts +++ b/src/workbench/statusBarWidgets.ts @@ -2,7 +2,7 @@ import type { MenuItem } from "@/ui"; import { call, commands } from "@/platform/ipc"; import { useProject } from "@/platform/project"; -import type { StatusItemContribution } from "./contributions"; +import { useContributions, type StatusItemContribution } from "./contributions"; export type WidgetOverrides = Partial>; @@ -14,25 +14,42 @@ export function isStatusItemVisible( return overrides?.[item.id] ?? item.defaultVisible !== false; } +export interface StatusWidgetRow { + id: string; + title: string; + /** Plugin widgets are named by their plugin, not translated. */ + verbatim: boolean; + visible: boolean; +} + +/** Every titled widget of `items` (in bar order) with its visibility: the rows users can switch. */ +export function statusWidgetRows( + items: StatusItemContribution[], + overrides: WidgetOverrides | undefined, +): StatusWidgetRow[] { + return items + .filter((i) => i.title) + .map((i) => ({ + id: i.id, + title: i.title!, + verbatim: i.id.startsWith("plugin."), + visible: isStatusItemVisible(i, overrides), + })); +} + /** The status bar context menu: every titled widget with a checkmark, in bar order. */ export function statusBarMenu( items: StatusItemContribution[], overrides: WidgetOverrides | undefined, set: (id: string, visible: boolean) => void = (id, v) => void setStatusWidgetVisible(id, v), ): MenuItem[] { - return items - .filter((i) => i.title) - .map((i) => { - const visible = isStatusItemVisible(i, overrides); - // Plugin widgets are named by their plugin, not translated. - return { - id: i.id, - label: i.title!, - verbatim: i.id.startsWith("plugin."), - checked: visible, - run: () => set(i.id, !visible), - }; - }); + return statusWidgetRows(items, overrides).map((row) => ({ + id: row.id, + label: row.title, + verbatim: row.verbatim, + checked: row.visible, + run: () => set(row.id, !row.visible), + })); } /** Persists the choice in the global settings (the whole map, since widget ids contain dots). */ @@ -43,6 +60,14 @@ export async function setStatusWidgetVisible(id: string, visible: boolean): Prom useProject.setState({ settings }); } +/** Flips one widget by id (View › Appearance › Status Bar Widgets); untitled or unknown widgets stay as they are. */ +export async function toggleStatusWidget(id: string): Promise { + const item = useContributions.getState().statusItems.get(id); + if (!item?.title) return; + const visible = isStatusItemVisible(item, useProject.getState().settings?.appearance?.statusBarWidgets); + await setStatusWidgetVisible(id, !visible); +} + export function useStatusWidgetOverrides(): WidgetOverrides | undefined { return useProject((s) => s.settings?.appearance?.statusBarWidgets); } diff --git a/src/workbench/tasks.test.tsx b/src/workbench/tasks.test.tsx index c659293bf..668a15972 100644 --- a/src/workbench/tasks.test.tsx +++ b/src/workbench/tasks.test.tsx @@ -9,7 +9,7 @@ vi.mock("@/platform/ipc", async (orig) => { const { emitLocalIdeEvent, isCancelled } = await import("@/platform/ipc"); const { cancelTask, installTaskEvents, queueTask, setIndexing, upsertTask, useTasks } = await import("./tasks"); -const { TaskStatus } = await import("./StatusBar"); +const { BackgroundTasksHost, TaskStatus } = await import("./StatusBar"); const task = (id: string) => useTasks.getState().tasks.find((t) => t.id === id); @@ -126,7 +126,12 @@ describe("cancelling", () => { done: false, uncancellable: "This step can't be cancelled", }); - render(); + render( + <> + + + , + ); fireEvent.click(screen.getByRole("button", { name: /Background tasks/ })); fireEvent.click(screen.getByRole("button", { name: "Cancel Downloading JDK 25" })); expect(cancel).toHaveBeenCalledOnce(); @@ -160,3 +165,19 @@ describe("cancelling", () => { uninstall(); }); }); + +it("shows progress and uncancellable tasks through an action without the status bar", async () => { + const { registerShellActions } = await import("./shellActions"); + const { executeAction } = await import("@/platform/actions"); + const dispose = registerShellActions(); + try { + upsertTask({ id: "scan", title: "Scanning", message: "Reading files", fraction: 0.25, done: false }); + render(); + await act(() => executeAction("view.backgroundTasks")); + expect(screen.getByRole("dialog", { name: "Background tasks" })).toHaveTextContent("25%"); + expect(screen.getByText("Reading files")).toBeVisible(); + fireEvent.keyDown(document, { key: "Escape" }); + } finally { + dispose(); + } +});