From b14b26e663fe0c5b6144d46370c2ea6bb2d956e4 Mon Sep 17 00:00:00 2001 From: Bruce12 <154927363+bruno120805@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:49:10 -0600 Subject: [PATCH] feat(config): add theme customization (#81) Added theme customization via config.toml #17 ``` [theme] border = "#665C54" accent = "#FABD2F" muted = "#928374" text = "#EBDBB2" text_sel = "#FBF1C7" match = "#FE8019" desc = "#A89984" desc_sel = "#EBDBB2" sel_bg = "#504945" scroll_info = "#83A598" ghost_text = "#7C6F64" hist= "#fe8019" hist_sel = "#d65d0e" sys = "#fb4934" sys_sel = "#cc241d" alias = "#b8bb26" alias_sel = "#98971a" ``` --------- Co-authored-by: verse91 Co-authored-by: VERSE --- integration/overlay.go | 120 +++++++++------------- internal/config/config.go | 51 ++++++--- internal/config/defaults.go | 4 +- internal/config/paths.go | 12 +++ internal/config/theme.go | 104 +++++++++++++++++++ root/theme_cmd.go | 106 +++++++++++++++++++ root/theme_cmd_test.go | 39 +++++++ root/wrapper.go | 199 +++++++++++++++++++----------------- themes/gruvbox.toml | 19 ++++ themes/tokyo-night.toml | 19 ++++ 10 files changed, 494 insertions(+), 179 deletions(-) create mode 100644 internal/config/theme.go create mode 100644 root/theme_cmd.go create mode 100644 root/theme_cmd_test.go create mode 100644 themes/gruvbox.toml create mode 100644 themes/tokyo-night.toml diff --git a/integration/overlay.go b/integration/overlay.go index ff9a5e0..5c29162 100644 --- a/integration/overlay.go +++ b/integration/overlay.go @@ -2,7 +2,6 @@ package integration import ( "fmt" - "image/color" "os" "strconv" "strings" @@ -129,38 +128,6 @@ func ComputeCursorCol(data []byte) int { return col } -type Theme struct { - Border color.Color - Accent color.Color - Muted color.Color - Text color.Color - TextSel color.Color - Match color.Color - Desc color.Color - DescSel color.Color - SelBg color.Color - ScrollInfo color.Color - GhostText color.Color -} - -var currentTheme = Theme{ - Border: lipgloss.Color("#a277ff"), - Accent: lipgloss.Color("#61ffca"), - Muted: lipgloss.Color("#6d6a7f"), - Text: lipgloss.Color("#edecee"), - TextSel: lipgloss.Color("#ffffff"), - Match: lipgloss.Color("#61ffca"), - Desc: lipgloss.Color("#9692a8"), - DescSel: lipgloss.Color("#edecee"), - SelBg: lipgloss.Color("#3d375e"), - ScrollInfo: lipgloss.Color("#a277ff"), - GhostText: lipgloss.Color("#4B4A4C"), -} - -func SetTheme(t Theme) { - currentTheme = t -} - type Overlay struct { mu sync.Mutex Visible bool @@ -477,7 +444,11 @@ func (o *Overlay) RenderGhostText(buffer string, userNavigated bool, cursorAtEnd if ghostText != "" { width := termWidth() - cursorCol := o.PromptLen + lipgloss.Width(buffer) + totalCol := o.PromptLen + lipgloss.Width(buffer) + cursorCol := totalCol + if width > 0 { + cursorCol = totalCol % width + } availableCols := width - cursorCol if availableCols <= 0 { ghostText = "" @@ -498,7 +469,7 @@ func (o *Overlay) RenderGhostText(buffer string, userNavigated bool, cursorAtEnd s.WriteString(ansi.SaveCursor) if ghostText != "" { - styled := lipgloss.NewStyle().Foreground(currentTheme.GhostText).Render(ghostText) + styled := lipgloss.NewStyle().Foreground(lipgloss.Color(config.Theme().GhostText)).Render(ghostText) s.WriteString(styled) } if padLen > 0 { @@ -520,28 +491,18 @@ func termWidth() int { return w } -// sourceTag renders a pill-style source label (" alias ") with inverted -// colors when selected. -func sourceTag(label, bgHex, fgHex string, selected bool) string { - style := lipgloss.NewStyle().Background(lipgloss.Color(bgHex)).Foreground(lipgloss.Color(fgHex)) - if selected { - style = lipgloss.NewStyle().Background(lipgloss.Color(fgHex)).Foreground(lipgloss.Color("#110f18")).Bold(true) - } - return style.Render(" " + label + " ") -} - func renderMatchedTitle(title, typed string, selected bool, w int) string { - t := currentTheme + t := config.Theme() textColor := t.Text if selected { textColor = t.TextSel } - base := lipgloss.NewStyle().Foreground(textColor) - match := lipgloss.NewStyle().Foreground(t.Match).Bold(true) + base := lipgloss.NewStyle().Foreground(lipgloss.Color(textColor)) + match := lipgloss.NewStyle().Foreground(lipgloss.Color(t.Match)).Bold(true) if selected { - base = base.Background(t.SelBg) - match = match.Background(t.SelBg) + base = base.Background(lipgloss.Color(t.SelBg)) + match = match.Background(lipgloss.Color(t.SelBg)) } display := fixedWidth(title, w) @@ -570,9 +531,9 @@ func (o *Overlay) draw() string { return "" } - t := currentTheme - border := lipgloss.NewStyle().Foreground(t.Border) - scrollStyle := lipgloss.NewStyle().Foreground(t.ScrollInfo) + t := config.Theme() + border := lipgloss.NewStyle().Foreground(lipgloss.Color(t.Border)) + scrollStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(t.ScrollInfo)) var s strings.Builder s.WriteString(ansi.ResetModeAutoWrap) @@ -580,6 +541,10 @@ func (o *Overlay) draw() string { typedLen := lipgloss.Width(o.TypedQuery) width := termWidth() + // ComputeCursorCol returns the total visual width, not the column on the + // current line. When the prompt is wider than the terminal the cursor has + // wrapped, so using PromptLen directly overflows the screen and the box + // lands at the wrong horizontal position. boxWidth := config.Get().UI.MaxWidth if boxWidth <= 0 { boxWidth = 76 // Default if 0 @@ -700,14 +665,14 @@ func (o *Overlay) draw() string { bg := lipgloss.NewStyle() if selected { - bg = bg.Background(t.SelBg) + bg = bg.Background(lipgloss.Color(t.SelBg)) } marker := " " - markerStyle := bg.Foreground(t.Muted) + markerStyle := bg.Foreground(lipgloss.Color(t.Muted)) if selected { marker = "▶" - markerStyle = bg.Foreground(t.Accent).Bold(true) + markerStyle = bg.Foreground(lipgloss.Color(t.Accent)).Bold(true) } iconGlyph := lookupIcon(it.Icon) @@ -715,7 +680,7 @@ func (o *Overlay) draw() string { if selected { iconColor = t.Accent } - iconStr := bg.Foreground(iconColor).Render(fixedWidth(iconGlyph, iconW)) + iconStr := bg.Foreground(lipgloss.Color(iconColor)).Render(fixedWidth(iconGlyph, iconW)) title := renderMatchedTitle(it.Cmd, o.TypedQuery, selected, titleW) @@ -727,26 +692,41 @@ func (o *Overlay) draw() string { var desc string if isClassic { if it.Icon == "alias" { - desc = bg.Foreground(descColor).Render(fixedWidth("alias: "+it.Desc, descW)) + desc = bg.Foreground(lipgloss.Color(descColor)).Render(fixedWidth("alias: "+it.Desc, descW)) } else { - desc = bg.Foreground(descColor).Render(fixedWidth(it.Desc, descW)) + desc = bg.Foreground(lipgloss.Color(descColor)).Render(fixedWidth(it.Desc, descW)) } } else { switch it.Icon { case "alias": - tag := sourceTag("alias", "#2a2342", "#a277ff", selected) - rem := max(descW-lipgloss.Width(tag)-1, 0) - desc = tag + bg.Render(" ") + bg.Foreground(descColor).Render(fixedWidth(it.Desc, rem)) + boxStyle := lipgloss.NewStyle().Background(lipgloss.Color(t.Alias)).Foreground(lipgloss.Color(t.AliasSel)) + if selected { + boxStyle = lipgloss.NewStyle().Background(lipgloss.Color(t.AliasSel)).Foreground(lipgloss.Color(t.SelText)).Bold(true) + } + tag := boxStyle.Render(" alias ") + tw := lipgloss.Width(tag) + rem := max(descW-tw-1, 0) + desc = tag + bg.Render(" ") + bg.Foreground(lipgloss.Color(descColor)).Render(fixedWidth(it.Desc, rem)) case "history": - tag := sourceTag("history", "#1a2d36", "#61ffca", selected) - rem := max(descW-lipgloss.Width(tag), 0) + boxStyle := lipgloss.NewStyle().Background(lipgloss.Color(t.History)).Foreground(lipgloss.Color(t.HistorySel)) + if selected { + boxStyle = lipgloss.NewStyle().Background(lipgloss.Color(t.HistorySel)).Foreground(lipgloss.Color(t.SelText)).Bold(true) + } + tag := boxStyle.Render(" history ") + tw := lipgloss.Width(tag) + rem := max(descW-tw, 0) desc = tag + bg.Render(strings.Repeat(" ", rem)) case "system": - tag := sourceTag("system", "#1e1d28", "#a277ff", selected) - rem := max(descW-lipgloss.Width(tag), 0) + boxStyle := lipgloss.NewStyle().Background(lipgloss.Color(t.Sys)).Foreground(lipgloss.Color(t.SysSel)) + if selected { + boxStyle = lipgloss.NewStyle().Background(lipgloss.Color(t.SysSel)).Foreground(lipgloss.Color(t.SelText)).Bold(true) + } + tag := boxStyle.Render(" system ") + tw := lipgloss.Width(tag) + rem := max(descW-tw, 0) desc = tag + bg.Render(strings.Repeat(" ", rem)) default: - desc = bg.Foreground(descColor).Render(fixedWidth(it.Desc, descW)) + desc = bg.Foreground(lipgloss.Color(descColor)).Render(fixedWidth(it.Desc, descW)) } } @@ -777,11 +757,11 @@ func (o *Overlay) draw() string { footerInfo := "" if !isClassic { - keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#a277ff")).Bold(true) + keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(t.Key)).Bold(true) selectKey := keyStyle.Render(config.FormatKeyName(config.Get().Keybindings.SelectSuggestion)) ctrlRKey := keyStyle.Render(config.FormatKeyName(config.Get().Keybindings.ToggleMode)) - acceptText := lipgloss.NewStyle().Foreground(t.ScrollInfo).Render(" Accept") - modeText := lipgloss.NewStyle().Foreground(t.ScrollInfo).Render(" Mode") + acceptText := lipgloss.NewStyle().Foreground(lipgloss.Color(t.ScrollInfo)).Render(" Accept") + modeText := lipgloss.NewStyle().Foreground(lipgloss.Color(t.ScrollInfo)).Render(" Mode") footerInfo = fmt.Sprintf(" %s%s • %s%s ", selectKey, acceptText, ctrlRKey, modeText) } diff --git a/internal/config/config.go b/internal/config/config.go index 0bffdbd..cfce910 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -144,30 +144,47 @@ func Init(cfg *Config) { } func AutoDetectConfigChange(onReload func(cfg *Config)) { - path, err := ConfigPath() + cfgPath, err := ConfigPath() if err != nil { return } + themePath, _ := ThemePath() + go func() { - var lastMod time.Time - if info, err := os.Stat(path); err == nil { - lastMod = info.ModTime() + statMod := func(p string) time.Time { + if info, err := os.Stat(p); err == nil { + return info.ModTime() + } + return time.Time{} } + cfgLast := statMod(cfgPath) + themeLast := statMod(themePath) ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for range ticker.C { - info, err := os.Stat(path) - if err == nil { - if !lastMod.IsZero() && info.ModTime().After(lastMod) { - lastMod = info.ModTime() - if newCfg, err := Load(); err == nil { - Init(newCfg) - if onReload != nil { - onReload(newCfg) - } + changed := false + cfgMod := statMod(cfgPath) + if !cfgLast.IsZero() && cfgMod.After(cfgLast) { + cfgLast = cfgMod + changed = true + } else if cfgLast.IsZero() { + cfgLast = cfgMod + } + if themePath != "" { + themeMod := statMod(themePath) + if !themeLast.IsZero() && (themeMod.After(themeLast) || themeMod.IsZero()) { + themeLast = themeMod + changed = true + } else if themeLast.IsZero() { + themeLast = themeMod + } + } + if changed { + if newCfg, err := Load(); err == nil { + Init(newCfg) + if onReload != nil { + onReload(newCfg) } - } else if lastMod.IsZero() { - lastMod = info.ModTime() } } } @@ -190,6 +207,10 @@ func Load() (*Config, error) { } } + if themePath, err := ThemePath(); err == nil { + LoadTheme(themePath) + } + applyEnv(cfg) // fallback for empty keybindings diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 076c9ce..db92ef9 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -1,6 +1,8 @@ package config -import "time" +import ( + "time" +) func DefaultConfig() *Config { return &Config{ diff --git a/internal/config/paths.go b/internal/config/paths.go index 1a50962..257fcde 100644 --- a/internal/config/paths.go +++ b/internal/config/paths.go @@ -17,6 +17,18 @@ func ConfigPath() (string, error) { return filepath.Join(dir, "iris", "config.toml"), nil } +func ThemePath() (string, error) { + configHome := os.Getenv("XDG_CONFIG_HOME") + if configHome != "" { + return filepath.Join(configHome, "iris", "theme.toml"), nil + } + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "iris", "theme.toml"), nil +} + func StatePath() (string, error) { dataHome := os.Getenv("XDG_DATA_HOME") if dataHome == "" { diff --git a/internal/config/theme.go b/internal/config/theme.go new file mode 100644 index 0000000..ee4bea5 --- /dev/null +++ b/internal/config/theme.go @@ -0,0 +1,104 @@ +package config + +import ( + "os" + "reflect" + "sync" + + "github.com/BurntSushi/toml" +) + +var defaultTheme = ThemeStyles{ + Border: "#a277ff", + Accent: "#61ffca", + Muted: "#6d6a7f", + Text: "#edecee", + TextSel: "#ffffff", + Key: "#a277ff", + Match: "#61ffca", + Desc: "#9692a8", + DescSel: "#edecee", + SelBg: "#3d375e", + SelText: "#110f18", + ScrollInfo: "#a277ff", + GhostText: "#4B4A4C", + History: "#1a2d36", + HistorySel: "#61ffca", + Sys: "#1e1d28", + SysSel: "#a277ff", + Alias: "#2a2342", + AliasSel: "#a277ff", +} + +var ( + themeMu sync.RWMutex + themeStyles = defaultTheme +) + +func Theme() ThemeStyles { + themeMu.RLock() + defer themeMu.RUnlock() + return themeStyles +} + +type ThemeStyles struct { + Border string `toml:"border"` + Accent string `toml:"accent"` + Muted string `toml:"muted"` + Text string `toml:"text"` + TextSel string `toml:"text_sel"` + Key string `toml:"key"` + Match string `toml:"match"` + Desc string `toml:"desc"` + DescSel string `toml:"desc_sel"` + SelBg string `toml:"sel_bg"` + SelText string `toml:"sel_text"` + ScrollInfo string `toml:"scroll_info"` + GhostText string `toml:"ghost_text"` + Sys string `toml:"sys"` + SysSel string `toml:"sys_sel"` + History string `toml:"hist"` + HistorySel string `toml:"hist_sel"` + Alias string `toml:"alias"` + AliasSel string `toml:"alias_sel"` +} + +// LoadTheme reads theme.toml at filePath +// Missing file - use defaults silently +// Missing/empty fields - fall back to the default value for that field +func LoadTheme(filePath string) { + themeMu.Lock() + defer themeMu.Unlock() + + themeStyles = defaultTheme + + data, err := os.ReadFile(filePath) + if err != nil { + // missing or unreadable file: use defaults + return + } + + var t ThemeStyles + if _, err := toml.Decode(string(data), &t); err != nil { + return + } + + applyThemeWithFallback(&themeStyles, t, defaultTheme) +} + +// applyThemeWithFallback copies non-empty string fields from src into dst, +// using def for any field that is empty in src +func applyThemeWithFallback(dst *ThemeStyles, src, def ThemeStyles) { + dv := reflect.ValueOf(dst).Elem() + sv := reflect.ValueOf(src) + dv2 := reflect.ValueOf(def) + + for i := range dv.NumField() { + sf := sv.Field(i).String() + if sf != "" { + dv.Field(i).SetString(sf) + } else { + dv.Field(i).SetString(dv2.Field(i).String()) + } + } +} diff --git a/root/theme_cmd.go b/root/theme_cmd.go new file mode 100644 index 0000000..3368ce7 --- /dev/null +++ b/root/theme_cmd.go @@ -0,0 +1,106 @@ +package root + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/versenilvis/iris/internal/config" +) + +var ThemeCmd = &cobra.Command{ + Use: "theme", + Short: "manage iris theme", +} + +var ThemeInitCmd = &cobra.Command{ + Use: "init", + Short: "initialize default theme file with comments", + Run: func(cmd *cobra.Command, args []string) { + path, err := config.ThemePath() + if err != nil { + fmt.Printf("failed to get theme path: %v\n", err) + return + } + + if _, statErr := os.Stat(path); statErr == nil { + fmt.Printf("theme file already exists at %s\n", path) + return + } + + _ = os.MkdirAll(filepath.Dir(path), 0755) + + defaultContent := `# ~/.config/iris/theme.toml +# iris theme file + +# overlay border color +border = "#a277ff" + +# accent color for selected marker and icon +accent = "#61ffca" + +# muted color for unselected icons +muted = "#6d6a7f" + +# default text color +text = "#edecee" + +# text color when selected +text_sel = "#ffffff" + +# footer shortcut key color +key = "#a277ff" + +# matched text color +match = "#61ffca" + +# description text color +desc = "#9692a8" + +# description text color when selected +desc_sel = "#edecee" + +# selected row background +sel_bg = "#3d375e" + +# selected tag pill text color +sel_text = "#110f18" + +# scroll counter color +scroll_info = "#a277ff" + +# inline ghost text color +ghost_text = "#4B4A4C" + +# "system" tag background color +sys = "#1e1d28" + +# "system" tag selected background color +sys_sel = "#a277ff" + +# "history" tag background color +hist = "#1a2d36" + +# "history" tag selected background color +hist_sel = "#61ffca" + +# "alias" tag background color +alias = "#2a2342" + +# "alias" tag selected background color (and alias word color) +alias_sel = "#a277ff" +` + err = os.WriteFile(path, []byte(defaultContent), 0644) + if err != nil { + fmt.Printf("failed to write theme file: %v\n", err) + return + } + fmt.Printf("initialized theme file at %s\n", path) + }, +} + +func init() { + ThemeCmd.AddCommand(ThemeInitCmd) + rootCmd.AddCommand(ThemeCmd) +} \ No newline at end of file diff --git a/root/theme_cmd_test.go b/root/theme_cmd_test.go new file mode 100644 index 0000000..0687763 --- /dev/null +++ b/root/theme_cmd_test.go @@ -0,0 +1,39 @@ +package root + +import ( + "os" + "strings" + "testing" + + "github.com/versenilvis/iris/internal/config" +) + +func TestThemeInitCommand(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "iris-theme-cmd-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // UserConfigDir follows HOME on macOS and XDG_CONFIG_HOME on Unix. + // Override both so the command cannot read or write the user's real theme. + t.Setenv("HOME", tmpDir) + t.Setenv("XDG_CONFIG_HOME", tmpDir) + + ThemeInitCmd.Run(ThemeInitCmd, []string{}) + + themePath, err := config.ThemePath() + if err != nil { + t.Fatalf("failed to get theme path: %v", err) + } + if _, statErr := os.Stat(themePath); statErr != nil { + t.Errorf("expected theme file to be created at %s, but it was not", themePath) + } + content, err := os.ReadFile(themePath) + if err != nil { + t.Fatalf("failed to read theme file: %v", err) + } + if !strings.Contains(string(content), "sel_text = \"#110f18\"") { + t.Error("expected initialized theme to include sel_text = \"#110f18\"") + } +} diff --git a/root/wrapper.go b/root/wrapper.go index 48b0357..d3730fc 100644 --- a/root/wrapper.go +++ b/root/wrapper.go @@ -276,8 +276,6 @@ func runWrapper() { overlay := integration.NewOverlay() - - // start background update check (async) pendingUpdate = startBackgroundUpdateCheck() updatePrinted := false @@ -289,10 +287,14 @@ func runWrapper() { var isCommandActive atomic.Bool var disableGhostText atomic.Bool disableGhostText.Store(!config.Get().UI.GhostText) + var renderOverlayFn atomic.Value // holds func() + renderOverlayFn.Store(func() {}) config.AutoDetectConfigChange(func(cfg *config.Config) { disableGhostText.Store(!cfg.UI.GhostText) + if renderer, ok := renderOverlayFn.Load().(func()); ok { + renderer() + } }) - renderOverlay := func() {} isExecuting := func() bool { if isCommandActive.Load() { // for bash: no preexec/precmd hooks, so fall back to TIOCGPGRP to detect when shell returns @@ -312,14 +314,16 @@ func runWrapper() { return pgrp != shellPGID } + suggestionsEnabled := true + // Shared handler for configured navigation keys (e.g. ctrl+j / ctrl+k). // Moves the overlay cursor when visible, otherwise opens history/spec // list and selects the next item in the requested direction. handleNavKey := func(dir string) { - intercepted = true - userNavigated.Store(true) - if overlay.IsVisible() { + intercepted = true + userNavigated.Store(true) + arrowDir := "down" if dir == "up" { arrowDir = "up" @@ -353,7 +357,12 @@ func runWrapper() { } b.WriteString(overlay.Render()) writeStdout([]byte(b.String())) - } else { + } else if suggestionsEnabled { + // hidden-overlay history navigation only when suggestions are enabled; + // otherwise let the navigation keys pass through to the shell + intercepted = true + userNavigated.Store(true) + activeModeMu.Lock() if activeMode == "" { activeMode = loadMode() @@ -443,8 +452,6 @@ func runWrapper() { } }() - - // listen for suggestion requests from shell scripts via the ipc pipe go func() { defer func() { @@ -565,14 +572,15 @@ func runWrapper() { cursorOffset = 0 bufferMu.Unlock() - renderOverlay() + if renderer, ok := renderOverlayFn.Load().(func()); ok { + renderer() + } } if err := scanner.Err(); err != nil { logger.Errorf("IPC scanner error: %v", err) } }() - suggestionsEnabled := true activeModeMu.Lock() activeMode = loadMode() activeModeMu.Unlock() @@ -651,7 +659,9 @@ func runWrapper() { } SetCurrentAISuggestion(sugg) if overlay.InjectAISuggestion(*sugg) { - renderOverlay() + if renderer, ok := renderOverlayFn.Load().(func()); ok { + renderer() + } } }) } @@ -693,7 +703,7 @@ func runWrapper() { writeStdout([]byte(b.String())) } - renderOverlay = func() { + renderOverlayFn.Store(func() { renderMu.Lock() defer renderMu.Unlock() @@ -718,9 +728,11 @@ func runWrapper() { renderMu.Unlock() renderMenuNow() }) - } + }) - renderOverlay() + if renderer, ok := renderOverlayFn.Load().(func()); ok { + renderer() + } // reads from stdin and decides what to forward or intercept // for most cases, I just handle the already have terminal shortcuts @@ -746,30 +758,30 @@ func runWrapper() { b := inputSlice[i] intercepted = false - if matched, consumed := config.MatchKey(inputSlice[i:], config.Get().Keybindings.ToggleMenu); matched { - intercepted = true - suggestionsEnabled = !suggestionsEnabled - if !suggestionsEnabled { - writeStdout([]byte(overlay.ClearAndDisable())) - } else { - shouldOverlayDraw = true + if matched, consumed := config.MatchKey(inputSlice[i:], config.Get().Keybindings.ToggleMenu); matched { + intercepted = true + suggestionsEnabled = !suggestionsEnabled + if !suggestionsEnabled { + writeStdout([]byte(overlay.ClearAndDisable())) + } else { + shouldOverlayDraw = true + } + i += consumed - 1 + continue } - i += consumed - 1 - continue - } - if matched, consumed := config.MatchKey(inputSlice[i:], config.Get().Keybindings.ToggleMode); matched { // ctrl+r: toggle between command specs and command history - i += consumed - 1 - intercepted = true - activeModeMu.Lock() - if activeMode == "spec" { - activeMode = "history" - } else { - activeMode = "spec" - } - saveMode(activeMode) - activeModeMu.Unlock() - if userNavigated.Load() { + if matched, consumed := config.MatchKey(inputSlice[i:], config.Get().Keybindings.ToggleMode); matched { // ctrl+r: toggle between command specs and command history + i += consumed - 1 + intercepted = true + activeModeMu.Lock() + if activeMode == "spec" { + activeMode = "history" + } else { + activeMode = "spec" + } + saveMode(activeMode) + activeModeMu.Unlock() + if userNavigated.Load() { bufferMu.Lock() naiveBuffer = overlay.GetTypedQuery() cursorOffset = 0 @@ -782,61 +794,60 @@ func runWrapper() { continue } - var isNavUp, isNavDown bool - var navConsumed int - if isNavUp, navConsumed = config.MatchKey(inputSlice[i:], config.Get().Keybindings.NavigateUp); !isNavUp { - isNavDown, navConsumed = config.MatchKey(inputSlice[i:], config.Get().Keybindings.NavigateDown) - } - - if isNavUp || isNavDown { - arrowDir := "down" - if isNavUp { - arrowDir = "up" + var isNavUp, isNavDown bool + var navConsumed int + if isNavUp, navConsumed = config.MatchKey(inputSlice[i:], config.Get().Keybindings.NavigateUp); !isNavUp { + isNavDown, navConsumed = config.MatchKey(inputSlice[i:], config.Get().Keybindings.NavigateDown) } - handleNavKey(arrowDir) - i += navConsumed - 1 - continue - } - if matched, consumed := config.MatchKey(inputSlice[i:], config.Get().Keybindings.SelectSuggestion); matched && config.Get().Keybindings.SelectSuggestion != "" { - if overlay.IsVisible() { - intercepted = true - selected := overlay.GetCurrentCmd() - if selected != "" { - activeModeMu.RLock() - currentMode := activeMode - activeModeMu.RUnlock() - if currentMode == "spec" { - s := strings.TrimSpace(selected) - if strings.HasSuffix(s, "/") || strings.HasSuffix(s, "\\") { - selected = s - } else { - selected = s + " " - } - } - bufferMu.Lock() - naiveBuffer = selected - cursorOffset = 0 - bufferMu.Unlock() - _, _ = ptmx.Write(append([]byte{0x15}, selected...)) - - overlay.ClearGhostTextState() - userNavigated.Store(false) - writeStdout([]byte(overlay.Render())) - renderOverlay() + if isNavUp || isNavDown { + arrowDir := "down" + if isNavUp { + arrowDir = "up" } + handleNavKey(arrowDir) + i += navConsumed - 1 + continue + } + + if matched, consumed := config.MatchKey(inputSlice[i:], config.Get().Keybindings.SelectSuggestion); matched && config.Get().Keybindings.SelectSuggestion != "" { + if overlay.IsVisible() { + intercepted = true + selected := overlay.GetCurrentCmd() + if selected != "" { + activeModeMu.RLock() + currentMode := activeMode + activeModeMu.RUnlock() + if currentMode == "spec" { + s := strings.TrimSpace(selected) + if strings.HasSuffix(s, "/") || strings.HasSuffix(s, "\\") { + selected = s + } else { + selected = s + " " + } + } + bufferMu.Lock() + naiveBuffer = selected + cursorOffset = 0 + bufferMu.Unlock() + _, _ = ptmx.Write(append([]byte{0x15}, selected...)) + + overlay.ClearGhostTextState() + userNavigated.Store(false) + writeStdout([]byte(overlay.Render())) + } + } + // always consume the full binding atomically, even when the overlay is hidden i += consumed - 1 continue } - } + if b == 0x0d || b == 0x0a { // enter + intercepted = true - if b == 0x0d || b == 0x0a { // enter - intercepted = true - - if b == 0x0d && i+1 < n && inputSlice[i+1] == 0x0a { + if b == 0x0d && i+1 < n && inputSlice[i+1] == 0x0a { i++ // consume trailing \n in \r\n to prevent matching ctrl+j } - + var selectedCmd string var shouldAutoExecute bool if overlay.IsVisible() && (config.Get().Core.AutoExecute || userNavigated.Load()) { @@ -925,13 +936,13 @@ func runWrapper() { if !isNavUp { isNavDown, navConsumed = config.MatchKey(inputSlice[i:], config.Get().Keybindings.NavigateDown) } - if isNavUp || isNavDown { - intercepted = true - arrowDir := "down" - if isNavUp { - arrowDir = "up" - } - handleNavKey(arrowDir) + if isNavUp || isNavDown { + intercepted = true + arrowDir := "down" + if isNavUp { + arrowDir = "up" + } + handleNavKey(arrowDir) i += navConsumed - 1 continue } @@ -1073,8 +1084,8 @@ func runWrapper() { continue } - if !intercepted { - _, _ = ptmx.Write([]byte{b}) + if !intercepted { + _, _ = ptmx.Write([]byte{b}) // we handle line editing keys manually to keep naiveBuffer in sync // since terminal is in raw mode, we must update our state for every change switch b { @@ -1210,7 +1221,9 @@ func runWrapper() { } } if shouldOverlayDraw { - renderOverlay() + if renderer, ok := renderOverlayFn.Load().(func()); ok { + renderer() + } } } } diff --git a/themes/gruvbox.toml b/themes/gruvbox.toml new file mode 100644 index 0000000..df74968 --- /dev/null +++ b/themes/gruvbox.toml @@ -0,0 +1,19 @@ +border = "#d79921" +accent = "#fabd2f" +muted = "#928374" +text = "#ebdbb2" +text_sel = "#fbf1c7" +key = "#d79921" +match = "#b8bb26" +desc = "#a89984" +desc_sel = "#ebdbb2" +sel_bg = "#3c3836" +sel_text = "#282828" +scroll_info = "#d79921" +ghost_text = "#665c54" +sys = "#32302f" +sys_sel = "#fb4934" +hist = "#282828" +hist_sel = "#83a598" +alias = "#32302f" +alias_sel = "#d3869b" \ No newline at end of file diff --git a/themes/tokyo-night.toml b/themes/tokyo-night.toml new file mode 100644 index 0000000..9d19f04 --- /dev/null +++ b/themes/tokyo-night.toml @@ -0,0 +1,19 @@ +border = "#7aa2f7" +accent = "#7dcfff" +muted = "#565f89" +text = "#c0caf5" +text_sel = "#ffffff" +key = "#bb9af7" +match = "#73daca" +desc = "#9aa5ce" +desc_sel = "#c0caf5" +sel_bg = "#283451" +sel_text = "#1a1b26" +scroll_info = "#7aa2f7" +ghost_text = "#414868" +sys = "#24283b" +sys_sel = "#f7768e" +hist = "#1f2335" +hist_sel = "#7dcfff" +alias = "#24283b" +alias_sel = "#bb9af7" \ No newline at end of file