Improve SSH editor interactions and transitions

This commit is contained in:
kuddev committed 2026-07-13 11:50:03 +08:00
1 parent dda80a1eca
commit c2b2f731b0
3 files changed
+302 -53

No files matched your search

+262 -41
View File
@@ -230,6 +230,10 @@ impl UiAnim {
}
fn step(&mut self, target: f32) {
self.step_with_speed(target, 16.0);
}
fn step_with_speed(&mut self, target: f32, speed: f32) {
let target = target.clamp(0.0, 1.0);
if (self.target - target).abs() > f32::EPSILON {
self.target = target;
@@ -244,7 +248,7 @@ impl UiAnim {
let now = Instant::now();
let dt =
self.last_step.replace(now).map_or(1.0 / 60.0, |t| (now - t).as_secs_f32().min(0.1));
self.value += (target - self.value) * (1.0 - (-16.0 * dt).exp());
self.value += (target - self.value) * (1.0 - (-speed.max(1.0) * dt).exp());
if (self.value - target).abs() < 0.004 {
self.value = target;
self.last_step = None;
@@ -256,11 +260,16 @@ impl UiAnim {
struct NebulaUiAnims {
left_sidebar: UiAnim,
right_drawer: UiAnim,
ssh_editor: UiAnim,
}
impl NebulaUiAnims {
fn new() -> Self {
Self { left_sidebar: UiAnim::new(1.0), right_drawer: UiAnim::new(0.0) }
Self {
left_sidebar: UiAnim::new(1.0),
right_drawer: UiAnim::new(0.0),
ssh_editor: UiAnim::new(0.0),
}
}
fn step(&mut self, left_open: bool, right_open: bool) {
@@ -424,11 +433,24 @@ pub enum SshEditorField {
Password,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SshEditorHit {
None,
Destination,
Password,
PasswordToggle,
SaveToggleBox,
SaveToggleLabel,
Primary,
Cancel,
}
#[derive(Debug, Clone)]
pub struct SshHostEditor {
pub destination: String,
pub password: String,
pub save_password: bool,
pub show_password: bool,
pub field: SshEditorField,
}
@@ -436,6 +458,8 @@ pub struct SshHostEditor {
pub struct SshEditorRects {
pub destination: (f32, f32, f32, f32),
pub password: (f32, f32, f32, f32),
pub password_toggle: (f32, f32, f32, f32),
pub save_checkbox: (f32, f32, f32, f32),
pub save_toggle: (f32, f32, f32, f32),
pub primary: (f32, f32, f32, f32),
pub cancel: (f32, f32, f32, f32),
@@ -1293,6 +1317,8 @@ pub struct Display {
pub nebula_confirm_buttons: Option<((f32, f32, f32, f32), (f32, f32, f32, f32))>,
pub nebula_ssh_editor: Option<SshHostEditor>,
pub nebula_ssh_editor_rects: Option<SshEditorRects>,
nebula_ssh_editor_open: bool,
nebula_ssh_editor_hover: SshEditorHit,
/// Inline images visible this frame, collected per pane during
/// `draw_pane` (grid lock + pane viewport at hand) and drawn in one
/// full-window pass in `present_frame` — mid-pane GL viewport swaps are
@@ -1646,6 +1672,8 @@ impl Display {
nebula_confirm_buttons: None,
nebula_ssh_editor: None,
nebula_ssh_editor_rects: None,
nebula_ssh_editor_open: false,
nebula_ssh_editor_hover: SshEditorHit::None,
nebula_frame_images: Vec::new(),
nebula_theme: settings_init.theme,
nebula_settings_open: false,
@@ -1754,12 +1782,59 @@ impl Display {
destination: String::new(),
password: String::new(),
save_password: true,
show_password: false,
field: SshEditorField::Destination,
});
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 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 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.destination) {
SshEditorHit::Destination
} 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.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() {
let field = match editor.field {
@@ -1807,6 +1882,12 @@ impl Display {
}
return true;
}
if hit(rects.password_toggle) {
if let Some(e) = self.nebula_ssh_editor.as_mut() {
e.show_password = !e.show_password;
}
return true;
}
if hit(rects.password) {
if let Some(e) = self.nebula_ssh_editor.as_mut() {
e.field = SshEditorField::Password;
@@ -1818,8 +1899,7 @@ impl Display {
return true;
}
if hit(rects.cancel) {
self.nebula_ssh_editor = None;
self.nebula_ssh_editor_rects = None;
self.close_ssh_editor();
return true;
}
if hit(rects.primary) {
@@ -1854,10 +1934,11 @@ impl Display {
);
}
}
// 凭据落盘后立即清除内存中的明文,但保留其余内容完成短退出动画。
editor.password.clear();
self.persist_nebula_settings();
self.nebula_ssh_editor_rects = None;
self.pending_update.dirty = true;
self.nebula_ssh_editor = Some(editor);
self.close_ssh_editor();
}
pub fn delete_ssh_host(&mut self, index: usize) {
@@ -3983,6 +4064,16 @@ impl Display {
}
fn draw_ssh_editor_modal(&mut self) {
self.nebula_ui_anims
.ssh_editor
.step_with_speed(if self.nebula_ssh_editor_open { 1.0 } else { 0.0 }, 26.0);
let progress = self.nebula_ui_anims.ssh_editor.value().clamp(0.0, 1.0);
if !self.nebula_ssh_editor_open && progress <= 0.004 {
self.nebula_ssh_editor = None;
self.nebula_ssh_editor_rects = None;
self.nebula_ssh_editor_hover = SshEditorHit::None;
return;
}
let Some(editor) = self.nebula_ssh_editor.clone() else {
self.nebula_ssh_editor_rects = None;
return;
@@ -3991,31 +4082,59 @@ impl Display {
let scale = self.window.scale_factor as f32;
let s = |v: f32| v * scale;
let sk = self.nebula_theme.skin();
let editor_focus = Rgba::new(108, 126, 148, 210);
let editor_primary = Rgba::new(104, 121, 141, 235);
let editor_check = Rgba::new(117, 136, 158, 230);
let editor_field_active = Rgba::new(69, 78, 91, 220);
let editor_field_idle = Rgba::new(58, 64, 75, 205);
let editor_caret = Rgba::new(205, 214, 225, 235);
let accent = Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255);
let editor_caret = Rgba::new(sk.ink_strong.r, sk.ink_strong.g, sk.ink_strong.b, 235);
let cell_h = size.cell_height();
let cell_w = size.cell_width();
let text_w = |text: &str| -> f32 {
text.chars().map(|c| c.width().unwrap_or(1)).sum::<usize>() as f32 * cell_w
};
let primary_label = "保存 Enter";
let cancel_label = "取消 Esc";
let save_label = "保存密码到 Windows 凭据管理器";
let box_w = s(560.0).min(size.width() - s(32.0));
let box_h = s(330.0).min(size.height() - s(32.0));
let bx = (size.width() - box_w) * 0.5;
let by = (size.height() - box_h) * 0.5;
// A short downward settle gives the modal direction without making it
// travel across the whole window. Closing reverses the same path upward.
let resting_y = (size.height() - box_h) * 0.5;
let by = resting_y - (1.0 - progress) * s(14.0);
let pad = s(28.0);
let field_h = s(42.0);
let field_w = box_w - pad * 2.0;
let destination = (bx + pad, by + s(86.0), field_w, field_h);
let password = (bx + pad, by + s(162.0), field_w, field_h);
let save_toggle = (bx + pad, by + s(220.0), s(180.0), s(28.0));
let primary = (bx + box_w - pad - s(112.0), by + box_h - s(58.0), s(112.0), s(36.0));
let cancel = (primary.0 - s(124.0), primary.1, s(112.0), s(36.0));
self.nebula_ssh_editor_rects =
Some(SshEditorRects { destination, password, save_toggle, primary, cancel });
let password_toggle =
(password.0 + password.2 - s(38.0), password.1 + s(4.0), s(34.0), password.3 - s(8.0));
// The whole label remains clickable, while the visual hover target is
// deliberately limited to the checkbox itself.
let save_toggle =
(bx + pad, by + s(220.0), (s(28.0) + text_w(save_label)).min(field_w), s(28.0));
let save_checkbox = (save_toggle.0, save_toggle.1 + s(5.0), s(18.0), s(18.0));
let button_pad = s(16.0);
let primary_w = s(112.0).max(text_w(primary_label) + button_pad * 2.0);
let cancel_w = s(112.0).max(text_w(cancel_label) + button_pad * 2.0);
let primary = (bx + box_w - pad - primary_w, by + box_h - s(58.0), primary_w, s(36.0));
let cancel = (primary.0 - s(12.0) - cancel_w, primary.1, cancel_w, s(36.0));
self.nebula_ssh_editor_rects = Some(SshEditorRects {
destination,
password,
password_toggle,
save_checkbox,
save_toggle,
primary,
cancel,
});
let mut quads = vec![
UiQuad::solid(0.0, 0.0, size.width(), size.height(), 0.0, Rgba::new(0, 0, 0, 170)),
UiQuad::solid(
0.0,
0.0,
size.width(),
size.height(),
0.0,
Rgba::new(0, 0, 0, (170.0 * progress).round() as u8),
),
UiQuad::solid(
bx - s(1.0),
by - s(1.0),
@@ -4030,46 +4149,111 @@ impl Display {
(destination, editor.field == SshEditorField::Destination),
(password, editor.field == SshEditorField::Password),
] {
let hovered = matches!(
(rect == destination, self.nebula_ssh_editor_hover),
(true, SshEditorHit::Destination) | (false, SshEditorHit::Password)
);
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),
if active { editor_focus } else { sk.hairline },
if active {
accent
} else if hovered {
sk.hover_strong
} else {
sk.hairline
},
));
quads.push(UiQuad::solid(rect.0, rect.1, rect.2, rect.3, s(6.0), sk.input));
if hovered && !active {
quads.push(UiQuad::solid(rect.0, rect.1, rect.2, rect.3, s(6.0), sk.surface));
}
}
if self.nebula_ssh_editor_hover == SshEditorHit::SaveToggleBox {
quads.push(UiQuad::solid(
rect.0,
rect.1,
rect.2,
rect.3,
save_checkbox.0 - s(2.0),
save_checkbox.1 - s(2.0),
save_checkbox.2 + s(4.0),
save_checkbox.3 + s(4.0),
s(6.0),
if active { editor_field_active } else { editor_field_idle },
sk.hover_strong,
));
}
if self.nebula_ssh_editor_hover == SshEditorHit::PasswordToggle {
quads.push(UiQuad::solid(
password_toggle.0,
password_toggle.1,
password_toggle.2,
password_toggle.3,
s(6.0),
sk.hover,
));
}
// Keep the dark outline requested for the light theme, but use a clean
// input-colored interior instead of a solid black status block.
let checkbox_edge =
if sk.is_light { Rgba::new(sk.ink.r, sk.ink.g, sk.ink.b, 210) } else { sk.hairline };
quads.push(UiQuad::solid(
save_toggle.0,
save_toggle.1 + s(5.0),
s(18.0),
s(18.0),
s(4.0),
if editor.save_password { editor_check } else { sk.track_off },
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),
checkbox_edge,
));
quads.push(UiQuad::solid(cancel.0, cancel.1, cancel.2, cancel.3, s(8.0), sk.surface));
quads.push(UiQuad::solid(
save_checkbox.0,
save_checkbox.1,
save_checkbox.2,
save_checkbox.3,
s(4.0),
sk.input,
));
for (rect, edge) in
[(cancel, sk.hairline), (primary, if sk.is_light { accent } else { sk.hairline })]
{
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(9.0),
edge,
));
}
quads.push(UiQuad::solid(cancel.0, cancel.1, cancel.2, cancel.3, s(8.0), sk.input));
quads.push(UiQuad::solid(
primary.0,
primary.1,
primary.2,
primary.3,
s(8.0),
editor_primary,
if sk.is_light { sk.input } else { accent },
));
if self.nebula_ssh_editor_hover == SshEditorHit::Cancel {
quads.push(UiQuad::solid(cancel.0, cancel.1, cancel.2, cancel.3, s(8.0), sk.hover));
}
if self.nebula_ssh_editor_hover == SshEditorHit::Primary {
quads.push(UiQuad::solid(
primary.0,
primary.1,
primary.2,
primary.3,
s(8.0),
if sk.is_light { sk.hover } else { sk.hover_strong },
));
}
let (caret_rect, caret_cols) = match editor.field {
SshEditorField::Destination => (destination, editor.destination.chars().count()),
SshEditorField::Password => (password, editor.password.chars().count()),
};
let caret_x = (caret_rect.0 + s(12.0) + caret_cols as f32 * cell_w)
.min(caret_rect.0 + caret_rect.2 - s(10.0));
let caret_x = (caret_rect.0 + s(12.0) + caret_cols as f32 * cell_w).min(
caret_rect.0 + caret_rect.2
- if editor.field == SshEditorField::Password { s(48.0) } else { s(10.0) },
);
quads.push(UiQuad::solid(
caret_x,
caret_rect.1 + s(10.0),
@@ -4119,6 +4303,8 @@ impl Display {
);
let masked = if editor.password.is_empty() {
"连接时询问".to_owned()
} else if editor.show_password {
editor.password.clone()
} else {
"•".repeat(editor.password.chars().count())
};
@@ -4130,30 +4316,65 @@ impl Display {
&masked,
gc,
);
const ICON_EYE: &str = "\u{ea70}";
const ICON_EYE_CLOSED: &str = "\u{eae7}";
let eye_icon = if editor.show_password { ICON_EYE_CLOSED } else { ICON_EYE };
self.renderer.draw_chrome_text(
&size,
password_toggle.0 + (password_toggle.2 - cell_w) * 0.5,
password_toggle.1 + (password_toggle.3 - cell_h) * 0.5,
if self.nebula_ssh_editor_hover == SshEditorHit::PasswordToggle {
sk.icon_hover
} else {
sk.icon
},
eye_icon,
gc,
);
self.renderer.draw_chrome_text(
&size,
save_toggle.0 + s(28.0),
save_toggle.1 + (save_toggle.3 - cell_h) / 2.0,
sk.ink,
"保存密码到 Windows 凭据管理器",
save_label,
gc,
);
if editor.save_password {
const ICON_CHECK: &str = "\u{eab2}";
self.renderer.draw_chrome_text(
&size,
save_checkbox.0 + (save_checkbox.2 - cell_w) * 0.5,
save_checkbox.1 + (save_checkbox.3 - cell_h) * 0.5,
if sk.is_light { sk.ink_strong } else { sk.icon_hover },
ICON_CHECK,
gc,
);
}
self.renderer.draw_chrome_text(
&size,
cancel.0 + s(30.0),
cancel.0 + (cancel.2 - text_w(cancel_label)) * 0.5,
cancel.1 + (cancel.3 - cell_h) / 2.0,
sk.ink,
"取消 Esc",
cancel_label,
gc,
);
self.renderer.draw_chrome_text(
&size,
primary.0 + s(24.0),
primary.0 + (primary.2 - text_w(primary_label)) * 0.5,
primary.1 + (primary.3 - cell_h) / 2.0,
sk.ink_on_accent,
"保存 Enter",
if sk.is_light { sk.accent } else { sk.ink_on_accent },
primary_label,
gc,
);
if self.nebula_ui_anims.ssh_editor.animating_to(if self.nebula_ssh_editor_open {
1.0
} else {
0.0
}) {
self.pending_update.dirty = true;
self.window.request_redraw();
}
}
/// Draw the window chrome and present the accumulated frame.
+11 -9
View File
@@ -139,15 +139,17 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
}
if self.ctx.display().nebula_ssh_editor.is_some() {
match &key.logical_key {
Key::Named(NamedKey::Escape) => self.ctx.display().nebula_ssh_editor = None,
Key::Named(NamedKey::Enter) => self.ctx.display().save_ssh_editor(),
Key::Named(NamedKey::Tab) => self.ctx.display().ssh_editor_next_field(),
Key::Named(NamedKey::Backspace) => self.ctx.display().ssh_editor_backspace(),
Key::Character(c) if mods.is_empty() || mods.shift_key() => {
self.ctx.display().ssh_editor_insert(c)
},
_ => {},
if self.ctx.display().ssh_editor_active() {
match &key.logical_key {
Key::Named(NamedKey::Escape) => self.ctx.display().close_ssh_editor(),
Key::Named(NamedKey::Enter) => self.ctx.display().save_ssh_editor(),
Key::Named(NamedKey::Tab) => self.ctx.display().ssh_editor_next_field(),
Key::Named(NamedKey::Backspace) => self.ctx.display().ssh_editor_backspace(),
Key::Character(c) if mods.is_empty() || mods.shift_key() => {
self.ctx.display().ssh_editor_insert(c)
},
_ => {},
}
}
self.ctx.mark_dirty();
return;
+29 -3
View File
@@ -562,6 +562,30 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
self.ctx.mouse_mut().x = x;
self.ctx.mouse_mut().y = y;
// The SSH editor is modal. Its controls own hover/cursor feedback while
// open, and the closing animation swallows pointer motion until the
// retained editor snapshot is released.
if self.ctx.display().nebula_ssh_editor.is_some() {
let hit = if self.ctx.display().ssh_editor_active() {
self.ctx.display().ssh_editor_hit(x as f32, y as f32)
} else {
crate::display::SshEditorHit::None
};
self.ctx.display().set_ssh_editor_hover(hit);
let cursor = match hit {
crate::display::SshEditorHit::Destination
| crate::display::SshEditorHit::Password => CursorIcon::Text,
crate::display::SshEditorHit::SaveToggleBox
| crate::display::SshEditorHit::SaveToggleLabel
| crate::display::SshEditorHit::PasswordToggle
| crate::display::SshEditorHit::Primary
| crate::display::SshEditorHit::Cancel => CursorIcon::Pointer,
crate::display::SshEditorHit::None => CursorIcon::Default,
};
self.ctx.window().set_mouse_cursor(cursor);
return;
}
// Nebula: while the left button holds a grabbed tab, pointer motion
// drives the reorder drag instead of hover / text selection.
if lmb_pressed && self.ctx.display().tab_drag_armed() {
@@ -1022,9 +1046,11 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
}
if button == MouseButton::Left && self.ctx.display().nebula_ssh_editor.is_some() {
let x = self.ctx.mouse().x as f32;
let y = self.ctx.mouse().y as f32;
self.ctx.display().ssh_editor_click(x, y);
if self.ctx.display().ssh_editor_active() {
let x = self.ctx.mouse().x as f32;
let y = self.ctx.mouse().y as f32;
self.ctx.display().ssh_editor_click(x, y);
}
self.ctx.mark_dirty();
return;
}