feat(theme): save and edit custom theme snapshots

Commit the theme library, preview editor, foreground picker, import and export, and shared settings integration. Preserve built-in themes and apply saved custom snapshots consistently to terminal colors, typography and window appearance.
This commit is contained in:
kuddev committed 2026-09-15 09:45:17 +08:00
1 parent 96d4cf0179
commit 6275b6435a
46 files changed
+13603 -334

No files matched your search

+57
View File
@@ -344,6 +344,62 @@ inactive-pane dimming defaults on to preserve the existing appearance. Both are
cached by the GPUI settings adapter; pointer movement and rendering do not read
settings files.
## ADR-0011 — Editable theme snapshots and preview before application
- **Status:** Implemented in the working tree, 2026-09-14. Windows GPUI product
build and native interaction checks have run; this is not a release claim.
- **Context:** The fixed built-in theme enum cannot represent user-created themes.
Users need to start from an existing theme, adjust common settings, preview text
colors and exchange themes without making the selection page an editor.
- **Decision:** Keep built-in identities compatible. Shared, dependency-free theme
values and validation belong to `nebula_settings`; versioned JSON, external format
adapters and library I/O belong to the application's `theme_library` capability.
A custom theme is an independent snapshot with a stable library identity and a
built-in fallback. Copying or renaming does not edit its source or create a runtime
inheritance chain. Existing atomic replacement and OS handle locks protect writes;
a revision check rejects a competing edit. Unknown native extension data survives
a round trip. External input is bounded static data and never executes configuration
scripts, includes or commands.
- **Interaction:** The theme dialog's lower-left custom action opens a dedicated
editor: existing template, name, visible common settings, then collapsed advanced
settings. Theme and icon category selections use the same rounded treatment.
The three suggested foreground swatches contain the original color and readable
cool/warm alternatives; a fourth multicolor swatch opens arbitrary color selection.
Foreground changes affect the preview and are saved with explicit application.
Back returns to the preserved theme picker selection and filter; it confirms
discarding editor changes before returning. Cancel, close and Escape separately
exit the workflow and confirm before discarding a changed draft. Saving alone
writes an independent library copy while leaving the active snapshot unchanged.
Font selection and color palettes belong to the editor draft, with component
popovers above the editor surface. Text and numeric inputs use a focusable
underline; inherited cursor color presentation follows foreground edits.
- **Runtime and cost:** Resolve the active theme on settings changes and read prepared
values during rendering. Theme defaults, explicit personal preferences and per-session
OSC overrides remain distinct; changing defaults must not erase session overrides or
reinterpret truecolor RGB as palette indices. Font and geometry overrides are optional.
Import, export and library writes use scoped background work with stale-result checks.
No extra production dependency or resident worker is justified by this feature.
- **Alternatives:** Extending the built-in enum with mutable global data, keeping a
second renderer-specific validation model, or translating each pair of formats
independently would couple unrelated lifecycles and duplicate behavior.
- **Validation:** Required evidence includes original-theme preservation, foreground
persistence/cancellation, malformed input and unknown fields, conflicting saves,
native palette behavior, keyboard/real control interaction and approved-layout
comparison. A contrast calculation for opaque default foreground/background is
not a guarantee for arbitrary transparency, syntax colors or displays. Browser
prototype checks and native GPUI acceptance are reported separately. The working
implementation has passed 54 settings-model tests, 24 document/store/format
contracts (including 90 static color round trips), 14 catalog/allocation tests,
and Windows native theme interaction checks. Actual window inspection exposed
and corrected a hidden font popup and stale inherited cursor HEX presentation.
Window captures cover the theme picker, common editor, font selector, palette,
and Back confirmation; these are Windows results, not cross-platform visual
or universal performance guarantees.
- **Revisit condition:** Dynamic inheritance, downloaded resources, additional format
semantics or system appearance slot changes require their own compatibility and
ownership evidence; they do not silently expand this snapshot contract.
## ADR-0012 — Bounded background images and explicit atlas retirement
- **Status:** Requested by the maintainer, 2026-09-14; implementation and validation in progress.
@@ -377,6 +433,7 @@ settings files.
equivalent ownership-aware image resources. Adopt target-size native decoding
only with verified peak accounting and compatibility evidence.
## ADR-0013 — Bounded, on-demand filename search
- **Date:** 2026-09-14
+127
View File
@@ -484,6 +484,133 @@
"admin_cancelled": "Administrator launch cancelled.",
"admin_failed": "Could not open an administrator terminal: {error}"
},
"theme_transfer": {
"import_title": "Import theme",
"export_title": "Export theme",
"import_description": "Choose theme files to preview and use as a template.",
"export_description": "Choose a format and review which settings it can include.",
"cancel": "Cancel",
"close": "Close",
"candidates": "Themes",
"diagnostics": "File details",
"completed": "Done",
"error": "Needs attention",
"export": "Export",
"exported": "Exported to",
"inspecting": "Reading theme files…",
"losses": "Settings this format omits",
"no_candidates": "No importable theme was found.",
"output_format": "Format",
"picking": "Choose files",
"preparing": "Preparing export…",
"preview": "Preview",
"template_ready": "Template loaded into editor",
"use_template": "Use as template",
"warnings": "Import notes",
"writing": "Writing file…"
},
"theme_editor": {
"title": "Customize theme",
"description": "Start with a theme and make it your own.",
"template": "Template",
"name": "Theme name",
"background": "Background",
"foreground": "Text color",
"accent": "Accent",
"cursor": "Cursor color",
"font_family": "Font",
"font_size": "Font size",
"line_height": "Line height",
"opacity": "Opacity",
"advanced": "Advanced settings",
"reset": "Reset changes",
"back": "Back",
"save_only": "Save only",
"apply": "Save and apply",
"import": "Import",
"export": "Export",
"common": "Common settings",
"preview_terminal": "Terminal",
"preview_interface": "Interface",
"preview_ansi": "ANSI colors",
"invalid_value": "Check the highlighted color or number.",
"new_name": "{name} copy",
"discard_title": "Discard unsaved changes?",
"discard_body": "Your draft has not been saved.",
"discard": "Discard changes",
"keep_editing": "Keep editing",
"loading": "Loading themes…",
"saving": "Saving…",
"library_error": "Could not load the theme library: {error}",
"save_error": "Could not save the theme: {error}",
"use_template": "Use as template",
"saved": "Saved to your themes",
"preview_only": "Changes stay in this preview until you apply them.",
"advanced_hide": "Hide advanced settings",
"ansi": "ANSI 16 colors",
"selection": "Selection and cursor text",
"selection_background": "Selection background",
"selection_foreground": "Selection text",
"cursor_text": "Cursor text",
"inherit": "Leave blank for automatic color",
"layout": "Panel layout",
"radius": "Radius",
"gutter": "Gutter",
"shadow": "Shadow",
"divider": "Divider",
"invalid_color": "The color is invalid",
"invalid_number": "The number is invalid",
"change_template_title": "Change template?",
"change_template_body": "Changing the template discards your unsaved changes.",
"change_template": "Change template",
"current_setting": "Current setting",
"live_preview": "Live preview",
"selected_line": "Selected text line",
"based_on": "Based on {name}; the original theme stays unchanged.",
"editing_status": "Theme draft",
"editing_unchanged": "Current settings are unchanged",
"editing_changed": "Preview includes unsaved changes",
"reset_short": "Reset"
},
"theme_picker": {
"title": "Choose a theme",
"all": "All",
"light": "Light",
"dark": "Dark",
"custom": "Custom",
"customize": "Customize theme",
"text_original": "Theme text color",
"text_cool": "Cool text color",
"text_warm": "Warm text color",
"text_custom": "Choose a text color",
"discard_body": "Your appearance changes have not been applied.",
"apply": "Use this theme",
"cancel": "Cancel",
"color_title": "Text color",
"hex_color": "Hex color",
"invalid_color": "Enter a color such as #aabbcc.",
"preview_hint": "Text colors update this preview. Apply to use them in your terminal.",
"loading": "Loading themes…",
"empty": "Your saved themes will appear here.",
"save_error": "Could not apply the theme: {error}",
"manual_hint": "Apply to use these colors and turn off Follow system.",
"preview_ready": "Ready for your next idea.",
"apply_icon": "Use this icon",
"icon_title": "Choose app icon",
"description": "Find a comfortable palette. Preview, then apply.",
"icon_description": "The same identity, in your favorite color.",
"selected": "Selected",
"text_suggestion": "Enter a color or choose a suggestion",
"color_hint": "The preview updates live. Apply the theme to use it in your terminal.",
"saturation_value": "Saturation and value",
"hue": "Hue",
"theme_count": "{count} themes",
"icon_count": "{count} colors",
"neutral": "Neutral",
"blue": "Blue",
"violet": "Violet",
"green": "Green"
},
"wallpaper": {
"load_failed": "Could not load the background image. Choose a readable PNG, JPEG, WebP, GIF or BMP image.",
"too_large": "The background image exceeds the memory limit. Choose a smaller image."
+127
View File
@@ -484,6 +484,133 @@
"admin_cancelled": "已取消管理员启动。",
"admin_failed": "无法打开管理员终端:{error}"
},
"theme_transfer": {
"import_title": "导入主题",
"export_title": "导出主题",
"import_description": "选择主题文件,预览后作为模板。",
"export_description": "选择格式,并查看它能够保留的设置。",
"cancel": "取消",
"close": "关闭",
"candidates": "主题",
"diagnostics": "文件说明",
"completed": "完成",
"error": "需要处理",
"export": "导出",
"exported": "已导出到",
"inspecting": "正在读取主题文件…",
"losses": "此格式不会保留的设置",
"no_candidates": "没有找到可导入的主题。",
"output_format": "格式",
"picking": "选择文件",
"preparing": "正在准备导出…",
"preview": "预览",
"template_ready": "模板已载入编辑器",
"use_template": "作为模板",
"warnings": "导入说明",
"writing": "正在写入文件…"
},
"theme_editor": {
"title": "自定义主题",
"description": "从现有主题开始,调整成自己的配色。",
"template": "模板",
"name": "主题名称",
"background": "终端背景",
"foreground": "文字颜色",
"accent": "强调色",
"cursor": "光标颜色",
"font_family": "字体",
"font_size": "字号",
"line_height": "行高",
"opacity": "不透明度",
"advanced": "高级设置",
"reset": "重置修改",
"back": "返回",
"save_only": "仅保存",
"apply": "保存并应用",
"import": "导入",
"export": "导出",
"common": "常用设置",
"preview_terminal": "终端",
"preview_interface": "界面",
"preview_ansi": "ANSI 色彩",
"invalid_value": "请检查标出的颜色或数值。",
"new_name": "{name} · 我的配色",
"discard_title": "放弃未保存修改?",
"discard_body": "当前草稿还没有保存。",
"discard": "放弃修改",
"keep_editing": "继续编辑",
"loading": "正在读取主题…",
"saving": "正在保存…",
"library_error": "无法读取主题库:{error}",
"save_error": "无法保存主题:{error}",
"use_template": "作为模板",
"saved": "已保存到自定义主题",
"preview_only": "改动仅在此处预览,应用后才会改变终端。",
"advanced_hide": "收起高级设置",
"ansi": "ANSI 16 色",
"selection": "选区与光标文字",
"selection_background": "选区背景",
"selection_foreground": "选区文字",
"cursor_text": "光标文字",
"inherit": "留空使用自动颜色",
"layout": "卡片布局",
"radius": "圆角",
"gutter": "留缝",
"shadow": "阴影",
"divider": "分割线",
"invalid_color": "颜色格式不正确",
"invalid_number": "数值格式不正确",
"change_template_title": "更换模板?",
"change_template_body": "更换模板会丢弃当前未保存的改动。",
"change_template": "更换模板",
"current_setting": "当前设置",
"live_preview": "实时预览",
"selected_line": "已选中的文字行",
"based_on": "基于{name},原主题不会被修改。",
"editing_status": "主题草稿",
"editing_unchanged": "当前设置尚未改变",
"editing_changed": "预览包含尚未保存的修改",
"reset_short": "重置"
},
"theme_picker": {
"title": "选择主题",
"all": "全部",
"light": "浅色",
"dark": "深色",
"custom": "自定义",
"customize": "自定义主题",
"text_original": "原主题文字",
"text_cool": "推荐冷色文字",
"text_warm": "推荐暖色文字",
"text_custom": "自定义文字颜色",
"discard_body": "外观修改还没有应用。",
"apply": "使用此主题",
"cancel": "取消",
"color_title": "文字颜色",
"hex_color": "十六进制颜色",
"invalid_color": "请输入形如 #aabbcc 的颜色。",
"preview_hint": "文字颜色实时预览,应用后才会改变终端。",
"loading": "正在读取主题…",
"empty": "保存的自定义主题会显示在这里。",
"save_error": "无法应用主题:{error}",
"manual_hint": "点击应用后使用这些颜色,并关闭「跟随系统」。",
"preview_ready": "准备好开始新的工作。",
"apply_icon": "使用此图标",
"icon_title": "选择应用图标",
"description": "找到舒服的配色,先预览,再应用。",
"icon_description": "保留同一个标识,找到喜欢的配色。",
"selected": "已选择",
"text_suggestion": "输入颜色或选择推荐色",
"color_hint": "颜色会实时更新预览;应用主题后才用于终端。",
"saturation_value": "饱和度和明度",
"hue": "色相",
"theme_count": "{count} 款主题",
"icon_count": "{count} 款配色",
"neutral": "中性",
"blue": "蓝色",
"violet": "紫色",
"green": "青绿"
},
"wallpaper": {
"load_failed": "无法加载背景图。请选择可正常读取的 PNG、JPEG、WebP、GIF 或 BMP 图片。",
"too_large": "背景图超出内存限制,请选择尺寸更小的图片。"
+1
View File
@@ -793,6 +793,7 @@ pub(crate) struct CardInk {
pub(crate) fg: Rgb,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct NebulaPalette {
pub(crate) panel: Rgba,
/// Standalone fill for inactive tab rows / the "+" pill. Currently unpainted
@@ -47,7 +47,7 @@ impl SettingsPane {
if let Some(rgb) = self.runtime.background {
return rgb;
}
let term = crate::gpui_shell::theme::chrome_theme_resolved(cx).palette().term_bg;
let term = crate::gpui_shell::theme::resolved_palette(cx).term_bg;
[term.r, term.g, term.b]
}
@@ -217,7 +217,7 @@ impl SettingsPane {
format!("{} {hex}", language.pick("主题默认", "Theme default")).into()
};
let open = self.bg_picker_open;
let sk = crate::gpui_shell::theme::chrome_theme_resolved(cx).skin();
let sk = crate::gpui_shell::theme::resolved_skin(cx);
let accent = super::rgb_hsla(sk.accent.r, sk.accent.g, sk.accent.b);
let ink_dim = super::rgb_hsla(sk.ink_dim.r, sk.ink_dim.g, sk.ink_dim.b);
let hairline = cx.theme().border;
@@ -320,7 +320,7 @@ impl SettingsPane {
fn background_picker_panel(&mut self, cx: &mut Context<Self>) -> gpui::Div {
let (h0, s0, v0) = self.bg_picker_hsv;
let (pw, _) = popup_size();
let sk = crate::gpui_shell::theme::chrome_theme_resolved(cx).skin();
let sk = crate::gpui_shell::theme::resolved_skin(cx);
let hairline = cx.theme().border;
let accent = super::rgb_hsla(sk.accent.r, sk.accent.g, sk.accent.b);
let ink_dim = super::rgb_hsla(sk.ink_dim.r, sk.ink_dim.g, sk.ink_dim.b);
@@ -489,7 +489,7 @@ fn rgb_paint(color: Rgb) -> gpui::Hsla {
.into()
}
fn paint_sv(window: &mut Window, bounds: Bounds<Pixels>, h0: f32, s0: f32, v0: f32) {
pub(super) fn paint_sv(window: &mut Window, bounds: Bounds<Pixels>, h0: f32, s0: f32, v0: f32) {
let origin = bounds.origin;
let svw = f32::from(bounds.size.width);
let svh = f32::from(bounds.size.height);
@@ -548,7 +548,7 @@ fn paint_sv(window: &mut Window, bounds: Bounds<Pixels>, h0: f32, s0: f32, v0: f
);
}
fn paint_hue(window: &mut Window, bounds: Bounds<Pixels>, h0: f32) {
pub(super) fn paint_hue(window: &mut Window, bounds: Bounds<Pixels>, h0: f32) {
let origin = bounds.origin;
let huw = f32::from(bounds.size.width);
let huh = f32::from(bounds.size.height);
+196 -13
View File
@@ -17,9 +17,10 @@
//! - 配置热重载(主应用用 notify 监视;本壳目前启动读一次)
use std::path::{Path, PathBuf};
use std::sync::Arc;
use gpui::{App, Global};
use nebula_settings::{CursorShapeName, RuntimeSettings};
use nebula_settings::{BlurModeName, CursorShapeName, RuntimeSettings};
use nebula_terminal::vte::ansi::CursorShape;
use serde::Deserialize;
@@ -54,7 +55,17 @@ pub struct Settings {
/// 域参与取整,才能在 125%/150% DPI 下保持同一行数。
pub font_offset_x: f32,
pub font_offset_y: f32,
/// Optional theme line-height multiplier. `None` keeps the shaped font
/// metrics, while `Some(multiplier)` is resolved as multiplier * font size.
pub(crate) theme_line_height: Option<f32>,
/// Effective window material values after user settings and theme defaults
/// have been merged once during settings loading.
pub(crate) visual_opacity: f32,
pub(crate) visual_blur: BlurModeName,
pub palette: Palette,
/// Fully resolved theme snapshot. Renderers use the derived palette above;
/// chrome helpers use this cache so they never reload theme files per frame.
pub(crate) resolved_theme: Arc<crate::gpui_shell::theme::ResolvedTheme>,
pub cursor_shape: Option<CursorShape>,
pub cursor_blink: Option<bool>,
/// 选区完成即复制(旧壳 `copy_on_select` 设置)。
@@ -106,15 +117,64 @@ fn resolve_ui_language(preference: nebula_settings::LanguagePref) -> UiLanguage
LanguagePreference::from(preference).resolved()
}
fn effective_font_sizes(
runtime_font_size_px: Option<f32>,
theme_font_size_px: Option<f32>,
toml_font_size_pt: Option<f32>,
) -> (f32, f32) {
let theme_font_size_px = theme_font_size_px.map(|size| size.clamp(4.0, 96.0));
let base_font_size_px = theme_font_size_px
.unwrap_or_else(|| toml_font_size_pt.unwrap_or(11.25).clamp(4.0, 96.0) * 4.0 / 3.0);
let font_size_px = runtime_font_size_px.or(theme_font_size_px).unwrap_or(base_font_size_px);
(font_size_px, base_font_size_px)
}
fn effective_theme_line_height(
resolved_theme: &crate::gpui_shell::theme::ResolvedTheme,
) -> Option<f32> {
resolved_theme
.typography()
.and_then(|typography| typography.line_height)
.map(|line_height| line_height.clamp(0.5, 3.0))
}
impl Settings {
/// Load the latest persisted runtime snapshot and derive the effective
/// theme from that same snapshot.
pub(crate) fn load_current(cx: &App) -> Self {
Self::load_current_snapshot(cx).1
}
/// Variant for callers that also keep a runtime mirror. Returning both
/// values prevents the mirror and global settings from being split by two
/// adjacent disk reads.
pub(crate) fn load_current_snapshot(cx: &App) -> (RuntimeSettings, Self) {
let runtime = RuntimeSettings::load();
let theme = crate::gpui_shell::theme::resolve_theme_name(
runtime.theme,
runtime.follow_system_theme,
crate::gpui_shell::theme::system_is_light(cx),
);
let settings = Self::load_with_runtime(theme, runtime.clone());
(runtime, settings)
}
/// `theme`:**生效**主题(follow_system 折算后,见
/// `theme::effective_theme_name`)。不在这里自行读 RuntimeSettings 的
/// 原始主题,否则 chrome 层与终端 palette 会在跟随系统时分家。
pub fn load(theme: nebula_settings::ThemeName) -> Self {
let runtime = RuntimeSettings::load();
Self::load_with_runtime(theme, RuntimeSettings::load())
}
/// Load from the exact runtime snapshot that was just persisted. Settings
/// panes use this entry point to avoid resolving the old theme between the
/// write and the global cache update.
pub fn load_with_runtime(theme: nebula_settings::ThemeName, runtime: RuntimeSettings) -> Self {
let resolved_theme =
Arc::new(crate::gpui_shell::theme::ResolvedTheme::from_runtime(&runtime, theme));
let ui_language = resolve_ui_language(runtime.language);
let path = find_config_file();
let mut load_notice = None;
let mut load_notice = resolved_theme.notice.clone();
let raw = path
.as_deref()
.map(|p| load_merged_toml(p, &mut load_notice, ui_language))
@@ -136,9 +196,12 @@ impl Settings {
};
// 字体:settings.txt(设置界面)覆盖 toml,最后落内置默认。
let themed_font_family =
resolved_theme.typography().and_then(|typography| typography.font_family.clone());
let normal_family = runtime
.font_family
.clone()
.or(themed_font_family)
.or_else(|| raw.font.normal.family.clone())
.unwrap_or_else(|| default_font_family().to_string());
let secondary = |desc: &RawFontDesc| -> String {
@@ -147,20 +210,33 @@ impl Settings {
// 字号语义(对齐旧壳写盘):settings.txt 的 font_size 是**逻辑像素**
// (设置 spinner/Ctrl+滚轮持久化时已除 scale);toml 的 font.size
// 才是 pt(1pt = 4/3 px @96dpi)。
let base_font_size_px = raw.font.size.unwrap_or(11.25).clamp(4.0, 96.0) * 4.0 / 3.0;
let font_size_px = runtime.font_size_px.unwrap_or(base_font_size_px);
let theme_font_size_px =
resolved_theme.typography().and_then(|typography| typography.font_size);
let (font_size_px, base_font_size_px) =
effective_font_sizes(runtime.font_size_px, theme_font_size_px, raw.font.size);
let offset = raw.font.offset.unwrap_or_else(default_font_offset);
let theme_line_height = effective_theme_line_height(&resolved_theme);
let visual_opacity = resolved_theme.effective_opacity(&runtime);
let visual_blur = resolved_theme.effective_blur(&runtime);
// 配色:toml 覆盖内置默认,主题裁定背景/浅色替换/Powerline 槽位。
// 跟随系统时由当前亮/暗主题全权决定终端底色;否则用户取色器压轴。
let mut palette = build_palette(&raw.colors);
apply_theme(&mut palette, theme);
apply_resolved_theme(&mut palette, &resolved_theme);
if let Some(background) =
runtime_background(runtime.follow_system_theme, runtime.background)
{
palette.background = rgba8(background);
}
let cursor_shape = runtime
.cursor_shape
.or_else(|| resolved_theme.effects().and_then(|effects| effects.cursor_shape));
let card = crate::gpui_shell::theme::PaneCardStyle::resolve_for_resolved_theme(
&resolved_theme,
&runtime,
);
Settings {
ui_language,
font_bold_family: secondary(&raw.font.bold),
@@ -170,8 +246,12 @@ impl Settings {
base_font_size_px,
font_offset_x: f32::from(offset.x),
font_offset_y: f32::from(offset.y),
theme_line_height,
visual_opacity,
visual_blur,
palette,
cursor_shape: runtime.cursor_shape.map(|shape| match shape {
resolved_theme,
cursor_shape: cursor_shape.map(|shape| match shape {
CursorShapeName::Block => CursorShape::Block,
CursorShapeName::Beam => CursorShape::Beam,
CursorShapeName::Underline => CursorShape::Underline,
@@ -204,7 +284,7 @@ impl Settings {
// 这里是唯一能正确合并「主题自带几何」与「用户显式覆盖」的地方:
// `theme` 已是 follow_system 折算后的**生效**主题,runtime 是同一次
// 装载读到的设置。别处再读一遍就会在跟随系统时和 chrome 分家。
card: crate::gpui_shell::theme::PaneCardStyle::resolve(theme, &runtime),
card,
}
}
@@ -254,13 +334,45 @@ fn runtime_background(
/// 背景永远替换;Powerline 槽位 16..=23 替换。原有主题保持旧合同:仅浅色
/// 主题替换前景与 ANSI-16;自带完整 palette 的主题(Nord/Paper)应用其明确色表。
fn apply_theme(palette: &mut Palette, theme: nebula_settings::ThemeName) {
let term = theme.term_theme();
let resolved = crate::gpui_shell::theme::ResolvedTheme::builtin(theme, None);
apply_resolved_theme(palette, &resolved);
}
fn apply_resolved_theme(palette: &mut Palette, resolved: &crate::gpui_shell::theme::ResolvedTheme) {
let term = resolved.base_name().term_theme();
if resolved.is_custom() {
palette.background = rgba8(resolved.terminal_background());
palette.foreground = rgba8(resolved.terminal_foreground());
palette.bright_foreground = palette.foreground;
palette.dim_foreground = Palette::dim_of(palette.foreground);
for (index, color) in resolved.ansi().into_iter().enumerate() {
palette.ansi[index] = rgba8(color);
if index < 8 {
palette.dim[index] = Palette::dim_of(palette.ansi[index]);
}
}
palette.cursor = resolved.terminal_cursor().map(rgba8).unwrap_or(palette.cursor);
palette.cursor_text = resolved.terminal_cursor_text().map(rgba8);
palette.cursor_stroke = resolved.terminal_cursor_stroke().map(rgba8);
let (selection_background, selection_foreground) = resolved.terminal_selection();
if let Some(selection) = selection_background {
palette.selection = rgba8(selection);
}
palette.selection_foreground = selection_foreground.map(rgba8);
palette.indexed.retain(|(index, _)| *index < 16);
palette.indexed.extend(
(16..=255).filter_map(|index| {
resolved.indexed(index).map(|color| (index as u8, rgba8(color)))
}),
);
return;
}
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 let Some(exact) = term.exact {
let foreground = rgba8(exact.foreground);
let foreground = rgba8(resolved.foreground_override().unwrap_or(exact.foreground));
palette.foreground = foreground;
palette.bright_foreground = foreground;
palette.dim_foreground = Palette::dim_of(foreground);
@@ -273,6 +385,7 @@ fn apply_theme(palette: &mut Palette, theme: nebula_settings::ThemeName) {
if let Some(cursor) = exact.cursor {
palette.cursor = rgba8(cursor);
}
palette.cursor_text = exact.cursor_text.map(rgba8);
palette.cursor_stroke = exact.cursor_stroke.map(rgba8);
if let Some(selection) = exact.selection_background {
palette.selection = rgba8(selection);
@@ -281,9 +394,16 @@ fn apply_theme(palette: &mut Palette, theme: nebula_settings::ThemeName) {
return;
}
if term.is_light {
palette.foreground = rgba8(nebula_settings::LIGHT_FOREGROUND);
let foreground =
rgba8(resolved.foreground_override().unwrap_or(nebula_settings::LIGHT_FOREGROUND));
palette.foreground = foreground;
palette.bright_foreground = foreground;
palette.dim_foreground = Palette::dim_of(foreground);
for (i, color) in nebula_settings::LIGHT_ANSI.into_iter().enumerate() {
palette.ansi[i] = rgba8(color);
if i < 8 {
palette.dim[i] = Palette::dim_of(palette.ansi[i]);
}
}
}
}
@@ -618,10 +738,15 @@ fn build_palette(raw: &RawColors) -> Palette {
#[cfg(test)]
mod tests {
use super::{Settings, apply_theme, resolve_ui_language, rgba8, runtime_background};
use super::{
Settings, apply_resolved_theme, apply_theme, effective_font_sizes,
effective_theme_line_height, resolve_ui_language, rgba8, runtime_background,
};
use crate::display::UiLanguage;
use crate::gpui_shell::terminal::colors::Palette;
use nebula_settings::{LanguagePref, ThemeName};
use nebula_settings::{
BlurModeName, LanguagePref, RawSettings, RuntimeSettings, ThemeDefinition, ThemeName,
};
#[test]
fn default_terminal_font_is_bundled_on_every_platform() {
@@ -673,6 +798,7 @@ mod tests {
assert_eq!(nord.foreground, rgba8([0xf1, 0xf6, 0xff]));
assert_eq!(nord.ansi[15], rgba8([0xec, 0xef, 0xf4]));
assert_eq!(nord.cursor, rgba8([0xe5, 0xe9, 0xf0]));
assert_eq!(nord.cursor_text, Some(rgba8([0x2e, 0x34, 0x40])));
assert_eq!(nord.cursor_stroke, Some(rgba8([0x88, 0xc0, 0xd0])));
assert_eq!(nord.selection, rgba8([0xe5, 0xe9, 0xf0]));
assert_eq!(nord.selection_foreground, Some(rgba8([0x2e, 0x34, 0x40])));
@@ -684,7 +810,64 @@ mod tests {
assert_eq!(paper.foreground, rgba8([0x1a, 0x1a, 0x1a]));
assert_eq!(paper.ansi[15], rgba8([0x2f, 0x2e, 0x2e]));
assert_eq!(paper.cursor, original_cursor);
assert_eq!(paper.cursor_text, None);
assert_eq!(paper.selection, original_selection);
assert_eq!(paper.selection_foreground, None);
}
#[test]
fn theme_foreground_override_updates_all_foreground_slots() {
let mut palette = Palette::default();
let resolved =
crate::gpui_shell::theme::ResolvedTheme::builtin(ThemeName::Nord, Some([1, 2, 3]));
apply_resolved_theme(&mut palette, &resolved);
let foreground = rgba8([1, 2, 3]);
assert_eq!(palette.foreground, foreground);
assert_eq!(palette.bright_foreground, foreground);
assert_eq!(palette.dim_foreground, Palette::dim_of(foreground));
}
#[test]
fn theme_font_size_is_used_until_runtime_font_size_is_explicit() {
assert_eq!(effective_font_sizes(None, Some(18.0), Some(12.0)), (18.0, 18.0));
assert_eq!(effective_font_sizes(Some(20.0), Some(18.0), Some(12.0)), (20.0, 18.0));
assert_eq!(effective_font_sizes(None, None, Some(12.0)), (16.0, 16.0));
}
#[test]
fn custom_theme_line_height_is_clamped_once_for_runtime_consumers() {
let mut definition = ThemeDefinition::from_builtin(ThemeName::Nord);
definition.typography.line_height = Some(1.5);
let resolved = crate::gpui_shell::theme::ResolvedTheme::custom(definition, None, None);
assert_eq!(effective_theme_line_height(&resolved), Some(1.5));
}
#[test]
fn theme_material_defaults_yield_to_explicit_runtime_values() {
let mut definition = ThemeDefinition::from_builtin(ThemeName::Nord);
definition.effects.opacity = Some(0.72);
definition.effects.blur = Some(BlurModeName::Mica);
let resolved = crate::gpui_shell::theme::ResolvedTheme::custom(definition, None, None);
let absent = RuntimeSettings::from_raw(&RawSettings::default());
assert!((resolved.effective_opacity(&absent) - 0.72).abs() < f32::EPSILON);
assert_eq!(resolved.effective_blur(&absent), BlurModeName::Mica);
let explicit =
RuntimeSettings::from_raw(&RawSettings::from_text("opacity=1.0\nblur=none\n"));
assert!((resolved.effective_opacity(&explicit) - 1.0).abs() < f32::EPSILON);
assert_eq!(resolved.effective_blur(&explicit), BlurModeName::None);
}
#[test]
fn missing_theme_materials_fall_back_to_runtime_defaults() {
let resolved = crate::gpui_shell::theme::ResolvedTheme::builtin(ThemeName::Nord, None);
let runtime = RuntimeSettings::from_raw(&RawSettings::default());
assert!((resolved.effective_opacity(&runtime) - 1.0).abs() < f32::EPSILON);
assert_eq!(resolved.effective_blur(&runtime), BlurModeName::None);
}
}
+12 -6
View File
@@ -173,15 +173,21 @@ fn init(cx: &mut App) {
// 网络图片加载:gpui 默认 NullHttpClient,markdown 文档里的 http(s)
// 图源全部失败;换成 ureq 实现(跑在后台 executor)。
http::register(cx);
// Read one runtime snapshot before installing any renderer. The resolved
// custom theme, terminal palette and chrome therefore start from the same
// immutable value and cannot diverge during bootstrap.
let runtime = nebula_settings::RuntimeSettings::load();
let theme = theme::resolve_theme_name(
runtime.theme,
runtime.follow_system_theme,
theme::system_is_light(cx),
);
let settings = config::Settings::load_with_runtime(theme, runtime);
gpui_component::set_locale(settings.ui_language.gpui_component_locale());
cx.set_global(settings);
theme::apply_chrome_theme(cx);
terminal::init(cx);
workspace::init(cx);
// 用户 nebula.toml + nebula_settings.txt,启动读一次;失败回退默认,
// 具体错误由 config 的 notice 上浮,并在可用时写入 stderr。
let settings = config::Settings::load(theme::effective_theme_name(cx));
gpui_component::set_locale(settings.ui_language.gpui_component_locale());
cx.set_global(settings);
toast::init(cx);
}
+49 -5
View File
@@ -24,7 +24,7 @@ use gpui::{
};
use gpui_component::input::InputEvent;
use gpui_component::select::{SelectEvent, SelectItem};
use nebula_settings::{RuntimeSettings, ThemeName, format_hex_rgb, persist_keys};
use nebula_settings::{RuntimeSettings, ThemeName, format_hex_rgb, parse_hex_rgb, persist_keys};
use design::GROUP_GAP;
use setting_help::{SettingHelp, help};
@@ -60,6 +60,13 @@ mod shell_picker;
#[cfg(all(test, feature = "gpui-test-support"))]
mod shell_picker_tests;
mod status;
mod theme_advanced;
mod theme_editor;
mod theme_foreground;
#[cfg(all(test, feature = "gpui-test-support"))]
mod theme_studio_tests;
mod theme_transfer;
mod theme_transfer_view;
use localization::*;
use navigation::*;
@@ -92,6 +99,10 @@ pub struct SettingsPane {
/// 当前分区(`SECTIONS` 下标);默认落在应用主页。
active_section: usize,
appearance_picker: Option<appearance_picker::AppearancePicker>,
appearance_picker_seq: u64,
pub(super) theme_editor: Option<theme_editor::ThemeEditor>,
theme_editor_seq: u64,
pub(super) theme_transfer: theme_transfer::ThemeTransferState,
theme_picker_trigger: FocusHandle,
icon_picker_trigger: FocusHandle,
expanded_setting_help: std::collections::HashSet<&'static str>,
@@ -116,6 +127,12 @@ pub struct SettingsPane {
bg_picker_trigger_bounds: Option<gpui::Bounds<gpui::Pixels>>,
bg_sv_bounds: Option<gpui::Bounds<gpui::Pixels>>,
bg_hue_bounds: Option<gpui::Bounds<gpui::Pixels>>,
/// Text-color input used by the theme picker custom swatch. It is kept as
/// a normal entity so the dialog can update the preview while typing;
/// nothing is persisted until the outer theme picker is applied.
pub(super) theme_foreground_input: Entity<InputState>,
pub(super) theme_foreground_input_syncing: bool,
pub(super) theme_foreground_picker: theme_foreground::ThemeForegroundState,
opacity_slider: Entity<SliderState>,
wallpaper_opacity_slider: Entity<SliderState>,
pub(super) proxy_url_input: Entity<InputState>,
@@ -225,10 +242,34 @@ impl SettingsPane {
cx: &mut Context<Self>,
) -> std::io::Result<()> {
persist_keys(updates)?;
self.runtime = RuntimeSettings::load();
let settings = crate::gpui_shell::config::Settings::load(
crate::gpui_shell::theme::effective_theme_name(cx),
self.apply_persisted_runtime(updates, cx);
Ok(())
}
/// Persist a related group of preferences against the bytes observed just
/// before the editor action. Theme application uses this boundary so an
/// external settings writer cannot be silently overwritten after the
/// theme document has been saved.
pub(super) fn try_persist_checked(
&mut self,
updates: &[(&str, String)],
cx: &mut Context<Self>,
) -> std::io::Result<()> {
let revision = crate::theme_library::preferences::load()?;
crate::theme_library::preferences::save(&revision, updates)?;
self.apply_persisted_runtime(updates, cx);
Ok(())
}
fn apply_persisted_runtime(&mut self, updates: &[(&str, String)], cx: &mut Context<Self>) {
let runtime = RuntimeSettings::load();
let theme = crate::gpui_shell::theme::resolve_theme_name(
runtime.theme,
runtime.follow_system_theme,
crate::gpui_shell::theme::system_is_light(cx),
);
self.runtime = runtime.clone();
let settings = crate::gpui_shell::config::Settings::load_with_runtime(theme, runtime);
gpui_component::set_locale(settings.ui_language.gpui_component_locale());
cx.set_global(settings);
if updates.iter().any(|(key, _)| matches!(*key, "ssh_proxy_mode" | "ssh_proxy_url")) {
@@ -236,7 +277,6 @@ impl SettingsPane {
}
cx.emit(SettingsPaneEvent::Changed);
cx.notify();
Ok(())
}
/// 语言切换不重建输入/下拉实体:重建会丢焦点、编辑值、undo 和订阅。
@@ -1326,6 +1366,8 @@ impl Render for SettingsPane {
let bg_dragging = self.bg_picker_drag.is_some();
let ssh_editor_modal = self.ssh_editor_modal(window, cx);
let appearance_picker_modal = self.appearance_picker_modal(window, cx);
let theme_editor_modal = self.theme_editor_modal(window, cx);
let theme_transfer_modal = self.theme_transfer_modal(window, cx);
let application_page = self.active_section == 0;
let header = self.render_search_header(window, cx);
@@ -1437,6 +1479,8 @@ impl Render for SettingsPane {
)
.when_some(ssh_editor_modal, |root, modal| root.child(modal))
.when_some(appearance_picker_modal, |root, modal| root.child(modal))
.when_some(theme_editor_modal, |root, modal| root.child(modal))
.when_some(theme_transfer_modal, |root, modal| root.child(modal))
.when(font_picker_open, |root| {
root
// 搜索框是当前焦点时 Escape 仍沿元素树冒泡到设置根;
@@ -24,12 +24,16 @@ impl SettingsPane {
) -> gpui::AnyElement {
let language = crate::gpui_shell::config::ui_language(cx);
let colors = AppearanceColors::current(cx);
let name = crate::gpui_shell::theme::effective_theme_name(cx);
let resolved = crate::gpui_shell::theme::resolved_theme(cx);
let name = resolved.base_name();
let icon = crate::app_icon::selected();
let label = if theme {
chrome_theme(name).short_label()
let label: SharedString = if theme {
resolved
.definition()
.map(|definition| definition.name.clone().into())
.unwrap_or_else(|| chrome_theme(name).short_label().into())
} else {
language.pick(icon.palette().name_zh, icon.palette().name_en)
language.pick(icon.palette().name_zh, icon.palette().name_en).into()
};
let action = if theme {
language.pick("更换主题", "Change theme")
@@ -38,15 +42,30 @@ impl SettingsPane {
};
let focus = if theme { &self.theme_picker_trigger } else { &self.icon_picker_trigger };
let sample = if theme {
div()
.size(px(45.0))
.flex_shrink_0()
.child(super::theme_picker::theme_sample(name, true, false))
let thumbnail = if let Some(definition) = resolved.definition() {
super::theme_picker::theme_definition_sample(
definition,
Some(resolved.terminal_foreground()),
true,
false,
)
} else {
super::theme_picker::theme_sample_with_foreground(
name,
resolved.foreground_override(),
true,
false,
)
};
div().size(px(45.0)).flex_shrink_0().child(thumbnail)
} else {
super::app_icon::icon_image(icon, 45.0, window)
};
h_flex()
.id(if theme { "open-theme-picker" } else { "open-icon-picker" })
.debug_selector(move || {
if theme { "open-theme-picker" } else { "open-icon-picker" }.to_owned()
})
.track_focus(&focus.clone().tab_stop(true))
.role(gpui::accesskit::Role::Button)
.aria_label(format!("{action}: {label}"))
@@ -1,4 +1,5 @@
use super::*;
use crate::i18n::Message;
use gpui::accesskit::{Role, Toggled};
use gpui_component::FocusTrapElement as _;
use nebula_settings::AppIconName;
@@ -6,17 +7,22 @@ use nebula_settings::AppIconName;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum AppearanceSelection {
Theme(ThemeName),
/// Index into the picker-owned custom theme snapshot. The index is local
/// to one dialog session, so the selection remains Copy without storing
/// filesystem strings inside every focus callback.
Custom(usize),
Icon(AppIconName),
}
impl AppearanceSelection {
pub(super) fn is_theme(self) -> bool {
matches!(self, Self::Theme(_))
matches!(self, Self::Theme(_) | Self::Custom(_))
}
pub(super) fn label(self, language: crate::display::UiLanguage) -> &'static str {
match self {
Self::Theme(theme) => chrome_theme(theme).short_label(),
Self::Custom(_) => language.text(Message::ThemePickerCustomize),
Self::Icon(icon) => language.pick(icon.palette().name_zh, icon.palette().name_en),
}
}
@@ -26,17 +32,19 @@ impl AppearanceSelection {
Self::Theme(theme) => {
crate::gpui_shell::theme::theme_card_persist_updates(theme).to_vec()
},
Self::Custom(_) => Vec::new(),
Self::Icon(icon) => vec![("app_icon", icon.settings_value().to_owned())],
}
}
pub(super) fn choices(self, filter: usize) -> Vec<Self> {
match self {
Self::Theme(_) => super::theme_picker::THEME_ORDER
Self::Theme(_) | Self::Custom(_) => super::theme_picker::THEME_ORDER
.into_iter()
.filter(|theme| match filter {
1 => chrome_theme(*theme).palette().is_light,
2 => !chrome_theme(*theme).palette().is_light,
3 => false,
_ => true,
})
.map(Self::Theme)
@@ -52,11 +60,90 @@ impl AppearanceSelection {
pub(super) struct AppearancePicker {
pub(super) draft: AppearanceSelection,
pub(super) initial_draft: AppearanceSelection,
pub(super) filter: usize,
pub(super) dark_preview: bool,
pub(super) focus: FocusHandle,
pub(super) options: Vec<(AppearanceSelection, FocusHandle)>,
pub(super) custom_themes: Vec<crate::theme_library::ThemeDocument>,
pub(super) custom_definitions: Vec<nebula_settings::ThemeDefinition>,
pub(super) foreground_focus: Vec<FocusHandle>,
pub(super) custom_loading: bool,
pub(super) custom_load_error: Option<String>,
pub(super) apply_busy: bool,
custom_load_seq: u64,
preference_revision: Result<crate::theme_library::preferences::PreferenceRevision, String>,
draft_touched: bool,
pub(super) error: Option<String>,
/// A picker-only terminal foreground override. `None` means use the
/// selected theme's declared foreground; it is persisted only on apply.
pub(super) foreground_override: Option<nebula_settings::Rgb8>,
pub(super) initial_foreground_override: Option<nebula_settings::Rgb8>,
}
impl AppearancePicker {
pub(super) fn set_foreground_preview(&mut self, foreground: Option<[u8; 3]>) {
self.draft_touched = true;
self.foreground_override = foreground;
self.error = None;
}
pub(super) fn choices(&self) -> Vec<AppearanceSelection> {
let mut choices = self.draft.choices(self.filter);
if self.draft.is_theme() {
choices.extend(
self.custom_themes
.iter()
.enumerate()
.filter(|(_, document)| match self.filter {
1 => document.appearance() == "light",
2 => document.appearance() == "dark",
3 => true,
_ => true,
})
.map(|(index, _)| AppearanceSelection::Custom(index)),
);
}
choices
}
pub(super) fn choice_label(
&self,
choice: AppearanceSelection,
language: crate::display::UiLanguage,
) -> String {
match choice {
AppearanceSelection::Custom(index) => self
.custom_themes
.get(index)
.map(|document| document.name().to_owned())
.unwrap_or_else(|| language.text(Message::ThemePickerCustomize).to_owned()),
_ => choice.label(language).to_owned(),
}
}
pub(super) fn custom_document(
&self,
choice: AppearanceSelection,
) -> Option<&crate::theme_library::ThemeDocument> {
match choice {
AppearanceSelection::Custom(index) => self.custom_themes.get(index),
_ => None,
}
}
pub(super) fn default_foreground(
&self,
choice: AppearanceSelection,
) -> Option<nebula_settings::Rgb8> {
match choice {
AppearanceSelection::Theme(theme) => Some(super::theme_picker::theme_foreground(theme)),
AppearanceSelection::Custom(index) => {
self.custom_definitions.get(index).map(|definition| definition.terminal.foreground)
},
AppearanceSelection::Icon(_) => None,
}
}
}
#[derive(Clone, Copy)]
@@ -76,7 +163,7 @@ pub(super) struct AppearanceColors {
impl AppearanceColors {
pub(super) fn current(cx: &App) -> Self {
let light = crate::gpui_shell::theme::chrome_theme_resolved(cx).palette().is_light;
let light = crate::gpui_shell::theme::resolved_palette(cx).is_light;
let theme = cx.theme();
Self {
surface: crate::gpui_shell::theme::settings_panel_bg(cx),
@@ -133,27 +220,119 @@ impl SettingsPane {
) {
self.close_font_picker(window, false, cx);
self.close_background_picker(cx);
self.appearance_picker_seq = self.appearance_picker_seq.wrapping_add(1);
let load_seq = self.appearance_picker_seq;
let draft = if theme {
AppearanceSelection::Theme(crate::gpui_shell::theme::effective_theme_name(cx))
} else {
AppearanceSelection::Icon(crate::app_icon::selected())
};
let foreground_override = if theme { self.runtime.theme_foreground } else { None };
let focus = cx.focus_handle();
let options =
draft.choices(0).into_iter().map(|choice| (choice, cx.focus_handle())).collect();
let choices = draft.choices(0);
let options = choices.into_iter().map(|choice| (choice, cx.focus_handle())).collect();
let foreground_focus = (0..4).map(|_| cx.focus_handle()).collect();
window.focus(&focus, cx);
self.appearance_picker = Some(AppearancePicker {
draft,
initial_draft: draft,
filter: 0,
dark_preview: false,
focus,
options,
custom_themes: Vec::new(),
custom_definitions: Vec::new(),
foreground_focus,
custom_loading: true,
custom_load_error: None,
apply_busy: false,
custom_load_seq: load_seq,
preference_revision: Err(crate::gpui_shell::config::ui_language(cx)
.text(Message::ThemePickerLoading)
.to_owned()),
draft_touched: false,
error: None,
foreground_override,
initial_foreground_override: foreground_override,
});
let executor = cx.background_executor().clone();
cx.spawn(async move |this, cx| {
let (result, revision) = executor
.spawn(async move {
let revision = crate::theme_library::preferences::load()
.map_err(|error| error.to_string());
let result = if theme {
crate::theme_library::ThemeLibraryStore::default()
.list()
.map(|snapshot| snapshot.custom)
.map_err(|error| error.to_string())
} else {
Ok(Vec::new())
};
(result, revision)
})
.await;
let _ = this.update(cx, |this, cx| {
this.finish_custom_theme_load(load_seq, result, revision, cx);
});
})
.detach();
cx.notify();
}
pub(super) fn close_appearance_picker(&mut self, window: &mut Window, cx: &mut Context<Self>) {
fn finish_custom_theme_load(
&mut self,
sequence: u64,
result: Result<Vec<crate::theme_library::ThemeDocument>, String>,
revision: Result<crate::theme_library::preferences::PreferenceRevision, String>,
cx: &mut Context<Self>,
) {
let active_custom_id = self.runtime.custom_theme.clone();
let Some(picker) = self.appearance_picker.as_mut() else { return };
if picker.custom_load_seq != sequence {
return;
}
picker.custom_loading = false;
picker.preference_revision = revision;
match result {
Ok(custom_themes) => {
picker.custom_definitions = custom_themes
.iter()
.filter_map(|document| document.definition().ok())
.collect();
picker.custom_themes = custom_themes;
picker.custom_load_error = None;
if picker.draft.is_theme() && !picker.draft_touched {
if let Some(active_id) = active_custom_id.as_deref() {
if let Some(index) = picker
.custom_themes
.iter()
.position(|document| document.id() == Some(active_id))
{
picker.draft = AppearanceSelection::Custom(index);
picker.initial_draft = picker.draft;
}
}
}
picker.options = picker
.choices()
.into_iter()
.map(|choice| (choice, cx.focus_handle()))
.collect();
},
Err(error) => {
picker.custom_load_error = Some(error.clone());
picker.error = Some(
crate::gpui_shell::config::ui_language(cx)
.format(Message::ThemeEditorLibraryError, &[("error", &error)]),
);
},
}
cx.notify();
}
fn close_appearance_picker_now(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.invalidate_theme_foreground_picker();
if let Some(picker) = self.appearance_picker.take() {
let trigger = if picker.draft.is_theme() {
&self.theme_picker_trigger
@@ -165,25 +344,171 @@ impl SettingsPane {
}
}
fn apply_appearance_selection(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(draft) = self.appearance_picker.as_ref().map(|picker| picker.draft) else {
fn appearance_picker_dirty(&self) -> bool {
self.appearance_picker.as_ref().is_some_and(|picker| {
picker.draft != picker.initial_draft
|| picker.foreground_override != picker.initial_foreground_override
})
}
/// Canceling a picker is deliberately explicit once a theme or text color
/// has been changed. The modal remains open while the confirmation dialog
/// is shown, so a mistaken click cannot lose the current draft.
pub(super) fn close_appearance_picker(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.appearance_picker.as_ref().is_some_and(|picker| picker.apply_busy) {
return;
}
if !self.appearance_picker_dirty() {
self.close_appearance_picker_now(window, cx);
return;
}
let language = crate::gpui_shell::config::ui_language(cx);
let pane = cx.entity().downgrade();
window.open_dialog(cx, move |dialog, window, _cx| {
let pane = pane.clone();
confirm_dialog(
dialog,
window,
language.text(Message::ThemeEditorDiscardTitle),
language.text(Message::ThemePickerDiscardBody),
language.text(Message::ThemeEditorDiscard),
language.text(Message::ThemeEditorKeepEditing),
ButtonVariant::Danger,
)
.on_ok(move |_, window, cx| {
let _ = pane.update(cx, |this, cx| {
this.close_appearance_picker_now(window, cx);
});
true
})
});
}
fn apply_appearance_selection(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
if self
.appearance_picker
.as_ref()
.is_none_or(|picker| picker.apply_busy || picker.custom_loading)
{
return;
}
let Some((draft, foreground_override)) = self
.appearance_picker
.as_ref()
.map(|picker| (picker.draft, picker.foreground_override))
else {
return;
};
if let Err(error) = self.try_persist(&draft.updates(), cx) {
let language = crate::gpui_shell::config::ui_language(cx);
if let Some(picker) = self.appearance_picker.as_mut() {
picker.error = Some(format!(
"{}: {error}",
language.pick("无法保存,请重试", "Could not save; please retry")
let updates = match draft {
AppearanceSelection::Custom(index) => {
let Some(document) = self
.appearance_picker
.as_ref()
.and_then(|picker| picker.custom_themes.get(index))
.cloned()
else {
return;
};
match crate::theme_library::preferences::custom_theme_updates(
&document,
foreground_override,
) {
Ok(updates) => updates,
Err(error) => {
if let Some(picker) = self.appearance_picker.as_mut() {
picker.error = Some(error.to_string());
}
cx.notify();
return;
},
}
},
AppearanceSelection::Theme(theme) => {
let mut updates = AppearanceSelection::Theme(theme).updates();
// Selecting a built-in theme leaves the custom library
// namespace; otherwise the stale custom_theme reference would
// win during the next runtime snapshot.
updates.push(("custom_theme", String::new()));
updates.push((
"theme_foreground",
foreground_override.map(nebula_settings::format_hex_rgb).unwrap_or_default(),
));
}
cx.notify();
updates
},
AppearanceSelection::Icon(icon) => AppearanceSelection::Icon(icon).updates(),
};
let session_seq = self
.appearance_picker
.as_ref()
.map(|picker| picker.custom_load_seq)
.unwrap_or_default();
if let Some(picker) = self.appearance_picker.as_mut() {
picker.apply_busy = true;
picker.error = None;
}
let revision =
self.appearance_picker.as_ref().expect("picker is open").preference_revision.clone();
let runtime_updates = updates.clone();
let executor = cx.background_executor().clone();
cx.spawn(async move |this, cx| {
let result = executor
.spawn(async move {
let revision = revision?;
crate::theme_library::preferences::save(&revision, &updates)
.map_err(|error| error.to_string())
})
.await;
let _ = this.update_in(cx, |this, window, cx| {
this.finish_appearance_apply(
session_seq,
draft,
runtime_updates,
result,
window,
cx,
);
});
})
.detach();
cx.notify();
}
fn finish_appearance_apply(
&mut self,
session_seq: u64,
draft: AppearanceSelection,
updates: Vec<(&'static str, String)>,
result: Result<crate::theme_library::preferences::PreferenceRevision, String>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let current = self
.appearance_picker
.as_ref()
.is_some_and(|picker| picker.custom_load_seq == session_seq);
if !current {
return;
}
if draft.is_theme() {
self.sync_background_color_picker(window, cx);
if let Some(picker) = self.appearance_picker.as_mut() {
picker.apply_busy = false;
}
match result {
Ok(_) => {
self.apply_persisted_runtime(&updates, cx);
if draft.is_theme() {
self.sync_background_color_picker(window, cx);
}
self.close_appearance_picker_now(window, cx);
},
Err(error) => {
let language = crate::gpui_shell::config::ui_language(cx);
if let Some(picker) = self.appearance_picker.as_mut() {
picker.error =
Some(language.format(Message::ThemePickerSaveError, &[("error", &error)]));
}
cx.notify();
},
}
self.close_appearance_picker(window, cx);
}
pub(super) fn intercept_appearance_picker(
@@ -209,7 +534,7 @@ impl SettingsPane {
cx.stop_propagation();
return;
}
let visible = picker.draft.choices(picker.filter);
let visible = picker.choices();
let current = visible.iter().position(|choice| {
picker
.options
@@ -237,8 +562,18 @@ impl SettingsPane {
cx: &mut Context<Self>,
) {
if let Some(picker) = self.appearance_picker.as_mut() {
if picker.apply_busy {
return;
}
picker.draft_touched = true;
picker.draft = choice;
picker.error = None;
if choice.is_theme() {
// A theme foreground override is scoped to the selected
// theme. Picking another template starts at that template's
// own readable foreground, just like the first color ball.
picker.foreground_override = None;
}
if let Some((_, focus)) = picker.options.iter().find(|(option, _)| *option == choice) {
window.focus(focus, cx);
}
@@ -256,16 +591,22 @@ impl SettingsPane {
) -> gpui::AnyElement {
let picker = self.appearance_picker.as_ref().unwrap();
let selected = choice == picker.draft;
let visible = picker.draft.choices(picker.filter);
let tab_choice = if visible.contains(&picker.draft) { picker.draft } else { visible[0] };
let visible = picker.choices();
let tab_choice = visible
.iter()
.copied()
.find(|choice| *choice == picker.draft)
.or_else(|| visible.first().copied())
.unwrap_or(picker.draft);
let focus = &picker.options.iter().find(|(option, _)| *option == choice).unwrap().1;
let colors = AppearanceColors::current(cx);
let language = crate::gpui_shell::config::ui_language(cx);
let label = picker.choice_label(choice, language);
div()
.id(SharedString::from(format!("appearance-option-{}", choice.label(language))))
.id(SharedString::from(format!("appearance-option-{label}")))
.track_focus(&focus.clone().tab_stop(choice == tab_choice))
.role(Role::RadioButton)
.aria_label(choice.label(language))
.aria_label(label)
.aria_toggled(if selected { Toggled::True } else { Toggled::False })
.w(px(width))
.flex_shrink_0()
@@ -290,18 +631,23 @@ impl SettingsPane {
let picker = self.appearance_picker.as_ref().unwrap();
let language = crate::gpui_shell::config::ui_language(cx);
let colors = AppearanceColors::current(cx);
let filters: &[(&'static str, &'static str)] = if picker.draft.is_theme() {
&[("全部", "All"), ("浅色", "Light"), ("深色", "Dark")]
let filters: &[Message] = if picker.draft.is_theme() {
&[
Message::ThemePickerAll,
Message::ThemePickerLight,
Message::ThemePickerDark,
Message::ThemePickerCustom,
]
} else {
&[
("全部", "All"),
("中性", "Neutral"),
("蓝色", "Blue"),
("紫色", "Violet"),
("青绿", "Green"),
Message::ThemePickerAll,
Message::ThemePickerNeutral,
Message::ThemePickerBlue,
Message::ThemePickerViolet,
Message::ThemePickerGreen,
]
};
let count = picker.draft.choices(picker.filter).len();
let count = picker.choices().len();
h_flex()
.mx(px(if compact { 19.0 } else { 27.0 }))
.pb(px(15.0))
@@ -309,19 +655,25 @@ impl SettingsPane {
.border_b_1()
.border_color(colors.line)
.flex_shrink_0()
.children(filters.iter().enumerate().map(|(index, (chinese, english))| {
.children(filters.iter().enumerate().map(|(index, message)| {
let selected = picker.filter == index;
Button::new(("appearance-filter", index))
.label(language.pick(chinese, english))
.debug_selector(move || format!("appearance-filter-{index}"))
.role(Role::RadioButton)
.toggled(selected)
.label(language.text(*message))
.ghost()
.h(px(27.0))
.px(px(if compact { 7.0 } else { 10.0 }))
.text_size(px(11.0))
.rounded(px(5.0))
.rounded_full()
.text_color(if selected { colors.ink } else { colors.secondary })
.when(selected, |button| button.bg(colors.selected).font_semibold())
.on_click(cx.listener(move |this, _, _, cx| {
if let Some(picker) = this.appearance_picker.as_mut() {
if picker.apply_busy {
return;
}
picker.filter = index;
}
cx.notify();
@@ -330,13 +682,13 @@ impl SettingsPane {
.when(!compact, |filters| {
filters.child(
div().flex_1().text_right().text_size(px(10.5)).text_color(colors.muted).child(
format!(
"{count} {}",
language.format(
if picker.draft.is_theme() {
language.pick("款主题", "themes")
Message::ThemePickerThemeCount
} else {
language.pick("款配色", "colors")
}
Message::ThemePickerIconCount
},
&[("count", &count.to_string())],
),
),
)
@@ -360,37 +712,30 @@ impl SettingsPane {
let width = (f32::from(viewport.width) - if compact { 24.0 } else { 40.0 }).min(770.0);
let height = f32::from(viewport.height) - 48.0;
let title = if draft.is_theme() {
language.pick("选择主题", "Choose theme")
language.text(Message::ThemePickerTitle)
} else {
language.pick("选择应用图标", "Choose app icon")
language.text(Message::ThemePickerIconTitle)
};
let subtitle = if draft.is_theme() {
language.pick(
"找到舒服的配色,先预览,再应用。",
"Find a comfortable palette. Preview, then apply.",
)
language.text(Message::ThemePickerDescription)
} else {
language.pick(
"保留同一个标识,找到喜欢的配色。",
"The same identity, in your favorite color.",
)
language.text(Message::ThemePickerIconDescription)
};
let body = if draft.is_theme() {
self.theme_picker_body(width - 2.0 - padding * 2.0, compact, window, cx)
} else {
self.icon_picker_body(width - 2.0 - padding * 2.0, compact, window, cx)
};
let apply_label = match draft {
AppearanceSelection::Theme(theme)
if !self.runtime.follow_system_theme && theme == self.runtime.theme =>
{
language.pick("保留此主题", "Keep this theme")
},
AppearanceSelection::Theme(_) => language.pick("使用此主题", "Use this theme"),
AppearanceSelection::Icon(_) => language.pick("使用此图标", "Use this icon"),
let apply_label = if picker.apply_busy {
language.text(Message::ThemeEditorSaving)
} else if draft.is_theme() {
language.text(Message::ThemePickerApply)
} else {
language.text(Message::ThemePickerApplyIcon)
};
let dialog = v_flex()
.id("appearance-picker-dialog")
.debug_selector(|| "appearance-picker-dialog".to_owned())
.role(Role::Dialog)
.aria_label(title)
.w(px(width.max(1.0)))
@@ -428,11 +773,12 @@ impl SettingsPane {
)
.child(
Button::new("close-appearance-picker")
.debug_selector(|| "close-appearance-picker".to_owned())
.icon(IconName::Close)
.ghost()
.size(px(28.0))
.text_color(colors.secondary)
.tooltip(language.pick("关闭", "Close"))
.tooltip(language.text(Message::ThemeTransferClose))
.on_click(cx.listener(|this, _, window, cx| {
this.close_appearance_picker(window, cx)
})),
@@ -469,7 +815,23 @@ impl SettingsPane {
.flex_shrink_0()
.border_t_1()
.border_color(colors.line)
.child(
.child(if draft.is_theme() {
Button::new("customize-theme")
.debug_selector(|| "customize-theme".to_owned())
.icon(IconName::Plus)
.label(language.text(Message::ThemePickerCustomize))
.disabled(picker.apply_busy || picker.custom_loading)
.ghost()
.h(px(31.0))
.px(px(if compact { 8.0 } else { 10.0 }))
.text_size(px(if compact { 10.0 } else { 11.0 }))
.rounded_full()
.text_color(colors.primary)
.on_click(cx.listener(|this, _, window, cx| {
this.open_theme_editor(window, cx);
}))
.into_any_element()
} else {
h_flex()
.min_w_0()
.gap(px(7.0))
@@ -477,17 +839,19 @@ impl SettingsPane {
.child(
div()
.text_color(colors.secondary)
.child(language.pick("已选择", "Selected")),
.child(language.text(Message::ThemePickerSelected)),
)
.child(div().font_medium().truncate().child(draft.label(language))),
)
.child(div().font_medium().truncate().child(draft.label(language)))
.into_any_element()
})
.child(
h_flex()
.gap(px(8.0))
.flex_shrink_0()
.child(
Button::new("cancel-appearance-picker")
.label(language.pick("取消", "Cancel"))
.debug_selector(|| "cancel-appearance-picker".to_owned())
.label(language.text(Message::ThemePickerCancel))
.h(px(33.0))
.px(px(if compact { 11.0 } else { 16.0 }))
.text_size(px(12.0))
@@ -501,6 +865,7 @@ impl SettingsPane {
)
.child(
Button::new("apply-appearance-picker")
.debug_selector(|| "apply-appearance-picker".to_owned())
.label(apply_label)
.h(px(33.0))
.px(px(if compact { 11.0 } else { 16.0 }))
@@ -509,6 +874,7 @@ impl SettingsPane {
.bg(colors.primary)
.border_color(colors.primary)
.text_color(colors.on_primary)
.disabled(picker.apply_busy || picker.custom_loading)
.on_click(cx.listener(|this, _, window, cx| {
this.apply_appearance_selection(window, cx)
})),
@@ -122,12 +122,10 @@ impl SettingsPane {
/// 恢复后的单一重载入口(设置/SSH 主机/远端配置)。
pub(super) fn reload_after_restore(&mut self, cx: &mut Context<Self>) {
self.runtime = RuntimeSettings::load();
self.ssh_hosts = crate::gpui_shell::ssh_hosts::SshHostLists::load();
self.backup_remote = crate::backup_remote::BackupRemoteConfig::load();
let settings = crate::gpui_shell::config::Settings::load(
crate::gpui_shell::theme::effective_theme_name(cx),
);
let (runtime, settings) = crate::gpui_shell::config::Settings::load_current_snapshot(cx);
self.runtime = runtime;
cx.set_global(settings);
cx.emit(SettingsPaneEvent::Changed);
cx.notify();
@@ -86,7 +86,7 @@ impl SettingsPane {
/// 系统字体和导入字体的探测都可能读大量文件,必须离开 UI 线程;目录
/// 结果只装配一次,整个字体组下拉框共享这一份缓存。
fn ensure_font_catalog(&mut self, cx: &mut Context<Self>) {
pub(super) fn ensure_font_catalog(&mut self, cx: &mut Context<Self>) {
if self.font_system.is_some() || self.font_loading {
return;
}
@@ -104,7 +104,7 @@ impl SettingsPane {
});
cx.spawn(async move |this, cx| {
let (system, imported) = task.await;
let _ = this.update(cx, |pane, cx| {
let _ = this.update_in(cx, |pane, window, cx| {
pane.font_system = Some(system);
for family in imported {
if !pane
@@ -116,6 +116,7 @@ impl SettingsPane {
}
}
pane.font_loading = false;
pane.refresh_theme_font_select(window, cx);
cx.notify();
});
})
@@ -228,7 +228,7 @@ impl SettingsPane {
));
let bg_hex_input = {
let term = crate::gpui_shell::theme::chrome_theme_resolved(cx).palette().term_bg;
let term = crate::gpui_shell::theme::resolved_palette(cx).term_bg;
let rgb = runtime.background.unwrap_or([term.r, term.g, term.b]);
let input = cx.new(|cx| {
InputState::new(window, cx)
@@ -248,6 +248,25 @@ impl SettingsPane {
));
input
};
let theme_foreground_input = cx.new(|cx| {
InputState::new(window, cx).placeholder("#rrggbb").default_value(
runtime
.theme_foreground
.map(format_hex_rgb)
.unwrap_or_else(|| "#ffffff".to_owned()),
)
});
subscriptions.push(cx.subscribe_in(
&theme_foreground_input,
window,
|this: &mut Self,
_: &Entity<InputState>,
event: &InputEvent,
window: &mut Window,
cx: &mut Context<Self>| {
this.on_theme_foreground_input_event(event, window, cx);
},
));
let opacity_slider = cx.new(|_| {
SliderState::new().min(0.00).max(1.00).step(0.05).default_value(runtime.opacity)
});
@@ -440,7 +459,7 @@ impl SettingsPane {
));
let bg_picker_hsv = {
let term = crate::gpui_shell::theme::chrome_theme_resolved(cx).palette().term_bg;
let term = crate::gpui_shell::theme::resolved_palette(cx).term_bg;
let rgb = runtime.background.unwrap_or([term.r, term.g, term.b]);
crate::display::rgb_to_hsv(crate::display::color::Rgb::new(rgb[0], rgb[1], rgb[2]))
};
@@ -486,6 +505,10 @@ impl SettingsPane {
runtime,
active_section: 1,
appearance_picker: None,
appearance_picker_seq: 0,
theme_editor: None,
theme_editor_seq: 0,
theme_transfer: theme_transfer::ThemeTransferState::default(),
theme_picker_trigger: cx.focus_handle(),
icon_picker_trigger: cx.focus_handle(),
expanded_setting_help: std::collections::HashSet::new(),
@@ -505,6 +528,9 @@ impl SettingsPane {
bg_picker_trigger_bounds: None,
bg_sv_bounds: None,
bg_hue_bounds: None,
theme_foreground_input,
theme_foreground_input_syncing: false,
theme_foreground_picker: theme_foreground::ThemeForegroundState::new(cx),
opacity_slider,
wallpaper_opacity_slider,
proxy_url_input,
@@ -27,7 +27,7 @@ pub(super) const SECTION_IDS: [&str; 10] = [
/// section that owns the control instead of merely filtering the current page.
pub(super) const SECTION_SEARCH_TERMS: [&str; 10] = [
"application app 应用 update 更新 version 版本 github support 支持",
"appearance 外观 theme 主题 font 字体 opacity 透明度 background 背景 cursor 光标 icon 图标 dim inactive panes 调暗非活动窗格 分屏变暗",
"appearance 外观 theme 主题 custom 自定义 template 模板 import 导入 export 导出 font 字体 opacity 透明度 background 背景 cursor 光标 icon 图标 dim inactive panes 调暗非活动窗格 分屏变暗",
"profiles 配置文件 shell terminal 终端 completion 补全 startup 启动 ai message notifications toast alerts bell 提醒 通知 弹窗 消息 右下角 ai消息通知 ai 消息通知 ai消息弹窗 ai 消息弹窗 铃声",
"providers provider ai 供应商 模型 api",
"ssh host 主机 remote 远程 connection 连接",
@@ -29,9 +29,7 @@ impl SettingsPane {
let ssh_test_seq = self.ssh_test_seq.wrapping_add(1);
let ssh_undo_seq = self.ssh_undo_seq.wrapping_add(1);
let backup_seq = self.backup_seq.wrapping_add(1);
let settings = crate::gpui_shell::config::Settings::load(
crate::gpui_shell::theme::effective_theme_name(cx),
);
let (_, settings) = crate::gpui_shell::config::Settings::load_current_snapshot(cx);
gpui_component::set_locale(settings.ui_language.gpui_component_locale());
cx.set_global(settings);
*self = Self::new(window, cx);
@@ -0,0 +1,655 @@
//! Advanced controls for the native theme editor.
//!
//! This module owns only the controls and their session-local values. The
//! theme editor owns the subscriptions and the actual draft, so opening or
//! closing the advanced section cannot leave listeners attached to the pane.
use std::collections::BTreeMap;
use super::appearance_picker::AppearanceColors;
use super::navigation::rgb_hsla;
use super::*;
use gpui_component::color_picker::{ColorPicker, ColorPickerEvent, ColorPickerState};
use nebula_settings::{
MAX_PANE_CARD_DIVIDER, MAX_PANE_CARD_GUTTER, MAX_PANE_CARD_RADIUS, Rgb8, ThemeDefinition,
format_hex_rgb, parse_hex_rgb,
};
const ANSI_COUNT: usize = 16;
/// All text is supplied by the settings catalog owner. Keeping resolved
/// labels in the editor session lets this module remain independent of the
/// catalog enum while still requiring typed messages at the call site.
#[derive(Clone)]
pub(super) struct ThemeAdvancedLabels {
pub(super) title: SharedString,
pub(super) ansi: SharedString,
pub(super) selection: SharedString,
pub(super) selection_background: SharedString,
pub(super) selection_foreground: SharedString,
pub(super) cursor_text: SharedString,
pub(super) inherit: SharedString,
pub(super) color_placeholder: SharedString,
pub(super) layout: SharedString,
pub(super) radius: SharedString,
pub(super) gutter: SharedString,
pub(super) shadow: SharedString,
pub(super) divider: SharedString,
pub(super) invalid_color: SharedString,
pub(super) invalid_number: SharedString,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum ThemeAdvancedField {
Ansi(usize),
SelectionBackground,
SelectionForeground,
CursorText,
Radius,
Gutter,
Divider,
}
impl ThemeAdvancedField {
pub(super) const INPUT_FIELDS: [Self; 22] = [
Self::Ansi(0),
Self::Ansi(1),
Self::Ansi(2),
Self::Ansi(3),
Self::Ansi(4),
Self::Ansi(5),
Self::Ansi(6),
Self::Ansi(7),
Self::Ansi(8),
Self::Ansi(9),
Self::Ansi(10),
Self::Ansi(11),
Self::Ansi(12),
Self::Ansi(13),
Self::Ansi(14),
Self::Ansi(15),
Self::SelectionBackground,
Self::SelectionForeground,
Self::CursorText,
Self::Radius,
Self::Gutter,
Self::Divider,
];
fn is_color(self) -> bool {
matches!(
self,
Self::Ansi(_)
| Self::SelectionBackground
| Self::SelectionForeground
| Self::CursorText
)
}
}
/// Session-local advanced editor state.
pub(super) struct ThemeAdvancedEditor {
pub(super) labels: ThemeAdvancedLabels,
ansi: [Rgb8; ANSI_COUNT],
selection_background: Option<Rgb8>,
selection_foreground: Option<Rgb8>,
cursor_text: Option<Rgb8>,
radius: f32,
gutter: f32,
divider: f32,
pub(super) shadow: bool,
ansi_inputs: Vec<Entity<InputState>>,
ansi_pickers: Vec<Entity<ColorPickerState>>,
selection_background_input: Entity<InputState>,
selection_foreground_input: Entity<InputState>,
cursor_text_input: Entity<InputState>,
selection_background_picker: Entity<ColorPickerState>,
selection_foreground_picker: Entity<ColorPickerState>,
cursor_text_picker: Entity<ColorPickerState>,
radius_input: Entity<InputState>,
gutter_input: Entity<InputState>,
divider_input: Entity<InputState>,
picker_subscriptions: Vec<gpui::Subscription>,
errors: BTreeMap<ThemeAdvancedField, String>,
}
impl ThemeAdvancedEditor {
pub(super) fn new(
definition: &ThemeDefinition,
labels: ThemeAdvancedLabels,
window: &mut Window,
cx: &mut Context<SettingsPane>,
) -> Self {
let mut make_input = |value: String, placeholder: SharedString| {
cx.new(|cx| InputState::new(window, cx).placeholder(placeholder).default_value(value))
};
let ansi_inputs: Vec<Entity<InputState>> = (0..ANSI_COUNT)
.map(|_| make_input(String::new(), labels.color_placeholder.clone()))
.collect();
let selection_background_input = make_input(String::new(), labels.inherit.clone());
let selection_foreground_input = make_input(String::new(), labels.inherit.clone());
let cursor_text_input = make_input(String::new(), labels.inherit.clone());
let radius_input = make_input(String::new(), SharedString::from("0..28"));
let gutter_input = make_input(String::new(), SharedString::from("0..32"));
let divider_input = make_input(String::new(), SharedString::from("0..4"));
let mut picker_subscriptions = Vec::with_capacity(ANSI_COUNT + 3);
let ansi_pickers = (0..ANSI_COUNT)
.map(|index| {
new_color_picker(
Some(definition.terminal.palette.get(index).unwrap_or([0, 0, 0])),
ansi_inputs[index].clone(),
window,
cx,
&mut picker_subscriptions,
)
})
.collect();
let selection_background_picker = new_color_picker(
definition.terminal.selection_background,
selection_background_input.clone(),
window,
cx,
&mut picker_subscriptions,
);
let selection_foreground_picker = new_color_picker(
definition.terminal.selection_foreground,
selection_foreground_input.clone(),
window,
cx,
&mut picker_subscriptions,
);
let cursor_text_picker = new_color_picker(
definition.terminal.cursor_text,
cursor_text_input.clone(),
window,
cx,
&mut picker_subscriptions,
);
let mut editor = Self {
labels,
ansi: [[0; 3]; ANSI_COUNT],
selection_background: None,
selection_foreground: None,
cursor_text: None,
radius: 0.0,
gutter: 0.0,
divider: 0.0,
shadow: false,
ansi_inputs,
ansi_pickers,
selection_background_input,
selection_foreground_input,
cursor_text_input,
selection_background_picker,
selection_foreground_picker,
cursor_text_picker,
radius_input,
gutter_input,
divider_input,
picker_subscriptions,
errors: BTreeMap::new(),
};
editor.sync_from_definition(definition, window, cx);
editor
}
/// The editor owns one subscription per returned input. Keeping the list
/// here makes registration explicit for the editor session that owns it.
pub(super) const fn input_fields() -> [ThemeAdvancedField; 22] {
ThemeAdvancedField::INPUT_FIELDS
}
pub(super) fn input(&self, field: ThemeAdvancedField) -> Option<&Entity<InputState>> {
match field {
ThemeAdvancedField::Ansi(index) => self.ansi_inputs.get(index),
ThemeAdvancedField::SelectionBackground => Some(&self.selection_background_input),
ThemeAdvancedField::SelectionForeground => Some(&self.selection_foreground_input),
ThemeAdvancedField::CursorText => Some(&self.cursor_text_input),
ThemeAdvancedField::Radius => Some(&self.radius_input),
ThemeAdvancedField::Gutter => Some(&self.gutter_input),
ThemeAdvancedField::Divider => Some(&self.divider_input),
}
}
/// Replace all control values after a template/reset/import operation.
/// Invalid input errors belong to the old draft and therefore disappear.
pub(super) fn sync_from_definition(
&mut self,
definition: &ThemeDefinition,
window: &mut Window,
cx: &mut App,
) {
self.ansi = definition.terminal.palette.ansi_colors();
self.selection_background = definition.terminal.selection_background;
self.selection_foreground = definition.terminal.selection_foreground;
self.cursor_text = definition.terminal.cursor_text;
self.radius = definition.layout.card.radius;
self.gutter = definition.layout.card.gutter;
self.divider = definition.layout.card.divider;
self.shadow = definition.layout.card.shadow;
self.errors.clear();
for index in 0..ANSI_COUNT {
let color = self.ansi[index];
set_input_value(&self.ansi_inputs[index], format_hex_rgb(color), window, cx);
sync_color_picker(&self.ansi_pickers[index], Some(color), window, cx);
}
set_input_value(
&self.selection_background_input,
optional_color_text(self.selection_background),
window,
cx,
);
set_input_value(
&self.selection_foreground_input,
optional_color_text(self.selection_foreground),
window,
cx,
);
set_input_value(&self.cursor_text_input, optional_color_text(self.cursor_text), window, cx);
sync_color_picker(&self.selection_background_picker, self.selection_background, window, cx);
sync_color_picker(&self.selection_foreground_picker, self.selection_foreground, window, cx);
sync_color_picker(&self.cursor_text_picker, self.cursor_text, window, cx);
set_input_value(&self.radius_input, format_number(self.radius), window, cx);
set_input_value(&self.gutter_input, format_number(self.gutter), window, cx);
set_input_value(&self.divider_input, format_number(self.divider), window, cx);
}
/// Read one input after its session-owned subscription receives Change.
/// A failed parse leaves the last valid value and only records an error.
pub(super) fn on_input_change(
&mut self,
field: ThemeAdvancedField,
draft: &mut ThemeDefinition,
window: &mut Window,
cx: &mut App,
) -> bool {
let Some(input) = self.input(field) else { return false };
let value = input.read(cx).value().to_string();
let valid = match field {
ThemeAdvancedField::Ansi(index) => parse_hex_rgb(value.trim()).is_some_and(|color| {
self.ansi[index] = color;
true
}),
ThemeAdvancedField::SelectionBackground => parse_optional_color(&value)
.map(|color| self.selection_background = color)
.is_some(),
ThemeAdvancedField::SelectionForeground => parse_optional_color(&value)
.map(|color| self.selection_foreground = color)
.is_some(),
ThemeAdvancedField::CursorText => {
parse_optional_color(&value).map(|color| self.cursor_text = color).is_some()
},
ThemeAdvancedField::Radius => parse_layout_number(&value, 0.0, MAX_PANE_CARD_RADIUS)
.map(|number| self.radius = number)
.is_some(),
ThemeAdvancedField::Gutter => parse_layout_number(&value, 0.0, MAX_PANE_CARD_GUTTER)
.map(|number| self.gutter = number)
.is_some(),
ThemeAdvancedField::Divider => parse_layout_number(&value, 0.0, MAX_PANE_CARD_DIVIDER)
.map(|number| self.divider = number)
.is_some(),
};
if valid {
self.errors.remove(&field);
self.apply_values(draft);
match field {
ThemeAdvancedField::Ansi(index) => {
sync_color_picker(
&self.ansi_pickers[index],
Some(self.ansi[index]),
window,
cx,
);
},
ThemeAdvancedField::SelectionBackground => {
sync_color_picker(
&self.selection_background_picker,
self.selection_background,
window,
cx,
);
},
ThemeAdvancedField::SelectionForeground => {
sync_color_picker(
&self.selection_foreground_picker,
self.selection_foreground,
window,
cx,
);
},
ThemeAdvancedField::CursorText => {
sync_color_picker(&self.cursor_text_picker, self.cursor_text, window, cx);
},
_ => {},
}
} else {
let message = if field.is_color() {
self.labels.invalid_color.to_string()
} else {
self.labels.invalid_number.to_string()
};
self.errors.insert(field, message);
}
valid
}
pub(super) fn toggle_shadow(&mut self, draft: &mut ThemeDefinition) {
self.shadow = !self.shadow;
self.apply_values(draft);
}
pub(super) fn set_shadow(&mut self, draft: &mut ThemeDefinition, shadow: bool) {
self.shadow = shadow;
self.apply_values(draft);
}
pub(super) fn has_errors(&self) -> bool {
!self.errors.is_empty()
}
pub(super) fn error(&self) -> Option<&str> {
self.errors.values().next().map(String::as_str)
}
/// Apply the last valid advanced snapshot to the editor draft. Save must
/// call this before serializing; invalid text never reaches the runtime.
pub(super) fn apply_to(&self, draft: &mut ThemeDefinition) -> Result<(), String> {
if let Some(error) = self.error() {
return Err(error.to_owned());
}
self.apply_values(draft);
draft.validate().map_err(|error| error.to_string())
}
fn apply_values(&self, draft: &mut ThemeDefinition) {
for (index, color) in self.ansi.into_iter().enumerate() {
let _ = draft.terminal.palette.set(index, color);
}
draft.terminal.selection_background = self.selection_background;
draft.terminal.selection_foreground = self.selection_foreground;
draft.terminal.cursor_text = self.cursor_text;
draft.layout.card.radius = self.radius;
draft.layout.card.gutter = self.gutter;
draft.layout.card.divider = self.divider;
draft.layout.card.shadow = self.shadow;
}
/// Render the advanced editor. The owning editor supplies the shadow
/// callback so this module never captures a pane or installs a subscription.
pub(super) fn render<F>(
&self,
colors: AppearanceColors,
error_color: Hsla,
disabled: bool,
on_shadow_toggle: F,
) -> gpui::Div
where
F: Fn(&bool, &mut gpui::Window, &mut gpui::App) + 'static,
{
let swatch = |picker: &Entity<ColorPickerState>, color: Option<Rgb8>| {
if disabled {
div()
.size(px(20.0))
.rounded(px(3.0))
.border_1()
.border_color(colors.control)
.bg(color.map_or(colors.subtle, |[r, g, b]| rgb_hsla(r, g, b)))
.into_any_element()
} else {
ColorPicker::new(picker).small().into_any_element()
}
};
let ansi_cell = |index: usize| {
h_flex()
.id(("theme-advanced-ansi", index))
.debug_selector(move || format!("theme-editor-ansi-{index}"))
.gap(px(6.0))
.items_center()
.w_full()
.child(swatch(&self.ansi_pickers[index], Some(self.ansi[index])))
.child(
div()
.w(px(22.0))
.text_size(px(10.5))
.text_color(colors.secondary)
.child(format!("{index:02}")),
)
.child(
div()
.flex_1()
.min_w_0()
.h(px(30.0))
.border_b_1()
.border_color(colors.line)
.child(
Input::new(&self.ansi_inputs[index])
.appearance(false)
.bordered(false)
.focus_bordered(false)
.disabled(disabled),
),
)
};
let optional_row = |label: SharedString,
input: &Entity<InputState>,
color: Option<Rgb8>,
picker: &Entity<ColorPickerState>| {
h_flex()
.gap(px(8.0))
.items_center()
.child(swatch(picker, color))
.child(
div()
.w(px(112.0))
.text_size(px(11.0))
.text_color(colors.secondary)
.child(label),
)
.child(
div()
.flex_1()
.min_w_0()
.h(px(30.0))
.border_b_1()
.border_color(colors.line)
.child(
Input::new(input)
.appearance(false)
.bordered(false)
.focus_bordered(false)
.disabled(disabled),
),
)
};
let number_row = |label: SharedString, input: &Entity<InputState>| {
h_flex()
.gap(px(8.0))
.items_center()
.child(
div().w(px(88.0)).text_size(px(11.0)).text_color(colors.secondary).child(label),
)
.child(
div().w(px(92.0)).h(px(30.0)).border_b_1().border_color(colors.line).child(
Input::new(input)
.appearance(false)
.bordered(false)
.focus_bordered(false)
.disabled(disabled),
),
)
};
let shadow = Switch::new("theme-advanced-shadow")
.checked(self.shadow)
.disabled(disabled)
.label(self.labels.shadow.clone())
.on_click(on_shadow_toggle);
let error = self.error().map(SharedString::from);
v_flex()
.w_full()
.min_w_0()
.gap(px(14.0))
.child(div().text_size(px(13.0)).font_semibold().child(self.labels.title.clone()))
.child(
v_flex()
.gap(px(7.0))
.child(
div()
.text_size(px(11.0))
.text_color(colors.secondary)
.child(self.labels.ansi.clone()),
)
.child(v_flex().gap(px(6.0)).children((0..8).map(|row| {
h_flex().gap(px(7.0)).children([ansi_cell(row * 2), ansi_cell(row * 2 + 1)])
}))),
)
.child(
v_flex()
.gap(px(7.0))
.child(
div()
.text_size(px(11.0))
.text_color(colors.secondary)
.child(self.labels.selection.clone()),
)
.child(optional_row(
self.labels.selection_background.clone(),
&self.selection_background_input,
self.selection_background,
&self.selection_background_picker,
))
.child(optional_row(
self.labels.selection_foreground.clone(),
&self.selection_foreground_input,
self.selection_foreground,
&self.selection_foreground_picker,
))
.child(optional_row(
self.labels.cursor_text.clone(),
&self.cursor_text_input,
self.cursor_text,
&self.cursor_text_picker,
)),
)
.child(
v_flex()
.gap(px(7.0))
.child(
div()
.text_size(px(11.0))
.text_color(colors.secondary)
.child(self.labels.layout.clone()),
)
.child(number_row(self.labels.radius.clone(), &self.radius_input))
.child(number_row(self.labels.gutter.clone(), &self.gutter_input))
.child(
h_flex()
.gap(px(8.0))
.child(number_row(self.labels.divider.clone(), &self.divider_input))
.child(shadow),
),
)
.when_some(error, |view, error| {
view.child(div().text_size(px(10.5)).text_color(error_color).child(error))
})
}
}
fn new_color_picker(
color: Option<Rgb8>,
input: Entity<InputState>,
window: &mut Window,
cx: &mut Context<SettingsPane>,
subscriptions: &mut Vec<gpui::Subscription>,
) -> Entity<ColorPickerState> {
let picker = cx.new(|cx| {
let state = ColorPickerState::new(window, cx);
match color {
Some([r, g, b]) => state.default_value(rgb_hsla(r, g, b)),
None => state,
}
});
let subscription = cx.subscribe_in(
&picker,
window,
move |this: &mut SettingsPane, _picker, event: &ColorPickerEvent, window, cx| {
let Some(editor) = this.theme_editor.as_ref() else { return };
if editor.save_busy {
return;
}
let ColorPickerEvent::Change(Some(value)) = event else {
return;
};
let rgba: gpui::Rgba = (*value).into();
let rgb = [
(rgba.r.clamp(0.0, 1.0) * 255.0).round() as u8,
(rgba.g.clamp(0.0, 1.0) * 255.0).round() as u8,
(rgba.b.clamp(0.0, 1.0) * 255.0).round() as u8,
];
input.update(cx, |state, cx| {
state.set_value(format_hex_rgb(rgb), window, cx);
});
},
);
subscriptions.push(subscription);
picker
}
fn sync_color_picker(
picker: &Entity<ColorPickerState>,
color: Option<Rgb8>,
window: &mut Window,
cx: &mut App,
) {
picker.update(cx, |state, cx| {
if let Some([r, g, b]) = color {
state.set_value(rgb_hsla(r, g, b), window, cx);
} else {
*state = ColorPickerState::new(window, cx);
cx.notify();
}
});
}
fn set_input_value(input: &Entity<InputState>, value: String, window: &mut Window, cx: &mut App) {
input.update(cx, |state, cx| state.set_value(value, window, cx));
}
fn optional_color_text(color: Option<Rgb8>) -> String {
color.map_or_else(String::new, format_hex_rgb)
}
fn parse_optional_color(value: &str) -> Option<Option<Rgb8>> {
if value.trim().is_empty() { Some(None) } else { parse_hex_rgb(value.trim()).map(Some) }
}
fn parse_layout_number(value: &str, min: f32, max: f32) -> Option<f32> {
let value = value.trim().parse::<f32>().ok()?;
value.is_finite().then_some(value).filter(|value| (min..=max).contains(value))
}
fn format_number(value: f32) -> String {
format!("{value:.2}")
}
#[cfg(test)]
mod tests {
use super::{parse_layout_number, parse_optional_color};
#[test]
fn optional_colors_accept_blank_as_inherit_but_reject_bad_hex() {
assert_eq!(parse_optional_color(" "), Some(None));
assert_eq!(parse_optional_color("#123"), Some(Some([17, 34, 51])));
assert_eq!(parse_optional_color("not-a-color"), None);
}
#[test]
fn layout_numbers_reject_non_finite_and_out_of_range_values() {
assert_eq!(parse_layout_number("NaN", 0.0, 28.0), None);
assert_eq!(parse_layout_number("inf", 0.0, 28.0), None);
assert_eq!(parse_layout_number("-1", 0.0, 28.0), None);
assert_eq!(parse_layout_number("29", 0.0, 28.0), None);
assert_eq!(parse_layout_number(" 8.5 ", 0.0, 28.0), Some(8.5));
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,420 @@
//! The editor's color dialog renders local draft controls in the Root modal layer.
use super::*;
use crate::display::BgPickerPart;
use gpui::accesskit::Role;
use gpui::{Bounds, FocusHandle, Pixels, Point, WeakEntity, canvas};
use nebula_settings::Rgb8;
use std::cell::Cell;
use std::rc::Rc;
impl SettingsPane {
pub(super) fn show_theme_editor_color_dialog(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(editor) = self.theme_editor.as_ref() else { return };
let Some(picker) = editor.color_picker.as_ref() else { return };
let sequence = picker.sequence;
let slot = picker.slot;
let input = editor_color_input(editor, slot);
let title = match slot {
ThemeColorSlot::Background => Message::ThemeEditorBackground,
ThemeColorSlot::Foreground => Message::ThemeEditorForeground,
ThemeColorSlot::Accent => Message::ThemeEditorAccent,
ThemeColorSlot::Cursor => Message::ThemeEditorCursor,
};
let language = crate::gpui_shell::config::ui_language(cx);
let pane = cx.entity().downgrade();
let committed = Rc::new(Cell::new(false));
window.open_dialog(cx, move |dialog, window, _| {
let pane_for_body = pane.clone();
let pane_for_close = pane.clone();
confirm_dialog(
dialog,
window,
language.text(title),
language.text(Message::ThemeEditorPreviewOnly),
language.text(Message::ThemeTransferCompleted),
language.text(Message::ThemePickerCancel),
ButtonVariant::Primary,
)
.margin_top(px(((f32::from(window.viewport_size().height) - 450.0) * 0.5).max(16.0)))
.max_h(px((f32::from(window.viewport_size().height) - 32.0).max(120.0)))
.content(move |content, window, cx| {
let Some(pane) = pane_for_body.upgrade() else { return content };
content.child(Self::theme_editor_color_body(pane, sequence, window, cx))
})
.on_ok({
let committed = committed.clone();
let input = input.clone();
move |_, _, cx| {
if parse_hex_rgb(&input.read(cx).value()).is_none() {
return false;
}
committed.set(true);
true
}
})
.on_close({
let committed = committed.clone();
move |_, window, cx| {
let _ = pane_for_close.update(cx, |this, cx| {
let current = this
.theme_editor
.as_ref()
.and_then(|editor| editor.color_picker.as_ref())
.is_some_and(|picker| picker.sequence == sequence);
if !current {
return;
}
if committed.get() {
this.commit_theme_color_picker_for_session(sequence, cx);
} else {
this.cancel_theme_color_picker(window, cx);
}
});
window.refresh();
}
})
});
}
fn theme_editor_color_body(
pane: Entity<Self>,
sequence: u64,
window: &mut Window,
cx: &mut App,
) -> gpui::Div {
let Some((hsv, current, sv_focus, hue_focus, input)) = pane.read_with(cx, |this, _| {
let editor = this.theme_editor.as_ref()?;
let picker = editor.color_picker.as_ref()?;
(picker.sequence == sequence).then(|| {
(
picker.hsv,
picker.current,
picker.sv_focus.clone(),
picker.hue_focus.clone(),
editor_color_input(editor, picker.slot),
)
})
}) else {
return div();
};
let language = crate::gpui_shell::config::ui_language(cx);
let colors = AppearanceColors::current(cx);
let weak = pane.downgrade();
let sv =
editor_color_axis(weak.clone(), sequence, BgPickerPart::Sv, hsv, sv_focus, window, cx);
let hue = editor_color_axis(
weak.clone(),
sequence,
BgPickerPart::Hue,
hsv,
hue_focus,
window,
cx,
);
let move_pane = weak.clone();
let up_pane = weak.clone();
let out_pane = weak.clone();
let input_focused = input.focus_handle(cx).is_focused(window);
let invalid = parse_hex_rgb(&input.read(cx).value()).is_none();
v_flex()
.w_full()
.gap(px(12.0))
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.on_mouse_move(move |event: &gpui::MouseMoveEvent, window, cx| {
let _ = move_pane.update(cx, |this, cx| {
let drag = this
.theme_editor
.as_ref()
.and_then(|editor| editor.color_picker.as_ref())
.filter(|picker| picker.sequence == sequence)
.and_then(|picker| picker.drag);
if event.pressed_button == Some(MouseButton::Left) {
if let Some(part) = drag {
this.theme_editor_color_pointer(
sequence,
part,
event.position,
window,
cx,
);
}
}
});
})
.on_mouse_up(MouseButton::Left, move |_, _, cx| {
stop_editor_color_drag(&up_pane, sequence, cx);
})
.on_mouse_up_out(MouseButton::Left, move |_, _, cx| {
stop_editor_color_drag(&out_pane, sequence, cx);
})
.child(
div()
.text_size(px(11.0))
.text_color(colors.secondary)
.child(language.text(Message::ThemeEditorPreviewOnly)),
)
.child(sv)
.child(hue)
.child(
h_flex()
.w_full()
.gap(px(10.0))
.items_center()
.child(div().size(px(28.0)).rounded(px(5.0)).bg(theme_color_value(current)))
.child(
div()
.debug_selector(|| "theme-editor-color-hex".to_owned())
.flex_1()
.min_w_0()
.border_b_1()
.border_color(if input_focused { colors.primary } else { colors.line })
.child(
Input::new(&input)
.w_full()
.appearance(false)
.bordered(false)
.focus_bordered(false),
),
),
)
.when(invalid, |body| {
body.child(
div()
.text_size(px(11.0))
.text_color(cx.theme().danger)
.child(language.text(Message::ThemePickerInvalidColor)),
)
})
.child(h_flex().w_full().flex_wrap().gap(px(8.0)).children(
super::super::theme_foreground::FOREGROUND_SWATCHES.into_iter().enumerate().map(
|(index, color)| {
let pane = weak.clone();
Button::new(SharedString::from(format!(
"theme-editor-color-palette-{index}"
)))
.debug_selector(move || format!("theme-editor-color-palette-{index}"))
.size(px(26.0))
.rounded_full()
.bg(theme_color_value(color))
.border_color(if current == color {
colors.primary
} else {
colors.control
})
.tooltip(format_hex_rgb(color))
.on_click(move |_, window, cx| {
cx.stop_propagation();
let _ = pane.update(cx, |this, cx| {
this.update_theme_color_picker_for_session(
sequence, color, window, cx,
);
window.refresh();
});
})
},
),
))
}
fn theme_editor_color_pointer(
&mut self,
sequence: u64,
part: BgPickerPart,
position: Point<Pixels>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(editor) = self.theme_editor.as_mut() else { return };
if editor.save_busy {
return;
}
let Some(picker) = editor.color_picker.as_mut() else { return };
if picker.sequence != sequence {
return;
}
let bounds = match part {
BgPickerPart::Sv => picker.sv_bounds,
BgPickerPart::Hue => picker.hue_bounds,
};
let Some(bounds) = bounds else { return };
let x = (f32::from(position.x - bounds.origin.x) / f32::from(bounds.size.width).max(1.0))
.clamp(0.0, 1.0);
match part {
BgPickerPart::Sv => {
picker.hsv.1 = x;
picker.hsv.2 = 1.0
- (f32::from(position.y - bounds.origin.y)
/ f32::from(bounds.size.height).max(1.0))
.clamp(0.0, 1.0);
},
BgPickerPart::Hue => picker.hsv.0 = x * 360.0,
}
let (h, s, v) = picker.hsv;
self.update_theme_color_picker_for_session(
sequence,
super::controls::hsv_to_rgb(h, s, v),
window,
cx,
);
window.refresh();
cx.notify();
}
}
fn editor_color_axis(
pane: WeakEntity<SettingsPane>,
sequence: u64,
part: BgPickerPart,
hsv: (f32, f32, f32),
focus: FocusHandle,
window: &Window,
cx: &App,
) -> gpui::Stateful<gpui::Div> {
let is_sv = matches!(part, BgPickerPart::Sv);
let selector = if is_sv { "theme-editor-color-sv" } else { "theme-editor-color-hue" };
let pane_for_bounds = pane.clone();
let pane_for_keys = pane.clone();
let language = crate::gpui_shell::config::ui_language(cx);
div()
.id(selector)
.debug_selector(move || selector.to_owned())
.w_full()
.h(px(if is_sv { 132.0 } else { 16.0 }))
.flex_shrink_0()
.rounded(px(5.0))
.overflow_hidden()
.cursor_pointer()
.track_focus(&focus.clone().tab_stop(true))
.role(Role::Slider)
.aria_label(language.text(if is_sv {
Message::ThemePickerSaturationValue
} else {
Message::ThemePickerHue
}))
.when(focus.is_focused(window), |axis| axis.border_1().border_color(cx.theme().foreground))
.on_mouse_down(MouseButton::Left, move |event, window, cx| {
cx.stop_propagation();
let _ = pane.update(cx, |this, cx| {
let Some(picker) =
this.theme_editor.as_mut().and_then(|editor| editor.color_picker.as_mut())
else {
return;
};
if picker.sequence != sequence {
return;
}
picker.drag = Some(part);
window.focus(&focus, cx);
this.theme_editor_color_pointer(sequence, part, event.position, window, cx);
});
})
.on_key_down(move |event: &KeyDownEvent, window, cx| {
let key = event.keystroke.key.to_ascii_lowercase();
if !matches!(key.as_str(), "left" | "right" | "up" | "down" | "home" | "end") {
return;
}
cx.stop_propagation();
let _ = pane_for_keys.update(cx, |this, cx| {
let Some(picker) =
this.theme_editor.as_mut().and_then(|editor| editor.color_picker.as_mut())
else {
return;
};
if picker.sequence != sequence {
return;
}
let (h, s, v) = &mut picker.hsv;
if is_sv {
match key.as_str() {
"left" => *s = (*s - 0.01).max(0.0),
"right" => *s = (*s + 0.01).min(1.0),
"up" => *v = (*v + 0.01).min(1.0),
"down" => *v = (*v - 0.01).max(0.0),
"home" => {
*s = 0.0;
*v = 1.0;
},
"end" => {
*s = 1.0;
*v = 0.0;
},
_ => {},
}
} else {
*h = match key.as_str() {
"home" => 0.0,
"end" => 360.0,
"left" | "down" => (*h - 3.0).max(0.0),
_ => (*h + 3.0).min(360.0),
};
}
let color = super::controls::hsv_to_rgb(*h, *s, *v);
this.update_theme_color_picker_for_session(sequence, color, window, cx);
window.refresh();
cx.notify();
});
})
.child(
canvas(
move |bounds: Bounds<Pixels>, _, cx| {
let _ = pane_for_bounds.update(cx, |this, _| {
let Some(picker) = this
.theme_editor
.as_mut()
.and_then(|editor| editor.color_picker.as_mut())
else {
return;
};
if picker.sequence != sequence {
return;
}
if is_sv {
picker.sv_bounds = Some(bounds);
} else {
picker.hue_bounds = Some(bounds);
}
});
},
move |bounds, _, window, _| {
if is_sv {
super::super::background_color::paint_sv(
window, bounds, hsv.0, hsv.1, hsv.2,
);
} else {
super::super::background_color::paint_hue(window, bounds, hsv.0);
}
},
)
.size_full(),
)
}
fn stop_editor_color_drag(pane: &WeakEntity<SettingsPane>, sequence: u64, cx: &mut App) {
let _ = pane.update(cx, |this, _| {
if let Some(picker) =
this.theme_editor.as_mut().and_then(|editor| editor.color_picker.as_mut())
{
if picker.sequence == sequence {
picker.drag = None;
}
}
});
}
fn editor_color_input(editor: &ThemeEditor, slot: ThemeColorSlot) -> Entity<InputState> {
match slot {
ThemeColorSlot::Background => editor.background_input.clone(),
ThemeColorSlot::Foreground => editor.foreground_input.clone(),
ThemeColorSlot::Accent => editor.accent_input.clone(),
ThemeColorSlot::Cursor => editor.cursor_input.clone(),
}
}
fn theme_color_value([r, g, b]: [u8; 3]) -> Hsla {
super::super::rgb_hsla(r, g, b)
}
@@ -0,0 +1,534 @@
//! Draft-only controls used by the native theme editor.
//!
//! The settings pane already has a live font picker and a live background
//! picker. Those controls intentionally persist their result because they
//! edit the application settings page. The theme editor has a different
//! transaction boundary: its controls may update the preview immediately,
//! but the runtime and preference files change only after Save and Apply.
use super::*;
use gpui::{Bounds, FocusHandle, Pixels};
use nebula_settings::{Rgb8, ThemeDefinition, ThemeUiColors, format_hex_rgb};
/// The four colors shown in the editor's common section.
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
pub(in crate::gpui_shell::settings_pane) enum ThemeColorSlot {
Background,
Foreground,
Accent,
Cursor,
}
impl ThemeColorSlot {
pub(super) const ALL: [Self; 4] =
[Self::Background, Self::Foreground, Self::Accent, Self::Cursor];
pub(super) const fn editor_input(self) -> EditorInput {
match self {
Self::Background => EditorInput::Background,
Self::Foreground => EditorInput::Foreground,
Self::Accent => EditorInput::Accent,
Self::Cursor => EditorInput::Cursor,
}
}
pub(super) fn read(self, definition: &ThemeDefinition) -> Rgb8 {
match self {
Self::Background => definition.terminal.background,
Self::Foreground => definition.terminal.foreground,
Self::Accent => definition.resolved_ui().accent,
Self::Cursor => definition.terminal.cursor.unwrap_or(definition.terminal.foreground),
}
}
/// Return the stored value, preserving `None` for an inherited cursor.
pub(super) fn read_override(self, definition: &ThemeDefinition) -> Option<Rgb8> {
match self {
Self::Background => Some(definition.terminal.background),
Self::Foreground => Some(definition.terminal.foreground),
Self::Accent => Some(definition.resolved_ui().accent),
Self::Cursor => definition.terminal.cursor,
}
}
pub(super) fn write(self, definition: &mut ThemeDefinition, color: Rgb8) {
match self {
Self::Background => definition.terminal.background = color,
Self::Foreground => definition.terminal.foreground = color,
Self::Accent => {
definition.ui = definition.resolved_ui();
definition.ui.derive = false;
definition.ui.accent = color;
},
Self::Cursor => definition.terminal.cursor = Some(color),
}
}
fn restore(
self,
definition: &mut ThemeDefinition,
original: Option<Rgb8>,
original_ui: Option<ThemeUiColors>,
) {
match self {
Self::Background => {
if let Some(color) = original {
definition.terminal.background = color;
}
},
Self::Foreground => {
if let Some(color) = original {
definition.terminal.foreground = color;
}
},
Self::Accent => {
if let Some(ui) = original_ui {
definition.ui = ui;
} else if let Some(color) = original {
definition.ui.accent = color;
}
},
Self::Cursor => definition.terminal.cursor = original,
}
}
}
/// A single editor-local color picker transaction.
///
/// `original` is the stored value at open time. It is intentionally an
/// `Option` so cancelling a cursor picker can restore inherited cursor color
/// semantics instead of turning the inherited value into an explicit one.
#[derive(Clone)]
pub(in crate::gpui_shell::settings_pane) struct ThemeColorPicker {
pub(super) slot: ThemeColorSlot,
pub(super) original: Option<Rgb8>,
pub(super) original_ui: Option<ThemeUiColors>,
pub(super) original_input_value: String,
pub(super) original_input_invalid: bool,
pub(super) current: Rgb8,
pub(super) hsv: (f32, f32, f32),
pub(super) sequence: u64,
pub(super) drag: Option<crate::display::BgPickerPart>,
pub(super) sv_bounds: Option<Bounds<Pixels>>,
pub(super) hue_bounds: Option<Bounds<Pixels>>,
pub(super) sv_focus: FocusHandle,
pub(super) hue_focus: FocusHandle,
}
impl ThemeColorPicker {
fn new(
slot: ThemeColorSlot,
original: Option<Rgb8>,
original_ui: Option<ThemeUiColors>,
original_input_value: String,
original_input_invalid: bool,
current: Rgb8,
sequence: u64,
sv_focus: FocusHandle,
hue_focus: FocusHandle,
) -> Self {
Self {
slot,
original,
original_ui,
original_input_value,
original_input_invalid,
current,
hsv: rgb_to_hsv(current),
sequence,
drag: None,
sv_bounds: None,
hue_bounds: None,
sv_focus,
hue_focus,
}
}
fn set_current(&mut self, color: Rgb8) {
self.current = color;
let (h, s, v) = rgb_to_hsv(color);
// Keep a useful hue marker while editing gray or black colors. This
// matches the existing foreground picker and avoids a visual jump.
self.hsv =
if s <= f32::EPSILON || v <= f32::EPSILON { (self.hsv.0, s, v) } else { (h, s, v) };
}
}
/// Convert an RGB color to the picker representation used by the existing
/// GPUI foreground/background controls.
pub(super) fn rgb_to_hsv(color: Rgb8) -> (f32, f32, f32) {
let color = crate::display::color::Rgb::new(color[0], color[1], color[2]);
let (h, s, v) = crate::display::rgb_to_hsv(color);
(h, s, v)
}
pub(super) fn hsv_to_rgb(h: f32, s: f32, v: f32) -> Rgb8 {
let color = crate::display::hsv_to_rgb(h, s, v);
[color.r, color.g, color.b]
}
impl SettingsPane {
/// Build the selector's values from the cache shared with the regular
/// settings font picker. Catalog construction is pure and does not touch
/// persistence. The draft's entire chain is kept visible even when a
/// manually entered fallback is not present in the system catalog.
pub(super) fn theme_font_options(
&self,
current_font: &str,
draft: &ThemeDefinition,
) -> Vec<String> {
let selected_chain = draft.typography.font_family.as_deref().unwrap_or(current_font);
let primary = crate::font_install::font_family_chain(selected_chain)
.first()
.cloned()
.unwrap_or_else(|| crate::font_install::REQUIRED_FONT_FAMILY.to_owned());
let system = self.font_system.as_deref().unwrap_or(&[]);
let mut options =
crate::font_install::font_catalog(system, &self.font_imported, true, "", &primary)
.into_iter()
.map(|entry| entry.name)
.collect::<Vec<_>>();
for family in crate::font_install::font_family_chain(selected_chain) {
if !options.iter().any(|known| known.eq_ignore_ascii_case(&family)) {
options.push(family);
}
}
if options.is_empty() {
options.push(crate::font_install::REQUIRED_FONT_FAMILY.to_owned());
}
options
}
/// Refresh the theme editor selector after the background font catalog
/// scan completes or after a template/reset replaces the draft.
pub(in crate::gpui_shell::settings_pane) fn refresh_theme_font_select(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let current_font = self.current_font_chain(cx);
let Some(editor) = self.theme_editor.as_ref() else { return };
let options = self.theme_font_options(&current_font, &editor.draft);
let selected_value = editor
.draft
.typography
.font_family
.as_deref()
.and_then(|value| crate::font_install::font_family_chain(value).first().cloned())
.unwrap_or_else(|| {
crate::font_install::font_family_chain(&current_font)
.first()
.cloned()
.unwrap_or_else(|| crate::font_install::REQUIRED_FONT_FAMILY.to_owned())
});
let selected = options
.iter()
.position(|family| family.eq_ignore_ascii_case(&selected_value))
.unwrap_or(0);
let labels = options.iter().cloned().map(SharedString::from).collect::<Vec<_>>();
let Some(editor) = self.theme_editor.as_mut() else { return };
editor.font_options = options;
editor.font_select.update(cx, |state, cx| {
state.set_items(labels, window, cx);
state.set_selected_index(Some(IndexPath::default().row(selected)), window, cx);
});
}
/// Select one family for the draft while preserving a manually entered
/// fallback chain. This path deliberately does not call the regular
/// settings font picker or emit a settings change event.
pub(super) fn set_theme_font_family(
&mut self,
family: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
let family = family.trim();
if family.is_empty() {
return;
}
let Some(editor) = self.theme_editor.as_ref() else { return };
if editor.save_busy {
return;
}
let current = editor
.draft
.typography
.font_family
.as_deref()
.map(str::to_owned)
.unwrap_or_else(|| self.current_font_chain(cx));
let value = crate::font_install::replace_primary_font_family(&current, family);
let input = {
let editor = self.theme_editor.as_mut().expect("theme editor checked above");
editor.draft.typography.font_family = Some(value.clone());
editor.input_values.insert(EditorInput::FontFamily, value.clone());
editor.invalid_inputs.remove(&EditorInput::FontFamily);
editor.draft_seq = editor.draft_seq.wrapping_add(1);
editor.saved = false;
editor.error = if editor.invalid_inputs.is_empty() {
editor
.advanced_editor
.as_ref()
.and_then(|advanced| advanced.error().map(str::to_owned))
} else {
Some(
crate::gpui_shell::config::ui_language(cx)
.text(crate::i18n::Message::ThemeEditorInvalidValue)
.to_owned(),
)
};
editor.font_input.clone()
};
input.update(cx, |state, cx| state.set_value(value, window, cx));
self.refresh_theme_font_select(window, cx);
cx.notify();
}
/// Start a picker session for one common color. The picker is a view
/// concern; this method only creates its transaction state.
pub(super) fn open_theme_color_picker(
&mut self,
slot: ThemeColorSlot,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(editor) = self.theme_editor.as_ref() else { return };
if editor.save_busy {
return;
}
if editor.color_picker.as_ref().is_some_and(|picker| picker.slot == slot) {
return;
}
if editor.color_picker.is_some() {
self.cancel_theme_color_picker(window, cx);
}
let sequence = {
let Some(editor) = self.theme_editor.as_mut() else { return };
editor.color_picker_seq = editor.color_picker_seq.wrapping_add(1).max(1);
let sequence = editor.color_picker_seq;
let original = slot.read_override(&editor.draft);
let original_ui = (slot == ThemeColorSlot::Accent).then_some(editor.draft.ui);
let input = slot.editor_input();
let original_input_value = editor
.input_values
.get(&input)
.cloned()
.unwrap_or_else(|| format_hex_rgb(slot.read(&editor.draft)));
let original_input_invalid = editor.invalid_inputs.contains(&input);
let current = slot.read(&editor.draft);
editor.color_picker = Some(ThemeColorPicker::new(
slot,
original,
original_ui,
original_input_value,
original_input_invalid,
current,
sequence,
cx.focus_handle(),
cx.focus_handle(),
));
sequence
};
self.show_theme_editor_color_dialog(window, cx);
window.refresh();
cx.notify();
}
pub(super) fn update_theme_color_picker(
&mut self,
color: Rgb8,
window: &mut Window,
cx: &mut Context<Self>,
) {
let sequence = self
.theme_editor
.as_ref()
.and_then(|editor| editor.color_picker.as_ref())
.map(|picker| picker.sequence);
let Some(sequence) = sequence else { return };
self.update_theme_color_picker_for_session(sequence, color, window, cx);
}
pub(super) fn update_theme_color_picker_for_session(
&mut self,
sequence: u64,
color: Rgb8,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(editor) = self.theme_editor.as_ref() else { return };
if editor.save_busy {
return;
}
let Some(picker) = editor.color_picker.as_ref() else { return };
if picker.sequence != sequence || picker.current == color {
return;
}
let slot = picker.slot;
let input = slot.editor_input();
let input_entity = {
let editor = self.theme_editor.as_mut().expect("theme editor checked above");
slot.write(&mut editor.draft, color);
editor.input_values.insert(input, format_hex_rgb(color));
editor.invalid_inputs.remove(&input);
editor.draft_seq = editor.draft_seq.wrapping_add(1);
editor.saved = false;
editor.error = if editor.invalid_inputs.is_empty() {
editor
.advanced_editor
.as_ref()
.and_then(|advanced| advanced.error().map(str::to_owned))
} else {
Some(
crate::gpui_shell::config::ui_language(cx)
.text(crate::i18n::Message::ThemeEditorInvalidValue)
.to_owned(),
)
};
if let Some(picker) = editor.color_picker.as_mut() {
picker.set_current(color);
}
match input {
EditorInput::Background => editor.background_input.clone(),
EditorInput::Foreground => editor.foreground_input.clone(),
EditorInput::Accent => editor.accent_input.clone(),
EditorInput::Cursor => editor.cursor_input.clone(),
_ => return,
}
};
input_entity.update(cx, |state, cx| {
state.set_value(format_hex_rgb(color), window, cx);
});
if let Some(editor) = self.theme_editor.as_mut() {
editor.sync_inherited_cursor_input(window, cx);
}
window.refresh();
cx.notify();
}
pub(super) fn cancel_theme_color_picker(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(picker) = self.theme_editor.as_mut().and_then(|editor| editor.color_picker.take())
else {
return;
};
let input = picker.slot.editor_input();
let (input_entity, original_input_value) = {
let editor = self.theme_editor.as_mut().expect("theme editor is open");
picker.slot.restore(&mut editor.draft, picker.original, picker.original_ui);
editor.input_values.insert(input, picker.original_input_value.clone());
if picker.original_input_invalid {
editor.invalid_inputs.insert(input);
} else {
editor.invalid_inputs.remove(&input);
}
editor.draft_seq = editor.draft_seq.wrapping_add(1);
editor.saved = false;
editor.error = if editor.invalid_inputs.is_empty() {
editor
.advanced_editor
.as_ref()
.and_then(|advanced| advanced.error().map(str::to_owned))
} else {
Some(
crate::gpui_shell::config::ui_language(cx)
.text(crate::i18n::Message::ThemeEditorInvalidValue)
.to_owned(),
)
};
(
match input {
EditorInput::Background => editor.background_input.clone(),
EditorInput::Foreground => editor.foreground_input.clone(),
EditorInput::Accent => editor.accent_input.clone(),
EditorInput::Cursor => editor.cursor_input.clone(),
_ => return,
},
picker.original_input_value,
)
};
input_entity.update(cx, |state, cx| state.set_value(original_input_value, window, cx));
if let Some(editor) = self.theme_editor.as_mut() {
editor.sync_inherited_cursor_input(window, cx);
}
window.refresh();
cx.notify();
}
pub(super) fn commit_theme_color_picker(&mut self, cx: &mut Context<Self>) {
if self.theme_editor.as_mut().and_then(|editor| editor.color_picker.take()).is_some() {
cx.notify();
}
}
pub(super) fn commit_theme_color_picker_for_session(
&mut self,
sequence: u64,
cx: &mut Context<Self>,
) {
let should_commit = self
.theme_editor
.as_ref()
.and_then(|editor| editor.color_picker.as_ref())
.is_some_and(|picker| picker.sequence == sequence);
if should_commit {
self.commit_theme_color_picker(cx);
}
}
pub(super) fn invalidate_theme_color_picker(&mut self) {
if let Some(editor) = self.theme_editor.as_mut() {
editor.color_picker = None;
}
}
}
impl ThemeEditor {
/// Synchronize picker HSV after a user edits the corresponding hex field.
/// The original snapshot remains untouched, so Cancel still restores the
/// color from when the picker opened.
pub(super) fn sync_color_picker_from_input(&mut self, field: EditorInput, color: Rgb8) {
if let Some(picker) = self.color_picker.as_mut() {
if picker.slot.editor_input() == field {
picker.set_current(color);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use nebula_settings::ThemeName;
#[test]
fn hsv_round_trip_keeps_color_close() {
for color in [[8, 10, 24], [253, 246, 227], [40, 42, 54], [255, 0, 127]] {
let (h, s, v) = rgb_to_hsv(color);
let round_trip = hsv_to_rgb(h, s, v);
assert!(
color
.into_iter()
.zip(round_trip)
.all(|(left, right)| (i16::from(left) - i16::from(right)).abs() <= 1),
"{color:?} -> ({h}, {s}, {v}) -> {round_trip:?}"
);
}
}
#[test]
fn cancelling_inherited_cursor_restores_none() {
let mut definition = ThemeDefinition::from_builtin(ThemeName::Nord);
definition.terminal.cursor = None;
let original = ThemeColorSlot::Cursor.read_override(&definition);
ThemeColorSlot::Cursor.write(&mut definition, [255, 0, 0]);
ThemeColorSlot::Cursor.restore(&mut definition, original, None);
assert_eq!(definition.terminal.cursor, None);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,742 @@
//! Theme picker foreground-color session.
//!
//! The picker owns only draft state. Runtime preferences are updated by the
//! outer appearance picker after the user confirms that picker, so dragging the
//! SV square, choosing a swatch, or typing a valid hex value stays reversible.
use crate::gpui_shell::prelude::{ButtonVariant, Input, h_flex, v_flex};
use crate::i18n::Message;
use gpui::accesskit::{Role, Toggled};
use gpui::prelude::FluentBuilder as _;
use gpui::{
Bounds, Context, FocusHandle, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton,
MouseDownEvent, MouseMoveEvent, ParentElement as _, Pixels, Point, SharedString,
StatefulInteractiveElement as _, Styled as _, Window, canvas, div, px,
};
use gpui_component::input::InputEvent;
use nebula_settings::{Rgb8, format_hex_rgb, parse_hex_rgb};
use std::cell::Cell;
use std::rc::Rc;
use super::*;
const SV_H: f32 = 132.0;
const HUE_H: f32 = 16.0;
const GAP: f32 = 8.0;
const SWATCH_SIZE: f32 = 28.0;
/// The same stable recommendation order used by the original foreground dialog.
pub(super) const FOREGROUND_SWATCHES: [[u8; 3]; 10] = [
[255, 255, 255],
[245, 247, 250],
[20, 24, 31],
[31, 94, 83],
[42, 91, 140],
[143, 76, 31],
[112, 73, 153],
[187, 53, 66],
[31, 125, 118],
[96, 105, 117],
];
pub(super) struct ThemeForegroundState {
pub(super) hsv: (f32, f32, f32),
pub(super) drag: Option<crate::display::BgPickerPart>,
pub(super) sv_bounds: Option<Bounds<Pixels>>,
pub(super) hue_bounds: Option<Bounds<Pixels>>,
pub(super) session_seq: u64,
pub(super) dialog_open: bool,
before_override: Option<Option<Rgb8>>,
pub(super) sv_focus: FocusHandle,
pub(super) hue_focus: FocusHandle,
pub(super) palette_focus: Vec<FocusHandle>,
}
impl ThemeForegroundState {
pub(super) fn new(cx: &mut Context<SettingsPane>) -> Self {
Self {
hsv: (0.0, 0.0, 1.0),
drag: None,
sv_bounds: None,
hue_bounds: None,
session_seq: 0,
dialog_open: false,
before_override: None,
sv_focus: cx.focus_handle(),
hue_focus: cx.focus_handle(),
palette_focus: (0..FOREGROUND_SWATCHES.len()).map(|_| cx.focus_handle()).collect(),
}
}
fn set_hsv_from_rgb(&mut self, rgb: Rgb8) {
let (h, s, v) =
crate::display::rgb_to_hsv(crate::display::color::Rgb::new(rgb[0], rgb[1], rgb[2]));
if s > f32::EPSILON && v > f32::EPSILON {
self.hsv = (h, s, v);
} else {
// Keep the previous hue for grayscale/black values, matching the
// background picker and preventing the hue cursor from jumping.
self.hsv = (self.hsv.0, s, v);
}
}
fn begin(&mut self, current: Rgb8, before_override: Option<Rgb8>) -> u64 {
self.session_seq = self.session_seq.wrapping_add(1).max(1);
self.dialog_open = true;
self.drag = None;
self.sv_bounds = None;
self.hue_bounds = None;
self.before_override = Some(before_override);
self.set_hsv_from_rgb(current);
self.session_seq
}
fn is_current(&self, sequence: u64) -> bool {
self.dialog_open && self.session_seq == sequence
}
fn finish(&mut self, sequence: u64) {
if self.session_seq != sequence {
return;
}
self.dialog_open = false;
self.drag = None;
self.sv_bounds = None;
self.hue_bounds = None;
self.before_override = None;
}
fn take_before_override(&mut self, sequence: u64) -> Option<Option<Rgb8>> {
if self.session_seq != sequence {
return None;
}
self.dialog_open = false;
self.drag = None;
self.sv_bounds = None;
self.hue_bounds = None;
self.before_override.take()
}
pub(super) fn invalidate(&mut self) {
self.session_seq = self.session_seq.wrapping_add(1).max(1);
self.dialog_open = false;
self.drag = None;
self.sv_bounds = None;
self.hue_bounds = None;
self.before_override = None;
}
}
impl SettingsPane {
pub(super) fn on_theme_foreground_input_event(
&mut self,
event: &InputEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !matches!(event, InputEvent::Change) {
if matches!(event, InputEvent::Focus | InputEvent::Blur) {
cx.notify();
}
return;
}
if self.theme_foreground_input_syncing
|| self
.appearance_picker
.as_ref()
.is_none_or(|picker| picker.apply_busy || !picker.draft.is_theme())
|| !self.theme_foreground_picker.dialog_open
{
return;
}
let value = self.theme_foreground_input.read(cx).value().to_string();
let Some(color) = parse_hex_rgb(&value) else { return };
let Some(original) = self
.appearance_picker
.as_ref()
.and_then(|picker| picker.default_foreground(picker.draft))
else {
return;
};
if let Some(picker) = self.appearance_picker.as_mut() {
picker.set_foreground_preview((color != original).then_some(color));
}
self.theme_foreground_picker.set_hsv_from_rgb(color);
// The color dialog is rendered by the workspace's Root layer, outside
// this pane. Refresh it together with the pane's terminal preview.
window.refresh();
cx.notify();
}
pub(super) fn set_theme_foreground(
&mut self,
color: Rgb8,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(original) = self
.appearance_picker
.as_ref()
.and_then(|picker| picker.default_foreground(picker.draft))
else {
return;
};
if self.appearance_picker.as_ref().is_some_and(|picker| picker.apply_busy) {
return;
}
if let Some(picker) = self.appearance_picker.as_mut() {
picker.set_foreground_preview((color != original).then_some(color));
}
if self.theme_foreground_picker.dialog_open {
self.theme_foreground_picker.set_hsv_from_rgb(color);
}
self.theme_foreground_input_syncing = true;
self.theme_foreground_input.update(cx, |input, cx| {
input.set_value(format_hex_rgb(color), window, cx);
});
self.theme_foreground_input_syncing = false;
window.refresh();
cx.notify();
}
pub(super) fn open_theme_foreground_picker(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some((current, previous)) = self.appearance_picker.as_ref().and_then(|picker| {
if picker.apply_busy {
return None;
}
let current = picker
.foreground_override
.or_else(|| picker.default_foreground(picker.draft))
.unwrap_or([255, 255, 255]);
Some((current, picker.foreground_override))
}) else {
return;
};
let sequence = self.theme_foreground_picker.begin(current, previous);
self.write_theme_foreground_input(current, window, cx);
let language = crate::gpui_shell::config::ui_language(cx);
let input = self.theme_foreground_input.clone();
let pane = cx.entity().downgrade();
let committed = Rc::new(Cell::new(false));
window.open_dialog(cx, move |dialog, window, dialog_cx| {
let pane_for_content = pane.clone();
let pane_for_close = pane.clone();
confirm_dialog(
dialog,
window,
language.text(Message::ThemePickerColorTitle),
language.text(Message::ThemePickerColorHint),
language.text(Message::ThemeTransferCompleted),
language.text(Message::ThemePickerCancel),
ButtonVariant::Primary,
)
.margin_top(px(((f32::from(window.viewport_size().height) - 560.0) * 0.5).max(16.0)))
.max_h(px((f32::from(window.viewport_size().height) - 32.0).max(120.0)))
.content(move |content, window, cx| {
if let Some(pane) = pane_for_content.upgrade() {
content.child(Self::theme_foreground_dialog_body(pane, sequence, window, cx))
} else {
content
}
})
.on_ok({
let committed = committed.clone();
let input = input.clone();
move |_, _, cx| {
if parse_hex_rgb(&input.read(cx).value()).is_none() {
return false;
}
committed.set(true);
true
}
})
.on_close({
let committed = committed.clone();
move |_, window, cx| {
let _ = pane_for_close.update(cx, |this, cx| {
if committed.get() {
this.finish_theme_foreground_session(sequence, cx);
} else {
this.cancel_theme_foreground_session(sequence, window, cx);
}
});
}
})
});
}
/// Build the controls inside DialogContent so their painted positions and
/// pointer hitboxes share the modal's content bounds.
fn theme_foreground_dialog_body(
pane_entity: Entity<SettingsPane>,
sequence: u64,
window: &mut Window,
dialog_cx: &mut App,
) -> gpui::Div {
let pane = pane_entity.downgrade();
let language = crate::gpui_shell::config::ui_language(dialog_cx);
let dialog_text_color = dialog_cx.theme().foreground;
let dialog_accent_color = dialog_cx.theme().primary;
let dialog_border_color = dialog_cx.theme().border;
let input = pane_entity.read(dialog_cx).theme_foreground_input.clone();
let (hsv, current, sv_focus, hue_focus, palette_focus) =
pane_entity.read_with(dialog_cx, |this, _| {
let current = this
.appearance_picker
.as_ref()
.and_then(|picker| picker.foreground_override)
.or_else(|| {
this.appearance_picker
.as_ref()
.and_then(|picker| picker.default_foreground(picker.draft))
})
.unwrap_or([255, 255, 255]);
(
this.theme_foreground_picker.hsv,
current,
this.theme_foreground_picker.sv_focus.clone(),
this.theme_foreground_picker.hue_focus.clone(),
this.theme_foreground_picker.palette_focus.clone(),
)
});
let pane_for_sv_bounds = pane.clone();
let sv = div()
.id("theme-foreground-sv")
.debug_selector(|| "theme-foreground-sv".to_owned())
.w_full()
.h(px(SV_H))
.rounded(px(7.0))
.overflow_hidden()
.cursor_pointer()
.track_focus(&sv_focus.clone().tab_stop(true))
.role(Role::Slider)
.aria_label(language.text(Message::ThemePickerSaturationValue))
.when(sv_focus.is_focused(window), |element| {
element.border_2().border_color(gpui::rgb(0xffffff))
})
.on_key_down({
let pane = pane.clone();
move |event: &KeyDownEvent, window, cx| {
if !matches!(
event.keystroke.key.to_ascii_lowercase().as_str(),
"left" | "right" | "up" | "down" | "home" | "end"
) {
return;
}
cx.stop_propagation();
let _ = pane.update(cx, |this, cx| {
this.nudge_theme_foreground(
sequence,
crate::display::BgPickerPart::Sv,
&event.keystroke.key,
window,
cx,
);
});
}
})
.on_mouse_down(MouseButton::Left, {
let pane = pane.clone();
move |event: &MouseDownEvent, window, cx| {
cx.stop_propagation();
let _ = pane.update(cx, |this, cx| {
this.begin_theme_foreground_drag(
sequence,
crate::display::BgPickerPart::Sv,
event.position,
window,
cx,
);
});
}
})
.child(
canvas(
move |bounds, _, cx| {
let _ = pane_for_sv_bounds.update(cx, |this, _| {
if this.theme_foreground_picker.is_current(sequence) {
this.theme_foreground_picker.sv_bounds = Some(bounds);
}
});
},
move |bounds, _, window, _| {
background_color::paint_sv(window, bounds, hsv.0, hsv.1, hsv.2);
},
)
.size_full(),
);
let pane_for_hue_bounds = pane.clone();
let hue = div()
.id("theme-foreground-hue")
.debug_selector(|| "theme-foreground-hue".to_owned())
.w_full()
.h(px(HUE_H))
.rounded(px(7.0))
.overflow_hidden()
.cursor_pointer()
.track_focus(&hue_focus.clone().tab_stop(true))
.role(Role::Slider)
.aria_label(language.text(Message::ThemePickerHue))
.when(hue_focus.is_focused(window), |element| {
element.border_2().border_color(gpui::rgb(0xffffff))
})
.on_key_down({
let pane = pane.clone();
move |event: &KeyDownEvent, window, cx| {
if !matches!(
event.keystroke.key.to_ascii_lowercase().as_str(),
"left" | "right" | "home" | "end"
) {
return;
}
cx.stop_propagation();
let _ = pane.update(cx, |this, cx| {
this.nudge_theme_foreground(
sequence,
crate::display::BgPickerPart::Hue,
&event.keystroke.key,
window,
cx,
);
});
}
})
.on_mouse_down(MouseButton::Left, {
let pane = pane.clone();
move |event: &MouseDownEvent, window, cx| {
cx.stop_propagation();
let _ = pane.update(cx, |this, cx| {
this.begin_theme_foreground_drag(
sequence,
crate::display::BgPickerPart::Hue,
event.position,
window,
cx,
);
});
}
})
.child(
canvas(
move |bounds, _, cx| {
let _ = pane_for_hue_bounds.update(cx, |this, _| {
if this.theme_foreground_picker.is_current(sequence) {
this.theme_foreground_picker.hue_bounds = Some(bounds);
}
});
},
move |bounds, _, window, _| {
background_color::paint_hue(window, bounds, hsv.0);
},
)
.size_full(),
);
let pane_for_move = pane.clone();
let pane_for_up = pane.clone();
let panel = v_flex()
.id("theme-foreground-picker-panel")
.debug_selector(|| "theme-foreground-picker-panel".to_owned())
.w_full()
.gap(px(GAP))
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.on_mouse_move(move |event: &MouseMoveEvent, window, cx| {
let _ = pane_for_move.update(cx, |this, cx| {
if event.pressed_button == Some(MouseButton::Left) {
this.apply_theme_foreground_pointer(sequence, event.position, window, cx);
} else if this.theme_foreground_picker.is_current(sequence) {
this.theme_foreground_picker.drag = None;
}
});
})
.on_mouse_up(MouseButton::Left, move |_, _, cx| {
let _ = pane_for_up.update(cx, |this, _| {
if this.theme_foreground_picker.is_current(sequence) {
this.theme_foreground_picker.drag = None;
}
});
})
.on_mouse_up_out(MouseButton::Left, {
let pane = pane.clone();
move |_, _, cx| {
let _ = pane.update(cx, |this, _| {
if this.theme_foreground_picker.is_current(sequence) {
this.theme_foreground_picker.drag = None;
}
});
}
})
.child(sv)
.child(hue);
let pane_for_cells = pane.clone();
let focused_swatch = palette_focus.iter().position(|focus| focus.is_focused(window));
let swatches = h_flex().w_full().flex_wrap().gap(px(GAP)).children(
FOREGROUND_SWATCHES.iter().copied().enumerate().map(move |(index, color)| {
let pane = pane_for_cells.clone();
let focus = palette_focus[index].clone();
let selected = current == color;
let label = format!(
"{} {}",
language.text(Message::ThemePickerTextCustom),
format_hex_rgb(color)
);
div()
.id(SharedString::from(format!("theme-foreground-palette-{index}")))
.debug_selector(move || format!("theme-foreground-palette-{index}"))
.size(px(SWATCH_SIZE))
.rounded_full()
.track_focus(&focus.clone().tab_stop(true))
.role(Role::RadioButton)
.aria_label(label.clone())
.aria_toggled(if selected { Toggled::True } else { Toggled::False })
.border_1()
.border_color(if selected { dialog_accent_color } else { dialog_border_color })
.when(focused_swatch == Some(index), |cell| {
cell.border_color(gpui::rgb(0xffffff))
})
.bg(super::rgb_hsla(color[0], color[1], color[2]))
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.on_click({
let pane = pane.clone();
move |_, window, cx| {
cx.stop_propagation();
let _ = pane.update(cx, |this, cx| {
this.set_theme_foreground_for_session(sequence, color, window, cx);
});
}
})
.on_key_down(move |event: &KeyDownEvent, window, cx| {
if event.keystroke.key.eq_ignore_ascii_case("enter")
|| event.keystroke.key.eq_ignore_ascii_case("space")
{
cx.stop_propagation();
let _ = pane.update(cx, |this, cx| {
this.set_theme_foreground_for_session(sequence, color, window, cx);
});
}
})
}),
);
let body = v_flex()
.w_full()
.gap(px(12.0))
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.child(
div()
.text_size(px(12.0))
.text_color(dialog_text_color)
.child(language.text(Message::ThemePickerColorHint)),
)
.child(
div()
.text_size(px(11.0))
.text_color(dialog_text_color)
.child(language.text(Message::ThemePickerTextSuggestion)),
)
.child(
div()
.id("theme-foreground-hex")
.debug_selector(|| "theme-foreground-hex".to_owned())
.w_full()
.border_b_1()
.border_color(if input.focus_handle(dialog_cx).is_focused(window) {
dialog_accent_color
} else {
dialog_border_color
})
.child(
Input::new(&input)
.w_full()
.appearance(false)
.bordered(false)
.focus_bordered(false),
),
)
.when(parse_hex_rgb(&input.read(dialog_cx).value()).is_none(), |body| {
body.child(
div()
.text_size(px(11.0))
.text_color(dialog_cx.theme().danger)
.child(language.text(Message::ThemePickerInvalidColor)),
)
})
.child(panel)
.child(swatches);
body
}
fn write_theme_foreground_input(
&mut self,
color: Rgb8,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.theme_foreground_input_syncing = true;
self.theme_foreground_input.update(cx, |input, cx| {
input.set_value(format_hex_rgb(color), window, cx);
});
self.theme_foreground_input_syncing = false;
}
fn set_theme_foreground_for_session(
&mut self,
sequence: u64,
color: Rgb8,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.theme_foreground_picker.is_current(sequence) {
return;
}
self.set_theme_foreground(color, window, cx);
}
fn begin_theme_foreground_drag(
&mut self,
sequence: u64,
part: crate::display::BgPickerPart,
position: Point<Pixels>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.theme_foreground_picker.is_current(sequence) {
return;
}
let focus = match part {
crate::display::BgPickerPart::Sv => &self.theme_foreground_picker.sv_focus,
crate::display::BgPickerPart::Hue => &self.theme_foreground_picker.hue_focus,
};
window.focus(focus, cx);
self.theme_foreground_picker.drag = Some(part);
self.apply_theme_foreground_pointer(sequence, position, window, cx);
}
fn apply_theme_foreground_pointer(
&mut self,
sequence: u64,
position: Point<Pixels>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.theme_foreground_picker.is_current(sequence) {
return;
}
let Some(part) = self.theme_foreground_picker.drag else { return };
let changed = match part {
crate::display::BgPickerPart::Sv => {
let Some(bounds) = self.theme_foreground_picker.sv_bounds else { return };
let w = f32::from(bounds.size.width).max(1.0);
let h = f32::from(bounds.size.height).max(1.0);
let x = f32::from(position.x - bounds.origin.x);
let y = f32::from(position.y - bounds.origin.y);
self.theme_foreground_picker.hsv.1 = (x / w).clamp(0.0, 1.0);
self.theme_foreground_picker.hsv.2 = (1.0 - y / h).clamp(0.0, 1.0);
true
},
crate::display::BgPickerPart::Hue => {
let Some(bounds) = self.theme_foreground_picker.hue_bounds else { return };
let w = f32::from(bounds.size.width).max(1.0);
let x = f32::from(position.x - bounds.origin.x);
self.theme_foreground_picker.hsv.0 = (x / w).clamp(0.0, 1.0) * 360.0;
true
},
};
if changed {
let (h, s, v) = self.theme_foreground_picker.hsv;
let color = crate::display::hsv_to_rgb(h, s, v);
self.set_theme_foreground_for_session(
sequence,
[color.r, color.g, color.b],
window,
cx,
);
}
}
fn nudge_theme_foreground(
&mut self,
sequence: u64,
part: crate::display::BgPickerPart,
key: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.theme_foreground_picker.is_current(sequence) {
return;
}
let key = key.to_ascii_lowercase();
match part {
crate::display::BgPickerPart::Sv => match key.as_str() {
"left" => self.theme_foreground_picker.hsv.1 -= 0.02,
"right" => self.theme_foreground_picker.hsv.1 += 0.02,
"down" => self.theme_foreground_picker.hsv.2 -= 0.02,
"up" => self.theme_foreground_picker.hsv.2 += 0.02,
"home" => self.theme_foreground_picker.hsv.1 = 0.0,
"end" => self.theme_foreground_picker.hsv.1 = 1.0,
_ => return,
},
crate::display::BgPickerPart::Hue => match key.as_str() {
"left" => self.theme_foreground_picker.hsv.0 -= 2.0,
"right" => self.theme_foreground_picker.hsv.0 += 2.0,
"home" => self.theme_foreground_picker.hsv.0 = 0.0,
"end" => self.theme_foreground_picker.hsv.0 = 360.0,
_ => return,
},
}
self.theme_foreground_picker.hsv.0 = self.theme_foreground_picker.hsv.0.rem_euclid(360.0);
self.theme_foreground_picker.hsv.1 = self.theme_foreground_picker.hsv.1.clamp(0.0, 1.0);
self.theme_foreground_picker.hsv.2 = self.theme_foreground_picker.hsv.2.clamp(0.0, 1.0);
let (h, s, v) = self.theme_foreground_picker.hsv;
let color = crate::display::hsv_to_rgb(h, s, v);
self.set_theme_foreground_for_session(sequence, [color.r, color.g, color.b], window, cx);
}
fn finish_theme_foreground_session(&mut self, sequence: u64, cx: &mut Context<Self>) {
if !self.theme_foreground_picker.is_current(sequence) {
return;
}
self.theme_foreground_picker.finish(sequence);
cx.notify();
}
fn cancel_theme_foreground_session(
&mut self,
sequence: u64,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(previous) = self.theme_foreground_picker.take_before_override(sequence) else {
return;
};
let Some(color) = self
.appearance_picker
.as_ref()
.and_then(|picker| previous.or_else(|| picker.default_foreground(picker.draft)))
else {
return;
};
if let Some(picker) = self.appearance_picker.as_mut() {
picker.foreground_override = previous;
picker.error = None;
}
self.write_theme_foreground_input(color, window, cx);
self.theme_foreground_picker.set_hsv_from_rgb(color);
cx.notify();
}
pub(super) fn invalidate_theme_foreground_picker(&mut self) {
self.theme_foreground_picker.invalidate();
}
}
@@ -1,9 +1,10 @@
use super::appearance_picker::{AppearanceColors, AppearanceSelection, picker_columns};
use super::*;
use crate::i18n::Message;
pub(super) const THEME_ORDER: [ThemeName; ThemeName::BUILTIN.len()] = ThemeName::BUILTIN;
fn theme_foreground(name: ThemeName) -> [u8; 3] {
pub(super) fn theme_foreground(name: ThemeName) -> [u8; 3] {
let theme = name.term_theme();
if let Some(exact) = theme.exact {
exact.foreground
@@ -15,15 +16,126 @@ fn theme_foreground(name: ThemeName) -> [u8; 3] {
}
}
fn on_solid_rgb(color: [u8; 3]) -> Hsla {
let luma =
0.2126 * f32::from(color[0]) + 0.7152 * f32::from(color[1]) + 0.0722 * f32::from(color[2]);
if luma < 150.0 { rgb_hsla(248, 250, 252) } else { rgb_hsla(15, 23, 42) }
}
/// The GPUI overflow mask is rectangular. Paint the rainbow inside circular
/// paths instead of relying on a rounded parent to clip rectangular children.
fn rainbow_swatch() -> gpui::Div {
div().size_full().child(
gpui::canvas(
|_, _, _| (),
|bounds, _, window, _| {
let radius = f32::from(bounds.size.width.min(bounds.size.height)) * 0.5;
let center = bounds.center();
let colors = [
rgb_hsla(238, 88, 105),
rgb_hsla(240, 188, 69),
rgb_hsla(119, 192, 97),
rgb_hsla(66, 182, 178),
rgb_hsla(80, 143, 225),
rgb_hsla(165, 102, 212),
];
for index in 0..colors.len() - 1 {
let left = -radius + radius * 2.0 * index as f32 / 5.0;
let right = -radius + radius * 2.0 * (index + 1) as f32 / 5.0;
let edge = |x: f32, lower: bool| {
let y = (radius * radius - x * x).max(0.0).sqrt();
gpui::point(center.x + px(x), center.y + px(if lower { y } else { -y }))
};
let mut path = gpui::PathBuilder::fill();
path.move_to(edge(left, false));
path.arc_to(
gpui::point(px(radius), px(radius)),
px(0.0),
false,
true,
edge(right, false),
);
path.line_to(edge(right, true));
path.arc_to(
gpui::point(px(radius), px(radius)),
px(0.0),
false,
true,
edge(left, true),
);
path.close();
if let Ok(path) = path.build() {
window.paint_path(
path,
gpui::linear_gradient(
90.0,
gpui::linear_color_stop(colors[index], 0.0),
gpui::linear_color_stop(colors[index + 1], 1.0),
),
);
}
}
},
)
.size_full(),
)
}
pub(super) fn theme_sample(name: ThemeName, token: bool, compact: bool) -> gpui::Div {
theme_sample_with_foreground(name, None, token, compact)
}
pub(super) fn theme_sample_with_foreground(
name: ThemeName,
foreground_override: Option<[u8; 3]>,
token: bool,
compact: bool,
) -> gpui::Div {
let theme = chrome_theme(name);
let palette = theme.palette();
let background = name.term_theme().background;
let foreground = theme_foreground(name);
let accent = theme.accent();
let chrome = rgb_hsla(palette.shell_bg.r, palette.shell_bg.g, palette.shell_bg.b);
let foreground = foreground_override.unwrap_or_else(|| theme_foreground(name));
theme_sample_values(
background,
[palette.shell_bg.r, palette.shell_bg.g, palette.shell_bg.b],
[theme.accent().r, theme.accent().g, theme.accent().b],
foreground,
token,
compact,
)
}
/// Render a small theme thumbnail from a complete custom snapshot. Keeping
/// this renderer beside the built-in card prevents the appearance trigger and
/// the picker from drifting into two different miniature representations.
pub(super) fn theme_definition_sample(
definition: &nebula_settings::ThemeDefinition,
foreground_override: Option<[u8; 3]>,
token: bool,
compact: bool,
) -> gpui::Div {
let ui = definition.resolved_ui();
theme_sample_values(
definition.terminal.background,
ui.shell,
ui.accent,
foreground_override.unwrap_or(definition.terminal.foreground),
token,
compact,
)
}
fn theme_sample_values(
background: [u8; 3],
chrome: [u8; 3],
accent: [u8; 3],
foreground: [u8; 3],
token: bool,
compact: bool,
) -> gpui::Div {
let chrome = rgb_hsla(chrome[0], chrome[1], chrome[2]);
let ink = rgb_hsla(foreground[0], foreground[1], foreground[2]);
let accent = rgb_hsla(accent.r, accent.g, accent.b);
let accent = rgb_hsla(accent[0], accent[1], accent[2]);
let line = |width, color| {
div().w(gpui::relative(width)).h(px(if token { 3.0 } else { 4.0 })).rounded_full().bg(color)
};
@@ -68,6 +180,21 @@ impl SettingsPane {
window: &Window,
cx: &Context<Self>,
) -> gpui::Div {
self.terminal_appearance_preview_with_foreground(
name, typography, compact, None, window, cx,
)
}
pub(super) fn terminal_appearance_preview_with_foreground(
&self,
name: ThemeName,
typography: bool,
compact: bool,
foreground_override: Option<[u8; 3]>,
window: &Window,
cx: &Context<Self>,
) -> gpui::Div {
let language = crate::gpui_shell::config::ui_language(cx);
let term = name.term_theme();
let background = if typography && !self.runtime.follow_system_theme {
self.runtime.background.unwrap_or(term.background)
@@ -76,7 +203,7 @@ impl SettingsPane {
};
let accent = chrome_theme(name).accent();
let accent = rgb_hsla(accent.r, accent.g, accent.b);
let foreground = theme_foreground(name);
let foreground = foreground_override.unwrap_or_else(|| theme_foreground(name));
let ink = rgb_hsla(foreground[0], foreground[1], foreground[2]);
let colors = AppearanceColors::current(cx);
let family = self.current_font_chain(cx);
@@ -112,7 +239,7 @@ impl SettingsPane {
))
.child(if cfg!(windows) { "PowerShell" } else { "Terminal" }),
)
.child(div().text_size(px(9.0)).text_color(ink.opacity(0.6)).child("PREVIEW")),
.child(div().text_size(px(9.0)).text_color(ink.opacity(0.6)).child(language.text(Message::ThemeTransferPreview))),
)
.child(
v_flex()
@@ -135,7 +262,7 @@ impl SettingsPane {
.child(div().text_color(accent).child("❯"))
.child("pebrel --version"),
)
.child(div().mt(px(10.0)).text_size(px(size * 0.75)).child(format!(
.child(div().mt(px(if compact { 6.0 } else { 10.0 })).text_size(px(size * 0.75)).child(format!(
"{} · {}",
crate::brand::NAME,
std::env::consts::OS
@@ -145,11 +272,11 @@ impl SettingsPane {
.gap(px(7.0))
.text_size(px(size * 0.73))
.child(Icon::new(IconName::Check).size(px(12.0)).text_color(accent))
.child("Ready for your next idea."),
.child(language.text(Message::ThemePickerPreviewReady)),
)
.child(
h_flex()
.mt(px(17.0))
.mt(px(if compact { 8.0 } else { 17.0 }))
.gap(px(7.0))
.child(div().text_color(accent).child("❯"))
.child(div().w(px(7.0)).h(px(13.0)).bg(ink.opacity(0.85))),
@@ -165,15 +292,17 @@ impl SettingsPane {
cx: &mut Context<Self>,
) -> gpui::Div {
let picker = self.appearance_picker.as_ref().unwrap();
let AppearanceSelection::Theme(draft) = picker.draft else { unreachable!() };
let draft = picker.draft;
let language = crate::gpui_shell::config::ui_language(cx);
let colors = AppearanceColors::current(cx);
let compact_preview = compact || f32::from(window.viewport_size().height) < 740.0;
let columns = picker_columns(true, f32::from(window.viewport_size().width));
let grid_width = if compact { width } else { width - 230.0 - 25.0 };
let gap = if compact { 6.0 } else { 9.0 };
let option_width = (grid_width - (columns - 1) as f32 * gap) / columns as f32;
let grid = h_flex()
.id("appearance-theme-grid")
.debug_selector(|| "appearance-theme-grid".to_owned())
.w(px(grid_width))
.flex_shrink_0()
.flex_wrap()
@@ -181,15 +310,32 @@ impl SettingsPane {
.gap_x(px(gap))
.gap_y(px(12.0))
.role(gpui::accesskit::Role::RadioGroup)
.aria_label(language.pick("配色主题", "Color themes"))
.children(picker.draft.choices(picker.filter).into_iter().map(|choice| {
let AppearanceSelection::Theme(name) = choice else { unreachable!() };
let selected = draft == name;
let content = v_flex()
.w_full()
.p(px(if compact { 6.0 } else { 8.0 }))
.child(theme_sample(name, false, compact))
.child(
.aria_label(language.text(Message::ThemePickerTitle))
.children(picker.choices().into_iter().map(|choice| {
let selected = draft == choice;
let sample = match choice {
AppearanceSelection::Theme(name) => theme_sample_with_foreground(
name,
if selected { picker.foreground_override } else { None },
false,
compact,
),
AppearanceSelection::Custom(index) => picker
.custom_definitions
.get(index)
.map(|definition| {
theme_definition_sample(
definition,
if selected { picker.foreground_override } else { None },
false,
compact,
)
})
.unwrap_or_else(div),
AppearanceSelection::Icon(_) => div(),
};
let content =
v_flex().w_full().p(px(if compact { 6.0 } else { 8.0 })).child(sample).child(
h_flex()
.mt(px(8.0))
.justify_between()
@@ -197,7 +343,7 @@ impl SettingsPane {
.text_size(px(if compact { 10.0 } else { 11.5 }))
.text_color(if selected { colors.ink } else { colors.secondary })
.when(selected, |caption| caption.font_semibold())
.child(div().truncate().child(choice.label(language)))
.child(div().truncate().child(picker.choice_label(choice, language)))
.child(
Icon::new(IconName::Check)
.size(px(12.0))
@@ -206,42 +352,212 @@ impl SettingsPane {
);
self.appearance_option(choice, option_width, content, window, cx)
}));
let palette = chrome_theme(draft).palette();
let accent = chrome_theme(draft).accent();
let term = draft.term_theme();
let swatches = [
(term.background, language.pick("终端背景", "Terminal background")),
(
[palette.shell_bg.r, palette.shell_bg.g, palette.shell_bg.b],
language.pick("界面背景", "Interface background"),
),
(theme_foreground(draft), language.pick("文字", "Text")),
([accent.r, accent.g, accent.b], language.pick("强调色", "Accent")),
];
let preview = v_flex().w(px(if compact { width } else { 230.0 })).flex_shrink_0()
.child(self.terminal_appearance_preview(draft, false, compact, window, cx))
.child(div().mt(px(if compact { 11.0 } else { 19.0 })).text_size(px(13.0)).font_semibold()
.when(!compact, |title| title.text_center()).child(chrome_theme(draft).short_label()))
.child(div().mt(px(5.0)).text_size(px(10.5)).text_color(colors.secondary).when(!compact, |text| text.text_center())
.child(if palette.is_light { language.pick("浅色主题", "Light theme") } else { language.pick("深色主题", "Dark theme") }))
.when(!compact, |preview| preview.child(h_flex().mt(px(20.0)).pt(px(17.0)).justify_center().gap(px(9.0))
.border_t_1().border_color(colors.line).children(swatches.into_iter().enumerate().map(|(index, (color, label))| {
let description = format!("{label} {}", format_hex_rgb(color));
div().id(("theme-color-swatch", index)).size(px(24.0)).rounded_full().border_1()
.border_color(colors.control).bg(rgb_hsla(color[0], color[1], color[2]))
.tooltip(move |window, cx| gpui_component::tooltip::Tooltip::new(description.clone()).build(window, cx))
}))))
.child(div().mt(px(if compact { 9.0 } else { 20.0 })).text_size(px(10.5))
.line_height(gpui::relative(1.7)).text_color(colors.secondary)
.child(if self.runtime.follow_system_theme {
language.pick("手动应用主题后,将关闭「跟随系统」。应用图标保持不变。", "Applying a theme turns off Follow system. Your app icon stays unchanged.")
} else {
language.pick("这里只预览主题,确认后才会生效。应用图标保持不变。", "This is a preview until you confirm. Your app icon stays unchanged.")
}));
if compact {
v_flex().w(px(width)).gap(px(20.0)).child(preview).child(grid)
let picker_status = if picker.custom_loading {
Some(
div()
.text_size(px(10.5))
.text_color(colors.secondary)
.child(language.text(Message::ThemePickerLoading)),
)
} else {
h_flex().w(px(width)).items_start().gap(px(25.0)).child(grid).child(preview)
picker.custom_load_error.as_ref().map(|error| {
div()
.text_size(px(10.5))
.text_color(colors.secondary)
.child(language.format(Message::ThemeEditorLibraryError, &[("error", error)]))
})
};
let grid_column = v_flex()
.w(px(grid_width))
.gap(px(8.0))
.when_some(picker_status, |column, status| column.child(status))
.child(grid);
let (is_light, original_foreground, preview) = match draft {
AppearanceSelection::Theme(name) => (
chrome_theme(name).palette().is_light,
theme_foreground(name),
self.terminal_appearance_preview_with_foreground(
name,
false,
compact_preview,
Some(picker.foreground_override.unwrap_or_else(|| theme_foreground(name))),
window,
cx,
),
),
AppearanceSelection::Custom(index) => {
let definition = picker.custom_definitions.get(index);
let foreground = definition
.map(|definition| definition.terminal.foreground)
.unwrap_or([255, 255, 255]);
(
definition.is_some_and(|definition| definition.appearance.is_light()),
foreground,
definition
.map(|definition| {
theme_definition_sample(
definition,
Some(picker.foreground_override.unwrap_or(foreground)),
false,
compact,
)
})
.unwrap_or_else(div),
)
},
AppearanceSelection::Icon(_) => unreachable!(),
};
let recommendations = nebula_settings::foreground_recommendations(
match draft {
AppearanceSelection::Theme(name) => name.term_theme().background,
AppearanceSelection::Custom(index) => picker
.custom_definitions
.get(index)
.map(|definition| definition.terminal.background)
.unwrap_or([15, 23, 42]),
AppearanceSelection::Icon(_) => [15, 23, 42],
},
original_foreground,
);
let selected_foreground = picker.foreground_override.unwrap_or(original_foreground);
let preview = v_flex()
.w(px(if compact { width } else { 230.0 }))
.flex_shrink_0()
.child(preview)
.child(
div()
.mt(px(if compact_preview { 11.0 } else { 19.0 }))
.text_size(px(13.0))
.font_semibold()
.when(!compact, |title| title.text_center())
.child(picker.choice_label(draft, language)),
)
.child(
div()
.mt(px(5.0))
.text_size(px(10.5))
.text_color(colors.secondary)
.when(!compact, |text| text.text_center())
.child(if is_light {
language.text(Message::ThemePickerLight)
} else {
language.text(Message::ThemePickerDark)
}),
)
.child(
h_flex()
.mt(px(if compact_preview { 9.0 } else { 20.0 }))
.pt(px(if compact_preview { 9.0 } else { 17.0 }))
.justify_center()
.gap(px(9.0))
.border_t_1()
.border_color(colors.line)
.children(recommendations.iter().copied().enumerate().map(|(index, color)| {
let selected = selected_foreground == color;
let focus = picker.foreground_focus[index].clone();
let description = format!(
"{} {}",
match index {
0 => language.text(Message::ThemePickerTextOriginal),
1 => language.text(Message::ThemePickerTextCool),
_ => language.text(Message::ThemePickerTextWarm),
},
format_hex_rgb(color)
);
div()
.id(("theme-foreground-swatch", index))
.debug_selector(move || format!("theme-foreground-swatch-{index}"))
.size(px(28.0))
.rounded_full()
.track_focus(&focus.clone().tab_stop(true))
.role(gpui::accesskit::Role::RadioButton)
.aria_label(description.clone())
.aria_toggled(if selected {
gpui::accesskit::Toggled::True
} else {
gpui::accesskit::Toggled::False
})
.border_1()
.border_color(if selected { colors.primary } else { colors.control })
.when(focus.is_focused(window), |swatch| {
swatch.border_color(colors.ink)
})
.bg(rgb_hsla(color[0], color[1], color[2]))
.cursor_pointer()
.tooltip(move |window, cx| {
gpui_component::tooltip::Tooltip::new(description.clone())
.build(window, cx)
})
.on_click(cx.listener(move |this, _, window, cx| {
this.set_theme_foreground(color, window, cx);
}))
.on_key_down(cx.listener(
move |this, event: &KeyDownEvent, window, cx| {
if event.keystroke.key.eq_ignore_ascii_case("enter")
|| event.keystroke.key.eq_ignore_ascii_case("space")
{
cx.stop_propagation();
this.set_theme_foreground(color, window, cx);
}
},
))
}))
.child({
let custom = picker.foreground_override.is_some()
&& !recommendations.contains(&selected_foreground);
let description = language.text(Message::ThemePickerTextCustom);
let focus = picker.foreground_focus[3].clone();
div()
.id("theme-foreground-custom-swatch")
.debug_selector(|| "theme-foreground-custom-swatch".to_owned())
.size(px(28.0))
.rounded_full()
.track_focus(&focus.clone().tab_stop(true))
.role(gpui::accesskit::Role::Button)
.aria_label(description)
.overflow_hidden()
.border_1()
.p(px(1.0))
.border_color(if custom { colors.primary } else { colors.control })
.when(focus.is_focused(window), |swatch| {
swatch.border_color(colors.ink)
})
.cursor_pointer()
.tooltip(move |window, cx| {
gpui_component::tooltip::Tooltip::new(description).build(window, cx)
})
.child(rainbow_swatch())
.on_click(cx.listener(|this, _, window, cx| {
this.open_theme_foreground_picker(window, cx);
}))
.on_key_down(cx.listener(
move |this, event: &KeyDownEvent, window, cx| {
if event.keystroke.key.eq_ignore_ascii_case("enter")
|| event.keystroke.key.eq_ignore_ascii_case("space")
{
cx.stop_propagation();
this.open_theme_foreground_picker(window, cx);
}
},
))
}),
)
.child(
div()
.mt(px(if compact_preview { 9.0 } else { 20.0 }))
.text_size(px(10.5))
.line_height(gpui::relative(1.7))
.text_color(colors.secondary)
.child(if self.runtime.follow_system_theme {
language.text(Message::ThemePickerManualHint)
} else {
language.text(Message::ThemePickerPreviewHint)
}),
);
if compact {
v_flex().w(px(width)).gap(px(20.0)).child(preview).child(grid_column)
} else {
h_flex().w(px(width)).items_start().gap(px(25.0)).child(grid_column).child(preview)
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,624 @@
//! Background theme import/export orchestration for the native settings pane.
//!
//! This module deliberately stops at the library boundary. It owns picker
//! lifetimes, bounded file reads, preview state, and atomic export writes; the
//! settings UI decides how a candidate is displayed and how an imported
//! document becomes an editor draft. No operation in this module publishes a
//! runtime preference.
use super::*;
use crate::theme_library::{
ExportArtifact, ImportCandidate, ImportDiagnostic, Inspection, MAX_IMPORT_CANDIDATES,
MAX_INPUT_BYTES, ThemeDocument, ThemeFormat, export, inspect,
};
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
/// The transfer surface has one modal with two modes. Keeping the mode in
/// state means a late picker result cannot accidentally be rendered as the
/// other operation after the user reopens the editor.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ThemeTransferMode {
Import,
Export,
}
/// UI-facing lifecycle phase. The UI can localize the labels while this
/// module keeps transitions independent from the current language.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ThemeTransferStatus {
Idle,
Picking,
Inspecting,
Ready,
Importing,
Exporting,
Writing,
Completed,
Error,
}
/// State retained by SettingsPane while a transfer modal is open.
///
/// The vectors and artifacts are snapshots. They are safe to render without
/// touching the filesystem, and the selected import document is only moved to
/// the editor by the caller after confirm_theme_import completes.
pub(super) struct ThemeTransferState {
pub(super) mode: Option<ThemeTransferMode>,
pub(super) sequence: u64,
pub(super) busy: bool,
pub(super) status: ThemeTransferStatus,
pub(super) error: Option<String>,
pub(super) notice: Option<String>,
pub(super) import_candidates: Vec<ImportCandidate>,
pub(super) import_diagnostics: Vec<ImportDiagnostic>,
pub(super) selected_import: usize,
/// The confirmed source document handed to the editor draft. Persistence
/// and custom ID/revision assignment happen only when the editor saves.
pub(super) imported_document: Option<ThemeDocument>,
pub(super) export_document: Option<ThemeDocument>,
pub(super) export_format: ThemeFormat,
pub(super) export_artifact: Option<ExportArtifact>,
pub(super) export_path: Option<PathBuf>,
}
impl Default for ThemeTransferState {
fn default() -> Self {
Self {
mode: None,
sequence: 0,
busy: false,
status: ThemeTransferStatus::Idle,
error: None,
notice: None,
import_candidates: Vec::new(),
import_diagnostics: Vec::new(),
selected_import: 0,
imported_document: None,
export_document: None,
export_format: ThemeFormat::Pebrel,
export_artifact: None,
export_path: None,
}
}
}
impl ThemeTransferState {
fn advance(&mut self) -> u64 {
self.sequence = self.sequence.wrapping_add(1);
self.sequence
}
fn open(&mut self, mode: ThemeTransferMode) -> u64 {
let sequence = self.advance();
self.mode = Some(mode);
self.busy = true;
self.status = ThemeTransferStatus::Picking;
self.error = None;
self.notice = None;
self.import_candidates.clear();
self.import_diagnostics.clear();
self.selected_import = 0;
self.imported_document = None;
self.export_document = None;
self.export_artifact = None;
self.export_path = None;
sequence
}
fn begin_task(&mut self, status: ThemeTransferStatus) -> u64 {
let sequence = self.advance();
self.busy = true;
self.status = status;
self.error = None;
self.notice = None;
self.imported_document = None;
self.export_path = None;
sequence
}
fn invalidate(&mut self) {
// Increment before clearing the mode. Any callback that already owns
// the old token will then fail its equality check even if it runs in
// the same event turn as the close action.
let sequence = self.advance();
let format = self.export_format;
*self = Self { sequence, export_format: format, ..Self::default() };
}
fn is_current(&self, sequence: u64) -> bool {
self.sequence == sequence
}
}
impl SettingsPane {
/// Start a multi-file import. The picker is asynchronous and all file
/// reads and format inspection run on the background executor.
pub(super) fn open_theme_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.theme_editor.as_ref().is_some_and(|editor| editor.save_busy) {
return;
}
let sequence = self.theme_transfer.open(ThemeTransferMode::Import);
let language = crate::gpui_shell::config::ui_language(cx);
let picked = crate::platform::file_picker::files(
window,
cx,
language.text(crate::i18n::Message::ThemeTransferImportTitle),
);
let executor = cx.background_executor().clone();
cx.notify();
cx.spawn_in(window, async move |this, cx| {
let paths = picked.await;
if paths.is_empty() {
let _ = this.update(cx, |this, cx| {
this.cancel_theme_transfer_if_current(sequence, cx);
});
return;
}
let transitioned = this
.update(cx, |this, cx| {
if !this.theme_transfer.is_current(sequence) {
return false;
}
this.theme_transfer.status = ThemeTransferStatus::Inspecting;
cx.notify();
true
})
.ok()
.unwrap_or(false);
if !transitioned {
return;
}
let inspection = executor.spawn(async move { inspect_paths(paths) }).await;
let _ = this.update(cx, |this, cx| {
this.finish_theme_import_inspection(sequence, inspection, cx);
});
})
.detach();
}
/// Select a candidate in the import preview without touching the store.
pub(super) fn select_theme_import(&mut self, index: usize, cx: &mut Context<Self>) {
if self.theme_transfer.mode != Some(ThemeTransferMode::Import)
|| self.theme_transfer.busy
|| index >= self.theme_transfer.import_candidates.len()
{
return;
}
self.theme_transfer.selected_import = index;
self.theme_transfer.error = None;
cx.notify();
}
pub(super) fn selected_theme_import(&self) -> Option<&ImportCandidate> {
self.theme_transfer.import_candidates.get(self.theme_transfer.selected_import)
}
/// Confirm the currently selected preview and hand its document to the
/// editor. The optional name is applied to the draft by the UI; no library
/// file is written here. The editor's Save action owns persistence and
/// optimistic conflict handling.
pub(super) fn confirm_theme_import(
&mut self,
requested_name: Option<String>,
cx: &mut Context<Self>,
) {
if self.theme_transfer.mode != Some(ThemeTransferMode::Import) || self.theme_transfer.busy {
return;
}
let Some(candidate) =
self.theme_transfer.import_candidates.get(self.theme_transfer.selected_import).cloned()
else {
self.theme_transfer.status = ThemeTransferStatus::Error;
self.theme_transfer.error = Some("no importable theme is selected".to_owned());
cx.notify();
return;
};
let mut document = candidate.document;
if let Some(name) = requested_name.filter(|name| !name.trim().is_empty()) {
match document.with_name(name.trim().to_owned()) {
Ok(named) => document = named,
Err(error) => {
self.theme_transfer.status = ThemeTransferStatus::Error;
self.theme_transfer.error = Some(error.to_string());
self.theme_transfer.notice = None;
cx.notify();
return;
},
}
}
self.theme_transfer.imported_document = Some(document.clone());
self.theme_transfer.busy = false;
self.theme_transfer.status = ThemeTransferStatus::Completed;
self.theme_transfer.notice = Some(format!("theme draft ready: {}", document.name()));
self.theme_transfer.error = None;
cx.notify();
}
/// Move the selected document to the UI layer. It has not been written to
/// the library yet; the editor owns the later Save operation.
pub(super) fn take_imported_theme(&mut self) -> Option<ThemeDocument> {
self.theme_transfer.imported_document.take()
}
/// Open an export preview for the editor's current draft. This operation
/// serializes the draft only; it does not save or activate it.
pub(super) fn open_theme_export(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
if self.theme_editor.as_ref().is_some_and(|editor| editor.save_busy) {
return;
}
let document = self.document_for_draft().ok();
let Some(document) = document else {
self.open_theme_export_error(
"the current theme draft could not be encoded".to_owned(),
cx,
);
return;
};
let format = self.theme_transfer.export_format;
self.open_theme_export_document(document, format, cx);
}
/// Variant for callers that already own a validated document (for example,
/// an imported theme or a picker preview).
pub(super) fn open_theme_export_document(
&mut self,
document: ThemeDocument,
format: ThemeFormat,
cx: &mut Context<Self>,
) {
let sequence = self.theme_transfer.open(ThemeTransferMode::Export);
self.theme_transfer.export_document = Some(document.clone());
self.theme_transfer.export_format = format;
self.theme_transfer.status = ThemeTransferStatus::Exporting;
cx.notify();
self.start_theme_export_prepare(sequence, document, format, cx);
}
/// Change the output adapter while keeping the current document. The
/// previous preparation task becomes stale immediately, so a slow format
/// conversion cannot overwrite the newly selected format.
pub(super) fn select_theme_export_format(
&mut self,
format: ThemeFormat,
cx: &mut Context<Self>,
) {
if self.theme_transfer.mode != Some(ThemeTransferMode::Export)
|| self.theme_transfer.busy
|| self.theme_transfer.export_format == format
{
return;
}
let Some(document) = self.theme_transfer.export_document.clone() else {
self.open_theme_export_error("no theme is ready to export".to_owned(), cx);
return;
};
let sequence = self.theme_transfer.begin_task(ThemeTransferStatus::Exporting);
self.theme_transfer.export_format = format;
self.theme_transfer.export_artifact = None;
cx.notify();
self.start_theme_export_prepare(sequence, document, format, cx);
}
fn start_theme_export_prepare(
&mut self,
sequence: u64,
document: ThemeDocument,
format: ThemeFormat,
cx: &mut Context<Self>,
) {
let executor = cx.background_executor().clone();
cx.spawn(async move |this, cx| {
let result = executor
.spawn(async move { export(&document, format).map_err(|error| error.to_string()) })
.await;
let _ = this.update(cx, |this, cx| {
this.finish_theme_export_prepare(sequence, result, cx);
});
})
.detach();
}
/// Confirm the prepared artifact and choose its destination. The write
/// itself is bounded to the already prepared string and uses the same
/// atomic writer as the rest of the application state.
pub(super) fn confirm_theme_export(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.theme_transfer.mode != Some(ThemeTransferMode::Export) || self.theme_transfer.busy {
return;
}
let Some(artifact) = self.theme_transfer.export_artifact.clone() else {
self.theme_transfer.status = ThemeTransferStatus::Error;
self.theme_transfer.error = Some("export preview is not ready".to_owned());
cx.notify();
return;
};
let name = self
.theme_transfer
.export_document
.as_ref()
.map(|document| document.name())
.unwrap_or("theme");
let suggested = suggested_export_filename(name, artifact.extension);
let directory = std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir());
let picked = cx.prompt_for_new_path(&directory, Some(&suggested));
// The destination picker is still cancellable. Lock the modal only
// after a concrete path has been returned and the atomic write is
// about to begin.
let sequence = self.theme_transfer.begin_task(ThemeTransferStatus::Picking);
let executor = cx.background_executor().clone();
cx.notify();
cx.spawn_in(window, async move |this, cx| {
let path = match picked.await {
Ok(Ok(Some(path))) => path,
Ok(Ok(None)) | Err(_) => {
let _ = this.update(cx, |this, cx| {
this.cancel_theme_transfer_if_current(sequence, cx);
});
return;
},
Ok(Err(error)) => {
let _ = this.update(cx, |this, cx| {
this.fail_theme_transfer_if_current(sequence, error.to_string(), cx);
});
return;
},
};
let transitioned = this
.update(cx, |this, cx| {
if !this.theme_transfer.is_current(sequence) {
return false;
}
this.theme_transfer.status = ThemeTransferStatus::Writing;
cx.notify();
true
})
.ok()
.unwrap_or(false);
if !transitioned {
return;
}
let write_path = path.clone();
let write_result = executor
.spawn(async move {
crate::atomic_file::write(&write_path, artifact.text.as_bytes())
.map_err(|error| error.to_string())
})
.await
.map(|_| path);
let _ = this.update(cx, |this, cx| {
this.finish_theme_export_write(sequence, write_result, cx);
});
})
.detach();
}
/// Close or cancel the transfer modal. Incrementing the sequence is
/// essential: picker and background futures are intentionally detached and
/// may complete after this call.
pub(super) fn cancel_theme_transfer(&mut self, cx: &mut Context<Self>) {
self.theme_transfer.invalidate();
cx.notify();
}
pub(super) fn theme_transfer_is_open(&self) -> bool {
self.theme_transfer.mode.is_some()
}
fn finish_theme_import_inspection(
&mut self,
sequence: u64,
inspection: Inspection,
cx: &mut Context<Self>,
) {
if !self.theme_transfer.is_current(sequence) {
return;
}
self.theme_transfer.busy = false;
self.theme_transfer.import_candidates = inspection.candidates;
self.theme_transfer.import_diagnostics = inspection.diagnostics;
self.theme_transfer.selected_import = 0;
if self.theme_transfer.import_candidates.is_empty() {
self.theme_transfer.status = ThemeTransferStatus::Error;
self.theme_transfer.error = Some("no importable themes were found".to_owned());
} else {
self.theme_transfer.status = ThemeTransferStatus::Ready;
self.theme_transfer.error = None;
self.theme_transfer.notice = Some(format!(
"{} theme{} ready to import",
self.theme_transfer.import_candidates.len(),
if self.theme_transfer.import_candidates.len() == 1 { " is" } else { "s are" },
));
}
cx.notify();
}
fn finish_theme_export_prepare(
&mut self,
sequence: u64,
result: Result<ExportArtifact, String>,
cx: &mut Context<Self>,
) {
if !self.theme_transfer.is_current(sequence) {
return;
}
self.theme_transfer.busy = false;
match result {
Ok(artifact) => {
self.theme_transfer.export_artifact = Some(artifact);
self.theme_transfer.status = ThemeTransferStatus::Ready;
self.theme_transfer.error = None;
self.theme_transfer.notice = None;
},
Err(error) => {
self.theme_transfer.export_artifact = None;
self.theme_transfer.status = ThemeTransferStatus::Error;
self.theme_transfer.error = Some(error);
self.theme_transfer.notice = None;
},
}
cx.notify();
}
fn finish_theme_export_write(
&mut self,
sequence: u64,
result: Result<PathBuf, String>,
cx: &mut Context<Self>,
) {
if !self.theme_transfer.is_current(sequence) {
return;
}
self.theme_transfer.busy = false;
match result {
Ok(path) => {
self.theme_transfer.export_path = Some(path.clone());
self.theme_transfer.status = ThemeTransferStatus::Completed;
self.theme_transfer.notice = Some(format!("exported theme to {}", path.display()));
self.theme_transfer.error = None;
},
Err(error) => {
self.theme_transfer.status = ThemeTransferStatus::Error;
self.theme_transfer.error = Some(error);
self.theme_transfer.notice = None;
},
}
cx.notify();
}
fn cancel_theme_transfer_if_current(&mut self, sequence: u64, cx: &mut Context<Self>) {
if self.theme_transfer.is_current(sequence) {
self.theme_transfer.invalidate();
cx.notify();
}
}
fn fail_theme_transfer_if_current(
&mut self,
sequence: u64,
error: String,
cx: &mut Context<Self>,
) {
if !self.theme_transfer.is_current(sequence) {
return;
}
self.theme_transfer.busy = false;
self.theme_transfer.status = ThemeTransferStatus::Error;
self.theme_transfer.error = Some(error);
self.theme_transfer.notice = None;
cx.notify();
}
fn open_theme_export_error(&mut self, error: String, cx: &mut Context<Self>) {
self.theme_transfer.open(ThemeTransferMode::Export);
self.theme_transfer.busy = false;
self.theme_transfer.status = ThemeTransferStatus::Error;
self.theme_transfer.error = Some(error);
cx.notify();
}
}
/// Read one selected file with both a metadata guard and a bounded stream
/// read. The second check protects against a file growing after the picker
/// returned and keeps memory usage independent of an untrusted file size.
fn read_theme_file(path: &Path) -> Result<String, String> {
let metadata =
std::fs::symlink_metadata(path).map_err(|error| format!("{}: {error}", path.display()))?;
if metadata.file_type().is_symlink() {
return Err(format!("{}: symbolic links are not accepted", path.display()));
}
if !metadata.is_file() {
return Err(format!("{}: path is not a regular file", path.display()));
}
if metadata.len() > MAX_INPUT_BYTES as u64 {
return Err(format!(
"{}: input exceeds the 256 KiB limit ({} bytes)",
path.display(),
metadata.len()
));
}
let mut file = fs::File::open(path).map_err(|error| format!("{}: {error}", path.display()))?;
let mut bytes = Vec::new();
file.by_ref()
.take((MAX_INPUT_BYTES + 1) as u64)
.read_to_end(&mut bytes)
.map_err(|error| format!("{}: {error}", path.display()))?;
if bytes.len() > MAX_INPUT_BYTES {
return Err(format!(
"{}: input exceeds the 256 KiB limit (more than {} bytes)",
path.display(),
MAX_INPUT_BYTES
));
}
String::from_utf8(bytes)
.map_err(|error| format!("{}: input is not UTF-8 ({error})", path.display()))
}
fn inspect_paths(paths: Vec<PathBuf>) -> Inspection {
let mut inspection = Inspection::default();
for path in paths {
let filename = path.display().to_string();
// Format detection only needs the basename. Passing the full path
// could select the wrong theme format based on a parent directory's
// name instead of the file itself.
let detected_filename = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(filename.as_str())
.to_owned();
match read_theme_file(&path) {
Ok(text) => {
let result = inspect(&text, detected_filename);
let mut candidates = result.candidates;
let available = MAX_IMPORT_CANDIDATES.saturating_sub(inspection.candidates.len());
let take = available.min(candidates.len());
let omitted = candidates.len().saturating_sub(take);
inspection.candidates.extend(candidates.drain(..take).map(|mut candidate| {
candidate.filename = filename.clone();
candidate
}));
inspection.diagnostics.extend(result.diagnostics.into_iter().map(
|mut diagnostic| {
diagnostic.filename = filename.clone();
diagnostic
},
));
if omitted > 0 {
inspection.diagnostics.push(ImportDiagnostic {
filename,
format: None,
message: format!(
"import candidate limit reached; {omitted} additional theme{} were skipped (limit {MAX_IMPORT_CANDIDATES})",
if omitted == 1 { "" } else { "s" }
),
});
}
},
Err(message) => {
inspection.diagnostics.push(ImportDiagnostic { filename, format: None, message })
},
}
}
inspection
}
fn suggested_export_filename(name: &str, extension: &str) -> String {
let mut base = String::new();
for character in name.chars() {
if character.is_control()
|| matches!(character, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|')
{
base.push('-');
} else {
base.push(character);
}
}
let base = base.trim().trim_matches('.');
let base = if base.is_empty() { "theme" } else { base };
format!("{base}.{extension}")
}
@@ -0,0 +1,622 @@
//! Native theme transfer modal.
//!
//! File work and persistence live in theme_transfer.rs. This module only
//! renders snapshots from that state and routes user actions back through the
//! transfer state machine.
use super::appearance_picker::AppearanceColors;
use super::theme_picker::theme_definition_sample;
use super::theme_transfer::{ThemeTransferMode, ThemeTransferStatus};
use super::*;
use crate::i18n::Message;
use crate::theme_library::{ImportCandidate, ThemeFormat};
use gpui::accesskit::Role;
const EXPORT_FORMATS: [ThemeFormat; 6] = [
ThemeFormat::Pebrel,
ThemeFormat::WindowsTerminal,
ThemeFormat::Kitty,
ThemeFormat::Ghostty,
ThemeFormat::WezTerm,
ThemeFormat::Alacritty,
];
impl SettingsPane {
pub(super) fn theme_transfer_modal(
&self,
window: &Window,
cx: &mut Context<Self>,
) -> Option<gpui::AnyElement> {
let mode = self.theme_transfer.mode?;
let language = crate::gpui_shell::config::ui_language(cx);
let colors = AppearanceColors::current(cx);
let viewport = window.viewport_size();
let compact = f32::from(viewport.width) < 720.0;
let width = (f32::from(viewport.width) - if compact { 24.0 } else { 40.0 })
.min(if compact { 520.0 } else { 760.0 })
.max(280.0);
let padding = if compact { 18.0 } else { 25.0 };
let busy = self.theme_transfer.busy;
let status = self.theme_transfer.status;
let write_locked =
matches!((mode, status), (ThemeTransferMode::Export, ThemeTransferStatus::Writing));
let error = self.theme_transfer.error.clone();
let completed = matches!(status, ThemeTransferStatus::Completed);
let notice = completed.then(|| match mode {
ThemeTransferMode::Import => language.text(Message::ThemeTransferTemplateReady),
ThemeTransferMode::Export => language.text(Message::ThemeTransferExported),
});
let title = match mode {
ThemeTransferMode::Import => language.text(Message::ThemeTransferImportTitle),
ThemeTransferMode::Export => language.text(Message::ThemeTransferExportTitle),
};
let subtitle = match mode {
ThemeTransferMode::Import => language.text(Message::ThemeTransferImportDescription),
ThemeTransferMode::Export => language.text(Message::ThemeTransferExportDescription),
};
let body = match mode {
ThemeTransferMode::Import => self.theme_transfer_import_body(compact, colors, cx),
ThemeTransferMode::Export => self.theme_transfer_export_body(compact, colors, cx),
};
let confirm_enabled = match mode {
ThemeTransferMode::Import => {
!busy
&& !self.theme_transfer.import_candidates.is_empty()
&& !matches!(status, ThemeTransferStatus::Completed)
},
ThemeTransferMode::Export => {
!busy
&& self.theme_transfer.export_artifact.is_some()
&& !matches!(status, ThemeTransferStatus::Completed)
},
};
let confirm_label = match mode {
ThemeTransferMode::Import => language.text(Message::ThemeTransferUseTemplate),
ThemeTransferMode::Export => language.text(Message::ThemeTransferExport),
};
let status_label = transfer_status_label(status, language);
let dialog = v_flex()
.id("theme-transfer-dialog")
.debug_selector(|| "theme-transfer-dialog".to_owned())
.role(Role::Dialog)
.aria_label(title)
.w(px(width))
.max_h(px((f32::from(viewport.height) - 28.0).max(240.0)))
.rounded(px(14.0))
.border_1()
.border_color(colors.control)
.bg(colors.surface)
.text_color(colors.ink)
.shadow_2xl()
.overflow_hidden()
.on_key_down(cx.listener(move |this, event: &KeyDownEvent, _window, cx| {
if event.keystroke.key.eq_ignore_ascii_case("escape") {
cx.stop_propagation();
if !write_locked {
this.cancel_theme_transfer(cx);
}
}
}))
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.on_click(|_, _, cx| cx.stop_propagation())
.child(
h_flex()
.px(px(padding))
.pt(px(if compact { 18.0 } else { 23.0 }))
.pb(px(if compact { 14.0 } else { 18.0 }))
.gap(px(12.0))
.items_start()
.flex_shrink_0()
.border_b_1()
.border_color(colors.line)
.child(
v_flex()
.flex_1()
.min_w_0()
.gap(px(5.0))
.child(
div()
.text_size(px(if compact { 17.0 } else { 19.0 }))
.font_semibold()
.child(title),
)
.child(
div()
.text_size(px(11.0))
.text_color(colors.secondary)
.child(subtitle),
),
)
.child(
h_flex()
.gap(px(7.0))
.items_center()
.child(
div()
.text_size(px(10.0))
.text_color(colors.muted)
.child(status_label),
)
.child(
Button::new("theme-transfer-close")
.icon(IconName::Close)
.ghost()
.size(px(28.0))
.text_color(colors.secondary)
.disabled(write_locked)
.tooltip(language.text(Message::ThemeTransferClose))
.on_click(cx.listener(|this, _, _, cx| {
this.cancel_theme_transfer(cx);
})),
),
),
)
.child(
div()
.id("theme-transfer-scroll")
.min_h_0()
.flex_1()
.overflow_y_scroll()
.px(px(padding))
.py(px(if compact { 16.0 } else { 21.0 }))
.child(body),
)
.when_some(error, |dialog, error| {
dialog.child(
div()
.px(px(padding))
.pb(px(10.0))
.text_size(px(11.0))
.text_color(cx.theme().danger)
.child(error),
)
})
.when_some(notice, |dialog, notice| {
if completed {
dialog.child(
div()
.px(px(padding))
.pb(px(10.0))
.text_size(px(11.0))
.text_color(cx.theme().success)
.child(notice),
)
} else {
dialog
}
})
.child(
h_flex()
.px(px(padding))
.py(px(15.0))
.gap(px(8.0))
.justify_end()
.flex_shrink_0()
.border_t_1()
.border_color(colors.line)
.child(
Button::new("theme-transfer-cancel")
.debug_selector(|| "theme-transfer-cancel".to_owned())
.label(if write_locked {
language.text(Message::ThemeTransferWriting)
} else {
language.text(Message::ThemeTransferCancel)
})
.ghost()
.h(px(32.0))
.px(px(if compact { 10.0 } else { 14.0 }))
.disabled(write_locked)
.on_click(cx.listener(|this, _, _, cx| {
this.cancel_theme_transfer(cx);
})),
)
.child(if completed {
Button::new("theme-transfer-done")
.debug_selector(|| "theme-transfer-done".to_owned())
.label(language.text(Message::ThemeTransferClose))
.with_variant(ButtonVariant::Primary)
.h(px(32.0))
.px(px(if compact { 11.0 } else { 16.0 }))
.on_click(cx.listener(|this, _, _, cx| {
this.cancel_theme_transfer(cx);
}))
.into_any_element()
} else {
Button::new("theme-transfer-confirm")
.debug_selector(|| "theme-transfer-confirm".to_owned())
.label(confirm_label)
.with_variant(ButtonVariant::Primary)
.h(px(32.0))
.px(px(if compact { 11.0 } else { 16.0 }))
.disabled(!confirm_enabled)
.on_click(cx.listener(move |this, _, window, cx| match mode {
ThemeTransferMode::Import => {
this.confirm_theme_import(None, cx);
if let Some(document) = this.take_imported_theme() {
match this.load_imported_theme_editor(document, window, cx)
{
Ok(()) => this.cancel_theme_transfer(cx),
Err(error) => {
this.theme_transfer.status =
ThemeTransferStatus::Error;
this.theme_transfer.error = Some(error);
cx.notify();
},
}
}
},
ThemeTransferMode::Export => {
this.confirm_theme_export(window, cx);
},
}))
.into_any_element()
}),
)
.into_any_element();
Some(
deferred(
anchored()
.anchor(gpui::Anchor::TopLeft)
.position(gpui::point(px(0.0), px(0.0)))
.child(
div()
.id("theme-transfer-overlay")
.w(viewport.width)
.h(viewport.height)
.flex()
.items_center()
.justify_center()
.occlude()
.bg(colors.scrim)
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _, _, cx| {
if write_locked {
cx.stop_propagation();
} else {
this.cancel_theme_transfer(cx);
}
}),
)
.on_scroll_wheel(|_, _, cx| cx.stop_propagation())
.child(dialog),
),
)
.with_priority(6)
.into_any_element(),
)
}
fn theme_transfer_import_body(
&self,
compact: bool,
colors: AppearanceColors,
cx: &mut Context<Self>,
) -> gpui::Div {
let language = crate::gpui_shell::config::ui_language(cx);
let state = &self.theme_transfer;
let candidates = state.import_candidates.clone();
let selected = state.selected_import;
let diagnostics = state.import_diagnostics.clone();
let busy = state.busy;
let candidate_count = candidates.len();
let cards = candidates.iter().enumerate().map(|(index, candidate)| {
self.theme_transfer_candidate_card(
index,
candidate,
index == selected,
compact,
colors,
cx,
)
});
let preview = candidates
.get(selected)
.and_then(|candidate| candidate.document.definition().ok())
.map(|definition| {
theme_definition_sample(&definition, None, false, compact)
.id("theme-transfer-selected-preview")
});
let mut body = v_flex().w_full().gap(px(if compact { 14.0 } else { 18.0 }));
if candidate_count == 0 {
body = body.child(
div()
.text_size(px(12.0))
.text_color(colors.secondary)
.child(language.text(Message::ThemeTransferNoCandidates)),
);
} else {
body = body
.child(
h_flex()
.w_full()
.justify_between()
.gap(px(8.0))
.child(
div()
.text_size(px(11.0))
.text_color(colors.secondary)
.child(language.text(Message::ThemeTransferCandidates)),
)
.child(
div()
.text_size(px(10.0))
.text_color(colors.muted)
.child(format!("{candidate_count}")),
),
)
.child(v_flex().w_full().gap(px(7.0)).children(cards))
.when_some(preview, |body, preview| {
body.child(
v_flex()
.w_full()
.gap(px(7.0))
.child(
div()
.text_size(px(11.0))
.text_color(colors.secondary)
.child(language.text(Message::ThemeTransferPreview)),
)
.child(preview),
)
});
}
if !diagnostics.is_empty() {
body = body.child(self.theme_transfer_diagnostics(&diagnostics, colors, cx));
}
body
}
fn theme_transfer_candidate_card(
&self,
index: usize,
candidate: &ImportCandidate,
selected: bool,
compact: bool,
colors: AppearanceColors,
cx: &mut Context<Self>,
) -> gpui::Stateful<gpui::Div> {
let language = crate::gpui_shell::config::ui_language(cx);
let name = candidate.document.name().to_owned();
let format = candidate.format.to_string();
let filename = candidate.filename.clone();
let warnings = candidate.warnings.clone();
let preview = candidate
.document
.definition()
.ok()
.map(|definition| theme_definition_sample(&definition, None, true, compact));
let selected_bg = colors.selected;
let hover_bg = colors.subtle;
let card = v_flex()
.id(("theme-transfer-candidate", index))
.debug_selector(move || format!("theme-transfer-candidate-{index}"))
.w_full()
.gap(px(8.0))
.p(px(if compact { 9.0 } else { 11.0 }))
.rounded(px(8.0))
.border_1()
.border_color(if selected { colors.primary } else { colors.control })
.bg(if selected { selected_bg } else { colors.surface })
.cursor_pointer()
.hover(move |card| card.bg(hover_bg))
.on_click(cx.listener(move |this, _, _, cx| {
this.select_theme_import(index, cx);
}));
card.child(
h_flex()
.w_full()
.gap(px(9.0))
.items_start()
.child(
div()
.w(px(if compact { 112.0 } else { 145.0 }))
.flex_shrink_0()
.when_some(preview, |slot, preview| slot.child(preview)),
)
.child(
v_flex()
.flex_1()
.min_w_0()
.gap(px(3.0))
.child(
h_flex()
.w_full()
.justify_between()
.gap(px(6.0))
.child(div().font_medium().truncate().child(name))
.child(
div()
.flex_shrink_0()
.text_size(px(10.0))
.text_color(colors.secondary)
.child(format),
),
)
.child(
div()
.text_size(px(10.0))
.text_color(colors.muted)
.truncate()
.child(filename),
)
.when(!warnings.is_empty(), |row| {
row.child(
div().text_size(px(10.0)).text_color(cx.theme().warning).child(
format!(
"{} {}",
language.text(Message::ThemeTransferWarnings),
warnings.join("; ")
),
),
)
}),
),
)
}
fn theme_transfer_diagnostics(
&self,
diagnostics: &[crate::theme_library::ImportDiagnostic],
colors: AppearanceColors,
cx: &mut Context<Self>,
) -> gpui::Div {
let language = crate::gpui_shell::config::ui_language(cx);
v_flex()
.w_full()
.gap(px(5.0))
.pt(px(8.0))
.border_t_1()
.border_color(colors.line)
.child(
div()
.text_size(px(11.0))
.font_medium()
.text_color(colors.secondary)
.child(language.text(Message::ThemeTransferDiagnostics)),
)
.children(diagnostics.iter().map(|diagnostic| {
v_flex()
.gap(px(2.0))
.text_size(px(10.0))
.text_color(cx.theme().danger)
.child(diagnostic.message.clone())
.child(
div()
.text_color(colors.muted)
.truncate()
.child(diagnostic.filename.clone()),
)
}))
}
fn theme_transfer_export_body(
&self,
compact: bool,
colors: AppearanceColors,
cx: &mut Context<Self>,
) -> gpui::Div {
let language = crate::gpui_shell::config::ui_language(cx);
let state = &self.theme_transfer;
let format = state.export_format;
let artifact = state.export_artifact.clone();
let losses = artifact.as_ref().map(|artifact| artifact.losses.clone()).unwrap_or_default();
let summary = artifact.as_ref().map(|artifact| artifact.summary.clone());
let busy = state.busy;
let selected_definition =
state.export_document.as_ref().and_then(|document| document.definition().ok());
let mut body = v_flex().w_full().gap(px(if compact { 14.0 } else { 18.0 })).child(
v_flex()
.w_full()
.gap(px(7.0))
.child(
div()
.text_size(px(11.0))
.text_color(colors.secondary)
.child(language.text(Message::ThemeTransferOutputFormat)),
)
.child(h_flex().w_full().flex_wrap().gap(px(5.0)).children(
EXPORT_FORMATS.into_iter().enumerate().map(|(index, candidate)| {
let selected = candidate == format;
Button::new(("theme-transfer-format", index))
.debug_selector(move || {
format!("theme-transfer-format-{:?}", candidate)
})
.role(Role::RadioButton)
.toggled(selected)
.label(candidate.to_string())
.ghost()
.small()
.rounded_full()
.text_color(if selected { colors.ink } else { colors.secondary })
.when(selected, |button| button.bg(colors.selected).font_semibold())
.disabled(busy)
.on_click(cx.listener(move |this, _, _, cx| {
this.select_theme_export_format(candidate, cx);
}))
}),
)),
);
if let Some(definition) = selected_definition {
body = body.child(
v_flex()
.w_full()
.gap(px(7.0))
.child(
div()
.text_size(px(11.0))
.text_color(colors.secondary)
.child(language.text(Message::ThemeTransferPreview)),
)
.child(theme_definition_sample(&definition, None, false, compact)),
);
}
if let Some(summary) = summary {
body =
body.child(div().text_size(px(11.0)).text_color(colors.secondary).child(summary));
}
if !losses.is_empty() {
body = body.child(
v_flex()
.w_full()
.gap(px(5.0))
.pt(px(8.0))
.border_t_1()
.border_color(colors.line)
.child(
div()
.text_size(px(11.0))
.font_medium()
.text_color(cx.theme().warning)
.child(language.text(Message::ThemeTransferLosses)),
)
.children(losses.into_iter().map(|loss| {
div()
.text_size(px(10.0))
.text_color(colors.secondary)
.child(format!("- {loss}"))
})),
);
}
if let Some(path) = &state.export_path {
body = body.child(
v_flex()
.gap(px(3.0))
.pt(px(8.0))
.border_t_1()
.border_color(colors.line)
.child(
div()
.text_size(px(11.0))
.text_color(colors.secondary)
.child(language.text(Message::ThemeTransferExported)),
)
.child(
div()
.text_size(px(10.0))
.text_color(colors.muted)
.truncate()
.child(path.display().to_string()),
),
);
}
body
}
}
fn transfer_status_label(
status: ThemeTransferStatus,
language: crate::display::UiLanguage,
) -> &'static str {
match status {
ThemeTransferStatus::Picking => language.text(Message::ThemeTransferPicking),
ThemeTransferStatus::Inspecting => language.text(Message::ThemeTransferInspecting),
ThemeTransferStatus::Exporting => language.text(Message::ThemeTransferPreparing),
ThemeTransferStatus::Importing => language.text(Message::ThemeTransferPreparing),
ThemeTransferStatus::Writing => language.text(Message::ThemeTransferWriting),
ThemeTransferStatus::Completed => language.text(Message::ThemeTransferCompleted),
ThemeTransferStatus::Error => language.text(Message::ThemeTransferError),
ThemeTransferStatus::Ready | ThemeTransferStatus::Idle => "",
}
}
@@ -22,6 +22,9 @@ pub struct Palette {
pub dim_foreground: Rgba,
/// 块状光标的填充色(主应用 NEBULA_DEFAULT_CURSOR 的 background)。
pub cursor: Rgba,
/// Text rendered inside a solid block cursor. `None` keeps the existing
/// background-colored fallback used by themes without an explicit value.
pub cursor_text: Option<Rgba>,
/// 主题可为 bar/underline 光标指定与 block 不同的颜色(如 Nord)。
pub cursor_stroke: Option<Rgba>,
/// 选区叠加色。主应用默认是反色语义;本壳以半透明叠加近似,
@@ -45,6 +48,7 @@ impl Default for Palette {
bright_foreground: rgb8(0xd6, 0xda, 0xea),
dim_foreground: Self::dim_of(rgb8(0xd6, 0xda, 0xea)),
cursor: rgb8(0x49, 0x4d, 0x72),
cursor_text: None,
cursor_stroke: None,
selection: Rgba { a: 0.60, ..rgb8(0x49, 0x4d, 0x72) },
selection_foreground: None,
@@ -534,7 +534,7 @@ impl Element for TerminalElement {
{
app_cursor_color.unwrap_or_else(|| theme.resolve(glyph.fg, &overrides, glyph.bold))
} else if cursor_inverts(glyph.row, glyph.col) {
theme.background
theme.cursor_text.unwrap_or(theme.background)
} else if let Some(foreground) = selected_foreground(glyph.row, glyph.col) {
foreground
} else {
@@ -595,7 +595,7 @@ impl Element for TerminalElement {
continue;
};
let fg: Hsla = if cursor_inverts(seg.row, cell.col) {
theme.background.into()
theme.cursor_text.unwrap_or(theme.background).into()
} else if let Some(foreground) = selected_foreground(seg.row, cell.col) {
foreground.into()
} else {
@@ -1641,7 +1641,7 @@ pub(super) fn rgba_rgb(color: crate::display::color::Rgb, alpha: f32) -> Rgba {
}
fn themed_anchor(palette: &super::colors::Palette, cx: &App) -> (crate::display::color::Rgb, bool) {
let sk = crate::gpui_shell::theme::chrome_theme_resolved(cx).skin();
let sk = crate::gpui_shell::theme::resolved_skin(cx);
// ANSI magenta = index 5;旧壳 `display.colors[NamedColor::Magenta]`。
let magenta = rgb_from_rgba(palette.ansi[5]);
let mix = if sk.is_light {
+26 -105
View File
@@ -8,16 +8,17 @@ mod notifications;
mod path_drop;
mod pointer;
mod runtime;
mod typography;
pub use broadcast::TerminalInput;
pub use runtime::InputOrigin;
use gpui::{
App, AppContext as _, Bounds, ClipboardItem, Context, EventEmitter, FocusHandle, Focusable,
Font, FontFeatures, FontStyle, FontWeight, Hsla, InteractiveElement as _, IntoElement,
KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _,
Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, StatefulInteractiveElement as _,
Styled as _, TextRun, UTF16Selection, Window, div, point, px,
Font, FontStyle, FontWeight, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton,
MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render,
ScrollWheelEvent, Size, StatefulInteractiveElement as _, Styled as _, UTF16Selection, Window,
div, point, px,
};
use gpui_component::Sizable as _;
use nebula_settings::CellWidthModeName;
@@ -42,18 +43,7 @@ use super::{KEY_CONTEXT, TerminalBackTab, TerminalTab};
use crate::gpui_shell::config::Settings;
use crate::gpui_shell::prelude::{ActiveTheme as _, Colorize as _};
use crate::{config::UiConfig, font_install::REQUIRED_FONT_FAMILY};
/// 等宽字体描述。GPUI 的 Windows 后端收到空 feature 列表会在
/// `apply_font_features` 里提前返回,Maple 的 contextual ligature 因而不会
/// 生效;显式给出 calt=1 会让该后端一并注册 liga/clig/calt。
fn mono_font(family: &str, weight: FontWeight, style: FontStyle) -> Font {
Font {
weight,
style,
features: FontFeatures(Arc::new(vec![("calt".to_owned(), 1)])),
..crate::font_install::gpui_font_with_fallbacks(family)
}
}
use typography::mono_font;
/// Overlay 滚动条的拇指宽度、最小高度与命中放宽量(逻辑 px;旧壳
/// `scrollbar_geometry` 的 4/24/8 设备 px 在同一 DPI 语义下等值)。4px 的细条
@@ -273,6 +263,9 @@ pub struct TerminalView {
/// contract. They are applied after GPUI has shaped the actual face.
font_offset_x: f32,
font_offset_y: f32,
/// Optional theme line-height multiplier. `None` preserves the shaped
/// font metrics; a value is applied to the logical font size.
line_height_multiplier: Option<f32>,
pub palette: Arc<Palette>,
/// 把**应用写死的**颜色按当前主题矫正(最低对比度 + 旧主题表面重映射)。
///
@@ -461,38 +454,18 @@ impl TerminalView {
pub const DEFAULT_GRID_COLUMNS: u16 = 116;
pub const DEFAULT_GRID_LINES: u16 = 30;
fn effective_cell_width(
raw_width: f32,
mode: nebula_settings::CellWidthModeName,
scale: f32,
offset_x: f32,
) -> Pixels {
// 旧壳的 crossfont 度量与取整都发生在设备像素域。若先在 GPUI
// 逻辑像素域取整,150% DPI 下每列会多出半个物理像素,116 列会
// 把启动窗口横向撑大几十像素。
let scale = scale.max(0.5);
let device_width = raw_width * scale + offset_x;
let device_width = match mode {
nebula_settings::CellWidthModeName::Compact => device_width.floor(),
nebula_settings::CellWidthModeName::Relaxed => device_width.round(),
};
px(device_width.max(1.0) / scale)
}
pub(super) fn cell_width_for_advance(&self, raw_width: f32, scale: f32) -> Pixels {
Self::effective_cell_width(raw_width, self.cell_width_mode, scale, self.font_offset_x)
}
fn effective_line_height(natural_height: f32, offset_y: f32, scale: f32) -> Pixels {
let scale = scale.max(0.5);
// GPUI's shaped line exposes the platform's ascent+descent, which on
// DirectWrite includes the same font line gap used by crossfont. The
// old shell then floors natural height + offset in device pixels.
px(((natural_height * scale + offset_y).floor().max(1.0)) / scale)
typography::effective_cell_width(raw_width, self.cell_width_mode, scale, self.font_offset_x)
}
pub(super) fn line_height_for_metrics(&self, natural_height: f32, scale: f32) -> Pixels {
Self::effective_line_height(natural_height, self.font_offset_y, scale)
typography::line_height_for_view(
self.font_size,
self.line_height_multiplier,
natural_height,
self.font_offset_y,
scale,
)
}
/// 启动稳定闸的宽限期:等待开窗 resize 落地到布局的最长时间。超时
@@ -505,74 +478,17 @@ impl TerminalView {
/// 与 legacy `config::cursor::Cursor::default().blink_interval` 保持一致。
const CURSOR_BLINK_INTERVAL: std::time::Duration = std::time::Duration::from_millis(750);
fn measure_cell_metrics(
window: &Window,
family: &str,
font_size: Pixels,
mode: nebula_settings::CellWidthModeName,
offset_x: f32,
offset_y: f32,
) -> (Pixels, Pixels) {
let font = mono_font(&family, FontWeight::NORMAL, FontStyle::Normal);
let sample = window.text_system().shape_line(
SharedString::new_static("M"),
font_size,
&[TextRun {
len: 1,
font,
color: Hsla::default(),
background_color: None,
underline: None,
strikethrough: None,
}],
None,
);
(
Self::effective_cell_width(
sample.width.as_f32(),
mode,
window.scale_factor(),
offset_x,
),
Self::effective_line_height(
sample.ascent.as_f32() + sample.descent.as_f32(),
offset_y,
window.scale_factor(),
),
)
}
/// 首帧前的当前单元格度量(与 element prepaint 同一公式:shape "M" 的
/// advance / ascent / descent + 配置 offset)。让 spawn 网格与首帧布局一致,避免启动
/// 即触发一次 ConPTY resize(DA 探询与回显竞态的温床)。
pub fn cell_metrics(window: &Window, cx: &App) -> (Pixels, Pixels) {
let (family, font_size, mode, offset_x, offset_y) = match cx.try_global::<Settings>() {
Some(settings) => (
settings.font_family.as_str(),
settings.font_size_px,
settings.cell_width_mode,
settings.font_offset_x,
settings.font_offset_y,
),
None => (REQUIRED_FONT_FAMILY, 15.0, CellWidthModeName::Compact, 0.0, 0.0),
};
Self::measure_cell_metrics(window, family, px(font_size), mode, offset_x, offset_y)
typography::cell_metrics(window, cx)
}
/// 旧壳窗口定形使用配置基准字号,不使用持久化缩放;缩放后的字号只
/// 影响最终能容纳的行列数。否则放大一级就会把 116 列全部加到窗宽上。
pub fn startup_cell_metrics(window: &Window, cx: &App) -> (Pixels, Pixels) {
let (family, font_size, mode, offset_x, offset_y) = match cx.try_global::<Settings>() {
Some(settings) => (
settings.font_family.as_str(),
settings.base_font_size_px,
settings.cell_width_mode,
settings.font_offset_x,
settings.font_offset_y,
),
None => (REQUIRED_FONT_FAMILY, 15.0, CellWidthModeName::Compact, 0.0, 0.0),
};
Self::measure_cell_metrics(window, family, px(font_size), mode, offset_x, offset_y)
typography::startup_cell_metrics(window, cx)
}
/// `spawn_grid`:PTY 出生网格(宿主已把窗口定形到该几何)。首帧布局
@@ -592,6 +508,7 @@ impl TerminalView {
cell_width_mode,
font_offset_x,
font_offset_y,
line_height_multiplier,
palette,
term_config,
copy_on_select,
@@ -608,6 +525,7 @@ impl TerminalView {
settings.cell_width_mode,
settings.font_offset_x,
settings.font_offset_y,
settings.theme_line_height,
Arc::new(settings.palette.clone()),
settings.term_config(),
settings.copy_on_select,
@@ -626,6 +544,7 @@ impl TerminalView {
CellWidthModeName::Compact,
0.0,
0.0,
None,
Arc::new(Palette::default()),
nebula_terminal::term::Config::default(),
// 旧壳的出厂默认即开。
@@ -776,6 +695,7 @@ impl TerminalView {
cell_width_mode,
font_offset_x,
font_offset_y,
line_height_multiplier,
palette,
color_resolver: Default::default(),
marked_text: None,
@@ -1378,8 +1298,8 @@ impl TerminalView {
));
return;
}
let theme = crate::gpui_shell::theme::effective_theme_name(cx);
cx.set_global(Settings::load(theme));
let settings = Settings::load_current(cx);
cx.set_global(settings);
self.apply_settings(cx);
cx.emit(TerminalViewEvent::FontSizeChanged);
cx.notify();
@@ -1414,6 +1334,7 @@ impl TerminalView {
self.cell_width_mode = settings.cell_width_mode;
self.font_offset_x = settings.font_offset_x;
self.font_offset_y = settings.font_offset_y;
self.line_height_multiplier = settings.theme_line_height;
// 底色换了就把矫正缓存作废,并记下「旧底色 → 新底色」这一跳:应用当初
// 按旧主题底色画的连续表面(面板、状态栏)要跟着搬过去,否则浅色主题上
// 会留一整块旧的深色板。旧壳 `apply_nebula_theme` 同一时机做同一件事。
@@ -1,8 +1,8 @@
use gpui::{FontStyle, FontWeight};
use super::{
TermMode, TerminalView, cursor_blink_allowed, mono_font, paste_line_count,
paste_needs_confirmation, restart_cursor_blink_phase, selection_scroll_lines,
TermMode, cursor_blink_allowed, paste_line_count, paste_needs_confirmation,
restart_cursor_blink_phase, selection_scroll_lines, typography,
};
#[test]
@@ -84,15 +84,18 @@ fn paste_confirmation_follows_execution_risk_not_volume() {
#[test]
fn terminal_font_explicitly_enables_maple_ligatures() {
let font =
mono_font(crate::font_install::REQUIRED_FONT_FAMILY, FontWeight::NORMAL, FontStyle::Normal);
let font = typography::mono_font(
crate::font_install::REQUIRED_FONT_FAMILY,
FontWeight::NORMAL,
FontStyle::Normal,
);
assert_eq!(font.features.tag_value_list(), &[("calt".to_owned(), 1)]);
}
#[test]
fn cell_width_mode_matches_the_legacy_grid_rounding_contract() {
assert_eq!(
f32::from(TerminalView::effective_cell_width(
f32::from(typography::effective_cell_width(
10.8,
nebula_settings::CellWidthModeName::Compact,
1.0,
@@ -101,7 +104,7 @@ fn cell_width_mode_matches_the_legacy_grid_rounding_contract() {
10.0
);
assert_eq!(
f32::from(TerminalView::effective_cell_width(
f32::from(typography::effective_cell_width(
10.8,
nebula_settings::CellWidthModeName::Relaxed,
1.0,
@@ -110,7 +113,7 @@ fn cell_width_mode_matches_the_legacy_grid_rounding_contract() {
11.0
);
let compact = f32::from(TerminalView::effective_cell_width(
let compact = f32::from(typography::effective_cell_width(
9.2,
nebula_settings::CellWidthModeName::Compact,
1.5,
@@ -118,7 +121,7 @@ fn cell_width_mode_matches_the_legacy_grid_rounding_contract() {
));
assert!((compact - 13.0 / 1.5).abs() < f32::EPSILON);
let relaxed = f32::from(TerminalView::effective_cell_width(
let relaxed = f32::from(typography::effective_cell_width(
9.2,
nebula_settings::CellWidthModeName::Relaxed,
1.5,
@@ -131,6 +134,25 @@ fn cell_width_mode_matches_the_legacy_grid_rounding_contract() {
fn line_height_uses_shaped_metrics_and_device_pixel_offset() {
// Maple's hhea metrics are 1.32em. At 150% DPI, the legacy 4px
// Windows offset yields floor(15 * 1.32 * 1.5 + 4) = 33 device px.
let height = TerminalView::effective_line_height(15.0 * 1.32, 4.0, 1.5);
let height = typography::effective_line_height(15.0 * 1.32, 4.0, 1.5);
assert!((f32::from(height) - 22.0).abs() < f32::EPSILON);
}
#[test]
fn theme_line_height_none_preserves_shaped_metrics() {
let natural = 15.0 * 1.32;
let legacy = typography::effective_line_height(natural, 4.0, 1.5);
let themed = typography::effective_line_height_with_theme(natural, 15.0, None, 4.0, 1.5);
assert_eq!(themed, legacy);
}
#[test]
fn theme_line_height_uses_font_size_and_keeps_device_pixel_flooring() {
// 15px * 1.5 = 22.5 logical px; at 150% DPI with a 4px device offset,
// floor(22.5 * 1.5 + 4) = 37 device px, or 37 / 1.5 logical px.
let height =
typography::effective_line_height_with_theme(15.0 * 1.32, 15.0, Some(1.5), 4.0, 1.5);
assert!((f32::from(height) - 37.0 / 1.5).abs() < f32::EPSILON);
}
@@ -0,0 +1,162 @@
//! Terminal font construction and cell metrics.
//!
//! The terminal view and its element must use one device-pixel rounding rule.
//! Keeping shaping, width, height, and theme line-height policy here prevents
//! the startup grid from drifting away from the first prepainted frame.
use gpui::{
App, Font, FontFeatures, FontStyle, FontWeight, Hsla, Pixels, SharedString, TextRun, Window, px,
};
use nebula_settings::CellWidthModeName;
use super::Settings;
use crate::font_install::{REQUIRED_FONT_FAMILY, gpui_font_with_fallbacks};
/// GPUI's Windows backend skips font feature setup for an empty feature list.
/// Explicitly enabling `calt` keeps Maple's contextual ligatures consistent
/// across the platforms where the bundled face is used.
pub(super) fn mono_font(family: &str, weight: FontWeight, style: FontStyle) -> Font {
Font {
weight,
style,
features: FontFeatures(std::sync::Arc::new(vec![("calt".to_owned(), 1)])),
..gpui_font_with_fallbacks(family)
}
}
/// Apply the legacy device-pixel rounding contract to one shaped cell width.
pub(super) fn effective_cell_width(
raw_width: f32,
mode: CellWidthModeName,
scale: f32,
offset_x: f32,
) -> Pixels {
let scale = scale.max(0.5);
let device_width = raw_width * scale + offset_x;
let device_width = match mode {
CellWidthModeName::Compact => device_width.floor(),
CellWidthModeName::Relaxed => device_width.round(),
};
px(device_width.max(1.0) / scale)
}
/// Keep the shaped font metrics unless a theme explicitly supplies a line
/// height. The offset is applied and floored in device pixels in both cases.
pub(super) fn effective_line_height(natural_height: f32, offset_y: f32, scale: f32) -> Pixels {
let scale = scale.max(0.5);
px(((natural_height * scale + offset_y).floor().max(1.0)) / scale)
}
pub(super) fn effective_line_height_with_theme(
natural_height: f32,
font_size: f32,
line_height_multiplier: Option<f32>,
offset_y: f32,
scale: f32,
) -> Pixels {
let target = line_height_multiplier
.map(|multiplier| (font_size * multiplier).max(1.0))
.unwrap_or(natural_height);
effective_line_height(target, offset_y, scale)
}
pub(super) fn line_height_for_view(
font_size: Pixels,
line_height_multiplier: Option<f32>,
natural_height: f32,
offset_y: f32,
scale: f32,
) -> Pixels {
effective_line_height_with_theme(
natural_height,
font_size.as_f32(),
line_height_multiplier,
offset_y,
scale,
)
}
fn measure_cell_metrics(
window: &Window,
family: &str,
font_size: Pixels,
mode: CellWidthModeName,
offset_x: f32,
offset_y: f32,
line_height_multiplier: Option<f32>,
) -> (Pixels, Pixels) {
let font = mono_font(family, FontWeight::NORMAL, FontStyle::Normal);
let sample = window.text_system().shape_line(
SharedString::new_static("M"),
font_size,
&[TextRun {
len: 1,
font,
color: Hsla::default(),
background_color: None,
underline: None,
strikethrough: None,
}],
None,
);
(
effective_cell_width(sample.width.as_f32(), mode, window.scale_factor(), offset_x),
effective_line_height_with_theme(
sample.ascent.as_f32() + sample.descent.as_f32(),
font_size.as_f32(),
line_height_multiplier,
offset_y,
window.scale_factor(),
),
)
}
pub(super) fn cell_metrics(window: &Window, cx: &App) -> (Pixels, Pixels) {
let (family, font_size, mode, offset_x, offset_y, line_height_multiplier) =
match cx.try_global::<Settings>() {
Some(settings) => (
settings.font_family.as_str(),
settings.font_size_px,
settings.cell_width_mode,
settings.font_offset_x,
settings.font_offset_y,
settings.theme_line_height,
),
None => (REQUIRED_FONT_FAMILY, 15.0, CellWidthModeName::Compact, 0.0, 0.0, None),
};
measure_cell_metrics(
window,
family,
px(font_size),
mode,
offset_x,
offset_y,
line_height_multiplier,
)
}
/// Startup sizing uses the configured base font, while a persisted terminal
/// zoom only affects the live grid after the window has been created.
pub(super) fn startup_cell_metrics(window: &Window, cx: &App) -> (Pixels, Pixels) {
let (family, font_size, mode, offset_x, offset_y, line_height_multiplier) =
match cx.try_global::<Settings>() {
Some(settings) => (
settings.font_family.as_str(),
settings.base_font_size_px,
settings.cell_width_mode,
settings.font_offset_x,
settings.font_offset_y,
settings.theme_line_height,
),
None => (REQUIRED_FONT_FAMILY, 15.0, CellWidthModeName::Compact, 0.0, 0.0, None),
};
measure_cell_metrics(
window,
family,
px(font_size),
mode,
offset_x,
offset_y,
line_height_multiplier,
)
}
+76 -32
View File
@@ -6,8 +6,11 @@ use crate::display::ui::theme::NebulaTheme;
use crate::renderer::ui::Rgba;
use nebula_settings::ThemeName;
mod custom;
mod syntax;
pub(crate) use custom::ResolvedTheme;
struct DocumentColors {
background: Hsla,
foreground: Hsla,
@@ -36,7 +39,29 @@ fn settings_theme_name(theme: NebulaTheme) -> ThemeName {
/// 当前生效的旧壳 chrome 主题(SSH 连接卡片等复用旧 Skin/palette 的
/// 视图从这里取色,避免第二套品牌色定义)。
pub(crate) fn chrome_theme_resolved(cx: &App) -> NebulaTheme {
chrome_theme(effective_theme_name(cx))
resolved_theme(cx).chrome_theme()
}
/// Resolved snapshot shared by terminal, chrome and settings helpers. Once
/// `Settings` is installed this is a pure in-memory lookup; the disk fallback
/// is only needed during the short bootstrap window before the global exists.
pub(crate) fn resolved_theme(cx: &App) -> std::sync::Arc<ResolvedTheme> {
cx.try_global::<crate::gpui_shell::config::Settings>()
.map(|settings| settings.resolved_theme.clone())
.unwrap_or_else(|| {
let runtime = nebula_settings::RuntimeSettings::load();
let fallback =
resolve_theme_name(runtime.theme, runtime.follow_system_theme, system_is_light(cx));
std::sync::Arc::new(ResolvedTheme::from_runtime(&runtime, fallback))
})
}
pub(crate) fn resolved_skin(cx: &App) -> crate::display::ui::theme::Skin {
resolved_theme(cx).skin()
}
pub(crate) fn resolved_palette(cx: &App) -> crate::display::ui::theme::NebulaPalette {
resolved_theme(cx).chrome_palette()
}
/// 当前 OS 外观是否为浅色。跟随系统时与旧壳 `WinitTheme::Light` 同一判定。
@@ -66,6 +91,9 @@ pub(crate) fn resolve_theme_name(
/// 亮/暗成员(规则与旧壳 `NebulaTheme::for_system_appearance` 同一来源)。
/// chrome 令牌与终端 palette 都必须走这里,两层才不会分家。
pub fn effective_theme_name(cx: &App) -> ThemeName {
if let Some(settings) = cx.try_global::<crate::gpui_shell::config::Settings>() {
return settings.resolved_theme.base_name();
}
let rt = nebula_settings::RuntimeSettings::load();
resolve_theme_name(rt.theme, rt.follow_system_theme, system_is_light(cx))
}
@@ -98,7 +126,7 @@ fn ink(c: Rgb) -> Hsla {
/// 再暗一档,旧壳只用于「确实在场但不参与层级竞争」的元素——侧栏数量
/// chip 的数字、未绑定键帽等。GPUI 全局 token 没有这一档,按需来取。
pub(crate) fn faint_ink(cx: &App) -> Hsla {
ink(chrome_theme(effective_theme_name(cx)).skin().ink_faint)
ink(resolved_skin(cx).ink_faint)
}
/// 侧栏运行 spinner 的两端颜色,逐字复用旧壳 `draw_chrome` 的底色裁定:
@@ -106,9 +134,8 @@ pub(crate) fn faint_ink(cx: &App) -> Hsla {
/// 铺成;若直接交给 GPUI 用半透明 hairline 叠画,交叠处会变深,看起来像
/// 一圈模糊珠子而不是连续圆环。
pub(crate) fn sidebar_spinner_colors(cx: &App, active: bool) -> (GpuiRgba, GpuiRgba) {
let chrome = chrome_theme(effective_theme_name(cx));
let palette = chrome.palette();
let sk = chrome.skin();
let palette = resolved_palette(cx);
let sk = resolved_skin(cx);
let shell = Rgba::new(palette.shell_bg.r, palette.shell_bg.g, palette.shell_bg.b, 255);
let base =
if active { crate::display::ui::surface::over(sk.accent_soft, shell) } else { shell };
@@ -174,8 +201,7 @@ fn wash_scaled(c: Rgba, f: f32) -> Hsla {
/// 旧壳 `shell_frame_color` 的不透明形态:panel 按自身 alpha 预合成到
/// shell_bg 上。整窗清到这个色;终端以 term_bg 圆角卡浮于其上,顶栏与
/// 侧栏融进壳色(一体化外壳)。GPUI 壳暂不接透明度滑块,alpha 取 1。
fn shell_color(theme: NebulaTheme) -> Hsla {
let p = theme.palette();
fn shell_color(p: crate::display::ui::theme::NebulaPalette) -> Hsla {
let pa = f32::from(p.panel.a) / 255.0;
let comp = |pv: u8, bv: u8| (f32::from(pv) * pa + f32::from(bv) * (1.0 - pa)).round() as u8;
to_hsla(
@@ -221,6 +247,17 @@ impl PaneCardStyle {
/// 与 `RuntimeSettings`,是唯一能正确合并两层的地方;别处自行读一遍
/// settings 就会在跟随系统主题时和 chrome 分家。
pub fn resolve(theme: ThemeName, runtime: &nebula_settings::RuntimeSettings) -> Self {
let resolved = ResolvedTheme::builtin(theme, None);
Self::resolve_for_resolved_theme(&resolved, runtime)
}
/// Resolve geometry from the same immutable theme snapshot used by the
/// terminal and chrome. Custom themes therefore do not silently fall back
/// to their built-in base card shape.
pub fn resolve_for_resolved_theme(
theme: &ResolvedTheme,
runtime: &nebula_settings::RuntimeSettings,
) -> Self {
let geometry = theme.card_geometry();
let radius = runtime.pane_card_radius.unwrap_or(geometry.radius);
let rounded = radius > 0.0;
@@ -251,7 +288,7 @@ impl PaneCardStyle {
/// 侧栏与正文的弱分界直接使用 HTML 对应的 `line` RGBA 令牌。
/// 它与外窗描边、强调色分别取值,不额外合成一套灰色。
pub fn card_divider_color(cx: &App) -> Hsla {
wash(chrome_theme_resolved(cx).skin().hairline)
wash(resolved_skin(cx).hairline)
}
/// 终端卡圆角。默认值的权威在 `nebula_settings::DEFAULT_PANE_CARD_RADIUS`,
@@ -265,7 +302,7 @@ pub fn card_radius(cx: &App) -> Pixels {
/// 偏移只给 y、不给 x:光源当作正上方,卡在窗口里居中偏右时也不会出现
/// 阴影朝一侧甩的违和感。
pub fn card_shadow(cx: &App) -> gpui::BoxShadow {
let is_light = chrome_theme_resolved(cx).palette().is_light;
let is_light = resolved_palette(cx).is_light;
gpui::BoxShadow {
color: hsla(0.0, 0.0, 0.0, if is_light { 0.10 } else { 0.28 }),
offset: point(px(0.0), px(6.0)),
@@ -363,7 +400,7 @@ pub fn paint_shell_around_card(
/// 旧壳设置页使用不透明 `Skin.panel`,不让终端壁纸穿透设置内容。
pub fn settings_panel_bg(cx: &App) -> Hsla {
solid(chrome_theme(effective_theme_name(cx)).skin().panel)
solid(resolved_skin(cx).panel)
}
/// Terminal completion colors are derived from the active Nebula skin. The
@@ -393,7 +430,7 @@ pub(crate) struct CompletionColors {
}
pub(crate) fn completion_colors(cx: &App, _term_bg: GpuiRgba) -> CompletionColors {
let sk = chrome_theme(effective_theme_name(cx)).skin();
let sk = resolved_skin(cx);
let alpha = |hex: u32, a: f32| -> Hsla {
GpuiRgba {
r: f32::from(((hex >> 16) & 0xff) as u8) / 255.0,
@@ -435,7 +472,7 @@ pub(crate) fn completion_colors(cx: &App, _term_bg: GpuiRgba) -> CompletionColor
/// 明显——那就是"刺眼"的来源。所以**降饱和、保亮度**:可辨来自亮度差,刺眼
/// 来自饱和度,两者可以拆开。
pub fn settings_mark(cx: &App) -> Hsla {
if chrome_theme(effective_theme_name(cx)).skin().is_light {
if resolved_skin(cx).is_light {
// 浅色底上要更暗才看得见,同样压饱和。
hsla(250.0 / 360.0, 0.40, 0.47, 1.0)
} else {
@@ -449,7 +486,7 @@ pub fn settings_mark(cx: &App) -> Hsla {
/// 在说"这是两个区",属于容器。两者同色同粗的话,画面上就出现两条同等分量
/// 的线在争同一件事的解释权。
pub fn settings_hairline(cx: &App) -> Hsla {
let sk = chrome_theme(effective_theme_name(cx)).skin();
let sk = resolved_skin(cx);
// 浅色底上黑线比深色底上白线更"重"(同 alpha 视觉对比更高),所以浅色
// 取更低的 alpha。
let alpha = if sk.is_light { 20 } else { 18 };
@@ -457,7 +494,7 @@ pub fn settings_hairline(cx: &App) -> Hsla {
}
pub fn settings_hover_bg(cx: &App, strong: bool) -> Hsla {
let sk = chrome_theme(effective_theme_name(cx)).skin();
let sk = resolved_skin(cx);
let (hover_alpha, strong_alpha) = if sk.is_light { (10, 18) } else { (30, 46) };
let alpha = if strong { strong_alpha } else { hover_alpha };
wash(Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, alpha))
@@ -474,15 +511,17 @@ pub fn apply_chrome_theme(cx: &mut App) {
// 托盘与 chrome 同一热应用节拍:启动 / 设置变更 / 系统外观。
crate::gpui_shell::apply_tray_setting();
let chrome = chrome_theme(effective_theme_name(cx));
let mode = if chrome.skin().is_light { ThemeMode::Light } else { ThemeMode::Dark };
let resolved = resolved_theme(cx);
let mode = if resolved.skin().is_light { ThemeMode::Light } else { ThemeMode::Dark };
Theme::change(mode, None, cx);
apply_skin_tokens(chrome, cx);
let colors = settings_theme_name(chrome).reviewed_palette();
let [r, g, b] = colors.code_background();
let [fr, fg, fb] = colors.foreground;
cx.set_global(DocumentColors { background: to_hsla(r, g, b), foreground: to_hsla(fr, fg, fb) });
apply_shell_opacity(chrome, cx);
apply_skin_tokens(&resolved, cx);
let background = resolved.terminal_background();
let foreground = resolved.terminal_foreground();
cx.set_global(DocumentColors {
background: to_hsla(background[0], background[1], background[2]),
foreground: to_hsla(foreground[0], foreground[1], foreground[2]),
});
apply_shell_opacity(&resolved, cx);
}
/// 只按当前不透明度重算壳色,不做别的。
@@ -495,23 +534,28 @@ pub fn apply_chrome_theme(cx: &mut App) {
/// 主题名由调用方传入:[`effective_theme_name`] 内部会 `RuntimeSettings::load()`
/// 读盘一次,那正是这条路径要避开的东西。设置页自己持有 `runtime` 镜像。
pub fn reapply_shell_opacity(name: ThemeName, follow_system: bool, cx: &mut App) {
let chrome = chrome_theme(resolve_theme_name(name, follow_system, system_is_light(cx)));
apply_shell_opacity(chrome, cx);
let _ = (name, follow_system);
reapply_prepared_surface_opacity(cx);
}
/// Wallpaper readiness changes on the cold load-completion path without a
/// settings event. Resolve the active theme before reapplying its surface alpha.
/// Wallpaper readiness can change asynchronously without a settings/theme reload.
pub(super) fn reapply_prepared_surface_opacity(cx: &mut App) {
let chrome = chrome_theme(effective_theme_name(cx));
apply_shell_opacity(chrome, cx);
let resolved = resolved_theme(cx);
apply_shell_opacity(&resolved, cx);
}
/// 一体化外壳(对齐旧壳 draw_chrome):窗口背景、侧栏、顶栏是同一块
/// 壳色,各自的分隔线取同色隐形;唯一的结构分界是内容区那张圆角卡。
/// 壳色带用户透明度(文字 token 不带——对比度不塌,旧壳裁定)。
fn apply_shell_opacity(chrome: NebulaTheme, cx: &mut App) {
fn apply_shell_opacity(chrome: &ResolvedTheme, cx: &mut App) {
// A theme's explicit material opacity is part of the resolved snapshot;
// otherwise keep the user's live/window opacity setting. This is read once
// here and never reparsed from the theme document on a frame.
// `wallpaper::refresh` has already merged the immutable theme default with
// the explicit runtime preference. Reading the resolved visual value here
// keeps chrome, terminal cards and native window effects on one contract.
let opacity = crate::gpui_shell::wallpaper::chrome_surface_opacity(cx);
let mut shell = shell_color(chrome);
let mut shell = shell_color(chrome.chrome_palette());
shell.a *= opacity;
let theme = Theme::global_mut(cx);
theme.background = shell;
@@ -553,7 +597,7 @@ fn soften(c: crate::display::color::Rgb, keep: f32) -> crate::display::color::Rg
crate::display::color::Rgb::new(pull(c.r), pull(c.g), pull(c.b))
}
fn apply_skin_tokens(chrome: NebulaTheme, cx: &mut App) {
fn apply_skin_tokens(chrome: &ResolvedTheme, cx: &mut App) {
let sk = chrome.skin();
let transparent = hsla(0.0, 0.0, 0.0, 0.0);
let theme = Theme::global_mut(cx);
@@ -698,7 +742,7 @@ fn apply_skin_tokens(chrome: NebulaTheme, cx: &mut App) {
// 1.16 的 Button、Slider、Switch 等背景统一读取 ThemeTokens。Nebula 的
// Skin 是纯色权威来源,因此在所有 ThemeColor 覆写完成后一次性解析,避免
// 新组件悄悄回落到 gpui-component 的默认主题色。
syntax::apply(theme, settings_theme_name(chrome).reviewed_palette());
syntax::apply(theme, chrome.base_name().reviewed_palette());
theme.tokens = (&theme.colors).into();
}
+400
View File
@@ -0,0 +1,400 @@
//! Runtime projection of a validated custom theme.
//!
//! Theme files and format adapters stay outside the render path. This module
//! owns the small amount of derivation needed by GPUI after a snapshot has
//! already been loaded and validated.
use nebula_settings::{Rgb8, RuntimeSettings, ThemeCardGeometry, ThemeDefinition, ThemeName};
use std::sync::{Mutex, OnceLock};
use crate::display::color::Rgb;
use crate::display::ui::theme::{NebulaPalette, NebulaTheme, Skin};
use crate::renderer::ui::Rgba;
/// The immutable theme snapshot consumed by configuration and chrome helpers.
///
/// `custom` is a complete value, never a reference to a base theme. `base`
/// remains available for compatibility features whose native representation is
/// still the built-in enum (syntax fallback and system appearance pairing).
#[derive(Clone, Debug)]
pub(crate) struct ResolvedTheme {
base: ThemeName,
custom: Option<ThemeDefinition>,
foreground_override: Option<Rgb8>,
pub(crate) notice: Option<String>,
/// All chrome derivation happens while a snapshot is created. Renderers
/// only copy these small value objects on the frame path.
palette: NebulaPalette,
skin: Skin,
}
impl ResolvedTheme {
/// Resolve the persisted runtime selection once per settings load. The
/// render path retains the returned snapshot and never reopens the theme
/// store. `custom_theme` accepts both a library id and the JSON snapshot
/// written by older editor builds.
pub(crate) fn from_runtime(runtime: &RuntimeSettings, fallback: ThemeName) -> Self {
let Some(reference) =
runtime.custom_theme.as_deref().filter(|value| !value.trim().is_empty())
else {
return Self::builtin(fallback, runtime.theme_foreground);
};
match load_definition(reference) {
Ok(definition) => {
remember_definition(reference, &definition);
Self::custom(definition, runtime.theme_foreground, None)
},
Err(error) => {
let notice = format!("Custom theme could not be loaded: {error}");
if let Some(previous) = previous_definition(reference) {
return Self::custom(previous, runtime.theme_foreground, Some(notice));
}
let mut builtin = Self::builtin(fallback, runtime.theme_foreground);
builtin.notice = Some(notice);
builtin
},
}
}
pub(crate) fn builtin(base: ThemeName, foreground_override: Option<Rgb8>) -> Self {
Self {
base,
custom: None,
foreground_override,
notice: None,
palette: crate::gpui_shell::theme::chrome_theme(base).palette(),
skin: crate::gpui_shell::theme::chrome_theme(base).skin(),
}
}
pub(crate) fn custom(
definition: ThemeDefinition,
foreground_override: Option<Rgb8>,
notice: Option<String>,
) -> Self {
let palette = custom_palette(&definition);
let skin = custom_skin(&definition);
Self {
base: definition.base,
custom: Some(definition),
foreground_override,
notice,
palette,
skin,
}
}
pub(crate) fn base_name(&self) -> ThemeName {
self.base
}
pub(crate) fn definition(&self) -> Option<&ThemeDefinition> {
self.custom.as_ref()
}
pub(crate) fn is_custom(&self) -> bool {
self.custom.is_some()
}
pub(crate) fn foreground_override(&self) -> Option<Rgb8> {
self.foreground_override
}
pub(crate) fn is_light(&self) -> bool {
self.custom.as_ref().map_or_else(
|| self.base.term_theme().is_light,
|definition| definition.appearance.is_light(),
)
}
pub(crate) fn terminal_background(&self) -> Rgb8 {
self.custom
.as_ref()
.map(|definition| definition.terminal.background)
.unwrap_or(self.base.term_theme().background)
}
pub(crate) fn terminal_foreground(&self) -> Rgb8 {
self.foreground_override.unwrap_or_else(|| {
self.custom
.as_ref()
.map(|definition| definition.terminal.foreground)
.unwrap_or_else(|| builtin_foreground(self.base))
})
}
pub(crate) fn terminal_cursor(&self) -> Option<Rgb8> {
self.custom.as_ref().map_or_else(
|| self.base.term_theme().exact.and_then(|colors| colors.cursor),
|definition| definition.terminal.cursor,
)
}
pub(crate) fn terminal_cursor_text(&self) -> Option<Rgb8> {
self.custom.as_ref().map_or_else(
|| self.base.term_theme().exact.and_then(|colors| colors.cursor_text),
|definition| definition.terminal.cursor_text,
)
}
pub(crate) fn terminal_cursor_stroke(&self) -> Option<Rgb8> {
self.custom.as_ref().map_or_else(
|| self.base.term_theme().exact.and_then(|colors| colors.cursor_stroke),
|definition| definition.terminal.cursor_stroke,
)
}
pub(crate) fn terminal_selection(&self) -> (Option<Rgb8>, Option<Rgb8>) {
self.custom.as_ref().map_or_else(
|| {
self.base
.term_theme()
.exact
.map(|colors| (colors.selection_background, colors.selection_foreground))
.unwrap_or((None, None))
},
|definition| {
(definition.terminal.selection_background, definition.terminal.selection_foreground)
},
)
}
pub(crate) fn ansi(&self) -> [Rgb8; 16] {
if let Some(definition) = &self.custom {
return definition.terminal.palette.ansi_colors();
}
self.base.term_theme().exact.map(|colors| colors.ansi).unwrap_or_else(|| {
if self.base.term_theme().is_light {
nebula_settings::LIGHT_ANSI
} else {
// A dark theme without an explicit palette keeps the
// existing user/config palette. This value is used only
// by callers that need a complete snapshot.
nebula_settings::ThemeDefinition::from_builtin(self.base)
.terminal
.palette
.ansi_colors()
}
})
}
pub(crate) fn indexed(&self, index: usize) -> Option<Rgb8> {
if let Some(definition) = &self.custom {
return definition.terminal.palette.get(index);
}
self.base.term_theme().powerline.get(index.checked_sub(16)?).copied()
}
pub(crate) fn card_geometry(&self) -> ThemeCardGeometry {
self.custom
.as_ref()
.map(|definition| definition.layout.card)
.unwrap_or_else(|| self.base.card_geometry())
}
pub(crate) fn typography(&self) -> Option<&nebula_settings::ThemeTypography> {
self.custom.as_ref().map(|definition| &definition.typography)
}
pub(crate) fn effects(&self) -> Option<&nebula_settings::ThemeEffects> {
self.custom.as_ref().map(|definition| &definition.effects)
}
pub(crate) fn effect_opacity(&self) -> Option<f32> {
self.effects().and_then(|effects| effects.opacity)
}
pub(crate) fn effect_blur(&self) -> Option<nebula_settings::BlurModeName> {
self.effects().and_then(|effects| effects.blur)
}
/// Resolve a material value once for the current runtime snapshot. An
/// explicit user value remains authoritative, including `1.0` opacity
/// and `none` blur; an absent value may inherit the custom theme default.
pub(crate) fn effective_opacity(&self, runtime: &RuntimeSettings) -> f32 {
if runtime.opacity_is_explicit() {
runtime.opacity
} else {
self.effect_opacity().unwrap_or(runtime.opacity)
}
}
pub(crate) fn effective_blur(
&self,
runtime: &RuntimeSettings,
) -> nebula_settings::BlurModeName {
if runtime.blur_is_explicit() {
runtime.blur
} else {
self.effect_blur().unwrap_or(runtime.blur)
}
}
pub(crate) fn chrome_palette(&self) -> NebulaPalette {
self.palette
}
pub(crate) fn skin(&self) -> Skin {
self.skin
}
pub(crate) fn chrome_theme(&self) -> NebulaTheme {
crate::gpui_shell::theme::chrome_theme(self.base)
}
}
fn load_definition(reference: &str) -> Result<ThemeDefinition, String> {
let reference = reference.trim();
let document = if reference.starts_with('{') {
crate::theme_library::ThemeDocument::from_json_bytes(reference.as_bytes())
.map_err(|error| error.to_string())?
} else {
let root = crate::platform::dirs::data_dir().join("themes");
crate::theme_library::ThemeLibraryStore::new(root)
.load(reference)
.map_err(|error| error.to_string())?
};
document.definition().map_err(|error| error.to_string())
}
#[derive(Clone)]
struct CachedTheme {
reference: String,
definition: ThemeDefinition,
}
fn previous_definition(reference: &str) -> Option<ThemeDefinition> {
last_valid_theme().lock().ok().and_then(|value| {
value
.as_ref()
.filter(|cached| cached.reference == reference)
.map(|cached| cached.definition.clone())
})
}
fn remember_definition(reference: &str, definition: &ThemeDefinition) {
if let Ok(mut value) = last_valid_theme().lock() {
*value =
Some(CachedTheme { reference: reference.to_owned(), definition: definition.clone() });
}
}
fn last_valid_theme() -> &'static Mutex<Option<CachedTheme>> {
static LAST_VALID: OnceLock<Mutex<Option<CachedTheme>>> = OnceLock::new();
LAST_VALID.get_or_init(|| Mutex::new(None))
}
fn custom_palette(definition: &ThemeDefinition) -> NebulaPalette {
let ui = definition.resolved_ui();
let line = ui.line_rgb();
let selection = ui.selection_rgb();
NebulaPalette {
panel: rgba(ui.background, 255),
pill: rgba(ui.background, 255),
tab_stroke_l: rgba(line, ui.line[3]),
tab_bg_l: rgba(selection, ui.selection[3]),
tab_bg_r: rgba(selection, ui.selection[3]),
edge_l: rgba(ui.accent, 255),
edge_r: rgba(ui.accent, 255),
edge_glow_l: rgba(ui.accent, 0),
glow_l: rgba(ui.accent, 0),
glow_r: rgba(ui.accent, 0),
is_light: definition.appearance.is_light(),
term_bg: rgb(definition.terminal.background),
shell_bg: rgb(ui.shell),
}
}
fn custom_skin(definition: &ThemeDefinition) -> Skin {
let ui = definition.resolved_ui();
let light = definition.appearance.is_light();
let background = ui.background;
let input = blend(background, definition.terminal.background, 0.22);
let muted = ui.muted;
let faint = blend(muted, background, 0.42);
let ink_on_accent = if contrast(ui.accent, [255; 3]) >= contrast(ui.accent, [0; 3]) {
[255; 3]
} else {
[0; 3]
};
let soft = ui.selection_rgb();
let line = ui.line_rgb();
let selection_alpha = ui.selection[3];
let line_alpha = ui.line[3];
let track_off =
if light { blend(line, background, 0.34) } else { blend(line, background, 0.62) };
let knob_off =
if light { blend(ui.muted, [255; 3], 0.45) } else { blend(ui.muted, [0; 3], 0.35) };
Skin {
panel: rgba(background, 255),
input: rgba(input, 255),
card: rgba(ui.shell, 255),
veil: if light { rgba([15, 23, 42], 44) } else { rgba([2, 6, 23], 92) },
ink: rgb(ui.foreground),
ink_dim: rgb(muted),
ink_strong: rgb(ui.foreground),
ink_faint: rgb(faint),
ink_ignored: rgb(faint),
ink_on_accent: rgb(ink_on_accent),
icon: rgb(muted),
icon_hover: rgb(ui.foreground),
accent: rgb(ui.accent),
accent_soft: rgba(soft, selection_alpha),
danger: rgba(ui.error, 255),
ok: rgba(ui.success, 255),
warn: rgba(ui.warning, 255),
hairline: rgba(line, line_alpha),
surface: rgba(soft, scaled_alpha(selection_alpha, if light { 0.56 } else { 0.38 })),
hover: rgba(soft, scaled_alpha(selection_alpha, if light { 0.78 } else { 0.70 })),
hover_strong: rgba(soft, scaled_alpha(selection_alpha, if light { 1.0 } else { 0.92 })),
track_off: rgba(track_off, 180),
toggle_track_off: rgba(track_off, 255),
toggle_track_on: rgba(ui.accent, 255),
toggle_border_off: rgba(line, 255),
toggle_border_on: rgba(ui.accent, 255),
knob_off: rgba(knob_off, 255),
knob_on: rgba(ink_on_accent, 255),
scrollbar_thumb: rgba(muted, 96),
is_light: light,
}
}
fn scaled_alpha(alpha: u8, factor: f32) -> u8 {
(f32::from(alpha) * factor).round().clamp(0.0, 255.0) as u8
}
fn builtin_foreground(name: ThemeName) -> Rgb8 {
let term = name.term_theme();
term.exact.map(|colors| colors.foreground).unwrap_or_else(|| {
if term.is_light {
nebula_settings::LIGHT_FOREGROUND
} else {
let p = name.reviewed_palette();
p.foreground
}
})
}
fn rgb([r, g, b]: Rgb8) -> Rgb {
Rgb::new(r, g, b)
}
fn rgba([r, g, b]: Rgb8, a: u8) -> Rgba {
Rgba::new(r, g, b, a)
}
fn contrast(left: Rgb8, right: Rgb8) -> f32 {
let luma = |color: Rgb8| {
0.2126 * f32::from(color[0]) + 0.7152 * f32::from(color[1]) + 0.0722 * f32::from(color[2])
};
(luma(left) - luma(right)).abs()
}
fn blend(left: Rgb8, right: Rgb8, amount: f32) -> Rgb8 {
[0, 1, 2].map(|index| {
(f32::from(left[index]) * (1.0 - amount) + f32::from(right[index]) * amount)
.round()
.clamp(0.0, 255.0) as u8
})
}
+12 -3
View File
@@ -65,7 +65,11 @@ struct Wallpaper {
/// Refresh prepared visual state without reading or decoding image files on the UI thread.
pub fn refresh(cx: &mut App) {
let rt = nebula_settings::RuntimeSettings::load();
update_wallpaper(&rt, rt.opacity, rt.blur, cx);
let (opacity, blur) = cx
.try_global::<crate::gpui_shell::config::Settings>()
.map(|settings| (settings.visual_opacity, settings.visual_blur))
.unwrap_or_else(|| effective_material(&rt));
update_wallpaper(&rt, opacity, blur, cx);
apply_window_effects(cx);
refresh_surface_opacity(cx);
}
@@ -250,7 +254,12 @@ pub fn chrome_surface_opacity(cx: &App) -> f32 {
/// Mica / Mica Alt 因此从首帧就走平台原生 backdrop,不再先挂一层普通透明背景。
pub fn initial_background_appearance() -> WindowBackgroundAppearance {
let runtime = nebula_settings::RuntimeSettings::load();
background_appearance(runtime.blur)
background_appearance(effective_material(&runtime).1)
}
fn effective_material(runtime: &nebula_settings::RuntimeSettings) -> (f32, BlurModeName) {
let resolved = crate::gpui_shell::theme::ResolvedTheme::from_runtime(runtime, runtime.theme);
(resolved.effective_opacity(runtime), resolved.effective_blur(runtime))
}
/// 模糊开关 → 窗口背景外观。**唯一落笔点**,启动与热应用共用。
@@ -688,7 +697,7 @@ pub fn paint_glass_overlay(bounds: Bounds<Pixels>, window: &mut Window, cx: &App
return;
}
let is_light = crate::gpui_shell::theme::chrome_theme_resolved(cx).skin().is_light;
let is_light = crate::gpui_shell::theme::resolved_skin(cx).is_light;
let tint = if is_light { TINT_ALPHA_LIGHT } else { TINT_ALPHA_DARK };
window.paint_quad(fill(bounds, Hsla { h: 0.0, s: 0.0, l: 1.0, a: tint }));
+9 -9
View File
@@ -1295,9 +1295,7 @@ impl NebulaWorkspace {
/// 设置或系统外观变化后的统一热应用:重载全局 `Settings`(主题经
/// follow_system 折算)、逐终端刷新、重建 chrome 令牌。
fn apply_runtime_settings(&mut self, cx: &mut Context<Self>) {
let settings = crate::gpui_shell::config::Settings::load(
crate::gpui_shell::theme::effective_theme_name(cx),
);
let (runtime, settings) = crate::gpui_shell::config::Settings::load_current_snapshot(cx);
cx.set_global(settings);
for tab in &self.tabs {
if let WorkspaceTab::Terminal { panes, .. } = tab {
@@ -1307,7 +1305,6 @@ impl NebulaWorkspace {
}
}
crate::gpui_shell::theme::apply_chrome_theme(cx);
let runtime = nebula_settings::RuntimeSettings::load();
crate::gpui_shell::apply_app_icon(runtime.app_icon, cx);
self.sidebar_width = runtime.sidebar_width;
self.tabs_position = runtime.tabs_position;
@@ -4152,14 +4149,17 @@ impl Render for NebulaWorkspace {
root.child(menu)
})
// 组件库的模态/通知层不会自己上屏:`Root::render` 只画宿主视图,
// dialog/notification 两层由宿主显式挂。挂在最外层链尾=盖住命令
// 面板和所有拖拽罩层;dialog 在下、notification 在上,确认框弹着
// 时仍看得见 toast。
// dialog/notification 两层由宿主显式挂。确认框需要晚于设置页中
// priority 4–6 的主题浮层绘制;通知再覆盖确认框。
//
// 少了这两行,`window.open_dialog` 只会把模态推进 `Root` 并抢走
// 焦点而不画任何东西——终端看着就像卡死了。
.children(Root::render_dialog_layer(window, cx))
.children(crate::gpui_shell::toast::render_layer(window, cx))
.children(Root::render_dialog_layer(window, cx).map(|layer| {
gpui::deferred(layer).with_priority(10)
}))
.children(crate::gpui_shell::toast::render_layer(window, cx).map(|layer| {
gpui::deferred(layer).with_priority(11)
}))
}
}
+2
View File
@@ -126,6 +126,8 @@ mod taskbar;
mod terminal_profiles;
mod text_document;
mod text_preview;
#[cfg(feature = "gpui-shell")]
pub(crate) mod theme_library;
mod tray;
mod update_check;
#[cfg(feature = "gpui-shell")]
+5 -3
View File
@@ -47,7 +47,7 @@ fn prompt_paths(
kind: PickerKind,
title: &'static str,
) -> impl Future<Output = Vec<PathBuf>> + 'static {
#[cfg(windows)]
#[cfg(all(windows, not(test)))]
{
let _ = cx;
let owner = native_owner(window);
@@ -66,7 +66,9 @@ fn prompt_paths(
});
selected_paths::<String>(rx)
}
#[cfg(not(windows))]
// GPUI's test platform owns deterministic picker responses on every host.
// Production Windows builds retain the owner-bound native worker above.
#[cfg(any(not(windows), test))]
{
let _ = window;
selected_paths(cx.prompt_for_paths(prompt_options(kind, title)))
@@ -84,7 +86,7 @@ fn prompt_options(kind: PickerKind, title: &'static str) -> gpui::PathPromptOpti
}
}
#[cfg(windows)]
#[cfg(all(windows, not(test)))]
fn native_owner(window: &Window) -> usize {
use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,181 @@
//! Format contracts exercise interchange as well as independent target schemas.
use super::*;
use nebula_settings::{ThemeDefinition, ThemeName};
const STATIC_FORMATS: [ThemeFormat; 6] = [
ThemeFormat::Pebrel,
ThemeFormat::WindowsTerminal,
ThemeFormat::Kitty,
ThemeFormat::Ghostty,
ThemeFormat::WezTerm,
ThemeFormat::Alacritty,
];
fn round_trip(document: &ThemeDocument, format: ThemeFormat) -> ThemeDocument {
let artifact = export(document, format).expect("export supported format");
let inspection = inspect(&artifact.text, format!("theme.{}", artifact.extension));
assert!(inspection.diagnostics.is_empty(), "{format}: {:?}", inspection.diagnostics);
assert_eq!(inspection.candidates.len(), 1, "{format}");
inspection.candidates.into_iter().next().unwrap().document
}
#[test]
fn fifteen_presets_retain_default_and_ansi_colors_in_six_static_formats() {
for theme in ThemeName::BUILTIN {
let original = builtin_document(theme).unwrap();
for format in STATIC_FORMATS {
let imported = round_trip(&original, format);
assert_eq!(
imported.color("terminal", "background"),
original.color("terminal", "background"),
"{theme:?} / {format} background"
);
assert_eq!(
imported.color("terminal", "foreground"),
original.color("terminal", "foreground"),
"{theme:?} / {format} foreground"
);
assert_eq!(imported.palette(), original.palette(), "{theme:?} / {format} ANSI");
if format == ThemeFormat::Pebrel {
assert_eq!(imported, original, "native envelope must preserve every field");
}
}
}
}
#[test]
fn indexed_colors_and_cursor_selection_roles_survive_formats_that_support_them() {
let mut definition = ThemeDefinition::from_builtin(ThemeName::Nord);
definition.terminal.cursor = Some([17, 34, 51]);
definition.terminal.cursor_text = Some([68, 85, 102]);
definition.terminal.selection_background = Some([119, 136, 153]);
definition.terminal.selection_foreground = Some([170, 187, 204]);
for (index, color) in [(16, [11, 22, 33]), (129, [44, 55, 66]), (255, [77, 88, 99])] {
assert!(definition.terminal.palette.set(index, color));
}
let document = from_definition(&definition).unwrap();
for format in [
ThemeFormat::Pebrel,
ThemeFormat::Kitty,
ThemeFormat::Ghostty,
ThemeFormat::WezTerm,
ThemeFormat::Alacritty,
] {
let imported = round_trip(&document, format).definition().unwrap();
assert_eq!(imported.terminal.palette, definition.terminal.palette, "{format}");
assert_eq!(imported.terminal.cursor, definition.terminal.cursor, "{format}");
assert_eq!(imported.terminal.cursor_text, definition.terminal.cursor_text, "{format}");
assert_eq!(
imported.terminal.selection_background, definition.terminal.selection_background,
"{format}"
);
assert_eq!(
imported.terminal.selection_foreground, definition.terminal.selection_foreground,
"{format}"
);
}
}
#[test]
fn alacritty_uses_its_magenta_field_instead_of_windows_terminal_purple() {
let source = r##"
[colors.primary]
background = "#001122"
foreground = "#ddeeff"
[colors.selection]
background = "#203040"
text = "#eeeeee"
[colors.normal]
black = "#010101"
red = "#020202"
green = "#030303"
yellow = "#040404"
blue = "#050505"
magenta = "#162738"
cyan = "#070707"
white = "#080808"
[colors.bright]
black = "#111111"
red = "#222222"
green = "#333333"
yellow = "#444444"
blue = "#555555"
magenta = "#a6b7c8"
cyan = "#777777"
white = "#888888"
"##;
let inspection = inspect(source, "alacritty.toml");
assert!(inspection.diagnostics.is_empty(), "{:?}", inspection.diagnostics);
let candidate = &inspection.candidates[0];
assert_eq!(
candidate.document.color("terminal", "selection_background"),
Some([0x20, 0x30, 0x40])
);
assert_eq!(candidate.document.palette().unwrap()[5], [0x16, 0x27, 0x38]);
assert_eq!(candidate.document.palette().unwrap()[13], [0xa6, 0xb7, 0xc8]);
let exported = export(&candidate.document, ThemeFormat::Alacritty).unwrap();
let data: toml::Value = toml::from_str(&exported.text).unwrap();
assert_eq!(data["colors"]["normal"]["magenta"].as_str(), Some("#162738"));
assert_eq!(data["colors"]["bright"]["magenta"].as_str(), Some("#a6b7c8"));
assert!(data["colors"]["normal"].get("purple").is_none());
assert_eq!(data["colors"]["selection"]["background"].as_str(), Some("#203040"));
assert!(data["colors"]["selection"].get("cursor").is_none());
}
#[test]
fn lossy_exports_report_omitted_fields_and_native_export_is_lossless() {
let document = builtin_document(ThemeName::Nord).unwrap();
for format in STATIC_FORMATS {
let artifact = export(&document, format).unwrap();
assert_eq!(artifact.losses.is_empty(), format == ThemeFormat::Pebrel, "{format}");
}
let windows = export(&document, ThemeFormat::WindowsTerminal).unwrap();
assert!(windows.losses.iter().any(|loss| loss.contains("indexed")));
}
#[test]
fn invalid_colors_oversized_files_and_scripts_never_produce_an_applicable_theme() {
for (text, filename) in [
("foreground = #zzzzzz\nbackground = #000000\n", "ghostty.conf"),
("return { colors = os.execute('echo unexpected') }", "wezterm.lua"),
("{\"schema_version\":999,\"name\":\"Future\"}", "future.pebrel-theme.json"),
] {
let inspection = inspect(text, filename);
assert!(inspection.candidates.is_empty(), "{filename}");
assert!(!inspection.diagnostics.is_empty(), "{filename}");
}
let inspection = inspect(&"x".repeat(MAX_INPUT_BYTES + 1), "oversized.conf");
assert!(inspection.candidates.is_empty());
assert!(!inspection.diagnostics.is_empty());
}
#[test]
fn unrecognized_theme_envelopes_are_rejected_by_format() {
let text = "[theme]\nname = 'Unknown'\nbackground = '#001122'\n";
for filename in ["custom.theme", "custom.conf", "custom.json"] {
let inspection = inspect(text, filename);
assert!(inspection.candidates.is_empty(), "{filename}");
assert!(!inspection.diagnostics.is_empty(), "{filename}");
}
}
#[test]
fn a_small_input_cannot_expand_to_an_unbounded_number_of_theme_snapshots() {
let scheme = serde_json::json!({
"name": "Compact",
"background": "#000000",
"foreground": "#ffffff"
});
for collection in [
serde_json::json!(vec![scheme.clone(); MAX_IMPORT_CANDIDATES + 1]),
serde_json::json!({ "schemes": vec![scheme.clone(); MAX_IMPORT_CANDIDATES + 1] }),
serde_json::json!({ "themes": vec![scheme; MAX_IMPORT_CANDIDATES + 1] }),
] {
let text = serde_json::to_string(&collection).unwrap();
assert!(text.len() < MAX_INPUT_BYTES);
let inspection = inspect(&text, "collection.json");
assert!(inspection.candidates.is_empty());
assert!(!inspection.diagnostics.is_empty());
}
}
File diff suppressed because it is too large. Load diff
+30
View File
@@ -0,0 +1,30 @@
//! Native theme library boundary.
//!
//! `document` owns the versioned Pebrel envelope, `formats` owns bounded
//! import/export adapters, and `store` owns durable custom-theme files. The
//! settings crate remains the single runtime definition authority.
pub(crate) mod document;
mod formats;
pub(crate) mod preferences;
mod store;
pub use document::{
DocumentError, MAX_DOCUMENT_BYTES, SCHEMA_VERSION, ThemeDocument, builtin_document,
builtin_documents, format_hex_rgb, format_hex_rgba, from_definition, parse_hex_rgb,
parse_hex_rgba, seed_document,
};
pub use formats::{
ExportArtifact, FormatError, ImportCandidate, ImportDiagnostic, Inspection,
MAX_IMPORT_CANDIDATES, MAX_INPUT_BYTES, ThemeFormat, export, inspect,
};
pub use store::{
MAX_CUSTOM_THEMES, RevisionPrecondition, StoreDiagnostic, StoreError, THEME_FILE_SUFFIX,
ThemeLibrarySnapshot, ThemeLibraryStore,
};
#[cfg(test)]
mod tests;
#[cfg(test)]
mod format_tests;
+265
View File
@@ -0,0 +1,265 @@
//! Conditional publication of theme-related preferences.
//!
//! These cold operations run on the editor's background executor. The shared
//! settings crate still owns key replacement semantics; this adapter supplies
//! the application's existing lock and atomic-file boundary. A revision check
//! detects changes since the dialog loaded. External writers that do not use
//! the same lock can still race after that check.
use std::fs::File;
use std::io::{self, Read};
use std::path::Path;
use super::{DocumentError, ThemeDocument};
const MAX_PREFERENCE_BYTES: usize = 1024 * 1024;
/// Prepare one explicit application of a stored custom theme. Both the
/// picker and editor use these updates, so existing personal overrides cannot
/// accidentally mask a value the user has just confirmed in the preview.
/// Absent optional theme values keep the corresponding personal preference.
/// Preparing these values performs no I/O and does not apply the theme.
pub(crate) fn custom_theme_updates(
document: &ThemeDocument,
foreground: Option<nebula_settings::Rgb8>,
) -> Result<Vec<(&'static str, String)>, DocumentError> {
let id = document.id().filter(|_| !document.is_builtin()).ok_or_else(|| {
DocumentError::Invalid("applying a custom theme requires a saved library id".to_owned())
})?;
let definition = document.definition()?;
let card = definition.layout.card;
let mut updates = vec![
("custom_theme", id.to_owned()),
("theme", definition.base.prompt_name().to_owned()),
("follow_system_theme", "0".to_owned()),
("background", nebula_settings::format_hex_rgb(definition.terminal.background)),
("theme_foreground", foreground.map(nebula_settings::format_hex_rgb).unwrap_or_default()),
("pane_card_radius", card.radius.to_string()),
("pane_card_gutter", card.gutter.to_string()),
("pane_card_shadow", if card.shadow { "1" } else { "0" }.to_owned()),
("pane_card_divider", card.divider.to_string()),
];
if let Some(family) = definition.typography.font_family {
updates.push(("font_family", family));
}
if let Some(size) = definition.typography.font_size {
updates.push(("font_size", size.to_string()));
}
if let Some(opacity) = definition.effects.opacity {
updates.push(("opacity", opacity.to_string()));
}
if let Some(blur) = definition.effects.blur {
updates.push(("blur", blur.settings_value().to_owned()));
}
if let Some(shape) = definition.effects.cursor_shape {
updates.push(("cursor_shape", shape.settings_value().to_owned()));
}
Ok(updates)
}
/// Exact bytes last observed by the dialog, including an absent-file revision.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct PreferenceRevision(Option<Vec<u8>>);
pub(crate) fn load() -> io::Result<PreferenceRevision> {
read_at(&nebula_settings::settings_path())
}
pub(crate) fn save(
expected: &PreferenceRevision,
updates: &[(&str, String)],
) -> io::Result<PreferenceRevision> {
save_at(&nebula_settings::settings_path(), expected, updates)
}
fn read_at(path: &Path) -> io::Result<PreferenceRevision> {
let file = match File::open(path) {
Ok(file) => file,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return Ok(PreferenceRevision(None));
},
Err(error) => return Err(error),
};
let mut bytes = Vec::new();
file.take((MAX_PREFERENCE_BYTES + 1) as u64).read_to_end(&mut bytes)?;
if bytes.len() > MAX_PREFERENCE_BYTES {
return Err(io::Error::new(io::ErrorKind::InvalidData, "settings file exceeds 1 MiB"));
}
std::str::from_utf8(&bytes).map_err(|error| {
io::Error::new(io::ErrorKind::InvalidData, format!("settings are not UTF-8: {error}"))
})?;
Ok(PreferenceRevision(Some(bytes)))
}
fn save_at(
path: &Path,
expected: &PreferenceRevision,
updates: &[(&str, String)],
) -> io::Result<PreferenceRevision> {
if updates.iter().any(|(key, value)| {
key.is_empty() || key.contains(['=', '\r', '\n']) || value.contains(['\r', '\n'])
}) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"a setting key or value contains a line separator",
));
}
let _lock = crate::atomic_file::try_lifetime_lock(path)?.ok_or_else(|| {
io::Error::new(io::ErrorKind::WouldBlock, "settings are being saved by another window")
})?;
let current = read_at(path)?;
if current != *expected {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"settings changed outside this dialog; reload before applying the theme",
));
}
let text = std::str::from_utf8(current.0.as_deref().unwrap_or_default())
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
let updated = nebula_settings::apply_updates(text, updates).into_bytes();
if updated.len() > MAX_PREFERENCE_BYTES {
return Err(io::Error::new(io::ErrorKind::InvalidData, "settings file exceeds 1 MiB"));
}
if current.0.as_deref() != Some(updated.as_slice()) {
crate::atomic_file::write(path, &updated)?;
}
Ok(PreferenceRevision(Some(updated)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn confirmed_theme_options_replace_old_active_overrides_together() {
let mut definition =
nebula_settings::ThemeDefinition::from_builtin(nebula_settings::ThemeName::Nord);
definition.terminal.foreground = [0xdd, 0xcc, 0xbb];
definition.typography.font_size = Some(18.0);
definition.typography.font_family = Some("Cascadia Code".to_owned());
definition.effects.opacity = Some(0.7);
definition.effects.blur = Some(nebula_settings::BlurModeName::Acrylic);
definition.layout.card.radius = 12.0;
definition.layout.card.gutter = 8.0;
let document = super::super::from_definition(&definition)
.unwrap()
.fork("custom-confirmed", "Confirmed")
.unwrap();
let initial = "font_size=14\nfont_family=Old Font\nopacity=1\nblur=none\npane_card_radius=0\ntheme_foreground=#ffffff\nshell=cmd\n";
let text = nebula_settings::apply_updates(
initial,
&custom_theme_updates(&document, None).unwrap(),
);
let runtime = nebula_settings::RuntimeSettings::from_raw(
&nebula_settings::RawSettings::from_text(&text),
);
assert_eq!(runtime.custom_theme.as_deref(), Some("custom-confirmed"));
assert_eq!(runtime.theme_foreground, None);
assert_eq!(runtime.font_size_px, Some(18.0));
assert_eq!(runtime.font_family.as_deref(), Some("Cascadia Code"));
assert_eq!(runtime.opacity, 0.7);
assert_eq!(runtime.blur, nebula_settings::BlurModeName::Acrylic);
assert_eq!(runtime.pane_card_radius, Some(12.0));
assert_eq!(runtime.pane_card_gutter, Some(8.0));
assert_eq!(runtime.shell.as_deref(), Some("cmd"));
}
#[test]
fn absent_theme_options_preserve_personal_settings_and_preview_override_is_explicit() {
let document = super::super::builtin_document(nebula_settings::ThemeName::Nord)
.unwrap()
.fork("custom-colors-only", "Colors only")
.unwrap();
let foreground = [0xf0, 0xd0, 0xa0];
let text = nebula_settings::apply_updates(
"font_family=My Font\nfont_size=19\nopacity=0.85\nblur=mica\n",
&custom_theme_updates(&document, Some(foreground)).unwrap(),
);
let runtime = nebula_settings::RuntimeSettings::from_raw(
&nebula_settings::RawSettings::from_text(&text),
);
assert_eq!(runtime.font_family.as_deref(), Some("My Font"));
assert_eq!(runtime.font_size_px, Some(19.0));
assert_eq!(runtime.opacity, 0.85);
assert_eq!(runtime.blur, nebula_settings::BlurModeName::Mica);
assert_eq!(runtime.theme_foreground, Some(foreground));
}
#[test]
fn applying_theme_and_foreground_is_one_revision_and_preserves_other_preferences() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.txt");
let initial = "# personal settings\r\nTHEME=Paper\r\nfont_family=Maple Mono NF CN\r\ncustom_key=keep\r\n";
std::fs::write(&path, initial).unwrap();
let revision = read_at(&path).unwrap();
let applied = save_at(
&path,
&revision,
&[
("theme", "Nord".to_owned()),
("custom_theme", "".to_owned()),
("theme_foreground", "#e0bc91".to_owned()),
],
)
.unwrap();
assert_eq!(read_at(&path).unwrap(), applied);
let saved = std::fs::read_to_string(&path).unwrap();
let runtime = nebula_settings::RuntimeSettings::from_raw(
&nebula_settings::RawSettings::from_text(&saved),
);
assert_eq!(runtime.theme, nebula_settings::ThemeName::Nord);
assert_eq!(runtime.theme_foreground, Some([0xe0, 0xbc, 0x91]));
assert_eq!(runtime.font_family.as_deref(), Some("Maple Mono NF CN"));
assert!(saved.contains("custom_key=keep"));
assert!(saved.contains("# personal settings"));
assert_eq!(save_at(&path, &applied, &[]).unwrap(), applied);
}
#[test]
fn stale_dialog_cannot_overwrite_an_external_edit_or_a_new_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.txt");
let absent = read_at(&path).unwrap();
std::fs::write(&path, "font_size=19\n").unwrap();
let error = save_at(&path, &absent, &[("theme", "Nord".to_owned())]).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "font_size=19\n");
let loaded = read_at(&path).unwrap();
std::fs::write(&path, "font_size=21\n").unwrap();
assert!(save_at(&path, &loaded, &[("theme", "Paper".to_owned())]).is_err());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "font_size=21\n");
}
#[test]
fn busy_writer_and_invalid_input_preserve_settings() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.txt");
std::fs::write(&path, "theme=Paper\n").unwrap();
let loaded = read_at(&path).unwrap();
let lock = crate::atomic_file::try_lifetime_lock(&path).unwrap().unwrap();
assert_eq!(
save_at(&path, &loaded, &[("theme", "Nord".to_owned())]).unwrap_err().kind(),
io::ErrorKind::WouldBlock
);
drop(lock);
assert_eq!(
save_at(&path, &loaded, &[("font_family", "Mono\nopacity=0".to_owned())])
.unwrap_err()
.kind(),
io::ErrorKind::InvalidInput
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "theme=Paper\n");
}
#[test]
fn malformed_or_oversized_settings_are_not_replaced() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.txt");
std::fs::write(&path, [0xff, 0xfe, 0x00]).unwrap();
assert_eq!(read_at(&path).unwrap_err().kind(), io::ErrorKind::InvalidData);
assert_eq!(std::fs::read(&path).unwrap(), [0xff, 0xfe, 0x00]);
std::fs::write(&path, vec![b'x'; MAX_PREFERENCE_BYTES + 1]).unwrap();
assert_eq!(read_at(&path).unwrap_err().kind(), io::ErrorKind::InvalidData);
assert_eq!(std::fs::metadata(&path).unwrap().len(), (MAX_PREFERENCE_BYTES + 1) as u64);
}
}
+511
View File
@@ -0,0 +1,511 @@
//! Durable custom-theme library.
//!
//! Built-in themes remain virtual values from `nebula_settings`; only user
//! themes are files. The store is deliberately synchronous and cold-path:
//! callers run it on their background executor and retain the returned
//! `ThemeDocument`/`ThemeDefinition` for rendering.
use std::fmt;
use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use super::document::{DocumentError, MAX_DOCUMENT_BYTES, ThemeDocument, builtin_documents};
pub const MAX_CUSTOM_THEMES: usize = 128;
pub const THEME_FILE_SUFFIX: &str = ".pebrel-theme.json";
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RevisionPrecondition {
/// Create a file only when no file with this ID exists.
Absent,
/// Update only when the current document has this revision.
Exact(u64),
/// Allow either creation or update, retaining the current revision chain.
Any,
/// Require the complete document snapshot to be unchanged. This catches
/// external edits that keep the same numeric revision.
Unchanged(ThemeDocument),
}
#[derive(Debug)]
pub enum StoreError {
Io { path: PathBuf, source: io::Error },
Document(DocumentError),
InvalidId(String),
InvalidDocument(String),
NotFound(String),
BuiltinReadOnly(String),
Busy,
Conflict { expected: Option<u64>, actual: Option<u64> },
Limit { limit: usize },
}
impl fmt::Display for StoreError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io { path, source } => {
write!(f, "theme store I/O at {}: {source}", path.display())
},
Self::Document(error) => error.fmt(f),
Self::InvalidId(id) => write!(f, "invalid theme id: {id}"),
Self::InvalidDocument(message) => write!(f, "invalid stored theme: {message}"),
Self::NotFound(id) => write!(f, "theme not found: {id}"),
Self::BuiltinReadOnly(id) => write!(f, "built-in theme is read-only: {id}"),
Self::Busy => f.write_str("theme library is busy; retry the operation"),
Self::Conflict { expected, actual } => {
write!(f, "theme revision conflict: expected {expected:?}, found {actual:?}")
},
Self::Limit { limit } => write!(f, "custom theme limit reached ({limit})"),
}
}
}
impl std::error::Error for StoreError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
Self::Document(error) => Some(error),
_ => None,
}
}
}
impl From<DocumentError> for StoreError {
fn from(error: DocumentError) -> Self {
Self::Document(error)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoreDiagnostic {
pub path: PathBuf,
pub message: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ThemeLibrarySnapshot {
pub builtins: Vec<ThemeDocument>,
pub custom: Vec<ThemeDocument>,
pub diagnostics: Vec<StoreDiagnostic>,
}
#[derive(Clone, Debug)]
pub struct ThemeLibraryStore {
root: PathBuf,
}
impl Default for ThemeLibraryStore {
fn default() -> Self {
Self::new(nebula_settings::settings_dir().join("themes"))
}
}
impl ThemeLibraryStore {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn root(&self) -> &Path {
&self.root
}
/// Return the exact path used by a custom theme. This is also useful to
/// diagnostics and tests without exposing a second path construction rule.
pub fn path_for(&self, id: &str) -> Result<PathBuf, StoreError> {
validate_id(id)?;
Ok(self.root.join(format!("{id}{THEME_FILE_SUFFIX}")))
}
pub fn list(&self) -> Result<ThemeLibrarySnapshot, StoreError> {
let mut snapshot = ThemeLibrarySnapshot {
builtins: builtin_documents(),
..ThemeLibrarySnapshot::default()
};
let entries = match fs::read_dir(&self.root) {
Ok(entries) => entries,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(snapshot),
Err(error) => return Err(io_error(&self.root, error)),
};
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
snapshot.diagnostics.push(StoreDiagnostic {
path: self.root.clone(),
message: format!("unable to inspect theme entry: {error}"),
});
continue;
},
};
let path = entry.path();
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
continue;
};
let Some(id) = file_name.strip_suffix(THEME_FILE_SUFFIX) else {
continue;
};
if let Err(error) = validate_id(id) {
snapshot.diagnostics.push(StoreDiagnostic { path, message: error.to_string() });
continue;
}
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(error) => {
snapshot.diagnostics.push(StoreDiagnostic {
path,
message: format!("unable to inspect file type: {error}"),
});
continue;
},
};
if file_type.is_symlink() {
snapshot.diagnostics.push(StoreDiagnostic {
path,
message: "symbolic links are not accepted as theme files".to_owned(),
});
continue;
}
if !file_type.is_file() {
continue;
}
match read_document(&path, id) {
Ok(Some(document)) => snapshot.custom.push(document),
Ok(None) => snapshot.diagnostics.push(StoreDiagnostic {
path,
message: "theme file disappeared while scanning".to_owned(),
}),
Err(error) => {
snapshot.diagnostics.push(StoreDiagnostic { path, message: error.to_string() })
},
}
}
snapshot.custom.sort_by(|left, right| {
left.name().cmp(right.name()).then_with(|| left.id().cmp(&right.id()))
});
Ok(snapshot)
}
pub fn load(&self, id: &str) -> Result<ThemeDocument, StoreError> {
if let Some(document) = builtin_documents()
.into_iter()
.find(|document| document.id() == Some(id) || document.name() == id)
{
return Ok(document);
}
let path = self.path_for(id)?;
read_document(&path, id)?.ok_or_else(|| StoreError::NotFound(id.to_owned()))
}
pub fn load_theme(&self, id: &str) -> Result<ThemeDocument, StoreError> {
self.load(id)
}
pub fn save(
&self,
document: &ThemeDocument,
precondition: RevisionPrecondition,
) -> Result<ThemeDocument, StoreError> {
if document.is_builtin() {
return Err(StoreError::BuiltinReadOnly(document.name().to_owned()));
}
let id = document
.id()
.ok_or_else(|| StoreError::InvalidDocument("custom themes require an id".to_owned()))?;
let path = self.path_for(id)?;
// Resolve once before taking the filesystem locks. This validates all
// runtime scalar semantics without doing JSON work while the lock is held.
document.definition()?;
let _global_lock = self.acquire_global_lock()?;
let _target_lock = self.acquire_target_lock(&path)?;
let current = read_document(&path, id)?;
let actual = current.as_ref().map(ThemeDocument::revision);
let expected = match precondition {
RevisionPrecondition::Absent => {
if current.is_some() {
return Err(StoreError::Conflict { expected: None, actual });
}
None
},
RevisionPrecondition::Exact(expected) => {
if actual != Some(expected) {
return Err(StoreError::Conflict { expected: Some(expected), actual });
}
Some(expected)
},
RevisionPrecondition::Any => actual,
RevisionPrecondition::Unchanged(expected_document) => {
if expected_document.id() != Some(id) {
return Err(StoreError::InvalidDocument(
"unchanged precondition belongs to another theme id".to_owned(),
));
}
if current.as_ref() != Some(&expected_document) {
return Err(StoreError::Conflict {
expected: Some(expected_document.revision()),
actual,
});
}
Some(expected_document.revision())
},
};
if current.is_none() && self.custom_file_count()? >= MAX_CUSTOM_THEMES {
return Err(StoreError::Limit { limit: MAX_CUSTOM_THEMES });
}
let revision = expected.map_or(1, |revision| revision.saturating_add(1));
let stored = document.with_id_and_revision(id.to_owned(), revision);
let bytes = stored.to_json_bytes()?;
if bytes.len() > MAX_DOCUMENT_BYTES {
return Err(StoreError::Document(DocumentError::TooLarge { bytes: bytes.len() }));
}
fs::create_dir_all(&self.root).map_err(|error| io_error(&self.root, error))?;
// Re-check after locking so a symlink swap cannot be atomically
// replaced as a regular theme file.
reject_symlink(&path)?;
crate::atomic_file::write(&path, &bytes).map_err(|error| io_error(&path, error))?;
Ok(stored)
}
/// Clone a built-in theme, choose a collision-free display name and save it
/// immediately. The returned custom theme therefore starts at revision 1.
pub fn fork_builtin(
&self,
builtin_name_or_id: &str,
requested_name: Option<&str>,
) -> Result<ThemeDocument, StoreError> {
let builtin = builtin_documents()
.into_iter()
.find(|document| {
document.name() == builtin_name_or_id || document.id() == Some(builtin_name_or_id)
})
.ok_or_else(|| StoreError::NotFound(builtin_name_or_id.to_owned()))?;
let snapshot = self.list()?;
let name = unique_name(requested_name.unwrap_or(builtin.name()), &snapshot)?;
let id = self.unique_id(&name, &snapshot)?;
let candidate = builtin.fork(id, name)?;
self.save(&candidate, RevisionPrecondition::Absent)
}
/// Import any validated document as a new custom theme. Native IDs are
/// intentionally ignored so an import cannot overwrite an existing file.
pub fn import(
&self,
document: &ThemeDocument,
requested_name: Option<&str>,
) -> Result<ThemeDocument, StoreError> {
let snapshot = self.list()?;
let name = unique_name(requested_name.unwrap_or(document.name()), &snapshot)?;
let id = self.unique_id(&name, &snapshot)?;
let candidate = document.with_name(name)?.with_id_and_revision(id, 0);
self.save(&candidate, RevisionPrecondition::Absent)
}
pub fn delete(&self, id: &str, expected_revision: u64) -> Result<(), StoreError> {
if builtin_documents()
.into_iter()
.any(|document| document.id() == Some(id) || document.name() == id)
{
return Err(StoreError::BuiltinReadOnly(id.to_owned()));
}
let path = self.path_for(id)?;
let _global_lock = self.acquire_global_lock()?;
let _target_lock = self.acquire_target_lock(&path)?;
let current = read_document(&path, id)?;
let Some(document) = current else {
return Err(StoreError::Conflict { expected: Some(expected_revision), actual: None });
};
if document.revision() != expected_revision {
return Err(StoreError::Conflict {
expected: Some(expected_revision),
actual: Some(document.revision()),
});
}
fs::remove_file(&path).map_err(|error| io_error(&path, error))
}
fn acquire_global_lock(&self) -> Result<crate::atomic_file::LifetimeFileLock, StoreError> {
let path = self.root.join(".pebrel-theme-library");
crate::atomic_file::try_lifetime_lock(&path)
.map_err(|error| io_error(&path, error))?
.ok_or(StoreError::Busy)
}
fn acquire_target_lock(
&self,
path: &Path,
) -> Result<crate::atomic_file::LifetimeFileLock, StoreError> {
crate::atomic_file::try_lifetime_lock(path)
.map_err(|error| io_error(path, error))?
.ok_or(StoreError::Busy)
}
fn custom_file_count(&self) -> Result<usize, StoreError> {
let entries = match fs::read_dir(&self.root) {
Ok(entries) => entries,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(0),
Err(error) => return Err(io_error(&self.root, error)),
};
let mut count = 0;
for entry in entries {
let entry = entry.map_err(|error| io_error(&self.root, error))?;
let path = entry.path();
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
continue;
};
let Some(id) = file_name.strip_suffix(THEME_FILE_SUFFIX) else {
continue;
};
if validate_id(id).is_err()
|| entry.file_type().map_err(|error| io_error(&path, error))?.is_symlink()
{
continue;
}
count += 1;
}
Ok(count)
}
fn unique_id(&self, name: &str, snapshot: &ThemeLibrarySnapshot) -> Result<String, StoreError> {
let base = slug(name);
for suffix in 1..=MAX_CUSTOM_THEMES + 1 {
let candidate = if suffix == 1 { base.clone() } else { format!("{base}-{suffix}") };
if validate_id(&candidate).is_err() {
continue;
}
if snapshot.custom.iter().all(|document| document.id() != Some(candidate.as_str()))
&& !self.path_for(&candidate)?.exists()
{
return Ok(candidate);
}
}
Err(StoreError::Limit { limit: MAX_CUSTOM_THEMES })
}
}
fn read_document(path: &Path, expected_id: &str) -> Result<Option<ThemeDocument>, StoreError> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(io_error(path, error)),
};
if metadata.file_type().is_symlink() {
return Err(StoreError::InvalidDocument(format!(
"symbolic links are not accepted: {}",
path.display()
)));
}
if !metadata.is_file() {
return Err(StoreError::InvalidDocument(format!(
"theme path is not a regular file: {}",
path.display()
)));
}
if metadata.len() > MAX_DOCUMENT_BYTES as u64 {
return Err(StoreError::Document(DocumentError::TooLarge {
bytes: metadata.len() as usize,
}));
}
let mut file = fs::File::open(path).map_err(|error| io_error(path, error))?;
if fs::symlink_metadata(path).map_err(|error| io_error(path, error))?.file_type().is_symlink() {
return Err(StoreError::InvalidDocument(format!(
"symbolic links are not accepted: {}",
path.display()
)));
}
let mut bytes = Vec::new();
file.by_ref()
.take((MAX_DOCUMENT_BYTES + 1) as u64)
.read_to_end(&mut bytes)
.map_err(|error| io_error(path, error))?;
if bytes.len() > MAX_DOCUMENT_BYTES {
return Err(StoreError::Document(DocumentError::TooLarge { bytes: bytes.len() }));
}
let document = ThemeDocument::from_json_bytes(&bytes)?;
if document.is_builtin() {
return Err(StoreError::InvalidDocument(
"custom theme files cannot be marked builtin".to_owned(),
));
}
if document.id() != Some(expected_id) {
return Err(StoreError::InvalidDocument(format!(
"document id {:?} does not match file id {expected_id}",
document.id()
)));
}
Ok(Some(document))
}
fn reject_symlink(path: &Path) -> Result<(), StoreError> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => Err(StoreError::InvalidDocument(
format!("symbolic links are not accepted: {}", path.display()),
)),
Ok(_) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(io_error(path, error)),
}
}
fn validate_id(id: &str) -> Result<(), StoreError> {
if id.is_empty()
|| id.len() > 128
|| !id.bytes().all(|byte| byte.is_ascii_alphanumeric() || b"_-".contains(&byte))
{
return Err(StoreError::InvalidId(id.to_owned()));
}
Ok(())
}
const MAX_THEME_NAME_BYTES: usize = 128;
fn unique_name(requested: &str, snapshot: &ThemeLibrarySnapshot) -> Result<String, StoreError> {
let base = requested.trim();
let base = if base.is_empty() { "Imported theme" } else { base };
let occupied = |name: &str| {
snapshot
.builtins
.iter()
.chain(snapshot.custom.iter())
.any(|document| document.name().eq_ignore_ascii_case(name))
};
// Check the final bounded candidate, rather than the unbounded source
// name. Otherwise a long name can truncate into an existing name.
for suffix_number in 0..=(MAX_CUSTOM_THEMES + 1) {
let suffix =
if suffix_number == 0 { String::new() } else { format!(" ({})", suffix_number + 1) };
let base_budget = MAX_THEME_NAME_BYTES.saturating_sub(suffix.len());
let candidate = format!("{}{}", truncate_name(base, base_budget), suffix);
if !candidate.is_empty() && !occupied(&candidate) {
return Ok(candidate);
}
}
Err(StoreError::Limit { limit: MAX_CUSTOM_THEMES })
}
fn truncate_name(value: &str, max_bytes: usize) -> String {
let mut end = value.len().min(max_bytes);
while !value.is_char_boundary(end) {
end -= 1;
}
value[..end].to_owned()
}
fn slug(value: &str) -> String {
let mut output = String::new();
for character in value.chars() {
if character.is_ascii_alphanumeric() || character == '-' || character == '_' {
output.push(character.to_ascii_lowercase());
} else if !output.ends_with('-') {
output.push('-');
}
}
let output = output.trim_matches('-');
if output.is_empty() { "theme".to_owned() } else { output.chars().take(128).collect() }
}
fn io_error(path: &Path, source: io::Error) -> StoreError {
StoreError::Io { path: path.to_owned(), source }
}
+145
View File
@@ -0,0 +1,145 @@
use super::*;
use nebula_settings::{CursorShapeName, ThemeDefinition, ThemeEffects, ThemeName, ThemeTypography};
#[test]
fn every_builtin_round_trips_through_the_native_envelope() {
assert_eq!(builtin_documents().len(), ThemeName::BUILTIN.len());
for theme in ThemeName::BUILTIN {
let definition = ThemeDefinition::from_builtin(theme);
let document = from_definition(&definition).expect("built-in document");
assert_eq!(document.definition().expect("resolved definition"), definition);
}
}
#[test]
fn optional_runtime_overrides_stay_optional() {
let mut definition = ThemeDefinition::from_builtin(ThemeName::Nord);
definition.typography = ThemeTypography { font_size: Some(18.0), ..ThemeTypography::default() };
definition.effects = ThemeEffects::default();
let document = from_definition(&definition).expect("native document");
let resolved = document.definition().expect("resolved definition");
assert_eq!(resolved, definition);
assert!(document.to_value()["typography"]["font_family"].is_null());
assert!(document.to_value()["effects"]["blur"].is_null());
}
#[test]
fn native_round_trip_preserves_layout_cursor_and_typography_overrides() {
let mut definition = ThemeDefinition::from_builtin(ThemeName::Nord);
definition.layout.padding_x = Some(32.0);
definition.layout.padding_y = Some(24.0);
definition.effects.cursor_shape = Some(CursorShapeName::Beam);
definition.typography.font_family = Some("Cascadia Code".to_owned());
definition.typography.font_size = Some(15.0);
definition.typography.line_height = Some(1.6);
definition.typography.ligatures = false;
definition.typography.ui_font_family = Some("Segoe UI".to_owned());
definition.typography.ui_font_size = Some(13.0);
let document = from_definition(&definition).expect("native document");
assert_eq!(document.definition().expect("resolved definition"), definition);
}
#[test]
fn with_definition_replaces_known_extended_fields_and_keeps_vendor_data() {
let mut value = builtin_documents().remove(0).to_value();
value["vendor"] = serde_json::json!({"future": true});
let document = ThemeDocument::from_value(value).expect("document");
let mut definition = document.definition().expect("definition");
definition.typography.ligatures = false;
definition.typography.ui_font_family = Some("Segoe UI".to_owned());
definition.typography.ui_font_size = Some(13.0);
definition.layout.padding_x = Some(32.0);
definition.effects.cursor_shape = Some(CursorShapeName::Underline);
let replaced = document.with_definition(&definition).expect("replacement");
assert_eq!(replaced.to_value()["vendor"]["future"], true);
assert_eq!(replaced.definition().expect("resolved replacement"), definition);
}
#[test]
fn native_merge_preserves_unknown_fields() {
let mut value = builtin_documents().remove(0).to_value();
value["vendor"] = serde_json::json!({"future": true});
value["terminal"]["vendor_color"] = serde_json::json!("#102030");
let document = ThemeDocument::from_value(value).expect("document");
let mut definition = document.definition().expect("definition");
definition.terminal.foreground = [1, 2, 3];
let replaced = document.with_definition(&definition).expect("replacement");
assert_eq!(replaced.to_value()["vendor"]["future"], true);
assert_eq!(replaced.to_value()["terminal"]["vendor_color"], "#102030");
assert_eq!(replaced.color("terminal", "foreground"), Some([1, 2, 3]));
}
#[test]
fn store_fork_uses_revision_and_name_conflicts() {
let directory = tempfile::tempdir().expect("temporary directory");
let store = ThemeLibraryStore::new(directory.path());
let first = store.fork_builtin("Nord", Some("Nord")).expect("first fork");
assert_eq!(first.name(), "Nord (2)");
assert_eq!(first.revision(), 1);
let second = store.fork_builtin("Nord", Some("Nord")).expect("second fork");
assert_eq!(second.name(), "Nord (3)");
assert_eq!(second.revision(), 1);
let stale = first.with_name("Changed").expect("rename");
let error = store.save(&stale, RevisionPrecondition::Exact(0)).expect_err("stale revision");
assert!(matches!(error, StoreError::Conflict { .. }));
assert_eq!(store.load(first.id().expect("id")).expect("load").name(), first.name());
let path = store.path_for(first.id().expect("id")).expect("path");
let mut external = first.to_value();
external["terminal"]["foreground"] = serde_json::json!("#010203");
std::fs::write(&path, serde_json::to_vec(&external).expect("json")).expect("external edit");
let error = store
.save(&first, RevisionPrecondition::Unchanged(first.clone()))
.expect_err("same revision external edit");
assert!(matches!(error, StoreError::Conflict { .. }));
}
#[test]
fn store_import_names_are_utf8_safe_at_the_byte_limit() {
let directory = tempfile::tempdir().expect("temporary directory");
let store = ThemeLibraryStore::new(directory.path());
let builtin = builtin_document(ThemeName::Nord).expect("built-in document");
// 42 CJK characters (126 bytes) plus one ASCII byte exercises a valid
// name immediately below the 128-byte limit.
let near_limit = format!("{}x", "界".repeat(42));
let near_limit_theme = store.import(&builtin, Some(&near_limit)).expect("near-limit import");
assert_eq!(near_limit_theme.name().len(), 127);
// The first candidate is truncated on a character boundary. The second
// candidate must reserve bytes for its suffix instead of truncating the
// suffix away and returning the same display name.
let long_name = "主题".repeat(30);
let first = store.import(&builtin, Some(&long_name)).expect("long import");
let second = store.import(&builtin, Some(&long_name)).expect("duplicate long import");
assert!(first.name().len() <= 128);
assert!(second.name().len() <= 128);
assert_ne!(first.name(), second.name());
assert!(second.name().ends_with(" (2)"));
}
#[test]
fn format_adapters_are_bounded_and_static() {
let inspection = inspect(
"{\"name\":\"Solar\",\"background\":\"#001122\",\"foreground\":\"#ddeeff\",\"black\":\"#000000\"}",
"solar.json",
);
assert_eq!(inspection.candidates.len(), 1);
assert!(inspection.candidates[0].warnings.iter().any(|warning| warning.contains("ANSI")));
let kitty = inspect(
"foreground #ddeeff\nbackground #001122\ninclude ~/.config/kitty/theme.conf\ncolor0 #000000\n",
"theme.conf",
);
assert_eq!(kitty.candidates.len(), 1);
assert!(kitty.candidates[0].warnings.iter().any(|warning| warning.contains("not executed")));
let unsupported = inspect("<plist><dict/></plist>", "theme.itermcolors");
assert!(unsupported.candidates.is_empty());
assert!(unsupported.diagnostics[0].message.contains("unavailable"));
}
+817
View File
@@ -0,0 +1,817 @@
//! Value model for user-authored themes.
//!
//! This module deliberately contains no file format or serialization code. An
//! importer/exporter can translate a file into [`ThemeDefinition`] at the
//! application boundary, while the settings crate remains a small, dependency
//! free authority for resolved theme values.
use core::fmt;
use crate::{
BlurModeName, CursorShapeName, ExactTermColors, LIGHT_ANSI, LIGHT_FOREGROUND,
MAX_PANE_CARD_DIVIDER, MAX_PANE_CARD_GUTTER, MAX_PANE_CARD_RADIUS, Rgb8, Rgba8,
ThemeCardGeometry, ThemeName, format_hex_rgb, parse_hex_rgb,
};
/// A complete user theme. `base` records the built-in palette used as the
/// starting point; it is metadata and does not create a runtime inheritance
/// chain. A cloned definition owns all of its strings and arrays.
#[derive(Clone, Debug, PartialEq)]
pub struct ThemeDefinition {
pub name: String,
pub base: ThemeName,
pub appearance: ThemeAppearance,
pub terminal: TerminalThemeColors,
pub ui: ThemeUiColors,
pub typography: ThemeTypography,
pub layout: ThemeLayout,
pub effects: ThemeEffects,
}
impl ThemeDefinition {
/// Resolve one of Nebula's existing themes into an editable snapshot.
///
/// The snapshot is intentionally concrete: changing the built-in theme
/// later cannot mutate an already imported or edited custom theme.
pub fn from_builtin(base: ThemeName) -> Self {
let term = base.term_theme();
let palette = base.reviewed_palette();
let exact = term.exact.unwrap_or_else(|| {
if term.is_light {
ExactTermColors {
foreground: LIGHT_FOREGROUND,
ansi: LIGHT_ANSI,
cursor: None,
cursor_text: None,
cursor_stroke: None,
selection_foreground: None,
selection_background: None,
}
} else {
ExactTermColors {
foreground: palette.foreground,
ansi: fallback_ansi(palette),
cursor: Some(palette.accent),
cursor_text: Some(term.background),
cursor_stroke: Some(palette.accent),
selection_foreground: Some(palette.foreground),
selection_background: Some(palette.shell),
}
}
});
let mut indexed = IndexedPalette::from_ansi(exact.ansi);
// These slots are part of Nebula's existing prompt contract. Keeping
// them in the snapshot means a custom theme recolors prompt chips and
// the rest of the terminal from one value object.
for (offset, color) in term.powerline.into_iter().enumerate() {
indexed.colors[16 + offset] = color;
}
Self {
name: base.prompt_name().to_owned(),
base,
appearance: if term.is_light { ThemeAppearance::Light } else { ThemeAppearance::Dark },
terminal: TerminalThemeColors {
background: term.background,
foreground: exact.foreground,
cursor: exact.cursor,
cursor_text: exact.cursor_text,
cursor_stroke: exact.cursor_stroke,
selection_foreground: exact.selection_foreground,
selection_background: exact.selection_background,
palette: indexed,
},
ui: ThemeUiColors::from_palette(palette),
typography: ThemeTypography::default(),
layout: ThemeLayout { card: base.card_geometry(), padding_x: None, padding_y: None },
effects: ThemeEffects::default(),
}
}
/// Alias useful to adapters that call a built-in value a snapshot.
pub fn snapshot(base: ThemeName) -> Self {
Self::from_builtin(base)
}
/// Validate user-editable scalar values before applying or exporting them.
pub fn validate(&self) -> Result<(), ThemeValidationError> {
let name = self.name.trim();
if name.is_empty() {
return Err(ThemeValidationError::EmptyName);
}
if name.len() > MAX_THEME_NAME_BYTES {
return Err(ThemeValidationError::NameTooLong);
}
if self.name.chars().any(char::is_control) {
return Err(ThemeValidationError::NameContainsControlCharacter);
}
self.typography.validate()?;
self.layout.validate()?;
self.effects.validate()?;
Ok(())
}
/// Derive UI anchors from the resolved terminal values using the theme's
/// declared light/dark appearance. This is the one shared rule for
/// `ui.derive`; adapters should call it instead of implementing their own
/// background mixing or ANSI fallback.
pub fn derived_ui(&self) -> ThemeUiColors {
ThemeUiColors::from_terminal(&self.terminal, self.appearance)
}
/// Return the explicit UI values or the authoritative derived values,
/// depending on the theme's `ui.derive` flag.
pub fn resolved_ui(&self) -> ThemeUiColors {
if self.ui.derive { self.derived_ui() } else { self.ui }
}
/// Return a copy with the terminal's default text color changed.
pub fn with_terminal_foreground(&self, foreground: Rgb8) -> Self {
let mut theme = self.clone();
theme.terminal.foreground = foreground;
theme
}
/// Return a copy with cursor colors changed. `None` preserves the host
/// cursor behavior when the theme does not declare a cursor override.
pub fn with_cursor(
&self,
cursor: Option<Rgb8>,
cursor_text: Option<Rgb8>,
cursor_stroke: Option<Rgb8>,
) -> Self {
let mut theme = self.clone();
theme.terminal.cursor = cursor;
theme.terminal.cursor_text = cursor_text;
theme.terminal.cursor_stroke = cursor_stroke;
theme
}
/// Return a copy with selection colors changed.
pub fn with_selection(&self, foreground: Option<Rgb8>, background: Option<Rgb8>) -> Self {
let mut theme = self.clone();
theme.terminal.selection_foreground = foreground;
theme.terminal.selection_background = background;
theme
}
/// Return a copy with one ANSI slot changed. ANSI slots stay mirrored in
/// the first 16 indexed entries, so exporters cannot accidentally produce
/// two different values for the same terminal color.
pub fn with_ansi(&self, index: usize, color: Rgb8) -> Option<Self> {
if index >= 16 {
return None;
}
let mut theme = self.clone();
theme.terminal.palette.set(index, color);
Some(theme)
}
/// Return a copy with one indexed color changed. Indices 0..15 are valid
/// too and are treated as ANSI edits for consistency.
pub fn with_indexed(&self, index: usize, color: Rgb8) -> Option<Self> {
if index >= 256 {
return None;
}
let mut theme = self.clone();
theme.terminal.palette.set(index, color);
Some(theme)
}
/// Parse a color in the same form used by `nebula_settings.txt`.
pub fn parse_color(value: &str) -> Option<Rgb8> {
parse_hex_rgb(value)
}
/// Format a color for a text or JSON adapter.
pub fn format_color(color: Rgb8) -> String {
format_hex_rgb(color)
}
}
impl Default for ThemeDefinition {
fn default() -> Self {
Self::from_builtin(ThemeName::default())
}
}
/// The appearance slot stored by a theme document. System following remains
/// a separate runtime preference, so a theme snapshot always has a concrete
/// light or dark semantic when it is applied.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ThemeAppearance {
Light,
#[default]
Dark,
}
impl ThemeAppearance {
pub const fn is_light(self) -> bool {
matches!(self, Self::Light)
}
pub const fn settings_value(self) -> &'static str {
match self {
Self::Light => "light",
Self::Dark => "dark",
}
}
pub fn from_settings(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"light" => Some(Self::Light),
"dark" => Some(Self::Dark),
_ => None,
}
}
}
/// Terminal colors that are independent from the UI chrome.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TerminalThemeColors {
pub background: Rgb8,
pub foreground: Rgb8,
pub cursor: Option<Rgb8>,
pub cursor_text: Option<Rgb8>,
pub cursor_stroke: Option<Rgb8>,
pub selection_foreground: Option<Rgb8>,
pub selection_background: Option<Rgb8>,
/// ANSI 0..15 and indexed 16..255 in one fixed-size palette.
pub palette: IndexedPalette,
}
impl TerminalThemeColors {
pub fn ansi(&self, index: usize) -> Option<Rgb8> {
self.palette.get(index)
}
pub fn indexed(&self, index: usize) -> Option<Rgb8> {
self.palette.get(index)
}
}
/// Complete xterm-style 256-color table. The first 16 entries are the
/// theme's ANSI colors, while entries 16..255 are always present as well.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IndexedPalette {
pub colors: [Rgb8; 256],
}
impl IndexedPalette {
pub fn from_ansi(ansi: [Rgb8; 16]) -> Self {
let mut colors = [[0; 3]; 256];
colors[..16].copy_from_slice(&ansi);
const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
for index in 16..232 {
let cube = index - 16;
colors[index] = [LEVELS[cube / 36], LEVELS[(cube / 6) % 6], LEVELS[cube % 6]];
}
for index in 232..256 {
let level = 8 + ((index - 232) * 10) as u8;
colors[index] = [level; 3];
}
Self { colors }
}
pub fn get(&self, index: usize) -> Option<Rgb8> {
self.colors.get(index).copied()
}
pub fn set(&mut self, index: usize, color: Rgb8) -> bool {
let Some(slot) = self.colors.get_mut(index) else { return false };
*slot = color;
true
}
pub fn ansi_colors(&self) -> [Rgb8; 16] {
let mut ansi = [[0; 3]; 16];
ansi.copy_from_slice(&self.colors[..16]);
ansi
}
/// The complete table already includes every indexed value.
pub fn indexed_colors(&self) -> &[Rgb8; 256] {
&self.colors
}
}
/// UI chrome semantic colors. This list intentionally stays small; adapters
/// can derive additional disabled/hover colors from these stable anchors.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ThemeUiColors {
/// Mirrors the native document's `ui.derive` flag.
pub derive: bool,
pub shell: Rgb8,
pub background: Rgb8,
pub foreground: Rgb8,
pub muted: Rgb8,
pub accent: Rgb8,
pub selection: Rgba8,
pub line: Rgba8,
pub frame: Rgb8,
pub error: Rgb8,
pub success: Rgb8,
pub warning: Rgb8,
pub info: Rgb8,
}
impl ThemeUiColors {
/// Build UI anchors from the reviewed built-in palette.
pub fn from_palette(palette: crate::ReviewedPalette) -> Self {
Self {
derive: false,
shell: palette.shell,
background: palette.background,
foreground: palette.foreground,
muted: palette.muted,
accent: palette.accent,
selection: palette.selected,
line: palette.line,
frame: palette.frame,
error: palette.red,
success: palette.green,
warning: palette.yellow,
info: palette.blue,
}
}
/// Build UI anchors from a complete terminal theme. The terminal
/// foreground/background remain byte-for-byte untouched; only UI semantic
/// fallbacks are derived. `appearance` controls the small selection
/// surface blend used when the terminal has no explicit selection color.
pub fn from_terminal(terminal: &TerminalThemeColors, appearance: ThemeAppearance) -> Self {
let background = terminal.background;
let foreground = terminal.foreground;
let muted = terminal.palette.colors[8];
let accent =
terminal.cursor.or(terminal.cursor_stroke).unwrap_or(terminal.palette.colors[14]);
let selection = terminal.selection_background.map_or_else(
|| {
let weight = if appearance.is_light() { 0.13 } else { 0.18 };
let rgb = mix_rgb(background, foreground, weight);
[rgb[0], rgb[1], rgb[2], 255]
},
|rgb| [rgb[0], rgb[1], rgb[2], 255],
);
let shell = match appearance {
ThemeAppearance::Light => mix_rgb(background, [0, 0, 0], 0.06),
ThemeAppearance::Dark => mix_rgb(background, [255, 255, 255], 0.06),
};
Self {
derive: true,
shell,
background,
foreground,
muted,
accent,
selection,
line: [muted[0], muted[1], muted[2], 255],
frame: muted,
error: terminal.palette.colors[9],
success: terminal.palette.colors[10],
warning: terminal.palette.colors[11],
info: terminal.palette.colors[14],
}
}
pub fn with_derive(mut self, derive: bool) -> Self {
self.derive = derive;
self
}
/// RGB components for backends that apply alpha themselves.
pub const fn selection_rgb(self) -> Rgb8 {
[self.selection[0], self.selection[1], self.selection[2]]
}
pub const fn line_rgb(self) -> Rgb8 {
[self.line[0], self.line[1], self.line[2]]
}
}
/// Optional typography overrides; `None` retains the user's global setting.
#[derive(Clone, Debug, PartialEq)]
pub struct ThemeTypography {
pub font_family: Option<String>,
pub font_size: Option<f32>,
pub line_height: Option<f32>,
pub letter_spacing: Option<f32>,
pub font_weight: Option<u16>,
/// Whether terminal text shaping may use ligatures. Built-in themes keep
/// the existing enabled default; imported documents can explicitly turn
/// it off without losing that choice at the JSON boundary.
pub ligatures: bool,
/// Optional UI/chrome font override. The GPUI adapter may choose to
/// consume this later, but the shared model must retain it now so an
/// import/export cycle is lossless.
pub ui_font_family: Option<String>,
pub ui_font_size: Option<f32>,
}
impl Default for ThemeTypography {
fn default() -> Self {
Self {
font_family: None,
font_size: None,
line_height: None,
letter_spacing: None,
font_weight: None,
ligatures: true,
ui_font_family: None,
ui_font_size: None,
}
}
}
impl ThemeTypography {
fn validate(&self) -> Result<(), ThemeValidationError> {
if self.font_family.as_deref().is_some_and(|family| {
family.trim().is_empty()
|| family.len() > MAX_FONT_FAMILY_BYTES
|| family.chars().any(char::is_control)
}) {
return Err(ThemeValidationError::InvalidFontFamily);
}
if self.ui_font_family.as_deref().is_some_and(|family| {
family.trim().is_empty()
|| family.len() > MAX_FONT_FAMILY_BYTES
|| family.chars().any(char::is_control)
}) {
return Err(ThemeValidationError::InvalidUiFontFamily);
}
if let Some(size) = self.font_size {
if !size.is_finite() || !(4.0..=96.0).contains(&size) {
return Err(ThemeValidationError::InvalidFontSize);
}
}
if let Some(line_height) = self.line_height {
if !line_height.is_finite() || !(0.5..=3.0).contains(&line_height) {
return Err(ThemeValidationError::InvalidLineHeight);
}
}
if let Some(letter_spacing) = self.letter_spacing {
if !letter_spacing.is_finite() || !(0.0..=8.0).contains(&letter_spacing) {
return Err(ThemeValidationError::InvalidLetterSpacing);
}
}
if let Some(weight) = self.font_weight {
if !(100..=900).contains(&weight) {
return Err(ThemeValidationError::InvalidFontWeight);
}
}
if let Some(size) = self.ui_font_size {
if !size.is_finite() || !(10.0..=20.0).contains(&size) {
return Err(ThemeValidationError::InvalidUiFontSize);
}
}
Ok(())
}
}
/// Layout values that belong to a theme rather than to a user's window size.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ThemeLayout {
pub card: ThemeCardGeometry,
pub padding_x: Option<f32>,
pub padding_y: Option<f32>,
}
impl ThemeLayout {
fn validate(&self) -> Result<(), ThemeValidationError> {
let card = self.card;
if !card.radius.is_finite() || !(0.0..=MAX_PANE_CARD_RADIUS).contains(&card.radius) {
return Err(ThemeValidationError::InvalidLayoutRadius);
}
if !card.gutter.is_finite() || !(0.0..=MAX_PANE_CARD_GUTTER).contains(&card.gutter) {
return Err(ThemeValidationError::InvalidLayoutGutter);
}
if !card.divider.is_finite() || !(0.0..=MAX_PANE_CARD_DIVIDER).contains(&card.divider) {
return Err(ThemeValidationError::InvalidLayoutDivider);
}
if self
.padding_x
.is_some_and(|padding| !padding.is_finite() || !(0.0..=48.0).contains(&padding))
{
return Err(ThemeValidationError::InvalidLayoutPaddingX);
}
if self
.padding_y
.is_some_and(|padding| !padding.is_finite() || !(0.0..=40.0).contains(&padding))
{
return Err(ThemeValidationError::InvalidLayoutPaddingY);
}
Ok(())
}
}
/// Optional visual effects; paths and fit/alignment are renderer-owned values.
#[derive(Clone, Debug, PartialEq)]
pub struct ThemeEffects {
pub opacity: Option<f32>,
pub blur: Option<BlurModeName>,
pub cursor_shape: Option<CursorShapeName>,
pub background_image: Option<String>,
pub background_image_opacity: Option<f32>,
pub background_image_fit: Option<String>,
pub background_image_alignment: Option<String>,
pub background_image_cover_chrome: Option<bool>,
}
impl Default for ThemeEffects {
fn default() -> Self {
Self {
opacity: None,
blur: None,
cursor_shape: None,
background_image: None,
background_image_opacity: None,
background_image_fit: None,
background_image_alignment: None,
background_image_cover_chrome: None,
}
}
}
impl ThemeEffects {
fn validate(&self) -> Result<(), ThemeValidationError> {
if let Some(opacity) = self.opacity {
if !opacity.is_finite() || !(0.0..=1.0).contains(&opacity) {
return Err(ThemeValidationError::InvalidOpacity);
}
}
if let Some(opacity) = self.background_image_opacity {
if !opacity.is_finite() || !(0.0..=1.0).contains(&opacity) {
return Err(ThemeValidationError::InvalidBackgroundImageOpacity);
}
}
for value in [
self.background_image.as_deref(),
self.background_image_fit.as_deref(),
self.background_image_alignment.as_deref(),
]
.into_iter()
.flatten()
{
if value.chars().any(char::is_control) {
return Err(ThemeValidationError::InvalidEffectText);
}
}
Ok(())
}
}
const MAX_THEME_NAME_BYTES: usize = 128;
const MAX_FONT_FAMILY_BYTES: usize = 256;
/// Reasons a theme cannot safely be applied or exported.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ThemeValidationError {
EmptyName,
NameTooLong,
NameContainsControlCharacter,
InvalidFontFamily,
InvalidFontSize,
InvalidLineHeight,
InvalidLetterSpacing,
InvalidFontWeight,
InvalidUiFontFamily,
InvalidUiFontSize,
InvalidLayoutRadius,
InvalidLayoutGutter,
InvalidLayoutDivider,
InvalidLayoutPaddingX,
InvalidLayoutPaddingY,
InvalidOpacity,
InvalidBackgroundImageOpacity,
InvalidEffectText,
}
impl fmt::Display for ThemeValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = match self {
Self::EmptyName => "theme name is empty",
Self::NameTooLong => "theme name is too long",
Self::NameContainsControlCharacter => "theme name contains a control character",
Self::InvalidFontFamily => {
"font family is empty, too long, or contains a control character"
},
Self::InvalidFontSize => "font size must be finite and between 4 and 96",
Self::InvalidLineHeight => "line height must be finite and between 0.5 and 3",
Self::InvalidLetterSpacing => "letter spacing must be finite and between 0 and 8",
Self::InvalidFontWeight => "font weight must be between 100 and 900",
Self::InvalidUiFontFamily => {
"UI font family is empty, too long, or contains a control character"
},
Self::InvalidUiFontSize => "UI font size must be finite and between 10 and 20",
Self::InvalidLayoutRadius => "card radius is outside the shared pane range",
Self::InvalidLayoutGutter => "card gutter is outside the shared pane range",
Self::InvalidLayoutDivider => "card divider is outside the shared pane range",
Self::InvalidLayoutPaddingX => "horizontal layout padding is outside the theme range",
Self::InvalidLayoutPaddingY => "vertical layout padding is outside the theme range",
Self::InvalidOpacity => "opacity must be finite and between 0 and 1",
Self::InvalidBackgroundImageOpacity => {
"background image opacity must be finite and between 0 and 1"
},
Self::InvalidEffectText => "effect text contains a control character",
};
f.write_str(message)
}
}
impl std::error::Error for ThemeValidationError {}
fn fallback_ansi(palette: crate::ReviewedPalette) -> [Rgb8; 16] {
[
palette.shell,
palette.red,
palette.green,
palette.yellow,
palette.blue,
palette.purple,
palette.cyan,
palette.foreground,
palette.frame,
palette.red,
palette.green,
palette.yellow,
palette.blue,
palette.purple,
palette.cyan,
palette.foreground,
]
}
fn mix_rgb(left: Rgb8, right: Rgb8, weight: f32) -> Rgb8 {
[0, 1, 2].map(|index| {
(f32::from(left[index]) * (1.0 - weight) + f32::from(right[index]) * weight).round() as u8
})
}
/// Return the original foreground plus cool and warm readable suggestions.
///
/// The first entry is always `original`, even when it is low contrast. The
/// second and third entries are checked against the supplied opaque background
/// with WCAG 2 AA's 4.5:1 normal-text threshold. This is a default foreground
/// and background reference; composited or translucent surfaces still require
/// the caller to verify the actual rendered result, and arbitrary user colors
/// are never silently rewritten.
pub fn foreground_recommendations(background: Rgb8, original: Rgb8) -> [Rgb8; 3] {
[
original,
readable_tint(background, [100, 178, 224]),
readable_tint(background, [235, 164, 76]),
]
}
/// WCAG relative contrast ratio for two opaque sRGB colors.
pub fn wcag_contrast_ratio(foreground: Rgb8, background: Rgb8) -> f64 {
let a = relative_luminance(foreground);
let b = relative_luminance(background);
(a.max(b) + 0.05) / (a.min(b) + 0.05)
}
/// Whether an opaque foreground/background pair reaches WCAG AA normal text.
pub fn meets_wcag_aa(foreground: Rgb8, background: Rgb8) -> bool {
wcag_contrast_ratio(foreground, background) >= 4.5
}
fn readable_tint(background: Rgb8, tint: Rgb8) -> Rgb8 {
let white = [255; 3];
let black = [0; 3];
let endpoint =
if wcag_contrast_ratio(white, background) >= wcag_contrast_ratio(black, background) {
white
} else {
black
};
if meets_wcag_aa(tint, background) {
return tint;
}
// Move toward the better contrast endpoint while retaining as much of the
// requested cool/warm hue as the contrast floor permits.
for step in 1..=255u16 {
let candidate = blend_rgb(tint, endpoint, step as u8);
if meets_wcag_aa(candidate, background) {
return candidate;
}
}
endpoint
}
fn blend_rgb(from: Rgb8, to: Rgb8, amount: u8) -> Rgb8 {
let amount = u16::from(amount);
let mut result = [0; 3];
for index in 0..3 {
let from = u16::from(from[index]);
let to = u16::from(to[index]);
result[index] = ((from * (255 - amount) + to * amount + 127) / 255) as u8;
}
result
}
fn relative_luminance(color: Rgb8) -> f64 {
let linear = |channel: u8| {
let channel = f64::from(channel) / 255.0;
if channel <= 0.04045 { channel / 12.92 } else { ((channel + 0.055) / 1.055).powf(2.4) }
};
linear(color[0]) * 0.2126 + linear(color[1]) * 0.7152 + linear(color[2]) * 0.0722
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_snapshot_keeps_declared_exact_colors() {
let nord = ThemeDefinition::from_builtin(ThemeName::Nord);
let exact = ThemeName::Nord.term_theme().exact.unwrap();
assert_eq!(nord.appearance, ThemeAppearance::Dark);
assert_eq!(nord.terminal.foreground, exact.foreground);
assert_eq!(nord.terminal.cursor, exact.cursor);
assert_eq!(nord.terminal.selection_background, exact.selection_background);
assert_eq!(nord.terminal.palette.ansi_colors(), exact.ansi);
assert_eq!(nord.terminal.palette.colors[16], ThemeName::Nord.term_theme().powerline[0]);
assert_eq!(nord.ui.selection[3], 255);
assert_eq!(nord.ui.line[3], 255);
for name in ThemeName::BUILTIN {
let Some(exact) = name.term_theme().exact else { continue };
let snapshot = ThemeDefinition::from_builtin(name);
assert_eq!(snapshot.terminal.foreground, exact.foreground, "{}", name.prompt_name());
assert_eq!(
snapshot.terminal.palette.ansi_colors(),
exact.ansi,
"{}",
name.prompt_name()
);
assert_eq!(snapshot.terminal.cursor, exact.cursor, "{}", name.prompt_name());
assert_eq!(
snapshot.terminal.selection_background,
exact.selection_background,
"{}",
name.prompt_name()
);
}
}
#[test]
fn ui_derivation_and_explicit_alpha_are_available_to_adapters() {
let mut theme = ThemeDefinition::from_builtin(ThemeName::Paper);
assert_eq!(theme.appearance, ThemeAppearance::Light);
theme.ui.derive = true;
assert!(theme.resolved_ui().derive);
assert_eq!(theme.resolved_ui().selection[3], 255);
let palette = ThemeName::Nord.reviewed_palette();
assert_eq!(ThemeUiColors::from_palette(palette).selection, palette.selected);
assert_eq!(ThemeUiColors::from_palette(palette).line, palette.line);
}
#[test]
fn indexed_palette_is_complete_and_ansi_is_mirrored() {
let theme = ThemeDefinition::from_builtin(ThemeName::CatppuccinMocha);
let ansi = theme.terminal.palette.ansi_colors();
assert_eq!(&ansi[..], &theme.terminal.palette.colors[..16]);
assert!(theme.terminal.palette.get(255).is_some());
assert_eq!(theme.terminal.palette.colors[232], [8; 3]);
assert_eq!(theme.terminal.palette.colors[255], [238; 3]);
}
#[test]
fn edits_clone_without_mutating_the_source() {
let source = ThemeDefinition::from_builtin(ThemeName::Nord);
let edited = source.with_terminal_foreground([1, 2, 3]);
assert_eq!(source.terminal.foreground, [0xf1, 0xf6, 0xff]);
assert_eq!(edited.terminal.foreground, [1, 2, 3]);
let edited = source.with_ansi(0, [9, 8, 7]).unwrap();
assert_eq!(source.terminal.palette.colors[0], [0x3b, 0x42, 0x52]);
assert_eq!(edited.terminal.palette.colors[0], [9, 8, 7]);
assert!(source.with_ansi(16, [0, 0, 0]).is_none());
assert!(source.with_indexed(256, [0, 0, 0]).is_none());
let mut palette = source.terminal.palette.clone();
assert!(!palette.set(256, [0, 0, 0]));
}
#[test]
fn validate_rejects_bad_scalars() {
let mut theme = ThemeDefinition::from_builtin(ThemeName::Nord);
theme.effects.opacity = Some(f32::NAN);
assert_eq!(theme.validate(), Err(ThemeValidationError::InvalidOpacity));
theme.effects.opacity = None;
theme.typography.font_size = Some(2.0);
assert_eq!(theme.validate(), Err(ThemeValidationError::InvalidFontSize));
}
#[test]
fn recommendations_keep_original_and_meet_wcag_for_solid_backgrounds() {
for background in [[0, 0, 0], [15, 17, 26], [255, 255, 255], [127, 127, 127]] {
let recommendations = foreground_recommendations(background, [1, 2, 3]);
assert_eq!(recommendations[0], [1, 2, 3]);
assert!(meets_wcag_aa(recommendations[1], background));
assert!(meets_wcag_aa(recommendations[2], background));
}
}
}
+45
View File
@@ -17,6 +17,12 @@ use std::collections::HashMap;
mod app_icon;
pub use app_icon::{AppIconName, AppIconPalette};
mod custom_theme;
pub use custom_theme::{
IndexedPalette, TerminalThemeColors, ThemeAppearance, ThemeDefinition, ThemeEffects,
ThemeLayout, ThemeTypography, ThemeUiColors, ThemeValidationError, foreground_recommendations,
meets_wcag_aa, wcag_contrast_ratio,
};
mod language;
mod quick_terminal;
mod themes;
@@ -180,6 +186,8 @@ pub fn apply_updates(text: &str, updates: &[(&str, String)]) -> String {
}
pub type Rgb8 = [u8; 3];
/// RGBA color used where the reviewed UI palette carries an explicit alpha.
pub type Rgba8 = [u8; 4];
/// 主题标识;`nebula_settings.txt` 里 `theme=` 持久化 [`Self::prompt_name`]。
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
@@ -934,6 +942,7 @@ impl ProxyModeName {
/// 新 UI 消费的运行时设置。字段与出厂默认逐项对照旧壳
/// `nebula_settings_load`;`Option` 字段的 `None` = 键未设置,调用方自选
/// 回退(如 font_family 回落 nebula.toml)。
#[derive(Clone)]
pub struct RuntimeSettings {
pub language: LanguagePref,
pub theme: ThemeName,
@@ -994,8 +1003,20 @@ pub struct RuntimeSettings {
pub tray: bool,
pub blur: BlurModeName,
pub opacity: f32,
/// Whether the corresponding material value was explicitly present in
/// `nebula_settings.txt`. Theme defaults may fill an absent value, while
/// an explicit user value (including `none` or `1.0`) remains authoritative.
opacity_explicit: bool,
blur_explicit: bool,
/// 终端背景覆盖色(设置页取色器写入,优先于主题背景)。
pub background: Option<Rgb8>,
/// 终端主题默认文字色覆盖。与 `background` 分开持久化,切换主题时
/// 可由调用方清除以恢复主题内置前景色。
pub theme_foreground: Option<Rgb8>,
/// Serialized custom theme identifier or payload. The settings layer only
/// preserves this value; file loading and format decoding belong to the
/// application adapter.
pub custom_theme: Option<String>,
/// 壁纸路径(空 = 无壁纸)。fit/alignment 存原文,解析归渲染层
/// (旧壳 `renderer::image` 的 parse 是权威记号表)。
pub background_image: Option<String>,
@@ -1070,8 +1091,10 @@ impl RuntimeSettings {
pub fn from_raw(raw: &RawSettings) -> Self {
let blur = raw.value("blur").and_then(BlurModeName::from_settings).unwrap_or_default();
let blur_explicit = raw.value("blur").and_then(BlurModeName::from_settings).is_some();
// Theme colors are opaque by default. Material selection never lowers opacity.
let opacity = raw.f32("opacity").unwrap_or(1.0).clamp(0.0, 1.0);
let opacity_explicit = raw.f32("opacity").is_some();
Self {
language: raw
@@ -1139,7 +1162,11 @@ impl RuntimeSettings {
tray: raw.bool_on("tray").unwrap_or(true),
blur,
opacity,
opacity_explicit,
blur_explicit,
background: raw.value("background").and_then(parse_hex_rgb),
theme_foreground: raw.value("theme_foreground").and_then(parse_hex_rgb),
custom_theme: raw.value("custom_theme").map(str::to_owned),
background_image: raw
.value("background_image")
.map(str::trim)
@@ -1194,6 +1221,18 @@ impl RuntimeSettings {
.map(|d| d.clamp(0.0, MAX_PANE_CARD_DIVIDER)),
}
}
/// True when the user supplied a valid opacity value, including the
/// explicit default `1.0`.
pub fn opacity_is_explicit(&self) -> bool {
self.opacity_explicit
}
/// True when the user supplied a valid blur/material value, including the
/// explicit `none` choice.
pub fn blur_is_explicit(&self) -> bool {
self.blur_explicit
}
}
/// 解析 `#rrggbb`(旧壳 `parse_hex_rgb` 同款:# 前缀可省)。
@@ -1361,6 +1400,8 @@ mod tests {
blur=0\n\
opacity=0.87\n\
background=#101216\n\
theme_foreground=#d6dae6\n\
custom_theme=my-night\n\
panel_resize=1\n\
sidebar_w=222\n\
ssh_proxy_mode=custom\n\
@@ -1398,6 +1439,8 @@ mod tests {
assert_eq!(settings.blur, BlurModeName::None);
assert!((settings.opacity - 0.87).abs() < 1e-6);
assert_eq!(settings.background, Some([0x10, 0x12, 0x16]));
assert_eq!(settings.theme_foreground, Some([0xd6, 0xda, 0xe6]));
assert_eq!(settings.custom_theme.as_deref(), Some("my-night"));
assert!(settings.panel_resize);
assert_eq!(settings.sidebar_width, 222.0);
assert_eq!(settings.ssh_proxy_mode, ProxyModeName::Custom);
@@ -1438,6 +1481,8 @@ mod tests {
assert_eq!(settings.blur, BlurModeName::None);
assert_eq!(settings.opacity, 1.0);
assert_eq!(settings.background, None);
assert_eq!(settings.theme_foreground, None);
assert_eq!(settings.custom_theme, None);
assert!(!settings.panel_resize);
assert_eq!(settings.sidebar_width, DEFAULT_SIDEBAR_WIDTH);
assert_eq!(settings.ssh_proxy_mode, ProxyModeName::Off);
+10 -1
View File
@@ -45,6 +45,8 @@ const RESET_KEYS: &[&str] = &[
"blur",
"opacity",
"background",
"theme_foreground",
"custom_theme",
"background_image",
"background_image_opacity",
"background_image_fit",
@@ -139,7 +141,7 @@ mod tests {
#[test]
fn reset_removes_all_overrides_and_keeps_user_data() {
let text = "# preferences\r\n THEME = Nord\r\ncopy_on_select=1\nkeybind=ctrl+x:Copy\nFONT_SIZE=30\nexecutor=custom\nblur=acrylic\nopacity=0.65\nssh_hosts=saved-host\nai_provider=custom\nfuture_setting=keep\n";
let text = "# preferences\r\n THEME = Nord\r\ncopy_on_select=1\nkeybind=ctrl+x:Copy\nFONT_SIZE=30\nexecutor=custom\nblur=acrylic\nopacity=0.65\nbackground=#101216\ntheme_foreground=#d6dae6\ncustom_theme=my-night\nssh_hosts=saved-host\nai_provider=custom\nfuture_setting=keep\n";
let result = default_settings_text(text);
assert_eq!(
result,
@@ -162,6 +164,13 @@ mod tests {
assert_eq!(default_settings_text(text), "");
}
#[test]
fn reset_removes_theme_overrides_but_keeps_unknown_theme_data() {
let text =
"theme_foreground=#d6dae6\ncustom_theme=my-night\ncustom_theme_file=theme.json\n";
assert_eq!(default_settings_text(text), "custom_theme_file=theme.json\n");
}
#[test]
fn reset_covers_every_runtime_settings_key() {
let source = include_str!("lib.rs");