diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cce2df670..3b92a5ff8 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -65,6 +65,7 @@ Completions Improved terminal support ^^^^^^^^^^^^^^^^^^^^^^^^^ +- Support for curly underlines in `fish_color_*` variables and :doc:`set_color ` (:issue:`10957`). - New documentation page `Terminal Compatibility `_ (also accessible via ``man fish-terminal-compatibility``) lists required and optional terminal control sequences used by fish. Other improvements diff --git a/doc_src/cmds/set_color.rst b/doc_src/cmds/set_color.rst index 0d66d5cb6..685af432e 100644 --- a/doc_src/cmds/set_color.rst +++ b/doc_src/cmds/set_color.rst @@ -52,8 +52,8 @@ The following options are available: **-r** or **--reverse** Sets reverse mode. -**-u** or **--underline** - Sets underlined mode. +**-u** or **--underline**, or **-uSTYLE** or **--underline=STYLE** + Set the underline mode; supported styles are **single** (default) and **curly**. **-h** or **--help** Displays help about using this command. diff --git a/doc_src/terminal-compatibility.rst b/doc_src/terminal-compatibility.rst index 92e14e909..9de7268db 100644 --- a/doc_src/terminal-compatibility.rst +++ b/doc_src/terminal-compatibility.rst @@ -120,6 +120,10 @@ Optional Commands - smul - Enter underline mode. - + * - ``\e[4:3m`` + - Su + - Enter curly underline mode. + - kitty * - ``\e[7m`` - rev - Enter reverse video mode (swap foreground and background colors). diff --git a/share/completions/set.fish b/share/completions/set.fish index 10e8e121c..2fb6066a8 100644 --- a/share/completions/set.fish +++ b/share/completions/set.fish @@ -132,6 +132,7 @@ 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 complete -c set -n '__fish_set_is_color true true' -a --reverse -x complete -c set -n '__fish_set_is_color true false' -a --underline -x +complete -c set -n '__fish_set_is_color true false' -a --underline=curly -x # Locale completions complete -c set -n '__fish_set_is_locale; and not __fish_seen_argument -s e -l erase' -x -a '(command -sq locale; and locale -a)' -d Locale diff --git a/share/completions/set_color.fish b/share/completions/set_color.fish index 3d0173189..74a1e46b7 100644 --- a/share/completions/set_color.fish +++ b/share/completions/set_color.fish @@ -4,6 +4,6 @@ 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' complete -c set_color -s r -l reverse -d 'Reverse color text' -complete -c set_color -s u -l underline -d 'Underline text' +complete -c set_color -s u -l underline -d 'Underline style' -a 'single curly' complete -c set_color -s h -l help -d 'Display help and exit' complete -c set_color -s c -l print-colors -d 'Print a list of all accepted color names' diff --git a/share/tools/web_config/webconfig.py b/share/tools/web_config/webconfig.py index 8b0d9c11d..a2f8e8742 100755 --- a/share/tools/web_config/webconfig.py +++ b/share/tools/web_config/webconfig.py @@ -240,7 +240,11 @@ def parse_color(color_str): if comp == "--bold" or comp == "-o": bold = True elif comp == "--underline" or comp == "-u": - underline = True + underline = "single" + elif comp.startswith("--underline="): + underline = comp.stripprefix("--underline=") + elif comp.startswith("-u"): # Multiple short options like "-rbcurly" are not yet supported. + underline = comp.stripprefix("-u") elif comp == "--italics" or comp == "-i": italics = True elif comp == "--dim" or comp == "-d": @@ -295,8 +299,8 @@ def unparse_color(col): ret += col["color"] if col["bold"]: ret += " --bold" - if col["underline"]: - ret += " --underline" + if col["underline"] is not None: + ret += " --underline=" + col["underline"] if col["italics"]: ret += " --italics" if col["dim"]: diff --git a/src/builtins/set_color.rs b/src/builtins/set_color.rs index 6348485d5..f266e7225 100644 --- a/src/builtins/set_color.rs +++ b/src/builtins/set_color.rs @@ -66,6 +66,14 @@ pub fn set_color(parser: &Parser, streams: &mut IoStreams, argv: &mut [&wstr]) - // and we don't error for missing colors. return Err(STATUS_INVALID_ARGS); } + TextFaceArgsAndOptionsResult::InvalidUnderlineStyle(arg) => { + streams.err.append(wgettext_fmt!( + "%ls: invalid underline style: %ls\n", + argv[0], + arg + )); + return Err(STATUS_INVALID_ARGS); + } TextFaceArgsAndOptionsResult::UnknownOption(unknown_option_index) => { builtin_unknown_option( parser, diff --git a/src/highlight/highlight.rs b/src/highlight/highlight.rs index 1a568f4a4..14ee3b450 100644 --- a/src/highlight/highlight.rs +++ b/src/highlight/highlight.rs @@ -28,7 +28,7 @@ }; use crate::path::{path_as_implicit_cd, path_get_cdpath, path_get_path, paths_are_same_file}; use crate::terminal::Outputter; -use crate::text_face::{parse_text_face, TextFace}; +use crate::text_face::{parse_text_face, TextFace, UnderlineStyle}; use crate::threads::assert_is_background_thread; use crate::tokenizer::{variable_assignment_equals_pos, PipeOrRedir}; use crate::wchar::{wstr, WString, L}; @@ -168,12 +168,12 @@ pub(crate) fn resolve_spec_uncached( if !valid_path_face.fg.is_normal() { face.fg = valid_path_face.fg; } - face.style = face.style.union(valid_path_face.style); + face.style = face.style.union_prefer_right(valid_path_face.style); } } if highlight.force_underline { - face.style.inject_underline(true); + face.style.inject_underline(UnderlineStyle::Single); } face diff --git a/src/highlight/tests.rs b/src/highlight/tests.rs index 9c27d3a46..9b88e8c5a 100644 --- a/src/highlight/tests.rs +++ b/src/highlight/tests.rs @@ -3,6 +3,7 @@ use crate::future_feature_flags::{self, FeatureFlag}; use crate::highlight::HighlightColorResolver; use crate::tests::prelude::*; +use crate::text_face::UnderlineStyle; use crate::wchar::prelude::*; use crate::{ env::EnvStack, @@ -696,8 +697,9 @@ fn test_trailing_spaces_after_command() { // Check that 'echo' is underlined for i in 0..4 { let face = resolver.resolve_spec(&colors[i], vars); - assert!( - face.style.is_underline(), + assert_eq!( + face.style.underline_style(), + Some(UnderlineStyle::Single), "Character at position {} of 'echo' should be underlined", i ); @@ -706,8 +708,9 @@ fn test_trailing_spaces_after_command() { // Check that trailing spaces are NOT underlined for i in 4..text.len() { let face = resolver.resolve_spec(&colors[i], vars); - assert!( - !face.style.is_underline(), + assert_eq!( + face.style.underline_style(), + None, "Trailing space at position {} should NOT be underlined", i ); diff --git a/src/terminal.rs b/src/terminal.rs index 0f5f59d1b..4ce1f3b4b 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -5,7 +5,7 @@ use crate::future_feature_flags::{self, FeatureFlag}; use crate::global_safety::RelaxedAtomicBool; use crate::screen::{is_dumb, only_grayscale}; -use crate::text_face::{TextFace, TextStyling}; +use crate::text_face::{TextFace, TextStyling, UnderlineStyle}; use crate::threads::MainThread; use crate::wchar::prelude::*; use crate::FLOGF; @@ -53,6 +53,7 @@ pub(crate) enum TerminalCommand<'a> { EnterStandoutMode, ExitItalicsMode, ExitUnderlineMode, + EnterCurlyUnderlineMode, // Screen clearing ClearScreen, @@ -148,6 +149,7 @@ fn write(out: &mut impl Output, sequence: &'static [u8]) -> bool { EnterStandoutMode => ti(self, b"\x1b[7m", |t| &t.enter_standout_mode), ExitItalicsMode => ti(self, b"\x1b[23m", |t| &t.exit_italics_mode), ExitUnderlineMode => ti(self, b"\x1b[24m", |t| &t.exit_underline_mode), + EnterCurlyUnderlineMode => write(self, b"\x1b[4:3m"), 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), @@ -497,18 +499,19 @@ pub(crate) fn set_text_face(&mut self, face: TextFace) { let mut last_bg_set = false; use TerminalCommand::{ - EnterBoldMode, EnterDimMode, EnterItalicsMode, EnterReverseMode, EnterStandoutMode, - EnterUnderlineMode, ExitAttributeMode, ExitItalicsMode, ExitUnderlineMode, + EnterBoldMode, EnterCurlyUnderlineMode, EnterDimMode, EnterItalicsMode, + EnterReverseMode, EnterStandoutMode, EnterUnderlineMode, ExitAttributeMode, + ExitItalicsMode, ExitUnderlineMode, }; // Removes all styles that are individually resettable. let non_resettable = |mut style: TextStyling| { style.italics = false; - style.underline = false; + style.underline_style = None; style }; let non_resettable_attributes_to_unset = - non_resettable(self.last.style).difference(non_resettable(style)); + non_resettable(self.last.style).difference_prefer_empty(non_resettable(style)); if !non_resettable_attributes_to_unset.is_empty() { // Only way to exit non-resettable ones is a reset of all attributes. self.reset_text_face(); @@ -577,11 +580,22 @@ pub(crate) fn set_text_face(&mut self, face: TextFace) { self.last.style.bold = true; } - let was_underline = self.last.style.is_underline(); - if !style.is_underline() && was_underline && self.write_command(ExitUnderlineMode) { - self.last.style.underline = false; - } else if style.is_underline() && !was_underline && self.write_command(EnterUnderlineMode) { - self.last.style.underline = true; + if style.underline_style != self.last.style.underline_style { + match style.underline_style { + None => { + if self.write_command(ExitUnderlineMode) { + self.last.style.underline_style = None; + } + } + Some(underline) => { + if self.write_command(match underline { + UnderlineStyle::Single => EnterUnderlineMode, + UnderlineStyle::Curly => EnterCurlyUnderlineMode, + }) { + self.last.style.underline_style = Some(underline); + } + } + } } let was_italics = self.last.style.is_italics(); diff --git a/src/text_face.rs b/src/text_face.rs index 8220efbbb..9a65e5363 100644 --- a/src/text_face.rs +++ b/src/text_face.rs @@ -3,10 +3,34 @@ use crate::wchar::prelude::*; use crate::wgetopt::{wopt, ArgType, WGetopter, WOption}; +trait StyleSet { + fn union_prefer_right(self, other: Self) -> Self; + fn difference_prefer_empty(self, other: Self) -> Self; +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(crate) enum UnderlineStyle { + Single, + Curly, +} + +impl StyleSet for Option { + fn union_prefer_right(self, other: Self) -> Self { + other.or(self) + } + + fn difference_prefer_empty(self, other: Self) -> Self { + if other.is_some() { + return None; + } + self + } +} + #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub(crate) struct TextStyling { pub(crate) bold: bool, - pub(crate) underline: bool, + pub(crate) underline_style: Option, pub(crate) italics: bool, pub(crate) dim: bool, pub(crate) reverse: bool, @@ -16,7 +40,7 @@ impl TextStyling { pub(crate) const fn default() -> Self { Self { bold: false, - underline: false, + underline_style: None, italics: false, dim: false, reverse: false, @@ -25,19 +49,23 @@ pub(crate) const fn default() -> Self { pub(crate) fn is_empty(&self) -> bool { *self == Self::default() } - pub(crate) fn union(self, other: Self) -> Self { + pub(crate) fn union_prefer_right(self, other: Self) -> Self { Self { bold: self.is_bold() || other.is_bold(), - underline: self.is_underline() || other.is_underline(), + underline_style: self + .underline_style + .union_prefer_right(other.underline_style), italics: self.is_italics() || other.is_italics(), dim: self.is_dim() || other.is_dim(), reverse: self.is_reverse() || other.is_reverse(), } } - pub(crate) fn difference(self, other: Self) -> Self { + pub(crate) fn difference_prefer_empty(self, other: Self) -> Self { Self { bold: self.is_bold() && !other.is_bold(), - underline: self.is_underline() && !other.is_underline(), + underline_style: self + .underline_style + .difference_prefer_empty(other.underline_style), italics: self.is_italics() && !other.is_italics(), dim: self.is_dim() && !other.is_dim(), reverse: self.is_reverse() && !other.is_reverse(), @@ -49,14 +77,14 @@ pub const fn is_bold(self) -> bool { self.bold } - /// Returns whether the text face is underlined. - pub const fn is_underline(self) -> bool { - self.underline + #[cfg(test)] + pub const fn underline_style(self) -> Option { + self.underline_style } - /// Set whether the text face is underline. - pub fn inject_underline(&mut self, underline: bool) { - self.underline = underline; + /// Set the given underline style. + pub fn inject_underline(&mut self, underline: UnderlineStyle) { + self.underline_style = Some(underline); } /// Returns whether the text face is italics. @@ -116,6 +144,7 @@ pub(crate) fn parse_text_face(arguments: &[WString]) -> SpecifiedTextFace { TextFaceArgsAndOptionsResult::Ok(parsed_text_faces) => parsed_text_faces, TextFaceArgsAndOptionsResult::PrintHelp | TextFaceArgsAndOptionsResult::InvalidArgs + | TextFaceArgsAndOptionsResult::InvalidUnderlineStyle(_) | TextFaceArgsAndOptionsResult::UnknownOption(_) => unreachable!(), }; assert!(!print_color_mode); @@ -142,6 +171,7 @@ pub(crate) enum TextFaceArgsAndOptionsResult<'a> { Ok(TextFaceArgsAndOptions<'a>), PrintHelp, InvalidArgs, + InvalidUnderlineStyle(&'a wstr), UnknownOption(usize), } @@ -150,12 +180,12 @@ pub(crate) fn parse_text_face_and_options<'a>( is_builtin: bool, ) -> TextFaceArgsAndOptionsResult<'a> { let builtin_extra_args = if is_builtin { 0 } else { "hc".len() }; - let short_options = L!(":b:oidruch"); + let short_options = L!(":b:oidru::ch"); let short_options = &short_options[..short_options.len() - builtin_extra_args]; let long_options: &[WOption] = &[ wopt(L!("background"), ArgType::RequiredArgument, 'b'), wopt(L!("bold"), ArgType::NoArgument, 'o'), - wopt(L!("underline"), ArgType::NoArgument, 'u'), + wopt(L!("underline"), ArgType::OptionalArgument, 'u'), wopt(L!("italics"), ArgType::NoArgument, 'i'), wopt(L!("dim"), ArgType::NoArgument, 'd'), wopt(L!("reverse"), ArgType::NoArgument, 'r'), @@ -184,7 +214,16 @@ pub(crate) fn parse_text_face_and_options<'a>( 'i' => style.italics = true, 'd' => style.dim = true, 'r' => style.reverse = true, - 'u' => style.underline = true, + 'u' => { + let arg = w.woptarg.unwrap_or(L!("single")); + if arg == "single" { + style.underline_style = Some(UnderlineStyle::Single); + } else if arg == "curly" { + style.underline_style = Some(UnderlineStyle::Curly); + } else if is_builtin { + return TextFaceArgsAndOptionsResult::InvalidUnderlineStyle(arg); + } + } 'c' => print_color_mode = true, ':' => { if is_builtin { diff --git a/tests/checks/set_color.fish b/tests/checks/set_color.fish index cd10bf6fa..ba6becd92 100644 --- a/tests/checks/set_color.fish +++ b/tests/checks/set_color.fish @@ -17,3 +17,14 @@ string escape (set_color --bold red --background=normal) # CHECK: \e\[m string escape (set_color --bold red --background=blue) # CHECK: \e\[1m\e\[31m\e\[44m + +string escape (set_color --underline=curly) +# CHECK: \e\[4:3m +string escape (set_color -ucurly) +# CHECK: \e\[4:3m +string escape (set_color -u) +# CHECK: \e\[4m +set_color --underline=asdf +# CHECKERR: set_color: invalid underline style: asdf +set_color -ushort +# CHECKERR: set_color: invalid underline style: short