refactor(gpui): fold terminal panel into workspace view, expand palette

Removes the standalone panel.rs in favor of workspace-managed terminal
views, extends the color palette mapping, and registers the config module.
This commit is contained in:
kuddev committed 2026-08-13 00:16:19 +08:00
1 parent c520bc6de2
commit 40a1f9edb2
7 files changed
+752 -337

No files matched your search

+366
View File
@@ -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<PathBuf>,
}
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<PathBuf> {
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<String> = [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::<toml::Table>() {
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<f32>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct RawFontDesc {
family: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct RawColors {
primary: RawPrimary,
cursor: RawCellColors,
selection: RawCellColors,
normal: RawAnsi8,
bright: RawAnsi8,
dim: Option<RawAnsi8>,
indexed_colors: Vec<RawIndexed>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct RawPrimary {
foreground: Option<String>,
background: Option<String>,
bright_foreground: Option<String>,
dim_foreground: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct RawCellColors {
// CellForeground/CellBackground 等特殊值 parse 失败自然落回默认。
foreground: Option<String>,
background: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct RawAnsi8 {
black: Option<String>,
red: Option<String>,
green: Option<String>,
yellow: Option<String>,
blue: Option<String>,
magenta: Option<String>,
cyan: Option<String>,
white: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct RawIndexed {
index: Option<u16>,
color: Option<String>,
}
/// 解析 `#rrggbb` / `0xrrggbb`。
fn parse_rgb(text: &str) -> Option<gpui::Rgba> {
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<String>| {
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<gpui::Rgba>; 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
}
+1
View File
@@ -1,4 +1,5 @@
mod app;
mod config;
mod terminal;
mod ui;
mod views;
+155 -108
View File
@@ -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<NamedColor> {
use NamedColor::*;
// Colors 的 256..268 段与 NamedColor 的显式判别值对齐。
-1
View File
@@ -6,6 +6,5 @@
pub mod colors;
pub mod element;
pub mod keymap;
pub mod panel;
pub mod session;
pub mod view;
-115
View File
@@ -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<TerminalView>,
_subscriptions: Vec<Subscription>,
}
impl TerminalPanel {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> 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<PanelEvent> for TerminalPanel {}
impl EventEmitter<TerminalPanelEvent> 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<SharedString> {
Some(self.tab_title(cx))
}
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.tab_title(cx)
}
fn title_suffix(
&mut self,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<impl IntoElement> {
// 根级 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<Self>) {
// Tab 被移除时立即回收会话;实体引用清零后 TerminalView::drop 会再兜底。
self.terminal.read(cx).shutdown();
}
}
impl Render for TerminalPanel {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.terminal.clone())
}
}
+8
View File
@@ -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);
}
+222 -113
View File
@@ -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<TerminalView>,
_subscription: Subscription,
}
pub struct NebulaWorkspace {
dock_area: Entity<DockArea>,
tab_panel: Entity<TabPanel>,
_subscriptions: Vec<Subscription>,
tabs: Vec<TerminalTab>,
active: usize,
sidebar_collapsed: bool,
}
impl NebulaWorkspace {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> 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<Self>) {
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<Self>) {
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<Self>) {
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<TerminalPanel>,
event: &TerminalPanelEvent,
view: &Entity<TerminalView>,
event: &TerminalViewEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<TabPanel>,
event: &PanelEvent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
// 覆盖用户直接点 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<Self>) {
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<Self>) {
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>) {
self.close_tab(self.active, window, cx);
}
fn focus_active(&self, window: &mut Window, cx: &mut Context<Self>) {
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<Self>) -> 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<Self>) -> 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()))
}
}