diff --git a/Cargo.toml b/Cargo.toml index 6a92883..6766790 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "nebula-completions", "nebula_hook", "nebula_gpui", + "nebula_settings", ] resolver = "2" diff --git a/nebula_gpui/Cargo.toml b/nebula_gpui/Cargo.toml index 7e3a0f4..edc81ad 100644 --- a/nebula_gpui/Cargo.toml +++ b/nebula_gpui/Cargo.toml @@ -24,6 +24,8 @@ gpui-component-assets = "=0.5.1" # 终端核心是纯 Rust(vte/grid/ConPTY),没有 GUI 原生依赖,因此可以跨 workspace # 边界直接 path 引用而不会重新引入 fontconfig links 冲突。 nebula_terminal = { path = "../nebula_terminal" } +# 运行时用户设置(nebula_settings.txt + 主题终端色表)的共享权威实现。 +nebula-settings = { path = "../nebula_settings" } futures = "0.3" # 读取用户 nebula.toml 的字体/配色子集;版本与主仓保持一致。 serde = { version = "1", features = ["derive"] } diff --git a/nebula_gpui/src/config/mod.rs b/nebula_gpui/src/config/mod.rs index 410bfb4..0f62f0b 100644 --- a/nebula_gpui/src/config/mod.rs +++ b/nebula_gpui/src/config/mod.rs @@ -1,18 +1,26 @@ -//! 读取用户的 `nebula.toml`,与 `nebula_app` 共享同一份配置文件。 +//! 读取用户配置:`nebula.toml` + `nebula_settings.txt`,与 `nebula_app` +//! 共享同一套文件与语义。 //! -//! 只解析 GPUI 壳当前消费的子集(字体、终端配色),解析全程宽容:未知字段 -//! 忽略、非法值回退默认,保证任何用户配置都不会阻止启动。查找路径与主应用 -//! `config::installed_config` 完全一致;`general.import`(及顶层 `import`) -//! 按主应用语义做值级合并:imports 先加载,主文件最后覆盖。 +//! 两个来源、一个优先级:`nebula_settings.txt`(设置界面持久化的运行时 +//! 意图,经共享 crate `nebula-settings` 读取)覆盖 `nebula.toml` 的对应 +//! 字段;主题对终端色表的影响永远在最后叠加(镜像旧壳 `apply_term_colors` +//! 的次序:用户配色是 defaults,主题裁定背景/浅色替换/Powerline 槽位)。 +//! +//! 解析全程宽容:未知字段忽略、非法值回退默认,任何用户配置都不会阻止 +//! 启动。toml 查找路径与主应用 `config::installed_config` 一致; +//! `general.import`(及顶层 `import`)按主应用语义值级合并。 //! //! 尚未对齐的字段(记录在案,后续步骤处理): //! - `font.offset` / `font.glyph_offset`(cell 度量微调) //! - `colors.cursor` 的 `CellForeground`/`CellBackground` 反色语义 +//! - `follow_system_theme`(系统外观联动切主题) //! - 配置热重载(主应用用 notify 监视;本壳目前启动读一次) use std::path::{Path, PathBuf}; use gpui::Global; +use nebula_settings::{CursorShapeName, RuntimeSettings}; +use nebula_terminal::vte::ansi::CursorShape; use serde::Deserialize; use crate::terminal::colors::Palette; @@ -26,7 +34,11 @@ pub struct Settings { /// GPUI 逻辑像素(配置里是 pt,1pt = 4/3 px @96dpi)。 pub font_size_px: f32, pub palette: Palette, - /// 实际加载的配置文件(诊断用;None 表示全默认)。 + pub cursor_shape: Option, + pub cursor_blink: Option, + /// 选区完成即复制(旧壳 `copy_on_select` 设置)。 + pub copy_on_select: bool, + /// 实际加载的 toml 配置文件(诊断用;None 表示无 toml)。 pub source_path: Option, } @@ -34,6 +46,7 @@ impl Global for Settings {} impl Settings { pub fn load() -> Self { + let runtime = RuntimeSettings::load(); let path = find_config_file(); let raw = path .as_deref() @@ -41,26 +54,82 @@ impl Settings { .unwrap_or_else(|| toml::Value::Table(Default::default())); let raw: RawConfig = raw.try_into().unwrap_or_default(); - let normal_family = raw - .font - .normal - .family + // 字体:settings.txt(设置界面)覆盖 toml,最后落内置默认。 + let normal_family = runtime + .font_family .clone() + .or_else(|| 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()) }; + let font_size_pt = + runtime.font_size_pt.or(raw.font.size).unwrap_or(11.25).clamp(4.0, 96.0); + + // 配色:toml 覆盖内置默认,主题最后裁定(与旧壳同序)。 + let mut palette = build_palette(&raw.colors); + apply_theme(&mut palette, runtime.theme); 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_size_px: font_size_pt * 4.0 / 3.0, + palette, + cursor_shape: runtime.cursor_shape.map(|shape| match shape { + CursorShapeName::Block => CursorShape::Block, + CursorShapeName::Beam => CursorShape::Beam, + CursorShapeName::Underline => CursorShape::Underline, + }), + cursor_blink: runtime.cursor_blink, + copy_on_select: runtime.copy_on_select, font_family: normal_family, source_path: path, } } + + /// 引擎 Term 的启动配置(默认光标形状/闪烁来自运行时设置)。 + pub fn term_config(&self) -> nebula_terminal::term::Config { + let mut config = nebula_terminal::term::Config::default(); + if let Some(shape) = self.cursor_shape { + config.default_cursor_style.shape = shape; + } + if let Some(blinking) = self.cursor_blink { + config.default_cursor_style.blinking = blinking; + } + config + } +} + +/// 主题叠加在 toml 配色之上(镜像旧壳 `apply_term_colors` 的范围与次序): +/// 背景永远替换;Powerline 槽位 16..=23 替换;浅色主题另替换前景与 +/// ANSI-16。dim 表与 bright_foreground 有意不动——与旧壳逐字段一致。 +fn apply_theme(palette: &mut Palette, theme: nebula_settings::ThemeName) { + let term = theme.term_theme(); + palette.background = rgba8(term.background); + for (i, color) in term.powerline.into_iter().enumerate() { + set_indexed(palette, nebula_settings::POWERLINE_SLOT0 + i as u8, rgba8(color)); + } + if term.is_light { + palette.foreground = rgba8(nebula_settings::LIGHT_FOREGROUND); + for (i, color) in nebula_settings::LIGHT_ANSI.into_iter().enumerate() { + palette.ansi[i] = rgba8(color); + } + } +} + +fn set_indexed(palette: &mut Palette, index: u8, color: gpui::Rgba) { + palette.indexed.retain(|(existing, _)| *existing != index); + palette.indexed.push((index, color)); +} + +fn rgba8(color: nebula_settings::Rgb8) -> gpui::Rgba { + gpui::Rgba { + r: color[0] as f32 / 255.0, + g: color[1] as f32 / 255.0, + b: color[2] as f32 / 255.0, + a: 1.0, + } } fn default_font_family() -> &'static str { diff --git a/nebula_gpui/src/terminal/session.rs b/nebula_gpui/src/terminal/session.rs index 12e28db..ee5a4f8 100644 --- a/nebula_gpui/src/terminal/session.rs +++ b/nebula_gpui/src/terminal/session.rs @@ -50,9 +50,11 @@ pub struct TerminalSession { pub notifier: Notifier, } -/// 启动一个本地 shell 会话。尺寸随后由首帧 prepaint 按真实布局重设。 +/// 启动一个本地 shell 会话。尺寸随后由首帧 prepaint 按真实布局重设; +/// `term_config` 携带运行时设置(默认光标形状/闪烁等)。 pub fn spawn( window_size: WindowSize, + term_config: Config, ) -> std::io::Result<(TerminalSession, UnboundedReceiver)> { let (tx, rx) = unbounded(); let proxy = EventProxy(tx); @@ -61,7 +63,7 @@ pub fn spawn( columns: window_size.num_cols as usize, screen_lines: window_size.num_lines as usize, }; - let term = Arc::new(FairMutex::new(Term::new(Config::default(), &grid, proxy.clone()))); + let term = Arc::new(FairMutex::new(Term::new(term_config, &grid, proxy.clone()))); let options = tty::Options::default(); tty::setup_env(); diff --git a/nebula_gpui/src/terminal/view.rs b/nebula_gpui/src/terminal/view.rs index 40fad41..a291f50 100644 --- a/nebula_gpui/src/terminal/view.rs +++ b/nebula_gpui/src/terminal/view.rs @@ -62,8 +62,9 @@ pub struct TerminalView { impl TerminalView { pub fn new(cx: &mut Context) -> Self { - // 字体与调色板来自用户 nebula.toml(bootstrap 时装载为全局 Settings)。 - let (families, font_size, palette) = match cx.try_global::() { + // 字体、调色板与终端启动配置来自用户配置(nebula.toml + + // nebula_settings.txt,bootstrap 时装载为全局 Settings)。 + let (families, font_size, palette, term_config) = match cx.try_global::() { Some(settings) => ( [ settings.font_family.clone(), @@ -73,11 +74,13 @@ impl TerminalView { ], px(settings.font_size_px), Arc::new(settings.palette.clone()), + settings.term_config(), ), None => ( std::array::from_fn(|_| String::from("Cascadia Mono")), px(15.0), Arc::new(Palette::default()), + nebula_terminal::term::Config::default(), ), }; // to_string:SharedString 的 &str 转换要求 'static,字体名是运行时值。 @@ -88,7 +91,7 @@ impl TerminalView { }; let initial = WindowSize { num_lines: 30, num_cols: 100, cell_width: 9, cell_height: 18 }; - let (session, error) = match session::spawn(initial) { + let (session, error) = match session::spawn(initial, term_config) { Ok((session, mut rx)) => { cx.spawn(async move |this, cx| { while let Some(event) = rx.next().await { diff --git a/nebula_settings/Cargo.toml b/nebula_settings/Cargo.toml new file mode 100644 index 0000000..505cf8b --- /dev/null +++ b/nebula_settings/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "nebula-settings" +version = "1.0.0" +license = "GPL-3.0-or-later" +description = "Shared reader for Nebula runtime user settings (nebula_settings.txt) and theme terminal-color tables" +publish = false +edition.workspace = true +rust-version.workspace = true + +# 刻意零依赖:任何壳(winit 旧壳 / GPUI)与任何渲染后端都能免费引用, +# 不给依赖图带来任何新节点。 +[dependencies] diff --git a/nebula_settings/src/lib.rs b/nebula_settings/src/lib.rs new file mode 100644 index 0000000..c5b37e2 --- /dev/null +++ b/nebula_settings/src/lib.rs @@ -0,0 +1,385 @@ +//! Nebula 运行时用户设置(`nebula_settings.txt`)与主题终端色表的共享读取。 +//! +//! 这是两个 UI 壳同读的权威实现:设置文件的路径解析、键值语义,以及每个 +//! 主题对终端色表的影响(背景替换、浅色主题的前景/ANSI-16 替换、Powerline +//! 提示符槽位)。设置界面写入的 `nebula_settings.txt` 是运行时用户意图的 +//! 权威来源,优先级高于 `nebula.toml` 的对应字段。 +//! +//! 同步事实(记录在案): +//! - `nebula_app/src/display/ui/theme.rs` 是旧壳体内的既有实现;本 crate 的 +//! 色值与其逐字一致。旧壳按 P4 裁定冻结保留,新 UI 一律读这里;改主题 +//! 色值时两处同步,直到 P3 接入完成后旧壳引用此 crate。 +//! - `nebula_terminal::tty::windows` 里有一份私有的同款路径/键值读取;引擎 +//! 不能依赖 UI 侧 crate(依赖方向是铁律),属有意保留的最小重复。 +//! - `follow_system_theme` 的系统外观联动暂未在此实现(消费方自行处理)。 + +use std::collections::HashMap; +use std::path::PathBuf; + +/// 设置目录:`NEBULA_CONFIG_DIR` 覆盖(便携/测试隔离,指向目录本身)→ +/// `%APPDATA%\Nebula` → `$HOME/.config/Nebula` → 临时目录。 +/// 与引擎 tty 层、旧壳、注入的 shell 提示符脚本使用同一套解析。 +pub fn settings_dir() -> PathBuf { + if let Some(path) = std::env::var_os("NEBULA_CONFIG_DIR").filter(|p| !p.is_empty()) { + return PathBuf::from(path); + } + std::env::var_os("APPDATA") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) + .unwrap_or_else(std::env::temp_dir) + .join("Nebula") +} + +pub fn settings_path() -> PathBuf { + settings_dir().join("nebula_settings.txt") +} + +/// `nebula_settings.txt` 的键值视图。行格式 `key=value`;键大小写不敏感, +/// 值两端修剪;空值视为未设置。 +#[derive(Default)] +pub struct RawSettings { + values: HashMap, +} + +impl RawSettings { + pub fn load() -> Self { + std::fs::read_to_string(settings_path()) + .map(|text| Self::from_text(&text)) + .unwrap_or_default() + } + + pub fn from_text(text: &str) -> Self { + let mut values = HashMap::new(); + for line in text.lines() { + if let Some((key, value)) = line.split_once('=') { + values.insert(key.trim().to_ascii_lowercase(), value.trim().to_owned()); + } + } + Self { values } + } + + pub fn value(&self, key: &str) -> Option<&str> { + self.values + .get(&key.to_ascii_lowercase()) + .map(String::as_str) + .filter(|value| !value.is_empty()) + } + + pub fn f32(&self, key: &str) -> Option { + self.value(key)?.parse().ok() + } + + /// `1/true/yes/on` → true;`0/false/no/off` → false;其余 `None`。 + pub fn bool_on(&self, key: &str) -> Option { + match self.value(key)?.to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Some(true), + "0" | "false" | "no" | "off" => Some(false), + _ => None, + } + } +} + +pub type Rgb8 = [u8; 3]; + +/// 主题标识;`nebula_settings.txt` 里 `theme=` 持久化 [`Self::prompt_name`]。 +#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] +pub enum ThemeName { + #[default] + Nebula, + SilverLight, + SteelDark, + LimestoneLight, + CoalDark, + LinenLight, + MossDark, +} + +impl ThemeName { + pub fn from_prompt_name(name: &str) -> Option { + Some(match name { + "Nebula" => Self::Nebula, + "SilverLight" => Self::SilverLight, + "SteelDark" => Self::SteelDark, + "LimestoneLight" => Self::LimestoneLight, + "CoalDark" => Self::CoalDark, + "LinenLight" => Self::LinenLight, + "MossDark" => Self::MossDark, + _ => return None, + }) + } + + pub fn prompt_name(self) -> &'static str { + match self { + Self::Nebula => "Nebula", + Self::SilverLight => "SilverLight", + Self::SteelDark => "SteelDark", + Self::LimestoneLight => "LimestoneLight", + Self::CoalDark => "CoalDark", + Self::LinenLight => "LinenLight", + Self::MossDark => "MossDark", + } + } + + /// 主题对终端色表的全部影响(旧壳 `apply_term_colors` 的数据形态)。 + pub fn term_theme(self) -> TermTheme { + // 背景与 is_light 来自各主题 palette();powerline 为提示符段色 + // (icon bg/fg、path bg/fg、branch bg/fg、time bg/fg)。 + match self { + Self::Nebula => TermTheme { + background: [15, 17, 26], + is_light: false, + powerline: [ + [57, 75, 112], + [192, 202, 245], + [41, 52, 82], + [169, 177, 214], + [47, 79, 79], + [139, 213, 202], + [29, 33, 46], + [100, 116, 139], + ], + }, + Self::SilverLight => TermTheme { + background: [255, 255, 255], + is_light: true, + powerline: [ + [229, 231, 235], + [55, 65, 81], + [243, 244, 246], + [55, 65, 81], + [224, 242, 254], + [3, 105, 161], + [249, 250, 251], + [107, 114, 128], + ], + }, + Self::SteelDark => TermTheme { + background: [26, 28, 36], + is_light: false, + powerline: [ + [71, 85, 105], + [241, 245, 249], + [51, 65, 85], + [203, 213, 225], + [59, 82, 73], + [163, 184, 153], + [40, 44, 56], + [148, 163, 184], + ], + }, + Self::LimestoneLight => TermTheme { + background: [255, 255, 255], + is_light: true, + powerline: [ + [214, 211, 209], + [250, 250, 249], + [231, 229, 228], + [68, 64, 60], + [200, 198, 167], + [41, 37, 36], + [235, 233, 230], + [163, 160, 151], + ], + }, + Self::CoalDark => TermTheme { + background: [23, 23, 23], + is_light: false, + powerline: [ + [82, 82, 82], + [245, 245, 245], + [64, 64, 64], + [212, 212, 212], + [74, 79, 65], + [181, 181, 166], + [48, 48, 48], + [115, 115, 115], + ], + }, + Self::LinenLight => TermTheme { + background: [255, 255, 255], + is_light: true, + powerline: [ + [212, 212, 208], + [255, 255, 255], + [229, 229, 223], + [63, 63, 63], + [181, 196, 177], + [45, 45, 45], + [236, 236, 230], + [176, 179, 176], + ], + }, + Self::MossDark => TermTheme { + background: [30, 33, 30], + is_light: false, + powerline: [ + [75, 85, 72], + [240, 253, 244], + [59, 66, 56], + [220, 252, 231], + [60, 79, 60], + [187, 247, 208], + [42, 47, 42], + [107, 114, 107], + ], + }, + } + } +} + +/// 一个主题对终端色表的影响。浅色主题额外用 [`LIGHT_FOREGROUND`] 与 +/// [`LIGHT_ANSI`] 替换前景与 ANSI-16(配置的暗底配色在浅底上不可读)。 +/// dim 表与 bright_foreground 有意不动——与旧壳逐字段一致。 +pub struct TermTheme { + pub background: Rgb8, + pub is_light: bool, + /// Powerline 提示符段色,发布到 256 色表 [`POWERLINE_SLOT0`]`..+8`。 + pub powerline: [Rgb8; 8], +} + +/// Powerline 槽位起点(索引色 16..=23)。 +pub const POWERLINE_SLOT0: u8 = 16; + +/// 浅色主题的替换前景(#24292f)。 +pub const LIGHT_FOREGROUND: Rgb8 = [36, 41, 47]; + +/// 浅色主题的 ANSI-16 替换表(GitHub Primer Light 派生;BrightWhite 刻意 +/// 用灰——纯白会消失在白底终端上)。 +pub const LIGHT_ANSI: [Rgb8; 16] = [ + [36, 41, 47], // black #24292f + [207, 34, 46], // red #cf222e + [26, 127, 55], // green #1a7f37 + [154, 103, 0], // yellow #9a6700 + [9, 105, 218], // blue #0969da + [130, 80, 223], // magenta #8250df + [27, 124, 131], // cyan #1b7c83 + [110, 119, 129], // white #6e7781 + [87, 96, 106], // bright black #57606a + [164, 14, 38], // bright red #a40e26 + [45, 164, 78], // bright green #2da44e + [191, 135, 0], // bright yellow #bf8700 + [33, 139, 255], // bright blue #218bff + [164, 117, 249], // bright magenta #a475f9 + [49, 146, 170], // bright cyan #3192aa + [140, 149, 159], // bright white #8c959f +]; + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum CursorShapeName { + Block, + Beam, + Underline, +} + +/// 新 UI 消费的运行时设置子集。字段随消费方增长;未设置的键保持 `None` +/// 让调用方自选回退。 +pub struct RuntimeSettings { + pub theme: ThemeName, + pub font_family: Option, + /// pt(与设置界面一致)。 + pub font_size_pt: Option, + pub cursor_shape: Option, + pub cursor_blink: Option, + pub copy_on_select: bool, + pub powerline: bool, +} + +impl RuntimeSettings { + pub fn load() -> Self { + Self::from_raw(&RawSettings::load()) + } + + pub fn from_raw(raw: &RawSettings) -> Self { + Self { + theme: raw.value("theme").and_then(ThemeName::from_prompt_name).unwrap_or_default(), + font_family: raw.value("font_family").map(str::to_owned), + font_size_pt: raw.f32("font_size").map(|size| size.clamp(4.0, 96.0)), + cursor_shape: raw.value("cursor_shape").and_then(|value| { + match value.to_ascii_lowercase().as_str() { + "block" => Some(CursorShapeName::Block), + "beam" | "bar" => Some(CursorShapeName::Beam), + "underline" => Some(CursorShapeName::Underline), + _ => None, + } + }), + cursor_blink: raw.bool_on("cursor_blink"), + copy_on_select: raw.bool_on("copy_on_select").unwrap_or(false), + powerline: raw.bool_on("powerline").unwrap_or(true), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_real_settings_shape() { + let raw = RawSettings::from_text( + "language=system\n\ + theme=SilverLight\n\ + follow_system_theme=0\n\ + shell=powershell\n\ + font_family=Maple Mono Normal NF CN\n\ + font_size=16.3\n\ + cursor_shape=beam\n\ + cursor_blink=1\n\ + copy_on_select=1\n\ + powerline=0\n\ + startup_directory=\n", + ); + let settings = RuntimeSettings::from_raw(&raw); + assert_eq!(settings.theme, ThemeName::SilverLight); + assert_eq!(settings.font_family.as_deref(), Some("Maple Mono Normal NF CN")); + assert_eq!(settings.font_size_pt, Some(16.3)); + assert_eq!(settings.cursor_shape, Some(CursorShapeName::Beam)); + assert_eq!(settings.cursor_blink, Some(true)); + assert!(settings.copy_on_select); + assert!(!settings.powerline); + // 空值 = 未设置。 + assert_eq!(raw.value("startup_directory"), None); + } + + #[test] + fn defaults_when_file_content_is_absent_or_junk() { + let settings = RuntimeSettings::from_raw(&RawSettings::from_text("theme=NoSuchTheme\n")); + assert_eq!(settings.theme, ThemeName::Nebula); + assert_eq!(settings.font_family, None); + assert!(!settings.copy_on_select); + assert!(settings.powerline); + } + + #[test] + fn theme_names_roundtrip() { + for theme in [ + ThemeName::Nebula, + ThemeName::SilverLight, + ThemeName::SteelDark, + ThemeName::LimestoneLight, + ThemeName::CoalDark, + ThemeName::LinenLight, + ThemeName::MossDark, + ] { + assert_eq!(ThemeName::from_prompt_name(theme.prompt_name()), Some(theme)); + } + } + + #[test] + fn light_themes_replace_foreground_dark_themes_do_not() { + assert!(ThemeName::SilverLight.term_theme().is_light); + assert!(ThemeName::LimestoneLight.term_theme().is_light); + assert!(ThemeName::LinenLight.term_theme().is_light); + assert!(!ThemeName::Nebula.term_theme().is_light); + assert!(!ThemeName::SteelDark.term_theme().is_light); + // 浅色主题终端底全白,背景由主题裁定而非用户配色。 + assert_eq!(ThemeName::SilverLight.term_theme().background, [255, 255, 255]); + assert_eq!(ThemeName::Nebula.term_theme().background, [15, 17, 26]); + } + + #[test] + fn keys_are_case_insensitive_and_trimmed() { + let raw = RawSettings::from_text(" Theme = SilverLight \nFONT_SIZE=12\n"); + assert_eq!(raw.value("theme"), Some("SilverLight")); + assert_eq!(raw.f32("font_size"), Some(12.0)); + } +}