diff --git a/nebula_app/src/gpui_shell/settings_pane/theme_advanced.rs b/nebula_app/src/gpui_shell/settings_pane/theme_advanced.rs index 914700f..118fbe0 100644 --- a/nebula_app/src/gpui_shell/settings_pane/theme_advanced.rs +++ b/nebula_app/src/gpui_shell/settings_pane/theme_advanced.rs @@ -136,6 +136,7 @@ impl ThemeAdvancedEditor { let ansi_pickers = (0..ANSI_COUNT) .map(|index| { new_color_picker( + ThemeAdvancedField::Ansi(index), Some(definition.terminal.palette.get(index).unwrap_or([0, 0, 0])), ansi_inputs[index].clone(), window, @@ -145,6 +146,7 @@ impl ThemeAdvancedEditor { }) .collect(); let selection_background_picker = new_color_picker( + ThemeAdvancedField::SelectionBackground, definition.terminal.selection_background, selection_background_input.clone(), window, @@ -152,6 +154,7 @@ impl ThemeAdvancedEditor { &mut picker_subscriptions, ); let selection_foreground_picker = new_color_picker( + ThemeAdvancedField::SelectionForeground, definition.terminal.selection_foreground, selection_foreground_input.clone(), window, @@ -159,6 +162,7 @@ impl ThemeAdvancedEditor { &mut picker_subscriptions, ); let cursor_text_picker = new_color_picker( + ThemeAdvancedField::CursorText, definition.terminal.cursor_text, cursor_text_input.clone(), window, @@ -211,6 +215,17 @@ impl ThemeAdvancedEditor { } } + #[cfg(test)] + pub(super) fn picker(&self, field: ThemeAdvancedField) -> Option<&Entity> { + match field { + ThemeAdvancedField::Ansi(index) => self.ansi_pickers.get(index), + ThemeAdvancedField::SelectionBackground => Some(&self.selection_background_picker), + ThemeAdvancedField::SelectionForeground => Some(&self.selection_foreground_picker), + ThemeAdvancedField::CursorText => Some(&self.cursor_text_picker), + _ => None, + } + } + /// Replace all control values after a template/reset/import operation. /// Invalid input errors belong to the old draft and therefore disappear. pub(super) fn sync_from_definition( @@ -557,6 +572,7 @@ impl ThemeAdvancedEditor { } fn new_color_picker( + field: ThemeAdvancedField, color: Option, input: Entity, window: &mut Window, @@ -590,6 +606,7 @@ fn new_color_picker( input.update(cx, |state, cx| { state.set_value(format_hex_rgb(rgb), window, cx); }); + this.on_theme_advanced_input(field, window, cx); }, ); subscriptions.push(subscription); diff --git a/nebula_app/src/gpui_shell/settings_pane/theme_editor.rs b/nebula_app/src/gpui_shell/settings_pane/theme_editor.rs index 23321b8..448f297 100644 --- a/nebula_app/src/gpui_shell/settings_pane/theme_editor.rs +++ b/nebula_app/src/gpui_shell/settings_pane/theme_editor.rs @@ -938,7 +938,7 @@ impl SettingsPane { cx.notify(); } - fn on_theme_advanced_input( + pub(super) fn on_theme_advanced_input( &mut self, field: ThemeAdvancedField, window: &mut Window, @@ -964,6 +964,14 @@ impl SettingsPane { cx.notify(); } + #[cfg(test)] + pub(super) fn theme_advanced_picker( + &self, + field: ThemeAdvancedField, + ) -> Option> { + self.theme_editor.as_ref()?.advanced_editor.as_ref()?.picker(field).cloned() + } + fn request_close_theme_editor(&mut self, window: &mut Window, cx: &mut Context) { if self.theme_editor.as_ref().is_some_and(|editor| editor.save_busy) { return; diff --git a/nebula_app/src/gpui_shell/settings_pane/theme_editor/view.rs b/nebula_app/src/gpui_shell/settings_pane/theme_editor/view.rs index 6ab62f3..d206ed3 100644 --- a/nebula_app/src/gpui_shell/settings_pane/theme_editor/view.rs +++ b/nebula_app/src/gpui_shell/settings_pane/theme_editor/view.rs @@ -21,6 +21,8 @@ impl SettingsPane { let foreground = theme_color(draft.terminal.foreground, 1.0); let accent = theme_color(resolved_ui.accent, 1.0); let cursor = theme_color(draft.terminal.cursor.unwrap_or(draft.terminal.foreground), 1.0); + let cursor_text = + theme_color(draft.terminal.cursor_text.unwrap_or(draft.terminal.background), 1.0); let selection_background = theme_color( draft .terminal @@ -233,7 +235,18 @@ impl SettingsPane { .gap(px(7.0)) .items_center() .child(div().text_color(accent).child("❯")) - .child(div().w(px(8.0)).h(px(font_size * line_height)).bg(cursor)), + .child( + div() + .debug_selector(|| "theme-editor-preview-cursor".to_owned()) + .w(px(font_size * 0.78)) + .h(px(font_size * line_height)) + .flex() + .items_center() + .justify_center() + .bg(cursor) + .text_color(cursor_text) + .child("A"), + ), ) .into_any_element(), }; diff --git a/nebula_app/src/gpui_shell/settings_pane/theme_studio_tests.rs b/nebula_app/src/gpui_shell/settings_pane/theme_studio_tests.rs index f4d331f..be86875 100644 --- a/nebula_app/src/gpui_shell/settings_pane/theme_studio_tests.rs +++ b/nebula_app/src/gpui_shell/settings_pane/theme_studio_tests.rs @@ -483,6 +483,28 @@ fn editor_template(pane: &Entity, cx: &mut VisualTestContext) -> T pane.read_with(cx, |pane, _| pane.theme_editor.as_ref().expect("theme editor state").template) } +fn emit_advanced_picker_color( + pane: &Entity, + field: super::theme_advanced::ThemeAdvancedField, + color: [u8; 3], + cx: &mut VisualTestContext, +) { + let picker = pane + .read_with(cx, |pane, _| pane.theme_advanced_picker(field).expect("advanced color picker")); + picker.update(cx, |_, cx| { + let [r, g, b] = color; + let hsla: gpui::Hsla = gpui::Rgba { + r: f32::from(r) / 255.0, + g: f32::from(g) / 255.0, + b: f32::from(b) / 255.0, + a: 1.0, + } + .into(); + cx.emit(gpui_component::color_picker::ColorPickerEvent::Change(Some(hsla))); + }); + draw(cx); +} + #[gpui::test] fn theme_editor_opens_from_the_picker_with_common_fields_and_collapsed_advanced( cx: &mut TestAppContext, @@ -728,6 +750,92 @@ fn theme_editor_save_and_apply_persists_copy_and_reloads_its_foreground(cx: &mut assert_eq!(palette_foreground(&mut window), applied.color("terminal", "foreground").unwrap()); } +#[gpui::test] +fn advanced_selection_and_cursor_text_picker_colors_preview_persist_apply_and_reopen( + cx: &mut TestAppContext, +) { + use super::theme_advanced::ThemeAdvancedField; + + let _fixture_guard = lock_theme_studio(); + let _settings_guard = SettingsBytesGuard::capture(); + std::fs::write(nebula_settings::settings_path(), TEST_SETTINGS) + .expect("write isolated advanced color settings"); + let mut theme_cleanup = CustomThemeCleanup::default(); + let (pane, mut window) = open_settings(cx); + let before_library_ids = custom_theme_ids(); + let saved_name = format!("Advanced colors native test {}", std::process::id()); + let selection_background = [0x1b, 0x2c, 0x3d]; + let selection_foreground = [0xe1, 0xd2, 0xc3]; + let cursor_text = [0x12, 0xeb, 0x77]; + + open_theme_editor(&mut window); + edit_input("theme-editor-name", &saved_name, &mut window); + click("theme-editor-advanced-toggle", &mut window); + assert!(window.debug_bounds("theme-editor-preview-cursor").is_some()); + + emit_advanced_picker_color( + &pane, + ThemeAdvancedField::SelectionBackground, + selection_background, + &mut window, + ); + emit_advanced_picker_color( + &pane, + ThemeAdvancedField::SelectionForeground, + selection_foreground, + &mut window, + ); + emit_advanced_picker_color(&pane, ThemeAdvancedField::CursorText, cursor_text, &mut window); + + let draft = editor_draft(&pane, &mut window); + assert_eq!(draft.terminal.selection_background, Some(selection_background)); + assert_eq!(draft.terminal.selection_foreground, Some(selection_foreground)); + assert_eq!(draft.terminal.cursor_text, Some(cursor_text)); + + click("theme-editor-save-apply", &mut window); + assert!(window.debug_bounds("theme-editor-dialog").is_none()); + + let after_documents = custom_theme_documents(); + for document in &after_documents { + if document.id().is_some_and(|id| !before_library_ids.iter().any(|before| before == id)) + && document.name().starts_with(&saved_name) + { + theme_cleanup.track(document); + } + } + let applied_id = RuntimeSettings::load().custom_theme.expect("advanced color theme is active"); + let applied = after_documents + .iter() + .find(|document| document.id() == Some(applied_id.as_str())) + .expect("advanced color theme remains in the library"); + let applied_definition = applied.definition().expect("saved advanced color definition"); + assert_eq!(applied_definition.terminal.selection_background, Some(selection_background)); + assert_eq!(applied_definition.terminal.selection_foreground, Some(selection_foreground)); + assert_eq!(applied_definition.terminal.cursor_text, Some(cursor_text)); + + window.read(|cx| { + let palette = &cx.global::().palette; + let bytes = |color: gpui::Rgba| { + [ + (color.r * 255.0).round() as u8, + (color.g * 255.0).round() as u8, + (color.b * 255.0).round() as u8, + ] + }; + assert_eq!(bytes(palette.selection), selection_background); + assert_eq!(palette.selection_foreground.map(bytes), Some(selection_foreground)); + assert_eq!(palette.cursor_text.map(bytes), Some(cursor_text)); + }); + + click("open-theme-picker", &mut window); + assert!(window.debug_bounds("appearance-picker-dialog").is_some()); + click("customize-theme", &mut window); + let reopened = editor_draft(&pane, &mut window); + assert_eq!(reopened.terminal.selection_background, Some(selection_background)); + assert_eq!(reopened.terminal.selection_foreground, Some(selection_foreground)); + assert_eq!(reopened.terminal.cursor_text, Some(cursor_text)); +} + #[gpui::test] fn invalid_color_stays_dirty_and_save_rejects_after_a_later_valid_edit(cx: &mut TestAppContext) { let _fixture_guard = lock_theme_studio(); diff --git a/nebula_app/src/gpui_shell/terminal/keymap/win32.rs b/nebula_app/src/gpui_shell/terminal/keymap/win32.rs index f9a73dd..77f6bfb 100644 --- a/nebula_app/src/gpui_shell/terminal/keymap/win32.rs +++ b/nebula_app/src/gpui_shell/terminal/keymap/win32.rs @@ -75,7 +75,7 @@ pub(super) fn virtual_key_of(key: &str) -> Option { /// Tab=0x09、Backspace=0x08):OpenConsole 1.22 的 VT 翻译层会丢弃 uChar=0 /// 的 VK_ESCAPE,于是读字节流的那类应用(Claude Code)收不到 Esc。修饰键与 /// 功能键保持 0,与真实键盘一致。逐条同旧壳 `control_char_fallback`。 -fn unicode_char_of(ks: &Keystroke, scan_code: u32) -> u16 { +fn unicode_char_of(ks: &Keystroke, virtual_key: u16, scan_code: u32) -> u16 { // 平台已经判出文本的(含 Ctrl 变体)以它为准,与 WM_CHAR 语义一致。 // `key_char` 若是 NUL,当作没文本:真实键盘的 Esc 不会写出 U+0000。 if let Some(text) = ks.key_char.as_deref() { @@ -88,7 +88,8 @@ fn unicode_char_of(ks: &Keystroke, scan_code: u32) -> u16 { } match ks.key.as_str() { "escape" => 0x1b, - "enter" => crate::platform::keyboard::enter_character( + "enter" => crate::platform::keyboard::native_character( + virtual_key, scan_code, ks.modifiers.shift, ks.modifiers.control, @@ -106,6 +107,24 @@ fn unicode_char_of(ks: &Keystroke, scan_code: u32) -> u16 { } }, "space" => b' ' as u16, + // GPUI 丢弃 Ctrl+字母的 C0 文本;Win32 记录必须恢复它,adb 等字节流 + // 应用才能收到 Ctrl+C。只补缺失文本,不把 AltGr 等组合合成为控制字符。 + _ if ks.key_char.is_none() + && ks.modifiers.control + && !ks.modifiers.alt + && !ks.modifiers.platform + && !ks.modifiers.function + && matches!(virtual_key, 0x41..=0x5a) => + { + let character = crate::platform::keyboard::native_character( + virtual_key, + scan_code, + ks.modifiers.shift, + ks.modifiers.control, + ks.modifiers.alt, + ); + if character <= 0x1f { character } else { 0 } + }, _ => 0, } } @@ -140,7 +159,7 @@ pub(super) fn win32_input_record(ks: &Keystroke, key_down: bool) -> Option Option { @@ -296,20 +297,22 @@ impl NebulaWorkspace { let overwrite_workspace = workspace.clone(); let overwrite_pending = pending.clone(); let overwrite_target = target.clone(); + // DialogClose 的固定 ID 会让同级包装器共享点击状态;直接在按钮上 + // 派发关闭动作,也让鼠标和键盘激活使用同一条路径。 let footer = DialogFooter::new() .child( - DialogClose::new().child( - Button::new("sftp-conflict-cancel") - .label(language.text(Message::TransferCancel)), - ), + Button::new("sftp-conflict-cancel") + .label(language.text(Message::TransferCancel)) + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(CancelDialog), cx); + }), ) .child(div().flex_1()) .child( - DialogClose::new().child( - Button::new("sftp-conflict-skip") - .label(language.text(Message::TransferSkip)) - .on_click(move |_, _, cx| { - let Some(workspace) = skip_workspace.upgrade() else { return }; + Button::new("sftp-conflict-skip") + .label(language.text(Message::TransferSkip)) + .on_click(move |_, window, cx| { + if let Some(workspace) = skip_workspace.upgrade() { let _ = workspace.update(cx, |workspace, cx| { workspace.start_remote_transfer( skip_pending.clone(), @@ -318,15 +321,15 @@ impl NebulaWorkspace { cx, ); }); - }), - ), + } + window.dispatch_action(Box::new(CancelDialog), cx); + }), ) .child( - DialogClose::new().child( - Button::new("sftp-conflict-keep-both") - .label(language.text(Message::TransferKeepBoth)) - .on_click(move |_, _, cx| { - let Some(workspace) = keep_workspace.upgrade() else { return }; + Button::new("sftp-conflict-keep-both") + .label(language.text(Message::TransferKeepBoth)) + .on_click(move |_, window, cx| { + if let Some(workspace) = keep_workspace.upgrade() { let _ = workspace.update(cx, |workspace, cx| { workspace.start_remote_transfer( keep_pending.clone(), @@ -335,17 +338,17 @@ impl NebulaWorkspace { cx, ); }); - }), - ), + } + window.dispatch_action(Box::new(CancelDialog), cx); + }), ) .child( - DialogClose::new().child( - Button::new("sftp-conflict-overwrite") - .label(language.text(Message::TransferOverwrite)) - .danger() - .disabled(!overwrite_allowed) - .on_click(move |_, _, cx| { - let Some(workspace) = overwrite_workspace.upgrade() else { return }; + Button::new("sftp-conflict-overwrite") + .label(language.text(Message::TransferOverwrite)) + .danger() + .disabled(!overwrite_allowed) + .on_click(move |_, window, cx| { + if let Some(workspace) = overwrite_workspace.upgrade() { let _ = workspace.update(cx, |workspace, cx| { workspace.start_remote_transfer( overwrite_pending.clone(), @@ -354,13 +357,20 @@ impl NebulaWorkspace { cx, ); }); - }), - ), + } + window.dispatch_action(Box::new(CancelDialog), cx); + }), ); center_modal_dialog(dialog, window, 220.0) .close_button(false) .overlay_closable(true) + // 此处没有默认传输策略。Enter 应交给聚焦按钮的按下/抬起处理, + // 不能由 Dialog 的默认确认动作直接关闭而跳过策略选择。 + .on_ok(|_, _, cx| { + cx.propagate(); + false + }) .title( div() .text_lg() diff --git a/nebula_app/src/platform/keyboard.rs b/nebula_app/src/platform/keyboard.rs index 917c1b3..3715e1a 100644 --- a/nebula_app/src/platform/keyboard.rs +++ b/nebula_app/src/platform/keyboard.rs @@ -1,10 +1,16 @@ //! Native character data discarded by the GPUI keyboard adapter. -/// Recover Return's native character without changing keyboard composition state. -/// GPUI filters control text out of `key_char`, including Ctrl+Enter's LF. -pub(crate) fn enter_character(scan_code: u32, shift: bool, control: bool, alt: bool) -> u16 { +/// Recover native character data without changing keyboard composition state. +/// GPUI filters control text out of `key_char`, including Ctrl+C's ETX. +pub(crate) fn native_character( + virtual_key: u16, + scan_code: u32, + shift: bool, + control: bool, + alt: bool, +) -> u16 { use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ - ToUnicode, VK_CONTROL, VK_MENU, VK_RETURN, VK_SHIFT, + ToUnicode, VK_CONTROL, VK_MENU, VK_SHIFT, }; let mut state = [0_u8; 256]; @@ -16,7 +22,7 @@ pub(crate) fn enter_character(scan_code: u32, shift: bool, control: bool, alt: b // prevents dead-key state mutation; 0x1 selects menu translation semantics. let count = unsafe { ToUnicode( - u32::from(VK_RETURN), + u32::from(virtual_key), scan_code, state.as_ptr(), text.as_mut_ptr(),