Repository: JanDeDobbeleer/oh-my-posh
Pull request #1255 (2021-11-18 22:00:14)

feature: Palette - a map of named Standard color values that can be referenced in theme segments
### Prerequisites

- [x] I have read and understood the `CONTRIBUTING` guide
- [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

Introduces the Palette feature - a way to maintain a map of named Standard color values and use references to these colors in places where a Standard color is expected. Example:

```json
{
  "palette": {
    "darkgrey3": "#4C566A",
    "lightgrey2": "#ECEFF4",
    "red": "#BF616A",
    "orange": "#D08770",
    "yellow": "#EBCB8B",
    "green": "#A3BE8C",
    "purple": "#B48EAD",
    ...
  },
  "blocks": [
    {
      "segments": [
        {
          "type": "git",
          "style": "powerline",
          "powerline_symbol": "\uE0B0",
          "foreground": "p:lightgrey2",
          "background": "p:darkgrey3",
          "background_templates": [
            "{{ if or (.Working.Changed) (.Staging.Changed) }}p:yellow{{ end }}",
            "{{ if and (gt .Ahead 0) (gt .Behind 0) }}p:purple{{ end }}",
            "{{ if gt .Ahead 0 }}p:green{{ end }}",
            "{{ if gt .Behind 0 }}p:orange{{ end }}"
          ]
        },
        ...
        {
          "type": "battery",
          "style": "powerline",
          "invert_powerline": true,
          "powerline_symbol": "\uE0B2",
          "foreground": "p:lightgrey2",
          "background": "p:darkgrey3",
          "properties": {
            "color_background": true,
            "charged_color": "p:green",
            "charging_color": "p:yellow",
            "discharging_color": "p:red",
          }
        },
        ...
      ]
    }
  ]
}

```

I was trying to build my dream posh theme for the last few days and quickly understood that using only Hex color values was very limiting. They are not glanceable (I do not understand what they represent immediately), changing the same color in a lot of places was tedious, importing and maintaining a consistent palette was also a pain.

So I built the Palette feature and want to share it. The implementation solves all the issues I had - allows named colors and allows importing existing color themes more easily. Palette colors can be used in most of the places where the Standard color is expected - foreground, background, their template variant, and properties.

I've been using the updated binary in my PowerShell module for the last day, and it works well. Palette has no measurable impact on performance, both in benchmarks and in real-world usage, and does not affect anything but colors.

Implementation is mostly a decorator for Segment's #backgroud() and #foreground() methods (that is why colors from properties also work) and some refactoring to engine and Block. It is still a fairly large PR due to a lot of added scaffolding - mainly tests and benchmarks.

Hope you will find this feature useful. If not, it was a fun thing to do anyway, so no worries if you find these changes to be too much to approve.

<!---

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.

-->

[cc]: https://www.conventionalcommits.org/en/v1.0.0/#summary


JanDeDobbeleer: @yehorb this has been a request before and I've been thinking about it again lately so I'm sure we'll be able to merge this. Allow me to find some time to review and we'll get this sorted 😉
JanDeDobbeleer: Based on this I've written my own implementation. It's simpler and has logical limitations which I feel are OK but make the whole architectural design a lot less impactful.
JanDeDobbeleer: I did the following:

```diff
diff --git a/src/config.go b/src/config.go
index 738475bc..f3e72d67 100644
--- a/src/config.go
+++ b/src/config.go
@@ -29,6 +29,7 @@ type Config struct {
 	Blocks               []*Block          `config:"blocks"`
 	Tooltips             []*Segment        `config:"tooltips"`
 	TransientPrompt      *TransientPrompt  `config:"transient_prompt"`
+	Palette              map[string]string `config:"palette"`
 }
 
 type TransientPrompt struct {
diff --git a/src/main.go b/src/main.go
index b5c45108..31350371 100644
--- a/src/main.go
+++ b/src/main.go
@@ -212,6 +212,7 @@ func main() {
 		writer = &AnsiWriter{
 			ansi:               ansi,
 			terminalBackground: getConsoleBackgroundColor(env, cfg.TerminalBackground),
+			palette:            cfg.Palette,
 		}
 	}
 	title := &consoleTitle{
diff --git a/src/writer_ansi.go b/src/writer_ansi.go
index caf8706c..61b67247 100644
--- a/src/writer_ansi.go
+++ b/src/writer_ansi.go
@@ -60,6 +60,7 @@ type promptWriter interface {
 type AnsiWriter struct {
 	builder            strings.Builder
 	ansi               *ansiUtils
+	palette            map[string]string
 	terminalBackground string
 	Colors             *Color
 	ParentColors       *Color
@@ -158,17 +159,35 @@ func (a *AnsiWriter) write(background, foreground, text string) {
 	}
 
 	getAnsiColors := func(background, foreground string) (string, string) {
+		getPaletteColor := func(color string) string {
+			mapPaletteColor := func(color string) string {
+				color = strings.TrimLeft(color, "p:")
+				if color, found := a.palette[color]; found {
+					return color
+				}
+				return "#111111"
+			}
+			for strings.HasPrefix(color, "p:") {
+				color = mapPaletteColor(color)
+			}
+			return color
+		}
 		getColorString := func(color string) string {
+			color = getPaletteColor(color)
 			if color == Background {
-				color = a.Colors.Background
-			} else if color == Foreground {
-				color = a.Colors.Foreground
-			} else if color == ParentBackground && a.ParentColors != nil {
-				color = a.ParentColors.Background
-			} else if color == ParentForeground && a.ParentColors != nil {
-				color = a.ParentColors.Foreground
-			} else if (color == ParentForeground || color == ParentBackground) && a.ParentColors == nil {
-				color = Transparent
+				return getPaletteColor(a.Colors.Background)
+			}
+			if color == Foreground {
+				return getPaletteColor(a.Colors.Foreground)
+			}
+			if color == ParentBackground && a.ParentColors != nil {
+				return getPaletteColor(a.ParentColors.Background)
+			}
+			if color == ParentForeground && a.ParentColors != nil {
+				return getPaletteColor(a.ParentColors.Foreground)
+			}
+			if (color == ParentForeground || color == ParentBackground) && a.ParentColors == nil {
+				return Transparent
 			}
 			return color
 		}
diff --git a/themes/schema.json b/themes/schema.json
index 252d81f4..cba467a8 100644
--- a/themes/schema.json
+++ b/themes/schema.json
@@ -7,7 +7,7 @@
   "definitions": {
     "color": {
       "type": "string",
-      "pattern": "^(#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})|black|red|green|yellow|blue|magenta|cyan|white|default|darkGray|lightRed|lightGreen|lightYellow|lightBlue|lightMagenta|lightCyan|lightWhite|transparent|parentBackground|parentForeground|background|foreground)$",
+      "pattern": "^(#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})|p:\\w*|black|red|green|yellow|blue|magenta|cyan|white|default|darkGray|lightRed|lightGreen|lightYellow|lightBlue|lightMagenta|lightCyan|lightWhite|transparent|parentBackground|parentForeground|background|foreground)$",
       "title": "Color string",
       "description": "https://ohmyposh.dev/docs/config-colors",
       "format": "color"
```

This works according to the expectations you wrote (and theme you shared), plus it has the advantage to work for overrides as well. The problem I always had with the palette is that I also went in your direction, which made it leak everywhere for no apparent reason. I only came to this solution because I could see a different path while going through this PR, so you made it a reality. Maybe I missed something, I'm looking forward to feedback, ideas :-)
yehorb: @JanDeDobbeleer cool, nice to hear I helped you to find the solution.
I have been tinkering with the code today, and also came to the conclusion that the Palette should be constrained to AnsiWriter, as it is the only part of the project that does "real" color processing anyway. I do agree that leaking it as deep as Blocks never felt right to me too.
Just as I was writing an update to say that Palette color will not work with Color overrides, I thought to myself "Wait, it doesn't sound right - palette colors not working with color overrides" - and dug through the code a bit more to find the color processing in AnsiWriter.
So how should we proceed? Should I close the pull request, and leave the implementation up to you? Or I can integrate the Palette class I have into AnsiWriter, just as you did, and you can review that version of the implementation.
JanDeDobbeleer: @yehorb you can apply my diff and write the tests for it if you're up for it, it's fine to start from my branch. Would love to have you be able to finish this!
yehorb: @JanDeDobbeleer, great, I will work on the updated implementation then.
yehorb: @JanDeDobbeleer, I believe I implemented the Palette according to your suggestion the best I could.
Palette is fully constrained to AnsiWriter as a part of a pluggable interface for color solving. The interface of AnsiWriter changed a bit, but this time it is much more constrained. Overall, I feel much better about this implementation, than the previous one.
I'm sorry for doing more than a minimal amount of changes (again), and all those force-pushes. I did not want to throw away the original code, so I just rebased the excessive changes away.
I hope you will find this implementation more manageable.
yehorb: @JanDeDobbeleer, I understand. I will try to rehash the current implementation a bit, to make it easier to follow.
yehorb: @JanDeDobbeleer, I did the refactoring of the Palette and Colors. Now everything relevant to color solving resides in colors.go. I also refactored Palette docs - they are now included in the Palette section in config-colors.md. Fixed a few typos along the way. Ready for another review.
JanDeDobbeleer: @yehorb I'm having a look tomorrow 👍🏻
yehorb: @JanDeDobbeleer looks like all of the fixes for now.
And also looks like `commitlint` is marking commits with any capital letters in the subject line as invalid. Should I rebase to make them all lowercase?
JanDeDobbeleer: > Should I rebase to make them all lowercase?

@yehorb I'm going to squash this, don't worry ;-)

Patch:
diff --git a/.vscode/settings.json b/.vscode/settings.json
index c0616b25..74ed8efa 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -2,7 +2,7 @@
   "go.lintTool": "golangci-lint",
   "go.useLanguageServer": true,
   "go.languageServerExperimentalFeatures": {
-    "diagnostics": false,
+    "diagnostics": false
   },
   "go.lintFlags": ["--fast"],
   "go.testOnSave": true,
@@ -13,7 +13,5 @@
     }
   },
   "go.formatTool": "gofmt",
-  "go.formatFlags": [
-    "-s"
-  ]
+  "go.formatFlags": ["-s"]
 }
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index b8f3cd71..2e9487d7 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -30,14 +30,45 @@
         },
         "statusbar": {
           "hide": false,
-          "color" : "#22C1D6",
-          "label" : "$(beaker) devcontainer: build omp",
+          "color": "#22C1D6",
+          "label": "$(beaker) devcontainer: build omp",
           "tooltip": "Compiles *oh-my-posh* from this repo while **overwriting** your preinstalled stable release."
         }
       },
       "group": "build",
       "problemMatcher": "$go",
       "args": ["build", "-v", "-o", "`readlink", "/usr/bin/oh-my-posh`"]
+    },
+    {
+      "label": "golangci-lint - docker",
+      "type": "shell",
+      "command": "docker",
+      "group": "build",
+      "presentation": {
+        "reveal": "always",
+        "focus": false,
+        "echo": true,
+        "showReuseMessage": false,
+        "panel": "dedicated",
+        "clear": false
+      },
+      "problemMatcher": {
+          "base": "$go",
+          "fileLocation": ["relative", "${workspaceFolder}/src"]
+      },
+      "args": [
+        "run", "--rm", "-v", "${workspaceFolder}/src:/app", "-w", "/app",
+        "golangci/golangci-lint:${input:golangci-lint-version}",
+        "golangci-lint", "run", "-v"
+      ]
+    }
+  ],
+  "inputs": [
+    {
+      "id": "golangci-lint-version",
+      "description": "Version of the golangci-lint Docker container to use in lint task.",
+      "type": "promptString",
+      "default": "v1.43.0"
     }
   ]
 }
diff --git a/docs/docs/config-colors.md b/docs/docs/config-colors.md
index a320491e..aa96534a 100644
--- a/docs/docs/config-colors.md
+++ b/docs/docs/config-colors.md
@@ -51,5 +51,121 @@ To change *only* the background color, just omit the first color from the above
 "prefix": "<,#FFFFFF>┏[</>",
 ```
 
+## Palette
+
+If your theme defined the Palette, you can use the _Palette reference_ `p:<palette key>` in places where the
+__Standard color__ is expected.
+
+### Defining a Palette
+
+Palette is a set of named __Standard colors__. To use a Palette, define a `"palette"` object
+at the top level of your theme:
+
+```json
+{
+    "$schema": "https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/schema.json",
+    "palette": {
+        "git-foreground": "#193549",
+        "git": "#FFFB38",
+        "git-modified": "#FF9248",
+        "git-diverged": "#FF4500",
+        "git-ahead": "#B388FF",
+        "git-behind": "#B388FF",
+        "red":   "#FF0000",
+        "green": "#00FF00",
+        "blue":  "#0000FF",
+        "white": "#FFFFFF",
+        "black": "#111111"
+    },
+    "blocks": {
+        ...
+    }
+}
+```
+
+Color names (palette keys) can have any string value, so be creative.
+Color values, on the other hand, should adhere to the __Standard color__ format.
+
+### Using a Palette
+
+You can now _Palette references_ in any [Segment's][segment] `foreground`, `foreground_templates`,
+`background`, `background_templates` properties, and other config properties that expect __Standard color__ value.
+_Palette reference_ format is `p:<palette key>`. Take a look at the [Git][git] segment using _Palette references_:
+
+```json
+{
+    "type": "git",
+    "style": "powerline",
+    "powerline_symbol": "\uE0B0",
+    "foreground": "p:git-foreground",
+    "background": "p:git",
+    "background_templates": [
+        "{{ if or (.Working.Changed) (.Staging.Changed) }}p:git-modified{{ end }}",
+        "{{ if and (gt .Ahead 0) (gt .Behind 0) }}p:git-diverged{{ end }}",
+        "{{ if gt .Ahead 0 }}p:git-ahead{{ end }}",
+        "{{ if gt .Behind 0 }}p:git-behind{{ end }}"
+    ],
+    ...
+},
+```
+
+Having all of the colors defined in one place allows you to import existing color themes (usually with slight
+tweaking to adhere to the format), easily change colors of multiple segments at once, and have a more
+organized theme overall. Be creative!
+
+### _Palette references_ and __Standard colors__
+
+Using Palette does not interfere with using __Standard colors__ in your theme. You can still use __Standard colors__
+everywhere. This can be useful if you want to use a specific color for a single segment element, or in a
+_Color override_ ([Battery segment][battery]):
+
+```json
+{
+    "type": "battery",
+    "style": "powerline",
+    "invert_powerline": true,
+    "powerline_symbol": "\uE0B2",
+    "foreground": "p:white",
+    "background": "p:black",
+    "properties": {
+        "battery_icon": "<#ffa500> [II ]- </>", // icon should always be orange
+        "discharging_icon": "- ",
+        "charging_icon": "+ ",
+        "charged_icon": "* ",
+        "color_background": true,
+        "charged_color": "#4caf50",     //
+        "charging_color": "#40c4ff",    // battery should use specific colors for status
+        "discharging_color": "#ff5722", //
+    }
+},
+```
+
+### Handling of invalid references
+
+Should you use an invalid _Palette reference_ as a color (for example typo `p:bleu` instead of `p:blue`),
+the Pallete engine will use the Transparent keyword as a fallback value. So if you see your prompt segments
+rendered with incorrect colors, and you are using a Palette, be sure to check the correctness of your references.
+
+### Recursive resolution
+
+Palette allows for recursive _Palette reference_ resolution. You can use a _Palette reference_ as a color
+value in Palette. This allows you to define named colors, and use references to those colors as Palette values.
+For example, `p:foreground` and `p:background`  will be correctly set to "#CAF0F80" and "#023E8A":
+
+```json
+    "$schema": "https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/schema.json",
+    "palette": {
+        "light-blue": "#CAF0F8",
+        "dark-blue": "#023E8A",
+        "foreground": "p:light-blue",
+        "background": "p:dark-blue"
+    },
+    "blocks": {
+        ...
+    }
+```
+
 [hexcolors]: https://htmlcolorcodes.com/color-chart/material-design-color-chart/
 [ansicolors]: https://htmlcolorcodes.com/color-chart/material-design-color-chart/
+[git]: /docs/segment-git
+[battery]: /docs/segment-battery
diff --git a/src/colors.go b/src/colors.go
new file mode 100644
index 00000000..6c705ba2
--- /dev/null
+++ b/src/colors.go
@@ -0,0 +1,133 @@
+package main
+
+import (
+	"fmt"
+
+	"github.com/gookit/color"
+)
+
+// MakeColors creates instance of AnsiColors to use in AnsiWriter according to
+// environment and configuration.
+func MakeColors(env environmentInfo, cfg *Config) AnsiColors {
+	cacheDisabled := env.getenv("OMP_CACHE_DISABLED") == "1"
+	return makeColors(cfg.Palette, !cacheDisabled)
+}
+
+func makeColors(palette Palette, cacheEnabled bool) (colors AnsiColors) {
+	colors = &DefaultColors{}
+	if palette != nil {
+		colors = &PaletteColors{ansiColors: colors, palette: palette}
+	}
+	if cacheEnabled {
+		colors = &CachedColors{ansiColors: colors}
+	}
+	return
+}
+
+// DefaultColors is the default AnsiColors implementation.
+type DefaultColors struct{}
+
+var (
+	// Map for color names and their respective foreground [0] or background [1] color codes
+	ansiColorCodes = map[string][2]AnsiColor{
+		"black":        {"30", "40"},
+		"red":          {"31", "41"},
+		"green":        {"32", "42"},
+		"yellow":       {"33", "43"},
+		"blue":         {"34", "44"},
+		"magenta":      {"35", "45"},
+		"cyan":         {"36", "46"},
+		"white":        {"37", "47"},
+		"default":      {"39", "49"},
+		"darkGray":     {"90", "100"},
+		"lightRed":     {"91", "101"},
+		"lightGreen":   {"92", "102"},
+		"lightYellow":  {"93", "103"},
+		"lightBlue":    {"94", "104"},
+		"lightMagenta": {"95", "105"},
+		"lightCyan":    {"96", "106"},
+		"lightWhite":   {"97", "107"},
+	}
+)
+
+const (
+	foregroundIndex = 0
+	backgroundIndex = 1
+)
+
+func (*DefaultColors) AnsiColorFromString(colorString string, isBackground bool) AnsiColor {
+	if len(colorString) == 0 {
+		return emptyAnsiColor
+	}
+	if colorString == Transparent {
+		return transparentAnsiColor
+	}
+	colorFromName, err := getAnsiColorFromName(colorString, isBackground)
+	if err == nil {
+		return colorFromName
+	}
+	style := color.HEX(colorString, isBackground)
+	if style.IsEmpty() {
+		return emptyAnsiColor
+	}
+	return AnsiColor(style.String())
+}
+
+// getAnsiColorFromName returns the color code for a given color name if the name is
+// known ANSI color name.
+func getAnsiColorFromName(colorName string, isBackground bool) (AnsiColor, error) {
+	if colorCodes, found := ansiColorCodes[colorName]; found {
+		if isBackground {
+			return colorCodes[backgroundIndex], nil
+		}
+		return colorCodes[foregroundIndex], nil
+	}
+	return "", fmt.Errorf("color name %s does not exist", colorName)
+}
+
+func IsAnsiColorName(colorString string) bool {
+	_, ok := ansiColorCodes[colorString]
+	return ok
+}
+
+// PaletteColors is the AnsiColors Decorator that uses the Palette to do named color
+// lookups before ANSI color code generation.
+type PaletteColors struct {
+	ansiColors AnsiColors
+	palette    Palette
+}
+
+func (p *PaletteColors) AnsiColorFromString(colorString string, isBackground bool) AnsiColor {
+	paletteColor, err := p.palette.ResolveColor(colorString)
+	if err != nil {
+		return emptyAnsiColor
+	}
+	ansiColor := p.ansiColors.AnsiColorFromString(paletteColor, isBackground)
+	return ansiColor
+}
+
+// CachedColors is the AnsiColors Decorator that does simple color lookup caching.
+// AnsiColorFromString calls are cheap, but not free, and having a simple cache in
+// has measurable positive effect on performance.
+type CachedColors struct {
+	ansiColors AnsiColors
+	colorCache map[cachedColorKey]AnsiColor
+}
+
+type cachedColorKey struct {
+	colorString  string
+	isBackground bool
+}
+
+func (c *CachedColors) AnsiColorFromString(colorString string, isBackground bool) AnsiColor {
+	if c.colorCache == nil {
+		c.colorCache = make(map[cachedColorKey]AnsiColor)
+	}
+	key := cachedColorKey{colorString, isBackground}
+	if ansiColor, hit := c.colorCache[key]; hit {
+		return ansiColor
+	}
+	ansiColor := c.ansiColors.AnsiColorFromString(colorString, isBackground)
+	c.colorCache[key] = ansiColor
+	return ansiColor
+}
diff --git a/src/config.go b/src/config.go
index 738475bc..273c66c9 100644
--- a/src/config.go
+++ b/src/config.go
@@ -29,6 +29,7 @@ type Config struct {
 	Blocks               []*Block          `config:"blocks"`
 	Tooltips             []*Segment        `config:"tooltips"`
 	TransientPrompt      *TransientPrompt  `config:"transient_prompt"`
+	Palette              Palette           `config:"palette"`
 }
 
 type TransientPrompt struct {
diff --git a/src/main.go b/src/main.go
index b5c45108..cac3a708 100644
--- a/src/main.go
+++ b/src/main.go
@@ -209,9 +209,11 @@ func main() {
 	if *args.Plain {
 		writer = &PlainWriter{}
 	} else {
+		writerColors := MakeColors(env, cfg)
 		writer = &AnsiWriter{
 			ansi:               ansi,
 			terminalBackground: getConsoleBackgroundColor(env, cfg.TerminalBackground),
+			ansiColors:         writerColors,
 		}
 	}
 	title := &consoleTitle{
diff --git a/src/palette.go b/src/palette.go
new file mode 100644
index 00000000..d476819b
--- /dev/null
+++ b/src/palette.go
@@ -0,0 +1,102 @@
+package main
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+)
+
+type Palette map[string]string
+
+const (
+	paletteKeyPrefix         = "p:"
+	paletteKeyError          = "palette: requested color %s does not exist in palette of colors %s"
+	paletteMaxRecursionDepth = 3 // allows 3 or less recusive resolutions
+	paletteRecursiveKeyError = "palette: recursive resolution of color %s returned palette reference %s and reached recursion depth %d"
+)
+
+// ResolveColor gets a color value from the palette using given colorName.
+// If colorName is not a palette reference, it is returned as is.
+func (p Palette) ResolveColor(colorName string) (string, error) {
+	return p.resolveColor(colorName, 1, &colorName)
+}
+
+// originalColorName is a pointer to save allocations
+func (p Palette) resolveColor(colorName string, depth int, originalColorName *string) (string, error) {
+	key, ok := asPaletteKey(colorName)
+	// colorName is not a palette key, return it as is
+	if !ok {
+		return colorName, nil
+	}
+
+	color, ok := p[key]
+	if !ok {
+		return "", &PaletteKeyError{Key: key, palette: p}
+	}
+
+	if _, isKey := isPaletteKey(color); isKey {
+		if depth > paletteMaxRecursionDepth {
+			return "", &PaletteRecursiveKeyError{Key: *originalColorName, Value: color, depth: depth}
+		}
+
+		return p.resolveColor(color, depth+1, originalColorName)
+	}
+
+	return color, nil
+}
+
+func asPaletteKey(colorName string) (string, bool) {
+	prefix, isKey := isPaletteKey(colorName)
+	if !isKey {
+		return "", false
+	}
+
+	key := strings.TrimPrefix(colorName, prefix)
+
+	return key, true
+}
+
+func isPaletteKey(colorName string) (string, bool) {
+	return paletteKeyPrefix, strings.HasPrefix(colorName, paletteKeyPrefix)
+}
+
+// PaletteKeyError records the missing Palette key.
+type PaletteKeyError struct {
+	Key     string
+	palette Palette
+}
+
+func (p *PaletteKeyError) Error() string {
+	keys := make([]string, 0, len(p.palette))
+	for key := range p.palette {
+		keys = append(keys, key)
+	}
+	sort.Strings(keys)
+	allColors := strings.Join(keys, ",")
+	errorStr := fmt.Sprintf(paletteKeyError, p.Key, allColors)
+	return errorStr
+}
+
+// PaletteRecursiveKeyError records the Palette key and resolved color value (which
+// is also a Palette key)
+type PaletteRecursiveKeyError struct {
+	Key   string
+	Value string
+	depth int
+}
+
+func (p *PaletteRecursiveKeyError) Error() string {
+	errorStr := fmt.Sprintf(paletteRecursiveKeyError, p.Key, p.Value, p.depth)
+	return errorStr
+}
+
+// maybeResolveColor wraps resolveColor and silences possible errors, returning
+// Transparent color by default, as a Block does not know how to handle color errors.
+func (p Palette) MaybeResolveColor(colorName string) string {
+	color, err := p.ResolveColor(colorName)
+	if err != nil {
+		return ""
+	}
+
+	return color
+}
diff --git a/src/properties.go b/src/properties.go
index b494d188..a8baeead 100644
--- a/src/properties.go
+++ b/src/properties.go
@@ -71,11 +71,10 @@ func (p *properties) getColor(property Property, defaultValue string) string {
 		return defaultValue
 	}
 	colorString := parseString(val, defaultValue)
-	_, err := getColorFromName(colorString, false)
-	if err == nil {
+	if IsAnsiColorName(colorString) {
 		return colorString
 	}
-	values := findNamedRegexMatch(`(?P<color>#[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})`, colorString)
+	values := findNamedRegexMatch(`(?P<color>#[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|p:.*)`, colorString)
 	if values != nil && values["color"] != "" {
 		return values["color"]
 	}
diff --git a/src/writer_ansi.go b/src/writer_ansi.go
index caf8706c..460aa036 100644
--- a/src/writer_ansi.go
+++ b/src/writer_ansi.go
@@ -1,52 +1,14 @@
 package main
 
 import (
-	"errors"
 	"fmt"
 	"strings"
-
-	"github.com/gookit/color"
-)
-
-var (
-	// Map for color names and their respective foreground [0] or background [1] color codes
-	colorMap = map[string][2]string{
-		"black":        {"30", "40"},
-		"red":          {"31", "41"},
-		"green":        {"32", "42"},
-		"yellow":       {"33", "43"},
-		"blue":         {"34", "44"},
-		"magenta":      {"35", "45"},
-		"cyan":         {"36", "46"},
-		"white":        {"37", "47"},
-		"default":      {"39", "49"},
-		"darkGray":     {"90", "100"},
-		"lightRed":     {"91", "101"},
-		"lightGreen":   {"92", "102"},
-		"lightYellow":  {"93", "103"},
-		"lightBlue":    {"94", "104"},
-		"lightMagenta": {"95", "105"},
-		"lightCyan":    {"96", "106"},
-		"lightWhite":   {"97", "107"},
-	}
 )
 
 const (
 	colorRegex = `<(?P<foreground>[^,>]+)?,?(?P<background>[^>]+)?>(?P<content>[^<]*)<\/>`
 )
 
-// Returns the color code for a given color name
-func getColorFromName(colorName string, isBackground bool) (string, error) {
-	colorMapOffset := 0
-	if isBackground {
-		colorMapOffset = 1
-	}
-	if colorCodes, found := colorMap[colorName]; found {
-		return colorCodes[colorMapOffset], nil
-	}
-	return "", errors.New("color name does not exist")
-}
-
 type promptWriter interface {
 	write(background, foreground, text string)
 	string() string
@@ -63,6 +25,7 @@ type AnsiWriter struct {
 	terminalBackground string
 	Colors             *Color
 	ParentColors       *Color
+	ansiColors         AnsiColors
 }
 
 type Color struct {
@@ -70,6 +33,32 @@ type Color struct {
 	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
+}
+
 const (
 	// Transparent implies a transparent color
 	Transparent = "transparent"
@@ -101,44 +90,30 @@ func (a *AnsiWriter) clearParentColors() {
 	a.ParentColors = nil
 }
 
-// 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`.
-func (a *AnsiWriter) getAnsiFromColorString(colorString string, isBackground bool) string {
-	if colorString == Transparent || len(colorString) == 0 {
-		return colorString
-	}
-	colorFromName, err := getColorFromName(colorString, isBackground)
-	if err == nil {
-		return colorFromName
-	}
-	style := color.HEX(colorString, isBackground)
-	if style.IsEmpty() {
-		return ""
-	}
-	return style.String()
+func (a *AnsiWriter) getAnsiFromColorString(colorString string, isBackground bool) AnsiColor {
+	return a.ansiColors.AnsiColorFromString(colorString, isBackground)
 }
 
-func (a *AnsiWriter) writeColoredText(background, foreground, text string) {
+func (a *AnsiWriter) writeColoredText(background, foreground AnsiColor, text string) {
 	// Avoid emitting empty strings with color codes
-	if text == "" || (foreground == Transparent && background == Transparent) {
+	if text == "" || (foreground.IsTransparent() && background.IsTransparent()) {
 		return
 	}
 	// default to white fg if empty, empty backgrond is supported
-	if len(foreground) == 0 {
+	if foreground.IsEmpty() {
 		foreground = a.getAnsiFromColorString("white", false)
 	}
-	if foreground == Transparent && len(background) != 0 && len(a.terminalBackground) != 0 {
+	if foreground.IsTransparent() && !background.IsEmpty() && len(a.terminalBackground) != 0 {
 		fgAnsiColor := a.getAnsiFromColorString(a.terminalBackground, false)
 		coloredText := fmt.Sprintf(a.ansi.colorFull, background, fgAnsiColor, text)
 		a.builder.WriteString(coloredText)
 		return
 	}
-	if foreground == Transparent && len(background) != 0 {
+	if foreground.IsTransparent() && !background.IsEmpty() {
 		coloredText := fmt.Sprintf(a.ansi.colorTransparent, background, text)
 		a.builder.WriteString(coloredText)
 		return
-	} else if len(background) == 0 || background == Transparent {
+	} else if background.IsEmpty() || background.IsTransparent() {
 		coloredText := fmt.Sprintf(a.ansi.colorSingle, foreground, text)
 		a.builder.WriteString(coloredText)
 		return
@@ -147,7 +122,7 @@ func (a *AnsiWriter) writeColoredText(background, foreground, text string) {
 	a.builder.WriteString(coloredText)
 }
 
-func (a *AnsiWriter) writeAndRemoveText(background, foreground, text, textToRemove, parentText string) string {
+func (a *AnsiWriter) writeAndRemoveText(background, foreground AnsiColor, text, textToRemove, parentText string) string {
 	a.writeColoredText(background, foreground, text)
 	return strings.Replace(parentText, textToRemove, "", 1)
 }
@@ -157,60 +132,68 @@ func (a *AnsiWriter) write(background, foreground, text string) {
 		return
 	}
 
-	getAnsiColors := func(background, foreground string) (string, string) {
-		getColorString := func(color string) string {
-			if color == Background {
-				color = a.Colors.Background
-			} else if color == Foreground {
-				color = a.Colors.Foreground
-			} else if color == ParentBackground && a.ParentColors != nil {
-				color = a.ParentColors.Background
-			} else if color == ParentForeground && a.ParentColors != nil {
-				color = a.ParentColors.Foreground
-			} else if (color == ParentForeground || color == ParentBackground) && a.ParentColors == nil {
-				color = Transparent
-			}
-			return color
-		}
-		background = getColorString(background)
-		foreground = getColorString(foreground)
-		inverted := foreground == Transparent && len(background) != 0
-		background = a.getAnsiFromColorString(background, !inverted)
-		foreground = a.getAnsiFromColorString(foreground, false)
-		return background, foreground
-	}
-
-	bgAnsi, fgAnsi := getAnsiColors(background, foreground)
+	bgAnsi, fgAnsi := a.asAnsiColors(background, foreground)
 	text = a.ansi.escapeText(text)
 	text = a.ansi.formatText(text)
 	text = a.ansi.generateHyperlink(text)
 
 	// first we match for any potentially valid colors enclosed in <>
-	match := findAllNamedRegexMatch(colorRegex, text)
-	for i := range match {
-		fg := match[i]["foreground"]
-		bg := match[i]["background"]
-		if fg == Transparent && len(bg) == 0 {
-			bg = background
+	// i.e., find color overrides
+	overrides := findAllNamedRegexMatch(colorRegex, text)
+	for _, override := range overrides {
+		fgOverride := override["foreground"]
+		bgOverride := override["background"]
+		if fgOverride == Transparent && len(bgOverride) == 0 {
+			bgOverride = background
 		}
-		bg, fg = getAnsiColors(bg, fg)
+		bgOverrideAnsi, fgOverrideAnsi := a.asAnsiColors(bgOverride, fgOverride)
 		// set colors if they are empty
-		if len(bg) == 0 {
-			bg = bgAnsi
+		if bgOverrideAnsi.IsEmpty() {
+			bgOverrideAnsi = bgAnsi
 		}
-		if len(fg) == 0 {
-			fg = fgAnsi
+		if fgOverrideAnsi.IsEmpty() {
+			fgOverrideAnsi = fgAnsi
 		}
-		escapedTextSegment := match[i]["text"]
-		innerText := match[i]["content"]
+		escapedTextSegment := override["text"]
+		innerText := override["content"]
 		textBeforeColorOverride := strings.Split(text, escapedTextSegment)[0]
 		text = a.writeAndRemoveText(bgAnsi, fgAnsi, textBeforeColorOverride, textBeforeColorOverride, text)
-		text = a.writeAndRemoveText(bg, fg, innerText, escapedTextSegment, 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) {
+	if backgroundValue, ok := a.isKeyword(background); ok {
+		background = backgroundValue
+	}
+	if foregroundValue, ok := a.isKeyword(foreground); ok {
+		foreground = foregroundValue
+	}
+	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) (string, bool) {
+	switch {
+	case color == Background:
+		return a.Colors.Background, true
+	case color == Foreground:
+		return a.Colors.Foreground, true
+	case color == ParentBackground && a.ParentColors != nil:
+		return a.ParentColors.Background, true
+	case color == ParentForeground && a.ParentColors != nil:
+		return a.ParentColors.Foreground, true
+	case (color == ParentBackground || color == ParentForeground) && a.ParentColors == nil:
+		return Transparent, true
+	default:
+		return "", false
+	}
+}
+
 func (a *AnsiWriter) string() string {
 	return a.builder.String()
 }
diff --git a/themes/schema.json b/themes/schema.json
index 6cd4d6fe..9d3e397f 100644
--- a/themes/schema.json
+++ b/themes/schema.json
@@ -6,12 +6,24 @@
   "description": "https://ohmyposh.dev/docs/config-overview",
   "definitions": {
     "color": {
+      "anyOf": [
+        { "$ref": "#/definitions/color_string" },
+        { "$ref": "#/definitions/palette_reference" }
+      ]
+    },
+    "color_string": {
       "type": "string",
       "pattern": "^(#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})|black|red|green|yellow|blue|magenta|cyan|white|default|darkGray|lightRed|lightGreen|lightYellow|lightBlue|lightMagenta|lightCyan|lightWhite|transparent|parentBackground|parentForeground|background|foreground)$",
       "title": "Color string",
       "description": "https://ohmyposh.dev/docs/config-colors",
       "format": "color"
     },
+    "palette_reference": {
+      "type": "string",
+      "pattern": "^p:.*$",
+      "title": "Palette reference",
+      "description": "https://ohmyposh.dev/docs/config-colors#palette"
+    },
     "color_templates": {
       "type": "array",
       "title": "Templates to define a color",
@@ -1763,6 +1775,15 @@
         "background": { "$ref": "#/definitions/color" },
         "foreground": { "$ref": "#/definitions/color" }
       }
+    },
+    "palette": {
+      "type": "object",
+      "title": "Palette",
+      "description": "https://ohmyposh.dev/docs/config-colors#palette",
+      "default": {},
+      "patternProperties": {
+        ".*": { "$ref": "#/definitions/color" }
+      }
     }
   }
 }


Test patch:
diff --git a/src/colors_test.go b/src/colors_test.go
new file mode 100644
index 00000000..628082de
--- /dev/null
+++ b/src/colors_test.go
@@ -0,0 +1,58 @@
+package main
+
+import (
+	"testing"
+
+	"github.com/alecthomas/assert"
+)
+
+func TestGetAnsiFromColorString(t *testing.T) {
+	cases := []struct {
+		Case       string
+		Expected   AnsiColor
+		Color      string
+		Background bool
+	}{
+		{Case: "Invalid background", Expected: emptyAnsiColor, Color: "invalid", Background: true},
+		{Case: "Invalid background", Expected: emptyAnsiColor, Color: "invalid", Background: false},
+		{Case: "Hex foreground", Expected: AnsiColor("38;2;170;187;204"), Color: "#AABBCC", Background: false},
+		{Case: "Hex backgrond", Expected: AnsiColor("48;2;170;187;204"), Color: "#AABBCC", Background: true},
+		{Case: "Base 8 foreground", Expected: AnsiColor("31"), Color: "red", Background: false},
+		{Case: "Base 8 background", Expected: AnsiColor("41"), Color: "red", Background: true},
+		{Case: "Base 16 foreground", Expected: AnsiColor("91"), Color: "lightRed", Background: false},
+		{Case: "Base 16 backround", Expected: AnsiColor("101"), Color: "lightRed", Background: true},
+	}
+	for _, tc := range cases {
+		ansiColors := &DefaultColors{}
+		ansiColor := ansiColors.AnsiColorFromString(tc.Color, tc.Background)
+		assert.Equal(t, tc.Expected, ansiColor, tc.Case)
+	}
+}
+
+func TestMakeColors(t *testing.T) {
+	colors := makeColors(nil, false)
+	assert.IsType(t, &DefaultColors{}, colors)
+
+	colors = makeColors(nil, true)
+	assert.IsType(t, &CachedColors{}, colors)
+	assert.IsType(t, &DefaultColors{}, colors.(*CachedColors).ansiColors)
+
+	colors = makeColors(testPalette, false)
+	assert.IsType(t, &PaletteColors{}, colors)
+	assert.IsType(t, &DefaultColors{}, colors.(*PaletteColors).ansiColors)
+
+	colors = makeColors(testPalette, true)
+	assert.IsType(t, &CachedColors{}, colors)
+	assert.IsType(t, &PaletteColors{}, colors.(*CachedColors).ansiColors)
+	assert.IsType(t, &DefaultColors{}, colors.(*CachedColors).ansiColors.(*PaletteColors).ansiColors)
+}
+
+func BenchmarkEngineRenderPalette(b *testing.B) {
+	var err error
+	for i := 0; i < b.N; i++ {
+		err = engineRender("jandedobbeleer-palette.omp.json")
+		if err != nil {
+			b.Fatal(err)
+		}
+	}
+}
diff --git a/src/config_test.go b/src/config_test.go
index 2ea3b815..bfe93800 100644
--- a/src/config_test.go
+++ b/src/config_test.go
@@ -3,11 +3,17 @@ package main
 import (
 	"testing"
 
+	"github.com/gookit/config/v2"
 	"github.com/stretchr/testify/assert"
 )
 
 func TestSettingsExportJSON(t *testing.T) {
+	defer testClearDefaultConfig()
 	content := exportConfig("../themes/jandedobbeleer.omp.json", "json")
 	assert.NotContains(t, content, "\\u003ctransparent\\u003e")
 	assert.Contains(t, content, "<transparent>")
 }
+
+func testClearDefaultConfig() {
+	config.Default().ClearAll()
+}
diff --git a/src/engine_test.go b/src/engine_test.go
index b9eaf37b..2d13968f 100644
--- a/src/engine_test.go
+++ b/src/engine_test.go
@@ -2,6 +2,8 @@ package main
 
 import (
 	"errors"
+	"os"
+	"path/filepath"
 	"strings"
 	"testing"
 
@@ -41,3 +43,78 @@ func TestCanWriteRPrompt(t *testing.T) {
 		assert.Equal(t, tc.Expected, got, tc.Case)
 	}
 }
+
+func BenchmarkEngineRender(b *testing.B) {
+	var err error
+	for i := 0; i < b.N; i++ {
+		err = engineRender("jandedobbeleer.omp.json")
+		if err != nil {
+			b.Fatal(err)
+		}
+	}
+}
+
+func engineRender(configPath string) error {
+	testDir, err := os.Getwd()
+	if err != nil {
+		return err
+	}
+
+	configPath = filepath.Join(testDir, "testdata", configPath)
+
+	var (
+		debug    = false
+		eval     = false
+		shell    = "pwsh"
+		plain    = false
+		pwd      = ""
+		pswd     = ""
+		code     = 2
+		execTime = 917.0
+	)
+
+	args := &args{
+		Debug:         &debug,
+		Config:        &configPath,
+		Eval:          &eval,
+		Shell:         &shell,
+		Plain:         &plain,
+		PWD:           &pwd,
+		PSWD:          &pswd,
+		ErrorCode:     &code,
+		ExecutionTime: &execTime,
+	}
+
+	env := &environment{}
+	env.init(args)
+	defer env.close()
+
+	cfg := GetConfig(env)
+	defer testClearDefaultConfig()
+
+	ansi := &ansiUtils{}
+	ansi.init(env.getShellName())
+	writerColors := MakeColors(env, cfg)
+	writer := &AnsiWriter{
+		ansi:               ansi,
+		terminalBackground: getConsoleBackgroundColor(env, cfg.TerminalBackground),
+		ansiColors:         writerColors,
+	}
+	title := &consoleTitle{
+		env:    env,
+		config: cfg,
+		ansi:   ansi,
+	}
+	engine := &engine{
+		config:       cfg,
+		env:          env,
+		writer:       writer,
+		consoleTitle: title,
+		ansi:         ansi,
+		plain:        *args.Plain,
+	}
+
+	engine.render()
+
+	return nil
+}
diff --git a/src/palette_test.go b/src/palette_test.go
new file mode 100644
index 00000000..95b61bc5
--- /dev/null
+++ b/src/palette_test.go
@@ -0,0 +1,233 @@
+package main
+
+import (
+	"testing"
+
+	"github.com/alecthomas/assert"
+)
+
+var (
+	testPalette = Palette{
+		"red":   "#FF0000",
+		"green": "#00FF00",
+		"blue":  "#0000FF",
+		"white": "#FFFFFF",
+		"black": "#000000",
+	}
+)
+
+type TestPaletteRequest struct {
+	Case          string
+	Request       string
+	ExpectedError bool
+	Expected      string
+}
+
+func TestPaletteShouldResolveColorFromTestPalette(t *testing.T) {
+	cases := []TestPaletteRequest{
+		{Case: "Palette red", Request: "p:red", Expected: "#FF0000"},
+		{Case: "Palette green", Request: "p:green", Expected: "#00FF00"},
+		{Case: "Palette blue", Request: "p:blue", Expected: "#0000FF"},
+		{Case: "Palette white", Request: "p:white", Expected: "#FFFFFF"},
+		{Case: "Palette black", Request: "p:black", Expected: "#000000"},
+	}
+
+	for _, tc := range cases {
+		testPaletteRequest(t, tc)
+	}
+}
+
+func testPaletteRequest(t *testing.T, tc TestPaletteRequest) {
+	actual, err := testPalette.ResolveColor(tc.Request)
+
+	if !tc.ExpectedError {
+		assert.Nil(t, err, tc.Case)
+		assert.Equal(t, tc.Expected, actual, "expected different color value")
+	} else {
+		assert.NotNil(t, err, tc.Case)
+		assert.Equal(t, tc.Expected, err.Error())
+	}
+}
+
+func TestPaletteShouldIgnoreNonPaletteColors(t *testing.T) {
+	cases := []TestPaletteRequest{
+		{Case: "Deep puprple", Request: "#1F1137", Expected: "#1F1137"},
+		{Case: "Light red", Request: "#D55252", Expected: "#D55252"},
+		{Case: "ANSI black", Request: "black", Expected: "black"},
+		{Case: "Foreground", Request: "foreground", Expected: "foreground"},
+	}
+
+	for _, tc := range cases {
+		testPaletteRequest(t, tc)
+	}
+}
+
+func TestPaletteShouldReturnErrorOnMissingColor(t *testing.T) {
+	cases := []TestPaletteRequest{
+		{
+			Case:          "Palette deep purple",
+			Request:       "p:deep-purple",
+			ExpectedError: true,
+			Expected:      "palette: requested color deep-purple does not exist in palette of colors black,blue,green,red,white",
+		},
+		{
+			Case:          "Palette cyan",
+			Request:       "p:cyan",
+			ExpectedError: true,
+			Expected:      "palette: requested color cyan does not exist in palette of colors black,blue,green,red,white",
+		},
+		{
+			Case:          "Palette foreground",
+			Request:       "p:foreground",
+			ExpectedError: true,
+			Expected:      "palette: requested color foreground does not exist in palette of colors black,blue,green,red,white",
+		},
+	}
+
+	for _, tc := range cases {
+		testPaletteRequest(t, tc)
+	}
+}
+
+func TestPaletteShouldHandleMixedCases(t *testing.T) {
+	cases := []TestPaletteRequest{
+		{Case: "Palette red", Request: "p:red", Expected: "#FF0000"},
+		{Case: "ANSI black", Request: "black", Expected: "black"},
+		{Case: "Cyan", Request: "#05E6FA", Expected: "#05E6FA"},
+		{Case: "Palette black", Request: "p:black", Expected: "#000000"},
+		{Case: "Palette pink", Request: "p:pink", ExpectedError: true, Expected: "palette: requested color pink does not exist in palette of colors black,blue,green,red,white"},
+	}
+
+	for _, tc := range cases {
+		testPaletteRequest(t, tc)
+	}
+}
+
+func TestPaletteShouldUseEmptyColorByDefault(t *testing.T) {
+	cases := []TestPaletteRequest{
+		{Case: "Palette magenta", Request: "p:magenta", Expected: ""},
+		{Case: "Palette gray", Request: "p:gray", Expected: ""},
+		{Case: "Palette rose", Request: "p:rose", Expected: ""},
+	}
+
+	for _, tc := range cases {
+		actual := testPalette.MaybeResolveColor(tc.Request)
+
+		assert.Equal(t, tc.Expected, actual, "expected different color value")
+	}
+}
+
+func TestPaletteShouldResolveRecursiveReference(t *testing.T) {
+	tp := Palette{
+		"light-blue": "#CAF0F8",
+		"dark-blue":  "#023E8A",
+		"foreground": "p:light-blue",
+		"background": "p:dark-blue",
+		"text":       "p:foreground",
+		"icon":       "p:background",
+		"void":       "p:void", // infinite recursion - error
+		"1":          "white",
+		"2":          "p:1",
+		"3":          "p:2",
+		"4":          "p:3", // 3 recursive lookups - allowed
+		"5":          "p:4", // 4 recursive lookups - error
+	}
+
+	cases := []TestPaletteRequest{
+		{
+			Case:     "Palette light-blue",
+			Request:  "p:light-blue",
+			Expected: "#CAF0F8",
+		},
+		{
+			Case:     "Palette foreground",
+			Request:  "p:foreground",
+			Expected: "#CAF0F8",
+		},
+		{
+			Case:     "Palette background",
+			Request:  "p:background",
+			Expected: "#023E8A",
+		},
+		{
+			Case:     "Palette text (2 recursive lookups)",
+			Request:  "p:text",
+			Expected: "#CAF0F8",
+		},
+		{
+			Case:     "Palette icon (2 recursive lookups)",
+			Request:  "p:icon",
+			Expected: "#023E8A",
+		},
+		{
+			Case:          "Palette void (infinite recursion)",
+			Request:       "p:void",
+			ExpectedError: true,
+			Expected:      "palette: recursive resolution of color p:void returned palette reference p:void and reached recursion depth 4",
+		},
+		{
+			Case:     "Palette p:4 (3 recursive lookups)",
+			Request:  "p:4",
+			Expected: "white",
+		},
+		{
+			Case:          "Palette p:5 (4 recursive lookups)",
+			Request:       "p:5",
+			ExpectedError: true,
+			Expected:      "palette: recursive resolution of color p:5 returned palette reference p:1 and reached recursion depth 4",
+		},
+	}
+
+	for _, tc := range cases {
+		actual, err := tp.ResolveColor(tc.Request)
+
+		if !tc.ExpectedError {
+			assert.Nil(t, err, "expected no error")
+			assert.Equal(t, tc.Expected, actual, "expected different color value")
+		} else {
+			assert.NotNil(t, err, "expected error")
+			assert.Equal(t, tc.Expected, err.Error())
+		}
+	}
+}
+
+func TestPaletteShouldHandleEmptyKey(t *testing.T) {
+	tp := Palette{
+		"": "#000000",
+	}
+
+	actual, err := tp.ResolveColor("p:")
+
+	assert.Nil(t, err, "expected no error")
+	assert.Equal(t, "#000000", actual, "expected different color value")
+}
+
+func BenchmarkPaletteMixedCaseResolution(b *testing.B) {
+	for i := 0; i < b.N; i++ {
+		benchmarkPaletteMixedCaseResolution()
+	}
+}
+
+func benchmarkPaletteMixedCaseResolution() {
+	cases := []TestPaletteRequest{
+		{Case: "Palette red", Request: "p:red", Expected: "#FF0000"},
+		{Case: "ANSI black", Request: "black", Expected: "black"},
+		{Case: "Cyan", Request: "#05E6FA", Expected: "#05E6FA"},
+		{Case: "Palette black", Request: "p:black", Expected: "#000000"},
+		{Case: "Palette pink", Request: "p:pink", ExpectedError: true, Expected: "palette: requested color pink does not exist in palette of colors black,blue,green,red,white"},
+		{Case: "Palette blue", Request: "p:blue", Expected: "#0000FF"},
+		// repeating the same set to have longer benchmarks
+		{Case: "Palette red", Request: "p:red", Expected: "#FF0000"},
+		{Case: "ANSI black", Request: "black", Expected: "black"},
+		{Case: "Cyan", Request: "#05E6FA", Expected: "#05E6FA"},
+		{Case: "Palette black", Request: "p:black", Expected: "#000000"},
+		{Case: "Palette pink", Request: "p:pink", ExpectedError: true, Expected: "palette: requested color pink does not exist in palette of colors black,blue,green,red,white"},
+		{Case: "Palette blue", Request: "p:blue", Expected: "#0000FF"},
+	}
+
+	for _, tc := range cases {
+		// both value and error values are irrelevant, but such assignment calms down
+		// golangci-lint "return value of `testPalette.ResolveColor` is not checked" error
+		_, _ = testPalette.ResolveColor(tc.Request)
+	}
+}
diff --git a/src/properties_test.go b/src/properties_test.go
index a39d6326..9e58c800 100644
--- a/src/properties_test.go
+++ b/src/properties_test.go
@@ -78,6 +78,16 @@ func TestDefaultColorWithUnavailableProperty(t *testing.T) {
 	assert.Equal(t, expected, value)
 }
 
+func TestGetPaletteColor(t *testing.T) {
+	expected := "p:red"
+	values := map[Property]interface{}{Background: expected}
+	properties := properties{
+		values: values,
+	}
+	value := properties.getColor(Background, "white")
+	assert.Equal(t, expected, value)
+}
+
 func TestGetBool(t *testing.T) {
 	expected := true
 	values := map[Property]interface{}{DisplayHost: expected}
diff --git a/src/segment_path_test.go b/src/segment_path_test.go
index c8a555b9..a76a43f3 100644
--- a/src/segment_path_test.go
+++ b/src/segment_path_test.go
@@ -726,6 +726,7 @@ func TestGetPwd(t *testing.T) {
 }
 
 func TestParseMappedLocations(t *testing.T) {
+	defer testClearDefaultConfig()
 	cases := []struct {
 		Case string
 		JSON string
diff --git a/src/testdata/jandedobbeleer-palette.omp.json b/src/testdata/jandedobbeleer-palette.omp.json
new file mode 100644
index 00000000..3c8bf8cd
--- /dev/null
+++ b/src/testdata/jandedobbeleer-palette.omp.json
@@ -0,0 +1,271 @@
+{
+  "palette": {
+    "session": "#C386F1",
+    "path": "#FF479C",
+    "git-foreground": "#193549",
+    "git": "#FFFB38",
+    "git-modified": "#FF9248",
+    "git-diverged": "#FF4500",
+    "git-ahead": "#B388FF",
+    "git-behind": "#B388FF",
+    "node": "#6CA35E",
+    "go": "#8ED1F7",
+    "julia": "#4063D8",
+    "python": "#FFDE57",
+    "ruby": "#AE1401",
+    "azfunc": "#FEAC19",
+    "aws-default": "#FFA400",
+    "aws-jan": "#F1184C",
+    "root": "#FFFF66",
+    "executiontime": "#83769C",
+    "exit": "#00897B",
+    "exit-red": "#E91E63",
+    "shell": "#0077C2",
+    "ytm": "#1BD760",
+    "battery": "#F36943",
+    "battery-charged": "#4CAF50",
+    "battery-charging": "#40C4FF",
+    "battery-discharging": "#FF5722",
+    "time": "#2E9599",
+    "white": "#FFFFFF",
+    "black": "#111111"
+  },
+  "blocks": [
+    {
+      "type": "prompt",
+      "alignment": "left",
+      "segments": [
+        {
+          "type": "session",
+          "style": "diamond",
+          "foreground": "palette:white",
+          "background": "palette:session",
+          "leading_diamond": "",
+          "trailing_diamond": "\uE0B0",
+          "properties": {
+            "postfix": " ",
+            "display_host": false
+          }
+        },
+        {
+          "type": "path",
+          "style": "powerline",
+          "powerline_symbol": "\uE0B0",
+          "foreground": "palette:white",
+          "background": "palette:path",
+          "properties": {
+            "prefix": "   ",
+            "home_icon": "~",
+            "folder_separator_icon": " \uE0b1 ",
+            "style": "folder"
+          }
+        },
+        {
+          "type": "git",
+          "style": "powerline",
+          "powerline_symbol": "\uE0B0",
+          "foreground": "palette:git-foreground",
+          "background": "palette:git",
+          "background_templates": [
+            "{{ if or (.Working.Changed) (.Staging.Changed) }}palette:git-modified{{ end }}",
+            "{{ if and (gt .Ahead 0) (gt .Behind 0) }}palette:git-diverged{{ end }}",
+            "{{ if gt .Ahead 0 }}palette:git-ahead{{ end }}",
+            "{{ if gt .Behind 0 }}palette:git-behind{{ end }}"
+          ],
+          "leading_diamond": "",
+          "trailing_diamond": "",
+          "properties": {
+            "fetch_status": true,
+            "fetch_stash_count": true,
+            "fetch_upstream_icon": true,
+            "branch_max_length": 25,
+            "template": "{{ .UpstreamIcon }}{{ .HEAD }}{{ .BranchStatus }}{{ if .Working.Changed }} \uF044 {{ .Working.String }}{{ end }}{{ if and (.Working.Changed) (.Staging.Changed) }} |{{ end }}{{ if .Staging.Changed }} \uF046 {{ .Staging.String }}{{ end }}{{ if gt .StashCount 0 }} \uF692 {{ .StashCount }}{{ end }}"
+          }
+        },
+        {
+          "type": "node",
+          "style": "powerline",
+          "powerline_symbol": "\uE0B0",
+          "foreground": "palette:white",
+          "background": "palette:node",
+          "properties": {
+            "prefix": " \uF898 ",
+            "display_version": true
+          }
+        },
+        {
+          "type": "go",
+          "style": "powerline",
+          "powerline_symbol": "\uE0B0",
+          "foreground": "palette:black",
+          "background": "palette:go",
+          "properties": {
+            "prefix": " \uE626 ",
+            "display_version": true
+          }
+        },
+        {
+          "type": "julia",
+          "style": "powerline",
+          "powerline_symbol": "\uE0B0",
+          "foreground": "palette:black",
+          "background": "palette:julia",
+          "properties": {
+            "prefix": " \uE624 ",
+            "display_version": true
+          }
+        },
+        {
+          "type": "python",
+          "style": "powerline",
+          "powerline_symbol": "\uE0B0",
+          "foreground": "palette:black",
+          "background": "palette:python",
+          "properties": {
+            "prefix": " \uE235 ",
+            "display_version": true,
+            "display_mode": "files",
+            "display_virtual_env": false
+          }
+        },
+        {
+          "type": "ruby",
+          "style": "powerline",
+          "powerline_symbol": "\uE0B0",
+          "foreground": "palette:white",
+          "background": "palette:ruby",
+          "properties": {
+            "prefix": " \uE791 ",
+            "display_version": true,
+            "display_mode": "files"
+          }
+        },
+        {
+          "type": "azfunc",
+          "style": "powerline",
+          "powerline_symbol": "\uE0B0",
+          "foreground": "palette:white",
+          "background": "palette:azfunc",
+          "properties": {
+            "prefix": " \uf0e7",
+            "display_version": false,
+            "display_mode": "files"
+          }
+        },
+        {
+          "type": "aws",
+          "style": "powerline",
+          "powerline_symbol": "\uE0B0",
+          "foreground": "palette:white",
+          "background_templates": [
+            "{{if contains \"default\" .Profile}}palette:aws-default{{end}}",
+            "{{if contains \"jan\" .Profile}}palette:aws-jan{{end}}"
+          ],
+          "properties": {
+            "prefix": " \uE7AD ",
+            "display_default": false
+          }
+        },
+        {
+          "type": "root",
+          "style": "powerline",
+          "powerline_symbol": "\uE0B0",
+          "foreground": "palette:black",
+          "background": "palette:root",
+          "properties": {
+            "root_icon": ""
+          }
+        },
+        {
+          "type": "executiontime",
+          "style": "plain",
+          "foreground": "palette:white",
+          "background": "palette:executiontime",
+          "leading_diamond": "",
+          "trailing_diamond": "",
+          "properties": {
+            "always_enabled": true,
+            "prefix": "<transparent>\uE0B0</> \ufbab",
+            "postfix": "\u2800"
+          }
+        },
+        {
+          "type": "exit",
+          "style": "diamond",
+          "foreground": "palette:white",
+          "background": "palette:exit",
+          "background_templates": [
+            "{{ if gt .Code 0 }}palette:exit-red{{ end }}"
+          ],
+          "leading_diamond": "",
+          "trailing_diamond": "\uE0B4",
+          "properties": {
+            "always_enabled": true,
+            "template": "\uE23A",
+            "prefix": "<parentBackground>\uE0B0</> "
+          }
+        }
+      ]
+    },
+    {
+      "type": "rprompt",
+      "segments": [
+        {
+          "type": "shell",
+          "style": "plain",
+          "foreground": "palette:white",
+          "background": "palette:shell",
+          "properties": {
+            "prefix": "<#0077c2,transparent>\uE0B6</>  ",
+            "postfix": " <transparent,#0077c2>\uE0B2</>"
+          }
+        },
+        {
+          "type": "ytm",
+          "style": "powerline",
+          "powerline_symbol": "\uE0B2",
+          "invert_powerline": true,
+          "foreground": "palette:black",
+          "background": "palette:ytm",
+          "properties": {
+            "prefix": " \uF167 ",
+            "paused_icon": " ",
+            "playing_icon": " "
+          }
+        },
+        {
+          "type": "battery",
+          "style": "powerline",
+          "invert_powerline": true,
+          "powerline_symbol": "\uE0B2",
+          "foreground": "palette:white",
+          "background": "palette:battery",
+          "properties": {
+            "battery_icon": "",
+            "discharging_icon": " ",
+            "charging_icon": " ",
+            "charged_icon": " ",
+            "color_background": true,
+            "charged_color": "palette:battery-charged",
+            "charging_color": "palette:battery-charging",
+            "discharging_color": "palette:battery-discharging",
+            "postfix": " "
+          }
+        },
+        {
+          "type": "time",
+          "style": "diamond",
+          "invert_powerline": true,
+          "leading_diamond": "\uE0B2",
+          "trailing_diamond": "\uE0B4",
+          "background": "palette:time",
+          "foreground": "palette:black"
+        }
+      ]
+    }
+  ],
+  "final_space": true,
+  "console_title": true,
+  "console_title_style": "template",
+  "console_title_template": "{{ .Shell }} in {{ .Folder }}"
+}
diff --git a/src/testdata/jandedobbeleer.omp.json b/src/testdata/jandedobbeleer.omp.json
new file mode 100644
index 00000000..16b8988d
--- /dev/null
+++ b/src/testdata/jandedobbeleer.omp.json
@@ -0,0 +1,238 @@
+{
+  "blocks": [
+    {
+      "type": "prompt",
+      "alignment": "left",
+      "segments": [
+        {
+          "type": "session",
+          "style": "diamond",
+          "foreground": "#ffffff",
+          "background": "#c386f1",
+          "leading_diamond": "",
+          "trailing_diamond": "\uE0B0",
+          "properties": {
+            "postfix": " ",
+            "display_host": false
+          }
+        },
+        {
+          "type": "path",
+          "style": "powerline",
+          "powerline_symbol": "\uE0B0",
+          "foreground": "#ffffff",
+          "background": "#ff479c",
+          "properties": {
+            "prefix": "   ",
+            "home_icon": "~",
+            "folder_separator_icon": " \uE0b1 ",
+            "style": "folder"
+          }
+        },
+        {
+          "type": "git",
+          "style": "powerline",
+          "powerline_symbol": "\uE0B0",
+          "foreground": "#193549",
+          "background": "#fffb38",
+          "background_templates": [
+            "{{ if or (.Working.Changed) (.Staging.Changed) }}#FF9248{{ end }}",
+            "{{ if and (gt .Ahead 0) (gt .Behind 0) }}#ff4500{{ end }}",
+            "{{ if gt .Ahead 0 }}#B388FF{{ end }}",
+            "{{ if gt .Behind 0 }}#B388FF{{ end }}"
+          ],
+          "leading_diamond": "",
+          "trailing_diamond": "",
+          "properties": {
+            "fetch_status": true,
+            "fetch_stash_count": true,
+            "fetch_upstream_icon": true,
+            "branch_max_length": 25,
+            "template": "{{ .UpstreamIcon }}{{ .HEAD }}{{ .BranchStatus }}{{ if .Working.Changed }} \uF044 {{ .Working.String }}{{ end }}{{ if and (.Working.Changed) (.Staging.Changed) }} |{{ end }}{{ if .Staging.Changed }} \uF046 {{ .Staging.String }}{{ end }}{{ if gt .StashCount 0 }} \uF692 {{ .StashCount }}{{ end }}"
+          }
+        },
+        {
+          "type": "node",
+          "style": "powerline",
+          "powerline_symbol": "\uE0B0",
+          "foreground": "#ffffff",
+          "background": "#6CA35E",
+          "properties": {
+            "prefix": " \uF898 ",
+            "display_version": true
+          }
+        },
+        {
+          "type": "go",
+          "style": "powerline",
+          "powerline_symbol": "\uE0B0",
+          "foreground": "#111111",
+          "background": "#8ED1F7",
+          "properties": {
+            "prefix": " \uE626 ",
+            "display_version": true
+          }
+        },
+        {
+          "type": "julia",
+          "style": "powerline",
+          "powerline_symbol": "\uE0B0",
+          "foreground": "#111111",
+          "background": "#4063D8",
+          "properties": {
+            "prefix": " \uE624 ",
+            "display_version": true
+          }
+        },
+        {
+          "type": "python",
+          "style": "powerline",
+          "powerline_symbol": "\uE0B0",
+          "foreground": "#111111",
+          "background": "#FFDE57",
+          "properties": {
+            "prefix": " \uE235 ",
+            "display_version": true,
+            "display_mode": "files",
+            "display_virtual_env": false
+          }
+        },
+        {
+          "type": "ruby",
+          "style": "powerline",
+          "powerline_symbol": "\uE0B0",
+          "foreground": "#ffffff",
+          "background": "#AE1401",
+          "properties": {
+            "prefix": " \uE791 ",
+            "display_version": true,
+            "display_mode": "files"
+          }
+        },
+        {
+          "type": "azfunc",
+          "style": "powerline",
+          "powerline_symbol": "\uE0B0",
+          "foreground": "#ffffff",
+          "background": "#FEAC19",
+          "properties": {
+            "prefix": " \uf0e7",
+            "display_version": false,
+            "display_mode": "files"
+          }
+        },
+        {
+          "type": "aws",
+          "style": "powerline",
+          "powerline_symbol": "\uE0B0",
+          "foreground": "#ffffff",
+          "background_templates": [
+            "{{if contains \"default\" .Profile}}#FFA400{{end}}",
+            "{{if contains \"jan\" .Profile}}#f1184c{{end}}"
+          ],
+          "properties": {
+            "prefix": " \uE7AD ",
+            "display_default": false
+          }
+        },
+        {
+          "type": "root",
+          "style": "powerline",
+          "powerline_symbol": "\uE0B0",
+          "foreground": "#111111",
+          "background": "#ffff66",
+          "properties": {
+            "root_icon": ""
+          }
+        },
+        {
+          "type": "executiontime",
+          "style": "plain",
+          "foreground": "#ffffff",
+          "background": "#83769c",
+          "leading_diamond": "",
+          "trailing_diamond": "",
+          "properties": {
+            "always_enabled": true,
+            "prefix": "<transparent>\uE0B0</> \ufbab",
+            "postfix": "\u2800"
+          }
+        },
+        {
+          "type": "exit",
+          "style": "diamond",
+          "foreground": "#ffffff",
+          "background": "#00897b",
+          "background_templates": ["{{ if gt .Code 0 }}#e91e63{{ end }}"],
+          "leading_diamond": "",
+          "trailing_diamond": "\uE0B4",
+          "properties": {
+            "always_enabled": true,
+            "template": "\uE23A",
+            "prefix": "<parentBackground>\uE0B0</> "
+          }
+        }
+      ]
+    },
+    {
+      "type": "rprompt",
+      "segments": [
+        {
+          "type": "shell",
+          "style": "plain",
+          "foreground": "#ffffff",
+          "background": "#0077c2",
+          "properties": {
+            "prefix": "<#0077c2,transparent>\uE0B6</>  ",
+            "postfix": " <transparent,#0077c2>\uE0B2</>"
+          }
+        },
+        {
+          "type": "ytm",
+          "style": "powerline",
+          "powerline_symbol": "\uE0B2",
+          "invert_powerline": true,
+          "foreground": "#111111",
+          "background": "#1BD760",
+          "properties": {
+            "prefix": " \uF167 ",
+            "paused_icon": " ",
+            "playing_icon": " "
+          }
+        },
+        {
+          "type": "battery",
+          "style": "powerline",
+          "invert_powerline": true,
+          "powerline_symbol": "\uE0B2",
+          "foreground": "#ffffff",
+          "background": "#f36943",
+          "properties": {
+            "battery_icon": "",
+            "discharging_icon": " ",
+            "charging_icon": " ",
+            "charged_icon": " ",
+            "color_background": true,
+            "charged_color": "#4caf50",
+            "charging_color": "#40c4ff",
+            "discharging_color": "#ff5722",
+            "postfix": " "
+          }
+        },
+        {
+          "type": "time",
+          "style": "diamond",
+          "invert_powerline": true,
+          "leading_diamond": "\uE0B2",
+          "trailing_diamond": "\uE0B4",
+          "background": "#2e9599",
+          "foreground": "#111111"
+        }
+      ]
+    }
+  ],
+  "final_space": true,
+  "console_title": true,
+  "console_title_style": "template",
+  "console_title_template": "{{ .Shell }} in {{ .Folder }}"
+}
diff --git a/src/writer_ansi_test.go b/src/writer_ansi_test.go
index e0ad7403..b97db374 100644
--- a/src/writer_ansi_test.go
+++ b/src/writer_ansi_test.go
@@ -6,28 +6,6 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
-func TestGetAnsiFromColorString(t *testing.T) {
-	cases := []struct {
-		Case       string
-		Expected   string
-		Color      string
-		Background bool
-	}{
-		{Case: "Invalid background", Expected: "", Color: "invalid", Background: true},
-		{Case: "Invalid background", Expected: "", Color: "invalid", Background: false},
-		{Case: "Hex foreground", Expected: "48;2;170;187;204", Color: "#AABBCC", Background: false},
-		{Case: "Base 8 foreground", Expected: "41", Color: "red", Background: false},
-		{Case: "Base 8 background", Expected: "41", Color: "red", Background: true},
-		{Case: "Base 16 foreground", Expected: "101", Color: "lightRed", Background: false},
-		{Case: "Base 16 backround", Expected: "101", Color: "lightRed", Background: true},
-	}
-	for _, tc := range cases {
-		renderer := &AnsiWriter{}
-		ansiColor := renderer.getAnsiFromColorString(tc.Color, true)
-		assert.Equal(t, tc.Expected, ansiColor, tc.Case)
-	}
-}
-
 func TestWriteANSIColors(t *testing.T) {
 	cases := []struct {
 		Case               string
@@ -184,6 +162,12 @@ func TestWriteANSIColors(t *testing.T) {
 			Expected: "\x1b[40m\x1b[30mtest\x1b[0m",
 			Colors:   &Color{Foreground: "black", Background: "white"},
 		},
+		{
+			Case:     "Google",
+			Input:    "<blue,white>G</><red,white>o</><yellow,white>o</><blue,white>g</><green,white>l</><red,white>e</>",
+			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 {
@@ -194,6 +178,7 @@ func TestWriteANSIColors(t *testing.T) {
 			ParentColors:       tc.Parent,
 			Colors:             tc.Colors,
 			terminalBackground: tc.TerminalBackground,
+			ansiColors:         &DefaultColors{},
 		}
 		renderer.write(tc.Colors.Background, tc.Colors.Foreground, tc.Input)
 		got := renderer.string()