Colored underlines in set_color and fish_color_*

Add a new underline-color option to set_color (instead of adding an optional
color argument to --underline); this allows to set the underline color
independently of underline style (line, curly, etc.). I don't think this
flexibility is very important but this approach is probably the least hacky.

Note that there are two variants:
1. \e[58:5:1m
2. \e[58;5;1m

Variant 1 breaks:
breakage from colon-variant for colored underlines
- cool-retro-term makes text blink
- GNU screen (goes into bold mode)
- terminology (goes into bold mode)

Variant 2 would break:
- mintty (Cygwin terminal) -- it enables bold font instead.
- Windows Terminal (where it paints the foreground yellow)
- JetBrains terminals echo the colons instead of consuming them
- putty
- GNU screen (goes into bold mode)
- st
- urxvt
- xterm
- etc.

So choose variant 1.

Closes #11388
Closes #7619
This commit is contained in:
Johannes Altmanninger
2025-04-14 15:36:50 +02:00
parent cc9849c279
commit ce631fd2fb
12 changed files with 196 additions and 83 deletions

View File

@@ -66,6 +66,7 @@ Completions
Improved terminal support
^^^^^^^^^^^^^^^^^^^^^^^^^
- Support for curly underlines in `fish_color_*` variables and :doc:`set_color <cmds/set_color>` (:issue:`10957`).
- Underlines can now be colored independent of text (:issue:`7619`).
- New documentation page `Terminal Compatibility <terminal-compatibility.html>`_ (also accessible via ``man fish-terminal-compatibility``) lists required and optional terminal control sequences used by fish.
Other improvements

View File

@@ -37,6 +37,9 @@ The following options are available:
**-b** or **--background** *COLOR*
Sets the background color.
**--underline-color** *COLOR*
Set the underline color.
**-c** or **--print-colors**
Prints the given colors or a colored list of the 16 named colors.

View File

@@ -90,12 +90,15 @@ Syntax highlighting variables
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The colors used by fish for syntax highlighting can be configured by changing the values of various variables. The value of these variables can be one of the colors accepted by the :doc:`set_color <cmds/set_color>` command.
The modifier switches accepted by ``set_color`` like
Options accepted by ``set_color`` like
``--background=``,
``--bold``,
``--dim``,
``--italics``,
``--reverse`` and
``--underline`` are also accepted.
``--reverse``,
``--underline`` and
``--underline-color=``
are also accepted.
Example: to make errors highlighted and red, use::

View File

@@ -144,6 +144,10 @@ Optional Commands
- setab
- Select background color Ps from the 256-color-palette.
-
* - ``\e[58:5: Ps m`` (note: colons not semicolons)
- Su
- Select underline color Ps from the 256-color-palette.
- kitty
* - ``\e[ Ps m``
- setaf
setab
@@ -161,6 +165,14 @@ Optional Commands
-
- Select background color from 24-bit RGB colors.
-
* - ``\e[58:2:: Ps : Ps : Ps m`` (note: colons not semicolons)
- Su
- Select underline color from 24-bit RGB colors.
- kitty
* - ``\e[59m``
- Su
- Reset underline color (follow foreground color).
- kitty
* - ``\e[ Ps S``
- indn
- Scroll forward Ps lines.

View File

@@ -127,6 +127,7 @@ complete -c set -n '__fish_seen_argument -s e -l erase; and __fish_seen_argument
# Color completions
complete -c set -n '__fish_set_is_color true false' -x -a '(set_color --print-colors)' -d 'text color'
complete -c set -n '__fish_set_is_color false true' -a '--background=(set_color --print-colors)'
complete -c set -n '__fish_set_is_color false true' -a '--underline-color=(set_color --print-colors)'
complete -c set -n '__fish_set_is_color true false' -a --bold -x
complete -c set -n '__fish_set_is_color true false' -a --dim -x
complete -c set -n '__fish_set_is_color true false' -a --italics -x

View File

@@ -1,5 +1,6 @@
complete -c set_color -x -d Color -a '(set_color --print-colors)'
complete -c set_color -s b -l background -x -a '(set_color --print-colors)' -d "Change background color"
complete -c set_color -s b -l underline-color -x -a '(set_color --print-colors)' -d "Change underline color"
complete -c set_color -s o -l bold -d 'Make font bold'
complete -c set_color -s i -l italics -d Italicise
complete -c set_color -s d -l dim -d 'Dim text'

View File

@@ -227,6 +227,7 @@ def parse_color(color_str):
comps = color_str.split(" ")
color = ""
background_color = ""
underline_color = ""
bold = False
underline = False
italics = False
@@ -251,37 +252,41 @@ def parse_color(color_str):
dim = True
elif comp == "--reverse" or comp == "-r":
reverse = True
elif comp.startswith("--background"):
# Background color
c = comp[len("--background=") :]
parsed_c = parse_one_color(c)
# We prefer the unparsed version - if it says "brgreen", we use brgreen,
# instead of 00ff00
if better_color(background_color, parsed_c) == parsed_c:
background_color = c
elif comp.startswith("-b"):
# Background color in short.
if comp == "-b":
if i + 1 == len(comps):
c = ""
else:
c = comps[i + 1]
i += 1
else:
c = comp[len("-b"):]
parsed_c = parse_one_color(c)
if better_color(background_color, parsed_c) == parsed_c:
background_color = c
else:
# Regular color
parsed_c = parse_one_color(comp)
if better_color(color, parsed_c) == parsed_c:
color = comp
def parse_opt(current_best: str, i: int, long_opt: str, short_opt: str | None) -> str:
if comp.startswith(long_opt):
c = comp[len(long_opt) :]
parsed_c = parse_one_color(c)
# We prefer the unparsed version - if it says "brgreen", we use brgreen,
# instead of 00ff00
if better_color(current_best, parsed_c) == parsed_c:
return True, c, i
elif short_opt is not None and comp.startswith(short_opt):
if comp == short_opt:
if i + 1 == len(comps):
c = ""
else:
c = comps[i + 1]
i += 1
else:
c = comp[len(short_opt):]
parsed_c = parse_one_color(c)
if better_color(current_best, parsed_c) == parsed_c:
return True, c, i
return False, current_best, i
is_bg, background_color, i = parse_opt(background_color, i, "--background", "-b")
is_ul, underline_color, i = parse_opt(underline_color, i, "--underline-color", None)
if not (is_bg or is_ul):
# Regular color
parsed_c = parse_one_color(comp)
if better_color(color, parsed_c) == parsed_c:
color = comp
i += 1
return {
"color": color,
"background": background_color,
"underline-color": underline_color,
"bold": bold,
"underline": underline,
"italics": italics,
@@ -309,6 +314,8 @@ def unparse_color(col):
ret += " --reverse"
if col["background"]:
ret += " --background=" + col["background"]
if col["underline-color"]:
ret += " --underline-color=" + col["underline-color"]
return ret

View File

@@ -3,13 +3,20 @@
use super::prelude::*;
use crate::color::Color;
use crate::common::str2wcstring;
use crate::terminal::{best_color, get_color_support, Outputter};
use crate::terminal::TerminalCommand::DefaultUnderlineColor;
use crate::terminal::{best_color, get_color_support, Output, Outputter, Paintable};
use crate::text_face::{
parse_text_face_and_options, TextFace, TextFaceArgsAndOptions, TextFaceArgsAndOptionsResult,
TextStyling,
};
fn print_colors(streams: &mut IoStreams, args: &[&wstr], style: TextStyling, bg: Option<Color>) {
fn print_colors(
streams: &mut IoStreams,
args: &[&wstr],
style: TextStyling,
bg: Option<Color>,
underline_color: Option<Color>,
) {
let outp = &mut Outputter::new_buffering();
// Rebind args to named_colors if there are no args.
@@ -24,7 +31,12 @@ fn print_colors(streams: &mut IoStreams, args: &[&wstr], style: TextStyling, bg:
for color_name in args {
if streams.out_is_terminal() {
let fg = Color::from_wstr(color_name).unwrap_or(Color::None);
outp.set_text_face(TextFace::new(fg, bg.unwrap_or(Color::None), style));
outp.set_text_face(TextFace::new(
fg,
bg.unwrap_or(Color::None),
underline_color.unwrap_or(Color::None),
style,
));
}
outp.write_wstr(color_name);
if streams.out_is_terminal() && bg.is_some() {
@@ -53,6 +65,7 @@ pub fn set_color(parser: &Parser, streams: &mut IoStreams, argv: &mut [&wstr]) -
let TextFaceArgsAndOptions {
wopt_index,
bgcolor,
underline_color,
style,
print_color_mode,
} = match parse_text_face_and_options(argv, /*is_builtin=*/ true) {
@@ -102,9 +115,14 @@ pub fn set_color(parser: &Parser, streams: &mut IoStreams, argv: &mut [&wstr]) -
None => None,
};
let underline_color = match underline_color {
Some(s) => Some(parse_color(s)?),
None => None,
};
if print_color_mode {
let args = &argv[wopt_index..argc];
print_colors(streams, args, style, bg);
print_colors(streams, args, style, bg, underline_color);
return Ok(SUCCESS);
}
@@ -139,9 +157,9 @@ pub fn set_color(parser: &Parser, streams: &mut IoStreams, argv: &mut [&wstr]) -
// - if fg and bg are equal, it makes one of them white
// - if bg is not normal, it makes the foreground bold
// The first one seems fine but the second one not really.
outp.set_text_face(TextFace::new(Color::None, Color::None, style));
outp.set_text_face(TextFace::new(Color::None, Color::None, Color::None, style));
if let Some(fg) = fg {
if !outp.write_color(fg, true /* is_fg */) {
if !outp.write_color(Paintable::Foreground, fg) {
// We need to do *something* or the lack of any output messes up
// when the cartesian product here would make "foo" disappear:
// $ echo (set_color foo)bar
@@ -149,7 +167,14 @@ pub fn set_color(parser: &Parser, streams: &mut IoStreams, argv: &mut [&wstr]) -
}
}
if let Some(bg) = bg {
outp.write_color(bg, false /* is_fg */);
outp.write_color(Paintable::Background, bg);
}
if let Some(underline_color) = underline_color {
if underline_color.is_normal() {
outp.write_command(DefaultUnderlineColor);
} else {
outp.write_color(Paintable::Underline, underline_color);
}
}
}

View File

@@ -183,10 +183,17 @@ pub(crate) fn resolve_spec_uncached(
/// Return the internal color code representing the specified color.
pub(crate) fn parse_text_face_for_highlight(var: &EnvVar) -> TextFace {
let face = parse_text_face(var.as_list());
let fg = face.fg.unwrap_or(Color::Normal);
let bg = face.bg.unwrap_or(Color::Normal);
let default = TextFace::default();
let fg = face.fg.unwrap_or(default.fg);
let bg = face.bg.unwrap_or(default.bg);
let underline_color = face.underline_color.unwrap_or(default.underline_color);
let style = face.style;
TextFace { fg, bg, style }
TextFace {
fg,
bg,
underline_color,
style,
}
}
fn command_is_valid(

View File

@@ -41,6 +41,13 @@ pub fn set_color_support(val: ColorSupport) {
COLOR_SUPPORT.store(val.bits(), Ordering::Relaxed);
}
#[derive(Clone, Copy)]
pub(crate) enum Paintable {
Foreground,
Background,
Underline,
}
#[derive(Clone)]
pub(crate) enum TerminalCommand<'a> {
// Text attributes
@@ -61,8 +68,9 @@ pub(crate) enum TerminalCommand<'a> {
ClearToEndOfScreen,
// Colors
SelectPaletteColor(/*is_foreground=*/ bool, u8),
SelectRgbColor(/*is_foreground=*/ bool, Color24),
SelectPaletteColor(Paintable, u8),
SelectRgbColor(Paintable, Color24),
DefaultUnderlineColor,
// Cursor Movement
CursorUp,
@@ -153,8 +161,9 @@ fn write(out: &mut impl Output, sequence: &'static [u8]) -> bool {
ClearScreen => ti(self, b"\x1b[H\x1b[2J", |term| &term.clear_screen),
ClearToEndOfLine => ti(self, b"\x1b[K", |term| &term.clr_eol),
ClearToEndOfScreen => ti(self, b"\x1b[J", |term| &term.clr_eos),
SelectPaletteColor(is_foreground, idx) => palette_color(self, is_foreground, idx),
SelectRgbColor(is_foreground, rgb) => rgb_color(self, is_foreground, rgb),
SelectPaletteColor(paintable, idx) => palette_color(self, paintable, idx),
SelectRgbColor(paintable, rgb) => rgb_color(self, paintable, rgb),
DefaultUnderlineColor => write(self, b"\x1b[59m"),
CursorUp => ti(self, b"\x1b[A", |term| &term.cursor_up),
CursorDown => ti(self, b"\n", |term| &term.cursor_down),
CursorLeft => ti(self, b"\x08", |term| &term.cursor_left),
@@ -232,20 +241,22 @@ pub(crate) fn use_terminfo() -> bool {
!future_feature_flags::test(FeatureFlag::ignore_terminfo) && TERM.lock().unwrap().is_some()
}
fn palette_color(out: &mut impl Output, foreground: bool, mut idx: u8) -> bool {
fn palette_color(out: &mut impl Output, paintable: Paintable, mut idx: u8) -> bool {
if only_grayscale() && !(Color::Named { idx }).is_grayscale() {
return false;
}
if use_terminfo() {
let term = crate::terminal::term();
let Some(command) = (if foreground {
term.set_a_foreground
let Some(command) = (match paintable {
Paintable::Foreground => term
.set_a_foreground
.as_ref()
.or(term.set_foreground.as_ref())
} else {
term.set_a_background
.or(term.set_foreground.as_ref()),
Paintable::Background => term
.set_a_background
.as_ref()
.or(term.set_background.as_ref())
.or(term.set_background.as_ref()),
Paintable::Underline => None,
}) else {
return false;
};
@@ -260,7 +271,14 @@ fn palette_color(out: &mut impl Output, foreground: bool, mut idx: u8) -> bool {
idx -= 8;
}
}
let bg = if foreground { 0 } else { 10 };
let bg = match paintable {
Paintable::Foreground => 0,
Paintable::Background => 10,
Paintable::Underline => {
write_to_output!(out, "\x1b[58:5:{}m", idx);
return true;
}
};
match idx {
0..=7 => write_to_output!(out, "\x1b[{}m", 30 + bg + idx),
8..=15 => write_to_output!(out, "\x1b[{}m", 90 + bg + (idx - 8)),
@@ -279,17 +297,19 @@ fn term_supports_color_natively(term: &Term, c: u8) -> bool {
}
}
fn rgb_color(out: &mut impl Output, foreground: bool, rgb: Color24) -> bool {
fn rgb_color(out: &mut impl Output, paintable: Paintable, rgb: Color24) -> bool {
// Foreground: ^[38;2;<r>;<g>;<b>m
// Background: ^[48;2;<r>;<g>;<b>m
write_to_output!(
out,
"\x1b[{};2;{};{};{}m",
if foreground { 38 } else { 48 },
rgb.r,
rgb.g,
rgb.b
);
// Underline: ^[58:2::<r>:<g>:<b>m
let code = match paintable {
Paintable::Foreground => 38,
Paintable::Background => 48,
Paintable::Underline => {
write_to_output!(out, "\x1b[58:2::{}:{}:{}m", rgb.r, rgb.g, rgb.b);
return true;
}
};
write_to_output!(out, "\x1b[{code};2;{};{};{}m", rgb.r, rgb.g, rgb.b);
true
}
@@ -398,14 +418,6 @@ fn index_for_color(c: Color) -> u8 {
c.to_term256_index()
}
fn write_foreground_color(outp: &mut Outputter, idx: u8) -> bool {
outp.write_command(TerminalCommand::SelectPaletteColor(true, idx))
}
fn write_background_color(outp: &mut Outputter, idx: u8) -> bool {
outp.write_command(TerminalCommand::SelectPaletteColor(false, idx))
}
pub struct Outputter {
/// Storage for buffered contents.
contents: Vec<u8>,
@@ -447,16 +459,12 @@ fn maybe_flush(&mut self) {
/// Unconditionally write the color string to the output.
/// Exported for builtin_set_color's usage only.
pub fn write_color(&mut self, color: Color, is_fg: bool) -> bool {
pub(crate) fn write_color(&mut self, paintable: Paintable, color: Color) -> bool {
let supports_term24bit = get_color_support().contains(ColorSupport::TERM_24BIT);
if !supports_term24bit || !color.is_rgb() {
// Indexed or non-24 bit color.
let idx = index_for_color(color);
if is_fg {
return write_foreground_color(self, idx);
} else {
return write_background_color(self, idx);
};
return self.write_command(TerminalCommand::SelectPaletteColor(paintable, idx));
}
if only_grayscale() && color.is_grayscale() {
@@ -464,7 +472,10 @@ pub fn write_color(&mut self, color: Color, is_fg: bool) -> bool {
}
// 24 bit!
self.write_command(TerminalCommand::SelectRgbColor(is_fg, color.to_color24()))
self.write_command(TerminalCommand::SelectRgbColor(
paintable,
color.to_color24(),
))
}
/// Unconditionally resets colors and text style.
@@ -494,14 +505,15 @@ pub(crate) fn reset_text_face(&mut self) {
pub(crate) fn set_text_face(&mut self, face: TextFace) {
let mut fg = face.fg;
let bg = face.bg;
let underline_color = face.underline_color;
let style = face.style;
let mut bg_set = false;
let mut last_bg_set = false;
use TerminalCommand::{
EnterBoldMode, EnterCurlyUnderlineMode, EnterDimMode, EnterItalicsMode,
EnterReverseMode, EnterStandoutMode, EnterUnderlineMode, ExitAttributeMode,
ExitItalicsMode, ExitUnderlineMode,
DefaultUnderlineColor, EnterBoldMode, EnterCurlyUnderlineMode, EnterDimMode,
EnterItalicsMode, EnterReverseMode, EnterStandoutMode, EnterUnderlineMode,
ExitAttributeMode, ExitItalicsMode, ExitUnderlineMode,
};
// Removes all styles that are individually resettable.
@@ -549,10 +561,11 @@ pub(crate) fn set_text_face(&mut self, face: TextFace) {
self.write_command(ExitAttributeMode);
self.last.bg = Color::Normal;
self.last.underline_color = Color::Normal;
self.last.style = TextStyling::default();
} else {
assert!(!fg.is_special());
self.write_color(fg, true /* foreground */);
self.write_color(Paintable::Foreground, fg);
}
self.last.fg = fg;
}
@@ -561,16 +574,28 @@ pub(crate) fn set_text_face(&mut self, face: TextFace) {
if bg.is_normal() {
self.write_command(ExitAttributeMode);
if !self.last.fg.is_normal() {
self.write_color(self.last.fg, true /* foreground */);
self.write_color(Paintable::Foreground, self.last.fg);
}
if !self.last.underline_color.is_normal() && !self.last.underline_color.is_none() {
self.write_color(Paintable::Underline, self.last.underline_color);
}
self.last.style = TextStyling::default();
} else {
assert!(!bg.is_special());
self.write_color(bg, false /* not foreground */);
self.write_color(Paintable::Background, bg);
}
self.last.bg = bg;
}
if !underline_color.is_none() && underline_color != self.last.underline_color {
if underline_color.is_normal() {
self.write_command(DefaultUnderlineColor);
} else {
self.write_color(Paintable::Underline, underline_color);
}
self.last.underline_color = underline_color;
}
// Lastly, we set bold, underline, italics, dim, and reverse modes correctly.
if style.is_bold()
&& !self.last.style.is_bold()

View File

@@ -107,6 +107,7 @@ pub const fn is_reverse(self) -> bool {
pub(crate) struct TextFace {
pub(crate) fg: Color,
pub(crate) bg: Color,
pub(crate) underline_color: Color,
pub(crate) style: TextStyling,
}
@@ -115,18 +116,25 @@ pub const fn default() -> Self {
Self {
fg: Color::Normal,
bg: Color::Normal,
underline_color: Color::None,
style: TextStyling::default(),
}
}
pub fn new(fg: Color, bg: Color, style: TextStyling) -> Self {
Self { fg, bg, style }
pub fn new(fg: Color, bg: Color, underline_color: Color, style: TextStyling) -> Self {
Self {
fg,
bg,
underline_color,
style,
}
}
}
pub(crate) struct SpecifiedTextFace {
pub(crate) fg: Option<Color>,
pub(crate) bg: Option<Color>,
pub(crate) underline_color: Option<Color>,
pub(crate) style: TextStyling,
}
@@ -138,6 +146,7 @@ pub(crate) fn parse_text_face(arguments: &[WString]) -> SpecifiedTextFace {
let TextFaceArgsAndOptions {
wopt_index,
bgcolor,
underline_color,
style,
print_color_mode,
} = match parse_text_face_and_options(&mut argv, /*is_builtin=*/ false) {
@@ -155,14 +164,21 @@ pub(crate) fn parse_text_face(arguments: &[WString]) -> SpecifiedTextFace {
get_color_support(),
);
let bg = bgcolor.and_then(Color::from_wstr);
let underline_color = underline_color.and_then(Color::from_wstr);
assert!(fg.map_or(true, |fg| !fg.is_none()));
assert!(bg.map_or(true, |bg| !bg.is_none()));
SpecifiedTextFace { fg, bg, style }
SpecifiedTextFace {
fg,
bg,
underline_color,
style,
}
}
pub(crate) struct TextFaceArgsAndOptions<'a> {
pub(crate) wopt_index: usize,
pub(crate) bgcolor: Option<&'a wstr>,
pub(crate) underline_color: Option<&'a wstr>,
pub(crate) style: TextStyling,
pub(crate) print_color_mode: bool,
}
@@ -184,6 +200,7 @@ pub(crate) fn parse_text_face_and_options<'a>(
let short_options = &short_options[..short_options.len() - builtin_extra_args];
let long_options: &[WOption] = &[
wopt(L!("background"), ArgType::RequiredArgument, 'b'),
wopt(L!("underline-color"), ArgType::RequiredArgument, '\x02'),
wopt(L!("bold"), ArgType::NoArgument, 'o'),
wopt(L!("underline"), ArgType::OptionalArgument, 'u'),
wopt(L!("italics"), ArgType::NoArgument, 'i'),
@@ -195,6 +212,7 @@ pub(crate) fn parse_text_face_and_options<'a>(
let long_options = &long_options[..long_options.len() - builtin_extra_args];
let mut bgcolor = None;
let mut underline_color = None;
let mut style = TextStyling::default();
let mut print_color_mode = false;
@@ -205,6 +223,10 @@ pub(crate) fn parse_text_face_and_options<'a>(
assert!(w.woptarg.is_some(), "Arg should have been set");
bgcolor = w.woptarg;
}
'\x02' => {
assert!(w.woptarg.is_some(), "Arg should have been set");
underline_color = w.woptarg;
}
'h' => {
if is_builtin {
return TextFaceArgsAndOptionsResult::PrintHelp;
@@ -241,6 +263,7 @@ pub(crate) fn parse_text_face_and_options<'a>(
TextFaceArgsAndOptionsResult::Ok(TextFaceArgsAndOptions {
wopt_index: w.wopt_index,
bgcolor,
underline_color,
style,
print_color_mode,
})

View File

@@ -28,3 +28,8 @@ set_color --underline=asdf
# CHECKERR: set_color: invalid underline style: asdf
set_color -ushort
# CHECKERR: set_color: invalid underline style: short
string escape (set_color --underline-color=red)
# CHECK: \e\[58:5:1m
string escape (set_color --underline-color=normal)
# CHECK: \e\[59m