Merge upstream main into fix/issue-283-color-scheme-echo

This commit is contained in:
GeekMr committed 2026-09-27 22:18:09 +08:00
commit 785502c886
7 files changed
+218 -37

No files matched your search

@@ -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<ColorPickerState>> {
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<Rgb8>,
input: Entity<InputState>,
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);
@@ -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<Entity<gpui_component::color_picker::ColorPickerState>> {
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<Self>) {
if self.theme_editor.as_ref().is_some_and(|editor| editor.save_busy) {
return;
@@ -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(),
};
@@ -483,6 +483,28 @@ fn editor_template(pane: &Entity<SettingsPane>, 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<SettingsPane>,
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::<crate::gpui_shell::config::Settings>().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();
@@ -75,7 +75,7 @@ pub(super) fn virtual_key_of(key: &str) -> Option<u16> {
/// 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<Vec<u
"\x1b[{};{};{};{key_down};{};1_",
vk,
scan_code,
unicode_char_of(ks, scan_code),
unicode_char_of(ks, vk, scan_code),
control_key_state
)
.into_bytes(),
@@ -2,6 +2,7 @@
use super::*;
use crate::i18n::Message;
use gpui_component::dialog::CancelDialog;
impl NebulaWorkspace {
pub(super) fn remote_transfer_snapshot(&self) -> Option<SftpSnapshot> {
@@ -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()
+11 -5
View File
@@ -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(),