feat: add settings tab and bilingual UI
This commit is contained in:
1 parent
992cc4f375
commit
928057ce37
10 files changed
+846
-410
No files matched your search
@@ -639,6 +639,7 @@ pub(super) fn draw_chrome(d: &mut Display) {
|
||||
// smoke on the light themes); close-hover red stays semantic.
|
||||
let palette = d.nebula_theme.palette();
|
||||
let sk = d.nebula_theme.skin();
|
||||
let language = d.ui_language();
|
||||
#[allow(non_snake_case)]
|
||||
let (HOVER_FILL, HOVER_FILL_STRONG) = (sk.hover, sk.hover_strong);
|
||||
const CLOSE_HOVER_FILL: Rgba = Rgba::new(240, 80, 104, 96);
|
||||
@@ -996,7 +997,7 @@ pub(super) fn draw_chrome(d: &mut Display) {
|
||||
d.nebula_chrome_hover,
|
||||
ChromeHit::TabsSection | ChromeHit::NewTab | ChromeHit::NewTabMenu
|
||||
);
|
||||
if !d.nebula_settings_open && tabs_plus_visible {
|
||||
if tabs_plus_visible {
|
||||
let plus_ink =
|
||||
if d.nebula_chrome_hover == ChromeHit::NewTab { sk.icon_hover } else { sk.icon };
|
||||
push_centered_add_icon(&mut quads, tab_layout.plus, scale, plus_ink);
|
||||
@@ -1006,18 +1007,16 @@ pub(super) fn draw_chrome(d: &mut Display) {
|
||||
if d.nebula_chrome_hover == ChromeHit::NewTabMenu {
|
||||
quads.push(UiQuad::solid(menu_x, menu_y, menu_w, menu_h, pill_r, HOVER_FILL_STRONG));
|
||||
}
|
||||
if !d.nebula_settings_open {
|
||||
push_centered_more_icon(
|
||||
&mut quads,
|
||||
tab_layout.menu,
|
||||
scale,
|
||||
if d.nebula_chrome_hover == ChromeHit::NewTabMenu { sk.icon_hover } else { sk.icon },
|
||||
);
|
||||
}
|
||||
push_centered_more_icon(
|
||||
&mut quads,
|
||||
tab_layout.menu,
|
||||
scale,
|
||||
if d.nebula_chrome_hover == ChromeHit::NewTabMenu { sk.icon_hover } else { sk.icon },
|
||||
);
|
||||
|
||||
// SSH HOSTS section (quad layer): hover fill per row + the per-section
|
||||
// overlay scrollbar thumbs. Row icons/labels render in the text pass.
|
||||
if d.left_sidebar_visible() && !d.nebula_settings_open {
|
||||
if d.left_sidebar_visible() {
|
||||
for (index, (hx, hy, hw, hh)) in tab_layout.hosts.iter().copied().enumerate() {
|
||||
if hw <= 0.0 {
|
||||
continue;
|
||||
@@ -1099,7 +1098,7 @@ pub(super) fn draw_chrome(d: &mut Display) {
|
||||
Gradient::Horizontal,
|
||||
));
|
||||
|
||||
// Right-side drawer (directory tree / git) sits below modal layers. Keeps
|
||||
// Right-side drawer (directory tree / git) remains part of the app shell. Keeps
|
||||
// drawing through slide-out; animation stepping is centralized in Display.
|
||||
d.step_chrome_anims();
|
||||
if d.side_panel_visible() {
|
||||
@@ -1126,7 +1125,7 @@ pub(super) fn draw_chrome(d: &mut Display) {
|
||||
}
|
||||
|
||||
// No base pill behind the gear — just the icon (hover still fills).
|
||||
// Nebula settings modal (dim veil, glass panel, controls) above the drawer.
|
||||
// The Settings special tab paints its page inside the normal content card.
|
||||
if d.nebula_settings_open {
|
||||
settings::push_quads(&d.settings_view(), &mut quads, &size, scale);
|
||||
}
|
||||
@@ -1228,9 +1227,8 @@ pub(super) fn draw_chrome(d: &mut Display) {
|
||||
// Vertical tab labels. Each row's Y comes from the eased anim slot; the
|
||||
// label is left-aligned after the accent gutter, the × pinned right.
|
||||
let row_text_cy = |ry: f32, rh: f32| ry + (rh - cell_h) / 2.0;
|
||||
// Sidebar text (caption, labels, × buttons) also stays under the
|
||||
// settings glass — skip it entirely while the modal is up.
|
||||
if d.left_sidebar_visible() && tab_layout.panel.2 > 0.0 && !d.nebula_settings_open {
|
||||
// Sidebar text remains visible because Settings is a normal tab.
|
||||
if d.left_sidebar_visible() && tab_layout.panel.2 > 0.0 {
|
||||
// "TABS" caption at the panel head, with an accordion chevron. The
|
||||
// panel abuts the top bar with no gap, so the caption is pushed down
|
||||
// inside the header band to keep clearance from the join.
|
||||
@@ -1247,7 +1245,7 @@ pub(super) fn draw_chrome(d: &mut Display) {
|
||||
section_title_tracking,
|
||||
TXT_DIM,
|
||||
section_title_flags,
|
||||
&format!("TABS {tabs_chevron}"),
|
||||
&format!("{} {tabs_chevron}", language.pick("标签页", "TABS")),
|
||||
&mut d.glyph_cache,
|
||||
);
|
||||
for (index, (tab_x, row_y, tab_w, tab_h)) in tab_layout.tabs.iter().copied().enumerate() {
|
||||
@@ -1463,7 +1461,7 @@ pub(super) fn draw_chrome(d: &mut Display) {
|
||||
section_title_tracking,
|
||||
TXT_DIM,
|
||||
section_title_flags,
|
||||
&format!("SSH HOSTS {hosts_chevron}"),
|
||||
&format!("{} {hosts_chevron}", language.pick("SSH 主机", "SSH HOSTS")),
|
||||
&mut d.glyph_cache,
|
||||
);
|
||||
// Empty state: the section stays visible with a hint teaching the
|
||||
@@ -1490,7 +1488,13 @@ pub(super) fn draw_chrome(d: &mut Display) {
|
||||
let mut line = String::new();
|
||||
let mut cols = 0;
|
||||
let mut line_y = hh_y + hh_h + s(2.0);
|
||||
for ch in "输入 ssh 命令,连接后自动保存".chars() {
|
||||
for ch in language
|
||||
.pick(
|
||||
"输入 ssh 命令,连接后自动保存",
|
||||
"Run an ssh command to save hosts after connecting",
|
||||
)
|
||||
.chars()
|
||||
{
|
||||
let ch_cols = ch.width().unwrap_or(0);
|
||||
if cols + ch_cols > max_cols && !line.is_empty() {
|
||||
d.renderer.draw_doc_text(
|
||||
@@ -1592,7 +1596,7 @@ pub(super) fn draw_chrome(d: &mut Display) {
|
||||
&mut d.glyph_cache,
|
||||
);
|
||||
}
|
||||
// Nebula settings modal text labels, above its quads.
|
||||
// Settings tab text labels, above its quads.
|
||||
if d.nebula_settings_open {
|
||||
let view = d.settings_view();
|
||||
let settings_shell_icons =
|
||||
@@ -1605,8 +1609,8 @@ pub(super) fn draw_chrome(d: &mut Display) {
|
||||
}
|
||||
|
||||
// Palette text (query + result rows) sits on top of every chrome label.
|
||||
// Drawer text stays below settings; otherwise it pierces the modal glass.
|
||||
if d.side_panel_visible() && !d.nebula_settings_open {
|
||||
// Drawer text remains in its own shell region beside the Settings page.
|
||||
if d.side_panel_visible() {
|
||||
if let Some(panel) = d.nebula_sftp_panel.as_ref() {
|
||||
let scale = d.window.scale_factor as f32;
|
||||
let layout = sftp_panel::layout(&d.side_panel_layout(), scale);
|
||||
|
||||
@@ -260,6 +260,7 @@ const RECENT_MAX: usize = 6;
|
||||
|
||||
/// Command palette UI + filtering state, embedded in `Display`.
|
||||
pub struct CommandPalette {
|
||||
language: super::UiLanguage,
|
||||
open: bool,
|
||||
query: String,
|
||||
query_selection: super::text_input::SelectAllState,
|
||||
@@ -294,6 +295,7 @@ pub struct CommandPalette {
|
||||
impl CommandPalette {
|
||||
pub fn new() -> Self {
|
||||
let mut palette = Self {
|
||||
language: super::UiLanguage::ZhCn,
|
||||
open: false,
|
||||
query: String::new(),
|
||||
query_selection: Default::default(),
|
||||
@@ -315,6 +317,13 @@ impl CommandPalette {
|
||||
self.open
|
||||
}
|
||||
|
||||
pub fn set_language(&mut self, language: super::UiLanguage) {
|
||||
self.language = language;
|
||||
if !self.profiles_only {
|
||||
self.refilter();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the palette is in default-shell picking mode.
|
||||
pub fn is_picking_default(&self) -> bool {
|
||||
self.picking_default
|
||||
@@ -337,7 +346,7 @@ impl CommandPalette {
|
||||
// The label carries a glyph-free prefix so profile rows read
|
||||
// distinctly from built-in actions; the haystack adds latin
|
||||
// aliases (matching the static items' convention).
|
||||
label: format!("启动:{name}"),
|
||||
label: format!("{}:{name}", self.language.pick("启动", "Launch")),
|
||||
search: format!("启动 {name} profile launch connect qidong"),
|
||||
index,
|
||||
})
|
||||
@@ -622,13 +631,46 @@ impl CommandPalette {
|
||||
None => PaletteRow {
|
||||
icon: String::new(),
|
||||
color_id: String::new(),
|
||||
label: ITEMS[idx].label.to_string(),
|
||||
label: localized_item_label(&ITEMS[idx], self.language).to_owned(),
|
||||
hint: ITEMS[idx].hint.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn localized_item_label(item: &PaletteItem, language: super::UiLanguage) -> &'static str {
|
||||
use PaletteAction::*;
|
||||
if language == super::UiLanguage::ZhCn {
|
||||
return item.label;
|
||||
}
|
||||
match item.action {
|
||||
NewTab => "New tab",
|
||||
CloseTab => "Close tab",
|
||||
NextTab => "Next tab",
|
||||
PrevTab => "Previous tab",
|
||||
NewWindow => "New window",
|
||||
SplitRight => "Split right",
|
||||
SplitDown => "Split down",
|
||||
ToggleFilesPanel => "Files panel",
|
||||
ToggleGitPanel => "Git panel",
|
||||
OpenSettings => "Open settings",
|
||||
OpenSettingsFile => "Open configuration file",
|
||||
ToggleGhost => "Toggle ghost completion",
|
||||
CycleAccept => "Cycle completion accept key",
|
||||
PickBackgroundImage => "Choose background image...",
|
||||
CycleBackground => "Cycle background color",
|
||||
ResetAppearance => "Restore appearance defaults",
|
||||
SelectTheme(NebulaTheme::Nebula) => "Theme: Nebula",
|
||||
SelectTheme(NebulaTheme::SilverLight) => "Theme: Silver Light",
|
||||
SelectTheme(NebulaTheme::SteelDark) => "Theme: Steel Dark",
|
||||
SelectTheme(NebulaTheme::LimestoneLight) => "Theme: Limestone",
|
||||
SelectTheme(NebulaTheme::CoalDark) => "Theme: Coal Dark",
|
||||
SelectTheme(NebulaTheme::LinenLight) => "Theme: Linen Light",
|
||||
SelectTheme(NebulaTheme::MossDark) => "Theme: Moss Dark",
|
||||
LaunchProfile(_) | LaunchShell(_) | SetDefaultShell(_) => item.label,
|
||||
}
|
||||
}
|
||||
|
||||
/// One rendered palette row. `icon` is the Nerd Font fallback glyph (empty for
|
||||
/// built-in action rows); `color_id` names a full-color brand PNG when the row
|
||||
/// is a detected shell (empty otherwise, so the glyph shows instead).
|
||||
@@ -764,14 +806,7 @@ pub(super) fn push_quads(
|
||||
Gradient::Axis([0.25, 0.95]),
|
||||
));
|
||||
|
||||
quads.push(UiQuad::solid(
|
||||
ix,
|
||||
iy,
|
||||
iw,
|
||||
ih,
|
||||
s(super::design_tokens::control::RADIUS),
|
||||
sk.input,
|
||||
));
|
||||
quads.push(UiQuad::solid(ix, iy, iw, ih, s(super::design_tokens::control::RADIUS), sk.input));
|
||||
if model.query_all_selected() && !model.query.is_empty() {
|
||||
let cell_w = size.cell_width();
|
||||
let columns: usize = model.query.chars().map(|c| c.width().unwrap_or(0)).sum();
|
||||
@@ -865,22 +900,28 @@ pub(super) fn draw_text(
|
||||
// 空态同时给出输入光标和用途提示,避免只有一个空白色块让用户猜测。
|
||||
r.draw_chrome_text(size, query_x, text_y, sk.ink_strong, cursor, gc);
|
||||
let placeholder = if model.profiles_only {
|
||||
"搜索 Shell 或 Profile…"
|
||||
model.language.pick("搜索 Shell 或 Profile…", "Search shells or profiles...")
|
||||
} else {
|
||||
"搜索命令、Shell 或 Profile…"
|
||||
model
|
||||
.language
|
||||
.pick("搜索命令、Shell 或 Profile…", "Search commands, shells or profiles...")
|
||||
};
|
||||
r.draw_chrome_text(size, query_x + s(10.0), text_y, sk.ink_dim, placeholder, gc);
|
||||
} else {
|
||||
let shown = if model.query_all_selected() {
|
||||
query.to_owned()
|
||||
} else {
|
||||
format!("{query}{cursor}")
|
||||
};
|
||||
let shown =
|
||||
if model.query_all_selected() { query.to_owned() } else { format!("{query}{cursor}") };
|
||||
r.draw_chrome_text(size, query_x, text_y, sk.ink_strong, &shown, gc);
|
||||
}
|
||||
|
||||
if model.is_empty() {
|
||||
r.draw_chrome_text(size, text_x, layout.list_y + s(8.0), sk.ink_dim, "无匹配命令", gc);
|
||||
r.draw_chrome_text(
|
||||
size,
|
||||
text_x,
|
||||
layout.list_y + s(8.0),
|
||||
sk.ink_dim,
|
||||
model.language.pick("无匹配命令", "No matching commands"),
|
||||
gc,
|
||||
);
|
||||
return icon_draws;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
use crate::config::template::{self, TemplateLanguage};
|
||||
|
||||
/// Persisted UI language choice. The serialized values are stable because the
|
||||
/// runtime settings file is also a supported hand-editing surface.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum LanguagePreference {
|
||||
#[default]
|
||||
System,
|
||||
ZhCn,
|
||||
EnUs,
|
||||
}
|
||||
|
||||
impl LanguagePreference {
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value.trim() {
|
||||
"system" => Some(Self::System),
|
||||
"zh-CN" => Some(Self::ZhCn),
|
||||
"en-US" => Some(Self::EnUs),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "system",
|
||||
Self::ZhCn => "zh-CN",
|
||||
Self::EnUs => "en-US",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolved(self) -> UiLanguage {
|
||||
let template = template::resolve_template_language(
|
||||
Some(self.as_str()),
|
||||
None,
|
||||
template::system_locale().as_deref(),
|
||||
)
|
||||
.unwrap_or(TemplateLanguage::EnUs);
|
||||
match template {
|
||||
TemplateLanguage::ZhCn => UiLanguage::ZhCn,
|
||||
TemplateLanguage::EnUs => UiLanguage::EnUs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved language used by the current process. `pick` keeps translations
|
||||
/// adjacent at call sites, which makes missing English text obvious in review.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UiLanguage {
|
||||
ZhCn,
|
||||
EnUs,
|
||||
}
|
||||
|
||||
impl UiLanguage {
|
||||
pub const fn pick<'a>(self, zh_cn: &'a str, en_us: &'a str) -> &'a str {
|
||||
match self {
|
||||
Self::ZhCn => zh_cn,
|
||||
Self::EnUs => en_us,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{LanguagePreference, UiLanguage};
|
||||
|
||||
#[test]
|
||||
fn persisted_language_values_are_stable() {
|
||||
for (raw, expected) in [
|
||||
("system", LanguagePreference::System),
|
||||
("zh-CN", LanguagePreference::ZhCn),
|
||||
("en-US", LanguagePreference::EnUs),
|
||||
] {
|
||||
assert_eq!(LanguagePreference::parse(raw), Some(expected));
|
||||
assert_eq!(expected.as_str(), raw);
|
||||
}
|
||||
assert_eq!(LanguagePreference::parse("zh"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_languages_do_not_depend_on_the_system_locale() {
|
||||
assert_eq!(LanguagePreference::ZhCn.resolved(), UiLanguage::ZhCn);
|
||||
assert_eq!(LanguagePreference::EnUs.resolved(), UiLanguage::EnUs);
|
||||
}
|
||||
}
|
||||
@@ -56,9 +56,9 @@ use crate::display::damage::{DamageTracker, damage_y_to_viewport_y};
|
||||
use crate::display::hint::{HintMatch, HintState};
|
||||
use crate::display::meter::Meter;
|
||||
use crate::display::window::Window;
|
||||
use crate::renderer::Rasterizer;
|
||||
use crate::event::{Event, EventType, Mouse, SearchState};
|
||||
use crate::message_bar::{MessageBuffer, MessageType};
|
||||
use crate::renderer::Rasterizer;
|
||||
use crate::renderer::rects::{RenderLine, RenderLines, RenderRect};
|
||||
use crate::renderer::ui::{Gradient, Rgba, UiQuad};
|
||||
use crate::renderer::{self, GlyphCache, Renderer, platform};
|
||||
@@ -75,6 +75,7 @@ mod chrome;
|
||||
pub mod command_palette;
|
||||
mod context_menu;
|
||||
pub mod design_tokens;
|
||||
mod i18n;
|
||||
pub mod markdown_view;
|
||||
pub mod sftp_panel;
|
||||
pub mod side_panel;
|
||||
@@ -86,6 +87,7 @@ use chrome::{
|
||||
truncate_tab_label,
|
||||
};
|
||||
pub use context_menu::{ContextMenuAction, ContextMenuHit, ContextMenuTarget};
|
||||
pub use i18n::{LanguagePreference, UiLanguage};
|
||||
|
||||
mod file_dialog;
|
||||
mod settings;
|
||||
@@ -325,13 +327,6 @@ pub enum AcceptKey {
|
||||
}
|
||||
|
||||
impl AcceptKey {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Right => "\u{2192}",
|
||||
Self::Tab => "Tab",
|
||||
Self::Both => "\u{2192} / Tab",
|
||||
}
|
||||
}
|
||||
fn cycle(self) -> Self {
|
||||
match self {
|
||||
Self::Right => Self::Tab,
|
||||
@@ -644,7 +639,7 @@ pub(crate) fn program_icon(program: &str) -> &'static str {
|
||||
"cursor" | "cursor-agent" => "\u{f0ec3}", // md-cursor-default-outline
|
||||
"aider" | "goose" | "crush" | "ollama" => "\u{f06a9}", // md-robot
|
||||
"opencode" => "\u{f489}", // oct-terminal
|
||||
"pi" => "\u{f135}", // fa-code
|
||||
"pi" => "\u{f135}", // fa-code
|
||||
"git" | "gh" | "lazygit" => "\u{f418}", // oct-git-branch
|
||||
"vim" | "nvim" | "vi" | "hx" | "nano" => "\u{e62b}", // custom-vim
|
||||
"ssh" | "mosh" => "\u{f489}", // oct-terminal (remote)
|
||||
@@ -1390,6 +1385,8 @@ pub struct Display {
|
||||
/// window is following the operating system.
|
||||
nebula_window_theme_override: Option<WinitTheme>,
|
||||
pub nebula_settings_open: bool,
|
||||
nebula_language_preference: LanguagePreference,
|
||||
nebula_language: UiLanguage,
|
||||
/// Paths from the last successful app configuration generation.
|
||||
nebula_config_paths: Vec<PathBuf>,
|
||||
/// Settings content scroll offset in scaled px (0 = top of the section).
|
||||
@@ -1598,11 +1595,8 @@ impl Display {
|
||||
let required_font_install = None;
|
||||
|
||||
debug!("Loading \"{}\" font", &settings_init.font_family);
|
||||
let font = config
|
||||
.font
|
||||
.clone()
|
||||
.with_family(settings_init.font_family.clone())
|
||||
.with_size(font_size);
|
||||
let font =
|
||||
config.font.clone().with_family(settings_init.font_family.clone()).with_size(font_size);
|
||||
let mut glyph_cache = GlyphCache::new(rasterizer, &font)?;
|
||||
#[cfg(windows)]
|
||||
let mut nebula_font_families = glyph_cache.private_font_families();
|
||||
@@ -1813,9 +1807,15 @@ impl Display {
|
||||
nebula_system_theme,
|
||||
nebula_window_theme_override,
|
||||
nebula_settings_open: false,
|
||||
nebula_language_preference: settings_init.language,
|
||||
nebula_language: settings_init.language.resolved(),
|
||||
nebula_config_paths: config.config_paths.clone(),
|
||||
nebula_settings_scroll: 0.0,
|
||||
nebula_palette: command_palette::CommandPalette::new(),
|
||||
nebula_palette: {
|
||||
let mut palette = command_palette::CommandPalette::new();
|
||||
palette.set_language(settings_init.language.resolved());
|
||||
palette
|
||||
},
|
||||
nebula_detected_shells: None,
|
||||
nebula_side_panel: side_panel::SidePanel::new(),
|
||||
nebula_sftp_panel: None,
|
||||
@@ -1884,6 +1884,10 @@ impl Display {
|
||||
self.nebula_settings_open
|
||||
}
|
||||
|
||||
pub fn ui_language(&self) -> UiLanguage {
|
||||
self.nebula_language
|
||||
}
|
||||
|
||||
pub fn settings_section(&self) -> NebulaSettingsSection {
|
||||
self.nebula_settings_section
|
||||
}
|
||||
@@ -1906,9 +1910,11 @@ impl Display {
|
||||
if !self.nebula_settings_open {
|
||||
return;
|
||||
}
|
||||
let area = self.terminal_card_rect();
|
||||
let max = settings::settings_max_scroll(
|
||||
&self.size_info,
|
||||
self.window.scale_factor as f32,
|
||||
area,
|
||||
self.nebula_settings_section,
|
||||
self.nebula_shell_picker_open,
|
||||
self.nebula_detected_shells.as_ref().map_or(0, Vec::len),
|
||||
@@ -2761,6 +2767,9 @@ impl Display {
|
||||
/// path so `draw_chrome` can still borrow `&mut renderer` afterwards.
|
||||
fn settings_view(&self) -> settings::SettingsView {
|
||||
settings::SettingsView {
|
||||
area: self.terminal_card_rect(),
|
||||
language_preference: self.nebula_language_preference,
|
||||
language: self.nebula_language,
|
||||
section: self.nebula_settings_section,
|
||||
hover: self.nebula_settings_hover,
|
||||
theme: self.nebula_theme,
|
||||
@@ -2808,24 +2817,31 @@ impl Display {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_settings(&mut self) {
|
||||
self.nebula_settings_open = !self.nebula_settings_open;
|
||||
if !self.nebula_settings_open {
|
||||
pub fn set_settings_tab_active(&mut self, active: bool) {
|
||||
if self.nebula_settings_open == active {
|
||||
return;
|
||||
}
|
||||
self.nebula_settings_open = active;
|
||||
if !active {
|
||||
self.nebula_shell_picker_open = false;
|
||||
self.nebula_font_picker_open = false;
|
||||
self.nebula_settings_hover = SettingsHit::None;
|
||||
} else {
|
||||
// Each explicit visit starts at a predictable page origin.
|
||||
self.nebula_settings_scroll = 0.0;
|
||||
}
|
||||
// Fresh open always starts at the top.
|
||||
self.nebula_settings_scroll = 0.0;
|
||||
self.pending_update.dirty = true;
|
||||
}
|
||||
|
||||
pub fn dismiss_settings(&mut self) {
|
||||
if self.nebula_settings_open {
|
||||
self.nebula_settings_open = false;
|
||||
self.nebula_shell_picker_open = false;
|
||||
self.nebula_font_picker_open = false;
|
||||
self.pending_update.dirty = true;
|
||||
pub fn set_ui_language(&mut self, preference: LanguagePreference) {
|
||||
if self.nebula_language_preference == preference {
|
||||
return;
|
||||
}
|
||||
self.nebula_language_preference = preference;
|
||||
self.nebula_language = preference.resolved();
|
||||
self.nebula_palette.set_language(self.nebula_language);
|
||||
self.persist_nebula_settings();
|
||||
self.pending_update.dirty = true;
|
||||
}
|
||||
|
||||
fn apply_nebula_theme(&mut self, theme: NebulaTheme) {
|
||||
@@ -3148,7 +3164,7 @@ impl Display {
|
||||
};
|
||||
if !path.exists() {
|
||||
let language = crate::config::template::resolve_template_language(
|
||||
None,
|
||||
Some(self.nebula_language_preference.as_str()),
|
||||
None,
|
||||
crate::config::template::system_locale().as_deref(),
|
||||
)
|
||||
@@ -3749,6 +3765,7 @@ impl Display {
|
||||
|
||||
fn persist_nebula_settings(&mut self) {
|
||||
settings::nebula_settings_write(&settings::NebulaRuntimeSettings {
|
||||
language: self.nebula_language_preference,
|
||||
ghost: self.nebula_ghost_enabled,
|
||||
accept: self.nebula_accept,
|
||||
shell: self.nebula_shell,
|
||||
@@ -3777,6 +3794,9 @@ impl Display {
|
||||
}
|
||||
|
||||
let settings = settings::nebula_settings_load(config);
|
||||
self.nebula_language_preference = settings.language;
|
||||
self.nebula_language = settings.language.resolved();
|
||||
self.nebula_palette.set_language(self.nebula_language);
|
||||
let image_changed = settings.background_image != self.nebula_background_image;
|
||||
let font_changed = settings.font_family != self.nebula_font_family;
|
||||
self.nebula_theme_preference = settings.theme;
|
||||
@@ -4704,6 +4724,26 @@ impl Display {
|
||||
self.present_frame(scheduler);
|
||||
}
|
||||
|
||||
/// Draw the Settings special tab. Its controls are emitted by the chrome
|
||||
/// pass so they retain the same hit geometry and icon texture pipeline as
|
||||
/// the rest of Nebula, but the base is a normal tab content card.
|
||||
pub fn draw_settings_frame(&mut self, scheduler: &mut Scheduler) {
|
||||
self.renderer.set_window_height(self.size_info.height());
|
||||
|
||||
let shell_bg = self.nebula_theme.palette().shell_bg;
|
||||
self.renderer.clear(shell_bg, self.nebula_window_opacity);
|
||||
self.draw_background_image();
|
||||
|
||||
let card_bg = self.nebula_background.unwrap_or(self.colors[NamedColor::Background]);
|
||||
let (cx, cy, cw, ch) = self.terminal_card_rect();
|
||||
let scale = self.window.scale_factor as f32;
|
||||
let card_r = (UI_SHELL_RADIUS_LOGICAL * scale).round();
|
||||
let card = Rgba::new(card_bg.r, card_bg.g, card_bg.b, 255);
|
||||
self.renderer.draw_ui(&self.size_info, &[UiQuad::solid(cx, cy, cw, ch, card_r, card)]);
|
||||
|
||||
self.present_frame(scheduler);
|
||||
}
|
||||
|
||||
/// Draw one pane of a multi-pane layout into `view`. `clear_first` clears
|
||||
/// the whole window before the first pane; later panes paint on top.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -6534,9 +6574,8 @@ mod nebula_ux_tests {
|
||||
|
||||
#[test]
|
||||
fn missing_font_notice_can_be_dismissed() {
|
||||
let confirm = NebulaConfirm::InstallRequiredFont {
|
||||
directory: std::path::PathBuf::from("fonts"),
|
||||
};
|
||||
let confirm =
|
||||
NebulaConfirm::InstallRequiredFont { directory: std::path::PathBuf::from("fonts") };
|
||||
|
||||
assert!(confirm.can_dismiss());
|
||||
}
|
||||
|
||||
+298
-135
@@ -1,5 +1,4 @@
|
||||
//! Top-left settings modal: a centered floating panel for Nebula's runtime
|
||||
//! appearance and completion settings.
|
||||
//! Settings special tab for Nebula's runtime appearance and completion settings.
|
||||
//!
|
||||
//! Mirrors the `command_palette` split, but goes one step further: besides the
|
||||
//! *model* (sections, hit-testing, geometry, and the `nebula_settings.txt`
|
||||
@@ -11,7 +10,7 @@
|
||||
//!
|
||||
//! Being a descendant module of `display`, this file can freely use the parent's
|
||||
//! private helpers (`contains_rect`, `truncate_tab_label`, `nebula_data_dir`,
|
||||
//! `NebulaTheme::palette`, `AcceptKey::label`, …) via `super::` — no visibility
|
||||
//! `NebulaTheme::palette`, `AcceptKey`, …) via `super::` — no visibility
|
||||
//! churn needed in `mod.rs`.
|
||||
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
@@ -23,13 +22,13 @@ use crate::renderer::{GlyphCache, Renderer};
|
||||
|
||||
use super::theme::Skin;
|
||||
use super::{
|
||||
AcceptKey, NebulaShell, NebulaTheme, SizeInfo, chrome_settings_button_rect, contains_rect,
|
||||
nebula_data_dir, truncate_tab_label,
|
||||
AcceptKey, LanguagePreference, NebulaShell, NebulaTheme, SizeInfo, UiLanguage,
|
||||
chrome_settings_button_rect, contains_rect, nebula_data_dir, truncate_tab_label,
|
||||
};
|
||||
|
||||
// Visual language: one flat panel color, one hairline, three text grays, ONE
|
||||
// accent — hierarchy comes from typography and spacing. Every color is a
|
||||
// [`Skin`] token from `display::theme` (single source of truth), so the modal
|
||||
// [`Skin`] token from `display::theme` (single source of truth), so the page
|
||||
// flips correctly between the light and dark theme families.
|
||||
|
||||
/// Sidebar sections of the settings panel. Deliberately small: only sections
|
||||
@@ -48,12 +47,12 @@ pub enum NebulaSettingsSection {
|
||||
}
|
||||
|
||||
impl NebulaSettingsSection {
|
||||
fn label(self) -> &'static str {
|
||||
fn label(self, language: UiLanguage) -> &'static str {
|
||||
match self {
|
||||
Self::Appearance => "外观",
|
||||
Self::Profiles => "配置文件",
|
||||
Self::Keymap => "按键映射",
|
||||
Self::Advanced => "高级",
|
||||
Self::Appearance => language.pick("外观", "Appearance"),
|
||||
Self::Profiles => language.pick("配置文件", "Profiles"),
|
||||
Self::Keymap => language.pick("按键映射", "Key bindings"),
|
||||
Self::Advanced => language.pick("高级", "Advanced"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,22 +60,22 @@ impl NebulaSettingsSection {
|
||||
/// Shortcut sheet shown in 设置→按键映射. Read-only for now: the combos on the
|
||||
/// right are Nebula's effective defaults; `[[keyboard.bindings]]` in the config
|
||||
/// file (设置→配置文件→打开配置文件) remaps the standard actions.
|
||||
pub(super) const KEYMAP_ROWS: &[(&str, &str)] = &[
|
||||
("新建标签页", "Ctrl+Shift+T"),
|
||||
("关闭标签页 / 分屏", "Ctrl+Shift+W"),
|
||||
("下一个 / 上一个标签页", "Ctrl+Tab / Ctrl+Shift+Tab"),
|
||||
("切换到第 N 个标签页", "Alt+1..9 或 Ctrl+1..9"),
|
||||
("新建窗口", "Ctrl+Shift+E"),
|
||||
("命令面板", "Ctrl+Shift+P"),
|
||||
("左右 / 上下分屏", "Ctrl+Shift+D / Ctrl+Shift+S"),
|
||||
("分屏焦点切换", "Ctrl+Alt+方向键"),
|
||||
("放大当前分屏", "Ctrl+Shift+Enter"),
|
||||
("启动 Profile N", "Ctrl+Shift+1..9"),
|
||||
("目录树 / Git 面板", "Ctrl+Shift+O / Ctrl+Shift+G"),
|
||||
("搜索(向前 / 向后)", "Ctrl+Shift+F / Ctrl+Shift+B"),
|
||||
("复制 / 粘贴", "Ctrl+Shift+C / Ctrl+V"),
|
||||
("字号 增 / 减 / 重置", "Ctrl+= / Ctrl+- / Ctrl+0"),
|
||||
("全屏", "Alt+Enter"),
|
||||
pub(super) const KEYMAP_ROWS: &[(&str, &str, &str)] = &[
|
||||
("新建标签页", "New tab", "Ctrl+Shift+T"),
|
||||
("关闭标签页 / 分屏", "Close tab / pane", "Ctrl+Shift+W"),
|
||||
("下一个 / 上一个标签页", "Next / previous tab", "Ctrl+Tab / Ctrl+Shift+Tab"),
|
||||
("切换到第 N 个标签页", "Select tab N", "Alt+1..9 / Ctrl+1..9"),
|
||||
("新建窗口", "New window", "Ctrl+Shift+E"),
|
||||
("命令面板", "Command palette", "Ctrl+Shift+P"),
|
||||
("左右 / 上下分屏", "Split right / down", "Ctrl+Shift+D / Ctrl+Shift+S"),
|
||||
("分屏焦点切换", "Move pane focus", "Ctrl+Alt+Arrow"),
|
||||
("放大当前分屏", "Zoom current pane", "Ctrl+Shift+Enter"),
|
||||
("启动 Profile N", "Launch Profile N", "Ctrl+Shift+1..9"),
|
||||
("目录树 / Git 面板", "Files / Git panel", "Ctrl+Shift+O / Ctrl+Shift+G"),
|
||||
("搜索(向前 / 向后)", "Search forward / backward", "Ctrl+Shift+F / Ctrl+Shift+B"),
|
||||
("复制 / 粘贴", "Copy / paste", "Ctrl+Shift+C / Ctrl+V"),
|
||||
("字号 增 / 减 / 重置", "Font size up / down / reset", "Ctrl+= / Ctrl+- / Ctrl+0"),
|
||||
("全屏", "Fullscreen", "Alt+Enter"),
|
||||
];
|
||||
|
||||
/// Hit result for the top-left Nebula settings affordance.
|
||||
@@ -85,9 +84,9 @@ pub enum SettingsHit {
|
||||
None,
|
||||
Toggle,
|
||||
Panel,
|
||||
Dismiss,
|
||||
Nav(NebulaSettingsSection),
|
||||
Theme(NebulaTheme),
|
||||
Language(LanguagePreference),
|
||||
SystemThemeToggle,
|
||||
GhostToggle,
|
||||
AcceptCycle,
|
||||
@@ -114,6 +113,7 @@ pub enum SettingsHit {
|
||||
// ---- runtime settings store (`Nebula/nebula_settings.txt`) ----
|
||||
|
||||
pub(super) struct NebulaRuntimeSettings {
|
||||
pub(super) language: LanguagePreference,
|
||||
pub(super) ghost: bool,
|
||||
pub(super) accept: AcceptKey,
|
||||
pub(super) shell: NebulaShell,
|
||||
@@ -157,6 +157,7 @@ pub(super) struct NebulaRuntimeSettings {
|
||||
pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings {
|
||||
let path = nebula_data_dir().join("nebula_settings.txt");
|
||||
let mut settings = NebulaRuntimeSettings {
|
||||
language: LanguagePreference::System,
|
||||
ghost: true,
|
||||
accept: AcceptKey::Both,
|
||||
shell: NebulaShell::PowerShell,
|
||||
@@ -186,6 +187,11 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings {
|
||||
if let Ok(data) = std::fs::read_to_string(path) {
|
||||
for line in data.lines() {
|
||||
match line.split_once('=') {
|
||||
Some(("language", v)) => {
|
||||
if let Some(language) = LanguagePreference::parse(v) {
|
||||
settings.language = language;
|
||||
}
|
||||
},
|
||||
Some(("ghost", v)) => settings.ghost = v.trim() != "0",
|
||||
Some(("theme", v)) => {
|
||||
if let Some(theme) = NebulaTheme::from_prompt_name(v.trim()) {
|
||||
@@ -316,7 +322,8 @@ pub(super) fn nebula_settings_write(settings: &NebulaRuntimeSettings) {
|
||||
let _ = std::fs::write(
|
||||
path,
|
||||
format!(
|
||||
"theme={theme}\nfollow_system_theme={}\nghost={}\naccept={accept}\nshell={shell}\nfont_family={}\nfetch={}\npowerline={}\nkeep_session={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\npinned_hosts={pinned_hosts}\nsaved_hosts={saved_hosts}\nhidden_hosts={hidden_hosts}\n",
|
||||
"language={}\ntheme={theme}\nfollow_system_theme={}\nghost={}\naccept={accept}\nshell={shell}\nfont_family={}\nfetch={}\npowerline={}\nkeep_session={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\npinned_hosts={pinned_hosts}\nsaved_hosts={saved_hosts}\nhidden_hosts={hidden_hosts}\n",
|
||||
settings.language.as_str(),
|
||||
settings.follow_system_theme as u8,
|
||||
settings.ghost as u8,
|
||||
settings.font_family,
|
||||
@@ -355,9 +362,11 @@ struct SettingsGeometry {
|
||||
hidden_host_count: usize,
|
||||
/// Full-width "窗口透明度" row (the frame); the stepper buttons below sit
|
||||
/// inside it.
|
||||
language_row: (f32, f32, f32, f32),
|
||||
opacity_row: (f32, f32, f32, f32),
|
||||
opacity_down: (f32, f32, f32, f32),
|
||||
opacity_up: (f32, f32, f32, f32),
|
||||
language: [(LanguagePreference, f32, f32, f32, f32); 3],
|
||||
background: (f32, f32, f32, f32),
|
||||
background_image: (f32, f32, f32, f32),
|
||||
reset: (f32, f32, f32, f32),
|
||||
@@ -376,7 +385,7 @@ struct SettingsGeometry {
|
||||
keep_session: (f32, f32, f32, f32),
|
||||
}
|
||||
|
||||
/// Scrollable-content viewport height for the settings modal.
|
||||
/// Scrollable-content viewport height for the Settings tab.
|
||||
fn settings_viewport_h(popup_h: f32, scale_factor: f32) -> f32 {
|
||||
popup_h - 72.0 * scale_factor
|
||||
}
|
||||
@@ -386,6 +395,7 @@ fn settings_viewport_h(popup_h: f32, scale_factor: f32) -> f32 {
|
||||
pub(super) fn settings_max_scroll(
|
||||
size_info: &SizeInfo,
|
||||
scale_factor: f32,
|
||||
area: (f32, f32, f32, f32),
|
||||
section: NebulaSettingsSection,
|
||||
shell_picker_open: bool,
|
||||
shell_picker_count: usize,
|
||||
@@ -396,6 +406,7 @@ pub(super) fn settings_max_scroll(
|
||||
let geometry = settings_geometry(
|
||||
size_info,
|
||||
scale_factor,
|
||||
area,
|
||||
0.0,
|
||||
shell_picker_open,
|
||||
shell_picker_count,
|
||||
@@ -416,6 +427,7 @@ pub(super) fn settings_max_scroll(
|
||||
fn settings_geometry(
|
||||
size_info: &SizeInfo,
|
||||
scale_factor: f32,
|
||||
area: (f32, f32, f32, f32),
|
||||
scroll: f32,
|
||||
shell_picker_open: bool,
|
||||
shell_picker_count: usize,
|
||||
@@ -424,23 +436,16 @@ fn settings_geometry(
|
||||
hidden_host_count: usize,
|
||||
) -> SettingsGeometry {
|
||||
let s = |v: f32| v * scale_factor;
|
||||
let w = size_info.width();
|
||||
let h = size_info.height();
|
||||
let margin = s(8.0);
|
||||
let bar_h = s(40.0);
|
||||
let gear = chrome_settings_button_rect(size_info, scale_factor);
|
||||
|
||||
// Floating, centered modal — fixed design size clamped to the window so it
|
||||
// never fills the whole screen, sized to hold the 4+3 theme card grid plus
|
||||
// the compact appearance/profile controls (no large vertical gaps).
|
||||
let popup_w = s(1000.0).min(w - 2.0 * margin);
|
||||
let popup_h = s(640.0).min(h - 2.0 * bar_h - 2.0 * margin);
|
||||
let popup_x = ((w - popup_w) * 0.5).max(margin);
|
||||
let popup_y = ((h - popup_h) * 0.5).max(bar_h + margin);
|
||||
let sidebar_w = s(240.0).min(popup_w * 0.30);
|
||||
// The settings surface is the active tab's content card. Keeping the
|
||||
// geometry rooted in that card makes sidebar/drawer animations and DPI
|
||||
// changes follow the exact same bounds as terminal and document tabs.
|
||||
let (popup_x, popup_y, popup_w, popup_h) = area;
|
||||
let sidebar_w = s(220.0).min(popup_w * 0.30);
|
||||
let sidebar = (popup_x, popup_y, sidebar_w, popup_h);
|
||||
let content_x = popup_x + sidebar_w + s(16.0);
|
||||
let content_w = (popup_w - sidebar_w - s(16.0)).max(s(520.0));
|
||||
let content_w = (popup_w - sidebar_w - s(16.0)).max(s(240.0));
|
||||
let content = (content_x, popup_y, content_w, popup_h);
|
||||
|
||||
// The header band (big section title) is fixed; everything below it
|
||||
@@ -479,11 +484,16 @@ fn settings_geometry(
|
||||
let system_theme_y0 = card_y0 + 2.0 * (64.0 + 48.0) + GROUP_ADVANCE;
|
||||
let color_y0 = system_theme_y0 + ROW_H + GROUP_ADVANCE;
|
||||
let iface_y0 = color_y0 + 2.0 * ROW_H + GROUP_ADVANCE;
|
||||
let appearance_h = s(iface_y0 + ROW_H + 32.0 - 72.0);
|
||||
let opacity_y0 = iface_y0 + ROW_H;
|
||||
let appearance_h = s(opacity_y0 + ROW_H + 32.0 - 72.0);
|
||||
// Opacity row controls, right-aligned with NO overlap: value · − · slider · +.
|
||||
let opacity_row = (row_x, at(iface_y0), row_w, row_h);
|
||||
let opacity_down = (row_x + row_w - s(224.0), at(iface_y0) + s(4.0), s(40.0), s(36.0));
|
||||
let opacity_up = (row_x + row_w - s(46.0), at(iface_y0) + s(4.0), s(40.0), s(36.0));
|
||||
let opacity_row = (row_x, at(opacity_y0), row_w, row_h);
|
||||
let opacity_down = (row_x + row_w - s(224.0), at(opacity_y0) + s(4.0), s(40.0), s(36.0));
|
||||
let opacity_up = (row_x + row_w - s(46.0), at(opacity_y0) + s(4.0), s(40.0), s(36.0));
|
||||
let language_track_w = s(360.0).min(row_w * 0.58);
|
||||
let language_w = language_track_w / 3.0;
|
||||
let language_x = row_x + row_w - language_track_w - s(8.0);
|
||||
let language_y = at(iface_y0) + s(5.0);
|
||||
|
||||
// Sidebar navigation rows. Only the two wired-up sections are listed; the
|
||||
// rects line up with the active-row highlight drawn while rendering.
|
||||
@@ -506,8 +516,7 @@ fn settings_geometry(
|
||||
let shell_picker_extra =
|
||||
if shell_picker_open { shell_picker_count as f32 * ROW_H } else { 0.0 };
|
||||
let font_y0 = shell_y0 + ROW_H + shell_picker_extra;
|
||||
let font_picker_extra =
|
||||
if font_picker_open { font_picker_count as f32 * ROW_H } else { 0.0 };
|
||||
let font_picker_extra = if font_picker_open { font_picker_count as f32 * ROW_H } else { 0.0 };
|
||||
let fetch_y0 = font_y0 + ROW_H + font_picker_extra;
|
||||
let ghost_y0 = fetch_y0 + 2.0 * ROW_H + GROUP_ADVANCE;
|
||||
let open_y0 = ghost_y0 + 2.0 * ROW_H + GROUP_ADVANCE;
|
||||
@@ -547,9 +556,21 @@ fn settings_geometry(
|
||||
system_theme: (row_x, at(system_theme_y0), row_w, row_h),
|
||||
background: (row_x, at(color_y0), row_w, row_h),
|
||||
background_image: (row_x, at(color_y0 + ROW_H), row_w, row_h),
|
||||
language_row: (row_x, at(iface_y0), row_w, row_h),
|
||||
opacity_row,
|
||||
opacity_down,
|
||||
opacity_up,
|
||||
language: [
|
||||
(LanguagePreference::System, language_x, language_y, language_w, s(34.0)),
|
||||
(LanguagePreference::ZhCn, language_x + language_w, language_y, language_w, s(34.0)),
|
||||
(
|
||||
LanguagePreference::EnUs,
|
||||
language_x + language_w * 2.0,
|
||||
language_y,
|
||||
language_w,
|
||||
s(34.0),
|
||||
),
|
||||
],
|
||||
shell: (row_x, at(shell_y0), row_w, row_h),
|
||||
shell_picker: (
|
||||
at(shell_y0 + ROW_H),
|
||||
@@ -587,6 +608,7 @@ fn settings_geometry(
|
||||
pub fn settings_hit(
|
||||
size_info: &SizeInfo,
|
||||
scale_factor: f32,
|
||||
area: (f32, f32, f32, f32),
|
||||
x: f32,
|
||||
y: f32,
|
||||
popup_open: bool,
|
||||
@@ -601,6 +623,7 @@ pub fn settings_hit(
|
||||
let geometry = settings_geometry(
|
||||
size_info,
|
||||
scale_factor,
|
||||
area,
|
||||
scroll,
|
||||
shell_picker_open,
|
||||
shell_picker_count,
|
||||
@@ -650,6 +673,11 @@ pub fn settings_hit(
|
||||
if contains_rect(geometry.background_image, x, y) {
|
||||
return SettingsHit::BackgroundImage;
|
||||
}
|
||||
for (language, lx, ly, lw, lh) in geometry.language {
|
||||
if contains_rect((lx, ly, lw, lh), x, y) {
|
||||
return SettingsHit::Language(language);
|
||||
}
|
||||
}
|
||||
if contains_rect(geometry.opacity_down, x, y) {
|
||||
return SettingsHit::OpacityDown;
|
||||
}
|
||||
@@ -680,12 +708,8 @@ pub fn settings_hit(
|
||||
if font_picker_open && in_viewport {
|
||||
let (picker_y0, row_h, max) = geometry.font_picker;
|
||||
for i in 0..max {
|
||||
let rect = (
|
||||
geometry.font.0,
|
||||
picker_y0 + i as f32 * row_h,
|
||||
geometry.font.2,
|
||||
row_h,
|
||||
);
|
||||
let rect =
|
||||
(geometry.font.0, picker_y0 + i as f32 * row_h, geometry.font.2, row_h);
|
||||
if contains_rect(rect, x, y) {
|
||||
return SettingsHit::FontPickerRow(i);
|
||||
}
|
||||
@@ -723,7 +747,7 @@ pub fn settings_hit(
|
||||
}
|
||||
}
|
||||
|
||||
if contains_rect(geometry.popup, x, y) { SettingsHit::Panel } else { SettingsHit::Dismiss }
|
||||
if contains_rect(geometry.popup, x, y) { SettingsHit::Panel } else { SettingsHit::None }
|
||||
}
|
||||
|
||||
// ---- rendering ----
|
||||
@@ -732,6 +756,11 @@ pub fn settings_hit(
|
||||
/// data (notably the wallpaper path) so the caller can hand it in by reference
|
||||
/// while still borrowing `&mut renderer` for [`draw_text`].
|
||||
pub(super) struct SettingsView {
|
||||
/// The active tab's content card in physical pixels. Settings fills this
|
||||
/// area like any other tab instead of inventing a second floating window.
|
||||
pub(super) area: (f32, f32, f32, f32),
|
||||
pub(super) language_preference: LanguagePreference,
|
||||
pub(super) language: UiLanguage,
|
||||
pub(super) section: NebulaSettingsSection,
|
||||
pub(super) hover: SettingsHit,
|
||||
pub(super) theme: NebulaTheme,
|
||||
@@ -766,9 +795,7 @@ pub(super) struct SettingsView {
|
||||
pub(super) scroll: f32,
|
||||
}
|
||||
|
||||
/// Push the settings modal's background quads (dim veil, flat neutral panel,
|
||||
/// sidebar separator, nav indicator, rows, theme cards and controls). Caller
|
||||
/// guards on the panel being open.
|
||||
/// Push the Settings tab's background quads, navigation, rows and controls.
|
||||
pub(super) fn push_quads(
|
||||
view: &SettingsView,
|
||||
quads: &mut Vec<UiQuad>,
|
||||
@@ -776,13 +803,12 @@ pub(super) fn push_quads(
|
||||
scale: f32,
|
||||
) {
|
||||
let s = |v: f32| v * scale;
|
||||
let w = size.width();
|
||||
let h = size.height();
|
||||
let sk = view.theme.skin();
|
||||
|
||||
let geometry = settings_geometry(
|
||||
size,
|
||||
scale,
|
||||
view.area,
|
||||
view.scroll,
|
||||
view.shell_picker_open,
|
||||
view.shells.len(),
|
||||
@@ -806,31 +832,8 @@ pub(super) fn push_quads(
|
||||
}
|
||||
};
|
||||
|
||||
// Heavily dim the whole window behind the modal. The panel is now opaque,
|
||||
// so the veil's only job is to push the terminal far back — a strong, near
|
||||
// -black wash reads as "modal focus" instead of the old faint glass tint.
|
||||
quads.push(UiQuad::solid(0.0, 0.0, w, h, 0.0, Rgba::new(0, 0, 0, 205)));
|
||||
|
||||
// Diffuse shadow: one wide, soft glow under the panel sells the Z-depth
|
||||
// (the softer and wider, the higher the panel appears to float).
|
||||
quads.push(UiQuad::glow(
|
||||
px - s(44.0),
|
||||
py - s(26.0),
|
||||
pw + s(88.0),
|
||||
ph + s(96.0),
|
||||
Rgba::new(0, 0, 0, 180),
|
||||
));
|
||||
|
||||
// Hairline edge + near-opaque themed panel (two-layer stroke: 1px larger
|
||||
// quad underneath), then a 1px top edge-light — the lit bevel.
|
||||
quads.push(UiQuad::solid(
|
||||
px - s(1.0),
|
||||
py - s(1.0),
|
||||
pw + s(2.0),
|
||||
ph + s(2.0),
|
||||
s(13.0),
|
||||
sk.hairline,
|
||||
));
|
||||
// The page is flush with the active tab card. No veil, drop shadow or
|
||||
// second window outline: depth belongs to the app shell, not this page.
|
||||
quads.push(UiQuad::solid(px, py, pw, ph, s(12.0), sk.panel));
|
||||
quads.push(UiQuad::solid(
|
||||
px + s(12.0),
|
||||
@@ -1033,18 +1036,36 @@ pub(super) fn push_quads(
|
||||
}
|
||||
|
||||
group_frame(quads, geometry.system_theme, 1);
|
||||
row_hover(
|
||||
quads,
|
||||
geometry.system_theme,
|
||||
view.hover == SettingsHit::SystemThemeToggle,
|
||||
);
|
||||
row_hover(quads, geometry.system_theme, view.hover == SettingsHit::SystemThemeToggle);
|
||||
toggle(quads, geometry.system_theme, view.follow_system_theme);
|
||||
|
||||
// 自定义背景: one 2-row frame. 界面: one single-row frame.
|
||||
// 自定义背景和界面都使用连续分组,避免设置 Tab 内再次出现
|
||||
// 漂浮卡片语言。
|
||||
group_frame(quads, geometry.background, 2);
|
||||
row_hover(quads, geometry.background, view.hover == SettingsHit::BackgroundColor);
|
||||
row_hover(quads, geometry.background_image, view.hover == SettingsHit::BackgroundImage);
|
||||
group_frame(quads, geometry.opacity_row, 1);
|
||||
group_frame(quads, geometry.language_row, 2);
|
||||
for (language, lx, ly, lw, lh) in geometry.language {
|
||||
let selected = language == view.language_preference;
|
||||
let hovered = view.hover == SettingsHit::Language(language);
|
||||
clip(
|
||||
quads,
|
||||
UiQuad::solid(
|
||||
lx,
|
||||
ly,
|
||||
lw,
|
||||
lh,
|
||||
s(6.0),
|
||||
if selected {
|
||||
sk.accent_soft
|
||||
} else if hovered {
|
||||
sk.hover
|
||||
} else {
|
||||
sk.surface
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Opacity controls inside the row: ghost stepper buttons flanking
|
||||
// a muted rail with a flat accent fill and a round thumb.
|
||||
@@ -1175,11 +1196,7 @@ pub(super) fn push_quads(
|
||||
for index in 0..geometry.hidden_host_count {
|
||||
let mut rect = geometry.hidden_host_row0;
|
||||
rect.1 += index as f32 * rect.3;
|
||||
row_hover(
|
||||
quads,
|
||||
rect,
|
||||
view.hover == SettingsHit::RestoreHiddenSsh(index),
|
||||
);
|
||||
row_hover(quads, rect, view.hover == SettingsHit::RestoreHiddenSsh(index));
|
||||
}
|
||||
}
|
||||
for (hit, rect) in [
|
||||
@@ -1305,8 +1322,7 @@ fn row_label(
|
||||
r.draw_chrome_text(size, vx.max(value_left), ty, value_ink, &value, gc);
|
||||
}
|
||||
|
||||
/// Draw the settings modal's text labels on top of its quads. Caller guards on
|
||||
/// the panel being open.
|
||||
/// Draw the Settings tab's text labels on top of its quads.
|
||||
pub(super) fn draw_text(
|
||||
view: &SettingsView,
|
||||
r: &mut Renderer,
|
||||
@@ -1318,10 +1334,12 @@ pub(super) fn draw_text(
|
||||
let cell_w = size.cell_width();
|
||||
let cell_h = size.cell_height();
|
||||
let sk = view.theme.skin();
|
||||
let language = view.language;
|
||||
|
||||
let geometry = settings_geometry(
|
||||
size,
|
||||
scale,
|
||||
view.area,
|
||||
view.scroll,
|
||||
view.shell_picker_open,
|
||||
view.shells.len(),
|
||||
@@ -1354,12 +1372,12 @@ pub(super) fn draw_text(
|
||||
py + s(22.0),
|
||||
1.5,
|
||||
sk.ink_strong,
|
||||
"Nebula 设置",
|
||||
language.pick("Nebula 设置", "Nebula Settings"),
|
||||
);
|
||||
{
|
||||
// Center the reset label inside its ghost button.
|
||||
let (rx, ry, rw, rh) = geometry.reset;
|
||||
let label = "恢复默认设置";
|
||||
let label = language.pick("恢复默认设置", "Restore defaults");
|
||||
let cols: usize = label.chars().map(|c| c.width().unwrap_or(0)).sum();
|
||||
let tx = rx + (rw - cols as f32 * cell_w) / 2.0;
|
||||
r.draw_chrome_text(size, tx, ry + (rh - cell_h) / 2.0, sk.ink_dim, label, gc);
|
||||
@@ -1380,7 +1398,7 @@ pub(super) fn draw_text(
|
||||
} else {
|
||||
sk.ink_dim
|
||||
},
|
||||
nav_section.label(),
|
||||
nav_section.label(view.language),
|
||||
gc,
|
||||
);
|
||||
}
|
||||
@@ -1396,7 +1414,7 @@ pub(super) fn draw_text(
|
||||
content_y + s(20.0),
|
||||
1.6,
|
||||
sk.ink_strong,
|
||||
section.label(),
|
||||
section.label(view.language),
|
||||
);
|
||||
|
||||
match section {
|
||||
@@ -1411,7 +1429,7 @@ pub(super) fn draw_text(
|
||||
&sk,
|
||||
content_x + s(24.0),
|
||||
group_y(cards_y),
|
||||
"主题",
|
||||
language.pick("主题", "Themes"),
|
||||
);
|
||||
}
|
||||
for (theme, ox, oy, ow, oh) in geometry.options {
|
||||
@@ -1443,7 +1461,16 @@ pub(super) fn draw_text(
|
||||
}
|
||||
let (st_x, st_y, _, st_h) = geometry.system_theme;
|
||||
if visible(group_y(st_y), title_h) {
|
||||
section_title(r, gc, size, scale, &sk, st_x, group_y(st_y), "主题模式");
|
||||
section_title(
|
||||
r,
|
||||
gc,
|
||||
size,
|
||||
scale,
|
||||
&sk,
|
||||
st_x,
|
||||
group_y(st_y),
|
||||
language.pick("主题模式", "Theme mode"),
|
||||
);
|
||||
}
|
||||
if visible(st_y, st_h) {
|
||||
r.draw_chrome_text(
|
||||
@@ -1451,17 +1478,28 @@ pub(super) fn draw_text(
|
||||
st_x + s(16.0),
|
||||
st_y + (st_h - cell_h) / 2.0,
|
||||
sk.ink,
|
||||
"跟随系统明暗模式",
|
||||
language.pick("跟随系统明暗模式", "Follow system appearance"),
|
||||
gc,
|
||||
);
|
||||
}
|
||||
let (bg_x, bg_y, _, bg_h) = geometry.background;
|
||||
if visible(group_y(bg_y), title_h) {
|
||||
section_title(r, gc, size, scale, &sk, bg_x, group_y(bg_y), "自定义背景");
|
||||
section_title(
|
||||
r,
|
||||
gc,
|
||||
size,
|
||||
scale,
|
||||
&sk,
|
||||
bg_x,
|
||||
group_y(bg_y),
|
||||
language.pick("自定义背景", "Custom background"),
|
||||
);
|
||||
}
|
||||
if visible(bg_y, bg_h) {
|
||||
let background_v =
|
||||
view.background.map(format_hex_rgb).unwrap_or_else(|| "主题默认".to_owned());
|
||||
let background_v = view
|
||||
.background
|
||||
.map(format_hex_rgb)
|
||||
.unwrap_or_else(|| language.pick("主题默认", "Theme default").to_owned());
|
||||
row_label(
|
||||
r,
|
||||
gc,
|
||||
@@ -1469,7 +1507,7 @@ pub(super) fn draw_text(
|
||||
scale,
|
||||
&sk,
|
||||
geometry.background,
|
||||
"背景色",
|
||||
language.pick("背景色", "Background color"),
|
||||
&background_v,
|
||||
sk.accent,
|
||||
);
|
||||
@@ -1481,7 +1519,7 @@ pub(super) fn draw_text(
|
||||
.background_image
|
||||
.as_deref()
|
||||
.map(|path| format!("{path} · {:.0}%", view.background_image_opacity * 100.0))
|
||||
.unwrap_or_else(|| "未设置".to_owned());
|
||||
.unwrap_or_else(|| language.pick("未设置", "Not set").to_owned());
|
||||
row_label(
|
||||
r,
|
||||
gc,
|
||||
@@ -1489,14 +1527,50 @@ pub(super) fn draw_text(
|
||||
scale,
|
||||
&sk,
|
||||
geometry.background_image,
|
||||
"背景图片",
|
||||
language.pick("背景图片", "Background image"),
|
||||
&image_v,
|
||||
sk.accent,
|
||||
);
|
||||
}
|
||||
let (or_x, or_y, _, or_h) = geometry.opacity_row;
|
||||
if visible(group_y(or_y), title_h) {
|
||||
section_title(r, gc, size, scale, &sk, content_x + s(24.0), group_y(or_y), "界面");
|
||||
let (lr_x, lr_y, _, lr_h) = geometry.language_row;
|
||||
if visible(group_y(lr_y), title_h) {
|
||||
section_title(
|
||||
r,
|
||||
gc,
|
||||
size,
|
||||
scale,
|
||||
&sk,
|
||||
content_x + s(24.0),
|
||||
group_y(lr_y),
|
||||
language.pick("界面", "Interface"),
|
||||
);
|
||||
}
|
||||
if visible(lr_y, lr_h) {
|
||||
r.draw_chrome_text(
|
||||
size,
|
||||
lr_x + s(16.0),
|
||||
lr_y + (lr_h - cell_h) / 2.0,
|
||||
sk.ink,
|
||||
language.pick("语言", "Language"),
|
||||
gc,
|
||||
);
|
||||
for (preference, lx, ly, lw, lh) in geometry.language {
|
||||
let label = match preference {
|
||||
LanguagePreference::System => language.pick("跟随系统", "Follow system"),
|
||||
LanguagePreference::ZhCn => "简体中文",
|
||||
LanguagePreference::EnUs => "English",
|
||||
};
|
||||
let cols: usize = label.chars().map(|c| c.width().unwrap_or(0)).sum();
|
||||
r.draw_chrome_text(
|
||||
size,
|
||||
lx + (lw - cols as f32 * cell_w) / 2.0,
|
||||
ly + (lh - cell_h) / 2.0,
|
||||
if preference == view.language_preference { sk.accent } else { sk.ink_dim },
|
||||
label,
|
||||
gc,
|
||||
);
|
||||
}
|
||||
}
|
||||
if visible(or_y, or_h) {
|
||||
r.draw_chrome_text(
|
||||
@@ -1504,7 +1578,7 @@ pub(super) fn draw_text(
|
||||
or_x + s(16.0),
|
||||
or_y + (or_h - cell_h) / 2.0,
|
||||
sk.ink,
|
||||
"窗口透明度",
|
||||
language.pick("窗口透明度", "Window opacity"),
|
||||
gc,
|
||||
);
|
||||
// Center the fullwidth −/+ glyphs inside their stepper buttons.
|
||||
@@ -1538,7 +1612,16 @@ pub(super) fn draw_text(
|
||||
// with the next group's title.
|
||||
let (sh_x, sh_y, _, sh_h) = geometry.shell;
|
||||
if visible(group_y(sh_y), title_h) {
|
||||
section_title(r, gc, size, scale, &sk, sh_x, group_y(sh_y), "终端");
|
||||
section_title(
|
||||
r,
|
||||
gc,
|
||||
size,
|
||||
scale,
|
||||
&sk,
|
||||
sh_x,
|
||||
group_y(sh_y),
|
||||
language.pick("终端", "Terminal"),
|
||||
);
|
||||
}
|
||||
if visible(sh_y, sh_h) {
|
||||
row_label(
|
||||
@@ -1548,7 +1631,7 @@ pub(super) fn draw_text(
|
||||
scale,
|
||||
&sk,
|
||||
geometry.shell,
|
||||
"默认 Shell",
|
||||
language.pick("默认 Shell", "Default shell"),
|
||||
&view.shell_label,
|
||||
sk.accent,
|
||||
);
|
||||
@@ -1597,7 +1680,7 @@ pub(super) fn draw_text(
|
||||
scale,
|
||||
&sk,
|
||||
geometry.font,
|
||||
"终端字体",
|
||||
language.pick("终端字体", "Terminal font"),
|
||||
font_value,
|
||||
if view.font_notice.is_some() { sk.ink_dim } else { sk.accent },
|
||||
);
|
||||
@@ -1611,7 +1694,7 @@ pub(super) fn draw_text(
|
||||
let (label, color) = match view.fonts.get(i) {
|
||||
Some(family) if family == &view.font_family => (family.as_str(), sk.accent),
|
||||
Some(family) => (family.as_str(), sk.ink),
|
||||
None => ("+ 导入字体…", sk.accent),
|
||||
None => (language.pick("+ 导入字体…", "+ Import font..."), sk.accent),
|
||||
};
|
||||
r.draw_chrome_text(
|
||||
size,
|
||||
@@ -1635,7 +1718,17 @@ pub(super) fn draw_text(
|
||||
// Boolean rows: the switch (drawn in `push_quads`) carries the
|
||||
// state; no "On/Off" string next to it.
|
||||
if visible(geometry.fetch.1, geometry.fetch.3) {
|
||||
row_label(r, gc, size, scale, &sk, geometry.fetch, "启动欢迎信息", "", sk.ink);
|
||||
row_label(
|
||||
r,
|
||||
gc,
|
||||
size,
|
||||
scale,
|
||||
&sk,
|
||||
geometry.fetch,
|
||||
language.pick("启动欢迎信息", "Startup welcome"),
|
||||
"",
|
||||
sk.ink,
|
||||
);
|
||||
}
|
||||
if visible(geometry.powerline.1, geometry.powerline.3) {
|
||||
row_label(
|
||||
@@ -1645,7 +1738,7 @@ pub(super) fn draw_text(
|
||||
scale,
|
||||
&sk,
|
||||
geometry.powerline,
|
||||
"Powerline 提示符",
|
||||
language.pick("Powerline 提示符", "Powerline prompt"),
|
||||
"",
|
||||
sk.ink,
|
||||
);
|
||||
@@ -1653,10 +1746,29 @@ pub(super) fn draw_text(
|
||||
|
||||
let (gh_x, gh_y, _, gh_h) = geometry.ghost;
|
||||
if visible(group_y(gh_y), title_h) {
|
||||
section_title(r, gc, size, scale, &sk, gh_x, group_y(gh_y), "补全");
|
||||
section_title(
|
||||
r,
|
||||
gc,
|
||||
size,
|
||||
scale,
|
||||
&sk,
|
||||
gh_x,
|
||||
group_y(gh_y),
|
||||
language.pick("补全", "Completion"),
|
||||
);
|
||||
}
|
||||
if visible(gh_y, gh_h) {
|
||||
row_label(r, gc, size, scale, &sk, geometry.ghost, "历史补全灰字", "", sk.ink);
|
||||
row_label(
|
||||
r,
|
||||
gc,
|
||||
size,
|
||||
scale,
|
||||
&sk,
|
||||
geometry.ghost,
|
||||
language.pick("历史补全灰字", "History ghost text"),
|
||||
"",
|
||||
sk.ink,
|
||||
);
|
||||
}
|
||||
if visible(geometry.accept.1, geometry.accept.3) {
|
||||
row_label(
|
||||
@@ -1666,15 +1778,28 @@ pub(super) fn draw_text(
|
||||
scale,
|
||||
&sk,
|
||||
geometry.accept,
|
||||
"补全接受键",
|
||||
view.accept.label(),
|
||||
language.pick("补全接受键", "Completion accept key"),
|
||||
match view.accept {
|
||||
AcceptKey::Right => language.pick("右方向键", "Right arrow"),
|
||||
AcceptKey::Tab => "Tab",
|
||||
AcceptKey::Both => language.pick("Tab 或右方向键", "Tab or Right arrow"),
|
||||
},
|
||||
sk.accent,
|
||||
);
|
||||
}
|
||||
|
||||
let (ocx, ocy, _ocw, och) = geometry.open_config_file;
|
||||
if visible(group_y(ocy), title_h) {
|
||||
section_title(r, gc, size, scale, &sk, ocx, group_y(ocy), "配置文件");
|
||||
section_title(
|
||||
r,
|
||||
gc,
|
||||
size,
|
||||
scale,
|
||||
&sk,
|
||||
ocx,
|
||||
group_y(ocy),
|
||||
language.pick("配置文件", "Configuration"),
|
||||
);
|
||||
}
|
||||
if visible(ocy, och) {
|
||||
r.draw_chrome_text(
|
||||
@@ -1682,7 +1807,7 @@ pub(super) fn draw_text(
|
||||
ocx + s(16.0),
|
||||
ocy + (och - cell_h) / 2.0,
|
||||
sk.accent,
|
||||
"打开配置文件",
|
||||
language.pick("打开配置文件", "Open configuration file"),
|
||||
gc,
|
||||
);
|
||||
}
|
||||
@@ -1698,13 +1823,26 @@ pub(super) fn draw_text(
|
||||
&sk,
|
||||
hx,
|
||||
group_y(hy),
|
||||
"已隐藏 SSH 主机 · 密码不会恢复",
|
||||
language.pick(
|
||||
"已隐藏 SSH 主机 · 密码不会恢复",
|
||||
"Hidden SSH hosts · passwords are not restored",
|
||||
),
|
||||
);
|
||||
}
|
||||
for (index, host) in view.hidden_hosts.iter().enumerate() {
|
||||
let rect = (hx, hy + index as f32 * hh, hw, hh);
|
||||
if visible(rect.1, rect.3) {
|
||||
row_label(r, gc, size, scale, &sk, rect, host, "恢复", sk.accent);
|
||||
row_label(
|
||||
r,
|
||||
gc,
|
||||
size,
|
||||
scale,
|
||||
&sk,
|
||||
rect,
|
||||
host,
|
||||
language.pick("恢复", "Restore"),
|
||||
sk.accent,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1720,20 +1858,42 @@ pub(super) fn draw_text(
|
||||
&sk,
|
||||
kx,
|
||||
group_y(ky),
|
||||
"快捷键(可在配置文件 [[keyboard.bindings]] 中自定义)",
|
||||
language.pick(
|
||||
"快捷键(可在配置文件 [[keyboard.bindings]] 中自定义)",
|
||||
"Shortcuts (customize in [[keyboard.bindings]])",
|
||||
),
|
||||
);
|
||||
}
|
||||
for (i, (label, combo)) in KEYMAP_ROWS.iter().enumerate() {
|
||||
for (i, (zh_label, en_label, combo)) in KEYMAP_ROWS.iter().enumerate() {
|
||||
let rect = (kx, ky + i as f32 * geometry.keymap_row_h, kw, kh);
|
||||
if visible(rect.1, rect.3) {
|
||||
row_label(r, gc, size, scale, &sk, rect, label, combo, sk.ink_dim);
|
||||
row_label(
|
||||
r,
|
||||
gc,
|
||||
size,
|
||||
scale,
|
||||
&sk,
|
||||
rect,
|
||||
view.language.pick(zh_label, en_label),
|
||||
combo,
|
||||
sk.ink_dim,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
NebulaSettingsSection::Advanced => {
|
||||
let (ax, ay, _, ah) = geometry.keep_session;
|
||||
if visible(group_y(ay), title_h) {
|
||||
section_title(r, gc, size, scale, &sk, ax, group_y(ay), "会话");
|
||||
section_title(
|
||||
r,
|
||||
gc,
|
||||
size,
|
||||
scale,
|
||||
&sk,
|
||||
ax,
|
||||
group_y(ay),
|
||||
language.pick("会话", "Sessions"),
|
||||
);
|
||||
}
|
||||
if visible(ay, ah) {
|
||||
// The switch (drawn in `push_quads`) carries the state; the
|
||||
@@ -1745,7 +1905,10 @@ pub(super) fn draw_text(
|
||||
scale,
|
||||
&sk,
|
||||
geometry.keep_session,
|
||||
"关闭窗口后保留会话(后台驻留,可恢复对话)",
|
||||
language.pick(
|
||||
"关闭窗口后保留会话(后台驻留,可恢复对话)",
|
||||
"Keep sessions after closing the window (resident and restorable)",
|
||||
),
|
||||
"",
|
||||
sk.ink,
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ impl Display {
|
||||
let scale = self.window.scale_factor as f32;
|
||||
let s = |value: f32| value * scale;
|
||||
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();
|
||||
@@ -84,7 +85,8 @@ impl Display {
|
||||
} else {
|
||||
zero
|
||||
};
|
||||
let save_label = "保存密码到 Windows 凭据管理器";
|
||||
let save_label = language
|
||||
.pick("保存密码到 Windows 凭据管理器", "Save password in Windows Credential Manager");
|
||||
let save_toggle = if show_password {
|
||||
(
|
||||
destination.0,
|
||||
@@ -135,9 +137,9 @@ impl Display {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let primary_action = "保存";
|
||||
let primary_action = language.pick("保存", "Save");
|
||||
let primary_key = "Enter";
|
||||
let cancel_action = "取消";
|
||||
let cancel_action = language.pick("取消", "Cancel");
|
||||
let cancel_key = "Esc";
|
||||
let key_pad = s(6.0);
|
||||
let label_gap = s(8.0);
|
||||
@@ -374,9 +376,9 @@ impl Display {
|
||||
skin.ink_strong,
|
||||
Flags::empty(),
|
||||
if editor.original_destination.is_some() {
|
||||
"编辑 SSH 主机"
|
||||
language.pick("编辑 SSH 主机", "Edit SSH host")
|
||||
} else {
|
||||
"添加 SSH 主机"
|
||||
language.pick("添加 SSH 主机", "Add SSH host")
|
||||
},
|
||||
glyph_cache,
|
||||
);
|
||||
@@ -385,7 +387,7 @@ impl Display {
|
||||
destination.0,
|
||||
destination.1 - cell_h - s(5.0),
|
||||
skin.ink,
|
||||
"连接地址",
|
||||
language.pick("连接地址", "Destination"),
|
||||
glyph_cache,
|
||||
);
|
||||
self.renderer.draw_doc_text(
|
||||
@@ -399,7 +401,10 @@ impl Display {
|
||||
skin.ink_dim
|
||||
},
|
||||
Flags::empty(),
|
||||
editor.error.as_deref().unwrap_or("user@host · 非 22 端口用 ssh://"),
|
||||
editor.error.as_deref().unwrap_or(language.pick(
|
||||
"user@host · 非 22 端口用 ssh://",
|
||||
"user@host · use ssh:// for non-22 ports",
|
||||
)),
|
||||
glyph_cache,
|
||||
);
|
||||
self.renderer.draw_chrome_text(
|
||||
@@ -415,20 +420,20 @@ impl Display {
|
||||
destination.0,
|
||||
auth_y - cell_h - s(5.0),
|
||||
skin.ink_dim,
|
||||
"认证方式",
|
||||
language.pick("认证方式", "Authentication"),
|
||||
glyph_cache,
|
||||
);
|
||||
let auth_labels = ["自动", "密码", "密钥", "Agent", "交互式"];
|
||||
for ((_, rect), label) in auth.iter().zip(auth_labels) {
|
||||
let auth_labels = if language == super::UiLanguage::ZhCn {
|
||||
["自动", "密码", "密钥", "Agent", "交互式"]
|
||||
} else {
|
||||
["Auto", "Password", "Key", "Agent", "Interactive"]
|
||||
};
|
||||
for ((mode, rect), label) in auth.iter().zip(auth_labels) {
|
||||
self.renderer.draw_chrome_text(
|
||||
&size,
|
||||
rect.0 + (rect.2 - text_width(label)) * 0.5,
|
||||
rect.1 + (rect.3 - cell_h) / 2.0,
|
||||
if editor.auth == auth_mode_for_label(label) {
|
||||
skin.ink_strong
|
||||
} else {
|
||||
skin.ink_dim
|
||||
},
|
||||
if editor.auth == *mode { skin.ink_strong } else { skin.ink_dim },
|
||||
label,
|
||||
glyph_cache,
|
||||
);
|
||||
@@ -445,6 +450,7 @@ impl Display {
|
||||
save_toggle,
|
||||
save_checkbox,
|
||||
save_label,
|
||||
language,
|
||||
field_h,
|
||||
cell_h,
|
||||
cell_w,
|
||||
@@ -459,15 +465,17 @@ impl Display {
|
||||
destination.0,
|
||||
key_header_y,
|
||||
skin.ink_dim,
|
||||
"私钥",
|
||||
language.pick("私钥", "Private keys"),
|
||||
glyph_cache,
|
||||
);
|
||||
self.renderer.draw_chrome_text(
|
||||
&size,
|
||||
add_private_key.0 + (add_private_key.2 - text_width("+ 添加私钥")) * 0.5,
|
||||
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"),
|
||||
glyph_cache,
|
||||
);
|
||||
if editor.private_keys.is_empty() {
|
||||
@@ -476,7 +484,10 @@ impl Display {
|
||||
destination.0,
|
||||
key_rows_y + s(7.0),
|
||||
skin.ink_faint,
|
||||
"未指定;将使用 IdentityFile 和默认 id_* 私钥",
|
||||
language.pick(
|
||||
"未指定;将使用 IdentityFile 和默认 id_* 私钥",
|
||||
"None specified; IdentityFile and default id_* keys will be used",
|
||||
),
|
||||
glyph_cache,
|
||||
);
|
||||
}
|
||||
@@ -505,7 +516,10 @@ impl Display {
|
||||
destination.0,
|
||||
content_y,
|
||||
skin.ink_dim,
|
||||
"仅使用 Windows OpenSSH Agent 与 Pageant,不回退密码。",
|
||||
language.pick(
|
||||
"仅使用 Windows OpenSSH Agent 与 Pageant,不回退密码。",
|
||||
"Use Windows OpenSSH Agent and Pageant only; do not fall back to passwords.",
|
||||
),
|
||||
glyph_cache,
|
||||
);
|
||||
} else if editor.auth == SshAuthMode::KeyboardInteractive {
|
||||
@@ -514,7 +528,10 @@ impl Display {
|
||||
destination.0,
|
||||
content_y,
|
||||
skin.ink_dim,
|
||||
"仅响应服务器的 keyboard-interactive / MFA 提示。",
|
||||
language.pick(
|
||||
"仅响应服务器的 keyboard-interactive / MFA 提示。",
|
||||
"Respond only to server keyboard-interactive / MFA prompts.",
|
||||
),
|
||||
glyph_cache,
|
||||
);
|
||||
}
|
||||
@@ -547,16 +564,6 @@ impl Display {
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_mode_for_label(label: &str) -> SshAuthMode {
|
||||
match label {
|
||||
"密码" => SshAuthMode::Password,
|
||||
"密钥" => SshAuthMode::PublicKey,
|
||||
"Agent" => SshAuthMode::Agent,
|
||||
"交互式" => SshAuthMode::KeyboardInteractive,
|
||||
_ => SshAuthMode::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
fn input_quads(
|
||||
quads: &mut Vec<UiQuad>,
|
||||
rect: Rect,
|
||||
@@ -662,6 +669,7 @@ fn draw_password_text(
|
||||
save_toggle: Rect,
|
||||
save_checkbox: Rect,
|
||||
save_label: &str,
|
||||
language: super::UiLanguage,
|
||||
field_h: f32,
|
||||
cell_h: f32,
|
||||
cell_w: f32,
|
||||
@@ -675,11 +683,11 @@ fn draw_password_text(
|
||||
password.0,
|
||||
password.1 - cell_h - s(5.0),
|
||||
skin.ink_dim,
|
||||
"密码",
|
||||
language.pick("密码", "Password"),
|
||||
glyph_cache,
|
||||
);
|
||||
let masked = if editor.password.is_empty() {
|
||||
"连接时询问".to_owned()
|
||||
language.pick("连接时询问", "Ask when connecting").to_owned()
|
||||
} else if editor.show_password {
|
||||
editor.password.clone()
|
||||
} else {
|
||||
|
||||
@@ -998,6 +998,8 @@ pub enum TabRequest {
|
||||
/// Open this file (markdown/text/json) in a read-only viewer tab —
|
||||
/// double-clicking a viewable file in the directory tree lands here.
|
||||
OpenDoc(std::path::PathBuf),
|
||||
/// Open or focus the singleton Settings tab. It owns no PTY.
|
||||
OpenSettings,
|
||||
Close,
|
||||
CloseIndex(usize),
|
||||
/// Duplicate a tab using the launch identity captured when it was opened.
|
||||
|
||||
@@ -722,7 +722,7 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
|
||||
SplitDown => self
|
||||
.ctx
|
||||
.nebula_tab(TabRequest::SplitToggle(crate::display::SplitDirection::TopBottom)),
|
||||
OpenSettings => self.ctx.display().toggle_settings(),
|
||||
OpenSettings => self.ctx.nebula_tab(TabRequest::OpenSettings),
|
||||
OpenSettingsFile => self.ctx.display().open_user_config_file(),
|
||||
ToggleGhost => self.ctx.display().toggle_ghost(),
|
||||
CycleAccept => self.ctx.display().cycle_accept(),
|
||||
|
||||
+18
-20
@@ -648,6 +648,7 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
|
||||
let font_picker_open = self.ctx.display().nebula_font_picker_open;
|
||||
let font_picker_count = self.ctx.display().font_picker_count();
|
||||
let hidden_host_count = self.ctx.display().hidden_ssh_host_count();
|
||||
let settings_area = self.ctx.display().terminal_card_rect();
|
||||
// A native context menu is a pointer-modal overlay: while it is open,
|
||||
// underlying links, tabs and drawer rows must not react to hover.
|
||||
if self.ctx.display().context_menu_interactive() {
|
||||
@@ -673,6 +674,7 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
|
||||
let settings_hover = crate::display::settings_hit(
|
||||
&window_size,
|
||||
scale,
|
||||
settings_area,
|
||||
x as f32,
|
||||
y as f32,
|
||||
settings_open,
|
||||
@@ -698,13 +700,9 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
|
||||
// pane's viewport — otherwise a band of the terminal area is mistaken
|
||||
// for chrome and swallows link hovers (no underline / no click).
|
||||
let resize_enabled = self.ctx.window().allows_drag_resize();
|
||||
if let Some(dir) = crate::display::resize_edge(
|
||||
&window_size,
|
||||
scale,
|
||||
x as f32,
|
||||
y as f32,
|
||||
resize_enabled,
|
||||
) {
|
||||
if let Some(dir) =
|
||||
crate::display::resize_edge(&window_size, scale, x as f32, y as f32, resize_enabled)
|
||||
{
|
||||
use winit::window::ResizeDirection::*;
|
||||
let icon = match dir {
|
||||
East | West => CursorIcon::EwResize,
|
||||
@@ -719,6 +717,7 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
|
||||
crate::display::SettingsHit::Toggle
|
||||
| crate::display::SettingsHit::Nav(_)
|
||||
| crate::display::SettingsHit::Theme(_)
|
||||
| crate::display::SettingsHit::Language(_)
|
||||
| crate::display::SettingsHit::SystemThemeToggle
|
||||
| crate::display::SettingsHit::GhostToggle
|
||||
| crate::display::SettingsHit::AcceptCycle
|
||||
@@ -743,7 +742,7 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
|
||||
self.ctx.window().set_mouse_cursor(CursorIcon::Default);
|
||||
return;
|
||||
},
|
||||
crate::display::SettingsHit::Dismiss | crate::display::SettingsHit::None => {},
|
||||
crate::display::SettingsHit::None => {},
|
||||
}
|
||||
if crate::display::in_chrome_bar(&window_size, scale, x as f32, y as f32) {
|
||||
let icon = match self.ctx.display().chrome_hit(x as f32, y as f32) {
|
||||
@@ -1543,9 +1542,7 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
|
||||
let scale = self.ctx.window().scale_factor as f32;
|
||||
// Window border resize takes priority over the chrome controls.
|
||||
let resize_enabled = self.ctx.window().allows_drag_resize();
|
||||
if let Some(dir) =
|
||||
crate::display::resize_edge(&size, scale, x, y, resize_enabled)
|
||||
{
|
||||
if let Some(dir) = crate::display::resize_edge(&size, scale, x, y, resize_enabled) {
|
||||
self.ctx.window().drag_resize(dir);
|
||||
return;
|
||||
}
|
||||
@@ -1574,9 +1571,11 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
|
||||
let font_picker_open = self.ctx.display().nebula_font_picker_open;
|
||||
let font_picker_count = self.ctx.display().font_picker_count();
|
||||
let hidden_host_count = self.ctx.display().hidden_ssh_host_count();
|
||||
let settings_area = self.ctx.display().terminal_card_rect();
|
||||
let settings_hit = crate::display::settings_hit(
|
||||
&size,
|
||||
scale,
|
||||
settings_area,
|
||||
x,
|
||||
y,
|
||||
settings_open,
|
||||
@@ -1614,8 +1613,7 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
|
||||
}
|
||||
match settings_hit {
|
||||
crate::display::SettingsHit::Toggle => {
|
||||
self.ctx.display().toggle_settings();
|
||||
self.ctx.mark_dirty();
|
||||
self.ctx.nebula_tab(crate::event::TabRequest::OpenSettings);
|
||||
return;
|
||||
},
|
||||
crate::display::SettingsHit::Nav(section) => {
|
||||
@@ -1628,6 +1626,11 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
|
||||
self.ctx.mark_dirty();
|
||||
return;
|
||||
},
|
||||
crate::display::SettingsHit::Language(language) => {
|
||||
self.ctx.display().set_ui_language(language);
|
||||
self.ctx.mark_dirty();
|
||||
return;
|
||||
},
|
||||
crate::display::SettingsHit::GhostToggle => {
|
||||
self.ctx.display().toggle_ghost();
|
||||
self.ctx.mark_dirty();
|
||||
@@ -1716,11 +1719,6 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
|
||||
return;
|
||||
},
|
||||
crate::display::SettingsHit::Panel => return,
|
||||
crate::display::SettingsHit::Dismiss => {
|
||||
self.ctx.display().dismiss_settings();
|
||||
self.ctx.mark_dirty();
|
||||
return;
|
||||
},
|
||||
crate::display::SettingsHit::None => {},
|
||||
}
|
||||
let chrome_hit = self.ctx.display().chrome_hit(x, y);
|
||||
@@ -2086,8 +2084,8 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
|
||||
}
|
||||
|
||||
pub fn mouse_wheel_input(&mut self, delta: MouseScrollDelta, phase: TouchPhase) {
|
||||
// The settings modal captures the wheel: scroll its content, not the
|
||||
// terminal hiding behind the veil.
|
||||
// The Settings tab captures the wheel: scroll its page instead of the
|
||||
// sink terminal used for special-tab input routing.
|
||||
if self.ctx.display().settings_open() {
|
||||
let px = match delta {
|
||||
MouseScrollDelta::LineDelta(_, lines) => {
|
||||
|
||||
+249
-152
@@ -48,9 +48,9 @@ use crate::message_bar::MessageBuffer;
|
||||
use crate::scheduler::{Scheduler, TimerId, Topic};
|
||||
use crate::{input, renderer, session};
|
||||
|
||||
mod nebula_fetch_art;
|
||||
/// New-tab welcome page (Windows logo + fastfetch intro). Stateless helpers.
|
||||
mod welcome;
|
||||
mod nebula_fetch_art;
|
||||
use welcome::nebula_fastfetch_intro_command_for;
|
||||
|
||||
/// Split-pane behaviour (toggle/resize/drag/focus); `impl WindowContext`.
|
||||
@@ -134,6 +134,9 @@ struct TabEntry {
|
||||
/// Document viewer content (markdown/text/json). `Some` marks this as a
|
||||
/// doc tab: it has no pane, and the draw/input paths route to the viewer.
|
||||
doc: Option<crate::display::markdown_view::DocView>,
|
||||
/// Settings is a singleton special tab. Like a document tab it owns no
|
||||
/// PTY, while its controls and state remain owned by `Display`.
|
||||
settings: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -143,6 +146,7 @@ enum TabLaunch {
|
||||
Shell { name: String, shell: tty::Shell },
|
||||
Ssh(String),
|
||||
Document(std::path::PathBuf),
|
||||
Settings,
|
||||
}
|
||||
|
||||
fn select_initial_shell(
|
||||
@@ -439,6 +443,7 @@ impl WindowContext {
|
||||
.and_then(|tab| tab.color),
|
||||
launch: TabLaunch::Default,
|
||||
doc: None,
|
||||
settings: false,
|
||||
}],
|
||||
0,
|
||||
1,
|
||||
@@ -682,7 +687,18 @@ impl WindowContext {
|
||||
self.open_doc_tab(path);
|
||||
false
|
||||
},
|
||||
TabRequest::OpenSettings => {
|
||||
self.open_settings_tab();
|
||||
false
|
||||
},
|
||||
TabRequest::Close => {
|
||||
if self
|
||||
.tabs
|
||||
.get(self.active_tab)
|
||||
.is_some_and(|tab| tab.doc.is_some() || tab.settings)
|
||||
{
|
||||
return self.close_tab(self.active_tab);
|
||||
}
|
||||
let id = self.focused_pane_id();
|
||||
// A pending confirm for this pane means the user re-triggered
|
||||
// the close (or pressed Enter, which re-dispatches this
|
||||
@@ -785,7 +801,7 @@ impl WindowContext {
|
||||
false
|
||||
},
|
||||
TabRequest::SplitIndex { index, direction } => {
|
||||
if self.tabs.get(index).is_some_and(|tab| tab.doc.is_none()) {
|
||||
if self.tabs.get(index).is_some_and(|tab| tab.doc.is_none() && !tab.settings) {
|
||||
self.select_tab(index);
|
||||
self.split_focused(direction);
|
||||
}
|
||||
@@ -884,15 +900,19 @@ impl WindowContext {
|
||||
// Insert right after the current tab (insert right next to the current tab)
|
||||
// rather than at the end of the bar.
|
||||
let at = (self.active_tab + 1).min(self.tabs.len());
|
||||
self.tabs.insert(at, TabEntry {
|
||||
layout: Layout::Leaf(id),
|
||||
active_pane: id,
|
||||
has_bell: false,
|
||||
custom_name: None,
|
||||
custom_color: None,
|
||||
launch: TabLaunch::Default,
|
||||
doc: None,
|
||||
});
|
||||
self.tabs.insert(
|
||||
at,
|
||||
TabEntry {
|
||||
layout: Layout::Leaf(id),
|
||||
active_pane: id,
|
||||
has_bell: false,
|
||||
custom_name: None,
|
||||
custom_color: None,
|
||||
launch: TabLaunch::Default,
|
||||
doc: None,
|
||||
settings: false,
|
||||
},
|
||||
);
|
||||
self.active_tab = at;
|
||||
self.resize_active_layout();
|
||||
self.dirty = true;
|
||||
@@ -918,24 +938,24 @@ impl WindowContext {
|
||||
|
||||
fn spawn_tab_profile_value(&mut self, profile: Profile) {
|
||||
// Profile cwd wins when it exists; else inherit the focused pane's.
|
||||
let cwd = profile
|
||||
.cwd
|
||||
.as_ref()
|
||||
.filter(|p| p.is_dir())
|
||||
.cloned()
|
||||
.or_else(|| self.focused_cwd());
|
||||
let cwd =
|
||||
profile.cwd.as_ref().filter(|p| p.is_dir()).cloned().or_else(|| self.focused_cwd());
|
||||
let shell = profile.shell();
|
||||
if let Some(id) = self.spawn_pane_detached_with(cwd, self.display.size_info, Some(shell)) {
|
||||
let at = (self.active_tab + 1).min(self.tabs.len());
|
||||
self.tabs.insert(at, TabEntry {
|
||||
layout: Layout::Leaf(id),
|
||||
active_pane: id,
|
||||
has_bell: false,
|
||||
custom_name: Some(profile.name.clone()),
|
||||
custom_color: None,
|
||||
launch: TabLaunch::Profile(profile),
|
||||
doc: None,
|
||||
});
|
||||
self.tabs.insert(
|
||||
at,
|
||||
TabEntry {
|
||||
layout: Layout::Leaf(id),
|
||||
active_pane: id,
|
||||
has_bell: false,
|
||||
custom_name: Some(profile.name.clone()),
|
||||
custom_color: None,
|
||||
launch: TabLaunch::Profile(profile),
|
||||
doc: None,
|
||||
settings: false,
|
||||
},
|
||||
);
|
||||
self.active_tab = at;
|
||||
self.resize_active_layout();
|
||||
self.dirty = true;
|
||||
@@ -946,23 +966,25 @@ impl WindowContext {
|
||||
/// `spawn_tab_profile` but the spec is passed in rather than looked up in
|
||||
/// the config, and the cwd inherits the focused pane's.
|
||||
fn spawn_tab_shell(&mut self, name: String, shell: nebula_terminal::tty::Shell) {
|
||||
if let Some(id) =
|
||||
self.spawn_pane_detached_with(
|
||||
self.focused_cwd(),
|
||||
self.display.size_info,
|
||||
Some(shell.clone()),
|
||||
)
|
||||
{
|
||||
if let Some(id) = self.spawn_pane_detached_with(
|
||||
self.focused_cwd(),
|
||||
self.display.size_info,
|
||||
Some(shell.clone()),
|
||||
) {
|
||||
let at = (self.active_tab + 1).min(self.tabs.len());
|
||||
self.tabs.insert(at, TabEntry {
|
||||
layout: Layout::Leaf(id),
|
||||
active_pane: id,
|
||||
has_bell: false,
|
||||
custom_name: Some(name.clone()),
|
||||
custom_color: None,
|
||||
launch: TabLaunch::Shell { name, shell },
|
||||
doc: None,
|
||||
});
|
||||
self.tabs.insert(
|
||||
at,
|
||||
TabEntry {
|
||||
layout: Layout::Leaf(id),
|
||||
active_pane: id,
|
||||
has_bell: false,
|
||||
custom_name: Some(name.clone()),
|
||||
custom_color: None,
|
||||
launch: TabLaunch::Shell { name, shell },
|
||||
doc: None,
|
||||
settings: false,
|
||||
},
|
||||
);
|
||||
self.active_tab = at;
|
||||
self.resize_active_layout();
|
||||
self.dirty = true;
|
||||
@@ -988,15 +1010,19 @@ impl WindowContext {
|
||||
self.next_pane_id += 1;
|
||||
self.panes.push(pane);
|
||||
let at = (self.active_tab + 1).min(self.tabs.len());
|
||||
self.tabs.insert(at, TabEntry {
|
||||
layout: Layout::Leaf(pane_id),
|
||||
active_pane: pane_id,
|
||||
has_bell: false,
|
||||
custom_name: Some(host.clone()),
|
||||
custom_color: None,
|
||||
launch: TabLaunch::Ssh(host),
|
||||
doc: None,
|
||||
});
|
||||
self.tabs.insert(
|
||||
at,
|
||||
TabEntry {
|
||||
layout: Layout::Leaf(pane_id),
|
||||
active_pane: pane_id,
|
||||
has_bell: false,
|
||||
custom_name: Some(host.clone()),
|
||||
custom_color: None,
|
||||
launch: TabLaunch::Ssh(host),
|
||||
doc: None,
|
||||
settings: false,
|
||||
},
|
||||
);
|
||||
self.active_tab = at;
|
||||
self.resize_active_layout();
|
||||
self.dirty = true;
|
||||
@@ -1019,57 +1045,59 @@ impl WindowContext {
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let Ok(exe) = std::env::current_exe() else {
|
||||
error!("Cannot locate nebula.exe for the SSH AskPass helper");
|
||||
return;
|
||||
};
|
||||
let shell_id = self.display.nebula_shell_id.clone().unwrap_or_else(|| {
|
||||
match self.display.nebula_shell {
|
||||
crate::display::NebulaShell::PowerShell => "powershell".into(),
|
||||
crate::display::NebulaShell::Bash => "bash".into(),
|
||||
}
|
||||
});
|
||||
let launch = match crate::ssh::build_pane_launch(&shell_id, &exe, &host) {
|
||||
Ok(launch) => launch,
|
||||
Err(err) => {
|
||||
error!("Refusing unsafe SSH destination {host:?}: {err}");
|
||||
let Ok(exe) = std::env::current_exe() else {
|
||||
error!("Cannot locate nebula.exe for the SSH AskPass helper");
|
||||
return;
|
||||
},
|
||||
};
|
||||
let default_shell = Self::default_shell_override();
|
||||
if let Some(id) = self.spawn_pane_detached_with(
|
||||
self.focused_cwd(),
|
||||
self.display.size_info,
|
||||
default_shell,
|
||||
) {
|
||||
let at = (self.active_tab + 1).min(self.tabs.len());
|
||||
self.tabs.insert(at, TabEntry {
|
||||
layout: Layout::Leaf(id),
|
||||
active_pane: id,
|
||||
has_bell: false,
|
||||
custom_name: Some(host.clone()),
|
||||
custom_color: None,
|
||||
launch: TabLaunch::Ssh(host),
|
||||
doc: None,
|
||||
};
|
||||
let shell_id = self.display.nebula_shell_id.clone().unwrap_or_else(|| {
|
||||
match self.display.nebula_shell {
|
||||
crate::display::NebulaShell::PowerShell => "powershell".into(),
|
||||
crate::display::NebulaShell::Bash => "bash".into(),
|
||||
}
|
||||
});
|
||||
self.active_tab = at;
|
||||
self.resize_active_layout();
|
||||
self.dirty = true;
|
||||
if let Some(pane) = self.panes.iter().find(|pane| pane.id == id) {
|
||||
pane.notifier.notify(launch.command);
|
||||
let launch = match crate::ssh::build_pane_launch(&shell_id, &exe, &host) {
|
||||
Ok(launch) => launch,
|
||||
Err(err) => {
|
||||
error!("Refusing unsafe SSH destination {host:?}: {err}");
|
||||
return;
|
||||
},
|
||||
};
|
||||
let default_shell = Self::default_shell_override();
|
||||
if let Some(id) = self.spawn_pane_detached_with(
|
||||
self.focused_cwd(),
|
||||
self.display.size_info,
|
||||
default_shell,
|
||||
) {
|
||||
let at = (self.active_tab + 1).min(self.tabs.len());
|
||||
self.tabs.insert(
|
||||
at,
|
||||
TabEntry {
|
||||
layout: Layout::Leaf(id),
|
||||
active_pane: id,
|
||||
has_bell: false,
|
||||
custom_name: Some(host.clone()),
|
||||
custom_color: None,
|
||||
launch: TabLaunch::Ssh(host),
|
||||
doc: None,
|
||||
settings: false,
|
||||
},
|
||||
);
|
||||
self.active_tab = at;
|
||||
self.resize_active_layout();
|
||||
self.dirty = true;
|
||||
if let Some(pane) = self.panes.iter().find(|pane| pane.id == id) {
|
||||
pane.notifier.notify(launch.command);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open `path` in a read-only document viewer tab. A tab already viewing
|
||||
/// this file is re-focused (and re-read, so the view is fresh) instead of
|
||||
/// duplicated — double-click twice shouldn't litter the bar.
|
||||
fn open_doc_tab(&mut self, path: std::path::PathBuf) {
|
||||
if let Some(index) = self
|
||||
.tabs
|
||||
.iter()
|
||||
.position(|tab| tab.doc.as_ref().is_some_and(|doc| doc.path == path))
|
||||
if let Some(index) =
|
||||
self.tabs.iter().position(|tab| tab.doc.as_ref().is_some_and(|doc| doc.path == path))
|
||||
{
|
||||
if let Some(doc) = self.tabs[index].doc.as_mut() {
|
||||
doc.reload();
|
||||
@@ -1084,16 +1112,51 @@ impl WindowContext {
|
||||
// as the file tree's markdown icon, so tab and tree read as one system.
|
||||
let label = format!("\u{eb1d} {}", doc.title);
|
||||
let at = (self.active_tab + 1).min(self.tabs.len());
|
||||
self.tabs.insert(at, TabEntry {
|
||||
layout: Layout::Leaf(DOC_PANE_ID),
|
||||
active_pane: DOC_PANE_ID,
|
||||
has_bell: false,
|
||||
custom_name: Some(label),
|
||||
custom_color: None,
|
||||
launch: TabLaunch::Document(doc.path.clone()),
|
||||
doc: Some(doc),
|
||||
});
|
||||
self.tabs.insert(
|
||||
at,
|
||||
TabEntry {
|
||||
layout: Layout::Leaf(DOC_PANE_ID),
|
||||
active_pane: DOC_PANE_ID,
|
||||
has_bell: false,
|
||||
custom_name: Some(label),
|
||||
custom_color: None,
|
||||
launch: TabLaunch::Document(doc.path.clone()),
|
||||
doc: Some(doc),
|
||||
settings: false,
|
||||
},
|
||||
);
|
||||
self.active_tab = at;
|
||||
self.display.set_settings_tab_active(false);
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Open Settings as a real singleton tab. Re-focusing the existing tab
|
||||
/// avoids duplicate preference surfaces and never starts a shell process.
|
||||
fn open_settings_tab(&mut self) {
|
||||
if let Some(index) = self.tabs.iter().position(|tab| tab.settings) {
|
||||
self.select_tab(index);
|
||||
self.display.set_settings_tab_active(true);
|
||||
self.dirty = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let at = (self.active_tab + 1).min(self.tabs.len());
|
||||
self.tabs.insert(
|
||||
at,
|
||||
TabEntry {
|
||||
layout: Layout::Leaf(DOC_PANE_ID),
|
||||
active_pane: DOC_PANE_ID,
|
||||
has_bell: false,
|
||||
custom_name: Some("\u{eb51} 设置".to_owned()),
|
||||
custom_color: None,
|
||||
launch: TabLaunch::Settings,
|
||||
doc: None,
|
||||
settings: true,
|
||||
},
|
||||
);
|
||||
self.active_tab = at;
|
||||
self.display.set_settings_tab_active(true);
|
||||
self.sync_chrome_tabs();
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
@@ -1118,18 +1181,23 @@ impl WindowContext {
|
||||
let doc = crate::display::markdown_view::DocView::open(path.clone());
|
||||
let label = format!("\u{eb1d} {}", doc.title);
|
||||
let at = (self.active_tab + 1).min(self.tabs.len());
|
||||
self.tabs.insert(at, TabEntry {
|
||||
layout: Layout::Leaf(DOC_PANE_ID),
|
||||
active_pane: DOC_PANE_ID,
|
||||
has_bell: false,
|
||||
custom_name: Some(label),
|
||||
custom_color: None,
|
||||
launch: TabLaunch::Document(path),
|
||||
doc: Some(doc),
|
||||
});
|
||||
self.tabs.insert(
|
||||
at,
|
||||
TabEntry {
|
||||
layout: Layout::Leaf(DOC_PANE_ID),
|
||||
active_pane: DOC_PANE_ID,
|
||||
has_bell: false,
|
||||
custom_name: Some(label),
|
||||
custom_color: None,
|
||||
launch: TabLaunch::Document(path),
|
||||
doc: Some(doc),
|
||||
settings: false,
|
||||
},
|
||||
);
|
||||
self.active_tab = at;
|
||||
self.dirty = true;
|
||||
},
|
||||
TabLaunch::Settings => self.open_settings_tab(),
|
||||
}
|
||||
|
||||
if self.tabs.len() > before {
|
||||
@@ -1158,6 +1226,7 @@ impl WindowContext {
|
||||
custom_color: tab.color,
|
||||
launch: TabLaunch::Default,
|
||||
doc: None,
|
||||
settings: false,
|
||||
});
|
||||
self.run_fastfetch_intro(id);
|
||||
}
|
||||
@@ -1229,9 +1298,16 @@ impl WindowContext {
|
||||
|
||||
/// Current tab list + per-tab cwd as a persistable session.
|
||||
fn session_snapshot(&self) -> session::Session {
|
||||
let tabs = self
|
||||
let active_tab = self
|
||||
.tabs
|
||||
.iter()
|
||||
.take(self.active_tab)
|
||||
.filter(|tab| tab.doc.is_none() && !tab.settings)
|
||||
.count();
|
||||
let tabs: Vec<_> = self
|
||||
.tabs
|
||||
.iter()
|
||||
.filter(|tab| tab.doc.is_none() && !tab.settings)
|
||||
.map(|t| session::TabSession {
|
||||
cwd: self
|
||||
.pane(t.active_pane)
|
||||
@@ -1241,7 +1317,7 @@ impl WindowContext {
|
||||
color: t.custom_color,
|
||||
})
|
||||
.collect();
|
||||
session::Session::new(self.active_tab, tabs)
|
||||
session::Session::new(active_tab.min(tabs.len().saturating_sub(1)), tabs)
|
||||
}
|
||||
|
||||
/// 1 Hz autosave (piggybacks on the chrome clock tick): persist the session
|
||||
@@ -1280,7 +1356,14 @@ impl WindowContext {
|
||||
fn dock_tab_into_active(&mut self, source: usize, nav: crate::display::SplitNav) {
|
||||
use crate::display::{SplitDirection, SplitNav};
|
||||
|
||||
if source >= self.tabs.len() || source == self.active_tab || self.tabs.len() < 2 {
|
||||
if source >= self.tabs.len()
|
||||
|| source == self.active_tab
|
||||
|| self.tabs.len() < 2
|
||||
|| self.tabs[source].doc.is_some()
|
||||
|| self.tabs[source].settings
|
||||
|| self.tabs[self.active_tab].doc.is_some()
|
||||
|| self.tabs[self.active_tab].settings
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1298,11 +1381,8 @@ impl WindowContext {
|
||||
SplitNav::Up => (SplitDirection::TopBottom, true),
|
||||
SplitNav::Down => (SplitDirection::TopBottom, false),
|
||||
};
|
||||
let (first, second) = if src_first {
|
||||
(src_entry.layout, old)
|
||||
} else {
|
||||
(old, src_entry.layout)
|
||||
};
|
||||
let (first, second) =
|
||||
if src_first { (src_entry.layout, old) } else { (old, src_entry.layout) };
|
||||
entry.layout = Layout::Split {
|
||||
direction,
|
||||
ratio: 0.5,
|
||||
@@ -1371,8 +1451,14 @@ impl WindowContext {
|
||||
if cwd.is_some() {
|
||||
pty_config.working_directory = cwd;
|
||||
}
|
||||
match Self::create_pane(&size_info, window_id, &self.config, pty_config, &self.proxy, pane_id)
|
||||
{
|
||||
match Self::create_pane(
|
||||
&size_info,
|
||||
window_id,
|
||||
&self.config,
|
||||
pty_config,
|
||||
&self.proxy,
|
||||
pane_id,
|
||||
) {
|
||||
Ok(pane) => {
|
||||
self.panes.push(pane);
|
||||
Some(pane_id)
|
||||
@@ -1438,9 +1524,8 @@ impl WindowContext {
|
||||
// `terminal.is_focused` starts true and may never see a focus
|
||||
// event, which would double-ring against the per-pane path.
|
||||
if self.display.window.has_focus() {
|
||||
let program = self
|
||||
.pane(pane_id)
|
||||
.and_then(|p| p.nebula_state.running_program.clone());
|
||||
let program =
|
||||
self.pane(pane_id).and_then(|p| p.nebula_state.running_program.clone());
|
||||
crate::notify::deliver(
|
||||
&self.display.window,
|
||||
&crate::notify::Notification::Bell { program },
|
||||
@@ -1479,8 +1564,7 @@ impl WindowContext {
|
||||
// spinner a start mark so the turn still animates.
|
||||
state.command_started.get_or_insert_with(std::time::Instant::now);
|
||||
},
|
||||
crate::ai_hook::AiHookKind::TurnDone
|
||||
| crate::ai_hook::AiHookKind::NeedsAttention => {
|
||||
crate::ai_hook::AiHookKind::TurnDone | crate::ai_hook::AiHookKind::NeedsAttention => {
|
||||
{
|
||||
let state = &mut self.panes[idx].nebula_state;
|
||||
state.awaiting_input = true;
|
||||
@@ -1556,6 +1640,7 @@ impl WindowContext {
|
||||
}
|
||||
self.active_tab = index;
|
||||
self.tabs[index].has_bell = false;
|
||||
self.display.set_settings_tab_active(self.tabs[index].settings);
|
||||
self.zoom = None;
|
||||
self.resize_active_layout();
|
||||
self.dirty = true;
|
||||
@@ -1615,6 +1700,9 @@ impl WindowContext {
|
||||
} else if self.active_tab >= self.tabs.len() {
|
||||
self.active_tab = self.tabs.len() - 1;
|
||||
}
|
||||
self.display.set_settings_tab_active(
|
||||
self.tabs.get(self.active_tab).is_some_and(|tab| tab.settings),
|
||||
);
|
||||
self.resize_active_layout();
|
||||
self.dirty = true;
|
||||
false
|
||||
@@ -1645,10 +1733,8 @@ impl WindowContext {
|
||||
self.display.font_size = self.config.font.size().scale(scale_factor);
|
||||
}
|
||||
|
||||
let font = self
|
||||
.display
|
||||
.effective_font(&self.config.font)
|
||||
.with_size(self.display.font_size);
|
||||
let font =
|
||||
self.display.effective_font(&self.config.font).with_size(self.display.font_size);
|
||||
self.display.pending_update.set_font(font);
|
||||
}
|
||||
|
||||
@@ -1796,6 +1882,14 @@ impl WindowContext {
|
||||
let divider_rects = self.layout_geometry(true).1;
|
||||
let focused = self.focused_pane_id();
|
||||
|
||||
// Settings is rendered inside the normal tab content card; it is not
|
||||
// a modal and therefore keeps the tab/sidebar chrome fully usable.
|
||||
if self.tabs.get(self.active_tab).is_some_and(|tab| tab.settings) {
|
||||
self.display.begin_pane_frame(&self.config);
|
||||
self.display.draw_settings_frame(scheduler);
|
||||
return;
|
||||
}
|
||||
|
||||
// Document-viewer tab: no pane, no grid. Draw the doc into the tab's
|
||||
// content rect; `present_frame` inside lays the normal chrome on top.
|
||||
if let Some(doc) = self.tabs.get_mut(self.active_tab).and_then(|tab| tab.doc.as_mut()) {
|
||||
@@ -1893,6 +1987,9 @@ impl WindowContext {
|
||||
}
|
||||
|
||||
fn sync_chrome_tabs(&mut self) {
|
||||
self.display.set_settings_tab_active(
|
||||
self.tabs.get(self.active_tab).is_some_and(|tab| tab.settings),
|
||||
);
|
||||
// The visible tab's activity is seen by definition — consume its
|
||||
// flag before it can render (dots are for background tabs only).
|
||||
if let Some(id) = self.tabs.get(self.active_tab).map(|t| t.active_pane) {
|
||||
@@ -1906,11 +2003,14 @@ impl WindowContext {
|
||||
let mut dots = Vec::with_capacity(self.tabs.len());
|
||||
let mut running = Vec::with_capacity(self.tabs.len());
|
||||
let mut logos = Vec::with_capacity(self.tabs.len());
|
||||
let ui_language = self.display.ui_language();
|
||||
for tab in &self.tabs {
|
||||
let pane = self.pane(tab.active_pane);
|
||||
let state = pane.map(|p| &p.nebula_state);
|
||||
// Use custom name if set, otherwise derive from cwd/title
|
||||
let mut label = if let Some(custom) = &tab.custom_name {
|
||||
let mut label = if tab.settings {
|
||||
format!("\u{eb51} {}", ui_language.pick("设置", "Settings"))
|
||||
} else if let Some(custom) = &tab.custom_name {
|
||||
custom.clone()
|
||||
} else {
|
||||
pane.map(Self::chrome_tab_label).unwrap_or_default()
|
||||
@@ -1934,14 +2034,15 @@ impl WindowContext {
|
||||
// for input" (claude between turns). The ring collapsing into a
|
||||
// dot IS the "turn finished, your move" signal — also on the
|
||||
// visible tab, where a merely-paused ring still read as busy.
|
||||
dots.push(tab.has_bell || state.is_some_and(|s| {
|
||||
s.finished_unseen || (s.command_started.is_some() && s.awaiting_input)
|
||||
}));
|
||||
dots.push(
|
||||
tab.has_bell
|
||||
|| state.is_some_and(|s| {
|
||||
s.finished_unseen || (s.command_started.is_some() && s.awaiting_input)
|
||||
}),
|
||||
);
|
||||
// Spinner only while the command actually works; once it rang BEL
|
||||
// and waits for input the dot above takes over.
|
||||
running.push(
|
||||
state.is_some_and(|s| s.command_started.is_some() && !s.awaiting_input),
|
||||
);
|
||||
running.push(state.is_some_and(|s| s.command_started.is_some() && !s.awaiting_input));
|
||||
}
|
||||
let active = self.active_tab.min(labels.len().saturating_sub(1));
|
||||
// displayed == storage index always holds now, so the bar is reorderable.
|
||||
@@ -1969,9 +2070,7 @@ impl WindowContext {
|
||||
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
.and_then(|path| {
|
||||
path.file_name().map(|n| n.to_string_lossy().into_owned())
|
||||
})
|
||||
.and_then(|path| path.file_name().map(|n| n.to_string_lossy().into_owned()))
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or_else(|| ".".to_owned())
|
||||
}
|
||||
@@ -2051,7 +2150,8 @@ impl WindowContext {
|
||||
// didn't move this batch (then it is already the current position).
|
||||
let latest_pos = self.event_queue.iter().rev().find_map(|e| match e {
|
||||
WinitEvent::WindowEvent {
|
||||
event: WindowEvent::CursorMoved { position, .. }, ..
|
||||
event: WindowEvent::CursorMoved { position, .. },
|
||||
..
|
||||
} => Some((position.x as f32, position.y as f32)),
|
||||
_ => None,
|
||||
});
|
||||
@@ -2097,10 +2197,11 @@ impl WindowContext {
|
||||
// A doc tab has no pane: its events run against `doc_pane` below so
|
||||
// chrome interaction (tab switching, closing, the sidebar) keeps
|
||||
// working; anything typed lands in the sink notifier.
|
||||
let doc_tab = self.tabs.get(self.active_tab).is_some_and(|tab| tab.doc.is_some());
|
||||
let special_tab =
|
||||
self.tabs.get(self.active_tab).is_some_and(|tab| tab.doc.is_some() || tab.settings);
|
||||
let focused = match self.pane_index(focused_id) {
|
||||
Some(index) => Some(index),
|
||||
None if doc_tab => None,
|
||||
None if special_tab => None,
|
||||
None => return,
|
||||
};
|
||||
|
||||
@@ -2114,8 +2215,8 @@ impl WindowContext {
|
||||
};
|
||||
self.display.nebula_pane_view = pane_view;
|
||||
|
||||
let old_is_searching = focused
|
||||
.is_some_and(|index| self.panes[index].search_state.history_index.is_some());
|
||||
let old_is_searching =
|
||||
focused.is_some_and(|index| self.panes[index].search_state.history_index.is_some());
|
||||
|
||||
let target_of = |event: &WinitEvent<Event>| match event {
|
||||
WinitEvent::UserEvent(event) => event.terminal_tab_id().unwrap_or(focused_id),
|
||||
@@ -2128,7 +2229,7 @@ impl WindowContext {
|
||||
let target_id = target_of(&event);
|
||||
let (pane, doc) = match self.pane_index(target_id) {
|
||||
Some(pane_idx) => (&mut self.panes[pane_idx], None),
|
||||
None if target_id == DOC_PANE_ID && doc_tab => (
|
||||
None if target_id == DOC_PANE_ID && special_tab => (
|
||||
&mut self.doc_pane,
|
||||
self.tabs.get_mut(self.active_tab).and_then(|tab| tab.doc.as_mut()),
|
||||
),
|
||||
@@ -2214,8 +2315,7 @@ impl WindowContext {
|
||||
.last_pty_resize
|
||||
.is_some_and(|t| now.duration_since(t) < Duration::from_millis(300));
|
||||
if dragging {
|
||||
let timer =
|
||||
TimerId::new(Topic::NebulaResizeSettle, self.display.window.id());
|
||||
let timer = TimerId::new(Topic::NebulaResizeSettle, self.display.window.id());
|
||||
scheduler.unschedule(timer);
|
||||
let event =
|
||||
Event::new(EventType::NebulaResizeSettled, self.display.window.id());
|
||||
@@ -2374,11 +2474,8 @@ mod startup_shell_tests {
|
||||
|
||||
#[test]
|
||||
fn startup_shell_uses_user_default_instead_of_the_base_pty_shell() {
|
||||
let selected = select_initial_shell(
|
||||
Some(shell("powershell.exe")),
|
||||
Some(shell("pwsh.exe")),
|
||||
None,
|
||||
);
|
||||
let selected =
|
||||
select_initial_shell(Some(shell("powershell.exe")), Some(shell("pwsh.exe")), None);
|
||||
assert_eq!(selected, Some(shell("pwsh.exe")));
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user