diff --git a/nebula_gpui/src/config/mod.rs b/nebula_gpui/src/config/mod.rs new file mode 100644 index 0000000..410bfb4 --- /dev/null +++ b/nebula_gpui/src/config/mod.rs @@ -0,0 +1,366 @@ +//! 读取用户的 `nebula.toml`,与 `nebula_app` 共享同一份配置文件。 +//! +//! 只解析 GPUI 壳当前消费的子集(字体、终端配色),解析全程宽容:未知字段 +//! 忽略、非法值回退默认,保证任何用户配置都不会阻止启动。查找路径与主应用 +//! `config::installed_config` 完全一致;`general.import`(及顶层 `import`) +//! 按主应用语义做值级合并:imports 先加载,主文件最后覆盖。 +//! +//! 尚未对齐的字段(记录在案,后续步骤处理): +//! - `font.offset` / `font.glyph_offset`(cell 度量微调) +//! - `colors.cursor` 的 `CellForeground`/`CellBackground` 反色语义 +//! - 配置热重载(主应用用 notify 监视;本壳目前启动读一次) + +use std::path::{Path, PathBuf}; + +use gpui::Global; +use serde::Deserialize; + +use crate::terminal::colors::Palette; + +/// 应用启动时装载一次的全局设置。 +pub struct Settings { + pub font_family: String, + pub font_bold_family: String, + pub font_italic_family: String, + pub font_bold_italic_family: String, + /// GPUI 逻辑像素(配置里是 pt,1pt = 4/3 px @96dpi)。 + pub font_size_px: f32, + pub palette: Palette, + /// 实际加载的配置文件(诊断用;None 表示全默认)。 + pub source_path: Option, +} + +impl Global for Settings {} + +impl Settings { + pub fn load() -> Self { + let path = find_config_file(); + let raw = path + .as_deref() + .map(load_merged_toml) + .unwrap_or_else(|| toml::Value::Table(Default::default())); + let raw: RawConfig = raw.try_into().unwrap_or_default(); + + let normal_family = raw + .font + .normal + .family + .clone() + .unwrap_or_else(|| default_font_family().to_string()); + let secondary = |desc: &RawFontDesc| -> String { + desc.family.clone().unwrap_or_else(|| normal_family.clone()) + }; + + Settings { + font_bold_family: secondary(&raw.font.bold), + font_italic_family: secondary(&raw.font.italic), + font_bold_italic_family: secondary(&raw.font.bold_italic), + font_size_px: raw.font.size.unwrap_or(11.25).clamp(4.0, 96.0) * 4.0 / 3.0, + palette: build_palette(&raw.colors), + font_family: normal_family, + source_path: path, + } + } +} + +fn default_font_family() -> &'static str { + #[cfg(windows)] + { + "Maple Mono Normal NF CN" + } + #[cfg(target_os = "macos")] + { + "Menlo" + } + #[cfg(not(any(windows, target_os = "macos")))] + { + "monospace" + } +} + +/// 查找配置文件;顺序与 `nebula_app::config::installed_config` 一致。 +/// `NEBULA_GPUI_CONFIG` 用于隔离测试:绝不能往用户真实配置目录写测试文件, +/// 正式版 Nebula 正在监视它做热重载。 +fn find_config_file() -> Option { + if let Ok(explicit) = std::env::var("NEBULA_GPUI_CONFIG") { + let path = PathBuf::from(explicit); + return path.exists().then_some(path); + } + + #[cfg(windows)] + { + dirs::config_dir().map(|p| p.join("nebula").join("nebula.toml")).filter(|p| p.exists()) + } + + #[cfg(not(windows))] + { + let file_name = "nebula.toml"; + if let Ok(xdg_home) = std::env::var("XDG_CONFIG_HOME") { + let base = PathBuf::from(xdg_home); + for candidate in [base.join("nebula").join(file_name), base.join(file_name)] { + if candidate.exists() { + return Some(candidate); + } + } + } + if let Ok(home) = std::env::var("HOME") { + let home = PathBuf::from(home); + for candidate in [ + home.join(".config/nebula").join(file_name), + home.join(format!(".{file_name}")), + ] { + if candidate.exists() { + return Some(candidate); + } + } + } + let etc = PathBuf::from("/etc/nebula").join(file_name); + etc.exists().then_some(etc) + } +} + +/// 读取主文件并按主应用语义合并 imports:imports 先加载,主文件最后覆盖。 +fn load_merged_toml(path: &Path) -> toml::Value { + let main = read_toml(path); + + let imports: Vec = [main.get("general").and_then(|g| g.get("import")), main.get("import")] + .into_iter() + .flatten() + .filter_map(|v| v.as_array()) + .flatten() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + + let mut merged = toml::Value::Table(Default::default()); + for import in imports { + let import_path = resolve_import_path(&import, path); + if import_path.exists() { + merged = merge_values(merged, read_toml(&import_path)); + } else { + eprintln!("[nebula-gpui] config import not found: {}", import_path.display()); + } + } + merge_values(merged, main) +} + +fn read_toml(path: &Path) -> toml::Value { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(err) => { + eprintln!("[nebula-gpui] failed to read config {}: {err}", path.display()); + return toml::Value::Table(Default::default()); + }, + }; + // toml 0.9:`Value: FromStr` 解析的是单个 TOML 值;文档必须走 `Table`。 + match text.parse::() { + Ok(table) => toml::Value::Table(table), + Err(err) => { + eprintln!("[nebula-gpui] failed to parse config {}: {err}", path.display()); + toml::Value::Table(Default::default()) + }, + } +} + +/// `~/` 展开为 home;相对路径相对于主配置文件所在目录。 +fn resolve_import_path(import: &str, base_config: &Path) -> PathBuf { + let mut path = PathBuf::from(import); + if let Ok(stripped) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + path = home.join(stripped); + } + } + if path.is_relative() { + if let Some(dir) = base_config.parent() { + path = dir.join(path); + } + } + path +} + +/// TOML 值级递归合并:表递归,其余类型 `other` 覆盖 `base`。 +fn merge_values(base: toml::Value, other: toml::Value) -> toml::Value { + match (base, other) { + (toml::Value::Table(mut base), toml::Value::Table(other)) => { + for (key, value) in other { + let merged = match base.remove(&key) { + Some(existing) => merge_values(existing, value), + None => value, + }; + base.insert(key, merged); + } + toml::Value::Table(base) + }, + (_, other) => other, + } +} + +// --------------------------------------------------------------------------- +// TOML 字段子集(与 nebula_app/src/config 的 TOML 形状兼容) +// --------------------------------------------------------------------------- + +#[derive(Deserialize, Default)] +#[serde(default)] +struct RawConfig { + font: RawFont, + colors: RawColors, +} + +#[derive(Deserialize, Default)] +#[serde(default)] +struct RawFont { + normal: RawFontDesc, + bold: RawFontDesc, + italic: RawFontDesc, + bold_italic: RawFontDesc, + /// pt,允许整数或浮点。 + size: Option, +} + +#[derive(Deserialize, Default)] +#[serde(default)] +struct RawFontDesc { + family: Option, +} + +#[derive(Deserialize, Default)] +#[serde(default)] +struct RawColors { + primary: RawPrimary, + cursor: RawCellColors, + selection: RawCellColors, + normal: RawAnsi8, + bright: RawAnsi8, + dim: Option, + indexed_colors: Vec, +} + +#[derive(Deserialize, Default)] +#[serde(default)] +struct RawPrimary { + foreground: Option, + background: Option, + bright_foreground: Option, + dim_foreground: Option, +} + +#[derive(Deserialize, Default)] +#[serde(default)] +struct RawCellColors { + // CellForeground/CellBackground 等特殊值 parse 失败自然落回默认。 + foreground: Option, + background: Option, +} + +#[derive(Deserialize, Default)] +#[serde(default)] +struct RawAnsi8 { + black: Option, + red: Option, + green: Option, + yellow: Option, + blue: Option, + magenta: Option, + cyan: Option, + white: Option, +} + +#[derive(Deserialize, Default)] +#[serde(default)] +struct RawIndexed { + index: Option, + color: Option, +} + +/// 解析 `#rrggbb` / `0xrrggbb`。 +fn parse_rgb(text: &str) -> Option { + let hex = text.strip_prefix('#').or_else(|| text.strip_prefix("0x"))?; + if hex.len() != 6 || !hex.is_ascii() { + return None; + } + let value = u32::from_str_radix(hex, 16).ok()?; + Some(gpui::Rgba { + r: ((value >> 16) & 0xff) as f32 / 255.0, + g: ((value >> 8) & 0xff) as f32 / 255.0, + b: (value & 0xff) as f32 / 255.0, + a: 1.0, + }) +} + +fn build_palette(raw: &RawColors) -> Palette { + let mut palette = Palette::default(); + let set = |slot: &mut gpui::Rgba, value: &Option| { + if let Some(rgba) = value.as_deref().and_then(parse_rgb) { + *slot = rgba; + } + }; + + set(&mut palette.foreground, &raw.primary.foreground); + set(&mut palette.background, &raw.primary.background); + // 主应用语义:bright/dim foreground 未配置时派生自 foreground。 + palette.bright_foreground = raw + .primary + .bright_foreground + .as_deref() + .and_then(parse_rgb) + .unwrap_or(palette.foreground); + palette.dim_foreground = raw + .primary + .dim_foreground + .as_deref() + .and_then(parse_rgb) + .unwrap_or_else(|| Palette::dim_of(palette.foreground)); + + set(&mut palette.cursor, &raw.cursor.background); + if let Some(selection) = raw.selection.background.as_deref().and_then(parse_rgb) { + // 用户显式选区色保持主应用的不透明语义。 + palette.selection = selection; + } + + let ansi8 = |group: &RawAnsi8| -> [Option; 8] { + [ + group.black.as_deref().and_then(parse_rgb), + group.red.as_deref().and_then(parse_rgb), + group.green.as_deref().and_then(parse_rgb), + group.yellow.as_deref().and_then(parse_rgb), + group.blue.as_deref().and_then(parse_rgb), + group.magenta.as_deref().and_then(parse_rgb), + group.cyan.as_deref().and_then(parse_rgb), + group.white.as_deref().and_then(parse_rgb), + ] + }; + + for (i, color) in ansi8(&raw.normal).into_iter().enumerate() { + if let Some(color) = color { + palette.ansi[i] = color; + } + } + for (i, color) in ansi8(&raw.bright).into_iter().enumerate() { + if let Some(color) = color { + palette.ansi[8 + i] = color; + } + } + if let Some(dim) = &raw.dim { + for (i, color) in ansi8(dim).into_iter().enumerate() { + if let Some(color) = color { + palette.dim[i] = color; + } + } + } else { + // 主应用语义:dim 未配置时由 normal 推导。 + for i in 0..8 { + palette.dim[i] = Palette::dim_of(palette.ansi[i]); + } + } + + for indexed in &raw.indexed_colors { + if let (Some(index), Some(color)) = + (indexed.index, indexed.color.as_deref().and_then(parse_rgb)) + { + if (16..=255).contains(&index) { + palette.indexed.push((index as u8, color)); + } + } + } + + palette +} diff --git a/nebula_gpui/src/main.rs b/nebula_gpui/src/main.rs index 6bb985d..5ab48f5 100644 --- a/nebula_gpui/src/main.rs +++ b/nebula_gpui/src/main.rs @@ -1,4 +1,5 @@ mod app; +mod config; mod terminal; mod ui; mod views; diff --git a/nebula_gpui/src/terminal/colors.rs b/nebula_gpui/src/terminal/colors.rs index 5ea892a..9b1997f 100644 --- a/nebula_gpui/src/terminal/colors.rs +++ b/nebula_gpui/src/terminal/colors.rs @@ -1,128 +1,175 @@ //! 终端调色板:把 vte 的 `Color` 解析成 GPUI 的 `Rgba`。 //! //! 解析顺序与 nebula_app 一致:OSC 4/10/11 的运行时覆盖(`Term::colors()`) -//! 优先,其次是这里的默认调色板。 +//! 优先,其次是 `Palette`(用户 nebula.toml 覆盖 deep-space 默认值,由 +//! `crate::config` 构造)。 use gpui::Rgba; use nebula_terminal::term::color::Colors; use nebula_terminal::vte::ansi::{Color, NamedColor, Rgb}; -/// 默认前景色(暗色主题下的正文灰)。 -pub const FOREGROUND: Rgba = rgb8(0xd6, 0xda, 0xe2); -/// 默认背景色,与 `ui::theme` 的窗口背景同族但独立,避免主题层反向依赖终端。 -pub const BACKGROUND: Rgba = rgb8(0x11, 0x14, 0x1a); -/// 光标颜色。 -pub const CURSOR: Rgba = rgb8(0x4f, 0xd6, 0x9c); -/// 选区背景色。 -pub const SELECTION: Rgba = Rgba { r: 0.24, g: 0.45, b: 0.38, a: 0.55 }; - const fn rgb8(r: u8, g: u8, b: u8) -> Rgba { Rgba { r: r as f32 / 255.0, g: g as f32 / 255.0, b: b as f32 / 255.0, a: 1.0 } } -/// ANSI 0-15 的默认值(VS Code 终端默认配色,可读性经过广泛验证)。 -const ANSI16: [Rgba; 16] = [ - rgb8(0x1e, 0x22, 0x28), // black - rgb8(0xcd, 0x31, 0x31), // red - rgb8(0x0d, 0xbc, 0x79), // green - rgb8(0xe5, 0xe5, 0x10), // yellow - rgb8(0x24, 0x72, 0xc8), // blue - rgb8(0xbc, 0x3f, 0xbc), // magenta - rgb8(0x11, 0xa8, 0xcd), // cyan - rgb8(0xe5, 0xe5, 0xe5), // white - rgb8(0x66, 0x66, 0x66), // bright black - rgb8(0xf1, 0x4c, 0x4c), // bright red - rgb8(0x23, 0xd1, 0x8b), // bright green - rgb8(0xf5, 0xf5, 0x43), // bright yellow - rgb8(0x3b, 0x8e, 0xea), // bright blue - rgb8(0xd6, 0x70, 0xd6), // bright magenta - rgb8(0x29, 0xb8, 0xdb), // bright cyan - rgb8(0xff, 0xff, 0xff), // bright white -]; +/// 一份终端实例的静态配色。默认值与 nebula_app 的 deep-space 主题逐字段一致 +/// (`nebula_app/src/config/color.rs`),保证两个壳在同一份用户配置下同色。 +#[derive(Clone)] +pub struct Palette { + pub foreground: Rgba, + pub background: Rgba, + pub bright_foreground: Rgba, + pub dim_foreground: Rgba, + /// 块状光标的填充色(主应用 NEBULA_DEFAULT_CURSOR 的 background)。 + pub cursor: Rgba, + /// 选区叠加色。主应用默认是反色语义;本壳以半透明叠加近似, + /// 用户显式配置 `colors.selection.background` 时用不透明具体色。 + pub selection: Rgba, + /// ANSI 0-15。 + pub ansi: [Rgba; 16], + /// Dim 0-7。 + pub dim: [Rgba; 8], + /// `colors.indexed_colors` 的 16-255 覆盖。 + pub indexed: Vec<(u8, Rgba)>, +} + +impl Default for Palette { + fn default() -> Self { + Palette { + background: rgb8(0x08, 0x0a, 0x18), + foreground: rgb8(0xd6, 0xda, 0xea), + bright_foreground: rgb8(0xd6, 0xda, 0xea), + dim_foreground: Self::dim_of(rgb8(0xd6, 0xda, 0xea)), + cursor: rgb8(0x49, 0x4d, 0x72), + selection: Rgba { a: 0.60, ..rgb8(0x49, 0x4d, 0x72) }, + ansi: [ + // normal + rgb8(0x1a, 0x1d, 0x2e), // black + rgb8(0xff, 0x6b, 0x81), // red + rgb8(0x65, 0xe8, 0x6e), // green + rgb8(0xf5, 0xc8, 0x4c), // yellow + rgb8(0x38, 0xa8, 0xff), // blue + rgb8(0xb4, 0x8c, 0xff), // magenta + rgb8(0x4f, 0xd6, 0xe0), // cyan + rgb8(0xd6, 0xda, 0xea), // white + // bright + rgb8(0x8d, 0x94, 0xaa), + rgb8(0xff, 0x8b, 0x9d), + rgb8(0x86, 0xf0, 0x90), + rgb8(0xff, 0xda, 0x7a), + rgb8(0x6c, 0xc0, 0xff), + rgb8(0xc9, 0xa8, 0xff), + rgb8(0x73, 0xe4, 0xec), + rgb8(0xf2, 0xf4, 0xfb), + ], + dim: [ + rgb8(0x0f, 0x0f, 0x0f), + rgb8(0x71, 0x2b, 0x2b), + rgb8(0x5f, 0x6f, 0x3a), + rgb8(0xa1, 0x7e, 0x4d), + rgb8(0x45, 0x68, 0x77), + rgb8(0x70, 0x4d, 0x68), + rgb8(0x4d, 0x77, 0x70), + rgb8(0x8e, 0x8e, 0x8e), + ], + indexed: Vec::new(), + } + } +} + +impl Palette { + /// 主应用的 dimming 系数。 + pub fn dim_of(color: Rgba) -> Rgba { + Rgba { r: color.r * 0.66, g: color.g * 0.66, b: color.b * 0.66, a: color.a } + } + + fn indexed_default(&self, index: u8) -> Rgba { + if index >= 16 { + if let Some((_, color)) = self.indexed.iter().find(|(i, _)| *i == index) { + return *color; + } + } + match index { + 0..=15 => self.ansi[index as usize], + 16..=231 => { + let idx = index as u16 - 16; + let comp = |c: u16| -> u8 { if c == 0 { 0 } else { (c * 40 + 55) as u8 } }; + rgb8(comp(idx / 36), comp(idx / 6 % 6), comp(idx % 6)) + }, + 232..=255 => { + let v = 8 + 10 * (index - 232); + rgb8(v, v, v) + }, + } + } + + fn named_default(&self, named: NamedColor) -> Rgba { + use NamedColor::*; + match named { + Foreground => self.foreground, + BrightForeground => self.bright_foreground, + DimForeground => self.dim_foreground, + Background => self.background, + Cursor => self.cursor, + DimBlack | DimRed | DimGreen | DimYellow | DimBlue | DimMagenta | DimCyan + | DimWhite => self.dim[named as usize - NamedColor::DimBlack as usize], + // 0-15 直接落在 ansi 表内。 + other => self.ansi[(other as usize).min(15)], + } + } + + /// 解析单元格颜色。`bold` 让 0-7 号命名色提亮为 8-15(经典终端行为)。 + pub fn resolve(&self, color: Color, overrides: &Colors, bold: bool) -> Rgba { + match color { + Color::Spec(rgb) => from_ansi_rgb(rgb), + Color::Indexed(index) => { + let index = if bold && index < 8 { index + 8 } else { index }; + overrides[index as usize] + .map(from_ansi_rgb) + .unwrap_or_else(|| self.indexed_default(index)) + }, + Color::Named(named) => { + let named = if bold && (named as usize) < 8 { + // 0-7 与 8-15 一一对应。 + match named { + NamedColor::Black => NamedColor::BrightBlack, + NamedColor::Red => NamedColor::BrightRed, + NamedColor::Green => NamedColor::BrightGreen, + NamedColor::Yellow => NamedColor::BrightYellow, + NamedColor::Blue => NamedColor::BrightBlue, + NamedColor::Magenta => NamedColor::BrightMagenta, + NamedColor::Cyan => NamedColor::BrightCyan, + NamedColor::White => NamedColor::BrightWhite, + other => other, + } + } else { + named + }; + overrides[named].map(from_ansi_rgb).unwrap_or_else(|| self.named_default(named)) + }, + } + } + + /// OSC 4/10/11 颜色查询(`Event::ColorRequest`)的回答值。 + pub fn query_reply(&self, index: usize, overrides: &Colors) -> Rgb { + if let Some(rgb) = overrides[index] { + return rgb; + } + let rgba = match index { + 0..=255 => self.indexed_default(index as u8), + _ => match index_to_named(index) { + Some(named) => self.named_default(named), + None => self.foreground, + }, + }; + Rgb { r: (rgba.r * 255.0) as u8, g: (rgba.g * 255.0) as u8, b: (rgba.b * 255.0) as u8 } + } +} pub fn from_ansi_rgb(rgb: Rgb) -> Rgba { rgb8(rgb.r, rgb.g, rgb.b) } -fn dim(color: Rgba) -> Rgba { - Rgba { r: color.r * 0.66, g: color.g * 0.66, b: color.b * 0.66, a: color.a } -} - -fn indexed_default(index: u8) -> Rgba { - match index { - 0..=15 => ANSI16[index as usize], - 16..=231 => { - let idx = index as u16 - 16; - let comp = |c: u16| -> u8 { if c == 0 { 0 } else { (c * 40 + 55) as u8 } }; - rgb8(comp(idx / 36), comp(idx / 6 % 6), comp(idx % 6)) - }, - 232..=255 => { - let v = 8 + 10 * (index - 232); - rgb8(v, v, v) - }, - } -} - -fn named_default(named: NamedColor) -> Rgba { - use NamedColor::*; - match named { - Foreground | BrightForeground => FOREGROUND, - Background => BACKGROUND, - Cursor => CURSOR, - DimForeground => dim(FOREGROUND), - DimBlack | DimRed | DimGreen | DimYellow | DimBlue | DimMagenta | DimCyan | DimWhite => { - dim(ANSI16[named as usize - NamedColor::DimBlack as usize]) - }, - // 0-15 直接落在 ANSI16 表内。 - other => ANSI16[(other as usize).min(15)], - } -} - -/// 解析单元格颜色。`bold` 让 0-7 号命名色提亮为 8-15(经典终端行为)。 -pub fn resolve(color: Color, overrides: &Colors, bold: bool) -> Rgba { - match color { - Color::Spec(rgb) => from_ansi_rgb(rgb), - Color::Indexed(index) => { - let index = if bold && index < 8 { index + 8 } else { index }; - overrides[index as usize].map(from_ansi_rgb).unwrap_or_else(|| indexed_default(index)) - }, - Color::Named(named) => { - let named = if bold && (named as usize) < 8 { - // SAFETY-free 提亮:0-7 与 8-15 一一对应。 - match named { - NamedColor::Black => NamedColor::BrightBlack, - NamedColor::Red => NamedColor::BrightRed, - NamedColor::Green => NamedColor::BrightGreen, - NamedColor::Yellow => NamedColor::BrightYellow, - NamedColor::Blue => NamedColor::BrightBlue, - NamedColor::Magenta => NamedColor::BrightMagenta, - NamedColor::Cyan => NamedColor::BrightCyan, - NamedColor::White => NamedColor::BrightWhite, - other => other, - } - } else { - named - }; - overrides[named].map(from_ansi_rgb).unwrap_or_else(|| named_default(named)) - }, - } -} - -/// OSC 4/10/11 颜色查询(`Event::ColorRequest`)的回答值。 -pub fn query_reply(index: usize, overrides: &Colors) -> Rgb { - if let Some(rgb) = overrides[index] { - return rgb; - } - let rgba = match index { - 0..=255 => indexed_default(index as u8), - _ => match index_to_named(index) { - Some(named) => named_default(named), - None => FOREGROUND, - }, - }; - Rgb { r: (rgba.r * 255.0) as u8, g: (rgba.g * 255.0) as u8, b: (rgba.b * 255.0) as u8 } -} - fn index_to_named(index: usize) -> Option { use NamedColor::*; // Colors 的 256..268 段与 NamedColor 的显式判别值对齐。 diff --git a/nebula_gpui/src/terminal/mod.rs b/nebula_gpui/src/terminal/mod.rs index d20a0fb..349e903 100644 --- a/nebula_gpui/src/terminal/mod.rs +++ b/nebula_gpui/src/terminal/mod.rs @@ -6,6 +6,5 @@ pub mod colors; pub mod element; pub mod keymap; -pub mod panel; pub mod session; pub mod view; diff --git a/nebula_gpui/src/terminal/panel.rs b/nebula_gpui/src/terminal/panel.rs deleted file mode 100644 index 01e6ec3..0000000 --- a/nebula_gpui/src/terminal/panel.rs +++ /dev/null @@ -1,115 +0,0 @@ -//! 终端的 Dock 面板合同:把 `TerminalView` 包装成 `gpui_component::dock::Panel`。 -//! -//! 面板只负责 Tab 语义(标题、关闭、焦点转发、生命周期),终端行为全部留在 -//! `TerminalView`;workspace 通过 `TerminalPanelEvent` 得知标题变化与会话结束。 - -use gpui::{ - App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, - ParentElement as _, Render, SharedString, Styled as _, Subscription, Window, div, -}; -use gpui_component::button::{Button, ButtonVariants as _}; -use gpui_component::dock::{Panel, PanelEvent}; -use gpui_component::{IconName, Sizable as _}; - -use super::view::{TerminalView, TerminalViewEvent}; - -/// 面板对 workspace 暴露的事件。 -pub enum TerminalPanelEvent { - /// Tab 标题需要刷新(宿主 TabPanel 不订阅面板重绘,需要 workspace 转发)。 - TitleChanged, - /// 会话已结束,宿主应关闭本 Tab。 - Exited, - /// 用户点击了面板的关闭按钮,宿主应关闭本 Tab。 - CloseRequested, -} - -pub struct TerminalPanel { - terminal: Entity, - _subscriptions: Vec, -} - -impl TerminalPanel { - pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let terminal = cx.new(TerminalView::new); - let subscription = cx.subscribe_in( - &terminal, - window, - |_, _, event: &TerminalViewEvent, _, cx| match event { - TerminalViewEvent::TitleChanged => { - cx.emit(TerminalPanelEvent::TitleChanged); - cx.notify(); - }, - TerminalViewEvent::Exited => cx.emit(TerminalPanelEvent::Exited), - }, - ); - Self { terminal, _subscriptions: vec![subscription] } - } - - fn tab_title(&self, cx: &App) -> SharedString { - // PowerShell 的 OSC 标题常是完整路径,按字符截断避免撑爆 Tab 栏。 - const MAX_CHARS: usize = 24; - let title = &self.terminal.read(cx).title; - let mut chars = title.chars(); - let head: String = chars.by_ref().take(MAX_CHARS).collect(); - if chars.next().is_some() { format!("{head}…").into() } else { head.into() } - } -} - -impl EventEmitter for TerminalPanel {} -impl EventEmitter for TerminalPanel {} - -impl Focusable for TerminalPanel { - fn focus_handle(&self, cx: &App) -> FocusHandle { - // 转发终端的焦点句柄,TabPanel 切换 Tab 时输入焦点直达终端。 - self.terminal.read(cx).focus_handle.clone() - } -} - -impl Panel for TerminalPanel { - fn panel_name(&self) -> &'static str { - "NebulaTerminalPanel" - } - - fn tab_name(&self, cx: &App) -> Option { - Some(self.tab_title(cx)) - } - - fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - self.tab_title(cx) - } - - fn title_suffix( - &mut self, - _window: &mut Window, - cx: &mut Context, - ) -> Option { - // 根级 TabPanel 被组件库判定为固定布局(closable 恒 false,不渲染 - // Tab 关闭按钮),所以由面板自己提供关闭入口,渲染在 Tab 栏右侧。 - Some( - Button::new("close-terminal") - .icon(IconName::Close) - .ghost() - .xsmall() - .tooltip("关闭当前终端 (Ctrl+Shift+W)") - .on_click( - cx.listener(|_, _, _, cx| cx.emit(TerminalPanelEvent::CloseRequested)), - ), - ) - } - - fn inner_padding(&self, _cx: &App) -> bool { - // 终端网格贴满面板,不要 Tab 内容区的默认内边距。 - false - } - - fn on_removed(&mut self, _window: &mut Window, cx: &mut Context) { - // Tab 被移除时立即回收会话;实体引用清零后 TerminalView::drop 会再兜底。 - self.terminal.read(cx).shutdown(); - } -} - -impl Render for TerminalPanel { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div().size_full().child(self.terminal.clone()) - } -} diff --git a/nebula_gpui/src/ui/bootstrap.rs b/nebula_gpui/src/ui/bootstrap.rs index c0c8930..83bcf89 100644 --- a/nebula_gpui/src/ui/bootstrap.rs +++ b/nebula_gpui/src/ui/bootstrap.rs @@ -5,4 +5,12 @@ pub fn init(cx: &mut App) { gpui_component::init(cx); super::theme::apply_nebula_theme(cx); crate::views::workspace::init(cx); + + // 用户 nebula.toml(字体/终端配色),启动读一次;失败静默回退默认值。 + let settings = crate::config::Settings::load(); + match &settings.source_path { + Some(path) => eprintln!("[nebula-gpui] config loaded: {}", path.display()), + None => eprintln!("[nebula-gpui] no user config found, using defaults"), + } + cx.set_global(settings); } diff --git a/nebula_gpui/src/views/workspace.rs b/nebula_gpui/src/views/workspace.rs index 1d908bb..c82e787 100644 --- a/nebula_gpui/src/views/workspace.rs +++ b/nebula_gpui/src/views/workspace.rs @@ -1,120 +1,225 @@ -//! Nebula 主工作区:自绘 TitleBar + 多 Tab 终端。 +//! Nebula 主工作区:左侧垂直 Tab 侧边栏 + 主终端卡片区。 //! -//! 布局约束:中心是单个根级 TabPanel(`stack_panel` 为空 → Tab 不可拖出分屏), -//! 因此 `TerminalPanel::on_removed` 只会由真实关闭触发。引入分屏布局时, -//! 会话清理需要改为由 workspace 的显式关闭路径驱动。 - -use std::sync::Arc; +//! 布局对齐 nebula_app 的产品形态(TABS 侧边栏、每项图标 + 标题 + 关闭、 +//! 激活高亮、主区圆角终端卡片),不再使用水平 TabPanel。终端实例由本视图 +//! 直接持有;会话清理走显式 `shutdown` + `TerminalView::drop` 兜底。 +use gpui::prelude::FluentBuilder as _; use gpui::{ - App, AppContext as _, Context, Entity, Focusable as _, FontWeight, InteractiveElement as _, - IntoElement, KeyBinding, ParentElement as _, Render, Styled as _, Subscription, Window, div, + App, AppContext as _, Context, Entity, FontWeight, InteractiveElement as _, IntoElement, + KeyBinding, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, + Styled as _, Subscription, Window, div, px, }; -use crate::terminal::panel::{TerminalPanel, TerminalPanelEvent}; +use crate::terminal::view::{TerminalView, TerminalViewEvent}; use crate::ui::prelude::*; -gpui::actions!(nebula_workspace, [NewTerminal, CloseActiveTerminal]); +gpui::actions!(nebula_workspace, [NewTerminal, CloseActiveTerminal, ToggleSidebar]); /// 注册工作区快捷键;在 `gpui_component::init` 之后调用一次。 pub fn init(cx: &mut App) { cx.bind_keys([ KeyBinding::new("ctrl-shift-t", NewTerminal, None), KeyBinding::new("ctrl-shift-w", CloseActiveTerminal, None), + KeyBinding::new("ctrl-shift-b", ToggleSidebar, None), ]); } +struct TerminalTab { + view: Entity, + _subscription: Subscription, +} + pub struct NebulaWorkspace { - dock_area: Entity, - tab_panel: Entity, - _subscriptions: Vec, + tabs: Vec, + active: usize, + sidebar_collapsed: bool, } impl NebulaWorkspace { pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let dock_area = cx.new(|cx| DockArea::new("nebula-workspace", Some(1), window, cx)); - let weak_dock = dock_area.downgrade(); - - let panel = cx.new(|cx| TerminalPanel::new(window, cx)); - let tabs = DockItem::tabs(vec![Arc::new(panel.clone())], &weak_dock, window, cx); - let tab_panel = match &tabs { - DockItem::Tabs { view, .. } => view.clone(), - _ => unreachable!("DockItem::tabs 恒定返回 Tabs 变体"), - }; - dock_area.update(cx, |dock, cx| dock.set_center(tabs, window, cx)); - - let subscriptions = vec![ - cx.subscribe_in(&panel, window, Self::on_terminal_event), - cx.subscribe_in(&tab_panel, window, Self::on_tab_layout_event), - ]; - - // 首个 Tab 的 active_ix 保持 0 不变,TabPanel 不会触发激活回调, - // 手动把初始输入焦点交给终端。 - let focus = panel.read(cx).focus_handle(cx); - window.defer(cx, move |window, _| window.focus(&focus)); - - Self { dock_area, tab_panel, _subscriptions: subscriptions } + let mut this = Self { tabs: Vec::new(), active: 0, sidebar_collapsed: false }; + this.add_terminal(window, cx); + this } fn add_terminal(&mut self, window: &mut Window, cx: &mut Context) { - let panel = cx.new(|cx| TerminalPanel::new(window, cx)); - self._subscriptions.push(cx.subscribe_in(&panel, window, Self::on_terminal_event)); - // add_panel 会激活新 Tab 并把焦点交给面板(即终端)。 - self.tab_panel.update(cx, |tabs, cx| tabs.add_panel(Arc::new(panel), window, cx)); - } - - fn close_active_terminal(&mut self, window: &mut Window, cx: &mut Context) { - let Some(panel) = self.tab_panel.read(cx).active_panel(cx) else { return }; - self.tab_panel.update(cx, |tabs, cx| tabs.remove_panel(panel, window, cx)); - self.quit_if_empty(cx); - } - - /// 终端应用惯例:最后一个 Tab 关闭即退出应用。 - fn quit_if_empty(&self, cx: &mut Context) { - if self.tab_panel.read(cx).active_panel(cx).is_none() { - cx.quit(); - } + let view = cx.new(TerminalView::new); + let subscription = cx.subscribe_in(&view, window, Self::on_terminal_event); + self.tabs.push(TerminalTab { view: view.clone(), _subscription: subscription }); + self.active = self.tabs.len() - 1; + self.focus_active(window, cx); + cx.notify(); } fn on_terminal_event( &mut self, - panel: &Entity, - event: &TerminalPanelEvent, + view: &Entity, + event: &TerminalViewEvent, window: &mut Window, cx: &mut Context, ) { match event { - TerminalPanelEvent::TitleChanged => { - // Tab 标题渲染在 TabPanel 的渲染树里,面板自身的 notify - // 不会让 TabPanel 重绘,需要在这里转发。 - self.tab_panel.update(cx, |_, cx| cx.notify()); - }, - TerminalPanelEvent::Exited | TerminalPanelEvent::CloseRequested => { - let panel = panel.clone(); - self.tab_panel.update(cx, |tabs, cx| { - tabs.remove_panel(Arc::new(panel), window, cx); - }); - self.quit_if_empty(cx); + // 侧边栏标题渲染在本视图里,直接重绘。 + TerminalViewEvent::TitleChanged => cx.notify(), + TerminalViewEvent::Exited => { + let id = view.entity_id(); + if let Some(ix) = self.tabs.iter().position(|t| t.view.entity_id() == id) { + self.close_tab(ix, window, cx); + } }, } } - fn on_tab_layout_event( - &mut self, - _: &Entity, - event: &PanelEvent, - _window: &mut Window, - cx: &mut Context, - ) { - // 覆盖用户直接点 Tab 关闭按钮的路径(不经过 workspace)。 - if matches!(event, PanelEvent::LayoutChanged) { - self.quit_if_empty(cx); + /// 终端应用惯例:最后一个 Tab 关闭即退出应用。 + fn close_tab(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { + if ix >= self.tabs.len() { + return; } + let tab = self.tabs.remove(ix); + // 立即回收会话;实体引用清零后 TerminalView::drop 再兜底。 + tab.view.read(cx).shutdown(); + + if self.tabs.is_empty() { + cx.quit(); + return; + } + if ix < self.active { + self.active -= 1; + } + if self.active >= self.tabs.len() { + self.active = self.tabs.len() - 1; + } + self.focus_active(window, cx); + cx.notify(); + } + + fn activate_tab(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { + if ix < self.tabs.len() && ix != self.active { + self.active = ix; + self.focus_active(window, cx); + cx.notify(); + } + } + + fn close_active(&mut self, window: &mut Window, cx: &mut Context) { + self.close_tab(self.active, window, cx); + } + + fn focus_active(&self, window: &mut Window, cx: &mut Context) { + if let Some(tab) = self.tabs.get(self.active) { + let focus = tab.view.read(cx).focus_handle.clone(); + window.defer(cx, move |window, _| window.focus(&focus)); + } + } + + fn tab_title(&self, ix: usize, cx: &App) -> SharedString { + const MAX_CHARS: usize = 20; + let title = &self.tabs[ix].view.read(cx).title; + let mut chars = title.chars(); + let head: String = chars.by_ref().take(MAX_CHARS).collect(); + if chars.next().is_some() { format!("{head}…").into() } else { head.into() } + } + + fn render_sidebar(&self, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let sidebar_bg = theme.sidebar; + let sidebar_border = theme.sidebar_border; + let muted = theme.muted_foreground; + let active_bg = theme.sidebar_accent; + let active_fg = theme.sidebar_accent_foreground; + let hover_bg = theme.list_hover; + + let items = (0..self.tabs.len()).map(|ix| { + let active = ix == self.active; + let title = self.tab_title(ix, cx); + h_flex() + .id(("sidebar-tab", ix)) + .gap_2() + .px_2() + .h(px(30.0)) + .items_center() + .rounded_md() + .cursor_pointer() + .when(active, |item| item.bg(active_bg).text_color(active_fg)) + .when(!active, |item| { + item.text_color(muted).hover(|style| style.bg(hover_bg)) + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.activate_tab(ix, window, cx); + })) + .child( + Icon::new(IconName::SquareTerminal) + .small() + .text_color(if active { active_fg } else { muted }), + ) + .child(div().flex_1().min_w_0().text_sm().truncate().child(title)) + .child( + Button::new(("close-tab", ix)) + .icon(IconName::Close) + .ghost() + .xsmall() + .on_click(cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.close_tab(ix, window, cx); + })), + ) + }); + + v_flex() + .w(px(210.0)) + .h_full() + .flex_shrink_0() + .bg(sidebar_bg) + .border_r_1() + .border_color(sidebar_border) + .p_2() + .gap_1() + .child( + h_flex() + .px_2() + .pb_1() + .items_center() + .child( + div() + .flex_1() + .text_xs() + .text_color(muted) + .child(format!("TABS {}", self.tabs.len())), + ) + .child( + Button::new("sidebar-new-tab") + .icon(IconName::Plus) + .ghost() + .xsmall() + .tooltip("新建终端 (Ctrl+Shift+T)") + .on_click(cx.listener(|this, _, window, cx| { + this.add_terminal(window, cx); + })), + ), + ) + .child(v_flex().flex_1().gap_1().children(items)) + .child( + h_flex().px_1().child( + Button::new("open-gallery") + .icon(IconName::Settings) + .ghost() + .small() + .tooltip("组件验收页") + .on_click(|_, _, cx| { + crate::app::open_gallery_window(cx); + }), + ), + ) } } impl Render for NebulaWorkspace { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme_border = cx.theme().border; + let collapsed = self.sidebar_collapsed; + let active_terminal = self.tabs.get(self.active).map(|tab| tab.view.clone()); + div() .size_full() .flex() @@ -125,58 +230,62 @@ impl Render for NebulaWorkspace { this.add_terminal(window, cx); })) .on_action(cx.listener(|this, _: &CloseActiveTerminal, window, cx| { - this.close_active_terminal(window, cx); + this.close_active(window, cx); + })) + .on_action(cx.listener(|this, _: &ToggleSidebar, _, cx| { + this.sidebar_collapsed = !this.sidebar_collapsed; + cx.notify(); })) .child( TitleBar::new() .child( - div() - .flex() + h_flex() + .gap_1() .items_center() - .gap_2() + .occlude() + .child( + Button::new("toggle-sidebar") + .icon(if collapsed { + IconName::PanelLeftOpen + } else { + IconName::PanelLeftClose + }) + .ghost() + .xsmall() + .tooltip("折叠/展开侧边栏 (Ctrl+Shift+B)") + .on_click(cx.listener(|this, _, _, cx| { + this.sidebar_collapsed = !this.sidebar_collapsed; + cx.notify(); + })), + ) .child( div() .text_sm() .font_weight(FontWeight::SEMIBOLD) .child("Nebula"), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("GPUI"), ), ) + .child(div()), + ) + .child( + // 不用 h_flex:它默认 items_center,会把子项高度压成内容高度。 + div() + .flex() + .flex_row() + .flex_1() + .min_h_0() + .when(!collapsed, |root| root.child(self.render_sidebar(cx))) .child( - div() - .flex() - .items_center() - .gap_1() - .pr_2() - // 阻止标题栏拖拽区吃掉按钮点击。 - .occlude() - .child( - Button::new("new-terminal") - .icon(IconName::Plus) - .ghost() - .small() - .tooltip("新建终端 (Ctrl+Shift+T)") - .on_click(cx.listener(|this, _, window, cx| { - this.add_terminal(window, cx); - })), - ) - .child( - Button::new("open-gallery") - .icon(IconName::LayoutDashboard) - .ghost() - .small() - .tooltip("组件验收页") - .on_click(|_, _, cx| { - crate::app::open_gallery_window(cx); - }), - ), + div().flex_1().min_w_0().p_2().child( + div() + .size_full() + .rounded_lg() + .border_1() + .border_color(theme_border) + .overflow_hidden() + .children(active_terminal), + ), ), ) - .child(div().flex_1().min_h_0().child(self.dock_area.clone())) } }