Merge PR #208 and preserve explicit extended-color overrides across themes

This commit is contained in:
kuddev committed 2026-09-20 15:07:36 +08:00
commit c23e74620f
6 files changed
+138 -58

No files matched your search

+5 -6
View File
@@ -142,11 +142,7 @@ pub(crate) fn is_fixed_color(color: Color, overrides: &Colors) -> bool {
match color {
Color::Spec(_) => true,
Color::Named(name) => overrides[name].is_some(),
Color::Indexed(index) => {
overrides[index as usize].is_some()
// 16..=23 是 Nebula 随主题重建的 powerline 动态槽位。
|| index >= 24
},
Color::Indexed(index) => overrides[index as usize].is_some() || index >= 16,
}
}
@@ -328,7 +324,10 @@ mod tests {
let mut overrides = Colors::default();
assert!(is_fixed_color(Color::Spec(VteRgb { r: 1, g: 2, b: 3 }), &overrides));
assert!(is_fixed_color(Color::Indexed(24), &overrides));
assert!(!is_fixed_color(Color::Indexed(16), &overrides));
for index in 16..=255 {
assert!(is_fixed_color(Color::Indexed(index), &overrides));
}
assert!(!is_fixed_color(Color::Indexed(15), &overrides));
assert!(!is_fixed_color(Color::Named(NamedColor::Red), &overrides));
overrides[NamedColor::Red] = Some(VteRgb { r: 1, g: 2, b: 3 });
+1 -16
View File
@@ -22,12 +22,6 @@ use crate::display::color::{List, Rgb};
use crate::renderer::ui::Rgba;
use nebula_terminal::vte::ansi::NamedColor;
/// First 256-color palette slot claimed for the powerline prompt chips
/// (16..=23: icon bg/fg, path bg/fg, branch bg/fg, time bg/fg). Chosen at the
/// very start of the 6×6×6 cube — the darkest corner, rarely load-bearing for
/// TUIs — so hijacking eight slots stays invisible in practice.
pub(crate) const POWERLINE_SLOT0: usize = 16;
/// Built-in chrome themes exposed from the settings panel. The original seven
/// Nebula looks remain unchanged; Nord/Paper are an additive dark/light pair
/// carrying their own complete palettes.
@@ -292,14 +286,6 @@ impl NebulaTheme {
*colors = *defaults;
let p = self.palette();
colors[NamedColor::Background] = p.term_bg;
// Powerline prompt slots: the injected prompt paints its segment chips
// with indexed colors 16..=23 instead of baked-in truecolor, so a
// theme switch remaps the palette and every chip ALREADY PRINTED in
// scrollback recolors instantly — indexed cells resolve the palette at
// draw time; truecolor is frozen the moment it is printed.
for (i, rgb) in self.powerline_colors().into_iter().enumerate() {
colors[POWERLINE_SLOT0 + i] = rgb;
}
if let Some(exact) = self.exact_term_colors() {
let foreground = rgb8(exact.foreground);
colors[NamedColor::Foreground] = foreground;
@@ -376,8 +362,7 @@ impl NebulaTheme {
}
}
/// Segment colors for the injected powerline prompt, published into the
/// 256-color palette at [`POWERLINE_SLOT0`]`..+8` by [`Self::apply_term_colors`].
/// 旧提示符段色参考;不发布到终端扩展色表,避免覆盖应用的索引色。
/// Order: icon bg/fg, path bg/fg, branch bg/fg, time bg/fg — one flat color
/// per chip (the old per-character truecolor gradient could never follow a
/// theme switch retroactively, which users read as "the prompt is stuck").
+57 -9
View File
@@ -368,7 +368,7 @@ fn runtime_background(
}
/// 主题叠加在 toml 配色之上(镜像旧壳 `apply_term_colors` 的范围与次序):
/// 背景永远替换;Powerline 槽位 16..=23 替换。原有主题保持旧合同:仅浅色
/// 背景永远替换;保留应用使用的扩展色槽。原有主题保持旧合同:仅浅色
/// 主题替换前景与 ANSI-16;自带完整 palette 的主题(Nord/Paper)应用其明确色表。
fn apply_theme(palette: &mut Palette, theme: nebula_settings::ThemeName) {
let resolved = crate::gpui_shell::theme::ResolvedTheme::builtin(theme, None);
@@ -405,9 +405,6 @@ fn apply_resolved_theme(palette: &mut Palette, resolved: &crate::gpui_shell::the
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(resolved.foreground_override().unwrap_or(exact.foreground));
palette.foreground = foreground;
@@ -447,11 +444,6 @@ fn apply_resolved_theme(palette: &mut Palette, resolved: &crate::gpui_shell::the
}
}
fn set_indexed(palette: &mut Palette, index: u8, color: gpui::Rgba) {
palette.indexed.retain(|(existing, _)| *existing != index);
palette.indexed.push((index, color));
}
fn rgba8(color: nebula_settings::Rgb8) -> gpui::Rgba {
gpui::Rgba {
r: color[0] as f32 / 255.0,
@@ -817,6 +809,62 @@ mod tests {
assert!(!settings.term_config().default_cursor_style.blinking);
}
#[test]
fn builtin_themes_preserve_application_extended_colors() {
use nebula_terminal::term::color::Colors;
use nebula_terminal::vte::ansi::{Color, Rgb};
for theme in ThemeName::BUILTIN {
let mut palette = Palette::default();
apply_theme(&mut palette, theme);
let overrides = Colors::default();
for (index, expected) in [
(16, [0, 0, 0]),
(17, [0, 0, 95]),
(18, [0, 0, 135]),
(19, [0, 0, 175]),
(20, [0, 0, 215]),
(21, [0, 0, 255]),
(22, [0, 95, 0]),
(23, [0, 95, 95]),
(231, [255, 255, 255]),
(255, [238, 238, 238]),
] {
assert_eq!(
palette.resolve(Color::Indexed(index), &overrides, false),
rgba8(expected),
"{} index {index}",
theme.prompt_name()
);
assert_eq!(
palette.query_reply(index as usize, &overrides),
Rgb { r: expected[0], g: expected[1], b: expected[2] }
);
}
}
}
#[test]
fn theme_switches_preserve_user_extended_colors_and_osc_overrides() {
use nebula_terminal::term::color::Colors;
use nebula_terminal::vte::ansi::{Color, Rgb};
let configured = [12, 34, 56];
let osc = Rgb { r: 78, g: 90, b: 123 };
let mut palette = Palette::default();
palette.indexed.push((16, rgba8(configured)));
for theme in ThemeName::BUILTIN {
apply_theme(&mut palette, theme);
let mut overrides = Colors::default();
assert_eq!(palette.resolve(Color::Indexed(16), &overrides, false), rgba8(configured));
overrides[16] = Some(osc);
assert_eq!(palette.resolve(Color::Indexed(16), &overrides, false), rgba8([78, 90, 123]));
assert_eq!(palette.query_reply(16, &overrides), osc);
overrides[16] = None;
assert_eq!(palette.query_reply(16, &overrides), Rgb { r: 12, g: 34, b: 56 });
}
}
#[test]
fn existing_dark_theme_keeps_user_terminal_colors() {
let mut palette = Palette::default();
+29 -8
View File
@@ -60,13 +60,7 @@ impl ThemeDefinition {
}
});
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;
}
let indexed = IndexedPalette::from_ansi(exact.ansi);
Self {
name: base.prompt_name().to_owned(),
@@ -724,6 +718,33 @@ fn relative_luminance(color: Rgb8) -> f64 {
mod tests {
use super::*;
#[test]
fn builtin_snapshots_preserve_standard_extended_colors() {
// 提示符配色不能覆盖应用使用的 xterm 色立方。
for name in ThemeName::BUILTIN {
let snapshot = ThemeDefinition::from_builtin(name);
for (index, expected) in [
(16, [0, 0, 0]),
(17, [0, 0, 95]),
(18, [0, 0, 135]),
(19, [0, 0, 175]),
(20, [0, 0, 215]),
(21, [0, 0, 255]),
(22, [0, 95, 0]),
(23, [0, 95, 95]),
(231, [255, 255, 255]),
(255, [238, 238, 238]),
] {
assert_eq!(
snapshot.terminal.palette.colors[index],
expected,
"{} index {index}",
name.prompt_name()
);
}
}
}
#[test]
fn builtin_snapshot_keeps_declared_exact_colors() {
let nord = ThemeDefinition::from_builtin(ThemeName::Nord);
@@ -733,7 +754,7 @@ mod tests {
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.terminal.palette.colors[16], [0, 0, 0]);
assert_eq!(nord.ui.selection[3], 255);
assert_eq!(nord.ui.line[3], 255);
+1 -4
View File
@@ -517,13 +517,10 @@ pub struct TermTheme {
/// 只有自带完整终端 palette 的主题使用;现有七个主题保持 `None`,继续
/// 走原来的背景/浅色替换合同。
pub exact: Option<ExactTermColors>,
/// Powerline 提示符段色,发布到 256 色表 [`POWERLINE_SLOT0`]`..+8`。
/// 旧提示符段色参考;不再发布到终端扩展色表。
pub powerline: [Rgb8; 8],
}
/// Powerline 槽位起点(索引色 16..=23)。
pub const POWERLINE_SLOT0: u8 = 16;
/// 浅色主题的替换前景(#24292f)。
pub const LIGHT_FOREGROUND: Rgb8 = [36, 41, 47];
+45 -15
View File
@@ -496,11 +496,8 @@ function global:prompt {
if ($global:NebulaPromptCount -gt 1) { $leadingNewline = "`n" }
}
# Segment colors come from the terminal's 256-color palette, slots
# 16..=23 (icon bg/fg, path bg/fg, branch bg/fg, time bg/fg), published
# per-theme by Nebula (theme.rs::apply_term_colors). Indexed colors mean a
# theme switch recolors every prompt already in scrollback — truecolor
# (the old scheme) is frozen the moment it prints. No theme file, no polling.
# 使用主题的 ANSI-16 索引,历史提示符仍随主题换色;不占用应用所需的
# xterm 扩展色槽。路径和时间用默认前景/背景,避免浅色主题的 ANSI 灰阶低对比。
if ($userPrompt) {
# 视觉全部来自用户提示符;Nebula 只补协议:133;A 标出提示符起点,标题
@@ -508,25 +505,25 @@ function global:prompt {
$output = "$e]133;A$([char]7)$e]2;NEBULA|$cwd|$branch$([char]7)$userPrompt"
} elseif (-not (Get-NebulaBoolSetting 'powerline' $true)) {
$branchText = if ($branch) { " ($branch)" } else { "" }
$output = "$leadingNewline$e]133;A$([char]7)$e]2;NEBULA|$cwd|$branch$([char]7)$e[38;5;19m$loc$branchText $e[35m$NebPromptArrow $reset"
$output = "$leadingNewline$e]133;A$([char]7)$e]2;NEBULA|$cwd|$branch$([char]7)$e[38;5;6m$loc$branchText $e[35m$NebPromptArrow $reset"
} else {
$segs = New-Object System.Collections.ArrayList
[void]$segs.Add(@{ bg=16; fg=17; t=" $NebFolderIcon " })
[void]$segs.Add(@{ bg=18; fg=19; t=" $loc " })
if ($branch) { [void]$segs.Add(@{ bg=20; fg=21; t=" $NebGitBranchIcon $branch " }) }
[void]$segs.Add(@{ bg=22; fg=23; t=" $NebClockIcon $time " })
[void]$segs.Add(@{ bg=4; fg='38;5;0'; t=" $NebFolderIcon " })
[void]$segs.Add(@{ bg=$null; fg='39'; t=" $loc " })
if ($branch) { [void]$segs.Add(@{ bg=$null; fg='38;5;6'; t=" $NebGitBranchIcon $branch " }) }
[void]$segs.Add(@{ bg=$null; fg='39'; t=" $NebClockIcon $time " })
# 49 = default background on both caps: the cap cell's square corners
# always match the real terminal bg (any theme / wallpaper).
$out = "$reset$e[38;5;$($segs[0].bg)m$e[49m$NebLeftRound$reset"
for ($i = 0; $i -lt $segs.Count; $i++) {
$s = $segs[$i]
$out += "$e[48;5;$($s.bg)m$e[38;5;$($s.fg)m$($s.t)"
if ($i -lt $segs.Count - 1) {
$nb = $segs[$i + 1].bg
$out += "$reset$e[38;5;$($s.bg)m$e[48;5;${nb}m$NebArrow$reset"
$bg = if ($null -eq $s.bg) { '49' } else { "48;5;$($s.bg)" }
$out += "$e[${bg}m$e[$($s.fg)m$($s.t)"
if ($null -ne $s.bg) {
$out += "$reset$e[38;5;$($s.bg)m$e[49m$NebArrow$reset"
} else {
$out += "$reset$e[38;5;$($s.bg)m$e[49m$NebRightRound$reset"
$out += $reset
}
}
$output = "$leadingNewline$e]133;A$([char]7)$e]2;NEBULA|$cwd|$branch$([char]7)$out`n`n$e[35m$NebPromptArrow $reset"
@@ -1357,6 +1354,39 @@ $env:NEBULA_CONFIG_DIR = $env:PEBREL_CONFIG_DIR
function global:Set-PSReadLineOption { }
"#;
#[test]
fn powershell_prompt_uses_only_theme_ansi_slots() {
run_powershell_integration_case(
PS_PRELUDE,
r#"
function global:git { $global:LASTEXITCODE = 0; 'test-branch' }
foreach ($powerline in @($true, $false)) {
$global:TestPowerline = $powerline
function global:Get-NebulaBoolSetting { param($key, $default); $global:TestPowerline }
$rendered = prompt
$colors = [regex]::Matches($rendered, '\x1b\[(?:38|48);5;(\d+)m')
if ($colors.Count -eq 0) { throw 'No indexed prompt colors' }
foreach ($color in $colors) {
if ([int]$color.Groups[1].Value -gt 15) {
throw "Prompt occupied extended color $($color.Groups[1].Value)"
}
}
if ($powerline) {
$e = [char]27
if (-not $rendered.Contains("$e[49m$e[39m $((Get-Location).Path) ")) {
throw 'Path must use theme default foreground and background'
}
if (-not $rendered.Contains("$e[49m$e[39m $([char]0xf017)")) {
throw 'Clock must use theme default foreground and background'
}
}
if ($rendered -notlike '*test-branch*') { throw 'Missing branch' }
if ($rendered -notlike "*$([char]27)]133;A*") { throw 'Missing prompt boundary' }
}
"#,
);
}
#[test]
fn powershell_integration_preserves_the_users_prediction_source() {
run_powershell_integration_case(