diff --git a/crates/gpui_term/src/element.rs b/crates/gpui_term/src/element.rs index dc725d1..865c6c9 100644 --- a/crates/gpui_term/src/element.rs +++ b/crates/gpui_term/src/element.rs @@ -558,6 +558,17 @@ impl InteractiveElement for TerminalElement { impl StatefulInteractiveElement for TerminalElement {} impl TerminalElement { + fn owns_selection_drag(terminal_view: &Entity, cx: &App) -> bool { + cx.try_global::() + .is_some_and(|owner| owner.0 == Some(terminal_view.entity_id())) + } + + fn clear_selection_drag_owner(terminal_view: &Entity, cx: &mut App) { + if Self::owns_selection_drag(terminal_view, cx) { + cx.set_global(crate::TerminalSelectionOwner(None)); + } + } + pub fn new( terminal: Entity, terminal_view: Entity, @@ -693,6 +704,9 @@ impl TerminalElement { terminal_view.update(cx, |view: &mut TerminalView, _| { view.set_mouse_left_down_in_terminal(true); }); + cx.set_global(crate::TerminalSelectionOwner(Some( + terminal_view.entity_id(), + ))); let scroll_top = terminal_view.read(cx).scroll_top(); terminal.update(cx, |terminal, cx| { @@ -759,6 +773,7 @@ impl TerminalElement { ); cx.notify(); }); + Self::clear_selection_drag_owner(terminal_view, cx); true } @@ -774,6 +789,10 @@ impl TerminalElement { window: &mut Window, cx: &mut App, ) { + if !Self::owns_selection_drag(terminal_view, cx) { + return; + } + // If the drag started in this terminal view, keep updating the selection even if the // cursor leaves the terminal hitbox (or focus changes). if !e.dragging() @@ -943,6 +962,7 @@ impl TerminalElement { }); if was_scrollbar_dragging { + Self::clear_selection_drag_owner(&terminal_view, cx); terminal_view.update(cx, |_, view_cx| view_cx.notify()); return; } @@ -951,6 +971,7 @@ impl TerminalElement { terminal.mouse_up(e, cx); cx.notify(); }); + Self::clear_selection_drag_owner(&terminal_view, cx); } }); } @@ -983,6 +1004,7 @@ impl TerminalElement { }); if was_scrollbar_dragging { + Self::clear_selection_drag_owner(&terminal_view, cx); terminal_view.update(cx, |_, view_cx| view_cx.notify()); return; } @@ -991,6 +1013,7 @@ impl TerminalElement { terminal.mouse_up(e, cx); cx.notify(); }); + Self::clear_selection_drag_owner(&terminal_view, cx); } }); } diff --git a/crates/gpui_term/src/lib.rs b/crates/gpui_term/src/lib.rs index c585132..5c44aeb 100644 --- a/crates/gpui_term/src/lib.rs +++ b/crates/gpui_term/src/lib.rs @@ -4,6 +4,11 @@ use bitflags::bitflags; rust_i18n::i18n!("../../locales"); +#[derive(Clone, Copy, Default)] +pub(crate) struct TerminalSelectionOwner(pub(crate) Option); + +impl gpui::Global for TerminalSelectionOwner {} + mod backends; mod builder; pub mod cast; diff --git a/crates/gpui_term/src/view/render.rs b/crates/gpui_term/src/view/render.rs index e259674..214fb48 100644 --- a/crates/gpui_term/src/view/render.rs +++ b/crates/gpui_term/src/view/render.rs @@ -87,6 +87,18 @@ impl TerminalView { .on_action(cx.listener(TerminalView::stop_cast_recording)) .on_action(cx.listener(TerminalView::toggle_cast_recording)) .on_key_down(cx.listener(Self::key_down)) + .on_mouse_move(cx.listener(|this, _, window, cx| { + // Hovering a visible terminal should make it the active input target. The + // terminal view only receives this event when it is the topmost hit element, so + // covered terminals cannot steal focus from the panel above them. + if cx + .try_global::() + .is_some_and(|owner| owner.0.is_some()) + { + return; + } + window.focus(&this.focus_handle, cx); + })) } fn terminal_view_root_mouse_handlers( diff --git a/crates/menubar/src/lib.rs b/crates/menubar/src/lib.rs index 3ae1404..2121e36 100644 --- a/crates/menubar/src/lib.rs +++ b/crates/menubar/src/lib.rs @@ -1,5 +1,34 @@ use gpui::App; +#[derive(Clone, Copy, Debug)] +pub struct MenuBarSettings { + pub auto_collapse: bool, +} + +impl Default for MenuBarSettings { + fn default() -> Self { + Self { + auto_collapse: true, + } + } +} + +impl gpui::Global for MenuBarSettings {} + +pub fn set_auto_collapse(auto_collapse: bool, cx: &mut App) { + if cx.has_global::() { + cx.global_mut::().auto_collapse = auto_collapse; + } else { + cx.set_global(MenuBarSettings { auto_collapse }); + } + cx.refresh_windows(); +} + +pub fn auto_collapse(cx: &App) -> bool { + cx.try_global::() + .map_or(true, |settings| settings.auto_collapse) +} + rust_i18n::i18n!("../../locales"); mod menu_bar; diff --git a/crates/menubar/src/menu_bar.rs b/crates/menubar/src/menu_bar.rs index 50b7041..29fd626 100644 --- a/crates/menubar/src/menu_bar.rs +++ b/crates/menubar/src/menu_bar.rs @@ -13,7 +13,7 @@ use gpui_component::{ }; use rust_i18n::t; -use crate::state::MenuBarState; +use crate::{auto_collapse, state::MenuBarState}; const CONTEXT: &str = "FoldableAppMenuBar"; @@ -237,7 +237,15 @@ impl FoldableAppMenuBar { window.prevent_default(); cx.stop_propagation(); - self.state.on_fold_click(); + if auto_collapse(cx) { + self.state.on_fold_click(); + } else if self.state.selected_ix == Some(0) { + // With a permanently expanded menubar, clicking the first menu toggles only its + // popup; the menubar itself must remain visible. + self.state.selected_ix = None; + } else { + self.state.selected_ix = Some(0); + } self.set_selected_index(self.state.selected_ix, window, cx); } @@ -246,7 +254,7 @@ impl FoldableAppMenuBar { return; } // Don't expand/open from hover when folded. - if !self.state.expanded { + if !auto_collapse(cx) || !self.state.expanded { return; } // Switch from other top-level menus back to the fold/app menu when the menubar is active. @@ -314,6 +322,12 @@ impl Render for FoldableAppMenuBar { self.sync_menus_from_app(window, cx); + if !auto_collapse(cx) { + self.state.expanded = true; + } else if self.state.selected_ix.is_none() { + self.state.expanded = false; + } + let fold_name: SharedString = self .fold_menu .as_ref() diff --git a/locales/en.yml b/locales/en.yml index d93e217..b6c1cfd 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -382,6 +382,7 @@ Settings: Appearance: Theme: "Theme" Language: "Language" + Menu: "Menu" Terminal: Terminal: "Terminal" Behavior: "Behavior" @@ -447,6 +448,9 @@ Settings: language: Title: "Language" Description: "Choose the application UI language." + menu_auto_collapse: + Title: "Auto-Collapse Menu" + Description: "Whether to automatically collapse the application menu." terminal: default_backend: Title: "Default backend" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 2601407..12f7844 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -382,6 +382,7 @@ Settings: Appearance: Theme: "主题" Language: "语言" + Menu: "菜单" Terminal: Terminal: "终端" Behavior: "行为" @@ -447,6 +448,9 @@ Settings: language: Title: "语言" Description: "选择应用界面语言。" + menu_auto_collapse: + Title: "自动折叠菜单" + Description: "是否自动折叠菜单。" terminal: default_backend: Title: "默认后端" diff --git a/termua/src/panel/assistant_panel.rs b/termua/src/panel/assistant_panel.rs index 83bdef0..c71c9be 100644 --- a/termua/src/panel/assistant_panel.rs +++ b/termua/src/panel/assistant_panel.rs @@ -8,6 +8,7 @@ use gpui_common::TermuaIcon; use gpui_component::{ ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _, button::{Button, ButtonVariants as _}, + dialog::{DialogAction, DialogClose, DialogFooter}, h_flex, input::{Textarea, TextareaState}, menu::{DropdownMenu as _, PopupMenu, PopupMenuItem}, @@ -1038,19 +1039,27 @@ impl AssistantPanelView { cx: &mut Context, ) { root.open_dialog( - move |dialog, _window, _app| { + move |dialog, _window, app| { + let cancel_button = Button::new("termua-assistant-run-command-cancel") + .label(t!("Assistant.Dialog.RunInTerminalCancel").to_string()) + .debug_selector(|| "termua-assistant-run-command-cancel".to_string()); + let run_button = Button::new("termua-assistant-run-command-run") + .label(t!("Assistant.Dialog.RunInTerminalOk").to_string()) + .primary() + .debug_selector(|| "termua-assistant-run-command-run".to_string()); + dialog .title(t!("Assistant.Dialog.RunInTerminalTitle").to_string()) .w(px(720.)) .child(Self::run_command_dialog_body( target_label.clone(), &command, + app, )) - .button_props( - gpui_component::dialog::DialogButtonProps::default() - .ok_text(t!("Assistant.Dialog.RunInTerminalOk").to_string()) - .cancel_text(t!("Assistant.Dialog.RunInTerminalCancel").to_string()) - .show_cancel(true), + .footer( + DialogFooter::new() + .child(DialogClose::new().child(cancel_button)) + .child(DialogAction::new().child(run_button)), ) .on_ok({ let this = this.clone(); @@ -1073,15 +1082,25 @@ impl AssistantPanelView { ); } - fn run_command_dialog_body(target_label: String, command: &str) -> AnyElement { + fn run_command_dialog_body(target_label: String, command: &str, app: &App) -> AnyElement { let command_md = format!("```sh\n{command}\n```"); + let field_label_color = app.theme().muted_foreground; + let card_border = app.theme().border.opacity(0.8); + let card_background = app.theme().border.opacity(0.12); + v_flex() - .gap_2() + .pt_2() + .gap_4() .child( h_flex() - .gap_2() - .items_start() - .child(div().child(t!("Assistant.Label.Target").to_string())) + .gap_3() + .items_center() + .child( + div() + .text_xs() + .text_color(field_label_color) + .child(t!("Assistant.Label.Target").to_string()), + ) .child( div().min_w_0().child( TextView::markdown("termua-assistant-run-target", target_label) @@ -1090,15 +1109,27 @@ impl AssistantPanelView { ), ) .child( - h_flex() - .gap_2() - .items_start() - .child(div().child(t!("Assistant.Label.Command").to_string())) + v_flex() + .gap_1() .child( - div().min_w_0().child( - TextView::markdown("termua-assistant-run-command", command_md) - .selectable(true), - ), + div() + .text_xs() + .text_color(field_label_color) + .child(t!("Assistant.Label.Command").to_string()), + ) + .child( + div() + .w_full() + .min_w_0() + .p_3() + .rounded_lg() + .border_1() + .border_color(card_border) + .bg(card_background) + .child( + TextView::markdown("termua-assistant-run-command", command_md) + .selectable(true), + ), ), ) .into_any_element() diff --git a/termua/src/settings.rs b/termua/src/settings.rs index 90ac148..f36dde4 100644 --- a/termua/src/settings.rs +++ b/termua/src/settings.rs @@ -142,7 +142,7 @@ where language } -#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default)] pub struct AppearanceSettings { pub theme: ThemeMode, @@ -151,6 +151,20 @@ pub struct AppearanceSettings { pub light_theme: Option, /// Name of the selected dark theme config (from ThemeRegistry). None = registry default. pub dark_theme: Option, + /// Whether the in-window application menu collapses to the menu icon. + pub menu_auto_collapse: bool, +} + +impl Default for AppearanceSettings { + fn default() -> Self { + Self { + theme: ThemeMode::default(), + language: Language::default(), + light_theme: None, + dark_theme: None, + menu_auto_collapse: true, + } + } } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -740,6 +754,7 @@ impl SettingsFile { set_language(self.appearance.language, cx); set_theme_mode(self.appearance.theme, window, cx); + menubar::set_auto_collapse(self.appearance.menu_auto_collapse, cx); } pub fn apply_assistant_settings(&self, cx: &mut App) { @@ -1100,6 +1115,7 @@ mod tests { let settings = SettingsFile::load_from_str_lenient("{}").unwrap(); assert_eq!(settings.terminal.copy_on_select, true); assert_eq!(settings.terminal.option_as_meta, false); + assert!(settings.appearance.menu_auto_collapse); assert_eq!(settings.terminal.ligatures_enabled(), true); } diff --git a/termua/src/window/main_window/actions.rs b/termua/src/window/main_window/actions.rs index 470569b..d1fcee4 100644 --- a/termua/src/window/main_window/actions.rs +++ b/termua/src/window/main_window/actions.rs @@ -133,20 +133,41 @@ impl TermuaWindow { gpui_component::Root::update(window, app, |root, window, cx| { root.open_dialog( move |dialog, _window, _app| { + let cancel_button = Button::new("termua-quit-confirm-cancel") + .label(t!("MainWindow.QuitConfirm.Button.Cancel").to_string()) + .debug_selector(|| "termua-quit-confirm-cancel".to_string()); + let quit_button = Button::new("termua-quit-confirm-quit") + .label(t!("MainWindow.QuitConfirm.Button.Quit").to_string()) + .primary() + .debug_selector(|| "termua-quit-confirm-quit".to_string()); + dialog - .title(t!("MainWindow.QuitConfirm.Title").to_string()) + .title( + h_flex() + .gap_2() + .items_center() + .child( + Icon::default() + .path(TermuaIcon::AlertCircle) + .text_color(_app.theme().warning), + ) + .child(t!("MainWindow.QuitConfirm.Title").to_string()), + ) .child( - div() + h_flex() + .gap_3() + .items_start() .debug_selector(|| "termua-quit-confirm-body".to_string()) - .child(t!("MainWindow.QuitConfirm.Body").to_string()), + .child( + div() + .flex_1() + .child(t!("MainWindow.QuitConfirm.Body").to_string()), + ), ) - .button_props( - gpui_component::dialog::DialogButtonProps::default() - .ok_text(t!("MainWindow.QuitConfirm.Button.Quit").to_string()) - .cancel_text( - t!("MainWindow.QuitConfirm.Button.Cancel").to_string(), - ) - .show_cancel(true), + .footer( + DialogFooter::new() + .child(DialogClose::new().child(cancel_button)) + .child(DialogAction::new().child(quit_button)), ) .on_ok(|_, _window, app| { app.quit(); diff --git a/termua/src/window/main_window/actions/ssh.rs b/termua/src/window/main_window/actions/ssh.rs index 3d25e73..89d6aff 100644 --- a/termua/src/window/main_window/actions/ssh.rs +++ b/termua/src/window/main_window/actions/ssh.rs @@ -8,7 +8,7 @@ use gpui_common::TermuaIcon; use gpui_component::{ Icon, button::{Button, ButtonVariants}, - dialog::DialogFooter, + dialog::{DialogAction, DialogClose, DialogFooter}, h_flex, v_flex, }; use gpui_dock::{DockPlacement, PanelView}; @@ -50,16 +50,22 @@ impl TermuaWindow { move |dialog, _window, app| { let decision_tx_ok = decision_tx.clone(); let decision_tx_cancel = decision_tx.clone(); + let reject_button = Button::new("termua-ssh-host-verify-reject") + .label(t!("SshHostVerify.Button.Reject").to_string()) + .debug_selector(|| "termua-ssh-host-verify-reject".to_string()); + let trust_button = Button::new("termua-ssh-host-verify-trust") + .label(t!("SshHostVerify.Button.TrustContinue").to_string()) + .primary() + .debug_selector(|| "termua-ssh-host-verify-trust".to_string()); dialog .title(Self::ssh_host_verification_dialog_title(app)) .w(px(720.)) .child(Self::ssh_host_verification_dialog_body(&target, &message)) - .button_props( - gpui_component::dialog::DialogButtonProps::default() - .ok_text(t!("SshHostVerify.Button.TrustContinue").to_string()) - .cancel_text(t!("SshHostVerify.Button.Reject").to_string()) - .show_cancel(true), + .footer( + DialogFooter::new() + .child(DialogClose::new().child(reject_button)) + .child(DialogAction::new().child(trust_button)), ) .on_ok(move |_, _window, _app| { let _ = decision_tx_ok.try_send(true); diff --git a/termua/src/window/main_window/tests.rs b/termua/src/window/main_window/tests.rs index 8481bf5..7f07af0 100644 --- a/termua/src/window/main_window/tests.rs +++ b/termua/src/window/main_window/tests.rs @@ -1337,6 +1337,16 @@ fn request_quit_with_open_tabs_requires_confirmation(cx: &mut gpui::TestAppConte window_cx.debug_bounds("termua-quit-confirm-body").is_some(), "expected quit confirmation dialog when tabs are open" ); + assert!( + window_cx + .debug_bounds("termua-quit-confirm-cancel") + .is_some(), + "expected quit confirmation dialog to render a Cancel button" + ); + assert!( + window_cx.debug_bounds("termua-quit-confirm-quit").is_some(), + "expected quit confirmation dialog to render a Quit button" + ); } #[cfg_attr(target_os = "macos", ignore)] diff --git a/termua/src/window/settings/meta.rs b/termua/src/window/settings/meta.rs index 385c680..edccd38 100644 --- a/termua/src/window/settings/meta.rs +++ b/termua/src/window/settings/meta.rs @@ -78,6 +78,15 @@ static ALL_SETTINGS_META: &[SettingMeta] = &[ section: SettingsNavSection::Appearance, page: SettingsPage::AppearanceLanguage, }, + #[cfg(not(target_os = "macos"))] + SettingMeta { + id: "appearance.menu_auto_collapse", + title: "Auto-collapse menu", + description: "Whether to automatically hide the application menu.", + keywords: &["appearance", "menu", "menubar", "collapse", "fold"], + section: SettingsNavSection::Appearance, + page: SettingsPage::AppearanceMenu, + }, SettingMeta { id: "terminal.default_backend", title: "Default backend", diff --git a/termua/src/window/settings/state.rs b/termua/src/window/settings/state.rs index fe03426..0b18305 100644 --- a/termua/src/window/settings/state.rs +++ b/termua/src/window/settings/state.rs @@ -122,6 +122,7 @@ impl TerminalKeybinding { pub enum SettingsPage { AppearanceTheme, AppearanceLanguage, + AppearanceMenu, Terminal, TerminalFont, TerminalKeyBindings, @@ -172,6 +173,16 @@ const SETTINGS_PAGE_SPECS: &[SettingsPageSpec] = &[ hint_key: None, is_sidebar_item: true, }, + #[cfg(not(target_os = "macos"))] + SettingsPageSpec { + section: SettingsNavSection::Appearance, + item_label_key: "Settings.Appearance.Menu", + page: SettingsPage::AppearanceMenu, + nav_item_id: "nav.page.appearance.menu", + heading_key: "Settings.Appearance.Menu", + hint_key: None, + is_sidebar_item: true, + }, // `SettingsPage::Terminal` maps to the group row (`nav.group.terminal`) and should not show as // a child item in the sidebar. SettingsPageSpec { @@ -346,6 +357,7 @@ fn nav_item_sort_key(page: SettingsPage) -> &'static str { match page { SettingsPage::AppearanceTheme => "Theme", SettingsPage::AppearanceLanguage => "Language", + SettingsPage::AppearanceMenu => "Menu", SettingsPage::Terminal => "Terminal", SettingsPage::TerminalFont => "Font", SettingsPage::TerminalKeyBindings => "Key Bindings", diff --git a/termua/src/window/settings/tests.rs b/termua/src/window/settings/tests.rs index d88079a..23b2bdb 100644 --- a/termua/src/window/settings/tests.rs +++ b/termua/src/window/settings/tests.rs @@ -2366,6 +2366,8 @@ fn setting_metadata_covers_all_controls() { assert!(ids.contains("appearance.light_theme")); assert!(ids.contains("appearance.dark_theme")); assert!(ids.contains("appearance.language")); + #[cfg(not(target_os = "macos"))] + assert!(ids.contains("appearance.menu_auto_collapse")); // Terminal / Font assert!(ids.contains("terminal.font_family")); @@ -2452,6 +2454,9 @@ fn sidebar_nav_specs_match_settings_nav_requirements() { .iter() .find(|group| group.section == SettingsNavSection::Appearance) .expect("expected Appearance group"); + #[cfg(not(target_os = "macos"))] + assert_eq!(appearance.items.len(), 3); + #[cfg(target_os = "macos")] assert_eq!(appearance.items.len(), 2); assert!( appearance @@ -2465,6 +2470,20 @@ fn sidebar_nav_specs_match_settings_nav_requirements() { .iter() .any(|item| item.page == SettingsPage::AppearanceLanguage) ); + #[cfg(not(target_os = "macos"))] + assert!( + appearance + .items + .iter() + .any(|item| item.page == SettingsPage::AppearanceMenu) + ); + #[cfg(target_os = "macos")] + assert!( + !appearance + .items + .iter() + .any(|item| item.page == SettingsPage::AppearanceMenu) + ); assert!( specs diff --git a/termua/src/window/settings/view.rs b/termua/src/window/settings/view.rs index ab70fa6..c0d32be 100644 --- a/termua/src/window/settings/view.rs +++ b/termua/src/window/settings/view.rs @@ -99,7 +99,7 @@ macro_rules! settings_supported_id_matches { | "assistant.provider_timeout_secs" | "assistant.extra_headers" | "assistant.api_key" - ) + ) || (cfg!(not(target_os = "macos")) && $id == "appearance.menu_auto_collapse") }; } @@ -1587,6 +1587,15 @@ impl SettingsWindow { match id { "appearance.theme" => Some(self.render_appearance_theme_control(cx)), "appearance.language" => Some(self.render_appearance_language_control(cx)), + "appearance.menu_auto_collapse" => Some(self.render_bool_switch( + "settings-appearance-menu-auto-collapse", + self.settings.appearance.menu_auto_collapse, + |this, checked, window, cx| { + this.settings.appearance.menu_auto_collapse = checked; + this.apply_and_save(window, cx); + }, + cx, + )), "appearance.light_theme" => { Some(self.render_theme_dropdown(ThemeDropdownKind::Light, window, cx)) }