diff --git a/docs/guide/interactive.md b/docs/guide/interactive.md index ff830d9..c4ca9ec 100644 --- a/docs/guide/interactive.md +++ b/docs/guide/interactive.md @@ -24,6 +24,14 @@ Use the following command to change the theme: flow config set theme (default|light|dark|dracula|tokyo-night) ``` +**Overriding the theme's colors** + +Additionally, you can override the theme colors by setting the `colorOverride` field in the config file. Any color not +set in the `colorOverride` field will use the default color for the set theme. +See the [config file reference](../types/config.md#ColorPalette) for more information. + +```yaml + ### Changing desktop notification settings Desktop notifications can be sent when executables are completed. Use the following command to enable or disable desktop notifications: diff --git a/docs/schemas/config_schema.json b/docs/schemas/config_schema.json index 6b6703d..01fc111 100644 --- a/docs/schemas/config_schema.json +++ b/docs/schemas/config_schema.json @@ -8,6 +8,55 @@ "currentWorkspace" ], "definitions": { + "ColorPalette": { + "description": "The color palette for the interactive UI.\nThe colors can be either an ANSI 16, ANSI 256, or TrueColor (hex) value.\nIf unset, the default color for the current theme will be used.\n", + "type": "object", + "properties": { + "black": { + "type": "string" + }, + "body": { + "type": "string" + }, + "border": { + "type": "string" + }, + "codeStyle": { + "description": "The style of the code block. For example, `monokai`, `dracula`, `github`, etc.\nSee [chroma styles](https://github.com/alecthomas/chroma/tree/master/styles) for available style names.\n", + "type": "string" + }, + "emphasis": { + "type": "string" + }, + "error": { + "type": "string" + }, + "gray": { + "type": "string" + }, + "info": { + "type": "string" + }, + "primary": { + "type": "string" + }, + "secondary": { + "type": "string" + }, + "success": { + "type": "string" + }, + "tertiary": { + "type": "string" + }, + "warning": { + "type": "string" + }, + "white": { + "type": "string" + } + } + }, "Interactive": { "description": "Configurations for the interactive UI.", "type": "object", @@ -30,6 +79,10 @@ } }, "properties": { + "colorOverride": { + "$ref": "#/definitions/ColorPalette", + "description": "Override the default color palette for the interactive UI.\nThis can be used to customize the colors of the UI.\n" + }, "currentNamespace": { "description": "The name of the current namespace.\n\nNamespaces are used to reference executables in the CLI using the format `workspace:namespace/name`.\nIf the namespace is not set, only executables defined without a namespace will be discovered.\n", "type": "string", diff --git a/docs/types/config.md b/docs/types/config.md index a269b9d..edb4eb2 100644 --- a/docs/types/config.md +++ b/docs/types/config.md @@ -23,6 +23,7 @@ Alternatively, a custom path can be set using the `FLOW_CONFIG_PATH` environment | Field | Description | Type | Default | Required | | ----- | ----------- | ---- | ------- | :--------: | +| `colorOverride` | Override the default color palette for the interactive UI. This can be used to customize the colors of the UI. | [ColorPalette](#ColorPalette) | | | | `currentNamespace` | The name of the current namespace. Namespaces are used to reference executables in the CLI using the format `workspace:namespace/name`. If the namespace is not set, only executables defined without a namespace will be discovered. | `string` | | | | `currentWorkspace` | The name of the current workspace. This should match a key in the `workspaces` or `remoteWorkspaces` map. | `string` | | | | `defaultLogMode` | The default log mode to use when running executables. This can either be `hidden`, `json`, `logfmt` or `text` `hidden` will not display any logs. `json` will display logs in JSON format. `logfmt` will display logs with a log level, timestamp, and message. `text` will just display the log message. | `string` | logfmt | | @@ -36,6 +37,36 @@ Alternatively, a custom path can be set using the `FLOW_CONFIG_PATH` environment ## Definitions +### ColorPalette + +The color palette for the interactive UI. +The colors can be either an ANSI 16, ANSI 256, or TrueColor (hex) value. +If unset, the default color for the current theme will be used. + + +**Type:** `object` + + + +**Properties:** + +| Field | Description | Type | Default | Required | +| ----- | ----------- | ---- | ------- | :--------: | +| `black` | | `string` | | | +| `body` | | `string` | | | +| `border` | | `string` | | | +| `codeStyle` | The style of the code block. For example, `monokai`, `dracula`, `github`, etc. See [chroma styles](https://github.com/alecthomas/chroma/tree/master/styles) for available style names. | `string` | | | +| `emphasis` | | `string` | | | +| `error` | | `string` | | | +| `gray` | | `string` | | | +| `info` | | `string` | | | +| `primary` | | `string` | | | +| `secondary` | | `string` | | | +| `success` | | `string` | | | +| `tertiary` | | `string` | | | +| `warning` | | `string` | | | +| `white` | | `string` | | | + ### Interactive Configurations for the interactive UI. diff --git a/internal/context/context.go b/internal/context/context.go index ce0e753..74b1f3a 100644 --- a/internal/context/context.go +++ b/internal/context/context.go @@ -5,10 +5,13 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" + "github.com/charmbracelet/lipgloss" "github.com/jahvon/tuikit" "github.com/jahvon/tuikit/io" + "github.com/jahvon/tuikit/styles" "github.com/pkg/errors" "github.com/jahvon/flow/internal/cache" @@ -89,11 +92,15 @@ func NewContext(ctx context.Context, stdIn, stdOut *os.File) *Context { tuikit.WithLoadingMsg("thinking..."), ) + theme := flowIO.Theme(cfg.Theme.String()) + if cfg.ColorOverride != nil { + theme = overrideThemeColor(theme, cfg.ColorOverride) + } c.TUIContainer, err = tuikit.NewContainer( ctx, app, tuikit.WithInput(stdIn), tuikit.WithOutput(stdOut), - tuikit.WithTheme(flowIO.Theme(cfg.Theme.String())), + tuikit.WithTheme(theme), ) if err != nil { panic(errors.Wrap(err, "TUI container initialization error")) @@ -183,6 +190,14 @@ func currentWorkspace(cfg *config.Config) (*workspace.Workspace, error) { if err != nil { return nil, err } + if runtime.GOOS == "darwin" { + // On macOS, paths that start with /tmp (and some other system directories) + // are actually symbolic links to paths under /private. The OS may return + // either form of the path - e.g., both "/tmp/file" and "/private/tmp/file" + // refer to the same location. We strip the "/private" prefix for consistent + // path comparison, while preserving the original paths for filesystem operations. + wd = strings.TrimPrefix(wd, "/private") + } for wsName, path := range cfg.Workspaces { rel, err := filepath.Rel(filepath.Clean(path), filepath.Clean(wd)) @@ -209,3 +224,49 @@ func currentWorkspace(cfg *config.Config) (*workspace.Workspace, error) { return filesystem.LoadWorkspaceConfig(ws, wsPath) } + +func overrideThemeColor(theme styles.Theme, palette *config.ColorPalette) styles.Theme { + if palette == nil { + return theme + } + if palette.Primary != nil { + theme.PrimaryColor = lipgloss.Color(*palette.Primary) + } + if palette.Secondary != nil { + theme.SecondaryColor = lipgloss.Color(*palette.Secondary) + } + if palette.Tertiary != nil { + theme.TertiaryColor = lipgloss.Color(*palette.Tertiary) + } + if palette.Success != nil { + theme.SuccessColor = lipgloss.Color(*palette.Success) + } + if palette.Warning != nil { + theme.WarningColor = lipgloss.Color(*palette.Warning) + } + if palette.Error != nil { + theme.ErrorColor = lipgloss.Color(*palette.Error) + } + if palette.Info != nil { + theme.InfoColor = lipgloss.Color(*palette.Info) + } + if palette.Body != nil { + theme.BodyColor = lipgloss.Color(*palette.Body) + } + if palette.Emphasis != nil { + theme.EmphasisColor = lipgloss.Color(*palette.Emphasis) + } + if palette.White != nil { + theme.White = lipgloss.Color(*palette.White) + } + if palette.Black != nil { + theme.Black = lipgloss.Color(*palette.Black) + } + if palette.Gray != nil { + theme.Gray = lipgloss.Color(*palette.Gray) + } + if palette.CodeStyle != nil { + theme.ChromaCodeStyle = *palette.CodeStyle + } + return theme +} diff --git a/internal/context/context_test.go b/internal/context/context_test.go new file mode 100644 index 0000000..2a5d7e0 --- /dev/null +++ b/internal/context/context_test.go @@ -0,0 +1,101 @@ +//nolint:testpackage +package context + +import ( + "os" + "path/filepath" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/jahvon/tuikit/styles" + "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/jahvon/flow/types/config" +) + +func TestContext(t *testing.T) { + RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Context Suite") +} + +var _ = ginkgo.Describe("Context", func() { + ginkgo.Describe("currentWorkspace", func() { + var ( + cfg *config.Config + tmpDir string + ) + + ginkgo.BeforeEach(func() { + tmpDir = ginkgo.GinkgoT().TempDir() + cfg = &config.Config{ + Workspaces: map[string]string{ + "ws1": filepath.Clean(filepath.Join(tmpDir, "ws1")), + "ws2": filepath.Clean(filepath.Join(tmpDir, "ws2")), + }, + CurrentWorkspace: "ws1", + WorkspaceMode: config.ConfigWorkspaceModeFixed, + } + }) + + ginkgo.AfterEach(func() { + _ = os.RemoveAll(tmpDir) + }) + + ginkgo.It("should return the current workspace in fixed mode", func() { + ws, err := currentWorkspace(cfg) + Expect(err).NotTo(HaveOccurred()) + Expect(ws.AssignedName()).To(Equal("ws1")) + Expect(ws.Location()).To(Equal(filepath.Join(tmpDir, "ws1"))) + }) + + ginkgo.It("should return the current workspace in dynamic mode", func() { + cfg.WorkspaceMode = config.ConfigWorkspaceModeDynamic + Expect(os.Mkdir(filepath.Join(tmpDir, "ws2"), 0750)).To(Succeed()) + // os.Setenv("PWD", filepath.Join(tmpDir, "ws2")) + Expect(os.Chdir(filepath.Join(tmpDir, "ws2"))).To(Succeed()) + + ws, err := currentWorkspace(cfg) + Expect(err).NotTo(HaveOccurred()) + Expect(ws.AssignedName()).To(Equal("ws2")) + Expect(ws.Location()).To(Equal(filepath.Join(tmpDir, "ws2"))) + }) + + ginkgo.It("should return an error if the current workspace is not found", func() { + cfg.CurrentWorkspace = "ws3" + _, err := currentWorkspace(cfg) + Expect(err).To(HaveOccurred()) + }) + }) + + ginkgo.Describe("overrideThemeColor", func() { + var theme styles.Theme + var palette *config.ColorPalette + + ginkgo.BeforeEach(func() { + theme = styles.Theme{ + PrimaryColor: "#000000", + SecondaryColor: "#FFFFFF", + } + palette = &config.ColorPalette{ + Primary: strPtr("#FF0000"), + Secondary: strPtr("#00FF00"), + } + }) + + ginkgo.It("should override the theme colors with the palette colors", func() { + newTheme := overrideThemeColor(theme, palette) + Expect(newTheme.PrimaryColor).To(Equal(lipgloss.Color("#FF0000"))) + Expect(newTheme.SecondaryColor).To(Equal(lipgloss.Color("#00FF00"))) + }) + + ginkgo.It("should not change the theme if the palette is nil", func() { + newTheme := overrideThemeColor(theme, nil) + Expect(newTheme).To(Equal(theme)) + }) + }) +}) + +func strPtr(s string) *string { + return &s +} diff --git a/types/config/config.gen.go b/types/config/config.gen.go index a2f930b..23fdf32 100644 --- a/types/config/config.gen.go +++ b/types/config/config.gen.go @@ -5,6 +5,57 @@ package config import "github.com/jahvon/tuikit/io" import "time" +// The color palette for the interactive UI. +// The colors can be either an ANSI 16, ANSI 256, or TrueColor (hex) value. +// If unset, the default color for the current theme will be used. +type ColorPalette struct { + // Black corresponds to the JSON schema field "black". + Black *string `json:"black,omitempty" yaml:"black,omitempty" mapstructure:"black,omitempty"` + + // Body corresponds to the JSON schema field "body". + Body *string `json:"body,omitempty" yaml:"body,omitempty" mapstructure:"body,omitempty"` + + // Border corresponds to the JSON schema field "border". + Border *string `json:"border,omitempty" yaml:"border,omitempty" mapstructure:"border,omitempty"` + + // The style of the code block. For example, `monokai`, `dracula`, `github`, etc. + // See [chroma styles](https://github.com/alecthomas/chroma/tree/master/styles) + // for available style names. + // + // + CodeStyle *string `json:"codeStyle,omitempty" yaml:"codeStyle,omitempty" mapstructure:"codeStyle,omitempty"` + + // Emphasis corresponds to the JSON schema field "emphasis". + Emphasis *string `json:"emphasis,omitempty" yaml:"emphasis,omitempty" mapstructure:"emphasis,omitempty"` + + // Error corresponds to the JSON schema field "error". + Error *string `json:"error,omitempty" yaml:"error,omitempty" mapstructure:"error,omitempty"` + + // Gray corresponds to the JSON schema field "gray". + Gray *string `json:"gray,omitempty" yaml:"gray,omitempty" mapstructure:"gray,omitempty"` + + // Info corresponds to the JSON schema field "info". + Info *string `json:"info,omitempty" yaml:"info,omitempty" mapstructure:"info,omitempty"` + + // Primary corresponds to the JSON schema field "primary". + Primary *string `json:"primary,omitempty" yaml:"primary,omitempty" mapstructure:"primary,omitempty"` + + // Secondary corresponds to the JSON schema field "secondary". + Secondary *string `json:"secondary,omitempty" yaml:"secondary,omitempty" mapstructure:"secondary,omitempty"` + + // Success corresponds to the JSON schema field "success". + Success *string `json:"success,omitempty" yaml:"success,omitempty" mapstructure:"success,omitempty"` + + // Tertiary corresponds to the JSON schema field "tertiary". + Tertiary *string `json:"tertiary,omitempty" yaml:"tertiary,omitempty" mapstructure:"tertiary,omitempty"` + + // Warning corresponds to the JSON schema field "warning". + Warning *string `json:"warning,omitempty" yaml:"warning,omitempty" mapstructure:"warning,omitempty"` + + // White corresponds to the JSON schema field "white". + White *string `json:"white,omitempty" yaml:"white,omitempty" mapstructure:"white,omitempty"` +} + // User Configuration for the Flow CLI. // Includes configurations for workspaces, templates, I/O, and other settings for // the CLI. @@ -17,6 +68,11 @@ import "time" // Alternatively, a custom path can be set using the `FLOW_CONFIG_PATH` environment // variable. type Config struct { + // Override the default color palette for the interactive UI. + // This can be used to customize the colors of the UI. + // + ColorOverride *ColorPalette `json:"colorOverride,omitempty" yaml:"colorOverride,omitempty" mapstructure:"colorOverride,omitempty"` + // The name of the current namespace. // // Namespaces are used to reference executables in the CLI using the format diff --git a/types/config/schema.yaml b/types/config/schema.yaml index 51ee0ea..77fa19d 100644 --- a/types/config/schema.yaml +++ b/types/config/schema.yaml @@ -28,6 +28,45 @@ definitions: description: Whether to play a sound when a command completes. required: [ enabled ] + ColorPalette: + type: object + description: | + The color palette for the interactive UI. + The colors can be either an ANSI 16, ANSI 256, or TrueColor (hex) value. + If unset, the default color for the current theme will be used. + properties: + primary: + type: string + secondary: + type: string + tertiary: + type: string + success: + type: string + warning: + type: string + error: + type: string + info: + type: string + body: + type: string + emphasis: + type: string + border: + type: string + white: + type: string + black: + type: string + gray: + type: string + codeStyle: + type: string + description: | + The style of the code block. For example, `monokai`, `dracula`, `github`, etc. + See [chroma styles](https://github.com/alecthomas/chroma/tree/master/styles) for available style names. + type: object properties: workspaces: @@ -73,6 +112,11 @@ properties: enum: [default, dark, light, dracula, tokyo-night] description: The theme of the interactive UI. default: default + colorOverride: + $ref: '#/definitions/ColorPalette' + description: | + Override the default color palette for the interactive UI. + This can be used to customize the colors of the UI. defaultTimeout: type: string description: |