feat(settings): shared nebula-settings crate; GPUI lab reads runtime settings + theme term colors

New zero-dependency workspace crate owning nebula_settings.txt path resolution, key semantics and the per-theme terminal color tables (background, light-theme foreground/ANSI-16 replacement, powerline slots 16..=23) - values transcribed verbatim from the legacy shell's theme.rs, which stays frozen per P4. The GPUI lab now honors the user's actual runtime intent: theme (SilverLight etc.) applied over toml colors in the same order as apply_term_colors, font family/size from the settings UI, default cursor shape/blink wired into the engine Term config, copy_on_select exposed for the next slice. Cargo.lock intentionally not committed: it currently carries the user's in-flight gpui-fork switch.
This commit is contained in:
kuddev committed 2026-08-13 12:18:21 +08:00
1 parent 7727bcb779
commit 189e5ac4ff
7 files changed
+491 -17

No files matched your search

+1
View File
@@ -7,6 +7,7 @@ members = [
"nebula-completions",
"nebula_hook",
"nebula_gpui",
"nebula_settings",
]
resolver = "2"
+2
View File
@@ -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"] }
+81 -12
View File
@@ -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<CursorShape>,
pub cursor_blink: Option<bool>,
/// 选区完成即复制(旧壳 `copy_on_select` 设置)。
pub copy_on_select: bool,
/// 实际加载的 toml 配置文件(诊断用;None 表示无 toml)。
pub source_path: Option<PathBuf>,
}
@@ -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 {
+4 -2
View File
@@ -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<Event>)> {
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();
+6 -3
View File
@@ -62,8 +62,9 @@ pub struct TerminalView {
impl TerminalView {
pub fn new(cx: &mut Context<Self>) -> Self {
// 字体与调色板来自用户 nebula.toml(bootstrap 时装载为全局 Settings)。
let (families, font_size, palette) = match cx.try_global::<Settings>() {
// 字体、调色板与终端启动配置来自用户配置(nebula.toml +
// nebula_settings.txt,bootstrap 时装载为全局 Settings)。
let (families, font_size, palette, term_config) = match cx.try_global::<Settings>() {
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 {
+12
View File
@@ -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]
+385
View File
@@ -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<String, String>,
}
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<f32> {
self.value(key)?.parse().ok()
}
/// `1/true/yes/on` → true;`0/false/no/off` → false;其余 `None`。
pub fn bool_on(&self, key: &str) -> Option<bool> {
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<Self> {
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<String>,
/// pt(与设置界面一致)。
pub font_size_pt: Option<f32>,
pub cursor_shape: Option<CursorShapeName>,
pub cursor_blink: Option<bool>,
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));
}
}