diff --git a/nebula_app/src/ai_hook.rs b/nebula_app/src/ai_hook.rs index 6c56d45..460fc39 100644 --- a/nebula_app/src/ai_hook.rs +++ b/nebula_app/src/ai_hook.rs @@ -1011,7 +1011,15 @@ export default function (pi: ExtensionAPI) { /// Write via tmp + rename (MoveFileEx REPLACE_EXISTING under the hood): /// readers never observe a torn file, a crash leaves the original intact. fn write_atomic(path: &Path, data: &str) -> std::io::Result<()> { - let tmp = path.with_extension("nebula-tmp"); + // 临时文件名带上进程号。多个 Nebula 实例各自守着同一份 + // `settings.json` 自愈,共用一个固定的 tmp 名就会互相踩:A 写 tmp、 + // B 覆盖同一个 tmp、A `rename` 把它搬走,B 的 `rename` 于是报 + // `ERROR_FILE_NOT_FOUND(2)`——一句"系统找不到指定的文件",指的却是 + // 那个临时文件,读起来像 settings.json 不见了。 + // + // 内容本身是幂等的(装的是同一套 hook 条目),所以最后谁赢都行, + // 要防的只是这个假报错。 + let tmp = path.with_extension(format!("nebula-tmp-{}", std::process::id())); std::fs::write(&tmp, data)?; std::fs::rename(&tmp, path) } diff --git a/nebula_app/src/display/chrome.rs b/nebula_app/src/display/chrome.rs index a89daad..006f50f 100644 --- a/nebula_app/src/display/chrome.rs +++ b/nebula_app/src/display/chrome.rs @@ -1695,6 +1695,9 @@ pub(super) fn draw_chrome(d: &mut Display) { let cy = row_text_cy(hy, hh); let name = d.nebula_ssh_hosts.get(index).map(String::as_str).unwrap_or("?"); let pinned = d.nebula_pinned_hosts.iter().any(|p| p == name); + // 起过名字就显示名字。地址仍然是这一行的身份(置顶、删除、连接 + // 都按地址走),只有给人看的那串文字换掉。 + let shown = d.nebula_ssh_labels.get(name).map(String::as_str).unwrap_or(name); // Label budget from its real start to the row's right edge // (minus the pin marker's slot when pinned), in columns. The // leading remote icon + space cost 2 columns of the drawn @@ -1706,7 +1709,7 @@ pub(super) fn draw_chrome(d: &mut Display) { let row_tracking = s(0.35); let max_cols = (((right - text_x) / (cell_w + row_tracking)).floor() as usize) .saturating_sub(2); - let label = truncate_tab_label(name, max_cols.max(1)); + let label = truncate_tab_label(shown, max_cols.max(1)); d.renderer.draw_chrome_text( &size, text_x, diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index 25fc51b..0e78953 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -109,7 +109,9 @@ pub use state::{ mod file_dialog; pub(crate) mod keymap; mod settings; +mod ssh_editor_input; mod ssh_editor_render; +pub(crate) mod ssh_connect; mod ssh_ui; mod text_input; @@ -755,18 +757,14 @@ pub(crate) fn nebula_link_log(message: impl AsRef) { } } -/// Directory holding Nebula's persistent state (`%APPDATA%\Nebula` or -/// `~/.config/Nebula`), created on demand. Settings live here next to the -/// history file managed by [`crate::nebula_history`] and the session -/// snapshot managed by [`crate::session`]. +/// Directory holding Nebula's persistent state, created on demand. Settings +/// live here next to the history file managed by [`crate::nebula_history`] and +/// the session snapshot managed by [`crate::session`]. +/// +/// Per-platform locations and the reasoning behind them live in +/// [`crate::platform::dirs`] — this is a thin alias kept for its 26 call sites. pub(crate) fn nebula_data_dir() -> PathBuf { - let base = std::env::var_os("APPDATA") - .map(PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) - .unwrap_or_else(std::env::temp_dir); - let dir = base.join("Nebula"); - let _ = std::fs::create_dir_all(&dir); - dir + crate::platform::dirs::data_dir().to_path_buf() } /// Read one raw `key=value` from `nebula_settings.txt` (case-insensitive key). @@ -1130,6 +1128,14 @@ pub struct Display { /// Transient "cols × rows" HUD shown briefly after a window resize; it fades /// out over ~0.9s. `None` when nothing is showing. nebula_resize_hud: Option, + + /// 每个 pane 的 SSH 连接进度。成功时立刻移除——卡片让位给真实终端, + /// 持续重绘也随之停止;失败保留,让用户读得到原因。 + nebula_ssh_connect: std::collections::HashMap, + /// 聚焦 pane 的 id,由绘制流程每帧同步。连接卡片只画在聚焦 pane 里, + /// 而 `nebula_pane_view` 只给几何、不给身份。 + nebula_focused_pane: u64, + /// Skip the first resize (window creation) so no HUD flashes at startup. nebula_resize_hud_armed: bool, @@ -1172,6 +1178,9 @@ pub struct Display { pub nebula_ssh_editor_rects: Option, nebula_ssh_editor_open: bool, nebula_ssh_editor_hover: SshEditorHit, + /// 正在拖选的字段。鼠标按在输入框里时置位,松开清掉——拖拽的语义是 + /// "从按下的那个字符拉到现在这个字符",所以中途划出框外也要继续跟。 + nebula_ssh_editor_drag: Option, /// 「测试连接」点击时暂存的请求;input 层随后取走并交给 SSH runtime。 /// display 不持有事件代理,这一格就是点击→网络之间的交接台。 nebula_ssh_test_request: Option, @@ -1279,6 +1288,9 @@ pub struct Display { /// them here makes Delete stable instead of letting the next merge revive /// the row immediately. nebula_hidden_hosts: Vec, + /// 地址 → 用户起的显示名,从 `ssh_profiles.json` 缓存而来。侧栏每帧都要 + /// 画这些行,读文件必须发生在保存那一刻,而不是绘制路径上。 + nebula_ssh_labels: std::collections::HashMap, /// Accordion fold state of the two sidebar sections. nebula_tabs_section_open: bool, nebula_hosts_section_open: bool, @@ -1784,6 +1796,8 @@ impl Display { cursor_hidden: Default::default(), nebula_pane_view: None, nebula_resize_hud: None, + nebula_ssh_connect: std::collections::HashMap::new(), + nebula_focused_pane: 0, nebula_resize_hud_armed: false, nebula_history: { let history = crate::nebula_history::NebulaHistory::load(); @@ -1806,6 +1820,7 @@ impl Display { nebula_ssh_editor_rects: None, nebula_ssh_editor_open: false, nebula_ssh_editor_hover: SshEditorHit::None, + nebula_ssh_editor_drag: None, nebula_ssh_test_request: None, nebula_ssh_test_seq: 0, nebula_frame_images: Vec::new(), @@ -1871,6 +1886,11 @@ impl Display { nebula_pinned_hosts: settings_init.pinned_hosts.clone(), nebula_saved_hosts: settings_init.saved_hosts.clone(), nebula_hidden_hosts: settings_init.hidden_hosts.clone(), + nebula_ssh_labels: crate::ssh_profiles::SshProfiles::load( + &nebula_data_dir().join("ssh_profiles.json"), + ) + .map(|profiles| profiles.labels()) + .unwrap_or_default(), nebula_tabs_section_open: true, nebula_hosts_section_open: true, nebula_tabs_scroll: 0, @@ -1981,464 +2001,6 @@ impl Display { self.nebula_hidden_hosts.len() } - pub fn open_ssh_editor(&mut self) { - self.nebula_ssh_editor = Some(SshHostEditor { - original_destination: None, - error: None, - destination_selection: Default::default(), - password_selection: Default::default(), - destination: String::new(), - password: String::new(), - save_password: true, - show_password: false, - auth: crate::ssh_profiles::SshAuthMode::Auto, - private_keys: Vec::new(), - field: SshEditorField::Destination, - focus: crate::ux::FocusIndex::default(), - test: Default::default(), - }); - self.nebula_ssh_editor_rects = None; - self.nebula_ssh_editor_open = true; - self.nebula_ssh_editor_hover = SshEditorHit::None; - // 每次打开都从零开始,避免上一次退出动画的残余进度造成闪跳。 - self.nebula_ui_anims.ssh_editor = UiAnim::new(0.0); - self.pending_update.dirty = true; - } - - pub fn edit_ssh_host(&mut self, index: usize) { - let Some(destination) = self.nebula_ssh_hosts.get(index).cloned() else { return }; - let profile = - crate::ssh_profiles::SshProfiles::load(&nebula_data_dir().join("ssh_profiles.json")) - .unwrap_or_else(|err| { - warn!("加载 SSH Profile 失败,编辑器使用自动认证: {err}"); - crate::ssh_profiles::SshProfiles::default() - }) - .for_destination(&destination); - self.nebula_ssh_editor = Some(SshHostEditor { - original_destination: Some(destination.clone()), - error: None, - destination_selection: Default::default(), - password_selection: Default::default(), - destination, - // Never pull a stored secret back into a text field. Leaving this - // blank preserves the existing credential when the address stays - // unchanged; typing a new value explicitly replaces it. - password: String::new(), - save_password: true, - show_password: false, - auth: profile.auth, - private_keys: profile.private_keys, - field: SshEditorField::Destination, - focus: crate::ux::FocusIndex::default(), - test: Default::default(), - }); - self.nebula_ssh_editor_rects = None; - self.nebula_ssh_editor_open = true; - self.nebula_ssh_editor_hover = SshEditorHit::None; - self.nebula_ui_anims.ssh_editor = UiAnim::new(0.0); - self.pending_update.dirty = true; - self.window.request_redraw(); - } - - pub fn ssh_editor_active(&self) -> bool { - self.nebula_ssh_editor_open && self.nebula_ssh_editor.is_some() - } - - pub fn close_ssh_editor(&mut self) { - if self.nebula_ssh_editor.is_some() { - self.nebula_ssh_editor_open = false; - self.nebula_ssh_editor_hover = SshEditorHit::None; - self.pending_update.dirty = true; - self.window.request_redraw(); - } - } - - pub fn ssh_editor_hit(&self, x: f32, y: f32) -> SshEditorHit { - let Some(rects) = self.nebula_ssh_editor_rects.as_ref() else { - return SshEditorHit::None; - }; - let hit = |r: (f32, f32, f32, f32)| x >= r.0 && x < r.0 + r.2 && y >= r.1 && y < r.1 + r.3; - if hit(rects.close) { - SshEditorHit::Close - } else if hit(rects.destination) { - SshEditorHit::Destination - } else if let Some((mode, _)) = rects.auth.iter().find(|(_, rect)| hit(*rect)) { - SshEditorHit::Auth(*mode) - } else if hit(rects.add_private_key) { - SshEditorHit::AddPrivateKey - } else if let Some((index, _)) = rects.private_key_rows.iter().find(|(_, rect)| hit(*rect)) - { - SshEditorHit::RemovePrivateKey(*index) - } else if hit(rects.password_toggle) { - SshEditorHit::PasswordToggle - } else if hit(rects.password) { - SshEditorHit::Password - } else if hit(rects.save_checkbox) { - SshEditorHit::SaveToggleBox - } else if hit(rects.save_toggle) { - SshEditorHit::SaveToggleLabel - } else if hit(rects.test) { - SshEditorHit::Test - } else if hit(rects.test_status) { - SshEditorHit::TestStatus - } else if hit(rects.cancel) { - SshEditorHit::Cancel - } else if hit(rects.primary) { - SshEditorHit::Primary - } else { - SshEditorHit::None - } - } - - pub fn set_ssh_editor_hover(&mut self, hover: SshEditorHit) { - if self.nebula_ssh_editor_hover != hover { - self.nebula_ssh_editor_hover = hover; - self.pending_update.dirty = true; - } - } - - pub fn ssh_editor_insert(&mut self, text: &str) { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - editor.error = None; - match editor.field { - SshEditorField::Destination => { - editor.destination_selection.insert(&mut editor.destination, text) - }, - SshEditorField::Password => { - editor.password_selection.insert(&mut editor.password, text) - }, - } - editor.test = Default::default(); - } - } - pub fn ssh_editor_backspace(&mut self) { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - let before = match editor.field { - SshEditorField::Destination => editor.destination.len(), - SshEditorField::Password => editor.password.len(), - }; - match editor.field { - SshEditorField::Destination => { - editor.destination_selection.backspace(&mut editor.destination) - }, - SshEditorField::Password => { - editor.password_selection.backspace(&mut editor.password) - }, - } - let after = match editor.field { - SshEditorField::Destination => editor.destination.len(), - SshEditorField::Password => editor.password.len(), - }; - if before != after { - editor.test = Default::default(); - } - } - } - - pub fn ssh_editor_select_all(&mut self) { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - match editor.field { - SshEditorField::Destination => { - editor.destination_selection.select(&editor.destination) - }, - SshEditorField::Password => editor.password_selection.select(&editor.password), - } - } - } - - /// Copying an invisible password would persist a secret in the system - /// clipboard without visible intent, so it is enabled only after Reveal. - pub fn ssh_editor_selected_text(&self) -> Option { - let editor = self.nebula_ssh_editor.as_ref()?; - match editor.field { - SshEditorField::Destination => { - editor.destination_selection.selected_text(&editor.destination) - }, - SshEditorField::Password if editor.show_password => { - editor.password_selection.selected_text(&editor.password) - }, - SshEditorField::Password => None, - } - } - - pub fn ssh_editor_next_field(&mut self, reverse: bool) { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - editor.destination_selection.clear(); - editor.password_selection.clear(); - let shows_password = ssh_ui::auth_sections(editor.auth).0; - // Destination, optional password, test, cancel, save. - let count = if shows_password { 5 } else { 4 }; - editor.focus.advance(count, reverse); - editor.field = match (shows_password, editor.focus.current()) { - (_, 0) => SshEditorField::Destination, - (true, 1) => SshEditorField::Password, - _ => editor.field, - }; - } - } - - pub fn ssh_editor_activate_focus(&mut self) { - let Some(editor) = self.nebula_ssh_editor.as_ref() else { return }; - let shows_password = ssh_ui::auth_sections(editor.auth).0; - match (shows_password, editor.focus.current()) { - (true, 2) | (false, 1) => self.queue_ssh_test(), - (true, 3) | (false, 2) => self.close_ssh_editor(), - (true, 4) | (false, 3) => self.save_ssh_editor(), - _ => {}, - } - } - - pub fn ssh_editor_toggle_save(&mut self) { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - editor.save_password = !editor.save_password; - editor.test = Default::default(); - } - } - - /// input 层在处理完编辑器点击后调用:取走「测试连接」暂存的请求。 - pub fn take_ssh_test_request(&mut self) -> Option { - self.nebula_ssh_test_request.take() - } - - /// Validate the current draft and stage an exact, numbered test request. - /// The input layer owns the event proxy and takes this request immediately - /// after mouse/keyboard activation. - fn queue_ssh_test(&mut self) { - let Some(editor) = self.nebula_ssh_editor.as_mut() else { return }; - let destination = editor.destination.trim().to_owned(); - let valid = !destination.is_empty() - && !destination - .chars() - .any(|c| c.is_whitespace() || c.is_control() || ";&|<>\"'`".contains(c)); - if !valid { - editor.error = Some(if destination.is_empty() { - "请输入 SSH 地址,例如 user@example.com".to_owned() - } else { - "地址不能包含空白、控制字符或 shell 分隔符".to_owned() - }); - editor.test = Default::default(); - return; - } - if matches!(editor.test, ssh_ui::SshTestState::Running { .. }) { - return; - } - - let shows_password = ssh_ui::auth_sections(editor.auth).0; - editor.focus.set(if shows_password { 2 } else { 1 }, if shows_password { 5 } else { 4 }); - self.nebula_ssh_test_seq = self.nebula_ssh_test_seq.wrapping_add(1).max(1); - let request_id = self.nebula_ssh_test_seq; - editor.error = None; - editor.test = ssh_ui::SshTestState::Running { request_id }; - self.nebula_ssh_test_request = Some(crate::ssh_session::SshTestRequest { - request_id, - destination, - auth: editor.auth, - private_keys: editor.private_keys.clone(), - password: (!editor.password.is_empty()).then(|| editor.password.clone()), - }); - } - - /// 后台测试完成回报。结果只属于发起时的草稿:地址已改或状态已非 - /// Running(用户改过字段被清位)时直接丢弃,不让旧结果背书新配置。 - pub fn ssh_test_done( - &mut self, - request_id: u64, - destination: &str, - ok: bool, - message: &str, - elapsed_ms: u64, - ) { - let Some(editor) = self.nebula_ssh_editor.as_mut() else { return }; - if editor.destination.trim() != destination - || editor.test != (ssh_ui::SshTestState::Running { request_id }) - { - return; - } - editor.test = if ok { - ssh_ui::SshTestState::Ok { elapsed_ms } - } else { - ssh_ui::SshTestState::Failed { summary: message.to_owned() } - }; - self.pending_update.dirty = true; - self.window.request_redraw(); - } - - pub fn ssh_editor_click(&mut self, x: f32, y: f32) -> bool { - match self.ssh_editor_hit(x, y) { - SshEditorHit::Destination => { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - let count = if ssh_ui::auth_sections(editor.auth).0 { 5 } else { 4 }; - editor.focus.set(0, count); - editor.destination_selection.clear(); - editor.password_selection.clear(); - editor.field = SshEditorField::Destination; - } - }, - SshEditorHit::PasswordToggle => { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - editor.show_password = !editor.show_password; - } - }, - SshEditorHit::Password => { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - editor.focus.set(1, 5); - editor.destination_selection.clear(); - editor.password_selection.clear(); - editor.field = SshEditorField::Password; - } - }, - SshEditorHit::Auth(mode) => { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - editor.auth = mode; - editor.error = None; - editor.test = Default::default(); - if !ssh_ui::auth_sections(mode).0 { - editor.field = SshEditorField::Destination; - } - } - }, - SshEditorHit::AddPrivateKey => { - if let Some(result) = file_dialog::pick_private_key_file(&self.window) { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - match result { - Ok(path) => { - ssh_ui::push_private_key(&mut editor.private_keys, path); - editor.error = None; - editor.test = Default::default(); - }, - Err(err) => editor.error = Some(err), - } - } - } - }, - SshEditorHit::RemovePrivateKey(index) => { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - if index < editor.private_keys.len() { - editor.private_keys.remove(index); - editor.test = Default::default(); - } - } - }, - SshEditorHit::SaveToggleBox | SshEditorHit::SaveToggleLabel => { - self.ssh_editor_toggle_save(); - }, - SshEditorHit::Test => self.queue_ssh_test(), - SshEditorHit::TestStatus => {}, - SshEditorHit::Close => { - self.close_ssh_editor(); - }, - SshEditorHit::Cancel => { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - let shows_password = ssh_ui::auth_sections(editor.auth).0; - editor.focus.set( - if shows_password { 3 } else { 2 }, - if shows_password { 5 } else { 4 }, - ); - } - self.close_ssh_editor(); - }, - SshEditorHit::Primary => { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - let shows_password = ssh_ui::auth_sections(editor.auth).0; - editor.focus.set( - if shows_password { 4 } else { 3 }, - if shows_password { 5 } else { 4 }, - ); - } - self.save_ssh_editor(); - }, - SshEditorHit::None => return false, - } - self.pending_update.dirty = true; - self.window.request_redraw(); - true - } - - pub fn save_ssh_editor(&mut self) { - let Some(mut editor) = self.nebula_ssh_editor.take() else { return }; - let destination = editor.destination.trim().to_owned(); - let valid = !destination.is_empty() - && !destination - .chars() - .any(|c| c.is_whitespace() || c.is_control() || ";&|<>\"'`".contains(c)); - if !valid { - editor.error = Some(if destination.is_empty() { - "请输入 SSH 地址,例如 user@example.com".to_owned() - } else { - "地址不能包含空白、控制字符或 shell 分隔符".to_owned() - }); - editor.field = SshEditorField::Destination; - self.nebula_ssh_editor = Some(editor); - self.pending_update.dirty = true; - self.window.request_redraw(); - return; - } - if let Some(original) = editor.original_destination.as_deref() { - if original != destination { - self.nebula_saved_hosts.retain(|host| host != original); - self.nebula_pinned_hosts.retain(|host| host != original); - if !self.nebula_hidden_hosts.iter().any(|host| host == original) { - self.nebula_hidden_hosts.push(original.to_owned()); - } - #[cfg(windows)] - { - let _ = crate::ssh_credentials::forget_password(original); - } - } - } - // Saving/editing is an explicit request to surface this destination, - // so it also undoes a previous Delete of the same address. - self.nebula_hidden_hosts.retain(|host| host != &destination); - self.nebula_saved_hosts.retain(|host| host != &destination); - self.nebula_saved_hosts.insert(0, destination.clone()); - self.nebula_saved_hosts.truncate(20); - self.nebula_ssh_hosts = merge_ssh_hosts( - &self.nebula_saved_hosts, - &self.nebula_pinned_hosts, - &self.nebula_hidden_hosts, - ); - let profile_path = nebula_data_dir().join("ssh_profiles.json"); - let mut profiles = - crate::ssh_profiles::SshProfiles::load(&profile_path).unwrap_or_else(|err| { - warn!("加载 SSH Profile 失败,将创建新文件: {err}"); - crate::ssh_profiles::SshProfiles::default() - }); - if let Some(original) = editor.original_destination.as_deref() { - if original != destination { - profiles.rename(original, &destination); - } - } - profiles.upsert(crate::ssh_profiles::SshProfileAuth { - destination: destination.clone(), - auth: editor.auth, - private_keys: editor.private_keys.clone(), - }); - if let Err(err) = profiles.save(&profile_path) { - editor.error = Some(format!("保存 SSH Profile 失败: {err}")); - self.nebula_ssh_editor = Some(editor); - self.pending_update.dirty = true; - self.window.request_redraw(); - return; - } - if ssh_ui::auth_sections(editor.auth).0 - && editor.save_password - && !editor.password.is_empty() - { - #[cfg(windows)] - { - let _ = crate::ssh_credentials::store_password( - &destination, - editor.password.as_bytes(), - ); - } - } - // 凭据落盘后立即清除内存中的明文,但保留其余内容完成短退出动画。 - editor.password.clear(); - self.persist_nebula_settings(); - self.nebula_ssh_editor = Some(editor); - self.close_ssh_editor(); - } - /// Ask before removing a saved destination. Config aliases use different /// wording because Delete hides them inside Nebula and never edits /// `~/.ssh/config` itself. @@ -6655,6 +6217,170 @@ impl Display { /// Overlay a transient, fading "cols × rows" HUD centered in the window, /// shown briefly after a resize (a resize overlay HUD). Keeps requesting /// redraws until it fades out, then clears itself. + /// 每帧同步聚焦 pane 的身份。连接卡片据此决定画在哪个 pane 里—— + /// `nebula_pane_view` 只给几何,不给身份。 + pub fn set_focused_pane(&mut self, pane: u64) { + self.nebula_focused_pane = pane; + } + + /// 后台 SSH runtime 上报的连接阶段。 + /// + /// `Ready` 直接移除状态:卡片退场,持续重绘随之停止,不会留下一个连完 + /// 还在后台跑粒子的 tab。 + pub fn ssh_connect_stage( + &mut self, + pane: u64, + destination: String, + stage: crate::ssh_session::SshStage, + ) { + if matches!(stage, crate::ssh_session::SshStage::Ready) { + self.nebula_ssh_connect.remove(&pane); + return; + } + match self.nebula_ssh_connect.entry(pane) { + std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut().set_stage(stage), + std::collections::hash_map::Entry::Vacant(entry) => { + // 只有 `Resolve` 能开一张新卡片。`Ready` 时状态已被移除,若 + // 任何后续阶段都能重建,一次会话中途断线就会让连接卡片在一 + // 个用了半天的终端上凭空复活。 + if matches!(stage, crate::ssh_session::SshStage::Resolve) { + entry.insert(ssh_connect::SshConnectState::new(destination)); + } + }, + } + } + + /// pane 关闭时丢弃它的连接状态。 + pub fn forget_ssh_connect(&mut self, pane: u64) { + self.nebula_ssh_connect.remove(&pane); + } + + /// 卡片当前占据的矩形 = 聚焦 pane 的内容区。绘制与命中共用它,两者不会 + /// 漂移。 + fn ssh_connect_rect(&self) -> (f32, f32, f32, f32) { + let view = self.pane_view(); + ( + view.padding_x(), + view.padding_y(), + view.width() - view.padding_x() - view.padding_right(), + view.height() - view.padding_y() - view.padding_bottom(), + ) + } + + /// 聚焦 pane 是否正被连接卡片接管(遮罩已经在画了)。 + pub fn ssh_connect_active(&self) -> bool { + self.nebula_ssh_connect + .get(&self.nebula_focused_pane) + .is_some_and(|state| state.visible()) + } + + /// 遮罩盖住整个 pane,所以卡片在场时 pane 内的一切点击都归卡片,不能 + /// 漏进终端去起拖选——侧栏拖拽残影那个 bug 的同类。 + pub fn ssh_connect_covers(&self, x: f32, y: f32) -> bool { + self.ssh_connect_active() && ssh_connect::covers(self.ssh_connect_rect(), x, y) + } + + pub fn ssh_connect_hit(&self, x: f32, y: f32) -> ssh_connect::SshConnectHit { + let Some(state) = self.nebula_ssh_connect.get(&self.nebula_focused_pane) else { + return ssh_connect::SshConnectHit::None; + }; + if !state.visible() { + return ssh_connect::SshConnectHit::None; + } + ssh_connect::hit_test( + state, + &self.ui_size_info(), + self.ssh_connect_rect(), + self.window.scale_factor as f32, + self.nebula_language, + x, + y, + ) + } + + /// 更新悬停并返回是否需要重绘。 + pub fn ssh_connect_set_hover(&mut self, hit: ssh_connect::SshConnectHit) -> bool { + let pane = self.nebula_focused_pane; + self.nebula_ssh_connect.get_mut(&pane).is_some_and(|state| state.set_hover(hit)) + } + + /// Logs 折叠是纯显示状态,就地处理;其余动作要关 pane 或重连,交给 + /// `window_context`。 + pub fn ssh_connect_toggle_logs(&mut self) { + let pane = self.nebula_focused_pane; + if let Some(state) = self.nebula_ssh_connect.get_mut(&pane) { + state.toggle_logs(); + } + } + + /// 聚焦 pane 的连接目标,供"重试"重新发起同一个连接。 + pub fn ssh_connect_destination(&self) -> Option { + self.nebula_ssh_connect + .get(&self.nebula_focused_pane) + .map(|state| state.destination().to_owned()) + } + + /// SSH 连接卡片:星云轨道 + 粒子流,画在聚焦 pane 内的浮层。 + fn draw_ssh_connect(&mut self) { + let pane = self.nebula_focused_pane; + if !self.nebula_ssh_connect.contains_key(&pane) { + return; + } + let delta = self.nebula_ui_anims.frame().delta; + // 借用分离:绘制要同时摸 renderer 与 glyph_cache,先把状态摘出来。 + let mut states = std::mem::take(&mut self.nebula_ssh_connect); + if let Some(state) = states.get_mut(&pane) { + state.step(delta); + if state.visible() { + let size = self.ui_size_info(); + let scale = self.window.scale_factor as f32; + let view = self.pane_view(); + // pane 的内容矩形:padding 编码了 pane 在窗口里的位置, + // 分屏时左右两半的 padding 是非对称的。 + let rect = ( + view.padding_x(), + view.padding_y(), + view.width() - view.padding_x() - view.padding_right(), + view.height() - view.padding_y() - view.padding_bottom(), + ); + let mut quads = Vec::new(); + // 遮罩用 pane 的真实底色,这样卡片浮在一块与终端同色的板上, + // 而不是凭空多出一层灰。 + let bg = self.nebula_background.unwrap_or(self.colors[NamedColor::Background]); + let backdrop = crate::renderer::ui::Rgba::opaque(bg); + let language = self.nebula_language; + ssh_connect::push_quads( + state, + &self.nebula_theme, + &mut quads, + &size, + rect, + scale, + language, + backdrop, + ); + self.renderer.draw_ui(&size, &quads); + let glyph_cache = &mut self.glyph_cache; + ssh_connect::draw_text( + state, + &self.nebula_theme, + language, + &mut self.renderer, + glyph_cache, + &size, + rect, + scale, + ); + } + // 门槛期内也要保持帧循环,否则永远到不了该显示的那一帧。 + // 失败态不再有动画,交给事件驱动即可。 + if !state.failed() { + self.window.request_redraw(); + } + } + self.nebula_ssh_connect = states; + } + fn draw_resize_hud(&mut self) { let Some(mut hud) = self.nebula_resize_hud else { return }; hud.opacity.step(self.nebula_ui_anims.frame()); @@ -6752,6 +6478,9 @@ impl Display { } } + // SSH 连接卡片:在 chrome 之上,resize HUD 之下。 + self.draw_ssh_connect(); + // Transient resize HUD painted on top of the chrome. self.draw_resize_hud(); context_menu::draw(self); diff --git a/nebula_app/src/display/ssh_connect.rs b/nebula_app/src/display/ssh_connect.rs new file mode 100644 index 0000000..5127260 --- /dev/null +++ b/nebula_app/src/display/ssh_connect.rs @@ -0,0 +1,1206 @@ +//! SSH 连接卡片:星云轨道 + 粒子流。 +//! +//! 画在 pane 内的浮层,**不整页接管**——整页会盖掉 ssh 自己的输出,而 +//! host key 警告、banner、`Permission denied` 恰恰是失败时最值钱的信息。 +//! +//! 动画承载信息:已完成段的填充宽度就是进度,粒子只在当前段流动。这是它 +//! 与"转圈"的根本区别——一眼能看出卡在解析、连接、认证还是开 shell。 +//! +//! 有 [`REVEAL_DELAY`] 的延迟门槛:连接比它更快时卡片从不出现。局域网 SSH +//! 常在 200ms 内完成,闪一下的加载页比直接出 prompt 更难受;连接池命中时 +//! [`crate::ssh_session::authenticated_session`] 更是瞬时返回。 + +use std::time::{Duration, Instant}; + +use unicode_width::UnicodeWidthChar; + +use super::color::Rgb; +use super::i18n::UiLanguage; +use super::ui::icons; +use super::ui::tokens::{radius, space, type_scale}; +use super::{NebulaTheme, SizeInfo}; +use crate::renderer::ui::{Gradient, Rgba, UiQuad}; +use crate::renderer::{GlyphCache, Renderer}; +use crate::ssh_session::SshStage; +use nebula_terminal::term::cell::Flags; + +/// 低于这个耗时的连接完全不显示卡片。 +const REVEAL_DELAY: Duration = Duration::from_millis(350); + +/// 粒子绕完当前段一轮的周期。 +const PARTICLE_PERIOD: f32 = 1.100; +/// 主粒子数量。 +const PARTICLE_COUNT: usize = 5; +/// 每颗粒子回溯的拖尾节数。 +const TRAIL_LEN: usize = 5; +/// 拖尾相邻两点的相位差。 +const TRAIL_GAP: f32 = 0.032; +/// 主粒子的相位。**刻意不等距**:等距排列读起来像传送带,不等距才像流体 +/// 的团簇。 +const PARTICLE_PHASE: [f32; PARTICLE_COUNT] = [0.0, 0.17, 0.31, 0.52, 0.63]; + +/// 卡片宽度(逻辑像素)。 +const CARD_W: f32 = 600.0; +/// 轨道节点直径。 +const NODE_D: f32 = 10.0; +/// 按钮高度。 +const BTN_H: f32 = 30.0; +/// 日志展开区显示的行数。 +const LOG_ROWS: usize = 6; + +/// 卡片上可点的部位。绘制与命中共用同一份 [`Layout`],所以图标位置和它的 +/// 命中区不可能漂移——这是 `icons.rs` 那条分层约定的延续。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum SshConnectHit { + #[default] + None, + /// 右上角的 Logs 折叠开关。 + Logs, + /// 连接中:放弃这次连接。 + Cancel, + /// 失败后:关掉这个 pane。 + Close, +} + +impl SshConnectHit { + pub(crate) fn is_none(self) -> bool { + matches!(self, SshConnectHit::None) + } +} + +/// 一个 pane 的连接进度。 +#[derive(Debug, Clone)] +pub(crate) struct SshConnectState { + /// 用户看到的目标,原样取自 tab 的启动身份。 + destination: String, + stage: SshStage, + started: Instant, + /// 粒子相位(0..1),按帧 delta 推进——与侧栏 spinner 同款做法,不读挂钟。 + phase: f32, + /// 填充条的当前进度(0..3 的连续值)。向 `stage_index` 插值,避免阶段 + /// 推进时轨道瞬移。 + fill: f32, + /// 失败原因;`Some` 时卡片停止动画并转为可读的错误态。 + failure: Option, + /// 当前悬停的部位。 + hover: SshConnectHit, + /// Logs 区是否展开。 + logs_open: bool, + /// 连接日志。**不伪造 `ssh -v` 的输出**——我们用 russh 自己实现客户端, + /// 每一行都对应一个真实发生过的调用,写成 OpenSSH 的样子只会在排障时 + /// 骗人。 + log: Vec, +} + +impl SshConnectState { + pub(crate) fn new(destination: String) -> Self { + let mut state = Self { + destination: destination.clone(), + stage: SshStage::Resolve, + started: Instant::now(), + phase: 0.0, + fill: 0.0, + failure: None, + hover: SshConnectHit::None, + logs_open: false, + log: Vec::new(), + }; + state.push_log(format!("resolve destination = {destination}")); + state + } + + fn push_log(&mut self, line: String) { + let at = self.started.elapsed().as_secs_f32(); + self.log.push(format!("{at:>6.2}s {line}")); + } + + /// 阶段推进。`Ready` 由调用方处理成"移除状态",不会存进来。 + pub(crate) fn set_stage(&mut self, stage: SshStage) { + // 同一阶段重复上报时不再记一行,否则日志会被刷屏。 + if self.stage != stage { + match &stage { + SshStage::Resolve => {}, + SshStage::Connect => self.push_log("connect tcp handshake + kex".to_owned()), + SshStage::Authenticate => self.push_log("auth authenticating".to_owned()), + SshStage::OpenShell => { + self.push_log("shell channel + pty + shell".to_owned()) + }, + SshStage::Ready => self.push_log("ready session established".to_owned()), + SshStage::Failed(message) => self.push_log(format!("error {message}")), + } + } + if let SshStage::Failed(message) = &stage { + self.failure = Some(message.clone()); + // 失败的原因是最该被看见的东西,直接把 Logs 展开,不让用户再点 + // 一次才发现真正的线索。 + self.logs_open = true; + } + self.stage = stage; + } + + /// 更新悬停部位;返回是否需要重绘。 + pub(crate) fn set_hover(&mut self, hit: SshConnectHit) -> bool { + let changed = self.hover != hit; + self.hover = hit; + changed + } + + pub(crate) fn toggle_logs(&mut self) { + self.logs_open = !self.logs_open; + } + + pub(crate) fn destination(&self) -> &str { + &self.destination + } + + pub(crate) fn failed(&self) -> bool { + self.failure.is_some() + } + + /// 卡片是否该出现。失败态无视门槛立刻显示——用户已经在等结果了。 + pub(crate) fn visible(&self) -> bool { + self.failed() || self.started.elapsed() >= REVEAL_DELAY + } + + /// 当前阶段在四格轨道上的下标。 + fn stage_index(&self) -> usize { + match self.stage { + SshStage::Resolve => 0, + SshStage::Connect => 1, + SshStage::Authenticate => 2, + SshStage::OpenShell | SshStage::Ready => 3, + // 失败停在它失败的那一格;解析阶段就失败的情况归到第 0 格。 + SshStage::Failed(_) => self.fill.round().clamp(0.0, 3.0) as usize, + } + } + + /// 推进动画。`delta` 来自共享的 motion frame,所以慢帧不会让粒子跳步。 + pub(crate) fn step(&mut self, delta: Duration) { + if self.failed() { + return; + } + let dt = delta.as_secs_f32(); + self.phase = (self.phase + dt / PARTICLE_PERIOD).fract(); + // 填充向目标阶段插值:指数趋近,320ms 量级的观感与原型一致。 + let target = self.stage_index() as f32; + let k = 1.0 - (-dt * 8.0).exp(); + self.fill += (target - self.fill) * k; + } + + fn elapsed_text(&self) -> String { + format!("{:.1}s", self.started.elapsed().as_secs_f32()) + } +} + +/// 卡片的像素布局。所有矩形都在窗口像素坐标系里,与文字共用同一套坐标 —— +/// resize HUD 那次事故(框按窗口居中、文字按终端网格居中)的教训。 +struct Layout { + card: (f32, f32, f32, f32), + /// 主机图标方块。 + icon: (f32, f32, f32, f32), + /// 身份文字的左边缘与两行基线。 + name_x: f32, + name_y: f32, + meta_y: f32, + /// 右上角 Logs 折叠开关。 + logs_btn: (f32, f32, f32, f32), + /// 轨道:左端节点中心、右端节点中心、纵向中心。 + rail_x0: f32, + rail_x1: f32, + rail_cy: f32, + /// 阶段标签基线。 + labels_y: f32, + /// 状态行基线。 + status_y: f32, + /// 失败详情基线(失败态才用)。 + detail_y: f32, + /// 日志区外框(展开时才有)。 + logs_area: Option<(f32, f32, f32, f32)>, + /// 底部按钮:矩形、语义、是否主按钮。绘制与命中同源。 + buttons: Vec<((f32, f32, f32, f32), SshConnectHit, bool)>, + pad: f32, +} + +/// 底部按钮的文案与语义,随状态切换:连接中只能放弃,失败后只剩收尾。 +/// +/// 这里**没有**「重试」——它要在同一个 tab 位上原子地重连,而 `Close` + +/// `NewSsh` 两个事件按序执行会先把最后一个 tab 连窗口一起关掉。宁可少一个 +/// 按钮,也不放一个按下去会出事的。 +fn button_specs(state: &SshConnectState, language: UiLanguage) -> Vec<(String, SshConnectHit, bool)> { + if state.failed() { + vec![(language.pick("关闭", "Close").to_owned(), SshConnectHit::Close, true)] + } else { + vec![(language.pick("取消", "Cancel").to_owned(), SshConnectHit::Cancel, false)] + } +} + +fn layout( + state: &SshConnectState, + size: &SizeInfo, + view: (f32, f32, f32, f32), + scale: f32, + language: UiLanguage, +) -> Layout { + let s = |v: f32| v * scale; + let pad = s(space::L); + let ch = size.cell_height(); + let cell_w = size.cell_width(); + let card_w = s(CARD_W).min(view.2 - s(space::L) * 2.0); + + // 内容从上往下堆,卡片高度由内容决定(内容自适应,不留空洞)。 + let icon_h = s(40.0); + let mut h = pad; + let icon_top = h; + h += icon_h + s(space::L); + let rail_top = h; + h += s(NODE_D) + s(space::XS) + ch * type_scale::SECTION_CAPTION + s(space::L); + let status_top = h; + h += ch; + + let detail_top = if state.failed() { + let top = h + s(space::XS); + h = top + ch * type_scale::SUPPORTING * 2.0; + top + } else { + 0.0 + }; + + let logs_h = if state.logs_open { + // 高度跟着真实行数走,最多 LOG_ROWS 行。固定高度会在只有三行日志时 + // 留下一大块空框,读起来像"还有内容没加载出来"。 + let rows = state.log.len().clamp(1, LOG_ROWS); + let inner = ch * type_scale::SUPPORTING * rows as f32 + s(space::S) * 2.0; + h += s(space::M) + inner; + Some(inner) + } else { + None + }; + + h += s(space::L); + let btn_top = h; + h += s(BTN_H) + pad; + + let card_x = (view.0 + (view.2 - card_w) * 0.5).round(); + let card_y = (view.1 + (view.3 - h) * 0.5).round(); + + let icon_x = card_x + pad; + let text_x = icon_x + icon_h + s(space::M); + // 两行身份文字在图标高度内垂直居中。 + let name_line = ch * type_scale::BODY; + let meta_line = ch * type_scale::SUPPORTING; + let text_block = name_line + s(2.0) + meta_line; + let text_top = card_y + icon_top + (icon_h - text_block) * 0.5; + + // Logs 按钮贴卡片右内缘,与图标同一条水平中线。 + let logs_label = language.pick("Logs", "Logs"); + let logs_w = text_w(logs_label, cell_w, type_scale::SUPPORTING) + s(space::M) * 2.0 + s(10.0); + let logs_btn = ( + (card_x + card_w - pad - logs_w).round(), + (card_y + icon_top + (icon_h - s(BTN_H)) * 0.5).round(), + logs_w.round(), + s(BTN_H), + ); + + // 底部按钮从右往左排,主按钮在左——与原型一致(提交 / 取消)。 + let mut buttons = Vec::new(); + let mut right = card_x + card_w - pad; + for (label, hit, primary) in button_specs(state, language).into_iter().rev() { + let w = (text_w(&label, cell_w, type_scale::BODY) + s(space::M) * 2.0).max(s(76.0)); + right -= w; + buttons.push(( + ((right).round(), (card_y + btn_top).round(), w.round(), s(BTN_H)), + hit, + primary, + )); + right -= s(space::S); + } + + Layout { + card: (card_x, card_y, card_w, h), + icon: (icon_x, card_y + icon_top, icon_h, icon_h), + name_x: text_x, + name_y: text_top.round(), + meta_y: (text_top + name_line + s(2.0)).round(), + logs_btn, + rail_x0: card_x + pad + s(NODE_D) * 0.5, + rail_x1: card_x + card_w - pad - s(NODE_D) * 0.5, + rail_cy: (card_y + rail_top + s(NODE_D) * 0.5).round(), + labels_y: (card_y + rail_top + s(NODE_D) + s(space::XS)).round(), + status_y: (card_y + status_top).round(), + detail_y: (card_y + detail_top).round(), + logs_area: logs_h.map(|inner| { + let top = card_y + btn_top - s(space::L) - inner; + ((card_x + pad).round(), top.round(), (card_w - pad * 2.0).round(), inner) + }), + buttons, + pad, + } +} + +fn contains(rect: (f32, f32, f32, f32), x: f32, y: f32) -> bool { + x >= rect.0 && x < rect.0 + rect.2 && y >= rect.1 && y < rect.1 + rect.3 +} + +/// 命中测试。卡片是模态浮层:**落在卡片内但不在任何控件上,也要吞掉事件**, +/// 否则点击会漏进下面的终端去拖选区——侧栏拖拽残影那个 bug 就是这么来的。 +pub(super) fn hit_test( + state: &SshConnectState, + size: &SizeInfo, + view: (f32, f32, f32, f32), + scale: f32, + language: UiLanguage, + x: f32, + y: f32, +) -> SshConnectHit { + let l = layout(state, size, view, scale, language); + if contains(l.logs_btn, x, y) { + return SshConnectHit::Logs; + } + for (rect, hit, _) in &l.buttons { + if contains(*rect, x, y) { + return *hit; + } + } + SshConnectHit::None +} + +/// 遮罩覆盖整个 pane,所以连接期间任何落在 pane 内的点击都不该穿透到终端。 +pub(super) fn covers(view: (f32, f32, f32, f32), x: f32, y: f32) -> bool { + contains(view, x, y) +} + +/// 节点中心的 x 坐标。 +fn node_x(l: &Layout, index: usize) -> f32 { + l.rail_x0 + (l.rail_x1 - l.rail_x0) * index as f32 / 3.0 +} + +/// 速度场:两端慢、中段快,与线性混合以保留端点的基础流速。 +/// +/// 拖尾位置也过这条曲线,于是快段自动拉长、慢段自动压缩——流体感就来自 +/// 这里,不是靠额外的装饰。 +fn ease(s: f32) -> f32 { + 0.35 * s + 0.65 * (s * s * (3.0 - 2.0 * s)) +} + +fn lerp_rgb(a: Rgb, b: Rgb, k: f32) -> Rgb { + let k = k.clamp(0.0, 1.0); + let mix = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * k).round() as u8; + Rgb::new(mix(a.r, b.r), mix(a.g, b.g), mix(a.b, b.b)) +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn push_quads( + state: &SshConnectState, + theme: &NebulaTheme, + quads: &mut Vec, + size: &SizeInfo, + view: (f32, f32, f32, f32), + scale: f32, + language: UiLanguage, + backdrop: Rgba, +) { + let l = layout(state, size, view, scale, language); + let s = |v: f32| v * scale; + let sk = theme.skin(); + let palette = theme.palette(); + let light = sk.is_light; + // 品牌色对:Nebula 主题自带的星云紫→青。浅色主题下压暗才够对比。 + let brand_l = if light { lerp_rgb(rgb_of(palette.edge_l), Rgb::new(0, 0, 0), 0.28) } else { rgb_of(palette.edge_l) }; + let brand_r = if light { lerp_rgb(rgb_of(palette.edge_r), Rgb::new(0, 0, 0), 0.28) } else { rgb_of(palette.edge_r) }; + + // ── 遮罩 ──────────────────────────────────────────────────── + // 连接期间 pane 里没有任何值得看的东西(grid 是空的,只有一个在 blink + // 的光标),却会跟着我们的持续重绘一起闪。用 pane 底色整块盖住:卡片 + // 由此成为真正的第一层,会话就绪后遮罩和卡片一起退场,露出真实终端。 + // ssh 自己的输出不会因此丢失——它们进了 Logs 区。 + quads.push(UiQuad::solid(view.0, view.1, view.2, view.3, 0.0, backdrop).pixel_snapped()); + + let (cx, cy, cw, chh) = l.card; + + // 阴影 + 面板:一个要求用户等待的浮层必须有真实高度。 + let shadow_alpha = if light { 26 } else { 56 }; + quads.push( + UiQuad::shadow( + cx, + cy, + cw, + chh, + s(radius::OVERLAY), + s(20.0), + s(7.0), + Rgba::new(0, 0, 0, shadow_alpha), + ) + .pixel_snapped(), + ); + quads.push(UiQuad::solid(cx, cy, cw, chh, s(radius::OVERLAY), sk.panel).pixel_snapped()); + // hairline 描边:与阴影二选一会显薄,浮层这一层两者都要(阴影给高度, + // 描边给边界),但描边只有 1px 且极淡。 + quads.push( + UiQuad::solid(cx, cy, cw, chh, s(radius::OVERLAY), sk.hairline).pixel_snapped(), + ); + quads.push( + UiQuad::solid( + cx + s(1.0), + cy + s(1.0), + cw - s(2.0), + chh - s(2.0), + s(radius::OVERLAY) - s(1.0), + sk.panel, + ) + .pixel_snapped(), + ); + + // 主机图标底:card 底 + hairline,不用彩色块。Termius 那种高饱和彩色 + // 方块排一屏时眼睛不知道看哪,而且颜色不承载任何可操作信息。 + let (ix, iy, iw, ih) = l.icon; + // 卡片底和图标底都带 alpha,直接拿它们当"挖空色"盖不住下面的墨迹—— + // 这正是 icons.rs 里 blend_over 存在的理由。先算出真实的不透明底色。 + let solid_panel = icons::blend_over(backdrop, sk.panel); + let solid_card = icons::blend_over(solid_panel, sk.card); + quads.push(UiQuad::solid(ix, iy, iw, ih, s(radius::CONTROL), solid_card).pixel_snapped()); + // 服务器机架墨迹:两层机箱 + 各一颗指示灯。字体私用区字形在分数 DPI 下 + // 会漂,所以和 icons.rs 一样走矢量,不借字形。 + { + let icx = ix + iw * 0.5; + let icy = iy + ih * 0.5; + let ink = Rgba::opaque(sk.ink_dim); + let bw = s(18.0); + let bh = s(7.5); + let gap = s(3.0); + let stroke = (s(1.4)).max(1.0); + let r = s(2.0); + for row in [-1.0f32, 1.0] { + let by = icy + row * (bh + gap) * 0.5 - bh * 0.5; + let bx = icx - bw * 0.5; + // 无 stroke 图元:ink 板 + 挖回底色的内芯。 + quads.push(UiQuad::solid(bx, by, bw, bh, r, ink).pixel_snapped()); + quads.push( + UiQuad::solid( + bx + stroke, + by + stroke, + bw - stroke * 2.0, + bh - stroke * 2.0, + (r - stroke).max(0.0), + solid_card, + ) + .pixel_snapped(), + ); + // 指示灯:靠左,与机箱同一条中线。 + let d = s(2.4); + quads.push(UiQuad::solid( + bx + stroke + s(2.2), + by + bh * 0.5 - d * 0.5, + d, + d, + d * 0.5, + ink, + )); + } + } + + // ── 星云轨道 ──────────────────────────────────────────────── + let rail_h = s(2.0); + let rail_y = (l.rail_cy - rail_h * 0.5).round(); + // 底轨:未完成部分。 + quads.push( + UiQuad::solid(l.rail_x0, rail_y, l.rail_x1 - l.rail_x0, rail_h, rail_h * 0.5, sk.hairline) + .pixel_snapped(), + ); + + let fill_to = node_x(&l, 0) + (l.rail_x1 - l.rail_x0) * (state.fill / 3.0); + let fill_w = (fill_to - l.rail_x0).max(0.0); + if fill_w > 0.5 { + // 已完成段:品牌紫→青,宽度即进度。这是全屏唯一一处渐变。 + let end = lerp_rgb(brand_l, brand_r, state.fill / 3.0); + if state.failed() { + quads.push( + UiQuad::solid(l.rail_x0, rail_y, fill_w, rail_h, rail_h * 0.5, sk.danger) + .pixel_snapped(), + ); + } else { + // 深色下给填充段极淡外溢;浅色主题 glow 一律关闭(theme.rs 把 + // 浅色 glow alpha 硬编码为 0:低 alpha 径向渐变在浅底上只落在 + // 极少数 8-bit 台阶,量化轮廓会读成模糊灰线)。 + if !light { + // brand-glow: 进度轨道的能量外溢是星云品牌位本身的画面。 + quads.push(UiQuad::glow( + l.rail_x0, + rail_y - s(3.0), + fill_w, + rail_h + s(6.0), + Rgba::opaque(end).with_alpha(0.20), + )); + } + quads.push( + UiQuad::gradient( + l.rail_x0, + rail_y, + fill_w, + rail_h, + rail_h * 0.5, + Rgba::opaque(brand_l), + Rgba::opaque(end), + Gradient::Horizontal, + ) + .pixel_snapped(), + ); + } + } + + // 粒子:只在当前段流动。失败后不画(动画停止)。 + if !state.failed() { + let seg = state.stage_index().min(2); + let from = node_x(&l, seg); + let to = node_x(&l, seg + 1); + let span = l.rail_x1 - l.rail_x0; + for i in 0..PARTICLE_COUNT { + let head = (state.phase + PARTICLE_PHASE[i]).fract(); + // 从尾画到头,头压在最上。 + for k in (0..=TRAIL_LEN).rev() { + let t = head - k as f32 * TRAIL_GAP; + if t < 0.0 || t > 1.0 { + continue; + } + let x = from + (to - from) * ease(t); + let fade = 1.0 - k as f32 / (TRAIL_LEN as f32 + 1.2); + // 进出两端淡入淡出,避免粒子在节点上凭空出现或撞停。 + let edge = (t / 0.14).min((1.0 - t) / 0.14).min(1.0); + let alpha = fade * fade * edge * if light { 0.55 } else { 0.9 }; + if alpha < 0.012 { + continue; + } + // 颜色按整条轨道的位置从品牌紫渐变到品牌青。 + let color = lerp_rgb(brand_l, brand_r, (x - l.rail_x0) / span.max(1.0)); + if !light { + let rad = if k == 0 { s(5.4) } else { s(4.4) * fade + s(1.2) }; + // brand-glow: 拖尾粒子就是发光体,不是在垫层级。 + quads.push(UiQuad::glow( + x - rad, + l.rail_cy - rad, + rad * 2.0, + rad * 2.0, + Rgba::opaque(color).with_alpha(alpha * 0.5), + )); + } + let cr = if k == 0 { s(1.55) } else { s(1.3) * fade + s(0.2) }; + quads.push(UiQuad::solid( + x - cr, + l.rail_cy - cr, + cr * 2.0, + cr * 2.0, + cr, + Rgba::opaque(color).with_alpha(alpha * if k == 0 { 1.0 } else { 0.78 }), + )); + } + } + } + + // 节点:三态。空心(未到)→ accent 实心带光环(正在做)→ 品牌青实心。 + let done_upto = state.fill.floor() as usize; + let active = state.stage_index(); + for i in 0..4 { + let d = s(NODE_D); + let x = node_x(&l, i) - d * 0.5; + let y = l.rail_cy - d * 0.5; + let failed_here = state.failed() && i == active; + if failed_here { + quads.push(UiQuad::solid(x, y, d, d, d * 0.5, sk.danger).pixel_snapped()); + } else if i <= done_upto && i < active { + quads.push(UiQuad::solid(x, y, d, d, d * 0.5, Rgba::opaque(brand_r)).pixel_snapped()); + } else if i == active { + if !light { + // brand-glow: 当前阶段节点的脉冲,和轨道粒子是同一套语言。 + quads.push(UiQuad::glow( + x - s(3.0), + y - s(3.0), + d + s(6.0), + d + s(6.0), + Rgba::opaque(sk.accent).with_alpha(0.45), + )); + } + quads.push(UiQuad::solid(x, y, d, d, d * 0.5, Rgba::opaque(sk.accent)).pixel_snapped()); + } else { + // 空心:外圈 hairline + 内芯挖回面板色。 + quads.push(UiQuad::solid(x, y, d, d, d * 0.5, sk.hairline).pixel_snapped()); + let inset = s(1.5); + quads.push( + UiQuad::solid( + x + inset, + y + inset, + d - inset * 2.0, + d - inset * 2.0, + (d - inset * 2.0) * 0.5, + sk.panel, + ) + .pixel_snapped(), + ); + } + } + + // ── Logs 折叠开关 ─────────────────────────────────────────── + // Tabby 那张连接图唯一真正值钱的东西就是它,所以它必须在失败之前就在 + // 场,而不是失败后才冒出来。 + push_button_frame(quads, l.logs_btn, s(radius::CONTROL), false, state.hover == SshConnectHit::Logs, sk); + { + let (bx, by, bw, bh) = l.logs_btn; + let cxx = bx + bw - s(space::M) - s(3.0); + let cyy = by + bh * 0.5; + let arm = s(3.2); + let stroke = (s(1.3)).max(1.0); + let ink = Rgba::opaque(sk.ink_dim); + // 展开时朝上,收起时朝下——方向指向"点下去会发生什么"。 + let dir = if state.logs_open { -1.0 } else { 1.0 }; + icons::push_segment( + quads, + (cxx - arm, cyy - arm * 0.5 * dir), + (cxx, cyy + arm * 0.5 * dir), + stroke, + ink, + ); + icons::push_segment( + quads, + (cxx, cyy + arm * 0.5 * dir), + (cxx + arm, cyy - arm * 0.5 * dir), + stroke, + ink, + ); + } + + // ── 日志区 ───────────────────────────────────────────────── + if let Some(area) = l.logs_area { + quads.push( + UiQuad::solid(area.0, area.1, area.2, area.3, s(radius::CONTROL), sk.hairline) + .pixel_snapped(), + ); + let inset = s(1.0); + quads.push( + UiQuad::solid( + area.0 + inset, + area.1 + inset, + area.2 - inset * 2.0, + area.3 - inset * 2.0, + s(radius::CONTROL) - inset, + sk.card, + ) + .pixel_snapped(), + ); + } + + // ── 底部按钮 ─────────────────────────────────────────────── + for (rect, hit, primary) in &l.buttons { + push_button_frame(quads, *rect, s(radius::CONTROL), *primary, state.hover == *hit, sk); + } +} + +/// 按钮底:主按钮实心 accent,次按钮 card 底 + hairline 描边。hover 只改 +/// 亮度,不改尺寸——动效纪律里"不加装饰动效"的直接后果。 +fn push_button_frame( + quads: &mut Vec, + rect: (f32, f32, f32, f32), + radius: f32, + primary: bool, + hovered: bool, + sk: crate::display::ui::theme::Skin, +) { + let (x, y, w, h) = rect; + let lift = |c: Rgba| { + if !hovered { + return c; + } + // 深色底往上提亮,浅色底往下压暗,两边都是"更靠近手指"的方向。 + let top = if sk.is_light { + Rgba::new(0, 0, 0, 20) + } else { + Rgba::new(255, 255, 255, 26) + }; + icons::blend_over(c, top) + }; + if primary { + quads.push(UiQuad::solid(x, y, w, h, radius, lift(Rgba::opaque(sk.accent))).pixel_snapped()); + } else { + quads.push(UiQuad::solid(x, y, w, h, radius, sk.hairline).pixel_snapped()); + let inset = (radius * 0.0 + 1.0).max(1.0); + quads.push( + UiQuad::solid( + x + inset, + y + inset, + w - inset * 2.0, + h - inset * 2.0, + (radius - inset).max(0.0), + lift(sk.card), + ) + .pixel_snapped(), + ); + } +} + +pub(super) fn rgb_of(c: Rgba) -> Rgb { + Rgb::new(c.r, c.g, c.b) +} + +/// 文字宽度(像素)。等宽字体下按列宽求和是精确的。 +fn text_w(text: &str, cell_w: f32, scale: f32) -> f32 { + let cols: usize = text.chars().map(|c| c.width().unwrap_or(0)).sum(); + cols as f32 * cell_w * scale +} + +/// 估算宽度与实际渲染宽度之间的安全系数。 +/// +/// UI 文字走 UI 字体,中文字形的实际推进宽度略大于"列数 × cell_w"的估算, +/// 按估算贴边排版会溢出卡片——失败详情第一次就是这么冲出右边界的。 +const TEXT_SAFETY: f32 = 0.94; + +/// 一块宽度能放下多少列。 +pub(super) fn cols_that_fit(width: f32, cell_w: f32, scale: f32) -> usize { + ((width * TEXT_SAFETY / (cell_w * scale)).floor() as usize).max(4) +} + +/// 按列宽截断,超出部分收成省略号。日志行里那条中文系统错误消息不截断就 +/// 会一路画到窗口外面去。 +pub(super) fn truncate_cols(text: &str, max_cols: usize) -> String { + let total: usize = text.chars().map(|c| c.width().unwrap_or(0)).sum(); + if total <= max_cols { + return text.to_owned(); + } + let budget = max_cols.saturating_sub(1); + let mut out = String::new(); + let mut cols = 0usize; + for c in text.chars() { + let w = c.width().unwrap_or(0); + if cols + w > budget { + break; + } + out.push(c); + cols += w; + } + out.push('…'); + out +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn draw_text( + state: &SshConnectState, + theme: &NebulaTheme, + language: UiLanguage, + r: &mut Renderer, + gc: &mut GlyphCache, + size: &SizeInfo, + view: (f32, f32, f32, f32), + scale: f32, +) { + let l = layout(state, size, view, scale, language); + let sk = theme.skin(); + let cell_w = size.cell_width(); + + // 身份:标签用主墨,技术地址降一级——列表和卡片同一套读法。 + let name = short_name(&state.destination); + r.draw_ui_text( + size, + l.name_x, + l.name_y, + type_scale::BODY, + sk.ink_strong, + Flags::empty(), + &name, + gc, + ); + let meta = format!("SSH · {}", state.destination); + r.draw_ui_text( + size, + l.name_x, + l.meta_y, + type_scale::SUPPORTING, + sk.ink_dim, + Flags::empty(), + &meta, + gc, + ); + + // 阶段标签:已完成 ink_dim、当前 ink、未到 ink_faint。 + let labels = stage_labels(language); + let active = state.stage_index(); + for (i, label) in labels.iter().enumerate() { + let ink = if state.failed() && i == active { + rgb_of(sk.danger) + } else if i == active { + sk.ink + } else if i < active { + sk.ink_dim + } else { + sk.ink_faint + }; + let w = text_w(label, cell_w, type_scale::SECTION_CAPTION); + // 首尾贴齐轨道两端,中间的以节点为中心——标签是节点的名字,不是 + // 均分的四列。 + let cx = node_x(&l, i); + let x = match i { + 0 => l.rail_x0 - s_half(scale), + 3 => l.rail_x1 + s_half(scale) - w, + _ => cx - w * 0.5, + }; + r.draw_ui_text( + size, + x.round(), + l.labels_y, + type_scale::SECTION_CAPTION, + ink, + Flags::empty(), + label, + gc, + ); + } + + // 状态行:左边当前动作,右边计时(失败后计时停在失败时刻)。 + let (msg, msg_ink) = match &state.failure { + Some(_) => (failure_headline(language), rgb_of(sk.danger)), + None => (stage_message(&state.stage, language), sk.ink), + }; + r.draw_ui_text( + size, + l.card.0 + l.pad, + l.status_y, + type_scale::BODY, + msg_ink, + Flags::empty(), + &msg, + gc, + ); + if !state.failed() { + let elapsed = state.elapsed_text(); + let w = text_w(&elapsed, cell_w, type_scale::BODY); + r.draw_ui_text( + size, + (l.card.0 + l.card.2 - l.pad - w).round(), + l.status_y, + type_scale::BODY, + sk.ink_faint, + Flags::empty(), + &elapsed, + gc, + ); + } + + // 失败详情:指到位置的原因,最多两行,末行溢出收成省略号。 + if let Some(reason) = &state.failure { + let avail = l.card.2 - l.pad * 2.0; + let per_line = cols_that_fit(avail, cell_w, type_scale::SUPPORTING); + let ch = size.cell_height(); + for (i, line) in wrap(reason, per_line).into_iter().take(2).enumerate() { + r.draw_ui_text( + size, + l.card.0 + l.pad, + l.detail_y + i as f32 * ch * type_scale::SUPPORTING * 1.35, + type_scale::SUPPORTING, + sk.ink_dim, + Flags::empty(), + &truncate_cols(&line, per_line), + gc, + ); + } + } + + // Logs 开关的标签(箭头是矢量,在 push_quads 里)。 + r.draw_ui_text( + size, + (l.logs_btn.0 + space::M * scale).round(), + (l.logs_btn.1 + (l.logs_btn.3 - size.cell_height() * type_scale::SUPPORTING) * 0.5).round(), + type_scale::SUPPORTING, + sk.ink_dim, + Flags::empty(), + "Logs", + gc, + ); + + // 日志:只显示末尾若干行,最新的在最下——和终端一样往下长。每行按日志 + // 区宽度截断,否则那条中文系统错误会一路画到窗口外面。 + if let Some(area) = l.logs_area { + let line_h = size.cell_height() * type_scale::SUPPORTING; + let inner_pad = space::S * scale; + let max_cols = cols_that_fit(area.2 - inner_pad * 2.0, cell_w, type_scale::SUPPORTING); + let start = state.log.len().saturating_sub(LOG_ROWS); + for (i, line) in state.log[start..].iter().enumerate() { + let y = area.1 + inner_pad + i as f32 * line_h; + if y + line_h > area.1 + area.3 { + break; + } + let ink = if line.contains("error") { rgb_of(sk.danger) } else { sk.ink_dim }; + r.draw_ui_text( + size, + (area.0 + inner_pad).round(), + y.round(), + type_scale::SUPPORTING, + ink, + Flags::empty(), + &truncate_cols(line, max_cols), + gc, + ); + } + } + + // 底部按钮文字:居中。主按钮的文字色按 accent 亮度选黑或白,不能写死。 + for (rect, hit, primary) in &l.buttons { + let label = button_specs(state, language) + .into_iter() + .find(|(_, h, _)| h == hit) + .map(|(label, _, _)| label) + .unwrap_or_default(); + let w = text_w(&label, cell_w, type_scale::BODY); + let ink = if *primary { on_accent(sk.accent) } else { sk.ink }; + r.draw_ui_text( + size, + (rect.0 + (rect.2 - w) * 0.5).round(), + (rect.1 + (rect.3 - size.cell_height() * type_scale::BODY) * 0.5).round(), + type_scale::BODY, + ink, + Flags::empty(), + &label, + gc, + ); + } +} + +/// accent 上的文字色。accent 在浅色主题里是深靛蓝、在深色主题里是亮蓝, +/// 写死黑或白都会在另一边糊掉。 +fn on_accent(accent: Rgb) -> Rgb { + let lum = 0.299 * accent.r as f32 + 0.587 * accent.g as f32 + 0.114 * accent.b as f32; + if lum > 150.0 { Rgb::new(12, 14, 18) } else { Rgb::new(255, 255, 255) } +} + +fn s_half(scale: f32) -> f32 { + NODE_D * 0.5 * scale +} + +/// 列表和卡片共用的短名:有别名就用别名,否则去掉 user@ 只留主机。 +fn short_name(destination: &str) -> String { + let host = destination.rsplit('@').next().unwrap_or(destination); + host.to_owned() +} + +fn stage_labels(language: UiLanguage) -> [String; 4] { + [ + language.pick("解析", "Resolve").to_owned(), + language.pick("连接", "Connect").to_owned(), + language.pick("认证", "Auth").to_owned(), + language.pick("会话", "Shell").to_owned(), + ] +} + +fn stage_message(stage: &SshStage, language: UiLanguage) -> String { + match stage { + SshStage::Resolve => language.pick("正在解析主机…", "Resolving host…"), + SshStage::Connect => language.pick("正在建立连接…", "Connecting…"), + SshStage::Authenticate => language.pick("正在认证…", "Authenticating…"), + SshStage::OpenShell => language.pick("正在打开会话…", "Opening shell…"), + SshStage::Ready => language.pick("已连接", "Connected"), + SshStage::Failed(_) => language.pick("连接失败", "Connection failed"), + } + .to_owned() +} + +fn failure_headline(language: UiLanguage) -> String { + language.pick("连接失败", "Connection failed").to_owned() +} + +/// 按列宽换行。中日韩宽字符按 2 列计,所以不能按 char 数切。 +fn wrap(text: &str, per_line: usize) -> Vec { + let mut lines = Vec::new(); + let mut line = String::new(); + let mut cols = 0usize; + for c in text.chars() { + if c == '\n' { + lines.push(std::mem::take(&mut line)); + cols = 0; + continue; + } + let w = c.width().unwrap_or(0); + if cols + w > per_line && !line.is_empty() { + lines.push(std::mem::take(&mut line)); + cols = 0; + } + line.push(c); + cols += w; + } + if !line.is_empty() { + lines.push(line); + } + lines +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn card_stays_hidden_until_the_reveal_delay() { + let state = SshConnectState::new("root@example.com".into()); + assert!(!state.visible(), "快连接不该闪一下加载页"); + } + + #[test] + fn failure_shows_immediately_regardless_of_delay() { + let mut state = SshConnectState::new("root@example.com".into()); + state.set_stage(SshStage::Failed("Permission denied".into())); + assert!(state.visible()); + assert!(state.failed()); + } + + #[test] + fn stage_index_maps_every_stage_into_the_four_slot_rail() { + let mut state = SshConnectState::new("h".into()); + for (stage, want) in [ + (SshStage::Resolve, 0), + (SshStage::Connect, 1), + (SshStage::Authenticate, 2), + (SshStage::OpenShell, 3), + (SshStage::Ready, 3), + ] { + state.set_stage(stage); + assert_eq!(state.stage_index(), want); + } + } + + #[test] + fn fill_interpolates_toward_the_stage_without_overshooting() { + let mut state = SshConnectState::new("h".into()); + state.set_stage(SshStage::Authenticate); + for _ in 0..240 { + state.step(Duration::from_millis(16)); + } + assert!((state.fill - 2.0).abs() < 0.05, "fill={}", state.fill); + assert!(state.fill <= 2.0 + f32::EPSILON, "插值不该越过目标"); + } + + #[test] + fn particle_phase_wraps_and_never_leaves_unit_range() { + let mut state = SshConnectState::new("h".into()); + for _ in 0..500 { + state.step(Duration::from_millis(33)); + assert!((0.0..1.0).contains(&state.phase), "phase={}", state.phase); + } + } + + #[test] + fn failed_state_freezes_the_animation() { + let mut state = SshConnectState::new("h".into()); + state.step(Duration::from_millis(16)); + let phase = state.phase; + state.set_stage(SshStage::Failed("boom".into())); + state.step(Duration::from_millis(100)); + assert_eq!(state.phase, phase, "失败后不该继续跑粒子"); + } + + #[test] + fn speed_field_is_monotonic_and_pins_both_ends() { + assert!((ease(0.0)).abs() < 1e-6); + assert!((ease(1.0) - 1.0).abs() < 1e-6); + let mut prev = -1.0; + for i in 0..=100 { + let v = ease(i as f32 / 100.0); + assert!(v > prev, "速度场必须单调递增"); + prev = v; + } + // 中段比端点快:这正是拖尾被拉长的原因。 + let mid = ease(0.55) - ease(0.45); + let head = ease(0.10) - ease(0.0); + assert!(mid > head, "中段速度应高于起步段"); + } + + #[test] + fn wrap_counts_wide_characters_as_two_columns() { + let lines = wrap("认证失败拒绝", 4); + assert_eq!(lines, vec!["认证".to_owned(), "失败".to_owned(), "拒绝".to_owned()]); + } + + #[test] + fn short_name_drops_the_user_prefix() { + assert_eq!(short_name("root@192.168.200.150"), "192.168.200.150"); + assert_eq!(short_name("web-01"), "web-01"); + } + + fn probe_size() -> SizeInfo { + SizeInfo::new_fully_asymmetric(1000.0, 1000.0, 10.0, 20.0, 0.0, 0.0, 64.0, 16.0) + } + + const VIEW: (f32, f32, f32, f32) = (0.0, 0.0, 1000.0, 800.0); + + #[test] + fn failure_opens_the_log_so_the_reason_is_not_one_click_away() { + let mut state = SshConnectState::new("root@h".into()); + assert!(!state.logs_open); + state.set_stage(SshStage::Failed("Permission denied".into())); + assert!(state.logs_open, "失败原因是最该被看见的东西"); + assert!( + state.log.iter().any(|line| line.contains("Permission denied")), + "失败原因必须进日志: {:?}", + state.log + ); + } + + #[test] + fn repeated_stage_reports_do_not_flood_the_log() { + let mut state = SshConnectState::new("root@h".into()); + let base = state.log.len(); + for _ in 0..20 { + state.set_stage(SshStage::Connect); + } + assert_eq!(state.log.len(), base + 1, "同一阶段重复上报只该记一行"); + } + + #[test] + fn buttons_and_logs_toggle_hit_where_they_are_drawn() { + let size = probe_size(); + let lang = UiLanguage::EnUs; + let state = SshConnectState::new("root@h".into()); + let l = layout(&state, &size, VIEW, 1.0, lang); + + let (bx, by, bw, bh) = l.logs_btn; + assert_eq!( + hit_test(&state, &size, VIEW, 1.0, lang, bx + bw * 0.5, by + bh * 0.5), + SshConnectHit::Logs + ); + + let (rect, hit, _) = l.buttons[0]; + assert_eq!(hit, SshConnectHit::Cancel, "连接中只该有取消"); + assert_eq!( + hit_test(&state, &size, VIEW, 1.0, lang, rect.0 + rect.2 * 0.5, rect.1 + rect.3 * 0.5), + SshConnectHit::Cancel + ); + } + + #[test] + fn failed_card_offers_close_instead_of_cancel() { + let size = probe_size(); + let lang = UiLanguage::EnUs; + let mut state = SshConnectState::new("root@h".into()); + state.set_stage(SshStage::Failed("boom".into())); + let l = layout(&state, &size, VIEW, 1.0, lang); + let (rect, hit, primary) = l.buttons[0]; + assert_eq!(hit, SshConnectHit::Close); + assert!(primary, "唯一动作应当是主按钮"); + assert_eq!( + hit_test(&state, &size, VIEW, 1.0, lang, rect.0 + rect.2 * 0.5, rect.1 + rect.3 * 0.5), + SshConnectHit::Close + ); + } + + #[test] + fn the_mask_swallows_clicks_anywhere_in_the_pane() { + // 卡片之外的空白也必须被吞掉,否则按压会漏进终端起拖选。 + assert!(covers(VIEW, 5.0, 5.0)); + assert!(covers(VIEW, 999.0, 799.0)); + assert!(!covers(VIEW, 1001.0, 400.0)); + } + + #[test] + fn opening_the_log_makes_the_card_taller_not_narrower() { + let size = probe_size(); + let lang = UiLanguage::EnUs; + let mut state = SshConnectState::new("root@h".into()); + let before = layout(&state, &size, VIEW, 1.0, lang).card; + state.toggle_logs(); + let after = layout(&state, &size, VIEW, 1.0, lang).card; + assert!(after.3 > before.3, "展开日志应当长高"); + assert_eq!(after.2, before.2, "宽度不该跟着变,否则读起来像在抖"); + assert!(layout(&state, &size, VIEW, 1.0, lang).logs_area.is_some()); + } +} diff --git a/nebula_app/src/display/ssh_editor_input.rs b/nebula_app/src/display/ssh_editor_input.rs new file mode 100644 index 0000000..f53115e --- /dev/null +++ b/nebula_app/src/display/ssh_editor_input.rs @@ -0,0 +1,623 @@ +//! SSH 主机编辑器的**交互层**:打开/关闭、命中、光标定位与拖选、字段校验、 +//! 测试连接、保存。 +//! +//! # 为什么单独一个文件 +//! +//! 这个域此前是 model([`ssh_ui`](super::ssh_ui))+ view +//! ([`ssh_editor_render`](super::ssh_editor_render))两分,交互逻辑却留在 +//! `display::mod` 里,跟另外三百多个方法堆在一起。结果是想弄清"点一下地址框 +//! 会发生什么",得在一个八千行的文件里翻。 +//! +//! 分法参照 Warp:`app/src/terminal/` 下 `model/`、`view.rs`、`input.rs` 三分, +//! 每个功能域自带这三块,中枢只负责装配。这里照搬同一条线——`mod.rs` 不该 +//! 知道端口框只收五位数字。 +//! +//! # 与相邻两层的边界 +//! +//! - **model**([`ssh_ui`](super::ssh_ui))—— 只有状态与不变量,不碰 +//! [`Display`]。 +//! - **view**([`ssh_editor_render`](super::ssh_editor_render))—— 只吃状态 +//! 吐 quad 与文字,不改状态。 +//! - **input**(本文件)—— 唯一同时持有两边的地方:把指针与按键翻译成对 +//! model 的修改,并拿 view 算出的矩形做命中。 + +use super::*; + +impl Display { + pub fn open_ssh_editor(&mut self) { + self.nebula_ssh_editor = Some(SshHostEditor { + original_destination: None, + error: None, + destination_cursor: Default::default(), + port_cursor: Default::default(), + label_cursor: Default::default(), + password_cursor: Default::default(), + destination: String::new(), + port: String::new(), + label: String::new(), + password: String::new(), + save_password: true, + show_password: false, + auth: crate::ssh_profiles::SshAuthMode::Auto, + private_keys: Vec::new(), + field: SshEditorField::Destination, + focus: crate::ux::FocusIndex::default(), + test: Default::default(), + }); + self.nebula_ssh_editor_rects = None; + self.nebula_ssh_editor_open = true; + self.nebula_ssh_editor_hover = SshEditorHit::None; + // 每次打开都从零开始,避免上一次退出动画的残余进度造成闪跳。 + self.nebula_ui_anims.ssh_editor = UiAnim::new(0.0); + self.pending_update.dirty = true; + } + + pub fn edit_ssh_host(&mut self, index: usize) { + let Some(destination) = self.nebula_ssh_hosts.get(index).cloned() else { return }; + let profile = + crate::ssh_profiles::SshProfiles::load(&nebula_data_dir().join("ssh_profiles.json")) + .unwrap_or_else(|err| { + warn!("加载 SSH Profile 失败,编辑器使用自动认证: {err}"); + crate::ssh_profiles::SshProfiles::default() + }) + .for_destination(&destination); + // 存盘的地址串里端口是内嵌的;编辑器分两个框显示,所以这里拆开。 + let (address, port) = ssh_ui::split_destination_port(&destination); + self.nebula_ssh_editor = Some(SshHostEditor { + original_destination: Some(destination.clone()), + error: None, + destination_cursor: Default::default(), + port_cursor: Default::default(), + label_cursor: Default::default(), + password_cursor: Default::default(), + destination: address, + port, + label: profile.label.clone().unwrap_or_default(), + // Never pull a stored secret back into a text field. Leaving this + // blank preserves the existing credential when the address stays + // unchanged; typing a new value explicitly replaces it. + password: String::new(), + save_password: true, + show_password: false, + auth: profile.auth, + private_keys: profile.private_keys, + field: SshEditorField::Destination, + focus: crate::ux::FocusIndex::default(), + test: Default::default(), + }); + self.nebula_ssh_editor_rects = None; + self.nebula_ssh_editor_open = true; + self.nebula_ssh_editor_hover = SshEditorHit::None; + self.nebula_ui_anims.ssh_editor = UiAnim::new(0.0); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + pub fn ssh_editor_active(&self) -> bool { + self.nebula_ssh_editor_open && self.nebula_ssh_editor.is_some() + } + + /// 「测试连接」正在跑。转圈指示器要按显示刷新率画,8fps 下一圈只有五六 + /// 帧,转起来是一顿一顿的——那种顿挫读作"卡住了",恰好和它要表达的 + /// "正在工作"相反。 + pub fn ssh_test_running(&self) -> bool { + self.nebula_ssh_editor + .as_ref() + .is_some_and(|editor| matches!(editor.test, ssh_ui::SshTestState::Running { .. })) + } + + pub fn close_ssh_editor(&mut self) { + if self.nebula_ssh_editor.is_some() { + self.nebula_ssh_editor_open = false; + self.nebula_ssh_editor_hover = SshEditorHit::None; + self.nebula_ssh_editor_drag = None; + self.pending_update.dirty = true; + self.window.request_redraw(); + } + } + + pub fn ssh_editor_hit(&self, x: f32, y: f32) -> SshEditorHit { + let Some(rects) = self.nebula_ssh_editor_rects.as_ref() else { + return SshEditorHit::None; + }; + let hit = |r: (f32, f32, f32, f32)| x >= r.0 && x < r.0 + r.2 && y >= r.1 && y < r.1 + r.3; + if hit(rects.close) { + SshEditorHit::Close + } else if hit(rects.destination) { + SshEditorHit::Destination + } else if hit(rects.port) { + SshEditorHit::Port + } else if hit(rects.label) { + SshEditorHit::Label + } else if let Some((mode, _)) = rects.auth.iter().find(|(_, rect)| hit(*rect)) { + SshEditorHit::Auth(*mode) + } else if hit(rects.add_private_key) { + SshEditorHit::AddPrivateKey + } else if let Some((index, _)) = rects.private_key_rows.iter().find(|(_, rect)| hit(*rect)) + { + SshEditorHit::RemovePrivateKey(*index) + } else if hit(rects.password_toggle) { + SshEditorHit::PasswordToggle + } else if hit(rects.password) { + SshEditorHit::Password + } else if hit(rects.save_checkbox) { + SshEditorHit::SaveToggleBox + } else if hit(rects.save_toggle) { + SshEditorHit::SaveToggleLabel + } else if hit(rects.test) { + SshEditorHit::Test + } else if hit(rects.test_status) { + SshEditorHit::TestStatus + } else if hit(rects.cancel) { + SshEditorHit::Cancel + } else if hit(rects.primary) { + SshEditorHit::Primary + } else { + SshEditorHit::None + } + } + + pub fn set_ssh_editor_hover(&mut self, hover: SshEditorHit) { + if self.nebula_ssh_editor_hover != hover { + self.nebula_ssh_editor_hover = hover; + self.pending_update.dirty = true; + } + } + + /// 把键盘焦点与文本光标一起放到 Tab 顺序里第 `slot` 个位置上。点击和 Tab + /// 走同一套序号,否则"点一下地址框再按 Tab"会跳到和视觉顺序无关的地方。 + /// + /// `select_all` 区分两种进入方式:Tab 键进来是全选(原生行为,方便直接 + /// 覆写),鼠标点进来是把光标放在点的位置——那时全选等于把用户瞄准的 + /// 那个字符位置扔掉。 + fn focus_ssh_editor_field(&mut self, slot: usize, select_all: bool) { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + let shows_password = ssh_ui::auth_sections(editor.auth).0; + let count = if shows_password { 7 } else { 6 }; + editor.focus.set(slot.min(count - 1), count); + editor.clear_selections(); + editor.field = match slot { + 1 => SshEditorField::Port, + 2 => SshEditorField::Label, + 3 if shows_password => SshEditorField::Password, + _ => SshEditorField::Destination, + }; + if select_all { + let (value, cursor) = editor.active_field(); + let text = value.clone(); + cursor.select_all(&text); + } + } + } + + /// 鼠标按在输入框里:聚焦该字段并把光标放到点击的字符缝隙上,同时开一段 + /// 拖选。`x` 是物理像素的窗口坐标。 + pub fn ssh_editor_press_field(&mut self, hit: SshEditorHit, x: f32) { + let Some(rects) = self.nebula_ssh_editor_rects.as_ref() else { return }; + let Some((field, rect)) = rects.field_of(hit) else { return }; + let origin = rects.metrics.origin(field); + let cell_w = rects.metrics.cell_w; + let slot = match field { + SshEditorField::Destination => 0, + SshEditorField::Port => 1, + SshEditorField::Label => 2, + SshEditorField::Password => 3, + }; + self.focus_ssh_editor_field(slot, false); + let Some(editor) = self.nebula_ssh_editor.as_mut() else { return }; + let index = editor.index_at(field, x - origin, cell_w); + let (value, cursor) = editor.field_mut(field); + let text = value.clone(); + cursor.place(&text, index); + let _ = rect; + self.nebula_ssh_editor_drag = Some(field); + } + + /// 拖动中:把选区从按下的锚点拉到当前位置。返回是否真的在拖,让调用方 + /// 决定要不要重绘。 + pub fn ssh_editor_drag_to(&mut self, x: f32) -> bool { + let Some(field) = self.nebula_ssh_editor_drag else { return false }; + let Some(rects) = self.nebula_ssh_editor_rects.as_ref() else { return false }; + let origin = rects.metrics.origin(field); + let cell_w = rects.metrics.cell_w; + let Some(editor) = self.nebula_ssh_editor.as_mut() else { return false }; + let index = editor.index_at(field, x - origin, cell_w); + let (value, cursor) = editor.field_mut(field); + let text = value.clone(); + cursor.extend_to(&text, index); + true + } + + pub fn ssh_editor_end_drag(&mut self) { + self.nebula_ssh_editor_drag = None; + } + + pub fn ssh_editor_dragging(&self) -> bool { + self.nebula_ssh_editor_drag.is_some() + } + + pub fn ssh_editor_insert(&mut self, text: &str) { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + editor.error = None; + // 端口框只收数字,且不超过 5 位。非法字符当场挡住,比让人填完 + // 再在保存时弹一句"端口无效"要好——那时已经不知道错在哪一格。 + let text = if editor.field == SshEditorField::Port { + let mut digits: String = text.chars().filter(char::is_ascii_digit).collect(); + if digits.is_empty() { + return; + } + // 位数上限在插入**前**算:先插再 `truncate` 会砍掉尾部,而 + // 用户刚打的那个字符正在光标处——被砍的看起来是别人的字符。 + let selected = editor + .port_cursor + .range(&editor.port) + .map_or(0, |(start, end)| end - start); + let room = 5usize.saturating_sub(editor.port.chars().count() - selected); + if room == 0 { + return; + } + digits.truncate(room); + digits + } else { + text.to_owned() + }; + let (value, cursor) = editor.active_field(); + cursor.insert(value, &text); + editor.test = Default::default(); + } + } + + pub fn ssh_editor_backspace(&mut self) { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + let before = editor.active_text().len(); + let (value, cursor) = editor.active_field(); + cursor.backspace(value); + if before != editor.active_text().len() { + editor.test = Default::default(); + } + } + } + + pub fn ssh_editor_delete_forward(&mut self) { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + let before = editor.active_text().len(); + let (value, cursor) = editor.active_field(); + cursor.delete_forward(value); + if before != editor.active_text().len() { + editor.test = Default::default(); + } + } + } + + /// ←/→ 移动光标;`extend` 为真时按住 Shift 扩选。 + pub fn ssh_editor_move_caret(&mut self, forward: bool, extend: bool) { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + let (value, cursor) = editor.active_field(); + let text = value.clone(); + cursor.step(&text, forward, extend); + } + } + + /// Home / End。 + pub fn ssh_editor_jump_caret(&mut self, to_end: bool, extend: bool) { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + let (value, cursor) = editor.active_field(); + let text = value.clone(); + cursor.jump(&text, to_end, extend); + } + } + + pub fn ssh_editor_select_all(&mut self) { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + let (value, cursor) = editor.active_field(); + let text = value.clone(); + cursor.select_all(&text); + } + } + + /// Copying an invisible password would persist a secret in the system + /// clipboard without visible intent, so it is enabled only after Reveal. + pub fn ssh_editor_selected_text(&self) -> Option { + let editor = self.nebula_ssh_editor.as_ref()?; + if editor.field == SshEditorField::Password && !editor.show_password { + return None; + } + let (text, cursor) = editor.field_view(editor.field); + cursor.selected_text(text) + } + + pub fn ssh_editor_next_field(&mut self, reverse: bool) { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + editor.clear_selections(); + let shows_password = ssh_ui::auth_sections(editor.auth).0; + // 地址、端口、标签、[密码]、测试、取消、保存。 + let count = if shows_password { 7 } else { 6 }; + editor.focus.advance(count, reverse); + editor.field = match (shows_password, editor.focus.current()) { + (_, 0) => SshEditorField::Destination, + (_, 1) => SshEditorField::Port, + (_, 2) => SshEditorField::Label, + (true, 3) => SshEditorField::Password, + _ => editor.field, + }; + // Tab 进一个输入框时全选:Windows 上所有输入框都这样,让"切过去 + // 直接覆写"是一次按键的事。焦点落在按钮上时不选,否则会有一段和 + // 当前操作无关的高亮一直亮着。 + if editor.focus.current() < if shows_password { 4 } else { 3 } { + let (value, cursor) = editor.active_field(); + let text = value.clone(); + cursor.select_all(&text); + } + } + } + + pub fn ssh_editor_activate_focus(&mut self) { + let Some(editor) = self.nebula_ssh_editor.as_ref() else { return }; + let shows_password = ssh_ui::auth_sections(editor.auth).0; + match (shows_password, editor.focus.current()) { + (true, 4) | (false, 3) => self.queue_ssh_test(), + (true, 5) | (false, 4) => self.close_ssh_editor(), + (true, 6) | (false, 5) => self.save_ssh_editor(), + _ => {}, + } + } + + pub fn ssh_editor_toggle_save(&mut self) { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + editor.save_password = !editor.save_password; + editor.test = Default::default(); + } + } + + /// input 层在处理完编辑器点击后调用:取走「测试连接」暂存的请求。 + pub fn take_ssh_test_request(&mut self) -> Option { + self.nebula_ssh_test_request.take() + } + + /// Validate the current draft and stage an exact, numbered test request. + /// The input layer owns the event proxy and takes this request immediately + /// after mouse/keyboard activation. + fn queue_ssh_test(&mut self) { + let Some(editor) = self.nebula_ssh_editor.as_mut() else { return }; + let destination = editor.destination.trim().to_owned(); + let valid = !destination.is_empty() + && !destination + .chars() + .any(|c| c.is_whitespace() || c.is_control() || ";&|<>\"'`".contains(c)); + if !valid { + editor.error = Some(if destination.is_empty() { + "请输入 SSH 地址,例如 user@example.com".to_owned() + } else { + "地址不能包含空白、控制字符或 shell 分隔符".to_owned() + }); + editor.test = Default::default(); + return; + } + if matches!(editor.test, ssh_ui::SshTestState::Running { .. }) { + return; + } + + let shows_password = ssh_ui::auth_sections(editor.auth).0; + editor.focus.set(if shows_password { 2 } else { 1 }, if shows_password { 5 } else { 4 }); + self.nebula_ssh_test_seq = self.nebula_ssh_test_seq.wrapping_add(1).max(1); + let request_id = self.nebula_ssh_test_seq; + editor.error = None; + editor.test = ssh_ui::SshTestState::Running { request_id }; + self.nebula_ssh_test_request = Some(crate::ssh_session::SshTestRequest { + request_id, + destination, + auth: editor.auth, + private_keys: editor.private_keys.clone(), + password: (!editor.password.is_empty()).then(|| editor.password.clone()), + }); + } + + /// 后台测试完成回报。结果只属于发起时的草稿:地址已改或状态已非 + /// Running(用户改过字段被清位)时直接丢弃,不让旧结果背书新配置。 + pub fn ssh_test_done( + &mut self, + request_id: u64, + destination: &str, + ok: bool, + message: &str, + elapsed_ms: u64, + ) { + let Some(editor) = self.nebula_ssh_editor.as_mut() else { return }; + if editor.destination.trim() != destination + || editor.test != (ssh_ui::SshTestState::Running { request_id }) + { + return; + } + editor.test = if ok { + ssh_ui::SshTestState::Ok { elapsed_ms } + } else { + ssh_ui::SshTestState::Failed { summary: message.to_owned() } + }; + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + pub fn ssh_editor_click(&mut self, x: f32, y: f32) -> bool { + match self.ssh_editor_hit(x, y) { + // 点哪个框就聚焦哪个框,并把光标放到点中的字符缝隙上,同时开一段 + // 拖选。焦点序号必须和 `ssh_editor_next_field` 的顺序一致,否则点 + // 完再按 Tab 会跳到毫不相干的地方。 + hit @ (SshEditorHit::Destination + | SshEditorHit::Port + | SshEditorHit::Label + | SshEditorHit::Password) => self.ssh_editor_press_field(hit, x), + SshEditorHit::PasswordToggle => { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + editor.show_password = !editor.show_password; + } + }, + SshEditorHit::Auth(mode) => { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + editor.auth = mode; + editor.error = None; + editor.test = Default::default(); + if !ssh_ui::auth_sections(mode).0 { + editor.field = SshEditorField::Destination; + } + } + }, + SshEditorHit::AddPrivateKey => { + if let Some(result) = file_dialog::pick_private_key_file(&self.window) { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + match result { + Ok(path) => { + ssh_ui::push_private_key(&mut editor.private_keys, path); + editor.error = None; + editor.test = Default::default(); + }, + Err(err) => editor.error = Some(err), + } + } + } + }, + SshEditorHit::RemovePrivateKey(index) => { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + if index < editor.private_keys.len() { + editor.private_keys.remove(index); + editor.test = Default::default(); + } + } + }, + SshEditorHit::SaveToggleBox | SshEditorHit::SaveToggleLabel => { + self.ssh_editor_toggle_save(); + }, + SshEditorHit::Test => self.queue_ssh_test(), + SshEditorHit::TestStatus => {}, + SshEditorHit::Close => { + self.close_ssh_editor(); + }, + SshEditorHit::Cancel => { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + let shows_password = ssh_ui::auth_sections(editor.auth).0; + editor.focus.set( + if shows_password { 3 } else { 2 }, + if shows_password { 5 } else { 4 }, + ); + } + self.close_ssh_editor(); + }, + SshEditorHit::Primary => { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + let shows_password = ssh_ui::auth_sections(editor.auth).0; + editor.focus.set( + if shows_password { 4 } else { 3 }, + if shows_password { 5 } else { 4 }, + ); + } + self.save_ssh_editor(); + }, + SshEditorHit::None => return false, + } + self.pending_update.dirty = true; + self.window.request_redraw(); + true + } + + pub fn save_ssh_editor(&mut self) { + let Some(mut editor) = self.nebula_ssh_editor.take() else { return }; + // 端口先单独校验:它有自己的输入框,错误也该指回那个框。u16 的上界拦不住 + // `0`——那不是一个能连的端口,却能通过 parse。 + let port = editor.port.trim().to_owned(); + if !port.is_empty() && !port.parse::().is_ok_and(|p| p > 0) { + editor.error = Some("端口需要是 1–65535 之间的数字".to_owned()); + editor.field = SshEditorField::Port; + self.nebula_ssh_editor = Some(editor); + self.pending_update.dirty = true; + self.window.request_redraw(); + return; + } + // 存盘仍然是一条地址串(凭据、profile、历史都以它为键),端口在这里拼回去。 + let destination = ssh_ui::join_destination_port(&editor.destination, &port); + let valid = !destination.is_empty() + && !destination + .chars() + .any(|c| c.is_whitespace() || c.is_control() || ";&|<>\"'`".contains(c)); + if !valid { + editor.error = Some(if destination.is_empty() { + "请输入 SSH 地址,例如 user@example.com".to_owned() + } else { + "地址不能包含空白、控制字符或 shell 分隔符".to_owned() + }); + editor.field = SshEditorField::Destination; + self.nebula_ssh_editor = Some(editor); + self.pending_update.dirty = true; + self.window.request_redraw(); + return; + } + if let Some(original) = editor.original_destination.as_deref() { + if original != destination { + self.nebula_saved_hosts.retain(|host| host != original); + self.nebula_pinned_hosts.retain(|host| host != original); + if !self.nebula_hidden_hosts.iter().any(|host| host == original) { + self.nebula_hidden_hosts.push(original.to_owned()); + } + #[cfg(windows)] + { + let _ = crate::ssh_credentials::forget_password(original); + } + } + } + // Saving/editing is an explicit request to surface this destination, + // so it also undoes a previous Delete of the same address. + self.nebula_hidden_hosts.retain(|host| host != &destination); + self.nebula_saved_hosts.retain(|host| host != &destination); + self.nebula_saved_hosts.insert(0, destination.clone()); + self.nebula_saved_hosts.truncate(20); + self.nebula_ssh_hosts = merge_ssh_hosts( + &self.nebula_saved_hosts, + &self.nebula_pinned_hosts, + &self.nebula_hidden_hosts, + ); + let profile_path = nebula_data_dir().join("ssh_profiles.json"); + let mut profiles = + crate::ssh_profiles::SshProfiles::load(&profile_path).unwrap_or_else(|err| { + warn!("加载 SSH Profile 失败,将创建新文件: {err}"); + crate::ssh_profiles::SshProfiles::default() + }); + if let Some(original) = editor.original_destination.as_deref() { + if original != destination { + profiles.rename(original, &destination); + } + } + profiles.upsert(crate::ssh_profiles::SshProfileAuth { + destination: destination.clone(), + auth: editor.auth, + private_keys: editor.private_keys.clone(), + // 空标签存成 None 而不是空串:两者语义相同,但 None 会被跳过序列化, + // 于是没起名字的主机在配置文件里干干净净。 + label: Some(editor.label.trim().to_owned()).filter(|label| !label.is_empty()), + }); + if let Err(err) = profiles.save(&profile_path) { + editor.error = Some(format!("保存 SSH Profile 失败: {err}")); + self.nebula_ssh_editor = Some(editor); + self.pending_update.dirty = true; + self.window.request_redraw(); + return; + } + // 只在存盘成功后才刷新缓存:写失败时侧栏应当继续显示旧名字,而不是 + // 显示一个磁盘上并不存在的标签。 + self.nebula_ssh_labels = profiles.labels(); + if ssh_ui::auth_sections(editor.auth).0 + && editor.save_password + && !editor.password.is_empty() + { + #[cfg(windows)] + { + let _ = crate::ssh_credentials::store_password( + &destination, + editor.password.as_bytes(), + ); + } + } + // 凭据落盘后立即清除内存中的明文,但保留其余内容完成短退出动画。 + editor.password.clear(); + self.persist_nebula_settings(); + self.nebula_ssh_editor = Some(editor); + self.close_ssh_editor(); + } +} diff --git a/nebula_app/src/display/ssh_editor_render.rs b/nebula_app/src/display/ssh_editor_render.rs index b5ecfd3..81c81d5 100644 --- a/nebula_app/src/display/ssh_editor_render.rs +++ b/nebula_app/src/display/ssh_editor_render.rs @@ -1,80 +1,191 @@ use unicode_width::UnicodeWidthChar; +use super::ssh_connect::{cols_that_fit, rgb_of, truncate_cols}; use super::ssh_ui::{SshTestState, auth_sections}; use super::*; use super::ui::theme; +use super::ui::tokens::{control, radius, space, type_scale}; use crate::ssh_profiles::SshAuthMode; type Rect = (f32, f32, f32, f32); -#[derive(Debug, Clone, Copy)] -struct SshEditorVerticalLayout { - destination_y: f32, - auth_y: f32, - password_y: f32, - save_toggle_y: f32, - content_y: f32, - key_header_y: f32, - key_rows_y: f32, - desired_h: f32, +/// 卡片宽度,单位是**设计单位**(见 [`ui_scale`])。 +const EDITOR_W: f32 = 440.0; +/// 头部高度:标题 + 关闭按钮那一条。 +const HEAD_H: f32 = 48.0; +/// 行式字段的 label 左列宽。定宽才能让所有控件左缘对齐成一条线—— +/// 让 label 自己撑宽会把控件推成参差不齐的锯齿。 +const LABEL_W: f32 = 84.0; +/// 控件高,同时也是最小点击区。 +const CTL_H: f32 = control::MIN_HIT_TARGET; +/// 端口输入框宽度:够放 5 位端口号,再宽就是浪费横向空间。 +const PORT_W: f32 = 76.0; +/// 私钥行高。 +const KEY_ROW_H: f32 = 30.0; +/// 私钥最多显示几行,超出保留尾部(最近添加的)。 +const KEY_ROWS_MAX: usize = 4; + +/// 这套尺寸是按 12.5px 等宽字排的,那个字号下一格的推进宽度就是这个数。 +const DESIGN_CELL_W: f32 = 6.9; + +/// 面板尺寸相对设计稿的放大倍数。 +/// +/// 上面那些常量(440 宽、84 的 label 列、32 的控件高)都是按 12.5px 的字定 +/// 的。但面板里的字跟着终端字号走:用户把终端调到 15px,字大了两成,盒子却 +/// 还是原来那么大——留白被字吃干净,读起来就是"挤"。 +/// +/// 放大按**平方根**打折,不是线性跟随:字变大本身就增加了视觉重量,留白只需 +/// 跟上一部分。线性跟随时字大两成、卡片也大两成,整张面板会压过它在界面里 +/// 应有的分量——那是一个添加主机的表单,不是主界面。 +/// +/// 下限钉在 1.0:字比设计稿小时不跟着缩,否则 32px 的控件会掉到点击区以下。 +/// 上限 1.3 是防止超大字号把卡片撑出窗口。 +fn ui_scale(cell_w_logical: f32) -> f32 { + (cell_w_logical / DESIGN_CELL_W).max(1.0).sqrt().min(1.3) } -/// 用同一组节奏令牌推导所有纵向位置。这样标签到自身控件保持紧密、不同 -/// 认证组之间保持明确留白,避免此前密码标签贴住分段控件、私钥组却离得过远。 -fn ssh_editor_vertical_layout( +/// 纵向布局结果,单位是逻辑像素、原点在卡片左上角。 +/// +/// 从"绝对 y 常量"改成流式推进:字段的位置由它前面的内容决定,所以增删 +/// 一行不需要手改后面每一个常量——之前 `DESTINATION_Y = 84` 那套写法, +/// 一旦在地址上面加个东西,下面全得重算。 +#[derive(Debug, Clone, Default)] +struct EditorLayout { + height: f32, + /// 分组卡片外框 (y, h):连接、认证。 + conn_group: (f32, f32), + auth_group: (f32, f32), + /// 分组标题行的 y。 + conn_head_y: f32, + auth_head_y: f32, + /// 「连接」组的字段行。 + dest_y: f32, + helper_y: f32, + port_y: f32, + label_y: f32, + /// 「认证」组:方式分段器,以及随方式切换的内容。 + auth_y: f32, + note_y: f32, + password_y: f32, + save_y: f32, + keys_y: f32, + add_key_y: f32, + /// 底部:测试状态条(无状态时为 None)与动作条。 + teststate_y: Option, + footer_y: f32, + footer_h: f32, +} + +/// 推导整张卡片的纵向布局。`cell_h` 是逻辑像素的 UI 行高。 +fn editor_layout( show_password: bool, show_keys: bool, - key_row_count: usize, + note_lines: usize, + key_rows: usize, + has_teststate: bool, cell_h: f32, -) -> SshEditorVerticalLayout { - const DESTINATION_Y: f32 = 84.0; - const AUTH_Y: f32 = 176.0; - const CONTROL_H: f32 = 32.0; - const LABEL_GAP: f32 = 8.0; - const GROUP_GAP: f32 = 18.0; - const SECTION_GAP: f32 = 24.0; - const SAVE_GAP: f32 = 12.0; - const SAVE_H: f32 = 28.0; - const KEY_LABEL_TO_ROWS: f32 = 30.0; - const KEY_EMPTY_H: f32 = 42.0; - const KEY_ROW_PITCH: f32 = 36.0; - const CONTENT_TO_FOOTER: f32 = 24.0; - const FOOTER_H: f32 = 64.0; +) -> EditorLayout { + let caption_h = cell_h * type_scale::SECTION_CAPTION; + let support_h = cell_h * type_scale::SUPPORTING; + // 组内字段之间的呼吸:比 XS 更紧,让同组字段读起来是一块。 + const FIELD_GAP: f32 = 6.0; - let auth_bottom = AUTH_Y + CONTROL_H; - let password_y = if show_password { - // auth control -> inter-group gap -> password label -> label/control gap. - auth_bottom + GROUP_GAP + cell_h + LABEL_GAP - } else { - 0.0 - }; - let save_toggle_y = if show_password { password_y + CONTROL_H + SAVE_GAP } else { 0.0 }; - let password_bottom = save_toggle_y + SAVE_H; - let content_y = auth_bottom + SECTION_GAP; - let key_header_y = if show_password { password_bottom + SECTION_GAP } else { content_y }; - let key_rows_y = key_header_y + KEY_LABEL_TO_ROWS; - let content_bottom = if show_keys { - key_rows_y - + if key_row_count == 0 { - KEY_EMPTY_H - } else { - key_row_count as f32 * KEY_ROW_PITCH - } - } else if show_password { - password_bottom - } else { - content_y + cell_h - }; + let mut l = EditorLayout::default(); + let mut y = HEAD_H + space::S; - SshEditorVerticalLayout { - destination_y: DESTINATION_Y, - auth_y: AUTH_Y, - password_y, - save_toggle_y, - content_y, - key_header_y, - key_rows_y, - desired_h: content_bottom + CONTENT_TO_FOOTER + FOOTER_H, + // ── 连接组 ──────────────────────────────────────────────── + l.conn_group.0 = y; + let mut gy = y + space::S; + l.conn_head_y = gy; + gy += caption_h + space::XS; + l.dest_y = gy; + gy += CTL_H + space::XXS; + l.helper_y = gy; + gy += support_h + FIELD_GAP; + l.port_y = gy; + gy += CTL_H + FIELD_GAP; + l.label_y = gy; + gy += CTL_H + space::S; + l.conn_group.1 = gy - y; + y = gy + space::S; + + // ── 认证组 ──────────────────────────────────────────────── + l.auth_group.0 = y; + let mut gy = y + space::S; + l.auth_head_y = gy; + gy += caption_h + space::XS; + l.auth_y = gy; + gy += CTL_H; + + if show_password { + gy += FIELD_GAP; + l.password_y = gy; + gy += CTL_H + space::XS; + l.save_y = gy; + gy += 26.0; + } + if show_keys { + gy += FIELD_GAP; + l.keys_y = gy; + let rows = key_rows.max(1); + gy += rows as f32 * (KEY_ROW_H + FIELD_GAP); + l.add_key_y = gy; + gy += CTL_H; + } + if !show_password && !show_keys { + // 自动 / 交互式:只有一句说明,不放空字段。 + gy += space::XS; + l.note_y = gy; + gy += support_h * note_lines as f32; + } + gy += space::S; + l.auth_group.1 = gy - y; + y = gy + space::S; + + // ── 底部 ───────────────────────────────────────────────── + if has_teststate { + l.teststate_y = Some(y); + y += space::XS * 2.0 + support_h; + } + l.footer_y = y; + l.footer_h = space::S * 2.0 + CTL_H; + l.height = y + l.footer_h; + l +} + +/// 忙碌指示器的相位,一圈 `0..1`。 +/// +/// 挂在挂钟上而不是累加帧增量:任何帧率下角度都是对的,掉帧只让它顿,不会 +/// 让它变慢——"转得比别处慢"恰恰最容易被读成已经卡死。 +fn spinner_phase() -> f32 { + const PERIOD_MS: u128 = 900; + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0.0, |since| (since.as_millis() % PERIOD_MS) as f32 / PERIOD_MS as f32) +} + +/// 转圈的忙碌指示器。 +/// +/// 原型那个 `border-top-color` 圆环靠旋转得到,而 quad 是轴对齐的、转不了。 +/// 所以把环离散成一圈点,让亮的那一段随相位绕行——读起来仍是"一段亮弧在 +/// 转",尾迹衰减还比纯色圆环更能表达方向。 +fn push_spinner(quads: &mut Vec, cx: f32, cy: f32, radius: f32, phase: f32, accent: Rgba) { + const DOTS: usize = 8; + let dot = (radius * 0.44).max(1.0); + for index in 0..DOTS { + let at = index as f32 / DOTS as f32; + let angle = std::f32::consts::TAU * at; + // 离头部越远越淡。平方衰减让头部足够突出,尾巴不至于糊成一个静止的环。 + let behind = (at - phase).rem_euclid(1.0); + let strength = (1.0 - behind).powi(2); + quads.push(UiQuad::solid( + cx + radius * angle.cos() - dot * 0.5, + cy + radius * angle.sin() - dot * 0.5, + dot, + dot, + dot * 0.5, + Rgba::new(accent.r, accent.g, accent.b, (40.0 + strength * 200.0) as u8), + )); } } @@ -94,44 +205,81 @@ impl Display { let size = self.ui_size_info(); let scale = self.window.scale_factor as f32; - let s = |value: f32| value * scale; + let cell_h = size.cell_height(); + let cell_w = size.cell_width(); + // `s()` 把**设计单位**换成物理像素:先按字号放大盒子,再乘 DPI。字本身 + // 不走这条路(它跟 cell 尺寸走),所以放大的只有留白和框。 + let ui = ui_scale(cell_w / scale); + let s = |value: f32| value * scale * ui; let skin = self.nebula_theme.skin(); let language = self.ui_language(); let accent = Rgba::new(skin.accent.r, skin.accent.g, skin.accent.b, 255); - let cell_h = size.cell_height(); - let cell_w = size.cell_width(); let text_width = |text: &str| -> f32 { text.chars().map(|c| c.width().unwrap_or(1)).sum::() as f32 * cell_w }; let (show_password, show_keys) = auth_sections(editor.auth); - // 稿一的核心不是把旧弹窗换色,而是让高度由真实内容决定。私钥最 - // 多展示四行,更多条目保留尾部(最近添加项),避免小屏越过页脚。 - let key_row_count = if show_keys { - editor.private_keys.len().max(1).min(4) - } else { - 0 + // 说明文案先按可用宽度折行,行数回填给布局——布局不能假设它只有 + // 一行,否则中文说明会顶穿分组卡片的下边缘。 + let note_text = match editor.auth { + SshAuthMode::Auto => language.pick( + "依次尝试可用私钥,失败再询问密码。多数情况选这个就行。", + "Tries each available key, then asks for a password. Pick this unless you need otherwise.", + ), + _ => language.pick( + "不预存任何凭据,连接时在终端里按提示输入(支持两步验证)。", + "Stores nothing; you answer the server's prompts in the terminal (2FA works).", + ), }; - let vertical = ssh_editor_vertical_layout( + // 模态住在终端卡里,不是整个窗口里。用卡片矩形而不是网格 padding: + // 卡片才是用户眼里"终端"的边界,它还带着侧栏折叠动画的位移。 + let stage = self.terminal_card_rect(); + let stage_radius = (UI_SHELL_RADIUS_LOGICAL * scale).round(); + let box_w = s(EDITOR_W).min(stage.2 - s(space::XL)); + let content_w = box_w - s(space::M) * 2.0; + // 组内字段的可用宽度:卡片内边距 → 分组卡片内边距 → label 左列。 + let field_w = content_w - s(space::S) * 2.0; + let ctl_w = field_w - s(LABEL_W) - s(space::S); + let note_cols = ((ctl_w + s(LABEL_W) + s(space::S)) + / (cell_w * type_scale::SUPPORTING)) + .floor() + .max(8.0) as usize; + let note_lines = wrap_status_tooltip(note_text, note_cols).len().max(1); + + // 私钥最多展示四行,更多条目保留尾部(最近添加项)。 + let key_rows = if show_keys { editor.private_keys.len().clamp(1, KEY_ROWS_MAX) } else { 0 }; + let has_teststate = editor.test != SshTestState::Idle; + let v = editor_layout( show_password, show_keys, - if editor.private_keys.is_empty() { 0 } else { key_row_count }, - cell_h / scale, + note_lines, + key_rows, + has_teststate, + // layout 全程用设计单位,所以行高也要换算过去。 + cell_h / scale / ui, ); - let box_w = s(460.0).min(size.width() - s(32.0)); - let box_h = s(vertical.desired_h).min(size.height() - s(32.0)); - let bx = (size.width() - box_w) * 0.5; - let resting_y = (size.height() - box_h) * 0.5; + + let box_h = s(v.height).min(stage.3 - s(space::XL)); + let bx = stage.0 + (stage.2 - box_w) * 0.5; + let resting_y = stage.1 + (stage.3 - box_h) * 0.5; let by = resting_y - (1.0 - progress) * s(14.0); - let pad = s(24.0); - let field_h = s(32.0); - let field_w = box_w - pad * 2.0; - let close = (bx + box_w - pad - s(28.0), by + s(14.0), s(28.0), s(28.0)); - let destination = (bx + pad, by + s(vertical.destination_y), field_w, field_h); - let auth_y = by + s(vertical.auth_y); - let auth_track = (destination.0, auth_y, field_w, s(32.0)); - let auth_pad = s(3.0); - let auth_w = (field_w - auth_pad * 2.0) / 4.0; + let field_h = s(CTL_H); + // 分组卡片外框与组内字段原点。 + let group_x = bx + s(space::M); + let field_x = group_x + s(space::S); + let ctl_x = field_x + s(LABEL_W) + s(space::S); + let conn_group = (group_x, by + s(v.conn_group.0), content_w, s(v.conn_group.1)); + let auth_group = (group_x, by + s(v.auth_group.0), content_w, s(v.auth_group.1)); + + let close = + (bx + box_w - s(space::S) - s(CTL_H), by + (s(HEAD_H) - s(CTL_H)) * 0.5, s(CTL_H), s(CTL_H)); + let destination = (ctl_x, by + s(v.dest_y), ctl_w, field_h); + let port = (ctl_x, by + s(v.port_y), s(PORT_W), field_h); + let host_label = (ctl_x, by + s(v.label_y), ctl_w, field_h); + + let auth_track = (ctl_x, by + s(v.auth_y), ctl_w, field_h); + let auth_pad = s(2.0); + let auth_w = (auth_track.2 - auth_pad * 2.0) / 4.0; let auth_modes = [ SshAuthMode::Auto, SshAuthMode::Password, @@ -149,91 +297,91 @@ impl Display { ), ) }); - let content_y = by + s(vertical.content_y); let zero = (0.0, 0.0, 0.0, 0.0); - let password = if show_password { - (destination.0, by + s(vertical.password_y), field_w, field_h) - } else { - zero - }; + let password = if show_password { (ctl_x, by + s(v.password_y), ctl_w, field_h) } else { zero }; let password_toggle = if show_password { - (password.0 + password.2 - s(38.0), password.1 + s(4.0), s(34.0), password.3 - s(8.0)) + (password.0 + password.2 - s(30.0), password.1 + s(2.0), s(28.0), password.3 - s(4.0)) } else { zero }; let save_label = language - .pick("保存密码到 Windows 凭据管理器", "Save password in Windows Credential Manager"); + .pick("保存到 Windows 凭据管理器", "Save in Windows Credential Manager"); let save_toggle = if show_password { - ( - destination.0, - by + s(vertical.save_toggle_y), - (s(28.0) + text_width(save_label)).min(field_w), - s(28.0), - ) - } else { - zero - }; - let save_checkbox = if show_password { - (save_toggle.0, save_toggle.1 + s(5.0), s(18.0), s(18.0)) + (ctl_x, by + s(v.save_y), (s(24.0) + text_width(save_label)).min(ctl_w), s(26.0)) } else { zero }; + let save_checkbox = + if show_password { (save_toggle.0, save_toggle.1 + s(5.0), s(16.0), s(16.0)) } else { zero }; - let key_header_y = by + s(vertical.key_header_y); - let add_private_key = if show_keys { - (destination.0 + field_w - s(112.0), key_header_y - s(6.0), s(112.0), s(28.0)) - } else { - zero - }; - let key_rows_y = by + s(vertical.key_rows_y); - let footer_top = by + box_h - s(64.0); - let footer_y = footer_top + s(16.0); - let available_key_height = (footer_top - s(10.0) - key_rows_y).max(0.0); - let available_rows = (available_key_height / s(36.0)).floor() as usize; - let show_empty_key_state = available_key_height >= s(40.0); - let visible_start = editor.private_keys.len().saturating_sub(available_rows); + // 私钥行占满 label 右侧的整条控件列。 + let key_rows_y = by + s(v.keys_y); + let visible_start = editor.private_keys.len().saturating_sub(KEY_ROWS_MAX); let visible_keys = if show_keys { editor .private_keys .iter() .enumerate() .skip(visible_start) - .take(available_rows) + .take(KEY_ROWS_MAX) .map(|(index, _)| { let row = ( - destination.0, - key_rows_y + (index - visible_start) as f32 * s(36.0), - field_w, - s(32.0), + ctl_x, + key_rows_y + (index - visible_start) as f32 * s(KEY_ROW_H + 6.0), + ctl_w, + s(KEY_ROW_H), ); - let remove = (row.0 + row.2 - s(34.0), row.1, s(34.0), row.3); + let remove = (row.0 + row.2 - s(24.0), row.1 + s(5.0), s(20.0), s(20.0)); (index, row, remove) }) .collect::>() } else { Vec::new() }; + let add_key_label = language.pick("+ 添加私钥", "+ Add private key"); + let add_private_key = if show_keys { + // 链接式按钮的点击区贴着文字本身,所以宽度也要按它的真实字号算—— + // 用 1.0 的字宽会把命中区撑大两成,鼠标停在空白处却亮起来。 + let w = text_width(add_key_label) * type_scale::SUPPORTING + s(space::XS) * 2.0; + (ctl_x, by + s(v.add_key_y), w, s(CTL_H)) + } else { + zero + }; let primary_action = language.pick("保存", "Save"); let cancel_action = language.pick("取消", "Cancel"); let test_action = language.pick("测试连接", "Test connection"); - let primary_w = s(72.0).max(text_width(primary_action) + s(28.0)); - let cancel_w = s(72.0).max(text_width(cancel_action) + s(28.0)); - let primary = (bx + box_w - pad - primary_w, footer_y, primary_w, s(32.0)); - let cancel = (primary.0 - s(8.0) - cancel_w, primary.1, cancel_w, s(32.0)); - let test_w = s(96.0).max(text_width(test_action) + s(24.0)); - let test = (bx + pad, footer_y, test_w, s(32.0)); - let test_status_x = test.0 + test.2 + s(8.0); - let test_status = ( - test_status_x, - footer_y, - (cancel.0 - s(8.0) - test_status_x).max(0.0), - s(32.0), - ); + let footer_y = by + s(v.footer_y) + s(space::S); + let primary_w = s(72.0).max(text_width(primary_action) + s(space::L)); + let cancel_w = s(72.0).max(text_width(cancel_action) + s(space::L)); + let primary = (bx + box_w - s(space::M) - primary_w, footer_y, primary_w, s(CTL_H)); + let cancel = (primary.0 - s(space::XS) - cancel_w, primary.1, cancel_w, s(CTL_H)); + let test_w = s(96.0).max(text_width(test_action) + s(space::L)); + let test = (bx + s(space::M), footer_y, test_w, s(CTL_H)); + // 测试状态自己占一条,不再挤在按钮之间——那里放不下真实的错误原因。 + let teststate = v.teststate_y.map(|y| { + (bx + s(space::M), by + s(y) + s(space::XS), content_w, cell_h * type_scale::SUPPORTING) + }); + let test_status = teststate.unwrap_or(zero); + let footer_top = by + s(v.footer_y); + + // 文字起点:绘制和命中共用这一份。端口居中,其余靠左;端口空着时起点 + // 落在框正中,于是光标停在中央而不是贴着左边等着。 + let pad = s(space::XS); + let port_shown = editor.display_text(SshEditorField::Port); + let metrics = ssh_ui::SshFieldMetrics { + destination_x: destination.0 + pad, + port_x: port.0 + (port.2 - text_width(&port_shown)).max(0.0) * 0.5, + label_x: host_label.0 + pad, + password_x: password.0 + pad, + cell_w, + }; self.nebula_ssh_editor_rects = Some(SshEditorRects { close, destination, + port, + label: host_label, password, password_toggle, auth, @@ -248,6 +396,7 @@ impl Display { test_status, primary, cancel, + metrics, }); let status_tooltip = if self.nebula_ssh_editor_hover == SshEditorHit::TestStatus { @@ -263,7 +412,7 @@ impl Display { let line_h = cell_h + s(3.0); let width = (widest as f32 * cell_w + s(24.0)).clamp(s(180.0), field_w); let height = lines.len() as f32 * line_h + s(16.0); - let x = bx + box_w - pad - width; + let x = bx + box_w - s(space::M) - width; let y = (footer_top - height - s(8.0)).max(by + s(10.0)); Some(((x, y, width, height), lines, line_h)) }, @@ -278,38 +427,47 @@ impl Display { // `skin.veil`,于是浅色主题下也罩一层 67% 的纯黑,比深色主题还重; // 圆角 10/11 也和别处的 8 对不上。 let mut quads = Vec::new(); - super::ui::surface::push_surface( + super::ui::surface::push_surface_in( &mut quads, (bx, by, box_w, box_h), - (size.width(), size.height()), + stage, + stage_radius, scale, &skin, super::ui::surface::Elevation::Modal, progress, ); - quads.push(UiQuad::solid( - bx, - footer_top, - box_w, - box_h - (footer_top - by), - 0.0, - super::ui::surface::fade(skin.surface, progress), - )); - quads.push(UiQuad::solid( - bx, - footer_top, - box_w, - s(1.0), - 0.0, - super::ui::surface::fade(skin.hairline, progress), - )); + let hairline = super::ui::surface::fade(skin.hairline, progress); + // 头部与页脚各一条 hairline,把"标题 / 内容 / 动作"分成三段。页脚 + // 不再整块填 surface——原型只有一条上边线,填色会让动作区看起来 + // 比内容区更重,而它其实是从属的。 + quads.push(UiQuad::solid(bx, by + s(HEAD_H), box_w, s(control::HAIRLINE), 0.0, hairline)); + quads.push(UiQuad::solid(bx, footer_top, box_w, s(control::HAIRLINE), 0.0, hairline)); + if teststate.is_some() { + quads.push(UiQuad::solid( + bx, + by + s(v.teststate_y.unwrap_or_default()), + box_w, + s(control::HAIRLINE), + 0.0, + hairline, + )); + } + + // 分组卡片:hairline 环 + card 内芯,两者由 `push_group` 合成成一个 + // 不透明填充。分开画会让描边色渗满整张卡(见该函数的注释)。 + for group in [conn_group, auth_group] { + super::ui::surface::push_group(&mut quads, group, scale, &skin, progress); + } + // 卡片内芯的实色。卡片里再嵌的东西(分段轨道)要拿它当底做合成。 + let group_fill = super::ui::surface::over(skin.card, skin.panel); if self.nebula_ssh_editor_hover == SshEditorHit::Close { quads.push(UiQuad::solid( close.0, close.1, close.2, close.3, - s(5.0), + s(radius::CHIP), skin.hover, )); } @@ -318,6 +476,26 @@ impl Display { destination, editor.field == SshEditorField::Destination, self.nebula_ssh_editor_hover == SshEditorHit::Destination, + // 地址校验失败时用 danger 描边:错误既在下方的 helper 里说清 + // 楚,也在出错的那个框上标出来,不让用户自己找是哪一行错了。 + if editor.error.is_some() { skin.danger } else { accent }, + &skin, + scale, + ); + input_quads( + &mut quads, + port, + editor.field == SshEditorField::Port, + self.nebula_ssh_editor_hover == SshEditorHit::Port, + accent, + &skin, + scale, + ); + input_quads( + &mut quads, + host_label, + editor.field == SshEditorField::Label, + self.nebula_ssh_editor_hover == SshEditorHit::Label, accent, &skin, scale, @@ -333,21 +511,22 @@ impl Display { scale, ); } - quads.push(UiQuad::solid( - auth_track.0 - s(1.0), - auth_track.1 - s(1.0), - auth_track.2 + s(2.0), - auth_track.3 + s(2.0), - s(7.0), + super::ui::surface::push_stroke( + &mut quads, + auth_track, + s(radius::CONTROL), + scale, skin.hairline, - )); + ); quads.push(UiQuad::solid( auth_track.0, auth_track.1, auth_track.2, auth_track.3, - s(6.0), - skin.surface, + s(radius::CONTROL), + // 轨道底同样要先跟卡片色合成成不透明的,否则上面那圈描边会渗满 + // 整条轨道(见 `surface::push_group`)。 + super::ui::surface::over(skin.surface, group_fill), )); for (mode, rect) in auth { let active = editor.auth == mode; @@ -358,8 +537,15 @@ impl Display { rect.1, rect.2, rect.3, - s(6.0), - if active { skin.hover_strong } else { skin.hover }, + s(radius::CHIP), + // 选中片就是 `panel`,两个主题共用一个 token——原型如此。 + // + // 前两轮我按"选中片必须比轨道亮"去分主题,那条规则本身是 + // 错的:深色下原型的选中片比轨道**暗** 18 级(panel 48,53,65 + // vs 轨道 66,71,83),读起来是按键被按进去;浅色下 panel 是 + // 纯白,比灰轨道亮,读起来是浮起来。方向相反但对比都成立, + // 而"同一个语义用同一个 token"才是可维护的那条线。 + if active { skin.panel } else { skin.hover }, )); } } @@ -370,25 +556,27 @@ impl Display { password_toggle.1, password_toggle.2, password_toggle.3, - s(6.0), + s(radius::CONTROL), skin.hover, )); } - quads.push(UiQuad::solid( - save_checkbox.0 - s(1.0), - save_checkbox.1 - s(1.0), - save_checkbox.2 + s(2.0), - save_checkbox.3 + s(2.0), - s(5.0), - skin.hairline, - )); + // 勾选后整块填 accent:白框白勾在深色底上和未勾选几乎读不出差别, + // 而这个开关决定密码存不存进凭据管理器,必须一眼看出状态。 + let checked = editor.save_password; + super::ui::surface::push_stroke( + &mut quads, + save_checkbox, + s(radius::CHIP), + scale, + if checked { accent } else { skin.hairline }, + ); quads.push(UiQuad::solid( save_checkbox.0, save_checkbox.1, save_checkbox.2, save_checkbox.3, - s(4.0), - skin.input, + s(radius::CHIP), + if checked { accent } else { skin.input }, )); } if show_keys { @@ -398,27 +586,31 @@ impl Display { add_private_key.1, add_private_key.2, add_private_key.3, - s(5.0), + s(radius::CHIP), skin.hover, )); } - if editor.private_keys.is_empty() && show_empty_key_state { - let empty = (destination.0, key_rows_y, field_w, s(40.0)); - quads.push(UiQuad::solid(empty.0, empty.1, empty.2, empty.3, s(6.0), skin.input)); + if editor.private_keys.is_empty() { + // 空态用虚线框:它是"这里可以放东西"的占位,不是一个已存在 + // 的条目,实线框会读成一行空数据。 + let empty = (ctl_x, key_rows_y, ctl_w, s(KEY_ROW_H)); dashed_border(&mut quads, empty, skin.hairline, scale); } for (index, row, remove) in &visible_keys { + super::ui::surface::push_stroke( + &mut quads, + *row, + s(radius::CONTROL), + scale, + skin.hairline, + ); quads.push(UiQuad::solid( row.0, row.1, row.2, row.3, - s(6.0), - if self.nebula_ssh_editor_hover == SshEditorHit::RemovePrivateKey(*index) { - skin.hover - } else { - skin.input - }, + s(radius::CONTROL), + skin.input, )); if self.nebula_ssh_editor_hover == SshEditorHit::RemovePrivateKey(*index) { quads.push(UiQuad::solid( @@ -426,7 +618,7 @@ impl Display { remove.1, remove.2, remove.3, - s(6.0), + s(radius::CHIP), skin.hover, )); } @@ -449,79 +641,79 @@ impl Display { SshTestState::Failed { .. } => Some(Rgba::new(248, 81, 73, 255)), SshTestState::Idle | SshTestState::Running { .. } => None, }; - if let Some(color) = status_dot { - quads.push(UiQuad::solid( - test.0 + test.2 + s(12.0), - footer_y + (test.3 - s(6.0)) * 0.5, - s(6.0), - s(6.0), - s(3.0), - color, - )); - } - - // Footer focus belongs to buttons, so leave the text caret in the two - // input fields only; otherwise it appears to edit a field while Enter - // is actually activating Test/Cancel/Save. - if editor.focus.current() <= usize::from(show_password) { - let caret_field = if editor.field == SshEditorField::Password && show_password { - SshEditorField::Password - } else { - SshEditorField::Destination - }; - let (caret_rect, caret_columns, selected) = match caret_field { - SshEditorField::Destination => ( - destination, - editor.destination.chars().count(), - editor.destination_selection.is_selected(), - ), - SshEditorField::Password => ( - password, - editor.password.chars().count(), - editor.password_selection.is_selected(), - ), - }; - // 选区需要持续可见;普通插入光标使用共享 500ms 相位,由 - // `chrome_editor_active` 的 8fps 时钟持续推进帧。 - if selected || super::caret_blink_on() { - draw_caret_quad( - &mut quads, - caret_rect, - caret_columns, - selected, - caret_field == SshEditorField::Password, - cell_w, - scale, - &skin, - ); + // 状态点挪进独立的状态条:它跟状态文字是一件事,之前贴在「测试连接」 + // 按钮右边,读起来像那个按钮自己的角标。 + if let Some(bar) = teststate { + let cy = bar.1 + cell_h * type_scale::SUPPORTING * 0.5; + if let Some(color) = status_dot { + quads.push(UiQuad::solid(bar.0, cy - s(3.0), s(6.0), s(6.0), s(3.0), color)); + } else if matches!(editor.test, SshTestState::Running { .. }) { + push_spinner(&mut quads, bar.0 + s(5.5), cy, s(5.5), spinner_phase(), accent); } } + + // Footer focus belongs to buttons, so leave the text caret in the input + // fields only; otherwise it appears to edit a field while Enter is + // actually activating Test/Cancel/Save. + let text_slots = if show_password { 4 } else { 3 }; + if editor.focus.current() < text_slots { + let caret_field = match editor.field { + SshEditorField::Password if !show_password => SshEditorField::Destination, + field => field, + }; + let caret_rect = match caret_field { + SshEditorField::Destination => destination, + SshEditorField::Port => port, + SshEditorField::Label => host_label, + SshEditorField::Password => password, + }; + // 光标、选区、命中三者共用 metrics 的起点与组件层的列换算,所以 + // 点在哪一格,光标就落在哪一格。 + super::ui::text_field::push_cursor( + &mut quads, + caret_rect.1, + caret_rect.3, + metrics.origin(caret_field), + &editor.display_text(caret_field), + editor.field_view(caret_field).1, + cell_w, + scale, + &skin, + ); + } if let Some((tooltip, ..)) = &status_tooltip { - quads.push(UiQuad::solid( - tooltip.0 - s(1.0), - tooltip.1 - s(1.0), - tooltip.2 + s(2.0), - tooltip.3 + s(2.0), - s(7.0), + super::ui::surface::push_stroke( + &mut quads, + *tooltip, + s(radius::CONTROL), + scale, skin.hairline, - )); + ); quads.push(UiQuad::solid( tooltip.0, tooltip.1, tooltip.2, tooltip.3, - s(6.0), + s(radius::CONTROL), skin.panel, )); } self.renderer.draw_ui(&size, &quads); let glyph_cache = &mut self.glyph_cache; + let support = type_scale::SUPPORTING; + let support_h = cell_h * support; + // 字段 label 在自己那一行里垂直居中。 + let label_y = |row_y: f32| row_y + (field_h - support_h) * 0.5; + // 输入框内的文字基线,左内边距与原型一致。 + let inner_y = |row_y: f32| row_y + (field_h - cell_h) * 0.5; + let inner_x = |rect: Rect| rect.0 + s(space::XS); + self.renderer.draw_ui_text( &size, - bx + pad, - by + s(20.0), - 1.15, + bx + s(space::M), + by + (s(HEAD_H) - cell_h * type_scale::DIALOG_TITLE) * 0.5, + type_scale::DIALOG_TITLE, skin.ink_strong, Flags::empty(), if editor.original_destination.is_some() { @@ -543,49 +735,152 @@ impl Display { "×", glyph_cache, ); - let destination_label = language.pick("连接地址", "Destination"); - let destination_label_y = destination.1 - cell_h - s(8.0); - self.renderer.draw_chrome_text( - &size, - destination.0, - destination_label_y, - skin.ink_dim, - destination_label, - glyph_cache, - ); - let destination_hint = editor.error.as_deref().unwrap_or(language.pick( - "支持 user@host,非 22 端口写 ssh://host:2222", - "Supports user@host; use ssh://host:2222 for a non-22 port", - )); - let destination_hint_scale = 0.72; + + // ── 分组标题 ─────────────────────────────────────────────── + for (head_y, icon, text) in [ + (v.conn_head_y, "\u{f233}", language.pick("连接", "Connection")), + (v.auth_head_y, "\u{f084}", language.pick("认证", "Authentication")), + ] { + // 图标和标题同色同层:它是标题的一部分,抢眼了反而把"这一组叫 + // 什么"推到第二位。 + self.renderer.draw_chrome_text( + &size, + field_x, + by + s(head_y) - (cell_h - cell_h * type_scale::SECTION_CAPTION) * 0.5, + skin.ink_dim, + icon, + glyph_cache, + ); + self.renderer.draw_ui_text( + &size, + field_x + cell_w + s(space::XS), + by + s(head_y), + type_scale::SECTION_CAPTION, + skin.ink_dim, + Flags::empty(), + text, + glyph_cache, + ); + } + + // ── 连接组的三个字段 ─────────────────────────────────────── + let dest_label = language.pick("地址", "Address"); self.renderer.draw_ui_text( &size, - destination.0, - destination.1 + destination.3 + s(6.0), - destination_hint_scale, - if editor.error.is_some() { - if skin.is_light { Rgb::new(207, 34, 46) } else { Rgb::new(248, 81, 73) } - } else { - skin.ink_dim - }, + field_x, + label_y(by + s(v.dest_y)), + support, + skin.ink_dim, Flags::empty(), - destination_hint, + dest_label, + glyph_cache, + ); + // 必填星紧跟标签,而不是塞在标签文字里——它是状态标记,不是名字的 + // 一部分,颜色也必须是 danger 才读得出"缺了会拦你"。 + self.renderer.draw_ui_text( + &size, + field_x + text_width(dest_label) * support + s(2.0), + label_y(by + s(v.dest_y)), + support, + rgb_of(skin.danger), + Flags::empty(), + "*", glyph_cache, ); self.renderer.draw_chrome_text( &size, - destination.0 + s(12.0), - destination.1 + (field_h - cell_h) / 2.0, + inner_x(destination), + inner_y(by + s(v.dest_y)), if editor.destination.is_empty() { skin.ink_faint } else { skin.ink }, if editor.destination.is_empty() { "user@example.com" } else { &editor.destination }, glyph_cache, ); - self.renderer.draw_chrome_text( + // helper 缩进到控件列,读起来是挂在地址框下面的注解,而不是新的一行。 + let hint = editor.error.as_deref().unwrap_or(language.pick( + "支持 user@host,也可粘贴 ssh://host:2222", + "Supports user@host, or paste ssh://host:2222", + )); + self.renderer.draw_ui_text( &size, - destination.0, - auth_y - cell_h - s(8.0), + ctl_x, + by + s(v.helper_y), + support, + if editor.error.is_some() { rgb_of(skin.danger) } else { skin.ink_faint }, + Flags::empty(), + hint, + glyph_cache, + ); + + for (row_y, text, rect, value, placeholder, field) in [ + ( + v.port_y, + language.pick("端口", "Port"), + port, + &editor.port, + "22", + SshEditorField::Port, + ), + ( + v.label_y, + language.pick("标签", "Label"), + host_label, + &editor.label, + language.pick("可选,列表里显示这个名字", "Optional, shown in the list"), + SshEditorField::Label, + ), + ] { + self.renderer.draw_ui_text( + &size, + field_x, + label_y(by + s(row_y)), + support, + skin.ink_dim, + Flags::empty(), + text, + glyph_cache, + ); + // 有内容时起点取 metrics——光标和命中用的是同一个数,三者才不会 + // 在全角字符或居中字段上互相漂移。占位文案不参与定位,它自己按 + // 同样的规则摆一次就行(端口居中,其余靠左)。 + let (shown, x) = if value.is_empty() { + let x = if field == SshEditorField::Port { + rect.0 + (rect.2 - text_width(placeholder)).max(0.0) * 0.5 + } else { + inner_x(rect) + }; + (placeholder, x) + } else { + (value.as_str(), metrics.origin(field)) + }; + self.renderer.draw_chrome_text( + &size, + x, + inner_y(by + s(row_y)), + if value.is_empty() { skin.ink_faint } else { skin.ink }, + shown, + glyph_cache, + ); + } + // 端口的默认值写在框外:默认 22 是事实,不该占着输入框冒充用户填过。 + self.renderer.draw_ui_text( + &size, + port.0 + port.2 + s(space::XS), + label_y(by + s(v.port_y)), + support, + skin.ink_faint, + Flags::empty(), + language.pick("默认 22", "default 22"), + glyph_cache, + ); + + self.renderer.draw_ui_text( + &size, + field_x, + label_y(by + s(v.auth_y)), + support, skin.ink_dim, - language.pick("认证方式", "Authentication"), + Flags::empty(), + language.pick("方式", "Method"), glyph_cache, ); let auth_labels = if language == super::UiLanguage::ZhCn { @@ -605,6 +900,17 @@ impl Display { } if show_password { + // 密码的 label 和其它字段走同一条左列,helper 只负责框内内容。 + self.renderer.draw_ui_text( + &size, + field_x, + label_y(by + s(v.password_y)), + support, + skin.ink_dim, + Flags::empty(), + language.pick("密码", "Password"), + glyph_cache, + ); draw_password_text( &mut self.renderer, glyph_cache, @@ -625,73 +931,100 @@ impl Display { ); } if show_keys { - self.renderer.draw_chrome_text( + // 私钥 label 顶对齐第一行,不是整列居中——列会随条目变高,居中 + // 会让标签越飘越低,读不出它管的是哪一段。 + self.renderer.draw_ui_text( &size, - destination.0, - key_header_y, + field_x, + label_y(by + s(v.keys_y)), + support, skin.ink_dim, + Flags::empty(), language.pick("私钥", "Private keys"), glyph_cache, ); - self.renderer.draw_chrome_text( + // 「添加私钥」是链接式按钮,不是实心键:它开的是系统文件对话框, + // 分量应当低于底部的保存/取消。 + self.renderer.draw_ui_text( &size, - add_private_key.0 - + (add_private_key.2 - text_width(language.pick("+ 添加私钥", "+ Add key"))) - * 0.5, - add_private_key.1 + (add_private_key.3 - cell_h) / 2.0, - skin.ink, - language.pick("+ 添加私钥", "+ Add key"), + add_private_key.0 + s(space::XXS), + add_private_key.1 + (add_private_key.3 - support_h) * 0.5, + support, + if self.nebula_ssh_editor_hover == SshEditorHit::AddPrivateKey { + skin.ink_strong + } else { + skin.accent + }, + Flags::empty(), + add_key_label, glyph_cache, ); - if editor.private_keys.is_empty() && show_empty_key_state { - self.renderer.draw_chrome_text( + if editor.private_keys.is_empty() { + // 空态文案必须按框宽截断:中文说明比看上去长,不截会一路画到 + // 虚线框外面,读起来像"这行字漏出来了"。 + let text = language.pick( + "未指定,将用 IdentityFile 与默认 id_* 私钥", + "None; IdentityFile and default id_* keys are used", + ); + let cols = cols_that_fit(ctl_w - s(space::XS) * 2.0, cell_w, support); + self.renderer.draw_ui_text( &size, - destination.0 + s(12.0), - key_rows_y + (s(40.0) - cell_h) * 0.5, + ctl_x + s(space::XS), + key_rows_y + (s(KEY_ROW_H) - support_h) * 0.5, + support, skin.ink_faint, - language.pick( - "未指定;将使用 IdentityFile 和默认 id_* 私钥", - "None specified; IdentityFile and default id_* keys will be used", - ), + Flags::empty(), + &truncate_cols(text, cols), glyph_cache, ); } for (index, row, remove) in &visible_keys { - let label = path_tail(&editor.private_keys[*index], 64); + let max_chars = (((row.2 - s(34.0)) / cell_w).floor() as usize).max(8); + let shown = path_tail(&editor.private_keys[*index], max_chars); self.renderer.draw_chrome_text( &size, - row.0 + s(10.0), - row.1 + (row.3 - cell_h) / 2.0, + row.0 + s(space::XS), + row.1 + (row.3 - cell_h) * 0.5, skin.ink, - &label, + &shown, glyph_cache, ); self.renderer.draw_chrome_text( &size, remove.0 + (remove.2 - text_width("×")) * 0.5, - remove.1 + (remove.3 - cell_h) / 2.0, - skin.icon, + remove.1 + (remove.3 - cell_h) * 0.5, + if self.nebula_ssh_editor_hover == SshEditorHit::RemovePrivateKey(*index) { + rgb_of(skin.danger) + } else { + skin.icon + }, "×", glyph_cache, ); } - } else if editor.auth == SshAuthMode::KeyboardInteractive { - self.renderer.draw_chrome_text( - &size, - destination.0, - content_y, - skin.ink_dim, - language.pick( - "仅响应服务器的 keyboard-interactive / MFA 提示。", - "Respond only to server keyboard-interactive / MFA prompts.", - ), - glyph_cache, - ); + } else if !show_password { + // 自动 / 交互式:这两种模式没有要填的东西,那就把"它会做什么" + // 讲清楚,而不是留一片空白让人怀疑没加载完。 + for (i, line) in wrap_status_tooltip(note_text, note_cols).into_iter().enumerate() { + self.renderer.draw_ui_text( + &size, + field_x, + by + s(v.note_y) + i as f32 * support_h, + support, + skin.ink_dim, + Flags::empty(), + &line, + glyph_cache, + ); + } } for (rect, label, ink) in [ (test, test_action, skin.ink), (cancel, cancel_action, skin.ink), - (primary, primary_action, Rgb::new(8, 12, 20)), + // 主按钮的字必须跟着 accent 走。写死的近黑在深色主题上碰巧成立 + // (accent 是亮蓝),到浅色主题就是黑字压深灰 accent——对比度掉到 + // 读不出来。ink_on_accent 就是为这件事存在的。 + (primary, primary_action, skin.ink_on_accent), ] { self.renderer.draw_chrome_text( &size, @@ -708,7 +1041,9 @@ impl Display { SshTestState::Running { .. } => ( Some(language.pick("正在连接…", "Connecting...").to_owned()), skin.ink_faint, - false, + // 转圈指示器和成功/失败的圆点占同一格,文字的左缩进因此一致 + // ——不然状态在三态之间切换时,文字会横着跳一下。 + true, ), SshTestState::Ok { elapsed_ms } => ( Some(format!( @@ -724,16 +1059,20 @@ impl Display { true, ), }; - if let Some(status) = status { - let status_x = test.0 + test.2 + s(if has_dot { 24.0 } else { 12.0 }); - let max_cols = (((cancel.0 - s(12.0) - status_x) / cell_w).floor() as isize).max(0); + if let (Some(status), Some(bar)) = (status, teststate) { + // 状态条独占一行,失败原因终于有地方完整显示,不再被挤在两个 + // 按钮之间截断成一句读不懂的半句话。 + let dot_w = if has_dot { s(space::S) } else { 0.0 }; + let max_cols = (((bar.2 - dot_w) / cell_w).floor() as isize).max(0); if max_cols > 0 { let shown = truncate_tab_label(&status, max_cols as usize); - self.renderer.draw_chrome_text( + self.renderer.draw_ui_text( &size, - status_x, - footer_y + (test.3 - cell_h) * 0.5, + bar.0 + dot_w, + bar.1, + support, status_ink, + Flags::empty(), &shown, glyph_cache, ); @@ -793,9 +1132,23 @@ fn wrap_status_tooltip(value: &str, budget: usize) -> Vec { #[cfg(test)] mod tests { - use super::{ssh_editor_vertical_layout, wrap_status_tooltip}; + use super::{CTL_H, DESIGN_CELL_W, editor_layout, ui_scale, wrap_status_tooltip}; use unicode_width::UnicodeWidthChar; + #[test] + fn panel_grows_with_the_font_so_padding_survives() { + // 字号正好是设计稿的基准:一比一,不放大。 + assert!((ui_scale(DESIGN_CELL_W) - 1.0).abs() < f32::EPSILON); + // 放大打折:字大 44%,盒子只大 20%(平方根)。线性跟随会让这张 + // 添加主机的表单压过它该有的分量。 + assert!((ui_scale(DESIGN_CELL_W * 1.44) - 1.2).abs() < 1e-5); + assert!(ui_scale(DESIGN_CELL_W * 1.25) < 1.25); + // 字更小时不跟着缩——32px 的控件缩下去就低于点击区了。 + assert!((ui_scale(DESIGN_CELL_W * 0.5) - 1.0).abs() < f32::EPSILON); + // 超大字号封顶,免得卡片撑出窗口。 + assert!((ui_scale(DESIGN_CELL_W * 5.0) - 1.3).abs() < f32::EPSILON); + } + #[test] fn ssh_status_tooltip_keeps_the_complete_utf8_error_within_columns() { let source = "认证失败:私钥路径 C:/用户/密钥/id_ed25519 不可用"; @@ -807,17 +1160,43 @@ mod tests { } #[test] - fn ssh_editor_groups_keep_crap_proximity_rhythm() { - let cell_h = 15.0; - let layout = ssh_editor_vertical_layout(true, true, 0, cell_h); - let auth_bottom = layout.auth_y + 32.0; - let password_label_y = layout.password_y - cell_h - 8.0; - let save_bottom = layout.save_toggle_y + 28.0; + fn editor_layout_stacks_fields_without_overlap() { + let l = editor_layout(true, true, 1, 2, true, 15.0); + // 连接组的三行按视觉顺序推进,helper 夹在地址和端口之间。 + assert!(l.dest_y + CTL_H <= l.helper_y); + assert!(l.helper_y < l.port_y); + assert!(l.port_y + CTL_H <= l.label_y); + // 两个分组不重叠,且认证组在连接组下面。 + assert!(l.conn_group.0 + l.conn_group.1 <= l.auth_group.0); + // 认证组内部的字段都落在组框里。 + let auth_bottom = l.auth_group.0 + l.auth_group.1; + for y in [l.auth_y, l.password_y, l.save_y, l.keys_y, l.add_key_y] { + assert!(y >= l.auth_group.0 && y <= auth_bottom, "字段 {y} 越出认证组"); + } + // 底部在所有内容之下,卡片高度包住页脚。 + assert!(l.teststate_y.unwrap() >= auth_bottom); + assert!(l.footer_y >= l.teststate_y.unwrap()); + assert_eq!(l.height, l.footer_y + l.footer_h); + } - assert!((password_label_y - auth_bottom - 18.0).abs() < f32::EPSILON); - assert!((layout.key_header_y - save_bottom - 24.0).abs() < f32::EPSILON); - assert!(layout.key_rows_y > layout.key_header_y); - assert!(layout.desired_h > layout.key_rows_y + 42.0); + #[test] + fn editor_layout_shrinks_when_the_auth_mode_needs_no_fields() { + let full = editor_layout(true, true, 1, 4, false, 15.0); + // keyboard-interactive:没有密码框也没有私钥列表,只剩一句说明。 + let bare = editor_layout(false, false, 1, 0, false, 15.0); + assert!(bare.height < full.height); + // 说明文案折成两行时,认证组要跟着长高——否则文字会顶穿组框下缘。 + let two_lines = editor_layout(false, false, 2, 0, false, 15.0); + assert!(two_lines.auth_group.1 > bare.auth_group.1); + assert!(two_lines.note_y + 15.0 * 2.0 * 0.8 <= two_lines.auth_group.0 + two_lines.auth_group.1); + } + + #[test] + fn editor_layout_grows_with_the_private_key_list() { + let one = editor_layout(false, true, 1, 1, false, 15.0); + let four = editor_layout(false, true, 1, 4, false, 15.0); + assert!(four.height > one.height); + assert!(four.add_key_y > one.add_key_y); } } @@ -831,21 +1210,20 @@ fn input_quads( scale: f32, ) { let s = |value: f32| value * scale; - quads.push(UiQuad::solid( - rect.0 - s(1.0), - rect.1 - s(1.0), - rect.2 + s(2.0), - rect.3 + s(2.0), - s(7.0), + super::ui::surface::push_stroke( + quads, + rect, + s(radius::CONTROL), + scale, if active { Rgba::new(accent.r, accent.g, accent.b, if skin.is_light { 118 } else { 136 }) } else { skin.hairline }, - )); - quads.push(UiQuad::solid(rect.0, rect.1, rect.2, rect.3, s(6.0), skin.input)); + ); + quads.push(UiQuad::solid(rect.0, rect.1, rect.2, rect.3, s(radius::CONTROL), skin.input)); if hovered && !active { - quads.push(UiQuad::solid(rect.0, rect.1, rect.2, rect.3, s(6.0), skin.hover)); + quads.push(UiQuad::solid(rect.0, rect.1, rect.2, rect.3, s(radius::CONTROL), skin.hover)); } } @@ -863,18 +1241,13 @@ fn button_quads( ) { let s = |value: f32| value * scale; for rect in [test, cancel] { - quads.push(UiQuad::solid( - rect.0 - s(1.0), - rect.1 - s(1.0), - rect.2 + s(2.0), - rect.3 + s(2.0), - s(7.0), - skin.hairline, - )); + super::ui::surface::push_stroke(quads, rect, s(radius::CONTROL), scale, skin.hairline); } - let test_focus = if shows_password { 2 } else { 1 }; - let cancel_focus = if shows_password { 3 } else { 2 }; - let primary_focus = if shows_password { 4 } else { 3 }; + // 焦点序号跟 `ssh_editor_next_field` 的 Tab 顺序走:地址、端口、标签、 + // [密码] 之后才轮到这三个按钮。 + let test_focus = if shows_password { 4 } else { 3 }; + let cancel_focus = test_focus + 1; + let primary_focus = test_focus + 2; for rect in [ (focus == test_focus).then_some(test), (focus == cancel_focus).then_some(cancel), @@ -888,29 +1261,45 @@ fn button_quads( rect.1 - s(2.0), rect.2 + s(4.0), rect.3 + s(4.0), - s(8.0), + s(radius::OVERLAY), skin.accent_soft, )); } - quads.push(UiQuad::solid(test.0, test.1, test.2, test.3, s(6.0), skin.surface)); - quads.push(UiQuad::solid(cancel.0, cancel.1, cancel.2, cancel.3, s(6.0), skin.hover)); - quads.push(UiQuad::solid(primary.0, primary.1, primary.2, primary.3, s(6.0), accent)); - if hover == SshEditorHit::Test { - quads.push(UiQuad::solid(test.0, test.1, test.2, test.3, s(6.0), skin.hover)); - } - if hover == SshEditorHit::Cancel { - quads.push(UiQuad::solid(cancel.0, cancel.1, cancel.2, cancel.3, s(6.0), skin.hover_strong)); - } - if hover == SshEditorHit::Primary { - quads.push(UiQuad::solid( - primary.0, - primary.1, - primary.2, - primary.3, - s(6.0), - Rgba::new(accent.r, accent.g, accent.b, 224), - )); - } + let r = s(radius::CONTROL); + // 「测试连接」和「取消」是同一档次的次要动作,同底同描边。此前取消用了更 + // 深的 hover,读起来像它比测试更重要——而真正的主动作是保存。 + // + // 底色跟 panel 合成成不透明的:surface 只有 6–8% 不透明度,盖不住 + // `push_stroke` 那个实心环,描边色会渗满整个按钮(见 `surface::push_group`)。 + // hover 是**换**底色不是叠一层——叠加会得到 surface+hover 的和,比原型深。 + let secondary = |hovered: bool| { + super::ui::surface::over(if hovered { skin.hover } else { skin.surface }, skin.panel) + }; + quads.push(UiQuad::solid( + test.0, + test.1, + test.2, + test.3, + r, + secondary(hover == SshEditorHit::Test), + )); + quads.push(UiQuad::solid( + cancel.0, + cancel.1, + cancel.2, + cancel.3, + r, + secondary(hover == SshEditorHit::Cancel), + )); + // 主按钮悬浮是**提亮** 8%,跟原型的 `filter: brightness(1.08)` 一致。 + // 此前叠一层 88% 的同色,等于把它往底色方向拉暗——悬浮反而显得更沉。 + let primary_fill = if hover == SshEditorHit::Primary { + let lift = |c: u8| (c as f32 * 1.08).min(255.0) as u8; + Rgba::new(lift(accent.r), lift(accent.g), lift(accent.b), accent.a) + } else { + accent + }; + quads.push(UiQuad::solid(primary.0, primary.1, primary.2, primary.3, r, primary_fill)); } /// UiQuad 没有虚线描边 primitive;这里用短线段拼一圈,只用于私钥空态。 @@ -935,41 +1324,6 @@ fn dashed_border(quads: &mut Vec, rect: Rect, color: Rgba, scale: f32) { } } -fn draw_caret_quad( - quads: &mut Vec, - rect: Rect, - columns: usize, - selected: bool, - password: bool, - cell_w: f32, - scale: f32, - skin: &theme::Skin, -) { - let s = |value: f32| value * scale; - let right_pad = if password { s(48.0) } else { s(10.0) }; - if selected && columns > 0 { - let width = (columns as f32 * cell_w).min(rect.2 - s(12.0) - right_pad); - quads.push(UiQuad::solid( - rect.0 + s(10.0), - rect.1 + s(7.0), - width + s(4.0), - rect.3 - s(14.0), - s(4.0), - skin.accent_soft, - )); - } else { - let x = (rect.0 + s(12.0) + columns as f32 * cell_w).min(rect.0 + rect.2 - right_pad); - quads.push(UiQuad::solid( - x, - rect.1 + s(10.0), - s(1.5).max(1.0), - rect.3 - s(20.0), - 0.0, - Rgba::new(skin.ink_strong.r, skin.ink_strong.g, skin.ink_strong.b, 235), - )); - } -} - #[allow(clippy::too_many_arguments)] fn draw_password_text( renderer: &mut Renderer, @@ -990,16 +1344,8 @@ fn draw_password_text( hover: SshEditorHit, ) { let s = |value: f32| value * scale; - renderer.draw_chrome_text( - size, - password.0, - password.1 - cell_h - s(8.0), - skin.ink_dim, - language.pick("密码", "Password"), - glyph_cache, - ); let masked = if editor.password.is_empty() { - language.pick("连接时询问", "Ask when connecting").to_owned() + language.pick("留空则连接时询问", "Leave blank to be asked").to_owned() } else if editor.show_password { editor.password.clone() } else { @@ -1007,7 +1353,7 @@ fn draw_password_text( }; renderer.draw_chrome_text( size, - password.0 + s(12.0), + password.0 + s(space::XS), password.1 + (field_h - cell_h) / 2.0, if editor.password.is_empty() { skin.ink_faint } else { skin.ink }, &masked, @@ -1024,9 +1370,9 @@ fn draw_password_text( ); renderer.draw_chrome_text( size, - save_toggle.0 + s(28.0), + save_toggle.0 + s(24.0), save_toggle.1 + (save_toggle.3 - cell_h) / 2.0, - skin.ink, + skin.ink_dim, save_label, glyph_cache, ); @@ -1035,7 +1381,9 @@ fn draw_password_text( size, save_checkbox.0 + (save_checkbox.2 - cell_w) * 0.5, save_checkbox.1 + (save_checkbox.3 - cell_h) / 2.0, - skin.icon_hover, + // 勾画在 accent 上,所以用 ink_on_accent——深色主题的强调色偏亮, + // 白勾会糊在里面。 + skin.ink_on_accent, "", glyph_cache, ); diff --git a/nebula_app/src/display/ssh_ui.rs b/nebula_app/src/display/ssh_ui.rs index 42a140d..cb9f78a 100644 --- a/nebula_app/src/display/ssh_ui.rs +++ b/nebula_app/src/display/ssh_ui.rs @@ -4,13 +4,17 @@ //! rule explicit: a pending deletion owns credential cleanup and only Undo may //! disarm it. -use super::text_input::SelectAllState; +use super::ui::text_field::TextCursor; use crate::ssh_profiles::SshAuthMode; use std::path::PathBuf; /// How long a destructive SSH action stays reversible in the in-app bar. pub const SSH_DELETE_UNDO_DURATION: std::time::Duration = std::time::Duration::from_secs(8); +/// 密码框的掩码字符。渲染和点击命中共用它:命中要按**看得见的那串**算列, +/// 否则密码里有一个全角字符,光标就会落到与手指差好几格的地方。 +pub const PASSWORD_MASK: &str = "•"; + pub fn auth_sections(mode: SshAuthMode) -> (bool, bool) { match mode { SshAuthMode::Auto => (true, true), @@ -30,6 +34,61 @@ pub fn push_private_key(keys: &mut Vec, path: PathBuf) -> bool { } } +/// 把一个地址串拆成「不含端口的地址」和「端口」,喂给编辑器的两个输入框。 +/// +/// 端口是 22 或根本没写时返回空串——空的端口框读作"用默认值",比预填一个 +/// `22` 好:预填会让人以为这是自己设的,删掉反而不知道会发生什么。 +/// +/// 端口位置写了非数字(`host:abc`)时整串原样留在地址里,交给地址校验去报 +/// 错。悄悄吞掉那一截,用户会看着自己粘进来的东西凭空少了一半。 +pub fn split_destination_port(destination: &str) -> (String, String) { + let trimmed = destination.trim(); + let (scheme, rest) = match trimmed.strip_prefix("ssh://") { + Some(rest) => ("ssh://", rest), + None => ("", trimmed), + }; + let (user, host_port) = match rest.rsplit_once('@') { + Some((user, host_port)) => (Some(user), host_port), + None => (None, rest), + }; + + let (host, port) = if let Some(after) = host_port.strip_prefix('[') { + // IPv6 字面量:`[::1]:22`,中括号里的冒号属于地址。 + match after.split_once(']') { + Some((host, suffix)) => { + (format!("[{host}]"), suffix.strip_prefix(':').unwrap_or_default()) + }, + None => (host_port.to_owned(), ""), + } + } else if let Some((host, port)) = host_port.rsplit_once(':') { + // 没加中括号的裸 IPv6 不能按 host:port 拆——最后一段是地址的一部分。 + if host.contains(':') { (host_port.to_owned(), "") } else { (host.to_owned(), port) } + } else { + (host_port.to_owned(), "") + }; + + if !port.is_empty() && port.parse::().is_err() { + return (trimmed.to_owned(), String::new()); + } + let mut address = String::from(scheme); + if let Some(user) = user { + address.push_str(user); + address.push('@'); + } + address.push_str(&host); + (address, if port == "22" { String::new() } else { port.to_owned() }) +} + +/// 拼回存盘用的地址串。地址框里自带的端口优先——用户刚粘进去的那个才是意图, +/// 端口框里的可能是上一台主机的残留。 +pub fn join_destination_port(address: &str, port: &str) -> String { + let (base, embedded) = split_destination_port(address); + let port = if embedded.is_empty() { port.trim() } else { embedded.as_str() }; + // 默认端口不写进地址:给每台主机都缀上 `:22` 只是噪音,而且会让同一台 + // 机器出现 `host` 和 `host:22` 两条记录、各自一份凭据。 + if port.is_empty() || port == "22" { base } else { format!("{base}:{port}") } +} + #[derive(Debug)] pub(super) struct SshDeleteUndo { pub(super) host: String, @@ -58,6 +117,11 @@ impl Drop for SshDeleteUndo { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SshEditorField { Destination, + /// 端口。数据上仍然属于 destination(`host:2222`),拆成独立字段只是 + /// 因为把端口埋在地址串里,用户既看不见默认值也不知道能改。 + Port, + /// 列表里显示的名字。空则回落到地址本身。 + Label, Password, } @@ -66,6 +130,8 @@ pub enum SshEditorHit { None, Close, Destination, + Port, + Label, Password, PasswordToggle, Auth(SshAuthMode), @@ -101,6 +167,10 @@ pub struct SshHostEditor { /// Destination before editing, when this modal was opened from a row. pub original_destination: Option, pub destination: String, + /// 端口输入框的内容。空 = 用默认 22,保存时不写进地址串。 + pub port: String, + /// 列表显示名。空 = 显示地址。 + pub label: String, pub password: String, pub save_password: bool, pub show_password: bool, @@ -111,14 +181,105 @@ pub struct SshHostEditor { pub error: Option, /// 「测试连接」当前状态;见 [`SshTestState`]。 pub test: SshTestState, - pub(super) destination_selection: SelectAllState, - pub(super) password_selection: SelectAllState, + pub(super) destination_cursor: TextCursor, + pub(super) port_cursor: TextCursor, + pub(super) label_cursor: TextCursor, + pub(super) password_cursor: TextCursor, +} + +impl SshHostEditor { + /// 某个字段的文本与光标。插入、退格、全选、点击定位对四个字段的处理完全 + /// 一样,集中到这里之后,再加字段只需要补这一个 match,而不是把同样的分支 + /// 在五个方法里各抄一遍——那种写法漏一处就是"某个框打不出字"。 + pub(super) fn field_mut(&mut self, field: SshEditorField) -> (&mut String, &mut TextCursor) { + match field { + SshEditorField::Destination => (&mut self.destination, &mut self.destination_cursor), + SshEditorField::Port => (&mut self.port, &mut self.port_cursor), + SshEditorField::Label => (&mut self.label, &mut self.label_cursor), + SshEditorField::Password => (&mut self.password, &mut self.password_cursor), + } + } + + /// 只读版本,给渲染与命中用。 + pub(super) fn field_view(&self, field: SshEditorField) -> (&str, &TextCursor) { + match field { + SshEditorField::Destination => (&self.destination, &self.destination_cursor), + SshEditorField::Port => (&self.port, &self.port_cursor), + SshEditorField::Label => (&self.label, &self.label_cursor), + SshEditorField::Password => (&self.password, &self.password_cursor), + } + } + + pub(super) fn active_field(&mut self) -> (&mut String, &mut TextCursor) { + self.field_mut(self.field) + } + + pub(super) fn active_text(&self) -> &str { + self.field_view(self.field).0 + } + + /// 清掉全部四个字段的选区,但保留各自的光标位置。切字段时用:选区是 + /// "此刻正在操作的一段",跨字段留着它只会让人以为那边还选着东西。 + pub(super) fn clear_selections(&mut self) { + self.destination_cursor.clear_selection(); + self.port_cursor.clear_selection(); + self.label_cursor.clear_selection(); + self.password_cursor.clear_selection(); + } + + /// 距文字起点 `offset_x` 像素处落在第几个字符的缝隙上。 + pub(super) fn index_at(&self, field: SshEditorField, offset_x: f32, cell_w: f32) -> usize { + let text = self.field_view(field).0; + if field == SshEditorField::Password && !self.show_password { + let masked = PASSWORD_MASK.repeat(text.chars().count()); + return super::ui::text_field::index_at(&masked, offset_x, cell_w); + } + super::ui::text_field::index_at(text, offset_x, cell_w) + } + + /// 实际画在框里的那串字,供渲染和光标定位共用。空串时返回空——占位文案 + /// 不参与光标定位,否则光标会跑到"留空则连接时询问"的末尾去。 + pub(super) fn display_text(&self, field: SshEditorField) -> String { + let text = self.field_view(field).0; + if field == SshEditorField::Password && !self.show_password { + return PASSWORD_MASK.repeat(text.chars().count()); + } + text.to_owned() + } +} + +/// 输入框里文字的排布,由渲染写入、命中读取。 +/// +/// 光标要落在字符缝隙上,就必须和文字用同一个起点和同一个格宽。两边各算一次 +/// 的写法在全角字符和居中字段上必然漂移,而漂移几像素恰恰是最难被报告、也 +/// 最伤手感的那类问题——所以这里让绘制把算好的值交出来,命中直接用。 +#[derive(Debug, Clone, Copy, Default)] +pub struct SshFieldMetrics { + pub destination_x: f32, + pub port_x: f32, + pub label_x: f32, + pub password_x: f32, + /// 一列的推进宽度(物理像素)。 + pub cell_w: f32, +} + +impl SshFieldMetrics { + pub fn origin(&self, field: SshEditorField) -> f32 { + match field { + SshEditorField::Destination => self.destination_x, + SshEditorField::Port => self.port_x, + SshEditorField::Label => self.label_x, + SshEditorField::Password => self.password_x, + } + } } #[derive(Debug, Clone)] pub struct SshEditorRects { pub close: (f32, f32, f32, f32), pub destination: (f32, f32, f32, f32), + pub port: (f32, f32, f32, f32), + pub label: (f32, f32, f32, f32), pub password: (f32, f32, f32, f32), pub password_toggle: (f32, f32, f32, f32), pub auth: [(SshAuthMode, (f32, f32, f32, f32)); 4], @@ -130,11 +291,26 @@ pub struct SshEditorRects { pub test_status: (f32, f32, f32, f32), pub primary: (f32, f32, f32, f32), pub cancel: (f32, f32, f32, f32), + pub metrics: SshFieldMetrics, +} + +impl SshEditorRects { + /// 输入框矩形 + 文字起点,供点击定位用。返回 `None` 表示这个 hit 不是 + /// 可编辑字段。 + pub fn field_of(&self, hit: SshEditorHit) -> Option<(SshEditorField, (f32, f32, f32, f32))> { + match hit { + SshEditorHit::Destination => Some((SshEditorField::Destination, self.destination)), + SshEditorHit::Port => Some((SshEditorField::Port, self.port)), + SshEditorHit::Label => Some((SshEditorField::Label, self.label)), + SshEditorHit::Password => Some((SshEditorField::Password, self.password)), + _ => None, + } + } } #[cfg(test)] mod tests { - use super::{auth_sections, push_private_key}; + use super::{auth_sections, join_destination_port, push_private_key, split_destination_port}; use crate::ssh_profiles::SshAuthMode; use std::path::PathBuf; @@ -154,4 +330,49 @@ mod tests { assert!(push_private_key(&mut keys, PathBuf::from(r"C:\Keys\second"))); assert_eq!(keys, vec![PathBuf::from(r"C:\Keys\first"), PathBuf::from(r"C:\Keys\second")]); } + + #[test] + fn destination_splits_into_address_and_port() { + assert_eq!( + split_destination_port("dev@example.com:2222"), + ("dev@example.com".to_owned(), "2222".to_owned()) + ); + // 默认端口不回填到端口框。 + assert_eq!( + split_destination_port("dev@example.com:22"), + ("dev@example.com".to_owned(), String::new()) + ); + assert_eq!( + split_destination_port("ssh://dev@example.com:2222"), + ("ssh://dev@example.com".to_owned(), "2222".to_owned()) + ); + assert_eq!( + split_destination_port("example.com"), + ("example.com".to_owned(), String::new()) + ); + } + + #[test] + fn destination_split_keeps_ipv6_and_bad_ports_intact() { + assert_eq!( + split_destination_port("dev@[fe80::1]:2222"), + ("dev@[fe80::1]".to_owned(), "2222".to_owned()) + ); + // 裸 IPv6 的末段是地址,不是端口。 + assert_eq!(split_destination_port("fe80::1"), ("fe80::1".to_owned(), String::new())); + // 端口位置不是数字:整串留在地址里让校验去报错,不能悄悄截掉。 + assert_eq!( + split_destination_port("dev@example.com:abc"), + ("dev@example.com:abc".to_owned(), String::new()) + ); + } + + #[test] + fn destination_join_prefers_the_port_typed_into_the_address() { + assert_eq!(join_destination_port("dev@example.com", "2222"), "dev@example.com:2222"); + assert_eq!(join_destination_port("dev@example.com", ""), "dev@example.com"); + assert_eq!(join_destination_port("dev@example.com", "22"), "dev@example.com"); + // 地址框里粘进来的端口压过端口框里的旧值。 + assert_eq!(join_destination_port("dev@example.com:2022", "2222"), "dev@example.com:2022"); + } } diff --git a/nebula_app/src/display/ui/guardrails.rs b/nebula_app/src/display/ui/guardrails.rs index a80fe52..6fab8b2 100644 --- a/nebula_app/src/display/ui/guardrails.rs +++ b/nebula_app/src/display/ui/guardrails.rs @@ -191,7 +191,7 @@ mod tests { 这正是「配色/圆角改不干净」的结构性原因。", "radius::OVERLAY * scale(浮层)/ radius::CONTROL * scale(控件)\n\ / radius::CHIP * scale(chip 键帽),或直接用 surface::push_* 配方", - 94, + 69, hits, ); } @@ -215,7 +215,7 @@ mod tests { "手写描边普遍内外用同一个半径,圆角处描边会变粗——「边缘发毛」的\n\ 来源之一。push_stroke 让外半径 = 内半径 + 描边宽,内外弧同心。", "surface::push_stroke(quads, rect, corner, scale, sk.hairline)", - 56, + 47, hits, ); } @@ -225,8 +225,16 @@ mod tests { let mut hits = Vec::new(); for path in source_files() { let Ok(src) = std::fs::read_to_string(&path) else { continue }; - for (index, line) in src.lines().enumerate() { - if line.contains("UiQuad::glow(") { + let lines: Vec<&str> = src.lines().collect(); + for (index, line) in lines.iter().enumerate() { + if !line.contains("UiQuad::glow(") { + continue; + } + // 星云品牌位的粒子/脉冲本来就该发光——那是画面主体,不是在给 + // 浮层垫高度。豁免必须在紧邻的上一行显式写出标记,所以它不会 + // 悄悄扩散:想用就得先写下理由,reviewer 一眼看得见。 + let exempt = index > 0 && lines[index - 1].contains("brand-glow:"); + if !exempt { hits.push((label(&path), index + 1, "用 glow 冒充阴影".to_owned())); } } diff --git a/nebula_app/src/display/ui/mod.rs b/nebula_app/src/display/ui/mod.rs index 80c29bc..15b7bce 100644 --- a/nebula_app/src/display/ui/mod.rs +++ b/nebula_app/src/display/ui/mod.rs @@ -28,6 +28,7 @@ //! - [`tokens`] —— 间距、圆角、字阶、动效、层级的常量目录 //! - [`theme`] —— [`Skin`](theme::Skin):主题派生的墨色与表面色 //! - [`surface`] —— 面板/卡片/描边/遮罩的配方(本层的核心) +//! - [`text_field`] —— 输入框的光标、选区、命中测试(唯一有可变状态的组件) //! - [`widgets`] —— slider / toggle / combobox / spinner //! - [`keycap`] —— 键帽 chip //! - [`icons`] —— 图标墨迹 @@ -52,6 +53,7 @@ pub mod guardrails; pub mod icons; pub mod keycap; pub mod surface; +pub mod text_field; pub mod theme; pub mod tokens; pub mod widgets; diff --git a/nebula_app/src/display/ui/surface.rs b/nebula_app/src/display/ui/surface.rs index c733d94..fd37e68 100644 --- a/nebula_app/src/display/ui/surface.rs +++ b/nebula_app/src/display/ui/surface.rs @@ -89,6 +89,18 @@ pub fn fade(color: Rgba, progress: f32) -> Rgba { Rgba::new(color.r, color.g, color.b, (color.a as f32 * progress.clamp(0.0, 1.0)).round() as u8) } +/// `top` 压在 `base` 上的合成色,alpha 取 `base` 的。 +/// +/// 用来把"半透明叠加色 + 不透明底"预先算成一个色,画一个 quad 而不是两个。 +/// 这不只是省一次绘制:见 [`push_group`],中间那层必须不透明,否则底下的 +/// 描边环会从整块面板里渗出来。 +#[inline] +pub fn over(top: Rgba, base: Rgba) -> Rgba { + let a = top.a as f32 / 255.0; + let mix = |t: u8, b: u8| (t as f32 * a + b as f32 * (1.0 - a)).round().clamp(0.0, 255.0) as u8; + Rgba::new(mix(top.r, base.r), mix(top.g, base.g), mix(top.b, base.b), base.a) +} + /// 发丝描边环。替代散落各处的手写 `(x-1, y-1, w+2, h+2)`。 /// /// 外圆角自动取 `radius + hairline`,保证内外弧**同心**——手写处常常内外用同一 @@ -120,12 +132,51 @@ pub fn push_surface( sk: &Skin, level: Elevation, progress: f32, +) { + push_surface_in( + quads, + rect, + (0.0, 0.0, viewport.0, viewport.1), + 0.0, + scale, + sk, + level, + progress, + ); +} + +/// 同 [`push_surface`],但遮罩只铺满 `veil_rect` 而不是整窗,并带 `veil_radius` +/// 的圆角。 +/// +/// 用于那些语义上属于某块区域的模态:SSH 主机编辑器是"对终端做的事",遮罩 +/// 盖住终端卡就够了。连侧栏和标题栏一起罩黑会把它读成"整个应用被阻断",而 +/// 那时侧栏其实仍然是可见的上下文。 +/// +/// 圆角是必须的:终端是一张圆角卡,直角遮罩会在四角各露出一块,读起来像糊 +/// 歪了一层贴纸。 +#[allow(clippy::too_many_arguments)] +pub fn push_surface_in( + quads: &mut Vec, + rect: Rect, + veil_rect: Rect, + veil_radius: f32, + scale: f32, + sk: &Skin, + level: Elevation, + progress: f32, ) { let (x, y, w, h) = rect; let corner = radius::OVERLAY * scale; if level.dims_background() { - quads.push(UiQuad::solid(0.0, 0.0, viewport.0, viewport.1, 0.0, fade(sk.veil, progress))); + quads.push(UiQuad::solid( + veil_rect.0, + veil_rect.1, + veil_rect.2, + veil_rect.3, + veil_radius, + fade(sk.veil, progress), + )); } // 外阴影承担 Z 轴层级;发丝环只负责在浅色背景上定义边界。两者分工不同, @@ -168,6 +219,24 @@ pub fn push_card(quads: &mut Vec, rect: Rect, scale: f32, sk: &Skin, sta quads.push(UiQuad::solid(x, y, w, h, corner, fill)); } +/// 带发丝描边的分组卡片:把同组字段收成一块可扫读的区域。 +/// +/// **不要写成 `push_stroke` + `push_card`。** [`push_stroke`] 画的是一个比 +/// 目标大 1px 的**实心**圆角矩形,靠上层填充盖住中心才形成"环"。`panel` 和 +/// `input` 都不透明,盖得住;而 `card` 只有 4.5% 不透明度,盖不住——描边色 +/// 有 95% 透过整张卡片,把它压深整整一档。 +/// +/// 2026-07-31 量到的实迹(Nebula 深色):panel (29,31,40) 经描边泄漏变成 +/// (50,52,62),再叠 card 得 (62,65,74),而原型是 (56,61,73)。所以这里把 +/// `card` 与 `panel` 预先合成成一个**不透明**色,一个 quad 画完,描边环也 +/// 就只剩它该有的那 1px。 +pub fn push_group(quads: &mut Vec, rect: Rect, scale: f32, sk: &Skin, progress: f32) { + let (x, y, w, h) = rect; + let corner = radius::OVERLAY * scale; + push_stroke(quads, rect, corner, scale, fade(sk.hairline, progress)); + quads.push(UiQuad::solid(x, y, w, h, corner, fade(over(sk.card, sk.panel), progress))); +} + /// 下沉表面(文本输入框)。聚焦时描边换成强调色——焦点不能只靠颜色深浅表示。 pub fn push_input(quads: &mut Vec, rect: Rect, scale: f32, sk: &Skin, focused: bool) { let (x, y, w, h) = rect; @@ -190,6 +259,33 @@ mod tests { NebulaTheme::SilverLight.skin() } + #[test] + fn a_group_card_seals_the_stroke_ring_with_an_opaque_fill() { + // 这条锁的是 2026-07-31 那个 bug:`push_stroke` 画的是实心矩形, + // 靠上层填充盖住中心才成环。card 只有 4.5% 不透明度,直接叠上去 + // 会让描边色渗满整张卡片,把它压深一整档。所以内芯必须不透明。 + for skin in [light(), NebulaTheme::Nebula.skin()] { + let mut quads = Vec::new(); + push_group(&mut quads, (40.0, 40.0, 300.0, 120.0), 1.0, &skin, 1.0); + + assert_eq!(quads.len(), 2, "只该有描边环和内芯两个 quad"); + assert_eq!(quads[1].color0.a, 255, "内芯必须不透明,否则描边渗出来"); + } + } + + #[test] + fn a_group_card_reads_one_step_above_its_panel() { + // card 是"比底板亮一档"的叠加色。合成之后这个方向必须还在—— + // 深色主题叠白、浅色主题叠 slate,两边的符号是相反的。 + let dark = NebulaTheme::Nebula.skin(); + let fill = over(dark.card, dark.panel); + assert!(fill.r > dark.panel.r, "深色下卡片要比面板亮"); + + let sun = light(); + let fill = over(sun.card, sun.panel); + assert!(fill.r < sun.panel.r, "浅色下卡片要比面板暗"); + } + #[test] fn stroke_ring_stays_concentric_with_its_fill() { // 内外弧同心的判据:外半径 - 内半径 == 描边宽度。不满足时描边在圆角 diff --git a/nebula_app/src/display/ui/text_field.rs b/nebula_app/src/display/ui/text_field.rs new file mode 100644 index 0000000..33a28c5 --- /dev/null +++ b/nebula_app/src/display/ui/text_field.rs @@ -0,0 +1,385 @@ +//! 自绘输入框的光标、选区与命中测试。 +//! +//! # 为什么在组件层 +//! +//! 在此之前每个输入框只有一个 `bool`("整个内容是否被全选"),光标永远画在 +//! 文本末尾。于是点击框内某处只能聚焦、不能定位,拖拽不能选中,方向键无处 +//! 可去——这不是没接线,是**模型里根本没有"光标在哪"这个概念**。 +//! +//! 这类能力是输入框的地板而不是天花板:Windows 上任何一个 `` 都自带 +//! 它们,用户不会认为这是功能,只会认为坏了。所以它必须住在组件层,让每个 +//! 新输入框自动获得,而不是各自实现一遍——各自实现的结局必然是"地址框能拖 +//! 选、密码框不能"这种说不出理由的不一致。 +//! +//! # 模型 +//! +//! 一个字符索引 `caret` 加一个可选的 `anchor`。选区是 `[min, max)` 的半开 +//! 区间,`anchor == caret` 视作没有选区。索引单位是**字符**而不是字节,因为 +//! 上层拿到的是"第几个字";字节偏移只在真正要切 `String` 时才换算。 +//! +//! 所有查询都对当前文本做 clamp。文本可能被外部改写(粘贴地址时自动拆出 +//! 端口、切换认证方式时清空密码),此时旧的 caret 会越界——clamp 让它退化 +//! 成"落在末尾",而不是 panic。 + +use super::caret; +use super::theme::Skin; +use super::tokens::{radius, space}; +use crate::renderer::ui::{Rgba, UiQuad}; +use unicode_width::UnicodeWidthChar; + +/// 输入框里的光标与选区。 +#[derive(Debug, Clone, Default)] +pub struct TextCursor { + caret: usize, + anchor: Option, +} + +impl TextCursor { + /// 光标位置,已 clamp 到 `text` 的长度内。 + pub fn caret(&self, text: &str) -> usize { + self.caret.min(char_count(text)) + } + + /// 选区的 `[start, end)`;没有选中任何字符时返回 `None`。 + pub fn range(&self, text: &str) -> Option<(usize, usize)> { + let len = char_count(text); + let anchor = self.anchor?.min(len); + let caret = self.caret.min(len); + (anchor != caret).then(|| (anchor.min(caret), anchor.max(caret))) + } + + pub fn has_selection(&self, text: &str) -> bool { + self.range(text).is_some() + } + + pub fn selected_text(&self, text: &str) -> Option { + let (start, end) = self.range(text)?; + Some(text.chars().skip(start).take(end - start).collect()) + } + + pub fn select_all(&mut self, text: &str) { + let len = char_count(text); + // 空串上不留锚点:否则 `has_selection` 为真而选区宽度为零,渲染会画 + // 出一条贴边的高亮,读起来像多了个看不懂的竖条。 + self.anchor = (len > 0).then_some(0); + self.caret = len; + caret::note_activity(); + } + + /// 单击定位:光标落在 `index`,清掉选区。 + pub fn place(&mut self, text: &str, index: usize) { + self.caret = index.min(char_count(text)); + self.anchor = None; + caret::note_activity(); + } + + /// 拖拽或 Shift+移动:保留锚点(没有就把当前光标当锚点),把光标拉到 + /// `index`。 + pub fn extend_to(&mut self, text: &str, index: usize) { + let len = char_count(text); + self.anchor.get_or_insert(self.caret.min(len)); + self.caret = index.min(len); + caret::note_activity(); + } + + /// 光标塌到末尾。Tab 切进一个字段时用这个,而不是 `select_all`—— + /// 全选会让紧接着的一次按键把已有内容整个吃掉。 + pub fn collapse_to_end(&mut self, text: &str) { + self.caret = char_count(text); + self.anchor = None; + caret::note_activity(); + } + + pub fn clear_selection(&mut self) { + self.anchor = None; + caret::note_activity(); + } + + /// 左右移动一格。 + /// + /// 有选区且不按 Shift 时,方向键是**塌陷到选区的那一端**而不是移动一格 + /// ——这是所有原生输入框的行为:选中一段后按 →,光标去选区右端,不是右 + /// 端再往右一个字符。 + pub fn step(&mut self, text: &str, forward: bool, extend: bool) { + if !extend { + if let Some((start, end)) = self.range(text) { + self.caret = if forward { end } else { start }; + self.anchor = None; + caret::note_activity(); + return; + } + } + let len = char_count(text); + let caret = self.caret.min(len); + let next = if forward { (caret + 1).min(len) } else { caret.saturating_sub(1) }; + if extend { + self.extend_to(text, next); + } else { + self.place(text, next); + } + } + + /// Home / End。 + pub fn jump(&mut self, text: &str, to_end: bool, extend: bool) { + let index = if to_end { char_count(text) } else { 0 }; + if extend { + self.extend_to(text, index); + } else { + self.place(text, index); + } + } + + /// 在光标处插入,先删掉选区。控制字符被丢弃——粘贴多行文本时把换行 + /// 带进单行输入框,会得到一个看不见却会被存盘的字符。 + pub fn insert(&mut self, text: &mut String, incoming: &str) { + self.delete_selection(text); + let filtered: String = incoming.chars().filter(|c| !c.is_control()).collect(); + if !filtered.is_empty() { + let at = byte_index(text, self.caret.min(char_count(text))); + let inserted = char_count(&filtered); + text.insert_str(at, &filtered); + self.caret = self.caret.min(char_count(text)) + inserted; + self.anchor = None; + } + caret::note_activity(); + } + + /// Backspace:有选区删选区,否则删光标前一个字符。 + pub fn backspace(&mut self, text: &mut String) { + if !self.delete_selection(text) { + let caret = self.caret.min(char_count(text)); + if caret > 0 { + let from = byte_index(text, caret - 1); + let to = byte_index(text, caret); + text.replace_range(from..to, ""); + self.caret = caret - 1; + } + } + caret::note_activity(); + } + + /// Delete:有选区删选区,否则删光标后一个字符。 + pub fn delete_forward(&mut self, text: &mut String) { + if !self.delete_selection(text) { + let caret = self.caret.min(char_count(text)); + if caret < char_count(text) { + let from = byte_index(text, caret); + let to = byte_index(text, caret + 1); + text.replace_range(from..to, ""); + } + } + caret::note_activity(); + } + + /// 删掉选区并把光标收到选区起点。返回是否真的删了东西,让调用方决定 + /// 要不要接着做"没有选区时"的那件事。 + fn delete_selection(&mut self, text: &mut String) -> bool { + let Some((start, end)) = self.range(text) else { + return false; + }; + let from = byte_index(text, start); + let to = byte_index(text, end); + text.replace_range(from..to, ""); + self.caret = start; + self.anchor = None; + true + } +} + +fn char_count(text: &str) -> usize { + text.chars().count() +} + +/// 第 `index` 个字符的字节偏移;越界时给 `text.len()`,于是"在末尾插入"是 +/// 自然的退化行为。 +fn byte_index(text: &str, index: usize) -> usize { + text.char_indices().nth(index).map_or(text.len(), |(at, _)| at) +} + +/// 一段文本占多少显示列。等宽网格上一个全角字符占两列,所以不能用字符数。 +pub fn columns(text: &str) -> usize { + text.chars().map(|c| c.width().unwrap_or(1)).sum() +} + +/// 前 `index` 个字符占多少列。光标和选区的像素位置都由它算,绘制与命中因此 +/// 共用同一套换算——两边各算一次必然会在全角字符上漂移。 +pub fn columns_before(text: &str, index: usize) -> usize { + text.chars().take(index).map(|c| c.width().unwrap_or(1)).sum() +} + +/// 落在 `offset_x`(相对文本起点的像素)处的字符边界。 +/// +/// 判据是"过半进位":点在一个字符的左半边,光标停在它**前面**;右半边,停在 +/// 它后面。这是原生输入框的手感——点击的意图是"插到这里",不是"选中这个字"。 +pub fn index_at(text: &str, offset_x: f32, cell_w: f32) -> usize { + if cell_w <= 0.0 { + return 0; + } + let mut x = 0.0; + for (index, character) in text.chars().enumerate() { + let width = character.width().unwrap_or(1) as f32 * cell_w; + if offset_x < x + width * 0.5 { + return index; + } + x += width; + } + char_count(text) +} + +/// 画选区高亮或光标竖线。 +/// +/// `display` 是**实际画在框里的那串字**:密码框传掩码,空框传空串(不要传 +/// 占位文案,否则光标会跑到占位文字的末尾去)。索引与真文本一一对应,因为 +/// 掩码是逐字符替换的。 +/// +/// `text_x` 由调用方给,因为对齐方式是调用方的事(端口居中、其余靠左)—— +/// 组件层只负责让光标落在与文字同一套换算上。 +#[allow(clippy::too_many_arguments)] +pub fn push_cursor( + quads: &mut Vec, + field_y: f32, + field_h: f32, + text_x: f32, + display: &str, + cursor: &TextCursor, + cell_w: f32, + scale: f32, + sk: &Skin, +) { + let inset = space::XXS * scale; + if let Some((start, end)) = cursor.range(display) { + let from = text_x + columns_before(display, start) as f32 * cell_w; + let width = (columns_before(display, end) - columns_before(display, start)) as f32 * cell_w; + quads.push(UiQuad::solid( + from, + field_y + inset, + width, + field_h - inset * 2.0, + radius::CHIP * scale, + sk.accent_soft, + )); + return; + } + // 没有选区才画竖线。相位来自共享节律:打字时常亮、停手后才呼吸, + // 且聚焦的瞬间必定是亮的。 + if !caret::is_on() { + return; + } + let x = text_x + columns_before(display, cursor.caret(display)) as f32 * cell_w; + let thickness = (1.5 * scale).max(1.0); + quads.push(UiQuad::solid( + x, + field_y + inset, + thickness, + field_h - inset * 2.0, + 0.0, + Rgba::new(sk.ink_strong.r, sk.ink_strong.g, sk.ink_strong.b, 235), + )); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn typing_lands_at_the_caret_not_the_end() { + let mut text = "abcd".to_owned(); + let mut cursor = TextCursor::default(); + cursor.place(&text, 2); + cursor.insert(&mut text, "XY"); + assert_eq!(text, "abXYcd"); + // 光标跟着插入的内容走,接着打字才会续在后面。 + assert_eq!(cursor.caret(&text), 4); + } + + #[test] + fn typing_replaces_the_selection() { + let mut text = "root@example.com".to_owned(); + let mut cursor = TextCursor::default(); + cursor.place(&text, 0); + cursor.extend_to(&text, 4); + assert_eq!(cursor.selected_text(&text).as_deref(), Some("root")); + cursor.insert(&mut text, "admin"); + assert_eq!(text, "admin@example.com"); + assert!(!cursor.has_selection(&text)); + } + + #[test] + fn backspace_eats_the_selection_before_the_character() { + let mut text = "abcdef".to_owned(); + let mut cursor = TextCursor::default(); + cursor.place(&text, 1); + cursor.extend_to(&text, 4); + cursor.backspace(&mut text); + assert_eq!(text, "aef"); + assert_eq!(cursor.caret(&text), 1); + // 选区没了,这次才轮到删单个字符。 + cursor.backspace(&mut text); + assert_eq!(text, "ef"); + } + + #[test] + fn arrow_collapses_a_selection_to_its_edge() { + let text = "abcdef".to_owned(); + let mut cursor = TextCursor::default(); + cursor.place(&text, 1); + cursor.extend_to(&text, 4); + cursor.step(&text, true, false); + assert_eq!(cursor.caret(&text), 4, "按右应该跳到选区右端,不是右端再右一格"); + cursor.place(&text, 1); + cursor.extend_to(&text, 4); + cursor.step(&text, false, false); + assert_eq!(cursor.caret(&text), 1); + } + + #[test] + fn shift_arrow_keeps_extending_from_the_anchor() { + let text = "abcdef".to_owned(); + let mut cursor = TextCursor::default(); + cursor.place(&text, 3); + cursor.step(&text, false, true); + cursor.step(&text, false, true); + assert_eq!(cursor.range(&text), Some((1, 3))); + } + + #[test] + fn a_stale_caret_degrades_to_the_end_instead_of_panicking() { + let mut text = "192.168.1.1:2222".to_owned(); + let mut cursor = TextCursor::default(); + cursor.collapse_to_end(&text); + // 粘贴地址后上层把端口拆走了,文本比光标记得的短。 + text = "192.168.1.1".to_owned(); + assert_eq!(cursor.caret(&text), char_count(&text)); + cursor.insert(&mut text, "x"); + assert_eq!(text, "192.168.1.1x"); + } + + #[test] + fn clicking_picks_the_nearer_character_boundary() { + let text = "abcd"; + // 一格宽 10:点在第 2 格的左半 → 停在它前面(索引 1)。 + assert_eq!(index_at(text, 11.0, 10.0), 1); + // 右半 → 停在它后面(索引 2)。 + assert_eq!(index_at(text, 19.0, 10.0), 2); + // 点在文字右边的空白里 → 落到末尾。 + assert_eq!(index_at(text, 999.0, 10.0), 4); + assert_eq!(index_at(text, -5.0, 10.0), 0); + } + + #[test] + fn wide_characters_advance_two_columns() { + let text = "生产数据库"; + assert_eq!(columns(text), 10); + assert_eq!(columns_before(text, 2), 4); + // 点在第三个字的左半边,光标要停在它前面而不是被算成第 5 个字符。 + assert_eq!(index_at(text, 41.0, 10.0), 2); + } + + #[test] + fn paste_strips_control_characters() { + let mut text = String::new(); + let mut cursor = TextCursor::default(); + cursor.insert(&mut text, "dev@host\r\n"); + assert_eq!(text, "dev@host"); + } +} diff --git a/nebula_app/src/display/ui/theme.rs b/nebula_app/src/display/ui/theme.rs index df8504f..b766c6f 100644 --- a/nebula_app/src/display/ui/theme.rs +++ b/nebula_app/src/display/ui/theme.rs @@ -427,32 +427,45 @@ impl NebulaTheme { let p = self.palette(); let a = self.accent(); let t = p.term_bg; - // 浮层相对**内容层**(终端)提亮 14 级。用 term_bg 而不是 p.panel: - // p.panel 是窗口外壳色,比终端还暗(Steel 外壳 22,24,30 vs 终端 - // 26,28,36),拿它当浮层底会让弹窗陷进背景里,方向正好是反的。 + // 浮层底从**窗口外壳** `shell_bg` 提亮 14 级,不是从终端底色 + // (2026-07-31 修正)。 + // + // 之前用的是 `term_bg`,理由写的是"浮层要比内容层亮"。方向没错, + // 起点错了:Nebula 的外壳 (34,38,48) 比终端 (15,17,26) 亮 20,于是 + // 从终端起跳的浮层落在 (29,31,40),比它实际漂浮的那张外壳还暗 5 级 + // ——弹窗陷进背景里,正是它想避免的那件事。原型的 (48,53,65) 就是 + // 外壳 +14。 + // + // 当初避开 `p.panel` 是对的(那是 chrome 面板色,Steel 下比终端暗), + // 但 `shell_bg` 是另一个字段,四个深色主题下都能让浮层高出终端 9–33 + // 级,方向对得上。 let lift = |c: u8| c.saturating_add(14); + let shell = p.shell_bg; if p.is_light { Skin { - // 明度阶梯(2026-07-29 裁定):相邻层必须差 8–14 级, - // 低于 8 级肉眼分不开,界面就只能靠 - // 画边框救场——边框一多画面就碎,这才是"脏"的观感来源。 + // 浅色的三层不是单调递增的明度阶梯,而是**凹槽**(2026-07-31 + // 裁定,推翻 07-29 的"浮层比内容更暗"):浮层底与输入面同为 + // 纯白,中间的分组卡片压暗一档挖出凹槽,输入框从凹槽里浮回 + // 表面。 // - // 浅色下浮层的方向是**比内容更暗**(参照产品的浅色命令面板底 - // 是 #F7F7F7,盖住的终端区是纯白;Fluent 同构:base #F3F3F3 - // → layer #FFFFFF)。此前 panel 250 与 card 255 只差 5 级,等于 - // 没有层级。现在 241 → 254 差 13,且比纯白终端暗 14。 + // 07-29 那版把方向做反了——panel 241 / card 254 / input 240, + // 卡片成了最亮的一层,输入框反而陷进卡片里。视觉上读成"白盒 + // 子套白盒子",输入框只能靠一条淡描边撑出来,而描边一多画面 + // 就碎。阶梯本身没错,错在浅色下**最亮的那层应该是可输入的 + // 表面**,不是承载它的容器。 // - // 这不推翻"白色打底"的裁定:slate-100 依然是近白冷调,不是回到 - // 主题族的银/岩灰底;变的是它与卡片之间终于有了可见的台阶。 - panel: Rgba::new(241, 245, 249, 252), // slate-100 - // A white inset on light panels: the hairline carries the - // "sunken" read, the fill stays cleaner than a gray wash. - input: Rgba::new(255, 255, 255, 240), - card: Rgba::new(255, 255, 255, 244), + // 压暗用 slate 叠加而不是降低白的明度:纯黑叠白底得到零色相 + // 的死灰,掺蓝的冷调灰才不显脏(同 hairline/surface 的理由)。 + // alpha 取 255:原型的 panel 是不透明的,而且 `push_stroke` + // 靠上层填充盖住中心才形成描边环——底板只要留一丝透明,那圈 + // 描边就会从整块面板里渗出来。 + panel: Rgba::new(255, 255, 255, 255), + input: Rgba::new(255, 255, 255, 255), + card: Rgba::new(100, 116, 139, 11), // slate-500 @ 4.5% // modal 专用(popover 不画遮罩,见设计文档裁定三)。冷调压暗 // 而不是白雾:白雾 75% 把背景提亮到和白弹窗同明度,弹窗反而 // 浮不起来,且糊掉全部上下文。20% 压暗下背景仍然可读。 - veil: Rgba::new(15, 23, 42, 51), // slate-900 @ 20% + veil: Rgba::new(15, 23, 42, 56), // slate-900 @ 22% ink: Rgb::new(51, 65, 85), // slate-700 ink_dim: Rgb::new(100, 116, 139), // slate-500 ink_strong: Rgb::new(15, 23, 42), // slate-900 @@ -461,9 +474,18 @@ impl NebulaTheme { ink_on_accent: Rgb::new(248, 250, 252), icon: Rgb::new(71, 85, 105), // slate-600 icon_hover: Rgb::new(15, 23, 42), // slate-900 - accent: a, - accent_soft: Rgba::new(a.r, a.g, a.b, 34), - danger: Rgba::new(196, 74, 88, 255), + // 浅色下的强调色是**中性深灰**,不跟主题走(2026-07-31 用户裁定, + // 对齐原型)。浅色底本身亮度就高,饱和的主题色压上去会抢过内容 + // ——焦点框、主按钮、勾选框同时用一个艳色,画面立刻吵起来。原型 + // 里这三处全是同一个深灰,"柔和"就是从这来的。 + // + // 深色主题不受影响:那边底暗,主题 accent 是唯一的亮点,反而 + // 是画面的锚。 + accent: Rgb::new(73, 80, 87), + accent_soft: Rgba::new(73, 80, 87, 36), // 14% + // 浅色的红要比深色那份更沉:同一个红压在白底上,明度对比大得多, + // 照搬深色值会艳到从整张卡片里跳出来。 + danger: Rgba::new(178, 58, 72, 255), // 2026-07-29 用户裁定:中性灰在屏幕上永远显脏。此前这几个 // 叠加色是纯黑 rgba(0,0,0,.05~.19),而纯黑叠在白底上只能得 // 到**零色相**的死灰——这就是"不干净"的物理来源。现在改叠 @@ -488,12 +510,16 @@ impl NebulaTheme { // (深色命令面板比背景亮,浅色命令面板比纯白终端暗)。 // // 阶梯:终端 26 → panel 40 → card 54,每层差 14。 - panel: Rgba::new(lift(t.r), lift(t.g), lift(t.b), 250), + // + // alpha 取 255 而不是 250:原型的 panel 是不透明的,而且 + // `push_stroke` 靠上层填充盖住中心才形成描边环——底板只要 + // 留一丝透明,那圈描边就会从整块面板里渗出来。 + panel: Rgba::new(lift(shell.r), lift(shell.g), lift(shell.b), 255), // Derive the inset/input surface from the terminal background // so it stays in-family on every dark theme (blue-black on // Nebula, pure gray on Coal) instead of one fixed navy. - input: Rgba::new(t.r, t.g, t.b, 220), - card: Rgba::new(255, 255, 255, 16), + input: Rgba::new(t.r, t.g, t.b, 219), // 86% + card: Rgba::new(255, 255, 255, 11), // 4.5% // modal 专用(popover 不画遮罩)。原值 alpha 150(59%)在本就 // 很暗的底上接近全黑,把上下文糊没了;36% 足够传达"被阻断", // 背景仍可读。冷调 slate-950 而不是纯黑,与整套灰同色温。 diff --git a/nebula_app/src/display/window.rs b/nebula_app/src/display/window.rs index d391795..838d349 100644 --- a/nebula_app/src/display/window.rs +++ b/nebula_app/src/display/window.rs @@ -48,8 +48,16 @@ use crate::config::window::{Identity, WindowConfig}; use crate::display::SizeInfo; /// Window icon for `_NET_WM_ICON` property. +/// +/// Three levels up, not two: this file sits in `src/display/`, so `../../` +/// would land on `nebula_app/extra` — which is a stray 8-byte regular file +/// holding the text `../extra` (a symlink that got committed as content under +/// `core.symlinks=false`, mode 100644). It is not a real symlink on any +/// platform, so the two-level path can never resolve. Windows never noticed +/// because this const is gated out there. Sibling `display/mod.rs` already uses +/// the correct `../../../`. #[cfg(all(feature = "x11", not(any(target_os = "macos", windows))))] -const WINDOW_ICON: &[u8] = include_bytes!("../../extra/logo/nebula.png"); +const WINDOW_ICON: &[u8] = include_bytes!("../../../extra/logo/nebula.png"); /// This should match the definition of IDI_ICON from `nebula.rc`. #[cfg(windows)] diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs index 056be68..ef4ed72 100644 --- a/nebula_app/src/event.rs +++ b/nebula_app/src/event.rs @@ -692,6 +692,17 @@ impl ApplicationHandler for Processor { window_context.display.window.request_redraw(); } }, + // 连接阶段推进:只更新拥有该 pane 的窗口。事件自带 tab_id, + // 没有 tab_id 的(不该出现)直接丢弃而不是误绘到别的 pane 上。 + (EventType::SshConnect(stage), Some(window_id)) => { + if let (Some(window_context), Some(pane)) = + (self.windows.get_mut(window_id), tab_id) + { + window_context.ssh_connect_stage(pane, stage); + window_context.dirty = true; + window_context.display.window.request_redraw(); + } + }, // Toast click: surface the window (and pane) the toast came from. // Must be consumed here — the generic Some(window_id) forwarding // below would park it in a window's event queue instead. @@ -2472,6 +2483,7 @@ impl input::Processor> { | EventType::SshDeleteUndoExpired | EventType::QuickTerminalHotkeyChanged { .. } | EventType::SshTestDone { .. } + | EventType::SshConnect(_) | EventType::SftpUpdated => (), // AI hook events are handled at the Processor level (they may // target any window's pane); FocusWindow, fix results and diff --git a/nebula_app/src/event/types.rs b/nebula_app/src/event/types.rs index 7e93a07..110f0b4 100644 --- a/nebula_app/src/event/types.rs +++ b/nebula_app/src/event/types.rs @@ -93,6 +93,9 @@ pub enum EventType { message: String, elapsed_ms: u64, }, + /// 直连 SSH 会话的连接阶段推进(后台 runtime → 窗口线程)。事件自带 + /// `tab_id`,接收侧据此定位 pane,无需在负载里重复 pane id。 + SshConnect(crate::ssh_session::SshStage), SftpUpdated, AiHook(crate::ai_hook::AiHookEvent), /// 助手修复请求完成(后台线程 → 主循环)。`fix: None` = 沉默(失败、 diff --git a/nebula_app/src/font_install.rs b/nebula_app/src/font_install.rs index fc5e014..ab1352a 100644 --- a/nebula_app/src/font_install.rs +++ b/nebula_app/src/font_install.rs @@ -15,13 +15,13 @@ pub struct StoredFont { pub created: bool, } +/// 导入字体的存放目录。**纯拼路径,不创建**——写入侧(`store_font`)自己 +/// `create_dir_all` 并把失败报给用户,读取侧(`imported_font_files`)容忍 +/// 目录不存在。这里若顺手创建,既让读路径每次多一次无谓 IO,也会让写侧 +/// 那条错误提示永不触发。 #[cfg(windows)] pub fn imported_font_directory() -> PathBuf { - let base = std::env::var_os("APPDATA") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(PathBuf::from) - .unwrap_or_else(std::env::temp_dir); - base.join("Nebula").join("fonts") + crate::platform::dirs::data_dir().join("fonts") } #[cfg(windows)] diff --git a/nebula_app/src/input/chrome.rs b/nebula_app/src/input/chrome.rs index a095faa..83e0e55 100644 --- a/nebula_app/src/input/chrome.rs +++ b/nebula_app/src/input/chrome.rs @@ -363,6 +363,35 @@ impl> Processor { // Right-side drawer (directory tree / git): header tabs switch views, // directory rows expand/collapse. Sits under the modal layers, so // only when no modal owns the pointer. + // SSH 连接卡片:遮罩盖住整个 pane,所以卡片在场时 pane 内的点击 + // 全归它——一个字节也不许漏进终端去起拖选(侧栏拖拽残影那个 bug + // 就是"未命中的按压漏进选区")。落在卡片外的空白同样被吞掉。 + if self.ctx.display().ssh_connect_active() + && !self.ctx.display().settings_open() + && self.ctx.display().nebula_confirm.is_none() + { + let x = self.ctx.mouse().x as f32; + let y = self.ctx.mouse().y as f32; + if self.ctx.display().ssh_connect_covers(x, y) { + if button == MouseButton::Left { + use crate::display::ssh_connect::SshConnectHit; + match self.ctx.display().ssh_connect_hit(x, y) { + SshConnectHit::Logs => { + self.ctx.display().ssh_connect_toggle_logs(); + self.ctx.mark_dirty(); + }, + // 取消与关闭是同一个动作:这个 pane 除了这条连接 + // 没有别的内容,留着它没有意义。 + SshConnectHit::Cancel | SshConnectHit::Close => { + self.ctx.nebula_tab(crate::event::TabRequest::Close); + }, + SshConnectHit::None => {}, + } + } + return; + } + } + if button == MouseButton::Left && self.ctx.display().nebula_sftp_panel.is_some() && !self.ctx.display().settings_open() diff --git a/nebula_app/src/input/keyboard.rs b/nebula_app/src/input/keyboard.rs index 47e1972..af9c81e 100644 --- a/nebula_app/src/input/keyboard.rs +++ b/nebula_app/src/input/keyboard.rs @@ -365,6 +365,23 @@ impl> Processor { self.ctx.display().ssh_editor_next_field(mods.shift_key()) }, Key::Named(NamedKey::Backspace) => self.ctx.display().ssh_editor_backspace(), + Key::Named(NamedKey::Delete) => { + self.ctx.display().ssh_editor_delete_forward() + }, + // 光标导航。按住 Shift 是扩选——这套组合键在 Windows 上 + // 属于肌肉记忆,缺一个都会被读成"这个输入框是坏的"。 + Key::Named(NamedKey::ArrowLeft) => { + self.ctx.display().ssh_editor_move_caret(false, mods.shift_key()) + }, + Key::Named(NamedKey::ArrowRight) => { + self.ctx.display().ssh_editor_move_caret(true, mods.shift_key()) + }, + Key::Named(NamedKey::Home) => { + self.ctx.display().ssh_editor_jump_caret(false, mods.shift_key()) + }, + Key::Named(NamedKey::End) => { + self.ctx.display().ssh_editor_jump_caret(true, mods.shift_key()) + }, Key::Character(c) if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("a") => { diff --git a/nebula_app/src/input/mouse.rs b/nebula_app/src/input/mouse.rs index 445befb..9b3bc43 100644 --- a/nebula_app/src/input/mouse.rs +++ b/nebula_app/src/input/mouse.rs @@ -73,6 +73,13 @@ impl> Processor { // retained editor snapshot is released. if self.ctx.display().nebula_ssh_editor.is_some() { self.ctx.display().set_ssh_delete_undo_hover(false); + // 拖选进行中:光标跟着指针走。划出输入框也继续跟——拖拽的语义是 + // "从按下那个字符拉到现在这个字符",中途手抖出框不该中断选择。 + if self.ctx.display().ssh_editor_dragging() + && self.ctx.display().ssh_editor_drag_to(x as f32) + { + self.ctx.mark_dirty(); + } let hit = if self.ctx.display().ssh_editor_active() { self.ctx.display().ssh_editor_hit(x as f32, y as f32) } else { @@ -81,6 +88,8 @@ impl> Processor { self.ctx.display().set_ssh_editor_hover(hit); let cursor = match hit { crate::display::SshEditorHit::Destination + | crate::display::SshEditorHit::Port + | crate::display::SshEditorHit::Label | crate::display::SshEditorHit::Password => CursorIcon::Text, crate::display::SshEditorHit::SaveToggleBox | crate::display::SshEditorHit::SaveToggleLabel @@ -336,6 +345,21 @@ impl> Processor { return; } + // SSH 连接卡片:遮罩盖住整个 pane,指针不能穿到下面的终端去变成 + // I 形光标——那会读作"这里可以选文字",而此刻并不能。 + if self.ctx.display().ssh_connect_active() { + let hit = self.ctx.display().ssh_connect_hit(x as f32, y as f32); + if self.ctx.display().ssh_connect_set_hover(hit) { + self.ctx.mark_dirty(); + } + if self.ctx.display().ssh_connect_covers(x as f32, y as f32) { + let cursor = + if hit.is_none() { CursorIcon::Default } else { CursorIcon::Pointer }; + self.ctx.window().set_mouse_cursor(cursor); + return; + } + } + // Drawer hover: rows / header tabs / action buttons light up, and the // pointer picks the matching cursor. The drawer overlays the grid, so // while the pointer is on it nothing below may react (no link hover, @@ -717,6 +741,9 @@ impl> Processor { // even while a TUI has grabbed the mouse. A plain click (never dragged) // returns `None` here and falls through to normal release handling. if button == MouseButton::Left { + // 松开就结束输入框的拖选。放在最前面:后面任何一条 early return + // 都会把这个拖拽状态留下,于是下一次移动鼠标还在选字。 + self.ctx.display().ssh_editor_end_drag(); if self.ctx.display().finish_settings_opacity_drag() { self.ctx.mark_dirty(); return; diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index 500e916..23d09a1 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -57,6 +57,7 @@ mod nebula_history; mod notify; #[cfg(windows)] mod panic; +mod platform; #[cfg(unix)] mod polling; mod process_tree; diff --git a/nebula_app/src/nebula_history.rs b/nebula_app/src/nebula_history.rs index b0c6c00..9d44d40 100644 --- a/nebula_app/src/nebula_history.rs +++ b/nebula_app/src/nebula_history.rs @@ -124,13 +124,7 @@ impl NebulaHistory { /// Path to `nebula_history.jsonl` under the user data dir, creating the /// directory if needed. fn history_path() -> PathBuf { - let base = std::env::var_os("APPDATA") - .map(PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) - .unwrap_or_else(std::env::temp_dir); - let dir = base.join("Nebula"); - let _ = std::fs::create_dir_all(&dir); - dir.join("nebula_history.jsonl") + crate::platform::dirs::data_dir().join("nebula_history.jsonl") } #[cfg(test)] diff --git a/nebula_app/src/notify.rs b/nebula_app/src/notify.rs index 918591b..c7dfd82 100644 --- a/nebula_app/src/notify.rs +++ b/nebula_app/src/notify.rs @@ -322,12 +322,11 @@ mod win { ensure_icon_file() } - /// Write the embedded logo to `%APPDATA%\Nebula\toast_icon.png` (idempotent; - /// refreshed when the embedded bytes change size, e.g. after a logo swap). + /// Write the embedded logo to `toast_icon.png` in the user data dir + /// (idempotent; refreshed when the embedded bytes change size, e.g. after a + /// logo swap). fn ensure_icon_file() -> Option { - let dir = PathBuf::from(std::env::var_os("APPDATA")?).join("Nebula"); - std::fs::create_dir_all(&dir).ok()?; - let path = dir.join("toast_icon.png"); + let path = crate::platform::dirs::data_dir().join("toast_icon.png"); let stale = std::fs::metadata(&path) .map(|meta| meta.len() != ICON_PNG.len() as u64) .unwrap_or(true); diff --git a/nebula_app/src/platform/dirs.rs b/nebula_app/src/platform/dirs.rs new file mode 100644 index 0000000..29c0fde --- /dev/null +++ b/nebula_app/src/platform/dirs.rs @@ -0,0 +1,173 @@ +//! 用户目录解析(005 §5 L1)。 +//! +//! 这里是 Nebula 全部落盘路径的唯一真相。改造前同一段 +//! 「`APPDATA` → `HOME/.config` → `Nebula`」的逻辑在仓库里被复制了 5 份 +//! (`display/mod.rs`、`nebula_history.rs`、`notify.rs`、`font_install.rs`、 +//! `nebula_terminal/tty/windows`),其中 `notify.rs` 只读 `APPDATA`——在 +//! Unix 上直接返回 `None`,通知图标永远写不出来。 +//! +//! ## 各平台落点 +//! +//! | 平台 | 目录 | +//! | :-- | :-- | +//! | Windows | `%APPDATA%\Nebula` | +//! | macOS | `~/Library/Application Support/Nebula` | +//! | Linux | `$XDG_CONFIG_HOME/nebula`,未设则 `~/.config/nebula` | +//! +//! **Windows 路径与改造前逐字节相同**——存量用户零迁移,这是硬约束。 +//! +//! Linux 改用 XDG 规范目录、且目录名转小写(`nebula` 而非 `Nebula`), +//! 是因为 Linux 目标从未编译成功过(见 004 的探测结论:交叉编译死在 +//! fontconfig/ring 的 native build script),**绝无存量用户**,此刻是唯一 +//! 能免费修正的时机。macOS 同理,从 `~/.config` 改到系统规定的 +//! Application Support。 +//! +//! ## 为什么 config 与 data 暂不分家 +//! +//! XDG 分 `XDG_CONFIG_HOME` 与 `XDG_DATA_HOME`,但 Windows 侧现在把设置、 +//! 历史、会话、字体全放在同一个 `%APPDATA%\Nebula` 下。此刻拆开就要给 +//! Windows 做数据迁移,风险远大于收益。先统一成一个正确的入口,拆分留到 +//! 真有需求时(那时只需改这个文件)。 + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +/// 覆盖数据目录。便携模式与自动化测试用——`ui_probe` 之类的工具跑起来 +/// 会写设置文件,指到临时目录就不会污染真实配置。 +const DIR_OVERRIDE_ENV: &str = "NEBULA_CONFIG_DIR"; + +/// 用户主目录。Windows `USERPROFILE`,Unix `HOME`。 +/// +/// 存在的理由是仓库里有 6 处只读 `USERPROFILE` 而没有 `HOME` 回落 +/// (`ai_hook.rs` 找 `.claude` / `.codex` / `.pi` 的四处最典型),那些路径 +/// 在 Linux/macOS 上恒为 `None`,对应功能静默消失。 +pub fn home_dir() -> Option { + #[cfg(windows)] + let raw = std::env::var_os("USERPROFILE"); + #[cfg(unix)] + let raw = std::env::var_os("HOME"); + + raw.map(PathBuf::from).filter(|path| !path.as_os_str().is_empty()) +} + +/// Nebula 的配置与数据目录,必要时创建。 +/// +/// 结果缓存在 `OnceLock`:改造前 26 个调用点每次都要读环境变量 + +/// `create_dir_all` 一次。缓存同时保证进程内路径恒定——半途改环境变量 +/// 不会让一部分文件写到旧目录、另一部分写到新目录。 +pub fn data_dir() -> &'static Path { + static DIR: OnceLock = OnceLock::new(); + DIR.get_or_init(|| { + let dir = resolve_data_dir(); + let _ = std::fs::create_dir_all(&dir); + dir + }) +} + +fn resolve_data_dir() -> PathBuf { + if let Some(raw) = std::env::var_os(DIR_OVERRIDE_ENV) { + let path = PathBuf::from(raw); + if !path.as_os_str().is_empty() { + return path; + } + } + + #[cfg(windows)] + { + // 与改造前逐字节一致:`%APPDATA%\Nebula`。 + std::env::var_os("APPDATA") + .map(PathBuf::from) + .or_else(home_dir) + .unwrap_or_else(std::env::temp_dir) + .join("Nebula") + } + + #[cfg(target_os = "macos")] + { + home_dir() + .map(|home| home.join("Library").join("Application Support")) + .unwrap_or_else(std::env::temp_dir) + .join("Nebula") + } + + #[cfg(all(unix, not(target_os = "macos")))] + { + // XDG 规定:相对路径的 XDG_CONFIG_HOME 必须当作未设置。 + std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .or_else(|| home_dir().map(|home| home.join(".config"))) + .unwrap_or_else(std::env::temp_dir) + .join("nebula") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn home_dir_rejects_empty_env() { + // 空字符串环境变量在实践中出现过(某些登录管理器),当作未设置。 + // 这里只验证过滤器本身——直接改进程环境会污染其他并发测试。 + let empty = PathBuf::from(""); + assert!(Some(empty).filter(|p: &PathBuf| !p.as_os_str().is_empty()).is_none()); + } + + #[test] + fn data_dir_is_absolute_and_named() { + let dir = data_dir(); + assert!(dir.is_absolute(), "数据目录必须是绝对路径:{dir:?}"); + // 覆盖环境变量下叶子名可以是任意的,只在默认路径上校验命名。 + if std::env::var_os(DIR_OVERRIDE_ENV).is_none() { + let leaf = dir.file_name().and_then(|name| name.to_str()).unwrap_or_default(); + assert!( + leaf.eq_ignore_ascii_case("nebula"), + "默认数据目录应以 nebula 结尾,实际 {leaf:?}" + ); + } + } + + /// Windows 侧路径是硬约束:必须逐字节等于改造前的 `%APPDATA%\Nebula`, + /// 否则存量用户的设置、历史、会话快照集体失联。 + #[cfg(windows)] + #[test] + fn windows_path_matches_pre_refactor_layout() { + if std::env::var_os(DIR_OVERRIDE_ENV).is_some() { + return; + } + let Some(appdata) = std::env::var_os("APPDATA") else { return }; + assert_eq!(data_dir(), PathBuf::from(appdata).join("Nebula")); + } + + /// Linux 侧遵守 XDG:相对路径的 `XDG_CONFIG_HOME` 按规范视为未设置。 + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn linux_path_follows_xdg() { + if std::env::var_os(DIR_OVERRIDE_ENV).is_some() { + return; + } + let dir = data_dir(); + assert_eq!(dir.file_name().and_then(|name| name.to_str()), Some("nebula")); + if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from) { + if xdg.is_absolute() { + assert_eq!(dir, xdg.join("nebula")); + return; + } + } + if let Some(home) = home_dir() { + assert_eq!(dir, home.join(".config").join("nebula")); + } + } + + /// macOS 有自己的规矩:Application Support,不是 `~/.config`。 + #[cfg(target_os = "macos")] + #[test] + fn macos_path_uses_application_support() { + if std::env::var_os(DIR_OVERRIDE_ENV).is_some() { + return; + } + let Some(home) = home_dir() else { return }; + assert_eq!(data_dir(), home.join("Library").join("Application Support").join("Nebula")); + } +} diff --git a/nebula_app/src/platform/mod.rs b/nebula_app/src/platform/mod.rs new file mode 100644 index 0000000..6a720b4 --- /dev/null +++ b/nebula_app/src/platform/mod.rs @@ -0,0 +1,39 @@ +//! 平台闸门层(docs/specs/005-cross-platform-foundation.md)。 +//! +//! 目的是让业务代码不再自己写 `#[cfg(windows)]`。三平台的差异全部收在 +//! 这里的子模块中,对外只暴露平台无关的签名。 +//! +//! 设计裁定(005 §4.1):**不用 trait 对象**。Tabby 的 `PlatformService` +//! 是 OOP + DI 的产物,Rust 里照搬一个 20 方法的 `dyn` trait 只会换来强制 +//! 动态分发和造假实现的测试负担——平台实现本就是编译期确定的。这里改用 +//! 「按能力切模块 + 模块内 `#[cfg]` 分派」,保留 Tabby 真正值钱的两点: +//! 单一入口,以及能力探测(让 UI 隐藏入口,而不是让功能在别的平台报错)。 + +pub mod dirs; + +/// 运行平台。与「配置平台」分离(005 §4.3):前者是我真正跑在哪,后者是 +/// 该套用哪份键位/修饰键默认值——Mac 用户在 Windows 上可以选 ⌘ 语义。 +/// 配置平台待 L5 键位分文件时落地。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Platform { + Windows, + MacOS, + Linux, +} + +impl Platform { + pub const fn current() -> Self { + #[cfg(windows)] + { + Self::Windows + } + #[cfg(target_os = "macos")] + { + Self::MacOS + } + #[cfg(all(unix, not(target_os = "macos")))] + { + Self::Linux + } + } +} diff --git a/nebula_app/src/ssh_profiles.rs b/nebula_app/src/ssh_profiles.rs index a70d0fe..6b35e30 100644 --- a/nebula_app/src/ssh_profiles.rs +++ b/nebula_app/src/ssh_profiles.rs @@ -1,260 +1,280 @@ -use std::io; -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Deserializer, Serialize}; -use windows_sys::Win32::Storage::FileSystem::{ - MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, -}; - -const PROFILE_VERSION: u32 = 1; - -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum SshAuthMode { - #[default] - Auto, - Password, - PublicKey, - KeyboardInteractive, -} - -impl<'de> Deserialize<'de> for SshAuthMode { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Ok(match value.as_str() { - "password" => Self::Password, - "public_key" => Self::PublicKey, - // v0.5 移除了 Agent;旧配置回退到 Auto,避免升级后配置失效。 - "agent" => Self::Auto, - "keyboard_interactive" => Self::KeyboardInteractive, - _ => Self::Auto, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct SshProfileAuth { - pub destination: String, - #[serde(default)] - pub auth: SshAuthMode, - #[serde(default)] - pub private_keys: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SshProfiles { - #[serde(default = "profile_version")] - version: u32, - #[serde(default)] - profiles: Vec, -} - -impl Default for SshProfiles { - fn default() -> Self { - Self { version: PROFILE_VERSION, profiles: Vec::new() } - } -} - -impl SshProfiles { - pub fn load(path: &Path) -> io::Result { - let data = match std::fs::read(path) { - Ok(data) => data, - Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()), - Err(err) => return Err(err), - }; - let mut profiles: Self = serde_json::from_slice(&data) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; - for profile in &mut profiles.profiles { - deduplicate_key_paths(&mut profile.private_keys); - } - profiles.version = PROFILE_VERSION; - Ok(profiles) - } - - pub fn save(&self, path: &Path) -> io::Result<()> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let data = serde_json::to_vec_pretty(self).map_err(io::Error::other)?; - let temporary = path.with_extension(format!("nebula-tmp-{}", std::process::id())); - std::fs::write(&temporary, data)?; - let result = replace_file(&temporary, path); - if result.is_err() { - let _ = std::fs::remove_file(&temporary); - } - result - } - - pub fn for_destination(&self, destination: &str) -> SshProfileAuth { - self.profiles - .iter() - .find(|profile| profile.destination == destination) - .cloned() - .unwrap_or_else(|| SshProfileAuth { - destination: destination.to_owned(), - auth: SshAuthMode::Auto, - private_keys: Vec::new(), - }) - } - - pub fn upsert(&mut self, mut profile: SshProfileAuth) { - deduplicate_key_paths(&mut profile.private_keys); - if let Some(existing) = - self.profiles.iter_mut().find(|existing| existing.destination == profile.destination) - { - *existing = profile; - } else { - self.profiles.push(profile); - } - } - - pub fn remove(&mut self, destination: &str) { - self.profiles.retain(|profile| profile.destination != destination); - } - - pub fn rename(&mut self, old: &str, new: &str) { - let Some(mut profile) = - self.profiles.iter().find(|profile| profile.destination == old).cloned() - else { - return; - }; - self.remove(old); - profile.destination = new.to_owned(); - self.upsert(profile); - } -} - -fn profile_version() -> u32 { - PROFILE_VERSION -} - -fn deduplicate_key_paths(paths: &mut Vec) { - let mut seen = Vec::::new(); - paths.retain(|path| { - let normalized = path.to_string_lossy().to_lowercase(); - if seen.contains(&normalized) { - false - } else { - seen.push(normalized); - true - } - }); -} - -fn replace_file(source: &Path, destination: &Path) -> io::Result<()> { - let source = wide_path(source); - let destination = wide_path(destination); - let ok = unsafe { - MoveFileExW( - source.as_ptr(), - destination.as_ptr(), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, - ) - }; - if ok == 0 { Err(io::Error::last_os_error()) } else { Ok(()) } -} - -fn wide_path(path: &Path) -> Vec { - use std::os::windows::ffi::OsStrExt; - path.as_os_str().encode_wide().chain(std::iter::once(0)).collect() -} - -#[cfg(test)] -mod tests { - use super::{SshAuthMode, SshProfileAuth, SshProfiles}; - use std::path::PathBuf; - - #[test] - fn missing_profile_defaults_to_auto_without_keys() { - let profiles = SshProfiles::default(); - - assert_eq!(profiles.for_destination("dev@example.com").auth, SshAuthMode::Auto); - assert!(profiles.for_destination("dev@example.com").private_keys.is_empty()); - } - - #[test] - fn profile_round_trip_preserves_mode_and_key_order() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("ssh_profiles.json"); - let mut profiles = SshProfiles::default(); - profiles.upsert(SshProfileAuth { - destination: "dev@example.com".to_owned(), - auth: SshAuthMode::PublicKey, - private_keys: vec![PathBuf::from(r"C:\Keys\first"), PathBuf::from(r"C:\Keys\second")], - }); - profiles.save(&path).unwrap(); - - let loaded = SshProfiles::load(&path).unwrap(); - let profile = loaded.for_destination("dev@example.com"); - assert_eq!(profile.auth, SshAuthMode::PublicKey); - assert_eq!( - profile.private_keys, - vec![PathBuf::from(r"C:\Keys\first"), PathBuf::from(r"C:\Keys\second")] - ); - } - - #[test] - fn duplicate_windows_key_paths_are_removed_without_reordering() { - let mut profiles = SshProfiles::default(); - profiles.upsert(SshProfileAuth { - destination: "dev@example.com".to_owned(), - auth: SshAuthMode::Auto, - private_keys: vec![ - PathBuf::from(r"C:\Keys\id_ed25519"), - PathBuf::from(r"c:\keys\ID_ED25519"), - PathBuf::from(r"C:\Keys\id_rsa"), - ], - }); - - assert_eq!( - profiles.for_destination("dev@example.com").private_keys, - vec![PathBuf::from(r"C:\Keys\id_ed25519"), PathBuf::from(r"C:\Keys\id_rsa")] - ); - } - - #[test] - fn renaming_profile_moves_auth_metadata() { - let mut profiles = SshProfiles::default(); - profiles.upsert(SshProfileAuth { - destination: "old@example.com".to_owned(), - auth: SshAuthMode::PublicKey, - private_keys: vec![PathBuf::from(r"C:\Keys\id_ed25519")], - }); - - profiles.rename("old@example.com", "new@example.com"); - - assert_eq!(profiles.for_destination("old@example.com").auth, SshAuthMode::Auto); - assert_eq!(profiles.for_destination("new@example.com").auth, SshAuthMode::PublicKey); - } - - #[test] - fn legacy_agent_mode_migrates_to_auto() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("ssh_profiles.json"); - std::fs::write( - &path, - r#"{"version":1,"profiles":[{"destination":"dev@example.com","auth":"agent","private_keys":[]}]}"#, - ) - .unwrap(); - - let loaded = SshProfiles::load(&path).unwrap(); - assert_eq!(loaded.for_destination("dev@example.com").auth, SshAuthMode::Auto); - } - - #[test] - fn unknown_auth_mode_falls_back_to_auto() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("ssh_profiles.json"); - std::fs::write( - &path, - r#"{"version":1,"profiles":[{"destination":"dev@example.com","auth":"future-mode","private_keys":[]}]}"#, - ) - .unwrap(); - - let loaded = SshProfiles::load(&path).unwrap(); - assert_eq!(loaded.for_destination("dev@example.com").auth, SshAuthMode::Auto); - } -} +use std::io; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Deserializer, Serialize}; +use windows_sys::Win32::Storage::FileSystem::{ + MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, +}; + +const PROFILE_VERSION: u32 = 1; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SshAuthMode { + #[default] + Auto, + Password, + PublicKey, + KeyboardInteractive, +} + +impl<'de> Deserialize<'de> for SshAuthMode { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Ok(match value.as_str() { + "password" => Self::Password, + "public_key" => Self::PublicKey, + // v0.5 移除了 Agent;旧配置回退到 Auto,避免升级后配置失效。 + "agent" => Self::Auto, + "keyboard_interactive" => Self::KeyboardInteractive, + _ => Self::Auto, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SshProfileAuth { + pub destination: String, + #[serde(default)] + pub auth: SshAuthMode, + #[serde(default)] + pub private_keys: Vec, + /// 列表里显示的名字。`None` / 空串都表示回落到地址本身,所以旧配置文件 + /// 不需要迁移就能读——`serde(default)` 让缺字段等价于"没起名字"。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SshProfiles { + #[serde(default = "profile_version")] + version: u32, + #[serde(default)] + profiles: Vec, +} + +impl Default for SshProfiles { + fn default() -> Self { + Self { version: PROFILE_VERSION, profiles: Vec::new() } + } +} + +impl SshProfiles { + pub fn load(path: &Path) -> io::Result { + let data = match std::fs::read(path) { + Ok(data) => data, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()), + Err(err) => return Err(err), + }; + let mut profiles: Self = serde_json::from_slice(&data) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + for profile in &mut profiles.profiles { + deduplicate_key_paths(&mut profile.private_keys); + } + profiles.version = PROFILE_VERSION; + Ok(profiles) + } + + pub fn save(&self, path: &Path) -> io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let data = serde_json::to_vec_pretty(self).map_err(io::Error::other)?; + let temporary = path.with_extension(format!("nebula-tmp-{}", std::process::id())); + std::fs::write(&temporary, data)?; + let result = replace_file(&temporary, path); + if result.is_err() { + let _ = std::fs::remove_file(&temporary); + } + result + } + + pub fn for_destination(&self, destination: &str) -> SshProfileAuth { + self.profiles + .iter() + .find(|profile| profile.destination == destination) + .cloned() + .unwrap_or_else(|| SshProfileAuth { + destination: destination.to_owned(), + auth: SshAuthMode::Auto, + private_keys: Vec::new(), + label: None, + }) + } + + /// 地址 → 显示名。侧栏每帧都要画主机行,不能每帧读一次 JSON,所以调用方 + /// 把结果缓存起来,只在编辑器保存后刷新。 + pub fn labels(&self) -> std::collections::HashMap { + self.profiles + .iter() + .filter_map(|profile| { + let label = profile.label.as_deref()?.trim(); + (!label.is_empty()).then(|| (profile.destination.clone(), label.to_owned())) + }) + .collect() + } + + pub fn upsert(&mut self, mut profile: SshProfileAuth) { + deduplicate_key_paths(&mut profile.private_keys); + if let Some(existing) = + self.profiles.iter_mut().find(|existing| existing.destination == profile.destination) + { + *existing = profile; + } else { + self.profiles.push(profile); + } + } + + pub fn remove(&mut self, destination: &str) { + self.profiles.retain(|profile| profile.destination != destination); + } + + pub fn rename(&mut self, old: &str, new: &str) { + let Some(mut profile) = + self.profiles.iter().find(|profile| profile.destination == old).cloned() + else { + return; + }; + self.remove(old); + profile.destination = new.to_owned(); + self.upsert(profile); + } +} + +fn profile_version() -> u32 { + PROFILE_VERSION +} + +fn deduplicate_key_paths(paths: &mut Vec) { + let mut seen = Vec::::new(); + paths.retain(|path| { + let normalized = path.to_string_lossy().to_lowercase(); + if seen.contains(&normalized) { + false + } else { + seen.push(normalized); + true + } + }); +} + +fn replace_file(source: &Path, destination: &Path) -> io::Result<()> { + let source = wide_path(source); + let destination = wide_path(destination); + let ok = unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if ok == 0 { Err(io::Error::last_os_error()) } else { Ok(()) } +} + +fn wide_path(path: &Path) -> Vec { + use std::os::windows::ffi::OsStrExt; + path.as_os_str().encode_wide().chain(std::iter::once(0)).collect() +} + +#[cfg(test)] +mod tests { + use super::{SshAuthMode, SshProfileAuth, SshProfiles}; + use std::path::PathBuf; + + #[test] + fn missing_profile_defaults_to_auto_without_keys() { + let profiles = SshProfiles::default(); + + assert_eq!(profiles.for_destination("dev@example.com").auth, SshAuthMode::Auto); + assert!(profiles.for_destination("dev@example.com").private_keys.is_empty()); + } + + #[test] + fn profile_round_trip_preserves_mode_and_key_order() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ssh_profiles.json"); + let mut profiles = SshProfiles::default(); + profiles.upsert(SshProfileAuth { + destination: "dev@example.com".to_owned(), + auth: SshAuthMode::PublicKey, + private_keys: vec![PathBuf::from(r"C:\Keys\first"), PathBuf::from(r"C:\Keys\second")], + label: None, + }); + profiles.save(&path).unwrap(); + + let loaded = SshProfiles::load(&path).unwrap(); + let profile = loaded.for_destination("dev@example.com"); + assert_eq!(profile.auth, SshAuthMode::PublicKey); + assert_eq!( + profile.private_keys, + vec![PathBuf::from(r"C:\Keys\first"), PathBuf::from(r"C:\Keys\second")] + ); + } + + #[test] + fn duplicate_windows_key_paths_are_removed_without_reordering() { + let mut profiles = SshProfiles::default(); + profiles.upsert(SshProfileAuth { + destination: "dev@example.com".to_owned(), + auth: SshAuthMode::Auto, + private_keys: vec![ + PathBuf::from(r"C:\Keys\id_ed25519"), + PathBuf::from(r"c:\keys\ID_ED25519"), + PathBuf::from(r"C:\Keys\id_rsa"), + ], + label: None, + }); + + assert_eq!( + profiles.for_destination("dev@example.com").private_keys, + vec![PathBuf::from(r"C:\Keys\id_ed25519"), PathBuf::from(r"C:\Keys\id_rsa")] + ); + } + + #[test] + fn renaming_profile_moves_auth_metadata() { + let mut profiles = SshProfiles::default(); + profiles.upsert(SshProfileAuth { + destination: "old@example.com".to_owned(), + auth: SshAuthMode::PublicKey, + private_keys: vec![PathBuf::from(r"C:\Keys\id_ed25519")], + label: None, + }); + + profiles.rename("old@example.com", "new@example.com"); + + assert_eq!(profiles.for_destination("old@example.com").auth, SshAuthMode::Auto); + assert_eq!(profiles.for_destination("new@example.com").auth, SshAuthMode::PublicKey); + } + + #[test] + fn legacy_agent_mode_migrates_to_auto() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ssh_profiles.json"); + std::fs::write( + &path, + r#"{"version":1,"profiles":[{"destination":"dev@example.com","auth":"agent","private_keys":[]}]}"#, + ) + .unwrap(); + + let loaded = SshProfiles::load(&path).unwrap(); + assert_eq!(loaded.for_destination("dev@example.com").auth, SshAuthMode::Auto); + } + + #[test] + fn unknown_auth_mode_falls_back_to_auto() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ssh_profiles.json"); + std::fs::write( + &path, + r#"{"version":1,"profiles":[{"destination":"dev@example.com","auth":"future-mode","private_keys":[]}]}"#, + ) + .unwrap(); + + let loaded = SshProfiles::load(&path).unwrap(); + assert_eq!(loaded.for_destination("dev@example.com").auth, SshAuthMode::Auto); + } +} diff --git a/nebula_app/src/ssh_session.rs b/nebula_app/src/ssh_session.rs index b1cb9cd..052d84b 100644 --- a/nebula_app/src/ssh_session.rs +++ b/nebula_app/src/ssh_session.rs @@ -7,6 +7,7 @@ use std::collections::HashMap; use std::io; use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::Receiver; use std::sync::{Arc, OnceLock}; use std::time::Duration; @@ -27,6 +28,39 @@ type SessionError = Box; type ClientSession = client::Handle; type SharedSession = Arc>; +/// 直连 SSH 会话的连接阶段。 +/// +/// 这些取值不是估算的进度百分比——因为我们用 russh 自己实现客户端,而不是 +/// spawn `ssh.exe` 再解析 `-v` 输出,每个阶段都对应一个真实的调用点: +/// [`SshDestination::resolve`] → [`client::connect`] → [`authenticate`] → +/// `channel_open_session` + `request_pty` + `request_shell`。 +/// +/// 连接池命中时 [`authenticated_session`] 直接返回既有连接,`Connect` 与 +/// `Authenticate` 都不会上报:复用是瞬时的,连接卡片也就不会浮出来。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SshStage { + /// 正在解析地址(含 `ssh -G` 读 `~/.ssh/config`)。 + Resolve, + /// 正在建立 TCP 连接并完成协议握手/密钥交换。 + Connect, + /// 正在认证。 + Authenticate, + /// 正在打开 channel、申请 pty 与 shell。 + OpenShell, + /// 会话已就绪:卡片让位给真实终端。 + Ready, + /// 连接失败,附带面向用户的原因。 + Failed(String), +} + +/// 向拥有该 pane 的窗口上报连接阶段。[`EventProxy`] 自带 `tab_id`,所以 +/// 后台 runtime 不需要知道 pane id 或 window id。 +fn report_stage(progress: Option<&EventProxy>, stage: SshStage) { + if let Some(proxy) = progress { + proxy.send_event(crate::event::EventType::SshConnect(stage)); + } +} + #[derive(Debug, Clone, PartialEq, Eq)] enum AuthMethod { PrivateKey(PathBuf), @@ -314,6 +348,9 @@ pub fn spawn_session( let (sender, receiver) = EventLoopSender::standalone()?; let profiles_path = crate::display::nebula_data_dir().join("ssh_profiles.json"); runtime()?.spawn(async move { + // 地址解析是第一个真实阶段:`ssh -G` 要读 ~/.ssh/config,慢链路上 + // 这一步本身就可能耗时。 + report_stage(Some(&event_proxy), SshStage::Resolve); let raw = destination.clone(); let resolved = tokio::task::spawn_blocking(move || { let resolved = SshDestination::resolve(&raw)?; @@ -327,6 +364,10 @@ pub fn spawn_session( Ok::<_, io::Error>((resolved, profiles.for_destination(&raw))) }) .await; + // 会话是否曾经就绪。它把两种 `Err` 分开:连接建立**之前**失败要把 + // 卡片留在屏幕上让用户读原因;建立**之后**断开则是普通的会话结束, + // 必须照常退出 pane,否则会留下一个连不上也关不掉的空壳。 + let ready = Arc::new(AtomicBool::new(false)); let result = match resolved { Ok(Ok((destination, profile))) => { run_session_async( @@ -336,17 +377,33 @@ pub fn spawn_session( terminal.clone(), event_proxy.clone(), receiver, + ready.clone(), ) .await }, Ok(Err(err)) => Err(err.into()), Err(err) => Err(format!("SSH 地址解析任务失败: {err}").into()), }; - if let Err(err) = result { - error!("直连 SSH 会话失败 {destination}: {err}"); - render_error(&terminal, &event_proxy, &format!("SSH 连接失败: {err}")); + match result { + Err(err) if !ready.load(Ordering::Relaxed) => { + // 这里刻意**不用** `error!`:它会额外推一条红色 message bar, + // 而连接卡片已经把同一条原因连同阶段和日志一起呈现了。同一个 + // 错误报两次,其中一次还被卡片的遮罩切成半残的红条。 + info!("直连 SSH 会话失败 {destination}: {err}"); + report_stage(Some(&event_proxy), SshStage::Failed(err.to_string())); + // 错误也写进 grid:卡片关掉之后它仍然留在回滚里。 + render_error(&terminal, &event_proxy, &format!("SSH 连接失败: {err}")); + // 这里**不** exit:pane 一退出 tab 就关了,卡片和它携带的 + // 失败原因会一起消失。收尾交给卡片上的「关闭」。 + }, + Err(err) => { + // 会话中途断开时没有卡片在场,message bar 是唯一的提示渠道。 + error!("直连 SSH 会话中断 {destination}: {err}"); + render_error(&terminal, &event_proxy, &format!("SSH 连接失败: {err}")); + terminal.lock().exit(); + }, + Ok(()) => terminal.lock().exit(), } - terminal.lock().exit(); event_proxy.send_event(TerminalEvent::Wakeup.into()); }); Ok(sender) @@ -359,12 +416,14 @@ async fn run_session_async( terminal: Arc>>, event_proxy: EventProxy, receiver: Receiver, + ready: Arc, ) -> Result<(), SessionError> { if let Some(proxy_jump) = destination.proxy_jump.as_deref() { return Err(format!("当前直连模式尚未接入跳板机 {proxy_jump}").into()); } - let session = authenticated_session(&destination, &profile).await?; + let session = authenticated_session(&destination, &profile, Some(&event_proxy)).await?; + report_stage(Some(&event_proxy), SshStage::OpenShell); let mut channel = { let session = session.lock().await; session.channel_open_session().await? @@ -383,6 +442,10 @@ async fn run_session_async( let hook_token = remote_hook_token()?; channel.set_env(false, "NEBULA_REMOTE_HOOK_TOKEN", hook_token.clone()).await?; channel.request_shell(true).await?; + // Shell 已就绪:连接卡片到此让位给真实终端,持续重绘随之停止。此后再 + // 出错就是会话中途断开,不该复活卡片。 + ready.store(true, Ordering::Relaxed); + report_stage(Some(&event_proxy), SshStage::Ready); let (input_tx, mut input_rx) = tokio::sync::mpsc::unbounded_channel(); tokio::task::spawn_blocking(move || { @@ -441,11 +504,14 @@ async fn wait_for_sync(deadline: Option) { async fn authenticated_session( destination: &SshDestination, profile: &crate::ssh_profiles::SshProfileAuth, + progress: Option<&EventProxy>, ) -> Result { let key = destination.pool_key(); if let Some(existing) = connection_pool().lock().await.get(&key).cloned() { if !existing.lock().await.is_closed() { info!("复用已认证 SSH 连接: {key}"); + // 复用不上报 Connect/Authenticate:这条路径是瞬时的,交给 + // 350ms 门槛把卡片整个吃掉,用户看到的就是直接出 prompt。 return Ok(existing); } connection_pool().lock().await.remove(&key); @@ -458,8 +524,10 @@ async fn authenticated_session( ..Default::default() }); let handler = ClientHandler { host: destination.host.clone(), port: destination.port }; + report_stage(progress, SshStage::Connect); let mut session = client::connect(config, (destination.host.as_str(), destination.port), handler).await?; + report_stage(progress, SshStage::Authenticate); authenticate(&mut session, destination, profile).await?; let session = Arc::new(tokio::sync::Mutex::new(session)); @@ -579,7 +647,8 @@ pub(crate) async fn open_sftp( return Err(format!("当前 SFTP 模式尚未接入跳板机 {proxy_jump}").into()); } - let session = authenticated_session(&destination, &profile).await?; + // SFTP 面板自己有加载态,不参与终端 pane 的连接卡片。 + let session = authenticated_session(&destination, &profile, None).await?; let channel = { let session = session.lock().await; session.channel_open_session().await? diff --git a/nebula_app/src/window_context.rs b/nebula_app/src/window_context.rs index ff24a4f..11e57e8 100644 --- a/nebula_app/src/window_context.rs +++ b/nebula_app/src/window_context.rs @@ -122,11 +122,11 @@ fn preferred_initial_cwd( /// per-frame allocation is introduced. #[inline] fn chrome_clock_interval( - any_tab_running: bool, + spinner_running: bool, editor_active: bool, chrome_animating: bool, ) -> Duration { - if any_tab_running { + if spinner_running { Duration::from_micros(16_667) } else if editor_active || chrome_animating { Duration::from_millis(125) @@ -1783,6 +1783,16 @@ impl WindowContext { self.panes.iter().position(|p| p.id == id) } + /// 把后台 SSH runtime 的阶段上报转给 display,顺带补上这个 pane 的目标 + /// 地址——事件本身只带 pane 身份,地址在 pane 上。 + pub fn ssh_connect_stage(&mut self, pane: PaneId, stage: crate::ssh_session::SshStage) { + let destination = self + .pane_index(pane) + .and_then(|index| self.panes[index].ssh_destination.clone()) + .unwrap_or_default(); + self.display.ssh_connect_stage(pane, destination, stage); + } + /// Working directory of the focused pane (from the shell's `NEBULA|cwd|…` /// title report) for a new tab/split to inherit. `None` if unknown. fn focused_cwd(&self) -> Option { @@ -2079,6 +2089,9 @@ impl WindowContext { let mut ids = Vec::new(); entry.layout.leaves(&mut ids); for id in ids { + // 连接中的 tab 被关掉:丢弃它的卡片状态,否则 HashMap 会随 + // 会话累积,而那个 pane 再也不会回来。 + self.display.forget_ssh_connect(id); if let Some(i) = self.pane_index(id) { let pane = self.panes.remove(i); let _ = pane.notifier.0.send(Msg::Shutdown); @@ -2233,7 +2246,7 @@ impl WindowContext { // the cadence class changes. let clock_timer = TimerId::new(Topic::NebulaClock, self.display.window.id()); let interval = chrome_clock_interval( - self.display.any_tab_running(), + self.display.any_tab_running() || self.display.ssh_test_running(), self.display.chrome_editor_active(), self.display.chrome_animating(), ); @@ -2303,6 +2316,9 @@ impl WindowContext { return; } + // 连接卡片只画在聚焦 pane 里,display 侧只有几何、没有身份。 + self.display.set_focused_pane(focused); + if pane_rects.len() <= 1 { let id = pane_rects.first().map(|(id, _)| *id).unwrap_or(focused); if let Some(idx) = self.pane_index(id) { diff --git a/nebula_app/src/window_context/split.rs b/nebula_app/src/window_context/split.rs index 9b7624e..eb185fa 100644 --- a/nebula_app/src/window_context/split.rs +++ b/nebula_app/src/window_context/split.rs @@ -506,6 +506,7 @@ impl WindowContext { match self.tabs[tab_idx].layout.remove_leaf(id) { RemoveResult::WasRoot => self.close_tab(tab_idx), RemoveResult::Collapsed(focus) => { + self.display.forget_ssh_connect(id); if let Some(i) = self.pane_index(id) { let pane = self.panes.remove(i); let _ = pane.notifier.0.send(Msg::Shutdown); diff --git a/scripts/package-release.ps1 b/scripts/package-release.ps1 index 8c5a3a4..1e2c03b 100644 --- a/scripts/package-release.ps1 +++ b/scripts/package-release.ps1 @@ -8,7 +8,17 @@ param( [switch] $SkipBuild, [switch] $Force, - [string] $OutputDirectory + [string] $OutputDirectory, + + # Cargo target directory to build into and package from. Defaults to the + # repo's own `target/`. + # + # Windows refuses to overwrite a running exe, so packaging while a Nebula + # instance is live out of `target/release/nebula.exe` dies with a bare + # "拒绝访问 (os error 5)" that names the linker, not the real cause. Point + # this at a separate directory to build a package without touching — let + # alone killing — the instance the user is working in. + [string] $TargetDirectory ) $ErrorActionPreference = 'Stop' @@ -20,8 +30,14 @@ if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { } elseif (-not [System.IO.Path]::IsPathRooted($OutputDirectory)) { $OutputDirectory = Join-Path $repo $OutputDirectory } +if ([string]::IsNullOrWhiteSpace($TargetDirectory)) { + $TargetDirectory = Join-Path $repo 'target' +} elseif (-not [System.IO.Path]::IsPathRooted($TargetDirectory)) { + $TargetDirectory = Join-Path $repo $TargetDirectory +} +$cargoTargetRoot = [System.IO.Path]::GetFullPath($TargetDirectory) $outputRoot = [System.IO.Path]::GetFullPath($OutputDirectory) -$targetRoot = Join-Path $repo "target\$Configuration" +$targetRoot = Join-Path $cargoTargetRoot $Configuration $stage = Join-Path $outputRoot ".stage-$Version-$PID" $zipPath = Join-Path $outputRoot "NebulaTerminal-v$Version-windows-x64.zip" $temporaryZip = Join-Path $outputRoot ".NebulaTerminal-v$Version-windows-x64-$PID.tmp.zip" @@ -71,7 +87,9 @@ function Remove-StageSafely([string] $Path) { if (-not $SkipBuild) { Push-Location $repo + $previousTargetDirectory = $env:CARGO_TARGET_DIR try { + $env:CARGO_TARGET_DIR = $cargoTargetRoot if ($Configuration -eq 'release') { & cargo build --workspace --release } else { @@ -81,6 +99,7 @@ if (-not $SkipBuild) { throw "Cargo build failed with exit code $LASTEXITCODE" } } finally { + $env:CARGO_TARGET_DIR = $previousTargetDirectory Pop-Location } } diff --git a/scripts/probe_ssh_connect.ps1 b/scripts/probe_ssh_connect.ps1 new file mode 100644 index 0000000..94289a3 --- /dev/null +++ b/scripts/probe_ssh_connect.ps1 @@ -0,0 +1,50 @@ +# Isolated probe instance for the SSH connect card. +# +# One APPDATA isolates every mutable file, mux.port included -- without that +# the launch just pings the existing instance and exits, so no new window. +# USERPROFILE / CLAUDE_CONFIG_DIR / CODEX_HOME / XDG_CONFIG_HOME are isolated +# too, so the AI hook self-heal cannot rewrite the real user config. +# +# NOTE: comments must stay pure ASCII. Windows PowerShell 5.1 reads a BOM-less +# UTF-8 script as ANSI (GBK here), which corrupts non-ASCII bytes and breaks +# the parser -- same trap as .cmd files. +param([switch]$Kill) + +$root = 'D:\temp_build\.probe-ssh' + +if ($Kill) { + Get-Process nebula -ErrorAction SilentlyContinue | + Where-Object { $_.Path -like '*\target\debug\nebula.exe' } | + Stop-Process -Force + return +} + +Remove-Item -Recurse -Force $root -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path "$root\appdata\Nebula", "$root\home" | Out-Null + +# saved_hosts is what actually populates the sidebar list; pinned_hosts only +# reorders it (see merge_ssh_hosts). Writing as ASCII on purpose: PS 5.1's +# -Encoding UTF8 emits a BOM, and the BOM would end up glued to the first key +# so the settings parser would never match it. +# +# 192.0.2.1 is RFC 5737 TEST-NET-1 and unroutable: the connection sits in the +# Connect stage for a long time, which is exactly what we need to watch the +# particles. 127.0.0.1 has no sshd here, so it fails fast -- that covers the +# failure state. +$settings = @( + 'saved_hosts=root@192.0.2.1,root@127.0.0.1', + 'pinned_hosts=root@192.0.2.1', + 'keep_session=false' +) -join [Environment]::NewLine +Set-Content -Path "$root\appdata\Nebula\nebula_settings.txt" -Value $settings -Encoding ascii + +$env:APPDATA = "$root\appdata" +$env:USERPROFILE = "$root\home" +$env:CLAUDE_CONFIG_DIR = "$root\home\.claude" +$env:CODEX_HOME = "$root\home\.codex" +$env:XDG_CONFIG_HOME = "$root\home\.config" +$env:NEBULA_DEBUG_LOG = '1' + +$exe = 'D:\temp_build\nebula\target\debug\nebula.exe' +$proc = Start-Process -FilePath $exe -PassThru +Write-Output ("pid=" + $proc.Id)