Files
pebrel/nebula_app/src/display/context_menu.rs
T

710 lines
24 KiB
Rust

//! Unified native context menu for sidebar tabs and SSH destinations.
//!
//! The menu is rendered by Nebula's existing OpenGL UI pipeline. Geometry,
//! hit-testing and animation live together here so pointer targets cannot drift
//! away from the pixels the user sees at non-integer DPI scales.
pub use super::context_menu_model::TAB_COLORS;
use super::ui::keycap;
use super::ui::surface::{self, fade as alpha};
use super::ui::tokens::{control, space};
use super::*;
// Maple Mono Normal NF CN ships the stable Codicon block below. Keep menu
// symbols on that block: newer Codicon additions such as U+EC86 are absent
// from the installed Maple version and rasterize as a missing-glyph box.
const ICON_COPY: &str = "\u{ebcc}";
const ICON_FORK: &str = "\u{ea68}";
const ICON_EXPORT: &str = "\u{eb4b}";
const ICON_SPLIT_RIGHT: &str = "\u{eb56}";
const ICON_SPLIT_DOWN: &str = "\u{eb57}";
const ICON_EDIT: &str = "\u{ea73}";
const ICON_CLOSE: &str = "\u{ea76}";
#[derive(Debug, Clone)]
pub(super) struct ContextMenu {
target: ContextMenuTarget,
anchor: (f32, f32),
motion: crate::motion::Tween,
hover: Option<ContextMenuAction>,
current_color: Option<Rgb>,
ai_fork: bool,
}
impl ContextMenu {
pub(super) fn new(
target: ContextMenuTarget,
anchor: (f32, f32),
current_color: Option<Rgb>,
) -> Self {
let mut motion = crate::motion::Tween::new(0.0);
motion.animate_role(
1.0,
crate::motion::MotionRole::Enter,
crate::motion::MotionPolicy::Full,
);
Self { target, anchor, motion, hover: None, current_color, ai_fork: false }
}
/// Enable the current-session fork row only after the pane owner has
/// verified both a hook identity and an official fork command shape.
pub(super) fn with_ai_fork(mut self, enabled: bool) -> Self {
self.ai_fork = enabled;
self
}
pub(super) fn begin_close(&mut self) {
if self.motion.target() != 0.0 {
self.motion.animate_role(
0.0,
crate::motion::MotionRole::Exit,
crate::motion::MotionPolicy::Full,
);
}
self.hover = None;
}
pub(super) fn set_hover(&mut self, action: Option<ContextMenuAction>) -> bool {
if self.hover == action {
return false;
}
self.hover = action;
true
}
pub(super) fn target(&self) -> ContextMenuTarget {
self.target
}
pub(super) fn finished(&self) -> bool {
self.motion.target() == 0.0 && !self.motion.is_active()
}
pub(super) fn animating(&self) -> bool {
self.motion.is_active()
}
pub(super) fn interactive(&self) -> bool {
self.motion.target() != 0.0
}
}
#[derive(Debug, Clone, Copy)]
struct Row {
action: ContextMenuAction,
icon: &'static str,
label: &'static str,
hint: &'static str,
rect: (f32, f32, f32, f32),
danger: bool,
}
fn row_shortcut<'a>(row: &Row, rename: &'a str) -> &'a str {
if matches!(row.action, ContextMenuAction::RenameTab(_)) { rename } else { row.hint }
}
#[derive(Debug, Clone)]
struct MenuLayout {
panel: (f32, f32, f32, f32),
rows: Vec<Row>,
separators: Vec<(f32, f32, f32, f32)>,
color_label: Option<(f32, f32)>,
colors: Vec<(Option<Rgb>, ContextMenuAction, (f32, f32, f32, f32))>,
}
fn contains((x, y, w, h): (f32, f32, f32, f32), px: f32, py: f32) -> bool {
px >= x && px <= x + w && py >= y && py <= y + h
}
fn layout(menu: &ContextMenu, size: SizeInfo, scale: f32, animated_y_offset: f32) -> MenuLayout {
let s = |v: f32| v * scale;
let width = s(252.0);
let row_h = s(38.0);
let pad = s(7.0);
let sep_h = s(9.0);
let color_top_pad = s(space::XS);
let color_label_gap = s(space::XS);
let swatch = s(control::ICON_BUTTON);
let color_bottom_pad = s(10.0);
let color_h = color_top_pad + size.cell_height() + color_label_gap + swatch + color_bottom_pad;
let (row_count, separators, extra) = match menu.target {
ContextMenuTarget::Tab(_) if menu.ai_fork => (7usize, 3usize, color_h),
ContextMenuTarget::Tab(_) => (6usize, 2usize, color_h),
ContextMenuTarget::Ssh(_) => (5usize, 1usize, 0.0),
ContextMenuTarget::Sftp(_) => (3usize, 1usize, 0.0),
ContextMenuTarget::SftpPanel => (4usize, 1usize, 0.0),
ContextMenuTarget::FileTree { .. } => (4usize, 1usize, 0.0),
};
let height = pad * 2.0 + row_count as f32 * row_h + separators as f32 * sep_h + extra;
let margin = s(8.0);
let mut x = menu.anchor.0;
let mut y = menu.anchor.1 + animated_y_offset;
if x + width > size.width() - margin {
x = (menu.anchor.0 - width).max(margin);
}
if y + height > size.height() - margin {
y = (size.height() - margin - height).max(margin);
}
// The anchor may itself sit inside the safety margin (a right-click on a
// close glyph at the edge), so clamp after choosing the preferred side as
// well. Without this final cap the menu could still overhang by the
// anchor-to-margin distance.
let max_x = (size.width() - margin - width).max(margin);
let max_y = (size.height() - margin - height).max(margin);
x = x.clamp(margin, max_x);
y = y.clamp(margin, max_y);
let panel = (x, y, width, height);
let mut rows = Vec::with_capacity(row_count);
let mut separator_rects = Vec::with_capacity(separators);
let mut cursor_y = y + pad;
let row = |action, icon, label, hint, y, danger| Row {
action,
icon,
label,
hint,
rect: (x + pad, y, width - 2.0 * pad, row_h),
danger,
};
let mut separator = |cursor_y: &mut f32| {
separator_rects.push((x + s(12.0), *cursor_y + sep_h * 0.5, width - s(24.0), s(1.0)));
*cursor_y += sep_h;
};
let mut color_label = None;
let mut colors = Vec::new();
match menu.target {
ContextMenuTarget::Tab(index) => {
if menu.ai_fork {
rows.push(row(
ContextMenuAction::ForkAiSession(index),
ICON_FORK,
"分叉 AI 会话",
"",
cursor_y,
false,
));
cursor_y += row_h;
separator(&mut cursor_y);
}
rows.push(row(
ContextMenuAction::DuplicateTab(index),
ICON_COPY,
"复制标签页",
"",
cursor_y,
false,
));
cursor_y += row_h;
rows.push(row(
ContextMenuAction::ExportTab(index),
ICON_EXPORT,
"导出为工作区…",
"",
cursor_y,
false,
));
cursor_y += row_h;
separator(&mut cursor_y);
rows.push(row(
ContextMenuAction::SplitTabRight(index),
ICON_SPLIT_RIGHT,
"左右分屏",
"Ctrl+Shift+D",
cursor_y,
false,
));
cursor_y += row_h;
rows.push(row(
ContextMenuAction::SplitTabDown(index),
ICON_SPLIT_DOWN,
"上下分屏",
"Ctrl+Shift+S",
cursor_y,
false,
));
cursor_y += row_h;
separator(&mut cursor_y);
rows.push(row(
ContextMenuAction::RenameTab(index),
ICON_EDIT,
"重命名",
"",
cursor_y,
false,
));
cursor_y += row_h;
rows.push(row(
ContextMenuAction::CloseTab(index),
ICON_CLOSE,
"关闭",
"Ctrl+Shift+W",
cursor_y,
true,
));
cursor_y += row_h;
let label_y = cursor_y + color_top_pad;
color_label = Some((x + s(14.0), label_y));
let gap = s(space::XS);
let start_x = x + s(14.0);
// 标签字盒结束后保留完整 XS 间距,避免标题和色板粘连。
let swatch_y = label_y + size.cell_height() + color_label_gap;
let all = std::iter::once(None).chain(TAB_COLORS.into_iter().map(Some));
for (slot, color) in all.enumerate() {
let rect = (start_x + slot as f32 * (swatch + gap), swatch_y, swatch, swatch);
colors.push((color, ContextMenuAction::SetTabColor { index, color }, rect));
}
},
ContextMenuTarget::Ssh(index) => {
rows.push(row(
ContextMenuAction::ConnectSsh(index),
"\u{f489}",
"连接",
"Enter",
cursor_y,
false,
));
cursor_y += row_h;
rows.push(row(
ContextMenuAction::OpenSftp(index),
"\u{f0c7}",
"打开 SFTP",
"",
cursor_y,
false,
));
cursor_y += row_h;
rows.push(row(
ContextMenuAction::CopySshAddress(index),
ICON_COPY,
"复制地址",
"",
cursor_y,
false,
));
cursor_y += row_h;
rows.push(row(
ContextMenuAction::EditSsh(index),
ICON_EDIT,
"编辑",
"",
cursor_y,
false,
));
cursor_y += row_h;
separator(&mut cursor_y);
rows.push(row(
ContextMenuAction::DeleteSsh(index),
"\u{ea81}",
"删除",
"",
cursor_y,
true,
));
},
ContextMenuTarget::Sftp(index) => {
rows.push(row(
ContextMenuAction::DownloadSftp(index),
"\u{eb4a}",
"下载",
"",
cursor_y,
false,
));
cursor_y += row_h;
rows.push(row(
ContextMenuAction::RenameSftp(index),
ICON_EDIT,
"重命名",
"F2",
cursor_y,
false,
));
cursor_y += row_h;
separator(&mut cursor_y);
rows.push(row(
ContextMenuAction::DeleteSftp(index),
"\u{ea81}",
"删除",
"",
cursor_y,
true,
));
},
ContextMenuTarget::SftpPanel => {
rows.push(row(ContextMenuAction::RefreshSftp, "\u{eb37}", "刷新", "", cursor_y, false));
cursor_y += row_h;
separator(&mut cursor_y);
rows.push(row(
ContextMenuAction::UploadFilesSftp,
"\u{eb4b}",
"上传文件",
"",
cursor_y,
false,
));
cursor_y += row_h;
rows.push(row(
ContextMenuAction::UploadDirectorySftp,
"\u{ea83}",
"上传目录",
"",
cursor_y,
false,
));
cursor_y += row_h;
rows.push(row(
ContextMenuAction::NewDirectorySftp,
"\u{eaf7}",
"新建目录",
"",
cursor_y,
false,
));
},
ContextMenuTarget::FileTree { row: index, is_dir } => {
// 目录的「打开」语义已由左键(展开/折叠)承担,首行给终端;
// 文件首行交给系统默认程序。
if is_dir {
rows.push(row(
ContextMenuAction::TerminalHereFileTree(index),
"\u{ea85}",
"在此处打开终端",
"",
cursor_y,
false,
));
} else {
rows.push(row(
ContextMenuAction::OpenFileTree(index),
"\u{ea7b}",
"打开",
"",
cursor_y,
false,
));
}
cursor_y += row_h;
rows.push(row(
ContextMenuAction::RevealFileTree(index),
"\u{eaf7}",
"在资源管理器中显示",
"",
cursor_y,
false,
));
cursor_y += row_h;
rows.push(row(
ContextMenuAction::CopyFileTreePath(index),
ICON_COPY,
"复制路径",
"",
cursor_y,
false,
));
cursor_y += row_h;
separator(&mut cursor_y);
rows.push(row(
ContextMenuAction::DeleteFileTree(index),
"\u{ea81}",
"删除",
"",
cursor_y,
true,
));
},
}
MenuLayout { panel, rows, separators: separator_rects, color_label, colors }
}
pub(super) fn hit_test(
menu: &ContextMenu,
size: SizeInfo,
scale: f32,
x: f32,
y: f32,
) -> ContextMenuHit {
let layout = layout(menu, size, scale, 0.0);
for row in &layout.rows {
if contains(row.rect, x, y) {
return ContextMenuHit::Action(row.action);
}
}
for (_, action, rect) in &layout.colors {
if contains(*rect, x, y) {
return ContextMenuHit::Action(*action);
}
}
if contains(layout.panel, x, y) { ContextMenuHit::Panel } else { ContextMenuHit::Outside }
}
fn fade_ink(base: Rgba, ink: Rgb, opacity: f32) -> Rgb {
let mix = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * opacity).round() as u8;
Rgb::new(mix(base.r, ink.r), mix(base.g, ink.g), mix(base.b, ink.b))
}
/// Paint the menu after the chrome/logo pass and before true modal dialogs.
pub(super) fn draw(display: &mut Display) {
let Some(mut menu) = display.nebula_context_menu.clone() else { return };
menu.motion.step(display.nebula_ui_anims.frame());
if menu.finished() {
display.nebula_context_menu = None;
return;
}
let progress = menu.motion.value().clamp(0.0, 1.0);
display.nebula_context_menu = Some(menu.clone());
// UI-anchored SizeInfo: menu rows/labels must not follow terminal zoom.
let size = display.ui_size_info();
let scale = display.window.scale_factor as f32;
let s = |v: f32| v * scale;
let layout = layout(&menu, size, scale, -s(5.0) * (1.0 - progress));
let sk = display.nebula_theme.skin();
// 阴影 + 发丝环 + 面板填充的配方现在只有一处定义(`ui::surface`)。
// 右键菜单是 Menu 层级:不画遮罩,阴影按小浮层的扩散取值。
let mut quads = Vec::new();
surface::push_surface(
&mut quads,
layout.panel,
(size.width(), size.height()),
scale,
&sk,
display.nebula_density,
surface::Elevation::Menu,
progress,
);
for separator in &layout.separators {
quads.push(UiQuad::solid(
separator.0,
separator.1,
separator.2,
separator.3,
0.0,
alpha(sk.hairline, progress),
));
}
for row in &layout.rows {
if menu.hover == Some(row.action) {
quads.push(UiQuad::solid(
row.rect.0,
row.rect.1,
row.rect.2,
row.rect.3,
s(6.0),
alpha(sk.hover, progress),
));
}
}
for (color, action, rect) in &layout.colors {
let selected = *color == menu.current_color;
if menu.hover == Some(*action) || selected {
let ring = if selected { sk.accent } else { sk.ink_dim };
quads.push(UiQuad::solid(
rect.0 - s(3.0),
rect.1 - s(3.0),
rect.2 + s(6.0),
rect.3 + s(6.0),
s(7.0),
alpha(Rgba::new(ring.r, ring.g, ring.b, 210), progress),
));
}
let swatch = color.unwrap_or(sk.accent);
quads.push(UiQuad::solid(
rect.0,
rect.1,
rect.2,
rect.3,
s(5.0),
alpha(Rgba::new(swatch.r, swatch.g, swatch.b, 255), progress),
));
}
// 右键菜单和命令面板共用同一颗键帽组件;快捷键不再是贴在右缘的一串
// 灰字,独立小键帽给出清晰的可按暗示,也让两类浮层形成同一套视觉语法。
let rename = keymap::effective_combo(&crate::config::Action::RenameTab, &display.nebula_keymap)
.map(|(combo, _)| combo)
.unwrap_or_default();
for row in &layout.rows {
let hint = row_shortcut(row, &rename);
if !hint.is_empty() {
let combo = keycap::layout_combo(
hint,
row.rect.0 + row.rect.2 - s(10.0),
row.rect.1 + row.rect.3 * 0.5,
size.cell_width(),
scale,
);
keycap::push_combo_with_progress(&mut quads, &sk, &combo, scale, progress);
}
}
display.renderer.draw_ui(&size, &quads);
// Text has no alpha channel in the glyph pipeline, so fade it toward the
// panel color. This avoids a one-frame label pop during both directions.
let ink = fade_ink(sk.panel, sk.ink, progress);
let dim = fade_ink(sk.panel, sk.ink_dim, progress);
let danger = fade_ink(sk.panel, Rgb::new(sk.danger.r, sk.danger.g, sk.danger.b), progress);
let cell_h = size.cell_height();
for row in &layout.rows {
let y = row.rect.1 + (row.rect.3 - cell_h) * 0.5;
display.renderer.draw_chrome_text(
&size,
row.rect.0 + s(10.0),
y,
if row.danger { danger } else { dim },
row.icon,
&mut display.glyph_cache,
);
display.renderer.draw_chrome_text(
&size,
row.rect.0 + s(38.0),
y,
if row.danger { danger } else { ink },
row.label,
&mut display.glyph_cache,
);
let hint = row_shortcut(row, &rename);
if !hint.is_empty() {
let combo = keycap::layout_combo(
hint,
row.rect.0 + row.rect.2 - s(10.0),
row.rect.1 + row.rect.3 * 0.5,
size.cell_width(),
scale,
);
let (_, key_y, _, key_h) = combo.bounds;
let key_text_y = key_y + (key_h - cell_h) * 0.5;
for (chip_x, chip_w, key) in &combo.chips {
let key_w = key.chars().map(|c| c.width().unwrap_or(0)).sum::<usize>() as f32
* size.cell_width();
display.renderer.draw_chrome_text(
&size,
chip_x + (chip_w - key_w) * 0.5,
key_text_y,
dim,
key,
&mut display.glyph_cache,
);
}
}
}
if let Some((x, y)) = layout.color_label {
display.renderer.draw_chrome_text(&size, x, y, dim, "标签颜色", &mut display.glyph_cache);
}
if let Some((None, _, rect)) = layout.colors.first() {
display.renderer.draw_chrome_text(
&size,
rect.0 + (rect.2 - size.cell_width()) * 0.5,
rect.1 + (rect.3 - cell_h) * 0.5,
fade_ink(
Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255),
sk.ink_on_accent,
progress,
),
"A",
&mut display.glyph_cache,
);
}
if menu.animating() {
display.window.request_redraw();
display.pending_update.dirty = true;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn size() -> SizeInfo {
SizeInfo::new(800.0, 600.0, 9.0, 18.0, 8.0, 8.0, false)
}
#[test]
fn tab_menu_stays_inside_bottom_right_window_edge() {
let menu = ContextMenu::new(ContextMenuTarget::Tab(2), (796.0, 596.0), None);
let layout = layout(&menu, size(), 1.0, 0.0);
assert!(layout.panel.0 >= 8.0);
assert!(layout.panel.1 >= 8.0);
assert!(layout.panel.0 + layout.panel.2 <= 792.0);
assert!(layout.panel.1 + layout.panel.3 <= 592.0);
}
#[test]
fn first_tab_row_and_auto_color_have_actions() {
let menu = ContextMenu::new(ContextMenuTarget::Tab(3), (100.0, 100.0), None);
let layout = layout(&menu, size(), 1.0, 0.0);
let first = layout.rows[0];
assert_eq!(
hit_test(&menu, size(), 1.0, first.rect.0 + 2.0, first.rect.1 + 2.0,),
ContextMenuHit::Action(ContextMenuAction::DuplicateTab(3))
);
assert_eq!(layout.colors.len(), 8);
assert_eq!(layout.colors[0].0, None);
assert_eq!(layout.colors[0].1, ContextMenuAction::SetTabColor { index: 3, color: None });
}
#[test]
fn ai_fork_row_only_appears_for_verified_live_sessions() {
let plain = ContextMenu::new(ContextMenuTarget::Tab(3), (100.0, 100.0), None);
assert_eq!(layout(&plain, size(), 1.0, 0.0).rows.len(), 6);
let forkable =
ContextMenu::new(ContextMenuTarget::Tab(3), (100.0, 100.0), None).with_ai_fork(true);
let layout = layout(&forkable, size(), 1.0, 0.0);
assert_eq!(layout.rows.len(), 7);
assert_eq!(layout.rows[0].action, ContextMenuAction::ForkAiSession(3));
assert_eq!(layout.rows[1].action, ContextMenuAction::DuplicateTab(3));
}
#[test]
fn sftp_row_menu_exposes_download_rename_and_delete() {
let menu = ContextMenu::new(ContextMenuTarget::Sftp(4), (100.0, 100.0), None);
let layout = layout(&menu, size(), 1.0, 0.0);
assert_eq!(layout.rows.len(), 3);
assert_eq!(layout.rows[0].action, ContextMenuAction::DownloadSftp(4));
assert_eq!(layout.rows[1].action, ContextMenuAction::RenameSftp(4));
assert_eq!(layout.rows[2].action, ContextMenuAction::DeleteSftp(4));
}
#[test]
fn sftp_panel_menu_exposes_directory_actions() {
let menu = ContextMenu::new(ContextMenuTarget::SftpPanel, (100.0, 100.0), None);
let layout = layout(&menu, size(), 1.0, 0.0);
assert_eq!(layout.rows.len(), 4);
assert_eq!(layout.rows[0].action, ContextMenuAction::RefreshSftp);
assert_eq!(layout.rows[1].action, ContextMenuAction::UploadFilesSftp);
assert_eq!(layout.rows[2].action, ContextMenuAction::UploadDirectorySftp);
assert_eq!(layout.rows[3].action, ContextMenuAction::NewDirectorySftp);
}
#[test]
fn file_tree_menu_swaps_first_row_by_kind() {
let file = ContextMenu::new(
ContextMenuTarget::FileTree { row: 7, is_dir: false },
(100.0, 100.0),
None,
);
let layout_file = layout(&file, size(), 1.0, 0.0);
assert_eq!(layout_file.rows.len(), 4);
assert_eq!(layout_file.rows[0].action, ContextMenuAction::OpenFileTree(7));
assert_eq!(layout_file.rows[1].action, ContextMenuAction::RevealFileTree(7));
assert_eq!(layout_file.rows[2].action, ContextMenuAction::CopyFileTreePath(7));
assert_eq!(layout_file.rows[3].action, ContextMenuAction::DeleteFileTree(7));
assert!(layout_file.rows[3].danger);
let dir = ContextMenu::new(
ContextMenuTarget::FileTree { row: 2, is_dir: true },
(100.0, 100.0),
None,
);
let layout_dir = layout(&dir, size(), 1.0, 0.0);
assert_eq!(layout_dir.rows[0].action, ContextMenuAction::TerminalHereFileTree(2));
}
}