diff --git a/README.md b/README.md index 66f4984..2234be5 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,25 @@ Automatic refresh is always enabled. It pauses when an error occurs to avoid rep The log level uses Go's `slog` [log levels](https://pkg.go.dev/log/slog#hdr-Levels), e.g: `info`, `debug`, `warn`, `error`. +### Themes + +**o7k** supports customizable YAML-based color themes (skins). Built-in skins are available under `internal/config/skins/` +and users can create their own. + +Four accessibility-oriented themes are included: **High Contrast**, **Protanopia**, **Deuteranopia**, and **Tritanopia**. + +To select a theme, set the `skin` property in `~/.config/o7k/config.yaml`: + +```yaml +skin: dracula +``` + +Custom skins can be placed in `~/.config/o7k/skins/` and selected by their filename without the `.yaml` extension: + +```yaml +skin: my-custom-theme +``` + ## Usage **o7k** uses the standard OpenStack `clouds.yaml` configuration and automatically discovers the diff --git a/internal/config/config.go b/internal/config/config.go index 667e6fd..eff4dd8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,9 +15,11 @@ const ( ) type Config struct { - Cache Cache `yaml:"cache"` - Log Log `yaml:"log"` - Refresh Refresh `yaml:"refresh"` + Cache Cache `yaml:"cache"` + Log Log `yaml:"log"` + Refresh Refresh `yaml:"refresh"` + Skin string `yaml:"skin"` + SkinColors Skin `yaml:"-"` } type Cache struct { @@ -68,13 +70,15 @@ func Load() (Config, error) { return cfg, err } - path := filepath.Join(dir, "o7k", "config.yaml") + dir = filepath.Join(dir, "o7k") + path := filepath.Join(dir, "config.yaml") data, err := os.ReadFile(path) - if os.IsNotExist(err) { - return cfg, nil - } - if err != nil { + if err == nil { + if err := yaml.Unmarshal(data, &cfg); err != nil { + return cfg, err + } + } else if !os.IsNotExist(err) { return cfg, fmt.Errorf("reading config: %w", err) } @@ -96,5 +100,17 @@ func Load() (Config, error) { cfg.Refresh.Interval = defaultRefreshInterval } + if cfg.Skin == "" { + cfg.Skin = "default" + } + + if err := installSkins(dir); err != nil { + return cfg, err + } + cfg.SkinColors, err = loadSkin(dir, cfg.Skin) + if err != nil { + return cfg, err + } + return cfg, nil } diff --git a/internal/config/skin.go b/internal/config/skin.go new file mode 100644 index 0000000..0cc007d --- /dev/null +++ b/internal/config/skin.go @@ -0,0 +1,42 @@ +package config + +type ColorPair struct { + Foreground string `yaml:"foreground"` + Background string `yaml:"background"` +} + +type Skin struct { + Main ColorPair `yaml:"main"` + Table ColorPair `yaml:"table"` + Context ColorPair `yaml:"context"` + Command ColorPair `yaml:"command"` + + ResourceTag ColorPair `yaml:"resource_tag"` + NavigationTag ColorPair `yaml:"navigation_tag"` + Status ColorPair `yaml:"status"` + Loading ColorPair `yaml:"loading"` + Info ColorPair `yaml:"info"` + StateOn ColorPair `yaml:"state_on"` + StateOff ColorPair `yaml:"state_off"` + ModalButton ColorPair `yaml:"modal_button"` + TableSelected ColorPair `yaml:"table_selected"` + + CommandGhost string `yaml:"command_ghost"` + CommandPlaceholder string `yaml:"command_placeholder"` + + HeaderLabel string `yaml:"header_label"` + HeaderValue string `yaml:"header_value"` + HeaderCommandKey string `yaml:"header_command_key"` + HeaderCommandText string `yaml:"header_command_text"` + Logo string `yaml:"logo"` + + JSONKey string `yaml:"json_key"` + JSONString string `yaml:"json_string"` + JSONNumber string `yaml:"json_number"` + JSONBool string `yaml:"json_bool"` + JSONNull string `yaml:"json_null"` + + TableTitle string `yaml:"table_title"` + TableHeader string `yaml:"table_header"` + ErrorTitle string `yaml:"error_title"` +} diff --git a/internal/config/skin_helpers.go b/internal/config/skin_helpers.go new file mode 100644 index 0000000..9ce8828 --- /dev/null +++ b/internal/config/skin_helpers.go @@ -0,0 +1,82 @@ +package config + +import ( + "embed" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +//go:embed skins/*.yaml +var embeddedSkins embed.FS + +func installSkins(configDir string) error { + skinsDir := filepath.Join(configDir, "skins") + + if err := os.MkdirAll(skinsDir, 0755); err != nil { + return fmt.Errorf("creating skins directory: %w", err) + } + + files, err := fs.Glob(embeddedSkins, "skins/*.yaml") + if err != nil { + return fmt.Errorf("listing embedded skins: %w", err) + } + + for _, file := range files { + data, err := embeddedSkins.ReadFile(file) + if err != nil { + return fmt.Errorf("reading embedded skin %s: %w", file, err) + } + + path := filepath.Join(skinsDir, filepath.Base(file)) + + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) + if os.IsExist(err) { + continue + } + if err != nil { + return fmt.Errorf("creating skin %s: %w", path, err) + } + + _, writeErr := f.Write(data) + closeErr := f.Close() + + if writeErr != nil { + return fmt.Errorf("writing skin %s: %w", path, writeErr) + } + if closeErr != nil { + return fmt.Errorf("closing skin %s: %w", path, closeErr) + } + } + + return nil +} + +func loadSkin(configDir, name string) (Skin, error) { + var skin Skin + + if name == "" || name != filepath.Base(name) || + strings.ContainsAny(name, `/\`) || name == "." || name == ".." { + return skin, fmt.Errorf("invalid skin name %q", name) + } + + path := filepath.Join(configDir, "skins", name+".yaml") + + data, err := os.ReadFile(path) + if err != nil { + return skin, fmt.Errorf("reading skin %q: %w", name, err) + } + + decoder := yaml.NewDecoder(strings.NewReader(string(data))) + decoder.KnownFields(true) + + if err := decoder.Decode(&skin); err != nil { + return skin, fmt.Errorf("parsing skin %q: %w", name, err) + } + + return skin, nil +} diff --git a/internal/config/skins/default.yaml b/internal/config/skins/default.yaml new file mode 100644 index 0000000..7df0ec7 --- /dev/null +++ b/internal/config/skins/default.yaml @@ -0,0 +1,54 @@ +main: + foreground: "#FFFFFF" + background: "" +table: + foreground: "#FFFFFF" + background: "" +table_selected: + foreground: "255" + background: "#ED1944" +table_header: "#FFFFFF" +context: + foreground: "#FFFFFF" + background: "" +command: + foreground: "#87CEFA" + background: "" +resource_tag: + foreground: "255" + background: "#ED1944" +navigation_tag: + foreground: "0" + background: "255" +status: + foreground: "#000000" + background: "#FFA500" +loading: + foreground: "#000000" + background: "#ff8c00" +info: + foreground: "#000000" + background: "#00BFFF" +state_on: + foreground: "#000000" + background: "#008000" +state_off: + foreground: "#000000" + background: "#FF0000" +modal_button: + foreground: "#000000" + background: "#00BFFF" +command_ghost: "#5C6370" +command_placeholder: "#666666" +header_label: "#FFA500" +header_value: "#FFFFFF" +header_command_key: "#00BFFF" +header_command_text: "#FFFFFF" +logo: "#FFA500" +json_key: "#87CEFA" +json_string: "#98C379" +json_number: "#E5C07B" +json_bool: "#C678DD" +json_null: "#7F848E" +table_title: "#FFFFFF" +error_title: "#ED1944" diff --git a/internal/config/skins/deuteranopia.yaml b/internal/config/skins/deuteranopia.yaml new file mode 100644 index 0000000..2663b15 --- /dev/null +++ b/internal/config/skins/deuteranopia.yaml @@ -0,0 +1,54 @@ +main: + foreground: "#F0F3F7" + background: "#151923" +table: + foreground: "#F0F3F7" + background: "#151923" +context: + foreground: "#F0F3F7" + background: "#151923" +command: + foreground: "#82BFFF" + background: "#151923" +resource_tag: + foreground: "#151923" + background: "#FFB347" +navigation_tag: + foreground: "#151923" + background: "#82BFFF" +status: + foreground: "#151923" + background: "#FFD166" +loading: + foreground: "#151923" + background: "#FFB347" +info: + foreground: "#151923" + background: "#82BFFF" +state_on: + foreground: "#151923" + background: "#82BFFF" +state_off: + foreground: "#151923" + background: "#FFB347" +modal_button: + foreground: "#151923" + background: "#82BFFF" +table_selected: + foreground: "#151923" + background: "#FFD166" +table_header: "#FFFFFF" +command_ghost: "#A0A8B8" +command_placeholder: "#A0A8B8" +header_label: "#FFD166" +header_value: "#FFFFFF" +header_command_key: "#82BFFF" +header_command_text: "#F0F3F7" +logo: "#FFD166" +json_key: "#82BFFF" +json_string: "#FFD166" +json_number: "#FFFFFF" +json_bool: "#D5A6FF" +json_null: "#A0A8B8" +table_title: "#FFFFFF" +error_title: "#FFB347" \ No newline at end of file diff --git a/internal/config/skins/dracula.yaml b/internal/config/skins/dracula.yaml new file mode 100644 index 0000000..b54f78a --- /dev/null +++ b/internal/config/skins/dracula.yaml @@ -0,0 +1,54 @@ +main: + foreground: "#F8F8F2" + background: "#282A36" +table: + foreground: "#F8F8F2" + background: "#282A36" +context: + foreground: "#F8F8F2" + background: "#282A36" +command: + foreground: "#8BE9FD" + background: "#282A36" +resource_tag: + foreground: "#282A36" + background: "#FF79C6" +navigation_tag: + foreground: "#282A36" + background: "#BD93F9" +status: + foreground: "#282A36" + background: "#FFB86C" +loading: + foreground: "#282A36" + background: "#F1FA8C" +info: + foreground: "#282A36" + background: "#8BE9FD" +state_on: + foreground: "#282A36" + background: "#50FA7B" +state_off: + foreground: "#282A36" + background: "#FF5555" +modal_button: + foreground: "#282A36" + background: "#BD93F9" +table_selected: + foreground: "#282A36" + background: "#BD93F9" +table_header: "#F8F8F2" +command_ghost: "#6272A4" +command_placeholder: "#6272A4" +header_label: "#FFB86C" +header_value: "#F8F8F2" +header_command_key: "#8BE9FD" +header_command_text: "#F8F8F2" +logo: "#BD93F9" +json_key: "#8BE9FD" +json_string: "#F1FA8C" +json_number: "#BD93F9" +json_bool: "#FF79C6" +json_null: "#6272A4" +table_title: "#F8F8F2" +error_title: "#FF5555" \ No newline at end of file diff --git a/internal/config/skins/greece.yaml b/internal/config/skins/greece.yaml new file mode 100644 index 0000000..5c18ba4 --- /dev/null +++ b/internal/config/skins/greece.yaml @@ -0,0 +1,54 @@ +main: + foreground: "#FFFFFF" + background: "#0D5EAF" +table: + foreground: "#FFFFFF" + background: "#0D5EAF" +context: + foreground: "#FFFFFF" + background: "#0D5EAF" +command: + foreground: "#66CCFF" + background: "#0D5EAF" +resource_tag: + foreground: "#0D5EAF" + background: "#FFFFFF" +navigation_tag: + foreground: "#FFFFFF" + background: "#1B365D" +status: + foreground: "#0D5EAF" + background: "#FFD166" +loading: + foreground: "#0D5EAF" + background: "#FFB347" +info: + foreground: "#0D5EAF" + background: "#66CCFF" +state_on: + foreground: "#FFFFFF" + background: "#176B45" +state_off: + foreground: "#FFFFFF" + background: "#B42332" +modal_button: + foreground: "#0D5EAF" + background: "#FFFFFF" +table_selected: + foreground: "#0D5EAF" + background: "#FFFFFF" +table_header: "#FFFFFF" +command_ghost: "#A9C9E8" +command_placeholder: "#B8D8F0" +header_label: "#FFD166" +header_value: "#FFFFFF" +header_command_key: "#66CCFF" +header_command_text: "#FFFFFF" +logo: "#FFFFFF" +json_key: "#A9E6FF" +json_string: "#FFE29A" +json_number: "#FFFFFF" +json_bool: "#FFCC80" +json_null: "#B8D8F0" +table_title: "#FFFFFF" +error_title: "#FFD166" \ No newline at end of file diff --git a/internal/config/skins/gruvbox-dark.yaml b/internal/config/skins/gruvbox-dark.yaml new file mode 100644 index 0000000..e273269 --- /dev/null +++ b/internal/config/skins/gruvbox-dark.yaml @@ -0,0 +1,54 @@ +main: + foreground: "#EBDBB2" + background: "#282828" +table: + foreground: "#EBDBB2" + background: "#282828" +context: + foreground: "#EBDBB2" + background: "#282828" +command: + foreground: "#83A598" + background: "#282828" +resource_tag: + foreground: "#282828" + background: "#FB4934" +navigation_tag: + foreground: "#282828" + background: "#FABD2F" +status: + foreground: "#282828" + background: "#FABD2F" +loading: + foreground: "#282828" + background: "#FE8019" +info: + foreground: "#282828" + background: "#83A598" +state_on: + foreground: "#282828" + background: "#B8BB26" +state_off: + foreground: "#282828" + background: "#FB4934" +modal_button: + foreground: "#282828" + background: "#FABD2F" +table_selected: + foreground: "#282828" + background: "#FABD2F" +table_header: "#EBDBB2" +command_ghost: "#928374" +command_placeholder: "#928374" +header_label: "#FABD2F" +header_value: "#EBDBB2" +header_command_key: "#83A598" +header_command_text: "#EBDBB2" +logo: "#FABD2F" +json_key: "#83A598" +json_string: "#B8BB26" +json_number: "#FE8019" +json_bool: "#D3869B" +json_null: "#928374" +table_title: "#EBDBB2" +error_title: "#FB4934" \ No newline at end of file diff --git a/internal/config/skins/high-contrast.yaml b/internal/config/skins/high-contrast.yaml new file mode 100644 index 0000000..b00b2c4 --- /dev/null +++ b/internal/config/skins/high-contrast.yaml @@ -0,0 +1,54 @@ +main: + foreground: "#FFFFFF" + background: "#000000" +table: + foreground: "#FFFFFF" + background: "#000000" +context: + foreground: "#FFFFFF" + background: "#000000" +command: + foreground: "#00FFFF" + background: "#000000" +resource_tag: + foreground: "#FFFFFF" + background: "#9B0000" +navigation_tag: + foreground: "#000000" + background: "#FFFFFF" +status: + foreground: "#000000" + background: "#FFFF00" +loading: + foreground: "#000000" + background: "#FFFF00" +info: + foreground: "#000000" + background: "#00FFFF" +state_on: + foreground: "#000000" + background: "#00FF00" +state_off: + foreground: "#FFFFFF" + background: "#B00000" +modal_button: + foreground: "#000000" + background: "#FFFFFF" +table_selected: + foreground: "#000000" + background: "#FFFF00" +table_header: "#FFFFFF" +command_ghost: "#B3B3B3" +command_placeholder: "#B3B3B3" +header_label: "#FFFF00" +header_value: "#FFFFFF" +header_command_key: "#00FFFF" +header_command_text: "#FFFFFF" +logo: "#FFFF00" +json_key: "#00FFFF" +json_string: "#00FF00" +json_number: "#FFFF00" +json_bool: "#FF80FF" +json_null: "#B3B3B3" +table_title: "#FFFFFF" +error_title: "#FF5555" \ No newline at end of file diff --git a/internal/config/skins/mocha.yaml b/internal/config/skins/mocha.yaml new file mode 100644 index 0000000..fe71f71 --- /dev/null +++ b/internal/config/skins/mocha.yaml @@ -0,0 +1,54 @@ +main: + foreground: "#CDD6F4" + background: "#1E1E2E" +table: + foreground: "#CDD6F4" + background: "#1E1E2E" +context: + foreground: "#CDD6F4" + background: "#1E1E2E" +command: + foreground: "#89B4FA" + background: "#1E1E2E" +resource_tag: + foreground: "#1E1E2E" + background: "#F38BA8" +navigation_tag: + foreground: "#1E1E2E" + background: "#CBA6F7" +status: + foreground: "#1E1E2E" + background: "#F9E2AF" +loading: + foreground: "#1E1E2E" + background: "#FAB387" +info: + foreground: "#1E1E2E" + background: "#89DCEB" +state_on: + foreground: "#1E1E2E" + background: "#A6E3A1" +state_off: + foreground: "#1E1E2E" + background: "#F38BA8" +modal_button: + foreground: "#1E1E2E" + background: "#CBA6F7" +table_selected: + foreground: "#1E1E2E" + background: "#CBA6F7" +table_header: "#CDD6F4" +command_ghost: "#6C7086" +command_placeholder: "#7F849C" +header_label: "#F9E2AF" +header_value: "#CDD6F4" +header_command_key: "#89B4FA" +header_command_text: "#CDD6F4" +logo: "#CBA6F7" +json_key: "#89B4FA" +json_string: "#A6E3A1" +json_number: "#FAB387" +json_bool: "#CBA6F7" +json_null: "#9399B2" +table_title: "#CDD6F4" +error_title: "#F38BA8" \ No newline at end of file diff --git a/internal/config/skins/nord.yaml b/internal/config/skins/nord.yaml new file mode 100644 index 0000000..8f4bf6d --- /dev/null +++ b/internal/config/skins/nord.yaml @@ -0,0 +1,54 @@ +main: + foreground: "#ECEFF4" + background: "#2E3440" +table: + foreground: "#ECEFF4" + background: "#2E3440" +context: + foreground: "#ECEFF4" + background: "#2E3440" +command: + foreground: "#88C0D0" + background: "#2E3440" +resource_tag: + foreground: "#2E3440" + background: "#BF616A" +navigation_tag: + foreground: "#2E3440" + background: "#88C0D0" +status: + foreground: "#2E3440" + background: "#EBCB8B" +loading: + foreground: "#2E3440" + background: "#D08770" +info: + foreground: "#2E3440" + background: "#8FBCBB" +state_on: + foreground: "#2E3440" + background: "#A3BE8C" +state_off: + foreground: "#ECEFF4" + background: "#BF616A" +modal_button: + foreground: "#2E3440" + background: "#88C0D0" +table_selected: + foreground: "#2E3440" + background: "#88C0D0" +table_header: "#ECEFF4" +command_ghost: "#7B88A1" +command_placeholder: "#7B88A1" +header_label: "#EBCB8B" +header_value: "#ECEFF4" +header_command_key: "#88C0D0" +header_command_text: "#ECEFF4" +logo: "#88C0D0" +json_key: "#81A1C1" +json_string: "#A3BE8C" +json_number: "#EBCB8B" +json_bool: "#B48EAD" +json_null: "#D8DEE9" +table_title: "#ECEFF4" +error_title: "#BF616A" \ No newline at end of file diff --git a/internal/config/skins/norton-commander.yaml b/internal/config/skins/norton-commander.yaml new file mode 100644 index 0000000..c46314d --- /dev/null +++ b/internal/config/skins/norton-commander.yaml @@ -0,0 +1,54 @@ +main: + foreground: "#55FFFF" + background: "#0000AA" +table: + foreground: "#55FFFF" + background: "#0000AA" +table_title: "#FFFF55" +table_header: "#FFFF55" +table_selected: + foreground: "#000000" + background: "#FFFF55" +context: + foreground: "#55FFFF" + background: "#0000AA" +command: + foreground: "#55FFFF" + background: "#0000AA" +resource_tag: + foreground: "#000000" + background: "#55FF55" +navigation_tag: + foreground: "#FFFFFF" + background: "#00AAAA" +status: + foreground: "#000000" + background: "#FFA500" +loading: + foreground: "#000000" + background: "#FFFF55" +info: + foreground: "#000000" + background: "#00AAAA" +state_on: + foreground: "#000000" + background: "#55FF55" +state_off: + foreground: "#FFFFFF" + background: "#AA0000" +modal_button: + foreground: "#000000" + background: "#00AAAA" +command_ghost: "#00AAAA" +command_placeholder: "#00AAAA" +header_label: "#FFFF55" +header_value: "#55FFFF" +header_command_key: "#FFFF55" +header_command_text: "#55FFFF" +logo: "#FFFF55" +json_key: "#55FFFF" +json_string: "#55FFFF" +json_number: "#FFFF55" +json_bool: "#FFFF55" +json_null: "#00AAAA" +error_title: "#FFFF55" diff --git a/internal/config/skins/protanopia.yaml b/internal/config/skins/protanopia.yaml new file mode 100644 index 0000000..f6dcecb --- /dev/null +++ b/internal/config/skins/protanopia.yaml @@ -0,0 +1,54 @@ +main: + foreground: "#E6EDF3" + background: "#101820" +table: + foreground: "#E6EDF3" + background: "#101820" +context: + foreground: "#E6EDF3" + background: "#101820" +command: + foreground: "#70CFFF" + background: "#101820" +resource_tag: + foreground: "#101820" + background: "#FFCC66" +navigation_tag: + foreground: "#101820" + background: "#70CFFF" +status: + foreground: "#101820" + background: "#FFCC66" +loading: + foreground: "#101820" + background: "#F4B860" +info: + foreground: "#101820" + background: "#70CFFF" +state_on: + foreground: "#101820" + background: "#70CFFF" +state_off: + foreground: "#101820" + background: "#FFCC66" +modal_button: + foreground: "#101820" + background: "#70CFFF" +table_selected: + foreground: "#101820" + background: "#FFCC66" +table_header: "#FFFFFF" +command_ghost: "#9AA8B5" +command_placeholder: "#9AA8B5" +header_label: "#FFCC66" +header_value: "#FFFFFF" +header_command_key: "#70CFFF" +header_command_text: "#E6EDF3" +logo: "#FFCC66" +json_key: "#70CFFF" +json_string: "#FFD580" +json_number: "#FFFFFF" +json_bool: "#B5A1FF" +json_null: "#AAB5C0" +table_title: "#FFFFFF" +error_title: "#FFCC66" \ No newline at end of file diff --git a/internal/config/skins/solarized-dark.yaml b/internal/config/skins/solarized-dark.yaml new file mode 100644 index 0000000..2058c94 --- /dev/null +++ b/internal/config/skins/solarized-dark.yaml @@ -0,0 +1,54 @@ +main: + foreground: "#93A1A1" + background: "#002B36" +table: + foreground: "#93A1A1" + background: "#002B36" +context: + foreground: "#93A1A1" + background: "#002B36" +command: + foreground: "#268BD2" + background: "#002B36" +resource_tag: + foreground: "#FDF6E3" + background: "#DC322F" +navigation_tag: + foreground: "#002B36" + background: "#2AA198" +status: + foreground: "#002B36" + background: "#B58900" +loading: + foreground: "#002B36" + background: "#CB4B16" +info: + foreground: "#002B36" + background: "#2AA198" +state_on: + foreground: "#002B36" + background: "#859900" +state_off: + foreground: "#FDF6E3" + background: "#DC322F" +modal_button: + foreground: "#002B36" + background: "#2AA198" +table_selected: + foreground: "#002B36" + background: "#2AA198" +table_header: "#EEE8D5" +command_ghost: "#657B83" +command_placeholder: "#657B83" +header_label: "#B58900" +header_value: "#EEE8D5" +header_command_key: "#2AA198" +header_command_text: "#93A1A1" +logo: "#B58900" +json_key: "#268BD2" +json_string: "#859900" +json_number: "#D33682" +json_bool: "#CB4B16" +json_null: "#657B83" +table_title: "#EEE8D5" +error_title: "#DC322F" \ No newline at end of file diff --git a/internal/config/skins/tokyo-night.yaml b/internal/config/skins/tokyo-night.yaml new file mode 100644 index 0000000..eed8f6a --- /dev/null +++ b/internal/config/skins/tokyo-night.yaml @@ -0,0 +1,54 @@ +main: + foreground: "#C0CAF5" + background: "#1A1B26" +table: + foreground: "#C0CAF5" + background: "#1A1B26" +context: + foreground: "#C0CAF5" + background: "#1A1B26" +command: + foreground: "#7AA2F7" + background: "#1A1B26" +resource_tag: + foreground: "#1A1B26" + background: "#F7768E" +navigation_tag: + foreground: "#1A1B26" + background: "#BB9AF7" +status: + foreground: "#1A1B26" + background: "#E0AF68" +loading: + foreground: "#1A1B26" + background: "#FF9E64" +info: + foreground: "#1A1B26" + background: "#7DCFFF" +state_on: + foreground: "#1A1B26" + background: "#9ECE6A" +state_off: + foreground: "#1A1B26" + background: "#F7768E" +modal_button: + foreground: "#1A1B26" + background: "#7AA2F7" +table_selected: + foreground: "#1A1B26" + background: "#7AA2F7" +table_header: "#C0CAF5" +command_ghost: "#565F89" +command_placeholder: "#565F89" +header_label: "#E0AF68" +header_value: "#C0CAF5" +header_command_key: "#7DCFFF" +header_command_text: "#C0CAF5" +logo: "#7AA2F7" +json_key: "#7DCFFF" +json_string: "#9ECE6A" +json_number: "#FF9E64" +json_bool: "#BB9AF7" +json_null: "#565F89" +table_title: "#C0CAF5" +error_title: "#F7768E" \ No newline at end of file diff --git a/internal/config/skins/tritanopia.yaml b/internal/config/skins/tritanopia.yaml new file mode 100644 index 0000000..5cfe532 --- /dev/null +++ b/internal/config/skins/tritanopia.yaml @@ -0,0 +1,54 @@ +main: + foreground: "#F5F5F5" + background: "#19191D" +table: + foreground: "#F5F5F5" + background: "#19191D" +context: + foreground: "#F5F5F5" + background: "#19191D" +command: + foreground: "#FF9EB5" + background: "#19191D" +resource_tag: + foreground: "#19191D" + background: "#FF9EB5" +navigation_tag: + foreground: "#19191D" + background: "#A8E6CF" +status: + foreground: "#19191D" + background: "#FFB4A2" +loading: + foreground: "#19191D" + background: "#FF9EB5" +info: + foreground: "#19191D" + background: "#A8E6CF" +state_on: + foreground: "#19191D" + background: "#A8E6CF" +state_off: + foreground: "#19191D" + background: "#FF9EB5" +modal_button: + foreground: "#19191D" + background: "#A8E6CF" +table_selected: + foreground: "#19191D" + background: "#FFB4A2" +table_header: "#FFFFFF" +command_ghost: "#A8A8B0" +command_placeholder: "#A8A8B0" +header_label: "#FFB4A2" +header_value: "#FFFFFF" +header_command_key: "#FF9EB5" +header_command_text: "#F5F5F5" +logo: "#FFB4A2" +json_key: "#FF9EB5" +json_string: "#A8E6CF" +json_number: "#FFFFFF" +json_bool: "#FFB4A2" +json_null: "#A8A8B0" +table_title: "#FFFFFF" +error_title: "#FF9EB5" \ No newline at end of file diff --git a/internal/ui/app.go b/internal/ui/app.go index b8f0016..b70ca36 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -27,6 +27,7 @@ const refreshInterval = 30 * time.Second type Model struct { version string + skin config.Skin registry *resource.Registry resource resource.Resource @@ -81,6 +82,8 @@ type Model struct { } func New(registry *resource.Registry, openstackContext *openstack.Context, pluginHost *plugins.Host, version string, cfg config.Config) Model { + initStyles(cfg.SkinColors) + r, ok := registry.Get("contexts") if !ok { return Model{ @@ -107,17 +110,17 @@ func New(registry *resource.Registry, openstackContext *openstack.Context, plugi table.WithHeight(10), ) - t.SetStyles(tableStyles()) + t.SetStyles(tableStyles(cfg.SkinColors)) command := textinput.New() command.Prompt = ":" command.CharLimit = 64 command.ShowSuggestions = true - command.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#87CEFA")) - command.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#87CEFA")) - command.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#87CEFA")) - command.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + command.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(cfg.SkinColors.Command.Foreground)).Background(lipgloss.Color(cfg.SkinColors.Command.Background)) + command.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(cfg.SkinColors.Command.Foreground)).Background(lipgloss.Color(cfg.SkinColors.Command.Background)) + command.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(cfg.SkinColors.Command.Foreground)).Background(lipgloss.Color(cfg.SkinColors.Command.Background)) + command.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(cfg.SkinColors.CommandPlaceholder)).Background(lipgloss.Color(cfg.SkinColors.Command.Background)) detailViewport := viewport.New(1, 1) detailViewport.SetHorizontalStep(4) @@ -137,6 +140,7 @@ func New(registry *resource.Registry, openstackContext *openstack.Context, plugi m := Model{ version: version, + skin: cfg.SkinColors, registry: registry, resource: r, @@ -710,19 +714,19 @@ func (m Model) View() string { if m.width > compactWidth { for _, entry := range m.navigation { resourceTags.WriteString( - navigationTagStyle.Render(" <"+entry.resource+"> ") + " ", + navigationTagStyle.Render(" <"+entry.resource+"> ") + mainStyle.Render(" "), ) } } activeResourceStyle := resourceTagStyle if color := plugins.ResourceColor(m.resource); color != "" { - activeResourceStyle = activeResourceStyle.Background(lipgloss.Color(color)) + activeResourceStyle = activeResourceStyle.Background(lipgloss.Color(color)).Foreground(lipgloss.Color("#FFFFFF")) } if m.detailMode { resourceTags.WriteString( - navigationTagStyle.Render(" <"+m.resource.Kind()+"> ") + " ", + navigationTagStyle.Render(" <"+m.resource.Kind()+"> ") + mainStyle.Render(" "), ) resourceTags.WriteString( activeResourceStyle.Render(" <" + strings.ToLower(m.detailLabel) + "> "), @@ -772,6 +776,7 @@ func (m Model) View() string { lipgloss.Top, lipgloss.NewStyle(). Width(max(m.width-lipgloss.Width(rightTag), 1)). + Background(lipgloss.Color(m.skin.Main.Background)). Render(resourceTags.String()), rightTag, ) @@ -789,6 +794,15 @@ func (m Model) View() string { commandLine = m.command.View() } + commandLine = lipgloss.NewStyle(). + Width(m.width). + Background(lipgloss.Color(m.skin.Main.Background)). + Render(commandLine) + + if commandLine == "" { + commandLine = mainStyle.Render(strings.Repeat(" ", m.width)) + } + view := header + "\n\n" + contentView + @@ -797,10 +811,14 @@ func (m Model) View() string { "\n" + commandLine + view = lipgloss.NewStyle(). + Width(m.width). + Height(m.height). + Background(lipgloss.Color(m.skin.Main.Background)). + Render(view) + if m.err != nil { - if m.err != nil { - return overlayCenter(view, m.renderErrorModal()) - } + return overlayCenter(view, m.renderErrorModal()) } return view diff --git a/internal/ui/helpers.go b/internal/ui/helpers.go index d9622ec..59bd884 100644 --- a/internal/ui/helpers.go +++ b/internal/ui/helpers.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "regexp" "strings" "github.com/charmbracelet/lipgloss" @@ -151,3 +152,18 @@ func (m Model) buildCacheKey(kind string) string { hash := sha256.Sum256([]byte(identity)) return fmt.Sprintf("%s:%s", hex.EncodeToString(hash[:8]), kind) } + +var ansiSGR = regexp.MustCompile("\x1b\\[[0-9;]*m") + +func preserveBackground(content string, background lipgloss.Color) string { + style := lipgloss.NewStyle().Background(background) + prefix := ansiSGR.FindString(style.Render("x")) + + if prefix == "" { + return content + } + + return prefix + ansiSGR.ReplaceAllStringFunc(content, func(sequence string) string { + return sequence + prefix + }) +} diff --git a/internal/ui/render.go b/internal/ui/render.go index 8a7c0c4..f716224 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -51,18 +51,19 @@ func (m Model) renderHeader() string { left := lipgloss.NewStyle(). Width(leftWidth). + Background(lipgloss.Color(m.skin.Main.Background)). Render(context) center := m.renderHeaderCommands(centerWidth) - // Fill all available space before the logo, so the logo - // remains at the terminal's right edge. center = lipgloss.NewStyle(). Width(centerWidth). + Background(lipgloss.Color(m.skin.Main.Background)). Render(center) right := lipgloss.NewStyle(). Width(rightWidth). Align(lipgloss.Right). + Background(lipgloss.Color(m.skin.Main.Background)). Render(renderedLogo) if !showContext { @@ -201,11 +202,16 @@ func (m Model) renderHeaderCommands(availableWidth int) string { } text = ansi.Truncate(text, max(width-keyWidth-1, 0), "") - lines = append(lines, key+text+" ") + line := key + text + lineWidth := lipgloss.Width(line) + if lineWidth < width { + line += mainStyle.Render(strings.Repeat(" ", width-lineWidth)) + } + lines = append(lines, line) } for len(lines) < commandsPerColumn { - lines = append(lines, "") + lines = append(lines, mainStyle.Render(strings.Repeat(" ", width))) } columns = append(columns, strings.Join(lines, "\n")) @@ -238,7 +244,8 @@ func (m Model) renderTable() string { right := remaining - left borderStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#00BFFF")) + Foreground(lipgloss.Color(m.skin.Main.Foreground)). + Background(lipgloss.Color(m.skin.Main.Background)) top := borderStyle.Render( "┌" + @@ -280,23 +287,38 @@ func (m Model) renderTable() string { emptyMessage, ) + emptyLines := strings.Split(emptyContent, "\n") + for i, line := range emptyLines { + emptyLines[i] = tableBodyStyle.Width(innerWidth).Align(lipgloss.Center).Render(line) + } + emptyContent = strings.Join(emptyLines, "\n") + body = header + "\n" + emptyContent } } - bodyLines := strings.Split(body, "\n") + selectedStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(m.skin.TableSelected.Foreground)). + Background(lipgloss.Color(m.skin.TableSelected.Background)) + paddingStyle := lipgloss.NewStyle(). + Background(lipgloss.Color(m.skin.Table.Background)) + + bodyLines := strings.Split(body, "\n") for i, line := range bodyLines { - lineWidth := lipgloss.Width(line) + selected := i > 0 && i-1 == m.table.Cursor() + if selected { + line = selectedStyle.Render(ansi.Strip(line)) + } else if i > 0 && strings.TrimSpace(ansi.Strip(line)) == "" { + line = paddingStyle.Render(strings.Repeat(" ", innerWidth)) + } + lineWidth := lipgloss.Width(line) if lineWidth < innerWidth { - line += strings.Repeat(" ", innerWidth-lineWidth) + line += paddingStyle.Render(strings.Repeat(" ", innerWidth-lineWidth)) } - bodyLines[i] = - borderStyle.Render("│") + - line + - borderStyle.Render("│") + bodyLines[i] = borderStyle.Render("│") + line + borderStyle.Render("│") } return top + @@ -326,7 +348,8 @@ func (m Model) renderDetails() string { right := remaining - left borderStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#00BFFF")) + Foreground(lipgloss.Color(m.skin.Main.Foreground)). + Background(lipgloss.Color(m.skin.Main.Background)) top := borderStyle.Render( "┌" + @@ -367,7 +390,7 @@ func (m Model) renderDetails() string { bodyLines[i] = borderStyle.Render("│") + - line + + preserveBackground(detailBodyStyle.Render(line), lipgloss.Color(m.skin.Main.Background)) + rightBorder } @@ -442,17 +465,16 @@ func overlayCenter(background, foreground string) string { } func (m Model) renderErrorModal() string { - hint := lipgloss.NewStyle(). - Faint(true). - Render("Press ") + + hintStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(m.skin.Main.Foreground)). + Background(lipgloss.Color(m.skin.Main.Background)). + Faint(true) + + hint := hintStyle.Render("Press ") + modalButtonStyle.Render(" Esc ") + - lipgloss.NewStyle(). - Faint(true). - Render(" or ") + + hintStyle.Render(" or ") + modalButtonStyle.Render(" Enter ") + - lipgloss.NewStyle(). - Faint(true). - Render(" to dismiss") + hintStyle.Render(" to dismiss") content := errorTitleStyle.Render("Error") + "\n\n" + diff --git a/internal/ui/styles.go b/internal/ui/styles.go index c9f7305..3c5d5c1 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -1,6 +1,7 @@ package ui import ( + "github.com/akyriako/o7k/internal/config" "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/lipgloss" ) @@ -8,135 +9,179 @@ import ( //Header and AppView var ( - resourceTagStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("255")). - Background(lipgloss.Color("#ED1944")). - Bold(true) + mainStyle lipgloss.Style + tableBodyStyle lipgloss.Style + detailBodyStyle lipgloss.Style + resourceTagStyle lipgloss.Style + navigationTagStyle lipgloss.Style + statusStyle lipgloss.Style + headerLabelStyle lipgloss.Style + headerValueStyle lipgloss.Style + headerCommandKeyStyle lipgloss.Style + headerCommandTextStyle lipgloss.Style + logoStyle lipgloss.Style + loadingStyle lipgloss.Style + infoStyle lipgloss.Style + autoRefreshStateOnStyle lipgloss.Style + autoRefreshStateOffStyle lipgloss.Style + modalButtonStyle lipgloss.Style + jsonKeyStyle lipgloss.Style + jsonStringStyle lipgloss.Style + jsonNumberStyle lipgloss.Style + jsonBoolStyle lipgloss.Style + jsonNullStyle lipgloss.Style + detailScrollbarTrackStyle lipgloss.Style + detailScrollbarThumbStyle lipgloss.Style + errorModalStyle lipgloss.Style + errorTitleStyle lipgloss.Style + tableTitleStyle lipgloss.Style + errorScrollbarTrackStyle lipgloss.Style + errorScrollbarThumbStyle lipgloss.Style +) - navigationTagStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("0")). - Background(lipgloss.Color("255")). - Bold(true) +func initStyles(skin config.Skin) { + mainStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(skin.Main.Foreground)). + Background(lipgloss.Color(skin.Main.Background)) - commandStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#87CEFA")) + tableBodyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(skin.Table.Foreground)).Background(lipgloss.Color(skin.Table.Background)) + detailBodyStyle = mainStyle + + resourceTagStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(skin.ResourceTag.Foreground)). + Background(lipgloss.Color(skin.ResourceTag.Background)). + Bold(true) - commandGhostStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#5C6370")). - Faint(true) + navigationTagStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(skin.NavigationTag.Foreground)). + Background(lipgloss.Color(skin.NavigationTag.Background)) statusStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#000000")). - Background(lipgloss.Color("#FFA500")). - Align(lipgloss.Center) + Foreground(lipgloss.Color(skin.Status.Foreground)). + Background(lipgloss.Color(skin.Status.Background)). + Align(lipgloss.Center) headerLabelStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFA500")). - Bold(true) + Foreground(lipgloss.Color(skin.HeaderLabel)). + Background(lipgloss.Color(skin.Context.Background)). + Bold(true) headerValueStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")) + Foreground(lipgloss.Color(skin.HeaderValue)). + Background(lipgloss.Color(skin.Context.Background)) headerCommandKeyStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#00BFFF")). - Bold(true) + Foreground(lipgloss.Color(skin.HeaderCommandKey)). + Background(lipgloss.Color(skin.Main.Background)). + Bold(true) headerCommandTextStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")) + Foreground(lipgloss.Color(skin.HeaderCommandText)). + Background(lipgloss.Color(skin.Main.Background)) logoStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFA500")). - Bold(true) + Foreground(lipgloss.Color(skin.Logo)). + Background(lipgloss.Color(skin.Main.Background)). + Bold(true) loadingStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#000000")). - Background(lipgloss.Color("#ff8c00")). - Bold(true) + Foreground(lipgloss.Color(skin.Loading.Foreground)). + Background(lipgloss.Color(skin.Loading.Background)). + Bold(true) infoStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#000000")). - Background(lipgloss.Color("#00BFFF")). - Bold(true) + Foreground(lipgloss.Color(skin.Info.Foreground)). + Background(lipgloss.Color(skin.Info.Background)). + Bold(true) autoRefreshStateOnStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#000000")). - Background(lipgloss.Color("#008000")). - Bold(true) + Foreground(lipgloss.Color(skin.StateOn.Foreground)). + Background(lipgloss.Color(skin.StateOn.Background)). + Bold(true) autoRefreshStateOffStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#000000")). - Background(lipgloss.Color("#FF0000")). - Bold(true) + Foreground(lipgloss.Color(skin.StateOff.Foreground)). + Background(lipgloss.Color(skin.StateOff.Background)). + Bold(true) modalButtonStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#000000")). - Background(lipgloss.Color("#00BFFF")). - Bold(true) -) + Foreground(lipgloss.Color(skin.ModalButton.Foreground)). + Background(lipgloss.Color(skin.ModalButton.Background)). + Bold(true) -//DetailsView + //DetailsView -var ( jsonKeyStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#87CEFA")) + Foreground(lipgloss.Color(skin.JSONKey)). + Background(lipgloss.Color(skin.Main.Background)) jsonStringStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#98C379")) + Foreground(lipgloss.Color(skin.JSONString)). + Background(lipgloss.Color(skin.Main.Background)) jsonNumberStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#E5C07B")) + Foreground(lipgloss.Color(skin.JSONNumber)). + Background(lipgloss.Color(skin.Main.Background)) jsonBoolStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#C678DD")) + Foreground(lipgloss.Color(skin.JSONBool)). + Background(lipgloss.Color(skin.Main.Background)) jsonNullStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#7F848E")) + Foreground(lipgloss.Color(skin.JSONNull)). + Background(lipgloss.Color(skin.Main.Background)) detailScrollbarTrackStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#00BFFF")) + Foreground(lipgloss.Color(skin.Main.Foreground)). + Background(lipgloss.Color(skin.Main.Background)) detailScrollbarThumbStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#00BFFF")). - Bold(true) -) + Foreground(lipgloss.Color(skin.Main.Foreground)). + Background(lipgloss.Color(skin.Main.Background)). + Bold(true) -//ErrorView + //ErrorView -var ( errorModalStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#ED1944")). - Foreground(lipgloss.Color("#FFFFFF")). - Padding(1, 1). - Width(60) + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(skin.Main.Foreground)). + BorderBackground(lipgloss.Color(skin.Main.Background)). + Foreground(lipgloss.Color(skin.Main.Foreground)). + Background(lipgloss.Color(skin.Main.Background)). + Padding(1, 1). + Width(60) errorTitleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#ED1944")). - Bold(true) -) + Foreground(lipgloss.Color(skin.ErrorTitle)). + Bold(true) -//TableView + errorScrollbarTrackStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(skin.Main.Foreground)) + + errorScrollbarThumbStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(skin.Main.Foreground)) + + //TableView -var ( tableTitleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")).Bold(true) -) + Foreground(lipgloss.Color(skin.TableTitle)).Bold(true) +} -func tableStyles() table.Styles { +func tableStyles(skin config.Skin) table.Styles { styles := table.DefaultStyles() + styles.Cell = styles.Cell. + Foreground(lipgloss.Color(skin.Table.Foreground)). + Background(lipgloss.Color(skin.Table.Background)) + + styles.Header = styles.Header. + Foreground(lipgloss.Color(skin.TableHeader)). + Background(lipgloss.Color(skin.Table.Background)) + styles.Selected = styles.Selected. - Foreground(lipgloss.Color("255")). - Background(lipgloss.Color("#ED1944")). + Foreground(lipgloss.Color(skin.TableSelected.Foreground)). + Background(lipgloss.Color(skin.TableSelected.Background)). Bold(true) return styles } - -var ( - errorScrollbarTrackStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#ED1944")) - - errorScrollbarThumbStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#ED1944")) -)