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:
1 parent
c520bc6de2
commit
40a1f9edb2
7 files changed
+752
-337
No files matched your search
@@ -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,4 +1,5 @@
|
||||
mod app;
|
||||
mod config;
|
||||
mod terminal;
|
||||
mod ui;
|
||||
mod views;
|
||||
|
||||
+155
-108
@@ -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 的显式判别值对齐。
|
||||
|
||||
@@ -6,6 +6,5 @@
|
||||
pub mod colors;
|
||||
pub mod element;
|
||||
pub mod keymap;
|
||||
pub mod panel;
|
||||
pub mod session;
|
||||
pub mod view;
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user