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:
1 parent
96d4cf0179
commit
6275b6435a
46 files changed
+13603
-334
No files matched your search
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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": "背景图超出内存限制,请选择尺寸更小的图片。"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(¤t_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(¤t_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(¤t, 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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user