Repository: JanDeDobbeleer/oh-my-posh
Pull request #4243 (2023-09-08 14:02:48)

feat(colors): allow nested overrides
### Prerequisites

- [x] I have read and understood the [contributing guide][CONTRIBUTING.md].
- [x] The commit message follows the [conventional commits][cc] guidelines.
- [x] Tests for the changes have been added (for bug fixes / features).
- [x] Docs have been added/updated (for bug fixes / features).

### Description

<!--
copilot:summary
-->
### <samp>🤖 Generated by Copilot at c189854</samp>

Refactored the `ansi.Writer` type and its methods to use a `ColorHistory` type to manage color overrides and resets more reliably. Fixed a bug with hyperlink alignment when the link text contained color codes. Added tests and documentation for the new types and functions.

### How

<!--
copilot:walkthrough
-->
### <samp>🤖 Generated by Copilot at c189854</samp>

*  Refactor the `Writer` type to use a `ColorHistory` stack for storing and retrieving the current colors ([link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4243/files?diff=unified&w=0#diff-0267d2a06e9c5eab5a04e9625772dd68774a70872a4014630cdb15183ce7dcd9L89-R94), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4243/files?diff=unified&w=0#diff-0267d2a06e9c5eab5a04e9625772dd68774a70872a4014630cdb15183ce7dcd9L306-R306), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4243/files?diff=unified&w=0#diff-0267d2a06e9c5eab5a04e9625772dd68774a70872a4014630cdb15183ce7dcd9L346-R346), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4243/files?diff=unified&w=0#diff-0267d2a06e9c5eab5a04e9625772dd68774a70872a4014630cdb15183ce7dcd9L381-R384), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4243/files?diff=unified&w=0#diff-0267d2a06e9c5eab5a04e9625772dd68774a70872a4014630cdb15183ce7dcd9L411-R410), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4243/files?diff=unified&w=0#diff-0267d2a06e9c5eab5a04e9625772dd68774a70872a4014630cdb15183ce7dcd9L477-R478))
*  Add the `ColorSet` and `ColorHistory` types and methods to the `ansi` package to implement the color stack logic ([link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4243/files?diff=unified&w=0#diff-30b9d265fa3e36a4cb4221faf43553e0306e258e6c27f55f007da34105896522R22-R76))
*  Update the `writeColorOverrides` function to use local variables for the parsed colors and extract the color reset logic into a separate function ([link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4243/files?diff=unified&w=0#diff-0267d2a06e9c5eab5a04e9625772dd68774a70872a4014630cdb15183ce7dcd9L419-R417), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4243/files?diff=unified&w=0#diff-0267d2a06e9c5eab5a04e9625772dd68774a70872a4014630cdb15183ce7dcd9L468-R440), [link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4243/files?diff=unified&w=0#diff-0267d2a06e9c5eab5a04e9625772dd68774a70872a4014630cdb15183ce7dcd9R484-R542))
*  Update the `replaceHyperlink` function to trim any ANSI escape sequences from the link text before calculating its width ([link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4243/files?diff=unified&w=0#diff-335a93b8a96a2b0f5cfdda59629f5ad2165e45c47a6570c1f5171b051d099071L79-R81))
*  Add a test case to verify that nested color overrides work as expected and are restored properly ([link](https://github.com/JanDeDobbeleer/oh-my-posh/pull/4243/files?diff=unified&w=0#diff-7214a6ffb60e6b167949f626066440b0deabc9698c4e7923aa23478ab773476aR198-R203))

<!---

Tips:

If you're not comfortable with working with Git,
we're working a guide (https://ohmyposh.dev/docs/contributing_git) to help you out.
Oh My Posh advises GitKraken (https://www.gitkraken.com/invite/nQmDPR9D) as your preferred cross platform Git GUI power tool.

-->

[CONTRIBUTING.md]: https://github.com/JanDeDobbeleer/oh-my-posh/blob/main/CONTRIBUTING.md
[cc]: https://www.conventionalcommits.org/en/v1.0.0/#summary



Patch:
diff --git a/.all-contributorsrc b/.all-contributorsrc
index ed581c32..f7632977 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -1883,6 +1883,15 @@
         "design",
         "doc"
       ]
+    },
+    {
+      "login": "0Ky",
+      "name": "ｃｒｙｐｔｉｘ",
+      "avatar_url": "https://avatars.githubusercontent.com/u/16103757?v=4",
+      "profile": "https://github.com/0Ky",
+      "contributions": [
+        "doc"
+      ]
     }
   ],
   "contributorsPerLine": 7,
diff --git a/src/ansi/ansi_writer.go b/src/ansi/ansi_writer.go
index 7a8290f7..820ad1a2 100644
--- a/src/ansi/ansi_writer.go
+++ b/src/ansi/ansi_writer.go
@@ -86,13 +86,12 @@ type Writer struct {
 	builder strings.Builder
 	length  int
 
-	foreground        Color
-	background        Color
-	currentForeground Color
-	currentBackground Color
-	runes             []rune
-	transparent       bool
-	invisible         bool
+	foreground  Color
+	background  Color
+	current     ColorHistory
+	runes       []rune
+	transparent bool
+	invisible   bool
 
 	shell                 string
 	format                string
@@ -303,7 +302,8 @@ func (w *Writer) Write(background, foreground, text string) {
 			colorOverride = false
 		}
 		if colorOverride {
-			w.currentBackground, w.currentForeground = w.asAnsiColors(match[BG], match[FG])
+			w.current.Add(w.asAnsiColors(match[BG], match[FG]))
+			// w.current.Background(), w.current.Foreground() = w.asAnsiColors(match[BG], match[FG])
 		}
 	}
 	w.writeSegmentColors()
@@ -343,8 +343,7 @@ func (w *Writer) Write(background, foreground, text string) {
 	w.writeEscapedAnsiString(resetStyle.End)
 
 	// reset current
-	w.currentBackground = ""
-	w.currentForeground = ""
+	w.current.Pop()
 }
 
 func (w *Writer) String() (string, int) {
@@ -378,11 +377,11 @@ func (w *Writer) writeSegmentColors() {
 	// use correct starting colors
 	bg := w.background
 	fg := w.foreground
-	if !w.currentBackground.IsEmpty() {
-		bg = w.currentBackground
+	if !w.current.Background().IsEmpty() {
+		bg = w.current.Background()
 	}
-	if !w.currentForeground.IsEmpty() {
-		fg = w.currentForeground
+	if !w.current.Foreground().IsEmpty() {
+		fg = w.current.Foreground()
 	}
 
 	// ignore processing fully tranparent colors
@@ -408,45 +407,14 @@ func (w *Writer) writeSegmentColors() {
 	}
 
 	// set current colors
-	w.currentBackground = bg
-	w.currentForeground = fg
+	w.current.Add(bg, fg)
 }
 
 func (w *Writer) writeColorOverrides(match map[string]string, background string, i int) int {
 	position := i
 	// check color reset first
 	if match[ANCHOR] == resetStyle.AnchorEnd {
-		// make sure to reset the colors if needed
-		position += len([]rune(resetStyle.AnchorEnd)) - 1
-
-		// do not reset when colors are identical
-		if w.currentBackground == w.background && w.currentForeground == w.foreground {
-			return position
-		}
-
-		// do not restore colors at the end of the string, we print it anyways
-		if position == len(w.runes)-1 {
-			return position
-		}
-
-		if w.transparent {
-			w.writeEscapedAnsiString(transparentEnd)
-		}
-
-		if w.background.IsClear() {
-			w.writeEscapedAnsiString(backgroundStyle.End)
-		}
-
-		if w.currentBackground != w.background && !w.background.IsClear() {
-			w.writeEscapedAnsiString(fmt.Sprintf(colorise, w.background))
-		}
-
-		if (w.currentForeground != w.foreground || w.transparent) && !w.foreground.IsClear() {
-			w.writeEscapedAnsiString(fmt.Sprintf(colorise, w.foreground))
-		}
-
-		w.transparent = false
-		return position
+		return w.endColorOverride(position)
 	}
 
 	position += len([]rune(match[ANCHOR])) - 1
@@ -465,48 +433,110 @@ func (w *Writer) writeColorOverrides(match map[string]string, background string,
 	if match[FG] == Transparent && len(match[BG]) == 0 {
 		match[BG] = background
 	}
-	w.currentBackground, w.currentForeground = w.asAnsiColors(match[BG], match[FG])
+
+	bg, fg := w.asAnsiColors(match[BG], match[FG])
 
 	// ignore processing fully tranparent colors
-	w.invisible = w.currentForeground.IsTransparent() && w.currentBackground.IsTransparent()
+	w.invisible = fg.IsTransparent() && bg.IsTransparent()
 	if w.invisible {
 		return position
 	}
 
 	// make sure we have colors
-	if w.currentForeground.IsEmpty() {
-		w.currentForeground = w.foreground
+	if fg.IsEmpty() {
+		fg = w.foreground
 	}
-	if w.currentBackground.IsEmpty() {
-		w.currentBackground = w.background
+	if bg.IsEmpty() {
+		bg = w.background
 	}
 
-	if w.currentForeground.IsTransparent() && len(w.TerminalBackground) != 0 {
+	w.current.Add(bg, fg)
+
+	if w.current.Foreground().IsTransparent() && len(w.TerminalBackground) != 0 {
 		background := w.getAnsiFromColorString(w.TerminalBackground, false)
 		w.writeEscapedAnsiString(fmt.Sprintf(colorise, background))
-		w.writeEscapedAnsiString(fmt.Sprintf(colorise, w.currentBackground.ToForeground()))
+		w.writeEscapedAnsiString(fmt.Sprintf(colorise, w.current.Background().ToForeground()))
 		return position
 	}
 
-	if w.currentForeground.IsTransparent() && !w.currentBackground.IsTransparent() {
+	if w.current.Foreground().IsTransparent() && !w.current.Background().IsTransparent() {
 		w.transparent = true
-		w.writeEscapedAnsiString(fmt.Sprintf(transparent, w.currentBackground))
+		w.writeEscapedAnsiString(fmt.Sprintf(transparent, w.current.Background()))
 		return position
 	}
 
-	if w.currentBackground != w.background {
+	if w.current.Background() != w.background {
 		// end the colors in case we have a transparent background
-		if w.currentBackground.IsTransparent() {
+		if w.current.Background().IsTransparent() {
 			w.writeEscapedAnsiString(backgroundEnd)
 		} else {
-			w.writeEscapedAnsiString(fmt.Sprintf(colorise, w.currentBackground))
+			w.writeEscapedAnsiString(fmt.Sprintf(colorise, w.current.Background()))
 		}
 	}
 
-	if w.currentForeground != w.foreground {
-		w.writeEscapedAnsiString(fmt.Sprintf(colorise, w.currentForeground))
+	if w.current.Foreground() != w.foreground {
+		w.writeEscapedAnsiString(fmt.Sprintf(colorise, w.current.Foreground()))
+	}
+
+	return position
+}
+
+func (w *Writer) endColorOverride(position int) int {
+	// pop the last colors from the stack
+	defer w.current.Pop()
+
+	// make sure to reset the colors if needed
+	position += len([]rune(resetStyle.AnchorEnd)) - 1
+
+	// reset colors to previous when we have > 2 in stack
+	// - first is always the starting colors used in Write()
+	// - second is the first override
+	// as soon as we have  more than 2, we can pop the last one
+	// and print the previous override as it wasn't ended yet
+	if w.current.Len() >= 3 {
+		fg := w.current.Foreground()
+		bg := w.current.Background()
+
+		w.current.Pop()
+
+		if w.current.Background() != bg {
+			w.writeEscapedAnsiString(fmt.Sprintf(colorise, w.current.Background()))
+		}
+
+		if w.current.Foreground() != fg {
+			w.writeEscapedAnsiString(fmt.Sprintf(colorise, w.current.Foreground()))
+		}
+
+		return position
+	}
+
+	// do not reset when colors are identical
+	if w.current.Background() == w.background && w.current.Foreground() == w.foreground {
+		return position
+	}
+
+	// do not restore colors at the end of the string, we print it anyways
+	if position == len(w.runes)-1 {
+		return position
+	}
+
+	if w.transparent {
+		w.writeEscapedAnsiString(transparentEnd)
+	}
+
+	if w.background.IsClear() {
+		w.writeEscapedAnsiString(backgroundStyle.End)
+	}
+
+	if w.current.Background() != w.background && !w.background.IsClear() {
+		w.writeEscapedAnsiString(fmt.Sprintf(colorise, w.background))
+	}
+
+	if (w.current.Foreground() != w.foreground || w.transparent) && !w.foreground.IsClear() {
+		w.writeEscapedAnsiString(fmt.Sprintf(colorise, w.foreground))
 	}
 
+	w.transparent = false
 	return position
 }
 
diff --git a/src/ansi/ansi_writer_hyperlink.go b/src/ansi/ansi_writer_hyperlink.go
index 3846678b..7a27c166 100644
--- a/src/ansi/ansi_writer_hyperlink.go
+++ b/src/ansi/ansi_writer_hyperlink.go
@@ -76,8 +76,9 @@ func (w *Writer) replaceHyperlink(text string) string {
 		return strings.Replace(text, results["ALL"], linkText, 1)
 	}
 
-	// we only care about the length of the text part
-	w.length += runewidth.StringWidth(linkText)
+	// we only care about the length of the actual text part
+	characters := w.trimAnsi(linkText)
+	w.length += runewidth.StringWidth(characters)
 
 	if w.Plain {
 		return linkText
diff --git a/src/ansi/colors.go b/src/ansi/colors.go
index fa95b45a..6c5590ce 100644
--- a/src/ansi/colors.go
+++ b/src/ansi/colors.go
@@ -19,6 +19,61 @@ type ColorString interface {
 	ToColor(colorString string, isBackground bool, trueColor bool) Color
 }
 
+type ColorSet struct {
+	Foreground Color
+	Background Color
+}
+
+type ColorHistory []*ColorSet
+
+func (c *ColorHistory) Len() int {
+	return len(*c)
+}
+
+func (c *ColorHistory) Add(background, foreground Color) {
+	colors := &ColorSet{
+		Foreground: foreground,
+		Background: background,
+	}
+
+	if c.Len() == 0 {
+		*c = append(*c, colors)
+		return
+	}
+
+	last := (*c)[c.Len()-1]
+	// never add the same colors twice
+	if last.Foreground == colors.Foreground && last.Background == colors.Background {
+		return
+	}
+
+	*c = append(*c, colors)
+}
+
+func (c *ColorHistory) Pop() {
+	if c.Len() == 0 {
+		return
+	}
+
+	*c = (*c)[:c.Len()-1]
+}
+
+func (c *ColorHistory) Background() Color {
+	if c.Len() == 0 {
+		return emptyColor
+	}
+
+	return (*c)[c.Len()-1].Background
+}
+
+func (c *ColorHistory) Foreground() Color {
+	if c.Len() == 0 {
+		return emptyColor
+	}
+
+	return (*c)[c.Len()-1].Foreground
+}
+
 // Color 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 Color string
diff --git a/website/docs/contributors.md b/website/docs/contributors.md
index 3bfe6abc..894ee0d5 100644
--- a/website/docs/contributors.md
+++ b/website/docs/contributors.md
@@ -272,6 +272,9 @@ Thanks goes to these wonderful people ([emoji key][acek]):
       <td align="center" valign="top" width="14.28%"><a href="https://carlwa.com"><img src="https://avatars.githubusercontent.com/u/2829438?v=4?s=100" width="100px;" alt="Carl Walsh"/><br /><sub><b>Carl Walsh</b></sub></a><br /><a href="https://github.com/JanDeDobbeleer/oh-my-posh/commits?author=darthwalsh" title="Documentation">📖</a></td>
       <td align="center" valign="top" width="14.28%"><a href="https://github.com/ercpereda"><img src="https://avatars.githubusercontent.com/u/13546685?v=4?s=100" width="100px;" alt="Ernesto R. C. Pereda"/><br /><sub><b>Ernesto R. C. Pereda</b></sub></a><br /><a href="https://github.com/JanDeDobbeleer/oh-my-posh/commits?author=ercpereda" title="Code">💻</a> <a href="#design-ercpereda" title="Design">🎨</a> <a href="https://github.com/JanDeDobbeleer/oh-my-posh/commits?author=ercpereda" title="Documentation">📖</a></td>
     </tr>
+    <tr>
+      <td align="center" valign="top" width="14.28%"><a href="https://github.com/0Ky"><img src="https://avatars.githubusercontent.com/u/16103757?v=4?s=100" width="100px;" alt="ｃｒｙｐｔｉｘ"/><br /><sub><b>ｃｒｙｐｔｉｘ</b></sub></a><br /><a href="https://github.com/JanDeDobbeleer/oh-my-posh/commits?author=0Ky" title="Documentation">📖</a></td>
+    </tr>
   </tbody>
 </table>
 
diff --git a/website/docs/faq.mdx b/website/docs/faq.mdx
index bd7b502b..c12ecbf1 100644
--- a/website/docs/faq.mdx
+++ b/website/docs/faq.mdx
@@ -26,7 +26,19 @@ oh-my-posh enable notice
 ### The prompt is slow (delay in showing the prompt between commands)
 
 :::tip Windows
-Make sure Windows Defender, or your AntiVirus, has an exclusion [configured][exclusion].
+The delay you're experiencing may be linked to the real-time protection features of antivirus software.
+
+To potentially alleviate this delay, ensure that Windows Defender or your primary antivirus software has an [exclusion][exclusion] set for the full path of the executable. To identify the full path of the executable in question, you can run the following command in a PowerShell prompt: 
+
+```powershell
+(Get-Command oh-my-posh).Source
+```
+
+Additionally, check your antivirus settings for features that actively scan scripts during execution. Even with exclusions set, some antivirus programs might still actively scan scripts, which can introduce a noticeable delay.
+:::
+
+:::caution
+Always proceed with caution to maintain a balance between system performance and security. Modifying security settings or adding exclusions can expose your system to potential threats.
 :::
 
 <Tabs


Test patch:
diff --git a/src/ansi/ansi_writer_test.go b/src/ansi/ansi_writer_test.go
index 24011b6a..c23ad58c 100644
--- a/src/ansi/ansi_writer_test.go
+++ b/src/ansi/ansi_writer_test.go
@@ -195,6 +195,12 @@ func TestWriteANSIColors(t *testing.T) {
 			Expected: "\x1b[40m\x1b[30mtest\x1b[0m",
 			Colors:   &Colors{Foreground: "black", Background: "white"},
 		},
+		{
+			Case:     "Nested override",
+			Input:    "hello, <red>world, <white>rabbit</> hello</>",
+			Expected: "\x1b[47m\x1b[30mhello, \x1b[31mworld, \x1b[37mrabbit\x1b[31m hello\x1b[0m",
+			Colors:   &Colors{Foreground: "black", Background: "white"},
+		},
 	}
 
 	for _, tc := range cases {