From 005445b9fe20f0b9169388750abc283aa4939b5e Mon Sep 17 00:00:00 2001 From: Jan De Dobbeleer Date: Tue, 3 Jan 2023 12:21:27 +0100 Subject: [PATCH] refactor(ansi): rewrite ansi and writer --- src/cli/config_export_image.go | 21 +- src/cli/debug.go | 20 +- src/color/ansi.go | 366 ------------ src/color/ansi_writer.go | 520 ++++++++++++++++++ src/color/ansi_writer_hyperlink.go | 129 +++++ ..._test.go => ansi_writer_hyperlink_test.go} | 33 +- .../{writer_test.go => ansi_writer_test.go} | 77 ++- src/color/colors.go | 34 ++ src/color/plain_writer.go | 44 -- src/color/text.go | 18 +- src/color/text_test.go | 10 +- src/color/writer.go | 247 --------- src/console/title.go | 30 - src/console/title_test.go | 129 ----- src/engine/block.go | 8 +- src/engine/engine.go | 108 ++-- src/engine/engine_test.go | 149 ++++- src/engine/image.go | 2 +- src/engine/image_test.go | 5 +- src/engine/new.go | 37 +- src/segments/path_test.go | 8 +- src/shell/constants.go | 16 +- 22 files changed, 970 insertions(+), 1041 deletions(-) delete mode 100644 src/color/ansi.go create mode 100644 src/color/ansi_writer.go create mode 100644 src/color/ansi_writer_hyperlink.go rename src/color/{ansi_test.go => ansi_writer_hyperlink_test.go} (70%) rename src/color/{writer_test.go => ansi_writer_test.go} (79%) delete mode 100644 src/color/plain_writer.go delete mode 100644 src/color/writer.go delete mode 100644 src/console/title.go delete mode 100644 src/console/title_test.go diff --git a/src/cli/config_export_image.go b/src/cli/config_export_image.go index 71c966a9..6714deb5 100644 --- a/src/cli/config_export_image.go +++ b/src/cli/config_export_image.go @@ -4,7 +4,6 @@ import ( "fmt" "github.com/jandedobbeleer/oh-my-posh/color" - "github.com/jandedobbeleer/oh-my-posh/console" "github.com/jandedobbeleer/oh-my-posh/engine" "github.com/jandedobbeleer/oh-my-posh/platform" "github.com/jandedobbeleer/oh-my-posh/shell" @@ -52,31 +51,21 @@ Exports the config to an image file using customized output options.`, Version: cliVersion, CmdFlags: &platform.Flags{ Config: config, - Shell: shell.PLAIN, + Shell: shell.GENERIC, }, } env.Init() defer env.Close() cfg := engine.LoadConfig(env) - ansi := &color.Ansi{} - ansi.InitPlain() writerColors := cfg.MakeColors() writer := &color.AnsiWriter{ - Ansi: ansi, TerminalBackground: shell.ConsoleBackgroundColor(env, cfg.TerminalBackground), AnsiColors: writerColors, } - consoleTitle := &console.Title{ - Env: env, - Ansi: ansi, - Template: cfg.ConsoleTitleTemplate, - } eng := &engine.Engine{ - Config: cfg, - Env: env, - Writer: writer, - ConsoleTitle: consoleTitle, - Ansi: ansi, + Config: cfg, + Env: env, + Writer: writer, } prompt := eng.PrintPrimary() imageCreator := &engine.ImageRenderer{ @@ -85,7 +74,7 @@ Exports the config to an image file using customized output options.`, CursorPadding: cursorPadding, RPromptOffset: rPromptOffset, BgColor: bgColor, - Ansi: ansi, + Ansi: writer, } if outputImage != "" { imageCreator.Path = cleanOutputPath(outputImage, env) diff --git a/src/cli/debug.go b/src/cli/debug.go index 4493e1c0..43b8e0f8 100644 --- a/src/cli/debug.go +++ b/src/cli/debug.go @@ -5,7 +5,6 @@ import ( "time" "github.com/jandedobbeleer/oh-my-posh/color" - "github.com/jandedobbeleer/oh-my-posh/console" "github.com/jandedobbeleer/oh-my-posh/engine" "github.com/jandedobbeleer/oh-my-posh/platform" "github.com/jandedobbeleer/oh-my-posh/shell" @@ -33,26 +32,17 @@ var debugCmd = &cobra.Command{ env.Init() defer env.Close() cfg := engine.LoadConfig(env) - ansi := &color.Ansi{} - ansi.InitPlain() writerColors := cfg.MakeColors() writer := &color.AnsiWriter{ - Ansi: ansi, TerminalBackground: shell.ConsoleBackgroundColor(env, cfg.TerminalBackground), AnsiColors: writerColors, } - consoleTitle := &console.Title{ - Env: env, - Ansi: ansi, - Template: cfg.ConsoleTitleTemplate, - } + writer.Init(shell.GENERIC) eng := &engine.Engine{ - Config: cfg, - Env: env, - Writer: writer, - ConsoleTitle: consoleTitle, - Ansi: ansi, - Plain: plain, + Config: cfg, + Env: env, + Writer: writer, + Plain: plain, } fmt.Print(eng.PrintDebug(startTime, cliVersion)) }, diff --git a/src/color/ansi.go b/src/color/ansi.go deleted file mode 100644 index 82848743..00000000 --- a/src/color/ansi.go +++ /dev/null @@ -1,366 +0,0 @@ -package color - -import ( - "fmt" - "strings" - - "github.com/jandedobbeleer/oh-my-posh/regex" - "github.com/jandedobbeleer/oh-my-posh/shell" -) - -const ( - AnsiRegex = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" - - OSC99 string = "osc99" - OSC7 string = "osc7" - OSC51 string = "osc51" -) - -type Ansi struct { - title string - shell string - linechange string - left string - right string - creset string - clearBelow string - clearLine string - saveCursorPosition string - restoreCursorPosition string - colorSingle string - colorFull string - colorTransparent string - escapeLeft string - escapeRight string - hyperlink string - hyperlinkRegex string - osc99 string - osc7 string - osc51 string - bold string - italic string - underline string - overline string - strikethrough string - blink string - reverse string - dimmed string - format string -} - -func (a *Ansi) Init(shellName string) { - a.shell = shellName - switch shellName { - case shell.ZSH: - a.format = "%%{%s%%}" - a.linechange = "%%{\x1b[%d%s%%}" - a.right = "%%{\x1b[%dC%%}" - a.left = "%%{\x1b[%dD%%}" - a.creset = "%{\x1b[0m%}" - a.clearBelow = "%{\x1b[0J%}" - a.clearLine = "%{\x1b[K%}" - a.saveCursorPosition = "%{\x1b7%}" - a.restoreCursorPosition = "%{\x1b8%}" - a.title = "%%{\x1b]0;%s\007%%}" - a.colorSingle = "%%{\x1b[%sm%%}%s%%{\x1b[0m%%}" - a.colorFull = "%%{\x1b[%sm\x1b[%sm%%}%s%%{\x1b[0m%%}" - a.colorTransparent = "%%{\x1b[%s;49m\x1b[7m%%}%s%%{\x1b[0m%%}" - a.escapeLeft = "%{" - a.escapeRight = "%}" - a.hyperlink = "%%{\x1b]8;;%s\x1b\\%%}%s%%{\x1b]8;;\x1b\\%%}" - a.hyperlinkRegex = `(?P%{\x1b]8;;(.+)\x1b\\%}(?P.+)%{\x1b]8;;\x1b\\%})` - a.osc99 = "%%{\x1b]9;9;\"%s\"\x1b\\%%}" - a.osc7 = "%%{\x1b]7;file:\"//%s/%s\"\x1b\\%%}" - a.osc51 = "%%{\x1b]51;A%s@%s:%s\x1b\\%%}" - a.bold = "%%{\x1b[1m%%}%s%%{\x1b[22m%%}" - a.italic = "%%{\x1b[3m%%}%s%%{\x1b[23m%%}" - a.underline = "%%{\x1b[4m%%}%s%%{\x1b[24m%%}" - a.overline = "%%{\x1b[53m%%}%s%%{\x1b[55m%%}" - a.blink = "%%{\x1b[5m%%}%s%%{\x1b[25m%%}" - a.reverse = "%%{\x1b[7m%%}%s%%{\x1b[27m%%}" - a.dimmed = "%%{\x1b[2m%%}%s%%{\x1b[22m%%}" - a.strikethrough = "%%{\x1b[9m%%}%s%%{\x1b[29m%%}" - case shell.BASH: - a.format = "\\[%s\\]" - a.linechange = "\\[\x1b[%d%s\\]" - a.right = "\\[\x1b[%dC\\]" - a.left = "\\[\x1b[%dD\\]" - a.creset = "\\[\x1b[0m\\]" - a.clearBelow = "\\[\x1b[0J\\]" - a.clearLine = "\\[\x1b[K\\]" - a.saveCursorPosition = "\\[\x1b7\\]" - a.restoreCursorPosition = "\\[\x1b8\\]" - a.title = "\\[\x1b]0;%s\007\\]" - a.colorSingle = "\\[\x1b[%sm\\]%s\\[\x1b[0m\\]" - a.colorFull = "\\[\x1b[%sm\x1b[%sm\\]%s\\[\x1b[0m\\]" - a.colorTransparent = "\\[\x1b[%s;49m\x1b[7m\\]%s\\[\x1b[0m\\]" - a.escapeLeft = "\\[" - a.escapeRight = "\\]" - a.hyperlink = "\\[\x1b]8;;%s\x1b\\\\\\]%s\\[\x1b]8;;\x1b\\\\\\]" - a.hyperlinkRegex = `(?P\\\[\x1b\]8;;(.+)\x1b\\\\\\\](?P.+)\\\[\x1b\]8;;\x1b\\\\\\\])` - a.osc99 = "\\[\x1b]9;9;\"%s\"\x1b\\\\\\]" - a.osc7 = "\\[\x1b]7;\"file://%s/%s\"\x1b\\\\\\]" - a.osc51 = "\\[\x1b]51;A;%s@%s:%s\x1b\\\\\\]" - a.bold = "\\[\x1b[1m\\]%s\\[\x1b[22m\\]" - a.italic = "\\[\x1b[3m\\]%s\\[\x1b[23m\\]" - a.underline = "\\[\x1b[4m\\]%s\\[\x1b[24m\\]" - a.overline = "\\[\x1b[53m\\]%s\\[\x1b[55m\\]" - a.blink = "\\[\x1b[5m\\]%s\\[\x1b[25m\\]" - a.reverse = "\\[\x1b[7m\\]%s\\[\x1b[27m\\]" - a.dimmed = "\\[\x1b[2m\\]%s\\[\x1b[22m\\]" - a.strikethrough = "\\[\x1b[9m\\]%s\\[\x1b[29m\\]" - default: - a.format = "%s" - a.linechange = "\x1b[%d%s" - a.right = "\x1b[%dC" - a.left = "\x1b[%dD" - a.creset = "\x1b[0m" - a.clearBelow = "\x1b[0J" - a.clearLine = "\x1b[K" - a.saveCursorPosition = "\x1b7" - a.restoreCursorPosition = "\x1b8" - a.title = "\x1b]0;%s\007" - a.colorSingle = "\x1b[%sm%s\x1b[0m" - a.colorFull = "\x1b[%sm\x1b[%sm%s\x1b[0m" - a.colorTransparent = "\x1b[%s;49m\x1b[7m%s\x1b[0m" - a.escapeLeft = "" - a.escapeRight = "" - // when in fish on Linux, it seems hyperlinks ending with \\ print a \ - // unlike on macOS. However, this is a fish bug, so do not try to fix it here: - // https://github.com/JanDeDobbeleer/oh-my-posh/pull/3288#issuecomment-1369137068 - a.hyperlink = "\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\" - a.hyperlinkRegex = "(?P\x1b]8;;(.+)\x1b\\\\\\\\?(?P.+)\x1b]8;;\x1b\\\\)" - a.osc99 = "\x1b]9;9;\"%s\"\x1b\\" - a.osc7 = "\x1b]7;\"file://%s/%s\"\x1b\\" - a.osc51 = "\x1b]51;A%s@%s:%s\x1b\\" - a.bold = "\x1b[1m%s\x1b[22m" - a.italic = "\x1b[3m%s\x1b[23m" - a.underline = "\x1b[4m%s\x1b[24m" - a.overline = "\x1b[53m%s\x1b[55m" - a.blink = "\x1b[5m%s\x1b[25m" - a.reverse = "\x1b[7m%s\x1b[27m" - a.dimmed = "\x1b[2m%s\x1b[22m" - a.strikethrough = "\x1b[9m%s\x1b[29m" - } -} - -func (a *Ansi) InitPlain() { - a.Init(shell.PLAIN) -} - -func (a *Ansi) GenerateHyperlink(text string) string { - const ( - LINK = "link" - TEXT = "text" - OTHER = "plain" - ) - - var result, hyperlink strings.Builder - var squareIndex, roundCount int - state := OTHER - - for i, s := range text { - if s == '[' && state == OTHER { - state = TEXT - hyperlink.WriteRune(s) - continue - } - - if state == OTHER { - result.WriteRune(s) - continue - } - - hyperlink.WriteRune(s) - - switch s { - case ']': - // potential end of text part of hyperlink - squareIndex = i - case '(': - // split into link part - if squareIndex == i-1 { - state = LINK - } - if state == LINK { - roundCount++ - } - case ')': - if state != LINK { - continue - } - roundCount-- - if roundCount != 0 { - continue - } - // end of link part - result.WriteString(a.replaceHyperlink(hyperlink.String())) - hyperlink.Reset() - state = OTHER - } - } - - result.WriteString(hyperlink.String()) - return result.String() -} - -func (a *Ansi) replaceHyperlink(text string) string { - // hyperlink matching - results := regex.FindNamedRegexMatch("(?P(?:\\[(?P.+)\\])(?:\\((?P.*)\\)))", text) - if len(results) != 3 { - return text - } - linkText := a.escapeLinkTextForFishShell(results["TEXT"]) - // build hyperlink ansi - hyperlink := fmt.Sprintf(a.hyperlink, results["URL"], linkText) - // replace original text by the new onex - return strings.Replace(text, results["ALL"], hyperlink, 1) -} - -func (a *Ansi) escapeLinkTextForFishShell(text string) string { - if a.shell != shell.FISH { - return text - } - escapeChars := map[string]string{ - `c`: `\c`, - `a`: `\a`, - `b`: `\b`, - `e`: `\e`, - `f`: `\f`, - `n`: `\n`, - `r`: `\r`, - `t`: `\t`, - `v`: `\v`, - `$`: `\$`, - `*`: `\*`, - `?`: `\?`, - `~`: `\~`, - `%`: `\%`, - `#`: `\#`, - `(`: `\(`, - `)`: `\)`, - `{`: `\{`, - `}`: `\}`, - `[`: `\[`, - `]`: `\]`, - `<`: `\<`, - `>`: `\>`, - `^`: `\^`, - `&`: `\&`, - `;`: `\;`, - `"`: `\"`, - `'`: `\'`, - `x`: `\x`, - `X`: `\X`, - `0`: `\0`, - `u`: `\u`, - `U`: `\U`, - } - if val, ok := escapeChars[text[0:1]]; ok { - return val + text[1:] - } - return text -} - -func (a *Ansi) formatText(text string) string { - replaceFormats := func(results []map[string]string) { - for _, result := range results { - var formatted string - switch result["format"] { - case "b": - formatted = fmt.Sprintf(a.bold, result["text"]) - case "u": - formatted = fmt.Sprintf(a.underline, result["text"]) - case "o": - formatted = fmt.Sprintf(a.overline, result["text"]) - case "i": - formatted = fmt.Sprintf(a.italic, result["text"]) - case "s": - formatted = fmt.Sprintf(a.strikethrough, result["text"]) - case "d": - formatted = fmt.Sprintf(a.dimmed, result["text"]) - case "f": - formatted = fmt.Sprintf(a.blink, result["text"]) - case "r": - formatted = fmt.Sprintf(a.reverse, result["text"]) - } - text = strings.Replace(text, result["context"], formatted, 1) - } - } - rgx := "(?P<(?P[buisrdfo])>(?P[^<]+))" - for results := regex.FindAllNamedRegexMatch(rgx, text); len(results) != 0; results = regex.FindAllNamedRegexMatch(rgx, text) { - replaceFormats(results) - } - return text -} - -func (a *Ansi) CarriageForward() string { - return fmt.Sprintf(a.right, 1000) -} - -func (a *Ansi) GetCursorForRightWrite(length, offset int) string { - strippedLen := length + (-offset) - return fmt.Sprintf(a.left, strippedLen) -} - -func (a *Ansi) ChangeLine(numberOfLines int) string { - position := "B" - if numberOfLines < 0 { - position = "F" - numberOfLines = -numberOfLines - } - return fmt.Sprintf(a.linechange, numberOfLines, position) -} - -func (a *Ansi) ConsolePwd(pwdType, userName, hostName, pwd string) string { - if strings.HasSuffix(pwd, ":") { - pwd += "\\" - } - switch pwdType { - case OSC7: - return fmt.Sprintf(a.osc7, hostName, pwd) - case OSC51: - return fmt.Sprintf(a.osc51, userName, hostName, pwd) - case OSC99: - fallthrough - default: - return fmt.Sprintf(a.osc99, pwd) - } -} - -func (a *Ansi) ClearAfter() string { - return a.clearLine + a.clearBelow -} - -func (a *Ansi) Title(title string) string { - // we have to do this to prevent bash/zsh from misidentifying escape sequences - switch a.shell { - case shell.BASH: - title = strings.NewReplacer("`", "\\`", `\`, `\\`).Replace(title) - case shell.ZSH: - title = strings.NewReplacer("`", "\\`", `%`, `%%`).Replace(title) - } - return fmt.Sprintf(a.title, title) -} - -func (a *Ansi) ColorReset() string { - return a.creset -} - -func (a *Ansi) FormatText(text string) string { - return fmt.Sprintf(a.format, text) -} - -func (a *Ansi) SaveCursorPosition() string { - return a.saveCursorPosition -} - -func (a *Ansi) RestoreCursorPosition() string { - return a.restoreCursorPosition -} - -func (a *Ansi) LineBreak() string { - cr := fmt.Sprintf(a.left, 1000) - lf := fmt.Sprintf(a.linechange, 1, "B") - return cr + lf -} diff --git a/src/color/ansi_writer.go b/src/color/ansi_writer.go new file mode 100644 index 00000000..3c57b1cf --- /dev/null +++ b/src/color/ansi_writer.go @@ -0,0 +1,520 @@ +package color + +import ( + "fmt" + "strings" + + "github.com/jandedobbeleer/oh-my-posh/regex" + "github.com/jandedobbeleer/oh-my-posh/shell" + "github.com/mattn/go-runewidth" +) + +type Writer interface { + Init(shellName string) + Write(background, foreground, text string) + String() (string, int) + SetColors(background, foreground string) + SetParentColors(background, foreground string) + CarriageForward() string + GetCursorForRightWrite(length, offset int) string + ChangeLine(numberOfLines int) string + ConsolePwd(pwdType, userName, hostName, pwd string) string + ClearAfter() string + FormatTitle(title string) string + FormatText(text string) string + SaveCursorPosition() string + RestoreCursorPosition() string + LineBreak() string + TrimAnsi(text string) string +} + +var ( + knownStyles = []*style{ + {AnchorStart: ``, AnchorEnd: ``, Start: "\x1b[1m", End: "\x1b[22m"}, + {AnchorStart: ``, AnchorEnd: ``, Start: "\x1b[4m", End: "\x1b[24m"}, + {AnchorStart: ``, AnchorEnd: ``, Start: "\x1b[53m", End: "\x1b[55m"}, + {AnchorStart: ``, AnchorEnd: ``, Start: "\x1b[3m", End: "\x1b[23m"}, + {AnchorStart: ``, AnchorEnd: ``, Start: "\x1b[9m", End: "\x1b[29m"}, + {AnchorStart: ``, AnchorEnd: ``, Start: "\x1b[2m", End: "\x1b[22m"}, + {AnchorStart: ``, AnchorEnd: ``, Start: "\x1b[5m", End: "\x1b[25m"}, + {AnchorStart: ``, AnchorEnd: ``, Start: "\x1b[7m", End: "\x1b[27m"}, + } + colorStyle = &style{AnchorStart: "COLOR", AnchorEnd: ``, End: "\x1b[0m"} +) + +type style struct { + AnchorStart string + AnchorEnd string + Start string + End string +} + +type Color struct { + Background string + Foreground string +} + +const ( + // Transparent implies a transparent color + Transparent = "transparent" + // Accent is the OS accent color + Accent = "accent" + // ParentBackground takes the previous segment's background color + ParentBackground = "parentBackground" + // ParentForeground takes the previous segment's color + ParentForeground = "parentForeground" + // Background takes the current segment's background color + Background = "background" + // Foreground takes the current segment's foreground color + Foreground = "foreground" + + anchorRegex = `^(?P<(?P[^,>]+)?,?(?P[^>]+)?>)` + colorise = "\x1b[%sm" + transparent = "\x1b[%s;49m\x1b[7m" + + AnsiRegex = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" + + OSC99 string = "osc99" + OSC7 string = "osc7" + OSC51 string = "osc51" +) + +// AnsiWriter writes colorized ANSI strings +type AnsiWriter struct { + TerminalBackground string + Colors *Color + ParentColors []*Color + AnsiColors AnsiColors + Plain bool + + builder strings.Builder + length int + + foreground AnsiColor + background AnsiColor + currentForeground AnsiColor + currentBackground AnsiColor + runes []rune + + shell string + format string + left string + right string + title string + linechange string + clearBelow string + clearLine string + saveCursorPosition string + restoreCursorPosition string + escapeLeft string + escapeRight string + hyperlink string + hyperlinkRegex string + osc99 string + osc7 string + osc51 string +} + +func (a *AnsiWriter) Init(shellName string) { + a.shell = shellName + switch a.shell { + case shell.BASH: + a.format = "\\[%s\\]" + a.linechange = "\\[\x1b[%d%s\\]" + a.right = "\\[\x1b[%dC\\]" + a.left = "\\[\x1b[%dD\\]" + a.clearBelow = "\\[\x1b[0J\\]" + a.clearLine = "\\[\x1b[K\\]" + a.saveCursorPosition = "\\[\x1b7\\]" + a.restoreCursorPosition = "\\[\x1b8\\]" + a.title = "\\[\x1b]0;%s\007\\]" + a.escapeLeft = "\\[" + a.escapeRight = "\\]" + a.hyperlink = "\\[\x1b]8;;%s\x1b\\\\\\]%s\\[\x1b]8;;\x1b\\\\\\]" + a.hyperlinkRegex = `(?P\\\[\x1b\]8;;(.+)\x1b\\\\\\\](?P.+)\\\[\x1b\]8;;\x1b\\\\\\\])` + a.osc99 = "\\[\x1b]9;9;\"%s\"\x1b\\\\\\]" + a.osc7 = "\\[\x1b]7;\"file://%s/%s\"\x1b\\\\\\]" + a.osc51 = "\\[\x1b]51;A;%s@%s:%s\x1b\\\\\\]" + case "zsh": + a.format = "%%{%s%%}" + a.linechange = "%%{\x1b[%d%s%%}" + a.right = "%%{\x1b[%dC%%}" + a.left = "%%{\x1b[%dD%%}" + a.clearBelow = "%{\x1b[0J%}" + a.clearLine = "%{\x1b[K%}" + a.saveCursorPosition = "%{\x1b7%}" + a.restoreCursorPosition = "%{\x1b8%}" + a.title = "%%{\x1b]0;%s\007%%}" + a.escapeLeft = "%{" + a.escapeRight = "%}" + a.hyperlink = "%%{\x1b]8;;%s\x1b\\%%}%s%%{\x1b]8;;\x1b\\%%}" + a.hyperlinkRegex = `(?P%{\x1b]8;;(.+)\x1b\\%}(?P.+)%{\x1b]8;;\x1b\\%})` + a.osc99 = "%%{\x1b]9;9;\"%s\"\x1b\\%%}" + a.osc7 = "%%{\x1b]7;file:\"//%s/%s\"\x1b\\%%}" + a.osc51 = "%%{\x1b]51;A%s@%s:%s\x1b\\%%}" + default: + a.linechange = "\x1b[%d%s" + a.right = "\x1b[%dC" + a.left = "\x1b[%dD" + a.clearBelow = "\x1b[0J" + a.clearLine = "\x1b[K" + a.saveCursorPosition = "\x1b7" + a.restoreCursorPosition = "\x1b8" + a.title = "\x1b]0;%s\007" + // when in fish on Linux, it seems hyperlinks ending with \\ print a \ + // unlike on macOS. However, this is a fish bug, so do not try to fix it here: + // https://github.com/JanDeDobbeleer/oh-my-posh/pull/3288#issuecomment-1369137068 + a.hyperlink = "\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\" + a.hyperlinkRegex = "(?P\x1b]8;;(.+)\x1b\\\\\\\\?(?P.+)\x1b]8;;\x1b\\\\)" + a.osc99 = "\x1b]9;9;\"%s\"\x1b\\" + a.osc7 = "\x1b]7;\"file://%s/%s\"\x1b\\" + a.osc51 = "\x1b]51;A%s@%s:%s\x1b\\" + } +} + +func (a *AnsiWriter) SetColors(background, foreground string) { + a.Colors = &Color{ + Background: background, + Foreground: foreground, + } +} + +func (a *AnsiWriter) SetParentColors(background, foreground string) { + if a.ParentColors == nil { + a.ParentColors = make([]*Color, 0) + } + a.ParentColors = append([]*Color{{ + Background: background, + Foreground: foreground, + }}, a.ParentColors...) +} + +func (a *AnsiWriter) CarriageForward() string { + return fmt.Sprintf(a.right, 1000) +} + +func (a *AnsiWriter) GetCursorForRightWrite(length, offset int) string { + strippedLen := length + (-offset) + return fmt.Sprintf(a.left, strippedLen) +} + +func (a *AnsiWriter) ChangeLine(numberOfLines int) string { + if a.Plain { + return "" + } + position := "B" + if numberOfLines < 0 { + position = "F" + numberOfLines = -numberOfLines + } + return fmt.Sprintf(a.linechange, numberOfLines, position) +} + +func (a *AnsiWriter) ConsolePwd(pwdType, userName, hostName, pwd string) string { + if a.Plain { + return "" + } + if strings.HasSuffix(pwd, ":") { + pwd += "\\" + } + switch pwdType { + case OSC7: + return fmt.Sprintf(a.osc7, hostName, pwd) + case OSC51: + return fmt.Sprintf(a.osc51, userName, hostName, pwd) + case OSC99: + fallthrough + default: + return fmt.Sprintf(a.osc99, pwd) + } +} + +func (a *AnsiWriter) ClearAfter() string { + if a.Plain { + return "" + } + return a.clearLine + a.clearBelow +} + +func (a *AnsiWriter) FormatTitle(title string) string { + title = a.TrimAnsi(title) + // we have to do this to prevent bash/zsh from misidentifying escape sequences + switch a.shell { + case shell.BASH: + title = strings.NewReplacer("`", "\\`", `\`, `\\`).Replace(title) + case shell.ZSH: + title = strings.NewReplacer("`", "\\`", `%`, `%%`).Replace(title) + } + return fmt.Sprintf(a.title, title) +} + +func (a *AnsiWriter) FormatText(text string) string { + return fmt.Sprintf(a.format, text) +} + +func (a *AnsiWriter) SaveCursorPosition() string { + return a.saveCursorPosition +} + +func (a *AnsiWriter) RestoreCursorPosition() string { + return a.restoreCursorPosition +} + +func (a *AnsiWriter) LineBreak() string { + cr := fmt.Sprintf(a.left, 1000) + lf := fmt.Sprintf(a.linechange, 1, "B") + return cr + lf +} + +func (a *AnsiWriter) Write(background, foreground, text string) { + if len(text) == 0 { + return + } + + if !a.Plain { + text = a.GenerateHyperlink(text) + } + + a.background, a.foreground = a.asAnsiColors(background, foreground) + // default to white foreground + if a.foreground.IsEmpty() { + a.foreground = a.AnsiColors.AnsiColorFromString("white", false) + } + // validate if we start with a color override + match := regex.FindNamedRegexMatch(anchorRegex, text) + if len(match) != 0 { + colorOverride := true + for _, style := range knownStyles { + if match["ANCHOR"] != style.AnchorStart { + continue + } + a.printEscapedAnsiString(style.Start) + colorOverride = false + } + if colorOverride { + a.currentBackground, a.currentForeground = a.asAnsiColors(match["BG"], match["FG"]) + } + } + a.writeSegmentColors() + + text = text[len(match["ANCHOR"]):] + a.runes = []rune(text) + + for i := 0; i < len(a.runes); i++ { + s := a.runes[i] + // ignore everything which isn't overriding + if s != '<' { + a.length += runewidth.RuneWidth(s) + a.builder.WriteRune(s) + continue + } + + // color/end overrides first + text = string(a.runes[i:]) + match = regex.FindNamedRegexMatch(anchorRegex, text) + if len(match) > 0 { + i = a.writeColorOverrides(match, background, i) + continue + } + + a.length += runewidth.RuneWidth(s) + a.builder.WriteRune(s) + } + + a.printEscapedAnsiString(colorStyle.End) + + // reset current + a.currentBackground = "" + a.currentForeground = "" +} + +func (a *AnsiWriter) printEscapedAnsiString(text string) { + if a.Plain { + return + } + if len(a.format) == 0 { + a.builder.WriteString(text) + return + } + a.builder.WriteString(fmt.Sprintf(a.format, text)) +} + +func (a *AnsiWriter) getAnsiFromColorString(colorString string, isBackground bool) AnsiColor { + return a.AnsiColors.AnsiColorFromString(colorString, isBackground) +} + +func (a *AnsiWriter) writeSegmentColors() { + // use correct starting colors + bg := a.background + fg := a.foreground + if !a.currentBackground.IsEmpty() { + bg = a.currentBackground + } + if !a.currentForeground.IsEmpty() { + fg = a.currentForeground + } + + if fg.IsTransparent() && len(a.TerminalBackground) != 0 { + background := a.getAnsiFromColorString(a.TerminalBackground, false) + a.printEscapedAnsiString(fmt.Sprintf(colorise, background)) + a.printEscapedAnsiString(fmt.Sprintf(colorise, bg.ToForeground())) + } else if fg.IsTransparent() && !bg.IsEmpty() { + a.printEscapedAnsiString(fmt.Sprintf(transparent, bg)) + } else { + if !bg.IsEmpty() && !bg.IsTransparent() { + a.printEscapedAnsiString(fmt.Sprintf(colorise, bg)) + } + if !fg.IsEmpty() { + a.printEscapedAnsiString(fmt.Sprintf(colorise, fg)) + } + } + + // set current colors + a.currentBackground = bg + a.currentForeground = fg +} + +func (a *AnsiWriter) writeColorOverrides(match map[string]string, background string, i int) (position int) { + position = i + // check color reset first + if match["ANCHOR"] == colorStyle.AnchorEnd { + // make sure to reset the colors if needed + position += len([]rune(colorStyle.AnchorEnd)) - 1 + // do not restore colors at the end of the string, we print it anyways + if position == len(a.runes)-1 { + return + } + if a.currentBackground != a.background { + a.printEscapedAnsiString(fmt.Sprintf(colorise, a.background)) + } + if a.currentForeground != a.foreground { + a.printEscapedAnsiString(fmt.Sprintf(colorise, a.foreground)) + } + return + } + + position += len([]rune(match["ANCHOR"])) - 1 + + for _, style := range knownStyles { + if style.AnchorEnd == match["ANCHOR"] { + a.printEscapedAnsiString(style.End) + return + } + if style.AnchorStart == match["ANCHOR"] { + a.printEscapedAnsiString(style.Start) + return + } + } + + if match["FG"] == Transparent && len(match["BG"]) == 0 { + match["BG"] = background + } + a.currentBackground, a.currentForeground = a.asAnsiColors(match["BG"], match["FG"]) + + // make sure we have colors + if a.currentForeground.IsEmpty() { + a.currentForeground = a.foreground + } + if a.currentBackground.IsEmpty() { + a.currentBackground = a.background + } + + if a.currentForeground.IsTransparent() && len(a.TerminalBackground) != 0 { + background := a.getAnsiFromColorString(a.TerminalBackground, false) + a.printEscapedAnsiString(fmt.Sprintf(colorise, background)) + a.printEscapedAnsiString(fmt.Sprintf(colorise, a.currentBackground.ToForeground())) + return + } + + if a.currentForeground.IsTransparent() && !a.currentBackground.IsTransparent() { + a.printEscapedAnsiString(fmt.Sprintf(transparent, a.currentBackground)) + return + } + + if a.currentBackground != a.background { + // end the colors in case we have a transparent background + if a.currentBackground.IsTransparent() { + a.printEscapedAnsiString(colorStyle.End) + } else { + a.printEscapedAnsiString(fmt.Sprintf(colorise, a.currentBackground)) + } + } + + if a.currentForeground != a.foreground || a.currentBackground.IsTransparent() { + a.printEscapedAnsiString(fmt.Sprintf(colorise, a.currentForeground)) + } + + return position +} + +func (a *AnsiWriter) asAnsiColors(background, foreground string) (AnsiColor, AnsiColor) { + background = a.expandKeyword(background) + foreground = a.expandKeyword(foreground) + inverted := foreground == Transparent && len(background) != 0 + backgroundAnsi := a.getAnsiFromColorString(background, !inverted) + foregroundAnsi := a.getAnsiFromColorString(foreground, false) + return backgroundAnsi, foregroundAnsi +} + +func (a *AnsiWriter) isKeyword(color string) bool { + switch color { + case Transparent, ParentBackground, ParentForeground, Background, Foreground: + return true + default: + return false + } +} + +func (a *AnsiWriter) expandKeyword(keyword string) string { + resolveParentColor := func(keyword string) string { + for _, color := range a.ParentColors { + if color == nil { + return Transparent + } + switch keyword { + case ParentBackground: + keyword = color.Background + case ParentForeground: + keyword = color.Foreground + default: + if len(keyword) == 0 { + return Transparent + } + return keyword + } + } + if len(keyword) == 0 { + return Transparent + } + return keyword + } + resolveKeyword := func(keyword string) string { + switch { + case keyword == Background && a.Colors != nil: + return a.Colors.Background + case keyword == Foreground && a.Colors != nil: + return a.Colors.Foreground + case (keyword == ParentBackground || keyword == ParentForeground) && a.ParentColors != nil: + return resolveParentColor(keyword) + default: + return Transparent + } + } + for ok := a.isKeyword(keyword); ok; ok = a.isKeyword(keyword) { + resolved := resolveKeyword(keyword) + if resolved == keyword { + break + } + keyword = resolved + } + return keyword +} + +func (a *AnsiWriter) String() (string, int) { + defer func() { + a.length = 0 + a.builder.Reset() + }() + + return a.builder.String(), a.length +} diff --git a/src/color/ansi_writer_hyperlink.go b/src/color/ansi_writer_hyperlink.go new file mode 100644 index 00000000..bc47de15 --- /dev/null +++ b/src/color/ansi_writer_hyperlink.go @@ -0,0 +1,129 @@ +package color + +import ( + "fmt" + "strings" + + "github.com/jandedobbeleer/oh-my-posh/regex" + "github.com/jandedobbeleer/oh-my-posh/shell" +) + +func (a *AnsiWriter) GenerateHyperlink(text string) string { + const ( + LINK = "link" + TEXT = "text" + OTHER = "plain" + ) + + // do not do this when we do not need to + anchorCount := strings.Count(text, "[") + strings.Count(text, "]") + strings.Count(text, "(") + strings.Count(text, ")") + if anchorCount < 4 { + return text + } + + var result, hyperlink strings.Builder + var squareIndex, roundCount int + state := OTHER + + for i, s := range text { + if s == '[' && state == OTHER { + state = TEXT + hyperlink.WriteRune(s) + continue + } + + if state == OTHER { + result.WriteRune(s) + continue + } + + hyperlink.WriteRune(s) + + switch s { + case ']': + // potential end of text part of hyperlink + squareIndex = i + case '(': + // split into link part + if squareIndex == i-1 { + state = LINK + } + if state == LINK { + roundCount++ + } + case ')': + if state != LINK { + continue + } + roundCount-- + if roundCount != 0 { + continue + } + // end of link part + result.WriteString(a.replaceHyperlink(hyperlink.String())) + hyperlink.Reset() + state = OTHER + } + } + + result.WriteString(hyperlink.String()) + return result.String() +} + +func (a *AnsiWriter) replaceHyperlink(text string) string { + // hyperlink matching + results := regex.FindNamedRegexMatch("(?P(?:\\[(?P.+)\\])(?:\\((?P.*)\\)))", text) + if len(results) != 3 { + return text + } + linkText := a.escapeLinkTextForFishShell(results["TEXT"]) + // build hyperlink ansi + hyperlink := fmt.Sprintf(a.hyperlink, results["URL"], linkText) + // replace original text by the new onex + return strings.Replace(text, results["ALL"], hyperlink, 1) +} + +func (a *AnsiWriter) escapeLinkTextForFishShell(text string) string { + if a.shell != shell.FISH { + return text + } + escapeChars := map[string]string{ + `c`: `\c`, + `a`: `\a`, + `b`: `\b`, + `e`: `\e`, + `f`: `\f`, + `n`: `\n`, + `r`: `\r`, + `t`: `\t`, + `v`: `\v`, + `$`: `\$`, + `*`: `\*`, + `?`: `\?`, + `~`: `\~`, + `%`: `\%`, + `#`: `\#`, + `(`: `\(`, + `)`: `\)`, + `{`: `\{`, + `}`: `\}`, + `[`: `\[`, + `]`: `\]`, + `<`: `\<`, + `>`: `\>`, + `^`: `\^`, + `&`: `\&`, + `;`: `\;`, + `"`: `\"`, + `'`: `\'`, + `x`: `\x`, + `X`: `\X`, + `0`: `\0`, + `u`: `\u`, + `U`: `\U`, + } + if val, ok := escapeChars[text[0:1]]; ok { + return val + text[1:] + } + return text +} diff --git a/src/color/ansi_test.go b/src/color/ansi_writer_hyperlink_test.go similarity index 70% rename from src/color/ansi_test.go rename to src/color/ansi_writer_hyperlink_test.go index ff56fd73..e7f824a2 100644 --- a/src/color/ansi_test.go +++ b/src/color/ansi_writer_hyperlink_test.go @@ -19,7 +19,7 @@ func TestGenerateHyperlinkNoUrl(t *testing.T) { {Text: "sample text with no url", ShellName: shell.BASH, Expected: "sample text with no url"}, } for _, tc := range cases { - a := Ansi{} + a := AnsiWriter{} a.Init(tc.ShellName) hyperlinkText := a.GenerateHyperlink(tc.Text) assert.Equal(t, tc.Expected, hyperlinkText) @@ -52,7 +52,7 @@ func TestGenerateHyperlinkWithUrl(t *testing.T) { }, } for _, tc := range cases { - a := Ansi{} + a := AnsiWriter{} a.Init(tc.ShellName) hyperlinkText := a.GenerateHyperlink(tc.Text) assert.Equal(t, tc.Expected, hyperlinkText) @@ -70,38 +70,13 @@ func TestGenerateHyperlinkWithUrlNoName(t *testing.T) { {Text: "[](http://www.google.be)", ShellName: shell.BASH, Expected: "[](http://www.google.be)"}, } for _, tc := range cases { - a := Ansi{} + a := AnsiWriter{} a.Init(tc.ShellName) hyperlinkText := a.GenerateHyperlink(tc.Text) assert.Equal(t, tc.Expected, hyperlinkText) } } -func TestFormatText(t *testing.T) { - cases := []struct { - Case string - Text string - Expected string - }{ - {Case: "single format", Text: "This is white", Expected: "This \x1b[1mis\x1b[22m white"}, - {Case: "double format", Text: "This is white, this is orange", Expected: "This \x1b[1mis\x1b[22m white, this \x1b[1mis\x1b[22m orange"}, - {Case: "underline", Text: "This is white", Expected: "This \x1b[4mis\x1b[24m white"}, - {Case: "italic", Text: "This is white", Expected: "This \x1b[3mis\x1b[23m white"}, - {Case: "strikethrough", Text: "This is white", Expected: "This \x1b[9mis\x1b[29m white"}, - {Case: "dimmed", Text: "This is white", Expected: "This \x1b[2mis\x1b[22m white"}, - {Case: "flash", Text: "This is white", Expected: "This \x1b[5mis\x1b[25m white"}, - {Case: "reversed", Text: "This is white", Expected: "This \x1b[7mis\x1b[27m white"}, - {Case: "double", Text: "This is white", Expected: "This \x1b[3m\x1b[5mis\x1b[25m\x1b[23m white"}, - {Case: "overline", Text: "This is white", Expected: "This \x1b[53mis\x1b[55m white"}, - } - for _, tc := range cases { - a := Ansi{} - a.InitPlain() - formattedText := a.formatText(tc.Text) - assert.Equal(t, tc.Expected, formattedText, tc.Case) - } -} - func TestGenerateFileLink(t *testing.T) { cases := []struct { Text string @@ -114,7 +89,7 @@ func TestGenerateFileLink(t *testing.T) { {Text: `[Windows](file:C:/Windows)`, Expected: "\x1b]8;;file:C:/Windows\x1b\\Windows\x1b]8;;\x1b\\"}, } for _, tc := range cases { - a := Ansi{} + a := AnsiWriter{} a.Init(shell.PWSH) hyperlinkText := a.GenerateHyperlink(tc.Text) assert.Equal(t, tc.Expected, hyperlinkText) diff --git a/src/color/writer_test.go b/src/color/ansi_writer_test.go similarity index 79% rename from src/color/writer_test.go rename to src/color/ansi_writer_test.go index 4b92bbcb..a1599c68 100644 --- a/src/color/writer_test.go +++ b/src/color/ansi_writer_test.go @@ -17,6 +17,31 @@ func TestWriteANSIColors(t *testing.T) { Parent *Color TerminalBackground string }{ + { + Case: "Bold", + Input: "test", + Expected: "\x1b[1m\x1b[30mtest\x1b[22m\x1b[0m", + Colors: &Color{Foreground: "black", Background: ParentBackground}, + }, + { + Case: "Bold with color override", + Input: "<#ffffff>test", + Expected: "\x1b[1m\x1b[30m\x1b[38;2;255;255;255mtest\x1b[30m\x1b[22m\x1b[0m", + Colors: &Color{Foreground: "black", Background: ParentBackground}, + }, + { + Case: "Bold with color override, flavor 2", + Input: "<#ffffff>test", + Expected: "\x1b[38;2;255;255;255m\x1b[1mtest\x1b[22m\x1b[0m", + Colors: &Color{Foreground: "black", Background: ParentBackground}, + }, + + { + Case: "Double override", + Input: "<#ffffff>jan@<#ffffff>Jans-MBP", + Expected: "\x1b[48;2;255;87;51m\x1b[38;2;255;255;255mjan\x1b[32m@\x1b[38;2;255;255;255mJans-MBP\x1b[0m", + Colors: &Color{Foreground: "green", Background: "#FF5733"}, + }, { Case: "No color override", Input: "test", @@ -47,28 +72,28 @@ func TestWriteANSIColors(t *testing.T) { { Case: "Inherit override foreground", Input: "hello world", - Expected: "\x1b[47m\x1b[30mhello \x1b[0m\x1b[47m\x1b[33mworld\x1b[0m", + Expected: "\x1b[47m\x1b[30mhello \x1b[33mworld\x1b[0m", Colors: &Color{Foreground: "black", Background: "white"}, Parent: &Color{Foreground: "yellow", Background: "red"}, }, { Case: "Inherit override background", Input: "hello world", - Expected: "\x1b[47m\x1b[30mhello \x1b[0m\x1b[41m\x1b[30mworld\x1b[0m", + Expected: "\x1b[47m\x1b[30mhello \x1b[41mworld\x1b[0m", Colors: &Color{Foreground: "black", Background: "white"}, Parent: &Color{Foreground: "yellow", Background: "red"}, }, { Case: "Inherit override background, no foreground specified", Input: "hello <,parentBackground>world", - Expected: "\x1b[47m\x1b[30mhello \x1b[0m\x1b[41m\x1b[30mworld\x1b[0m", + Expected: "\x1b[47m\x1b[30mhello \x1b[41mworld\x1b[0m", Colors: &Color{Foreground: "black", Background: "white"}, Parent: &Color{Foreground: "yellow", Background: "red"}, }, { Case: "Inherit no parent foreground", Input: "hello world", - Expected: "\x1b[47m\x1b[30mhello \x1b[0m\x1b[47;49m\x1b[7mworld\x1b[0m", + Expected: "\x1b[47m\x1b[30mhello \x1b[47;49m\x1b[7mworld\x1b[0m", Colors: &Color{Foreground: "black", Background: "white"}, }, { @@ -80,21 +105,21 @@ func TestWriteANSIColors(t *testing.T) { { Case: "Inherit override both", Input: "hello world", - Expected: "\x1b[47m\x1b[30mhello \x1b[0m\x1b[41m\x1b[33mworld\x1b[0m", + Expected: "\x1b[47m\x1b[30mhello \x1b[41m\x1b[33mworld\x1b[0m", Colors: &Color{Foreground: "black", Background: "white"}, Parent: &Color{Foreground: "yellow", Background: "red"}, }, { Case: "Inherit override both inverted", Input: "hello world", - Expected: "\x1b[47m\x1b[30mhello \x1b[0m\x1b[43m\x1b[31mworld\x1b[0m", + Expected: "\x1b[47m\x1b[30mhello \x1b[43m\x1b[31mworld\x1b[0m", Colors: &Color{Foreground: "black", Background: "white"}, Parent: &Color{Foreground: "yellow", Background: "red"}, }, { Case: "Inline override", Input: "hello, world, rabbit", - Expected: "\x1b[47m\x1b[30mhello, \x1b[0m\x1b[47m\x1b[31mworld\x1b[0m\x1b[47m\x1b[30m, rabbit\x1b[0m", + Expected: "\x1b[47m\x1b[30mhello, \x1b[31mworld\x1b[30m, rabbit\x1b[0m", Colors: &Color{Foreground: "black", Background: "white"}, }, { @@ -106,15 +131,9 @@ func TestWriteANSIColors(t *testing.T) { { Case: "Transparent foreground override", Input: "hello <#ffffff>world", - Expected: "\x1b[32mhello \x1b[0m\x1b[38;2;255;255;255mworld\x1b[0m", + Expected: "\x1b[32mhello \x1b[38;2;255;255;255mworld\x1b[0m", Colors: &Color{Foreground: "green", Background: Transparent}, }, - { - Case: "Double override", - Input: "<#ffffff>jan@<#ffffff>Jans-MBP", - Expected: "\x1b[48;2;255;87;51m\x1b[38;2;255;255;255mjan\x1b[0m\x1b[48;2;255;87;51m\x1b[32m@\x1b[0m\x1b[48;2;255;87;51m\x1b[38;2;255;255;255mJans-MBP\x1b[0m", - Colors: &Color{Foreground: "green", Background: "#FF5733"}, - }, { Case: "No foreground", Input: "test", @@ -130,7 +149,7 @@ func TestWriteANSIColors(t *testing.T) { { Case: "Transparent foreground, terminal background set", Input: "test", - Expected: "\x1b[48;2;255;87;51m\x1b[38;2;33;47;60mtest\x1b[0m", + Expected: "\x1b[38;2;33;47;60m\x1b[48;2;255;87;51mtest\x1b[0m", Colors: &Color{Foreground: Transparent, Background: "#FF5733"}, TerminalBackground: "#212F3C", }, @@ -140,6 +159,18 @@ func TestWriteANSIColors(t *testing.T) { Expected: "\x1b[47m\x1b[30mtest\x1b[0m", Colors: &Color{Foreground: "black", Background: "white"}, }, + { + Case: "Background for background override", + Input: "<,background>test", + Expected: "\x1b[47m\x1b[30mtest\x1b[0m", + Colors: &Color{Foreground: "black", Background: "white"}, + }, + { + Case: "Google", + Input: "Google", + Expected: "\x1b[47m\x1b[34mG\x1b[40m\x1b[30m\x1b[47m\x1b[31mo\x1b[40m\x1b[30m\x1b[47m\x1b[33mo\x1b[40m\x1b[30m\x1b[47m\x1b[34mg\x1b[40m\x1b[30m\x1b[47m\x1b[32ml\x1b[40m\x1b[30m\x1b[47m\x1b[31me\x1b[0m", //nolint: lll + Colors: &Color{Foreground: "black", Background: "black"}, + }, { Case: "Foreground for background override", Input: "test", @@ -152,36 +183,22 @@ func TestWriteANSIColors(t *testing.T) { Expected: "\x1b[40m\x1b[37mtest\x1b[0m", Colors: &Color{Foreground: "black", Background: "white"}, }, - { - Case: "Background for background override", - Input: "<,background>test", - Expected: "\x1b[47m\x1b[30mtest\x1b[0m", - Colors: &Color{Foreground: "black", Background: "white"}, - }, { Case: "Background for foreground override", Input: "<,foreground>test", Expected: "\x1b[40m\x1b[30mtest\x1b[0m", Colors: &Color{Foreground: "black", Background: "white"}, }, - { - Case: "Google", - Input: "Google", - Expected: "\x1b[47m\x1b[34mG\x1b[0m\x1b[47m\x1b[31mo\x1b[0m\x1b[47m\x1b[33mo\x1b[0m\x1b[47m\x1b[34mg\x1b[0m\x1b[47m\x1b[32ml\x1b[0m\x1b[47m\x1b[31me\x1b[0m", - Colors: &Color{Foreground: "black", Background: "black"}, - }, } for _, tc := range cases { - ansi := &Ansi{} - ansi.Init(shell.PWSH) renderer := &AnsiWriter{ - Ansi: ansi, ParentColors: []*Color{tc.Parent}, Colors: tc.Colors, TerminalBackground: tc.TerminalBackground, AnsiColors: &DefaultColors{}, } + renderer.Init(shell.GENERIC) renderer.Write(tc.Colors.Background, tc.Colors.Foreground, tc.Input) got, _ := renderer.String() assert.Equal(t, tc.Expected, got, tc.Case) diff --git a/src/color/colors.go b/src/color/colors.go index d8316065..95f7a2c6 100644 --- a/src/color/colors.go +++ b/src/color/colors.go @@ -10,6 +10,40 @@ import ( "github.com/gookit/color" ) +// AnsiColors is the interface that wraps AnsiColorFromString method. +// +// AnsiColorFromString gets the ANSI color code for a given color string. +// This can include a valid hex color in the format `#FFFFFF`, +// but also a name of one of the first 16 ANSI colors like `lightBlue`. +type AnsiColors interface { + AnsiColorFromString(colorString string, isBackground bool) AnsiColor +} + +// AnsiColor is an ANSI color code ready to be printed to the console. +// Example: "38;2;255;255;255", "48;2;255;255;255", "31", "95". +type AnsiColor string + +const ( + emptyAnsiColor = AnsiColor("") + transparentAnsiColor = AnsiColor(Transparent) +) + +func (c AnsiColor) IsEmpty() bool { + return c == emptyAnsiColor +} + +func (c AnsiColor) IsTransparent() bool { + return c == transparentAnsiColor +} + +func (c AnsiColor) ToForeground() AnsiColor { + colorString := string(c) + if strings.HasPrefix(colorString, "38;") { + return AnsiColor(strings.Replace(colorString, "38;", "48;", 1)) + } + return c +} + func MakeColors(palette Palette, cacheEnabled bool, accentColor string, env platform.Environment) (colors AnsiColors) { defaultColors := &DefaultColors{} defaultColors.SetAccentColor(env, accentColor) diff --git a/src/color/plain_writer.go b/src/color/plain_writer.go deleted file mode 100644 index 067b3edd..00000000 --- a/src/color/plain_writer.go +++ /dev/null @@ -1,44 +0,0 @@ -package color - -import ( - "strings" - - "github.com/jandedobbeleer/oh-my-posh/regex" -) - -// PlainWriter writes a plain string -type PlainWriter struct { - Ansi *Ansi - - builder strings.Builder - length int -} - -func (a *PlainWriter) SetColors(background, foreground string) {} -func (a *PlainWriter) SetParentColors(background, foreground string) {} - -func (a *PlainWriter) Write(background, foreground, text string) { - if len(text) == 0 { - return - } - writeAndRemoveText := func(text, textToRemove, parentText string) string { - a.length += a.Ansi.MeasureText(text) - a.builder.WriteString(text) - return strings.Replace(parentText, textToRemove, "", 1) - } - match := regex.FindAllNamedRegexMatch(colorRegex, text) - for i := range match { - escapedTextSegment := match[i]["text"] - innerText := match[i]["content"] - textBeforeColorOverride := strings.Split(text, escapedTextSegment)[0] - text = writeAndRemoveText(textBeforeColorOverride, textBeforeColorOverride, text) - text = writeAndRemoveText(innerText, escapedTextSegment, text) - } - a.length += a.Ansi.MeasureText(text) - a.builder.WriteString(text) -} - -func (a *PlainWriter) String() (string, int) { - defer a.builder.Reset() - return a.builder.String(), a.length -} diff --git a/src/color/text.go b/src/color/text.go index e5ddad58..b47f68be 100644 --- a/src/color/text.go +++ b/src/color/text.go @@ -12,37 +12,37 @@ func init() { //nolint:gochecknoinits runewidth.DefaultCondition.EastAsianWidth = false } -func (ansi *Ansi) MeasureText(text string) int { +func (a *AnsiWriter) MeasureText(text string) int { // skip strings with ANSI if !strings.Contains(text, "\x1b") { - text = ansi.TrimEscapeSequences(text) + text = a.TrimEscapeSequences(text) length := runewidth.StringWidth(text) return length } if strings.Contains(text, "\x1b]8;;") { - matches := regex.FindAllNamedRegexMatch(ansi.hyperlinkRegex, text) + matches := regex.FindAllNamedRegexMatch(a.hyperlinkRegex, text) for _, match := range matches { text = strings.ReplaceAll(text, match["STR"], match["TEXT"]) } } - text = ansi.TrimAnsi(text) - text = ansi.TrimEscapeSequences(text) + text = a.TrimAnsi(text) + text = a.TrimEscapeSequences(text) length := runewidth.StringWidth(text) return length } -func (ansi *Ansi) TrimAnsi(text string) string { +func (a *AnsiWriter) TrimAnsi(text string) string { if len(text) == 0 || !strings.Contains(text, "\x1b") { return text } return regex.ReplaceAllString(AnsiRegex, text, "") } -func (ansi *Ansi) TrimEscapeSequences(text string) string { +func (a *AnsiWriter) TrimEscapeSequences(text string) string { if len(text) == 0 { return text } - text = strings.ReplaceAll(text, ansi.escapeLeft, "") - text = strings.ReplaceAll(text, ansi.escapeRight, "") + text = strings.ReplaceAll(text, a.escapeLeft, "") + text = strings.ReplaceAll(text, a.escapeRight, "") return text } diff --git a/src/color/text_test.go b/src/color/text_test.go index e192a693..63935277 100644 --- a/src/color/text_test.go +++ b/src/color/text_test.go @@ -33,18 +33,18 @@ func TestMeasureText(t *testing.T) { env.On("TemplateCache").Return(&platform.TemplateCache{ Env: make(map[string]string), }) - shells := []string{shell.BASH, shell.ZSH, shell.PLAIN} + shells := []string{shell.BASH, shell.ZSH, shell.GENERIC} for _, shell := range shells { for _, tc := range cases { - ansi := &Ansi{} - ansi.Init(shell) + ansiWriter := &AnsiWriter{} + ansiWriter.Init(shell) tmpl := &template.Text{ Template: tc.Template, Env: env, } text, _ := tmpl.Render() - text = ansi.GenerateHyperlink(text) - got := ansi.MeasureText(text) + text = ansiWriter.GenerateHyperlink(text) + got := ansiWriter.MeasureText(text) assert.Equal(t, tc.Expected, got, fmt.Sprintf("%s: %s", shell, tc.Case)) } } diff --git a/src/color/writer.go b/src/color/writer.go deleted file mode 100644 index 9baabc35..00000000 --- a/src/color/writer.go +++ /dev/null @@ -1,247 +0,0 @@ -package color - -import ( - "fmt" - "strings" - - "github.com/jandedobbeleer/oh-my-posh/regex" -) - -const ( - colorRegex = `<(?P[^,>]+)?,?(?P[^>]+)?>(?P[^<]*)<\/>` -) - -type Writer interface { - Write(background, foreground, text string) - String() (string, int) - SetColors(background, foreground string) - SetParentColors(background, foreground string) -} - -// AnsiWriter writes colorized ANSI strings -type AnsiWriter struct { - Ansi *Ansi - TerminalBackground string - Colors *Color - ParentColors []*Color - AnsiColors AnsiColors - - builder strings.Builder - length int -} - -type Color struct { - Background string - Foreground string -} - -// AnsiColors is the interface that wraps AnsiColorFromString method. -// -// AnsiColorFromString gets the ANSI color code for a given color string. -// This can include a valid hex color in the format `#FFFFFF`, -// but also a name of one of the first 16 ANSI colors like `lightBlue`. -type AnsiColors interface { - AnsiColorFromString(colorString string, isBackground bool) AnsiColor -} - -// AnsiColor is an ANSI color code ready to be printed to the console. -// Example: "38;2;255;255;255", "48;2;255;255;255", "31", "95". -type AnsiColor string - -const ( - emptyAnsiColor = AnsiColor("") - transparentAnsiColor = AnsiColor(Transparent) -) - -func (c AnsiColor) IsEmpty() bool { - return c == emptyAnsiColor -} - -func (c AnsiColor) IsTransparent() bool { - return c == transparentAnsiColor -} - -func (c AnsiColor) ToForeground() AnsiColor { - colorString := string(c) - if strings.HasPrefix(colorString, "38;") { - return AnsiColor(strings.Replace(colorString, "38;", "48;", 1)) - } - return c -} - -const ( - // Transparent implies a transparent color - Transparent = "transparent" - // Accent is the OS accent color - Accent = "accent" - // ParentBackground takes the previous segment's background color - ParentBackground = "parentBackground" - // ParentForeground takes the previous segment's color - ParentForeground = "parentForeground" - // Background takes the current segment's background color - Background = "background" - // Foreground takes the current segment's foreground color - Foreground = "foreground" -) - -func (a *AnsiWriter) SetColors(background, foreground string) { - a.Colors = &Color{ - Background: background, - Foreground: foreground, - } -} - -func (a *AnsiWriter) SetParentColors(background, foreground string) { - if a.ParentColors == nil { - a.ParentColors = make([]*Color, 0) - } - a.ParentColors = append([]*Color{{ - Background: background, - Foreground: foreground, - }}, a.ParentColors...) -} - -func (a *AnsiWriter) getAnsiFromColorString(colorString string, isBackground bool) AnsiColor { - return a.AnsiColors.AnsiColorFromString(colorString, isBackground) -} - -func (a *AnsiWriter) writeColoredText(background, foreground AnsiColor, text string) { - // Avoid emitting empty strings with color codes - if text == "" || (foreground.IsTransparent() && background.IsTransparent()) { - return - } - a.length += a.Ansi.MeasureText(text) - // default to white fg if empty, empty backgrond is supported - if foreground.IsEmpty() { - foreground = a.getAnsiFromColorString("white", false) - } - if foreground.IsTransparent() && !background.IsEmpty() && len(a.TerminalBackground) != 0 { - bgAnsiColor := a.getAnsiFromColorString(a.TerminalBackground, false) - coloredText := fmt.Sprintf(a.Ansi.colorFull, background.ToForeground(), bgAnsiColor, text) - a.builder.WriteString(coloredText) - return - } - if foreground.IsTransparent() && !background.IsEmpty() { - coloredText := fmt.Sprintf(a.Ansi.colorTransparent, background, text) - a.builder.WriteString(coloredText) - return - } else if background.IsEmpty() || background.IsTransparent() { - coloredText := fmt.Sprintf(a.Ansi.colorSingle, foreground, text) - a.builder.WriteString(coloredText) - return - } - coloredText := fmt.Sprintf(a.Ansi.colorFull, background, foreground, text) - a.builder.WriteString(coloredText) -} - -func (a *AnsiWriter) writeAndRemoveText(background, foreground AnsiColor, text, textToRemove, parentText string) string { - a.writeColoredText(background, foreground, text) - return strings.Replace(parentText, textToRemove, "", 1) -} - -func (a *AnsiWriter) Write(background, foreground, text string) { - if len(text) == 0 { - return - } - - bgAnsi, fgAnsi := a.asAnsiColors(background, foreground) - text = a.Ansi.formatText(text) - text = a.Ansi.GenerateHyperlink(text) - - // first we match for any potentially valid colors enclosed in <> - // i.e., find color overrides - overrides := regex.FindAllNamedRegexMatch(colorRegex, text) - for _, override := range overrides { - fgOverride := override["foreground"] - bgOverride := override["background"] - if fgOverride == Transparent && len(bgOverride) == 0 { - bgOverride = background - } - bgOverrideAnsi, fgOverrideAnsi := a.asAnsiColors(bgOverride, fgOverride) - // set colors if they are empty - if bgOverrideAnsi.IsEmpty() { - bgOverrideAnsi = bgAnsi - } - if fgOverrideAnsi.IsEmpty() { - fgOverrideAnsi = fgAnsi - } - escapedTextSegment := override["text"] - innerText := override["content"] - textBeforeColorOverride := strings.Split(text, escapedTextSegment)[0] - text = a.writeAndRemoveText(bgAnsi, fgAnsi, textBeforeColorOverride, textBeforeColorOverride, text) - text = a.writeAndRemoveText(bgOverrideAnsi, fgOverrideAnsi, innerText, escapedTextSegment, text) - } - // color the remaining part of text with background and foreground - a.writeColoredText(bgAnsi, fgAnsi, text) -} - -func (a *AnsiWriter) asAnsiColors(background, foreground string) (AnsiColor, AnsiColor) { - background = a.expandKeyword(background) - foreground = a.expandKeyword(foreground) - inverted := foreground == Transparent && len(background) != 0 - backgroundAnsi := a.getAnsiFromColorString(background, !inverted) - foregroundAnsi := a.getAnsiFromColorString(foreground, false) - return backgroundAnsi, foregroundAnsi -} - -func (a *AnsiWriter) isKeyword(color string) bool { - switch color { - case Transparent, ParentBackground, ParentForeground, Background, Foreground: - return true - default: - return false - } -} - -func (a *AnsiWriter) expandKeyword(keyword string) string { - resolveParentColor := func(keyword string) string { - for _, color := range a.ParentColors { - if color == nil { - return Transparent - } - switch keyword { - case ParentBackground: - keyword = color.Background - case ParentForeground: - keyword = color.Foreground - default: - if len(keyword) == 0 { - return Transparent - } - return keyword - } - } - if len(keyword) == 0 { - return Transparent - } - return keyword - } - resolveKeyword := func(keyword string) string { - switch { - case keyword == Background && a.Colors != nil: - return a.Colors.Background - case keyword == Foreground && a.Colors != nil: - return a.Colors.Foreground - case (keyword == ParentBackground || keyword == ParentForeground) && a.ParentColors != nil: - return resolveParentColor(keyword) - default: - return Transparent - } - } - for ok := a.isKeyword(keyword); ok; ok = a.isKeyword(keyword) { - resolved := resolveKeyword(keyword) - if resolved == keyword { - break - } - keyword = resolved - } - return keyword -} - -func (a *AnsiWriter) String() (string, int) { - defer func() { - a.length = 0 - a.builder.Reset() - }() - return a.builder.String(), a.length -} diff --git a/src/console/title.go b/src/console/title.go deleted file mode 100644 index e38d690f..00000000 --- a/src/console/title.go +++ /dev/null @@ -1,30 +0,0 @@ -package console - -import ( - "github.com/jandedobbeleer/oh-my-posh/color" - "github.com/jandedobbeleer/oh-my-posh/platform" - "github.com/jandedobbeleer/oh-my-posh/template" -) - -type Title struct { - Env platform.Environment - Ansi *color.Ansi - Template string -} - -func (t *Title) GetTitle() string { - title := t.getTitleTemplateText() - title = t.Ansi.TrimAnsi(title) - return t.Ansi.Title(title) -} - -func (t *Title) getTitleTemplateText() string { - tmpl := &template.Text{ - Template: t.Template, - Env: t.Env, - } - if text, err := tmpl.Render(); err == nil { - return text - } - return "" -} diff --git a/src/console/title_test.go b/src/console/title_test.go deleted file mode 100644 index 4f8c4954..00000000 --- a/src/console/title_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package console - -import ( - "testing" - - "github.com/jandedobbeleer/oh-my-posh/color" - "github.com/jandedobbeleer/oh-my-posh/mock" - "github.com/jandedobbeleer/oh-my-posh/platform" - - "github.com/stretchr/testify/assert" -) - -func TestGetTitle(t *testing.T) { - cases := []struct { - Template string - Root bool - User string - Cwd string - PathSeparator string - ShellName string - Expected string - }{ - { - Template: "{{.Env.USERDOMAIN}} :: {{.PWD}}{{if .Root}} :: Admin{{end}} :: {{.Shell}}", - Cwd: "C:\\vagrant", - PathSeparator: "\\", - ShellName: "PowerShell", - Root: true, - Expected: "\x1b]0;MyCompany :: C:\\vagrant :: Admin :: PowerShell\a", - }, - { - Template: "{{.Folder}}{{if .Root}} :: Admin{{end}} :: {{.Shell}}", - Cwd: "C:\\vagrant", - PathSeparator: "\\", - ShellName: "PowerShell", - Expected: "\x1b]0;vagrant :: PowerShell\a", - }, - { - Template: "{{.UserName}}@{{.HostName}}{{if .Root}} :: Admin{{end}} :: {{.Shell}}", - Root: true, - User: "MyUser", - PathSeparator: "\\", - ShellName: "PowerShell", - Expected: "\x1b]0;MyUser@MyHost :: Admin :: PowerShell\a", - }, - } - - for _, tc := range cases { - env := new(mock.MockedEnvironment) - env.On("Pwd").Return(tc.Cwd) - env.On("Home").Return("/usr/home") - env.On("PathSeparator").Return(tc.PathSeparator) - env.On("TemplateCache").Return(&platform.TemplateCache{ - Env: map[string]string{ - "USERDOMAIN": "MyCompany", - }, - Shell: tc.ShellName, - UserName: "MyUser", - Root: tc.Root, - HostName: "MyHost", - PWD: tc.Cwd, - Folder: "vagrant", - }) - ansi := &color.Ansi{} - ansi.InitPlain() - ct := &Title{ - Env: env, - Ansi: ansi, - Template: tc.Template, - } - got := ct.GetTitle() - assert.Equal(t, tc.Expected, got) - } -} - -func TestGetConsoleTitleIfGethostnameReturnsError(t *testing.T) { - cases := []struct { - Template string - Root bool - User string - Cwd string - PathSeparator string - ShellName string - Expected string - }{ - { - Template: "Not using Host only {{.UserName}} and {{.Shell}}", - User: "MyUser", - PathSeparator: "\\", - ShellName: "PowerShell", - Expected: "\x1b]0;Not using Host only MyUser and PowerShell\a", - }, - { - Template: "{{.UserName}}@{{.HostName}} :: {{.Shell}}", - User: "MyUser", - PathSeparator: "\\", - ShellName: "PowerShell", - Expected: "\x1b]0;MyUser@ :: PowerShell\a", - }, - { - Template: "\x1b[93m[\x1b[39m\x1b[96mconsole-title\x1b[39m\x1b[96m ≡\x1b[39m\x1b[31m +0\x1b[39m\x1b[31m ~1\x1b[39m\x1b[31m -0\x1b[39m\x1b[31m !\x1b[39m\x1b[93m]\x1b[39m", - Expected: "\x1b]0;[console-title ≡ +0 ~1 -0 !]\a", - }, - } - - for _, tc := range cases { - env := new(mock.MockedEnvironment) - env.On("Pwd").Return(tc.Cwd) - env.On("Home").Return("/usr/home") - env.On("TemplateCache").Return(&platform.TemplateCache{ - Env: map[string]string{ - "USERDOMAIN": "MyCompany", - }, - Shell: tc.ShellName, - UserName: "MyUser", - Root: tc.Root, - HostName: "", - }) - ansi := &color.Ansi{} - ansi.InitPlain() - ct := &Title{ - Env: env, - Ansi: ansi, - Template: tc.Template, - } - got := ct.GetTitle() - assert.Equal(t, tc.Expected, got) - } -} diff --git a/src/engine/block.go b/src/engine/block.go index 2d73a180..96d345fb 100644 --- a/src/engine/block.go +++ b/src/engine/block.go @@ -53,26 +53,22 @@ type Block struct { env platform.Environment writer color.Writer - ansi *color.Ansi activeSegment *Segment previousActiveSegment *Segment } -func (b *Block) Init(env platform.Environment, writer color.Writer, ansi *color.Ansi) { +func (b *Block) Init(env platform.Environment, writer color.Writer) { b.env = env b.writer = writer - b.ansi = ansi b.executeSegmentLogic() } func (b *Block) InitPlain(env platform.Environment, config *Config) { - b.ansi = &color.Ansi{} - b.ansi.InitPlain() b.writer = &color.AnsiWriter{ - Ansi: b.ansi, TerminalBackground: shell.ConsoleBackgroundColor(env, config.TerminalBackground), AnsiColors: config.MakeColors(), } + b.writer.Init(shell.GENERIC) b.env = env b.executeSegmentLogic() } diff --git a/src/engine/engine.go b/src/engine/engine.go index e188f71f..f4bf97b8 100644 --- a/src/engine/engine.go +++ b/src/engine/engine.go @@ -6,19 +6,16 @@ import ( "time" "github.com/jandedobbeleer/oh-my-posh/color" - "github.com/jandedobbeleer/oh-my-posh/console" "github.com/jandedobbeleer/oh-my-posh/platform" "github.com/jandedobbeleer/oh-my-posh/shell" "github.com/jandedobbeleer/oh-my-posh/template" ) type Engine struct { - Config *Config - Env platform.Environment - Writer color.Writer - Ansi *color.Ansi - ConsoleTitle *console.Title - Plain bool + Config *Config + Env platform.Environment + Writer color.Writer + Plain bool console strings.Builder currentLineLength int @@ -30,13 +27,6 @@ func (e *Engine) write(text string) { e.console.WriteString(text) } -func (e *Engine) writeANSI(text string) { - if e.Plain { - return - } - e.console.WriteString(text) -} - func (e *Engine) string() string { text := e.console.String() e.console.Reset() @@ -71,9 +61,9 @@ func (e *Engine) PrintPrimary() string { e.renderBlock(block) } if len(e.Config.ConsoleTitleTemplate) > 0 { - e.writeANSI(e.ConsoleTitle.GetTitle()) + title := e.getTitleTemplateText() + e.write(e.Writer.FormatTitle(title)) } - e.writeANSI(e.Ansi.ColorReset()) if e.Config.FinalSpace { e.write(" ") } @@ -88,7 +78,7 @@ func (e *Engine) printPWD() { cwd := e.Env.Pwd() // Backwards compatibility for deprecated OSC99 if e.Config.OSC99 { - e.writeANSI(e.Ansi.ConsolePwd(color.OSC99, "", "", cwd)) + e.write(e.Writer.ConsolePwd(color.OSC99, "", "", cwd)) return } // Allow template logic to define when to enable the PWD (when supported) @@ -102,13 +92,13 @@ func (e *Engine) printPWD() { } user := e.Env.User() host, _ := e.Env.Host() - e.writeANSI(e.Ansi.ConsolePwd(pwdType, user, host, cwd)) + e.write(e.Writer.ConsolePwd(pwdType, user, host, cwd)) } func (e *Engine) newline() { // WARP terminal will remove \n from the prompt, so we hack a newline in if e.isWarp() { - e.write(e.Ansi.LineBreak()) + e.write(e.Writer.LineBreak()) } else { e.write("\n") } @@ -140,6 +130,17 @@ func (e *Engine) shouldFill(block *Block, length int) (string, bool) { return strings.Repeat(filler, repeat), true } +func (e *Engine) getTitleTemplateText() string { + tmpl := &template.Text{ + Template: e.Config.ConsoleTitleTemplate, + Env: e.Env, + } + if text, err := tmpl.Render(); err == nil { + return text + } + return "" +} + func (e *Engine) renderBlock(block *Block) { defer func() { // Due to a bug in PowerShell, the end of the line needs to be cleared. @@ -147,7 +148,7 @@ func (e *Engine) renderBlock(block *Block) { // color of the line above the new input line. Clearing the line fixes this, // but can hopefully one day be removed when this is resolved natively. if e.Env.Shell() == shell.PWSH || e.Env.Shell() == shell.PWSH5 { - e.writeANSI(e.Ansi.ClearAfter()) + e.write(e.Writer.ClearAfter()) } }() // when in bash, for rprompt blocks we need to write plain @@ -155,7 +156,7 @@ func (e *Engine) renderBlock(block *Block) { if e.Env.Shell() == shell.BASH && (block.Type == RPrompt || block.Alignment == Right) { block.InitPlain(e.Env, e.Config) } else { - block.Init(e.Env, e.Writer, e.Ansi) + block.Init(e.Env, e.Writer) } if !block.Enabled() { return @@ -171,7 +172,7 @@ func (e *Engine) renderBlock(block *Block) { e.newline() case Prompt: if block.VerticalOffset != 0 { - e.writeANSI(e.Ansi.ChangeLine(block.VerticalOffset)) + e.write(e.Writer.ChangeLine(block.VerticalOffset)) } if block.Alignment == Left { @@ -208,17 +209,16 @@ func (e *Engine) renderBlock(block *Block) { return } // this can contain ANSI escape sequences - ansi := e.Ansi + writer := e.Writer if e.Env.Shell() == shell.BASH { - ansi = &color.Ansi{} - ansi.InitPlain() + writer.Init(shell.GENERIC) } - prompt := ansi.CarriageForward() - prompt += ansi.GetCursorForRightWrite(length, block.HorizontalOffset) + prompt := writer.CarriageForward() + prompt += writer.GetCursorForRightWrite(length, block.HorizontalOffset) prompt += text e.currentLineLength = 0 if e.Env.Shell() == shell.BASH { - prompt = e.Ansi.FormatText(prompt) + prompt = e.Writer.FormatText(prompt) } e.write(prompt) case RPrompt: @@ -234,9 +234,7 @@ func (e *Engine) PrintDebug(startTime time.Time, version string) string { e.write("\n\x1b[1mSegments:\x1b[0m\n\n") // console title timing titleStartTime := time.Now() - title := e.ConsoleTitle.GetTitle() - title = strings.TrimPrefix(title, "\x1b]0;") - title = strings.TrimSuffix(title, "\a") + title := e.getTitleTemplateText() segmentTiming := &SegmentTiming{ name: "ConsoleTitle", nameLength: 12, @@ -247,7 +245,7 @@ func (e *Engine) PrintDebug(startTime time.Time, version string) string { segmentTimings = append(segmentTimings, segmentTiming) // loop each segments of each blocks for _, block := range e.Config.Blocks { - block.Init(e.Env, e.Writer, e.Ansi) + block.Init(e.Env, e.Writer) longestSegmentName, timings := block.Debug() segmentTimings = append(segmentTimings, timings...) if longestSegmentName > largestSegmentNameLength { @@ -278,11 +276,11 @@ func (e *Engine) print() string { } // Warp doesn't support RPROMPT so we need to write it manually if e.isWarp() { - e.write(e.Ansi.SaveCursorPosition()) - e.write(e.Ansi.CarriageForward()) - e.write(e.Ansi.GetCursorForRightWrite(e.rpromptLength, 0)) + e.write(e.Writer.SaveCursorPosition()) + e.write(e.Writer.CarriageForward()) + e.write(e.Writer.GetCursorForRightWrite(e.rpromptLength, 0)) e.write(e.rprompt) - e.write(e.Ansi.RestoreCursorPosition()) + e.write(e.Writer.RestoreCursorPosition()) // escape double quotes contained in the prompt prompt := fmt.Sprintf("PS1=\"%s\"", strings.ReplaceAll(e.string(), `"`, `\"`)) return prompt @@ -291,29 +289,29 @@ func (e *Engine) print() string { prompt := fmt.Sprintf("PS1=\"%s\"", strings.ReplaceAll(e.string(), `"`, `\"`)) prompt += fmt.Sprintf("\nRPROMPT=\"%s\"", e.rprompt) return prompt - case shell.PWSH, shell.PWSH5, shell.PLAIN, shell.NU: + case shell.PWSH, shell.PWSH5, shell.GENERIC, shell.NU: if !e.canWriteRightBlock(true) { break } - e.write(e.Ansi.SaveCursorPosition()) - e.write(e.Ansi.CarriageForward()) - e.write(e.Ansi.GetCursorForRightWrite(e.rpromptLength, 0)) + e.write(e.Writer.SaveCursorPosition()) + e.write(e.Writer.CarriageForward()) + e.write(e.Writer.GetCursorForRightWrite(e.rpromptLength, 0)) e.write(e.rprompt) - e.write(e.Ansi.RestoreCursorPosition()) + e.write(e.Writer.RestoreCursorPosition()) case shell.BASH: if !e.canWriteRightBlock(true) { break } // in bash, the entire rprompt needs to be escaped for the prompt to be interpreted correctly // see https://github.com/jandedobbeleer/oh-my-posh/pull/2398 - ansi := &color.Ansi{} - ansi.InitPlain() - prompt := ansi.SaveCursorPosition() - prompt += ansi.CarriageForward() - prompt += ansi.GetCursorForRightWrite(e.rpromptLength, 0) + writer := &color.AnsiWriter{} + writer.Init(shell.GENERIC) + prompt := writer.SaveCursorPosition() + prompt += writer.CarriageForward() + prompt += writer.GetCursorForRightWrite(e.rpromptLength, 0) prompt += e.rprompt - prompt += ansi.RestoreCursorPosition() - prompt = e.Ansi.FormatText(prompt) + prompt += writer.RestoreCursorPosition() + prompt = e.Writer.FormatText(prompt) e.write(prompt) } @@ -345,8 +343,8 @@ func (e *Engine) PrintTooltip(tip string) string { Segments: []*Segment{tooltip}, } switch e.Env.Shell() { - case shell.ZSH, shell.CMD, shell.FISH, shell.PLAIN: - block.Init(e.Env, e.Writer, e.Ansi) + case shell.ZSH, shell.CMD, shell.FISH, shell.GENERIC: + block.Init(e.Env, e.Writer) if !block.Enabled() { return "" } @@ -358,9 +356,9 @@ func (e *Engine) PrintTooltip(tip string) string { return "" } text, length := block.RenderSegments() - e.write(e.Ansi.ClearAfter()) - e.write(e.Ansi.CarriageForward()) - e.write(e.Ansi.GetCursorForRightWrite(length, 0)) + e.write(e.Writer.ClearAfter()) + e.write(e.Writer.CarriageForward()) + e.write(e.Writer.GetCursorForRightWrite(length, 0)) e.write(text) return e.string() } @@ -434,7 +432,7 @@ func (e *Engine) PrintExtraPrompt(promptType ExtraPromptType) string { return prompt } return str - case shell.PWSH, shell.PWSH5, shell.CMD, shell.BASH, shell.FISH, shell.NU, shell.PLAIN: + case shell.PWSH, shell.PWSH5, shell.CMD, shell.BASH, shell.FISH, shell.NU, shell.GENERIC: // Return the string and empty our buffer str, _ := e.Writer.String() return str @@ -455,7 +453,7 @@ func (e *Engine) PrintRPrompt() string { if block == nil { return "" } - block.Init(e.Env, e.Writer, e.Ansi) + block.Init(e.Env, e.Writer) if !block.Enabled() { return "" } diff --git a/src/engine/engine_test.go b/src/engine/engine_test.go index 474765f6..88189223 100644 --- a/src/engine/engine_test.go +++ b/src/engine/engine_test.go @@ -5,7 +5,6 @@ import ( "testing" "github.com/jandedobbeleer/oh-my-posh/color" - "github.com/jandedobbeleer/oh-my-posh/console" "github.com/jandedobbeleer/oh-my-posh/mock" "github.com/jandedobbeleer/oh-my-posh/platform" "github.com/jandedobbeleer/oh-my-posh/shell" @@ -71,15 +70,16 @@ func TestPrintPWD(t *testing.T) { Env: make(map[string]string), Shell: "shell", }) - ansi := &color.Ansi{} - ansi.InitPlain() + + writer := &color.AnsiWriter{} + writer.Init(shell.GENERIC) engine := &Engine{ Env: env, Config: &Config{ PWD: tc.PWD, OSC99: tc.OSC99, }, - Ansi: ansi, + Writer: writer, } engine.printPWD() got := engine.print() @@ -101,25 +101,16 @@ func engineRender() { cfg := LoadConfig(env) defer testClearDefaultConfig() - ansi := &color.Ansi{} - ansi.InitPlain() writerColors := cfg.MakeColors() writer := &color.AnsiWriter{ - Ansi: ansi, TerminalBackground: shell.ConsoleBackgroundColor(env, cfg.TerminalBackground), AnsiColors: writerColors, } - consoleTitle := &console.Title{ - Env: env, - Ansi: ansi, - Template: cfg.ConsoleTitleTemplate, - } + writer.Init(shell.GENERIC) engine := &Engine{ - Config: cfg, - Env: env, - Writer: writer, - ConsoleTitle: consoleTitle, - Ansi: ansi, + Config: cfg, + Env: env, + Writer: writer, } engine.PrintPrimary() @@ -130,3 +121,127 @@ func BenchmarkEngineRenderPalette(b *testing.B) { engineRender() } } + +func TestGetTitle(t *testing.T) { + cases := []struct { + Template string + Root bool + User string + Cwd string + PathSeparator string + ShellName string + Expected string + }{ + { + Template: "{{.Env.USERDOMAIN}} :: {{.PWD}}{{if .Root}} :: Admin{{end}} :: {{.Shell}}", + Cwd: "C:\\vagrant", + PathSeparator: "\\", + ShellName: "PowerShell", + Root: true, + Expected: "\x1b]0;MyCompany :: C:\\vagrant :: Admin :: PowerShell\a", + }, + { + Template: "{{.Folder}}{{if .Root}} :: Admin{{end}} :: {{.Shell}}", + Cwd: "C:\\vagrant", + PathSeparator: "\\", + ShellName: "PowerShell", + Expected: "\x1b]0;vagrant :: PowerShell\a", + }, + { + Template: "{{.UserName}}@{{.HostName}}{{if .Root}} :: Admin{{end}} :: {{.Shell}}", + Root: true, + User: "MyUser", + PathSeparator: "\\", + ShellName: "PowerShell", + Expected: "\x1b]0;MyUser@MyHost :: Admin :: PowerShell\a", + }, + } + + for _, tc := range cases { + env := new(mock.MockedEnvironment) + env.On("Pwd").Return(tc.Cwd) + env.On("Home").Return("/usr/home") + env.On("PathSeparator").Return(tc.PathSeparator) + env.On("TemplateCache").Return(&platform.TemplateCache{ + Env: map[string]string{ + "USERDOMAIN": "MyCompany", + }, + Shell: tc.ShellName, + UserName: "MyUser", + Root: tc.Root, + HostName: "MyHost", + PWD: tc.Cwd, + Folder: "vagrant", + }) + ansi := &color.AnsiWriter{} + ansi.Init(shell.GENERIC) + engine := &Engine{ + Config: &Config{ + ConsoleTitleTemplate: tc.Template, + }, + Writer: ansi, + Env: env, + } + title := engine.getTitleTemplateText() + got := ansi.FormatTitle(title) + assert.Equal(t, tc.Expected, got) + } +} + +func TestGetConsoleTitleIfGethostnameReturnsError(t *testing.T) { + cases := []struct { + Template string + Root bool + User string + Cwd string + PathSeparator string + ShellName string + Expected string + }{ + { + Template: "Not using Host only {{.UserName}} and {{.Shell}}", + User: "MyUser", + PathSeparator: "\\", + ShellName: "PowerShell", + Expected: "\x1b]0;Not using Host only MyUser and PowerShell\a", + }, + { + Template: "{{.UserName}}@{{.HostName}} :: {{.Shell}}", + User: "MyUser", + PathSeparator: "\\", + ShellName: "PowerShell", + Expected: "\x1b]0;MyUser@ :: PowerShell\a", + }, + { + Template: "\x1b[93m[\x1b[39m\x1b[96mconsole-title\x1b[39m\x1b[96m ≡\x1b[39m\x1b[31m +0\x1b[39m\x1b[31m ~1\x1b[39m\x1b[31m -0\x1b[39m\x1b[31m !\x1b[39m\x1b[93m]\x1b[39m", + Expected: "\x1b]0;[console-title ≡ +0 ~1 -0 !]\a", + }, + } + + for _, tc := range cases { + env := new(mock.MockedEnvironment) + env.On("Pwd").Return(tc.Cwd) + env.On("Home").Return("/usr/home") + env.On("TemplateCache").Return(&platform.TemplateCache{ + Env: map[string]string{ + "USERDOMAIN": "MyCompany", + }, + Shell: tc.ShellName, + UserName: "MyUser", + Root: tc.Root, + HostName: "", + }) + ansi := &color.AnsiWriter{} + ansi.Init(shell.GENERIC) + engine := &Engine{ + Config: &Config{ + ConsoleTitleTemplate: tc.Template, + }, + Writer: ansi, + Env: env, + } + title := engine.getTitleTemplateText() + got := ansi.FormatTitle(title) + assert.Equal(t, tc.Expected, got) + } +} diff --git a/src/engine/image.go b/src/engine/image.go index b64e7d5b..16b5d983 100644 --- a/src/engine/image.go +++ b/src/engine/image.go @@ -110,7 +110,7 @@ type ImageRenderer struct { CursorPadding int RPromptOffset int BgColor string - Ansi *color.Ansi + Ansi *color.AnsiWriter Path string diff --git a/src/engine/image_test.go b/src/engine/image_test.go index 3e7dcf73..f2831d19 100644 --- a/src/engine/image_test.go +++ b/src/engine/image_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/jandedobbeleer/oh-my-posh/color" + "github.com/jandedobbeleer/oh-my-posh/shell" "github.com/stretchr/testify/assert" ) @@ -32,8 +33,8 @@ func runImageTest(config, content string) (string, error) { return "", err } defer os.Remove(file.Name()) - ansi := &color.Ansi{} - ansi.InitPlain() + ansi := &color.AnsiWriter{} + ansi.Init(shell.GENERIC) image := &ImageRenderer{ AnsiString: content, Ansi: ansi, diff --git a/src/engine/new.go b/src/engine/new.go index 2eec953a..5dcc10c1 100644 --- a/src/engine/new.go +++ b/src/engine/new.go @@ -2,7 +2,6 @@ package engine import ( "github.com/jandedobbeleer/oh-my-posh/color" - "github.com/jandedobbeleer/oh-my-posh/console" "github.com/jandedobbeleer/oh-my-posh/platform" "github.com/jandedobbeleer/oh-my-posh/shell" ) @@ -17,37 +16,19 @@ func New(flags *platform.Flags) *Engine { env.Init() cfg := LoadConfig(env) - ansi := &color.Ansi{} - var writer color.Writer - if flags.Plain { - ansi.InitPlain() - writer = &color.PlainWriter{ - Ansi: ansi, - } - } else { - ansi.Init(env.Shell()) - writerColors := cfg.MakeColors() - writer = &color.AnsiWriter{ - Ansi: ansi, - TerminalBackground: shell.ConsoleBackgroundColor(env, cfg.TerminalBackground), - AnsiColors: writerColors, - } - } - - consoleTitle := &console.Title{ - Env: env, - Ansi: ansi, - Template: cfg.ConsoleTitleTemplate, + ansiWriter := &color.AnsiWriter{ + TerminalBackground: shell.ConsoleBackgroundColor(env, cfg.TerminalBackground), + AnsiColors: cfg.MakeColors(), + Plain: flags.Plain, } + ansiWriter.Init(env.Shell()) eng := &Engine{ - Config: cfg, - Env: env, - Writer: writer, - ConsoleTitle: consoleTitle, - Ansi: ansi, - Plain: flags.Plain, + Config: cfg, + Env: env, + Writer: ansiWriter, + Plain: flags.Plain, } return eng diff --git a/src/segments/path_test.go b/src/segments/path_test.go index 3d3b4e64..791b6754 100644 --- a/src/segments/path_test.go +++ b/src/segments/path_test.go @@ -135,7 +135,7 @@ func TestParent(t *testing.T) { env.On("Home").Return(tc.HomePath) env.On("Pwd").Return(tc.Pwd) env.On("Flags").Return(&platform.Flags{}) - env.On("Shell").Return(shell.PLAIN) + env.On("Shell").Return(shell.GENERIC) env.On("PathSeparator").Return(tc.PathSeparator) env.On("GOOS").Return(tc.GOOS) path := &Path{ @@ -811,7 +811,7 @@ func TestFullAndFolderPath(t *testing.T) { PSWD: tc.Pswd, } env.On("Flags").Return(args) - env.On("Shell").Return(shell.PLAIN) + env.On("Shell").Return(shell.GENERIC) if len(tc.Template) == 0 { tc.Template = "{{ if gt .StackCount 0 }}{{ .StackCount }} {{ end }}{{ .Path }}" } @@ -870,7 +870,7 @@ func TestFullPathCustomMappedLocations(t *testing.T) { PSWD: tc.Pwd, } env.On("Flags").Return(args) - env.On("Shell").Return(shell.PLAIN) + env.On("Shell").Return(shell.GENERIC) env.On("TemplateCache").Return(&platform.TemplateCache{ Env: map[string]string{ "HOME": "/a/b/c", @@ -902,7 +902,7 @@ func TestFolderPathCustomMappedLocations(t *testing.T) { PSWD: pwd, } env.On("Flags").Return(args) - env.On("Shell").Return(shell.PLAIN) + env.On("Shell").Return(shell.GENERIC) path := &Path{ env: env, props: properties.Map{ diff --git a/src/shell/constants.go b/src/shell/constants.go index 960b1268..e6082786 100644 --- a/src/shell/constants.go +++ b/src/shell/constants.go @@ -1,12 +1,12 @@ package shell const ( - ZSH = "zsh" - BASH = "bash" - PWSH = "pwsh" - FISH = "fish" - PWSH5 = "powershell" - CMD = "cmd" - NU = "nu" - PLAIN = "shell" + ZSH = "zsh" + BASH = "bash" + PWSH = "pwsh" + FISH = "fish" + PWSH5 = "powershell" + CMD = "cmd" + NU = "nu" + GENERIC = "shell" )