diff --git a/action.go b/action.go index 68991a5..3452499 100644 --- a/action.go +++ b/action.go @@ -12,6 +12,7 @@ import ( "context" "github.com/lestrrat-go/pdebug" + "github.com/peco/peco/config" "github.com/peco/peco/hub" "github.com/peco/peco/internal/keyseq" "github.com/peco/peco/internal/util" @@ -446,7 +447,7 @@ func doCancel(ctx context.Context, state *Peco, e Event) { // peco.Cancel -> end program, exit with failure err := makeIgnorable(errors.New("user canceled")) - if state.onCancel == OnCancelError { + if state.onCancel == config.OnCancelError { err = setExitStatus(err, 1) } state.Exit(err) @@ -463,7 +464,7 @@ func batchAction(ctx context.Context, state *Peco, fn func(context.Context)) { func doToggleSelectionAndSelectNext(ctx context.Context, state *Peco, e Event) { batchAction(ctx, state, func(ctx context.Context) { doToggleSelection(ctx, state, e) - if state.LayoutType() != LayoutTypeBottomUp { + if state.LayoutType() != config.LayoutTypeBottomUp { state.Hub().SendPaging(ctx, hub.ToLineBelow) } else { state.Hub().SendPaging(ctx, hub.ToLineAbove) diff --git a/config.go b/config/config.go similarity index 55% rename from config.go rename to config/config.go index 1f8aab4..7cc8177 100644 --- a/config.go +++ b/config/config.go @@ -1,4 +1,4 @@ -package peco +package config import ( "encoding/json" @@ -6,7 +6,6 @@ import ( "fmt" "os" "path/filepath" - "strconv" "strings" "github.com/goccy/go-yaml" @@ -93,61 +92,11 @@ type CustomFilterConfig struct { BufferThreshold int `json:"BufferThreshold" yaml:"BufferThreshold"` } -// StyleSet holds styles for various sections -type StyleSet struct { - Basic Style `json:"Basic" yaml:"Basic"` - SavedSelection Style `json:"SavedSelection" yaml:"SavedSelection"` - Selected Style `json:"Selected" yaml:"Selected"` - Query Style `json:"Query" yaml:"Query"` - QueryCursor Style `json:"QueryCursor" yaml:"QueryCursor"` - Matched Style `json:"Matched" yaml:"Matched"` - Prompt Style `json:"Prompt" yaml:"Prompt"` - Context Style `json:"Context" yaml:"Context"` -} - -// Attribute represents terminal display attributes such as colors -// and text styling (bold, underline, reverse). It is a uint32 bitfield: -// -// Bits 0-8: Palette color index (0=default, 1-256 for 256-color palette) -// Bits 0-23: RGB color value (when AttrTrueColor flag is set) -// Bit 24: AttrTrueColor flag — distinguishes true color from palette -// Bit 25: AttrBold -// Bit 26: AttrUnderline -// Bit 27: AttrReverse -// Bits 28-31: Reserved -type Attribute uint32 - -// Named palette color constants (values 0-8). -const ( - ColorDefault Attribute = 0x0000 - ColorBlack Attribute = 0x0001 - ColorRed Attribute = 0x0002 - ColorGreen Attribute = 0x0003 - ColorYellow Attribute = 0x0004 - ColorBlue Attribute = 0x0005 - ColorMagenta Attribute = 0x0006 - ColorCyan Attribute = 0x0007 - ColorWhite Attribute = 0x0008 -) - -const ( - AttrTrueColor Attribute = 0x01000000 - AttrBold Attribute = 0x02000000 - AttrUnderline Attribute = 0x04000000 - AttrReverse Attribute = 0x08000000 -) - -// Style describes display attributes for foreground and background. -type Style struct { - fg Attribute - bg Attribute -} - -var homedirFunc = util.Homedir - // DefaultPrompt is the default prompt string shown in the query line. const DefaultPrompt = "QUERY>" +var homedirFunc = util.Homedir + // Init initializes the Config with default values func (c *Config) Init() error { c.Keymap = make(map[string]string) @@ -179,157 +128,31 @@ func (c *Config) ReadFilename(filename string) error { } } - if !IsValidLayoutType(LayoutType(c.Layout)) { + if !IsValidLayoutType(c.Layout) { return fmt.Errorf("invalid layout type: %s", c.Layout) } return nil } -var ( - stringToFg = map[string]Attribute{ - "default": ColorDefault, - "black": ColorBlack, - "red": ColorRed, - "green": ColorGreen, - "yellow": ColorYellow, - "blue": ColorBlue, - "magenta": ColorMagenta, - "cyan": ColorCyan, - "white": ColorWhite, - } - stringToBg = map[string]Attribute{ - "on_default": ColorDefault, - "on_black": ColorBlack, - "on_red": ColorRed, - "on_green": ColorGreen, - "on_yellow": ColorYellow, - "on_blue": ColorBlue, - "on_magenta": ColorMagenta, - "on_cyan": ColorCyan, - "on_white": ColorWhite, - } - stringToFgAttr = map[string]Attribute{ - "bold": AttrBold, - "underline": AttrUnderline, - "reverse": AttrReverse, - } - stringToBgAttr = map[string]Attribute{ - "on_bold": AttrBold, - } -) - -// NewStyleSet creates a new StyleSet struct -func NewStyleSet() *StyleSet { - ss := &StyleSet{} - ss.Init() - return ss -} - -// Init initializes the StyleSet with default foreground and background colors -// for each UI element (basic, query, matched, selected, prompt, context, etc.). -func (ss *StyleSet) Init() { - ss.Basic.fg = ColorDefault - ss.Basic.bg = ColorDefault - ss.Query.fg = ColorDefault - ss.Query.bg = ColorDefault - ss.Matched.fg = ColorCyan - ss.Matched.bg = ColorDefault - ss.SavedSelection.fg = ColorBlack | AttrBold - ss.SavedSelection.bg = ColorCyan - ss.Selected.fg = ColorDefault | AttrUnderline - ss.Selected.bg = ColorMagenta - ss.Prompt.fg = ColorDefault - ss.Prompt.bg = ColorDefault - ss.Context.fg = ColorDefault | AttrBold - ss.Context.bg = ColorDefault -} - -// UnmarshalJSON satisfies json.RawMessage. -func (s *Style) UnmarshalJSON(buf []byte) error { - raw := []string{} - if err := json.Unmarshal(buf, &raw); err != nil { - return fmt.Errorf("failed to unmarshal Style: %w", err) - } - return stringsToStyle(s, raw) -} - -// UnmarshalYAML decodes a YAML array of strings into a Style. -func (s *Style) UnmarshalYAML(unmarshal func(any) error) error { - var raw []string - if err := unmarshal(&raw); err != nil { - return fmt.Errorf("failed to unmarshal Style from YAML: %w", err) - } - return stringsToStyle(s, raw) -} - -// stringsToStyle parses an array of color and attribute strings (e.g. "red", -// "on_blue", "bold", "#ff00ff") into a Style's foreground and background Attributes. -func stringsToStyle(style *Style, raw []string) error { - style.fg = ColorDefault - style.bg = ColorDefault - - for _, s := range raw { - fg, ok := stringToFg[s] - if ok { - style.fg = fg - } else if strings.HasPrefix(s, "#") && len(s) == 7 { - if rgb, err := strconv.ParseUint(s[1:], 16, 32); err == nil { - style.fg = Attribute(rgb) | AttrTrueColor - } - } else { - if fg, err := strconv.ParseUint(s, 10, 8); err == nil { - style.fg = Attribute(fg + 1) - } - } - - bg, ok := stringToBg[s] - if ok { - style.bg = bg - } else if strings.HasPrefix(s, "on_#") && len(s) == 10 { - if rgb, err := strconv.ParseUint(s[4:], 16, 32); err == nil { - style.bg = Attribute(rgb) | AttrTrueColor - } - } else { - if strings.HasPrefix(s, "on_") { - if bg, err := strconv.ParseUint(s[3:], 10, 8); err == nil { - style.bg = Attribute(bg + 1) - } - } - } - } - - for _, s := range raw { - if fgAttr, ok := stringToFgAttr[s]; ok { - style.fg |= fgAttr - } - - if bgAttr, ok := stringToBgAttr[s]; ok { - style.bg |= bgAttr - } - } - - return nil -} - -// ConfigLocator locates a config file in a given directory. -type ConfigLocator interface { +// Locator locates a config file in a given directory. +type Locator interface { Locate(string) (string, error) } -// ConfigLocatorFunc is a function that implements ConfigLocator. -type ConfigLocatorFunc func(string) (string, error) +// LocatorFunc is a function that implements Locator. +type LocatorFunc func(string) (string, error) // Locate calls the underlying function. -func (f ConfigLocatorFunc) Locate(dir string) (string, error) { +func (f LocatorFunc) Locate(dir string) (string, error) { return f(dir) } var configFilenames = []string{"config.json", "config.yaml", "config.yml"} -// defaultConfigLocator searches for a config file with one of the known +// DefaultConfigLocator searches for a config file with one of the known // filenames (config.json, config.yaml, config.yml) in the given directory. -var defaultConfigLocator = ConfigLocatorFunc(func(dir string) (string, error) { +var DefaultConfigLocator = LocatorFunc(func(dir string) (string, error) { for _, basename := range configFilenames { file := filepath.Join(dir, basename) if _, err := os.Stat(file); err == nil { @@ -340,7 +163,7 @@ var defaultConfigLocator = ConfigLocatorFunc(func(dir string) (string, error) { }) // LocateRcfile attempts to find the config file in various locations -func LocateRcfile(locater ConfigLocator) (string, error) { +func LocateRcfile(locater Locator) (string, error) { // http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html // // Try in this order: diff --git a/config_test.go b/config/config_test.go similarity index 82% rename from config_test.go rename to config/config_test.go index 07dcc71..47ccf6d 100644 --- a/config_test.go +++ b/config/config_test.go @@ -1,4 +1,4 @@ -package peco +package config import ( "encoding/json" @@ -22,28 +22,28 @@ var expectedConfig = Config{ Prompt: "[peco]", Style: StyleSet{ Matched: Style{ - fg: ColorCyan | AttrBold, - bg: ColorRed, + Fg: ColorCyan | AttrBold, + Bg: ColorRed, }, Query: Style{ - fg: ColorYellow | AttrBold, - bg: ColorDefault, + Fg: ColorYellow | AttrBold, + Bg: ColorDefault, }, Selected: Style{ - fg: ColorBlack | AttrUnderline, - bg: ColorCyan, + Fg: ColorBlack | AttrUnderline, + Bg: ColorCyan, }, SavedSelection: Style{ - fg: ColorBlack | AttrBold, - bg: ColorCyan, + Fg: ColorBlack | AttrBold, + Bg: ColorCyan, }, Prompt: Style{ - fg: ColorGreen | AttrBold, - bg: ColorDefault, + Fg: ColorGreen | AttrBold, + Bg: ColorDefault, }, Context: Style{ - fg: ColorDefault | AttrBold, - bg: ColorDefault, + Fg: ColorDefault | AttrBold, + Bg: ColorDefault, }, }, } @@ -111,39 +111,39 @@ func TestStringsToStyle(t *testing.T) { tests := []stringsToStyleTest{ { strings: []string{"on_default", "default"}, - style: &Style{fg: ColorDefault, bg: ColorDefault}, + style: &Style{Fg: ColorDefault, Bg: ColorDefault}, }, { strings: []string{"bold", "on_blue", "yellow"}, - style: &Style{fg: ColorYellow | AttrBold, bg: ColorBlue}, + style: &Style{Fg: ColorYellow | AttrBold, Bg: ColorBlue}, }, { strings: []string{"underline", "on_cyan", "black"}, - style: &Style{fg: ColorBlack | AttrUnderline, bg: ColorCyan}, + style: &Style{Fg: ColorBlack | AttrUnderline, Bg: ColorCyan}, }, { strings: []string{"reverse", "on_red", "white"}, - style: &Style{fg: ColorWhite | AttrReverse, bg: ColorRed}, + style: &Style{Fg: ColorWhite | AttrReverse, Bg: ColorRed}, }, { strings: []string{"on_bold", "on_magenta", "green"}, - style: &Style{fg: ColorGreen, bg: ColorMagenta | AttrBold}, + style: &Style{Fg: ColorGreen, Bg: ColorMagenta | AttrBold}, }, { strings: []string{"underline", "on_240", "214"}, - style: &Style{fg: Attribute(214+1) | AttrUnderline, bg: Attribute(240 + 1)}, + style: &Style{Fg: Attribute(214+1) | AttrUnderline, Bg: Attribute(240 + 1)}, }, { strings: []string{"#ff8800", "on_#0088ff"}, - style: &Style{fg: Attribute(0xff8800) | AttrTrueColor, bg: Attribute(0x0088ff) | AttrTrueColor}, + style: &Style{Fg: Attribute(0xff8800) | AttrTrueColor, Bg: Attribute(0x0088ff) | AttrTrueColor}, }, { strings: []string{"bold", "#00ff00", "on_#000000"}, - style: &Style{fg: Attribute(0x00ff00) | AttrTrueColor | AttrBold, bg: Attribute(0x000000) | AttrTrueColor}, + style: &Style{Fg: Attribute(0x00ff00) | AttrTrueColor | AttrBold, Bg: Attribute(0x000000) | AttrTrueColor}, }, { strings: []string{"#000000"}, - style: &Style{fg: Attribute(0x000000) | AttrTrueColor, bg: ColorDefault}, + style: &Style{Fg: Attribute(0x000000) | AttrTrueColor, Bg: ColorDefault}, }, } @@ -151,7 +151,7 @@ func TestStringsToStyle(t *testing.T) { var a Style for _, test := range tests { t.Logf(" checking %s...", test.strings) - require.NoError(t, stringsToStyle(&a, test.strings), "stringsToStyle should succeed") + require.NoError(t, StringsToStyle(&a, test.strings), "StringsToStyle should succeed") require.Equal(t, test.style, &a, "Expected '%s' to be '%#v', but got '%#v'", test.strings, test.style, a) } } @@ -172,7 +172,7 @@ func TestLocateRcfile(t *testing.T) { } i := 0 - locater := ConfigLocatorFunc(func(dir string) (string, error) { + locater := LocatorFunc(func(dir string) (string, error) { t.Logf("looking for file in %s", dir) require.True(t, i <= len(expected)-1, "Got %d directories, only have %d", i+1, len(expected)) require.Equal(t, expected[i], dir, "Expected %s, got %s", expected[i], dir) @@ -213,7 +213,7 @@ func TestLocateRcfileYAML(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", "") t.Setenv("XDG_CONFIG_DIRS", "") - file, err := LocateRcfile(defaultConfigLocator) + file, err := LocateRcfile(DefaultConfigLocator) require.NoError(t, err) require.Equal(t, filepath.Join(pecoDir, "config.yaml"), file) } @@ -265,15 +265,6 @@ func TestOnCancelBehavior(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "bogus") }) - - t.Run("invalid CLI option rejected", func(t *testing.T) { - p := newPeco() - var opts CLIOptions - opts.OptOnCancel = "bogus" - err := p.ApplyConfig(opts) - require.Error(t, err) - require.Contains(t, err.Error(), "bogus") - }) } func TestReadFilenameYAML(t *testing.T) { diff --git a/height.go b/config/height.go similarity index 89% rename from height.go rename to config/height.go index cf5f862..1b28a51 100644 --- a/height.go +++ b/config/height.go @@ -1,4 +1,4 @@ -package peco +package config import ( "fmt" @@ -49,11 +49,11 @@ func ParseHeightSpec(s string) (HeightSpec, error) { return HeightSpec{Value: v, IsPercent: false}, nil } -// chromLines is the number of lines used by the prompt and status bar. -const chromLines = 2 +// ChromLines is the number of lines used by the prompt and status bar. +const ChromLines = 2 // Resolve converts the HeightSpec to an absolute number of screen rows, -// clamped to [chromLines+1, termHeight]. +// clamped to [ChromLines+1, termHeight]. // // For absolute values, Value is the number of result lines — the prompt // and status bar are added automatically (total = Value + 2). @@ -64,10 +64,10 @@ func (h HeightSpec) Resolve(termHeight int) int { if h.IsPercent { height = termHeight * h.Value / 100 } else { - height = h.Value + chromLines + height = h.Value + ChromLines } - minHeight := chromLines + 1 // at least 1 result line + minHeight := ChromLines + 1 // at least 1 result line if height < minHeight { height = minHeight } diff --git a/height_test.go b/config/height_test.go similarity index 95% rename from height_test.go rename to config/height_test.go index 65cf4fc..5906fbf 100644 --- a/height_test.go +++ b/config/height_test.go @@ -1,4 +1,4 @@ -package peco +package config import ( "testing" @@ -77,7 +77,7 @@ func TestParseHeightSpec(t *testing.T) { } func TestHeightSpecResolve(t *testing.T) { - // For absolute values: Value is result lines, total = Value + chromLines(2) + // For absolute values: Value is result lines, total = Value + ChromLines(2) t.Run("absolute adds chrome", func(t *testing.T) { spec := HeightSpec{Value: 10, IsPercent: false} // 10 result lines + 2 chrome = 12 total @@ -107,7 +107,7 @@ func TestHeightSpecResolve(t *testing.T) { // but Resolve handles it defensively. spec := HeightSpec{Value: 0, IsPercent: false} // 0 + 2 = 2, clamped to min 3 - require.Equal(t, chromLines+1, spec.Resolve(24)) + require.Equal(t, ChromLines+1, spec.Resolve(24)) }) // For percentages: Value is percentage of total terminal height @@ -123,8 +123,8 @@ func TestHeightSpecResolve(t *testing.T) { t.Run("percentage clamp to min", func(t *testing.T) { spec := HeightSpec{Value: 1, IsPercent: true} - // 1% of 24 = 0, clamped to chromLines+1 = 3 - require.Equal(t, chromLines+1, spec.Resolve(24)) + // 1% of 24 = 0, clamped to ChromLines+1 = 3 + require.Equal(t, ChromLines+1, spec.Resolve(24)) }) t.Run("percentage clamp to terminal height", func(t *testing.T) { diff --git a/config/layout.go b/config/layout.go new file mode 100644 index 0000000..c37ba94 --- /dev/null +++ b/config/layout.go @@ -0,0 +1,24 @@ +package config + +// LayoutType describes the types of layout that peco can take +type LayoutType = string + +const ( + DefaultLayoutType = LayoutTypeTopDown // LayoutTypeTopDown makes the layout so the items read from top to bottom + LayoutTypeTopDown = "top-down" // LayoutTypeTopDown displays prompt at top, list top-to-bottom + LayoutTypeBottomUp = "bottom-up" // LayoutTypeBottomUp displays prompt at bottom, list bottom-to-top + LayoutTypeTopDownQueryBottom = "top-down-query-bottom" // LayoutTypeTopDownQueryBottom displays list top-to-bottom, prompt at bottom +) + +// validLayoutTypes enumerates all recognized layout type values. +var validLayoutTypes = map[LayoutType]struct{}{ + LayoutTypeTopDown: {}, + LayoutTypeBottomUp: {}, + LayoutTypeTopDownQueryBottom: {}, +} + +// IsValidLayoutType checks if a string is a supported layout type +func IsValidLayoutType(v LayoutType) bool { + _, ok := validLayoutTypes[v] + return ok +} diff --git a/config/style.go b/config/style.go new file mode 100644 index 0000000..de96211 --- /dev/null +++ b/config/style.go @@ -0,0 +1,184 @@ +package config + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// StyleSet holds styles for various sections +type StyleSet struct { + Basic Style `json:"Basic" yaml:"Basic"` + SavedSelection Style `json:"SavedSelection" yaml:"SavedSelection"` + Selected Style `json:"Selected" yaml:"Selected"` + Query Style `json:"Query" yaml:"Query"` + QueryCursor Style `json:"QueryCursor" yaml:"QueryCursor"` + Matched Style `json:"Matched" yaml:"Matched"` + Prompt Style `json:"Prompt" yaml:"Prompt"` + Context Style `json:"Context" yaml:"Context"` +} + +// Attribute represents terminal display attributes such as colors +// and text styling (bold, underline, reverse). It is a uint32 bitfield: +// +// Bits 0-8: Palette color index (0=default, 1-256 for 256-color palette) +// Bits 0-23: RGB color value (when AttrTrueColor flag is set) +// Bit 24: AttrTrueColor flag — distinguishes true color from palette +// Bit 25: AttrBold +// Bit 26: AttrUnderline +// Bit 27: AttrReverse +// Bits 28-31: Reserved +type Attribute uint32 + +// Named palette color constants (values 0-8). +const ( + ColorDefault Attribute = 0x0000 + ColorBlack Attribute = 0x0001 + ColorRed Attribute = 0x0002 + ColorGreen Attribute = 0x0003 + ColorYellow Attribute = 0x0004 + ColorBlue Attribute = 0x0005 + ColorMagenta Attribute = 0x0006 + ColorCyan Attribute = 0x0007 + ColorWhite Attribute = 0x0008 +) + +const ( + AttrTrueColor Attribute = 0x01000000 + AttrBold Attribute = 0x02000000 + AttrUnderline Attribute = 0x04000000 + AttrReverse Attribute = 0x08000000 +) + +// Style describes display attributes for foreground and background. +type Style struct { + Fg Attribute + Bg Attribute +} + +var ( + StringToFg = map[string]Attribute{ + "default": ColorDefault, + "black": ColorBlack, + "red": ColorRed, + "green": ColorGreen, + "yellow": ColorYellow, + "blue": ColorBlue, + "magenta": ColorMagenta, + "cyan": ColorCyan, + "white": ColorWhite, + } + StringToBg = map[string]Attribute{ + "on_default": ColorDefault, + "on_black": ColorBlack, + "on_red": ColorRed, + "on_green": ColorGreen, + "on_yellow": ColorYellow, + "on_blue": ColorBlue, + "on_magenta": ColorMagenta, + "on_cyan": ColorCyan, + "on_white": ColorWhite, + } + StringToFgAttr = map[string]Attribute{ + "bold": AttrBold, + "underline": AttrUnderline, + "reverse": AttrReverse, + } + StringToBgAttr = map[string]Attribute{ + "on_bold": AttrBold, + } +) + +// NewStyleSet creates a new StyleSet struct +func NewStyleSet() *StyleSet { + ss := &StyleSet{} + ss.Init() + return ss +} + +// Init initializes the StyleSet with default foreground and background colors +// for each UI element (basic, query, matched, selected, prompt, context, etc.). +func (ss *StyleSet) Init() { + ss.Basic.Fg = ColorDefault + ss.Basic.Bg = ColorDefault + ss.Query.Fg = ColorDefault + ss.Query.Bg = ColorDefault + ss.Matched.Fg = ColorCyan + ss.Matched.Bg = ColorDefault + ss.SavedSelection.Fg = ColorBlack | AttrBold + ss.SavedSelection.Bg = ColorCyan + ss.Selected.Fg = ColorDefault | AttrUnderline + ss.Selected.Bg = ColorMagenta + ss.Prompt.Fg = ColorDefault + ss.Prompt.Bg = ColorDefault + ss.Context.Fg = ColorDefault | AttrBold + ss.Context.Bg = ColorDefault +} + +// UnmarshalJSON satisfies json.RawMessage. +func (s *Style) UnmarshalJSON(buf []byte) error { + raw := []string{} + if err := json.Unmarshal(buf, &raw); err != nil { + return fmt.Errorf("failed to unmarshal Style: %w", err) + } + return StringsToStyle(s, raw) +} + +// UnmarshalYAML decodes a YAML array of strings into a Style. +func (s *Style) UnmarshalYAML(unmarshal func(any) error) error { + var raw []string + if err := unmarshal(&raw); err != nil { + return fmt.Errorf("failed to unmarshal Style from YAML: %w", err) + } + return StringsToStyle(s, raw) +} + +// StringsToStyle parses an array of color and attribute strings (e.g. "red", +// "on_blue", "bold", "#ff00ff") into a Style's foreground and background Attributes. +func StringsToStyle(style *Style, raw []string) error { + style.Fg = ColorDefault + style.Bg = ColorDefault + + for _, s := range raw { + fg, ok := StringToFg[s] + if ok { + style.Fg = fg + } else if strings.HasPrefix(s, "#") && len(s) == 7 { + if rgb, err := strconv.ParseUint(s[1:], 16, 32); err == nil { + style.Fg = Attribute(rgb) | AttrTrueColor + } + } else { + if fg, err := strconv.ParseUint(s, 10, 8); err == nil { + style.Fg = Attribute(fg + 1) + } + } + + bg, ok := StringToBg[s] + if ok { + style.Bg = bg + } else if strings.HasPrefix(s, "on_#") && len(s) == 10 { + if rgb, err := strconv.ParseUint(s[4:], 16, 32); err == nil { + style.Bg = Attribute(rgb) | AttrTrueColor + } + } else { + if strings.HasPrefix(s, "on_") { + if bg, err := strconv.ParseUint(s[3:], 10, 8); err == nil { + style.Bg = Attribute(bg + 1) + } + } + } + } + + for _, s := range raw { + if fgAttr, ok := StringToFgAttr[s]; ok { + style.Fg |= fgAttr + } + + if bgAttr, ok := StringToBgAttr[s]; ok { + style.Bg |= bgAttr + } + } + + return nil +} diff --git a/issues_test.go b/issues_test.go index 05bc453..d327788 100644 --- a/issues_test.go +++ b/issues_test.go @@ -9,6 +9,7 @@ import ( "context" + "github.com/peco/peco/config" "github.com/peco/peco/filter" "github.com/peco/peco/internal/keyseq" "github.com/peco/peco/line" @@ -25,7 +26,7 @@ func TestIssue212_SanityCheck(t *testing.T) { require.Equal(t, state.config.Layout, "top-down", "Default layout type should be 'top-down', got '%s'", state.config.Layout) require.Equal(t, len(state.config.Keymap), 0, "Default keymap should be empty, but got '%#v'", state.config.Keymap) - defstyle := StyleSet{} + defstyle := config.StyleSet{} defstyle.Init() require.Equal(t, state.config.Style, defstyle, "should be default style") require.Equal(t, state.config.Prompt, "QUERY>", "Default prompt should be 'QUERY>', but got '%s'", state.config.Prompt) diff --git a/layout.go b/layout.go index d0c551d..94ee552 100644 --- a/layout.go +++ b/layout.go @@ -12,21 +12,12 @@ import ( "github.com/lestrrat-go/pdebug" "github.com/mattn/go-runewidth" + "github.com/peco/peco/config" "github.com/peco/peco/hub" "github.com/peco/peco/internal/ansi" linepkg "github.com/peco/peco/line" ) -// LayoutType describes the types of layout that peco can take -type LayoutType string - -const ( - DefaultLayoutType = LayoutTypeTopDown // LayoutTypeTopDown makes the layout so the items read from top to bottom - LayoutTypeTopDown = "top-down" // LayoutTypeTopDown displays prompt at top, list top-to-bottom - LayoutTypeBottomUp = "bottom-up" // LayoutTypeBottomUp displays prompt at bottom, list bottom-to-top - LayoutTypeTopDownQueryBottom = "top-down-query-bottom" // LayoutTypeTopDownQueryBottom displays list top-to-bottom, prompt at bottom -) - // VerticalAnchor describes the direction to which elements in the // layout are anchored to type VerticalAnchor int @@ -59,7 +50,7 @@ type UserPrompt struct { *AnchorSettings prompt string promptLen int - styles *StyleSet + styles *config.StyleSet } // StatusBar is the interface for printing status messages @@ -71,7 +62,7 @@ type StatusBar interface { type screenStatusBar struct { *AnchorSettings clearTimer *time.Timer - styles *StyleSet + styles *config.StyleSet timerMutex sync.Mutex } @@ -85,7 +76,7 @@ type ListArea struct { sortTopDown bool displayCache []linepkg.Line dirty bool - styles *StyleSet + styles *config.StyleSet } // BasicLayout is... the basic layout :) At this point this is the @@ -122,25 +113,19 @@ func (f LayoutBuilderFunc) Build(state *Peco) (*BasicLayout, error) { return f(state) } -var layoutRegistry = map[LayoutType]LayoutBuilder{} +var layoutRegistry = map[string]LayoutBuilder{} // RegisterLayout registers a layout builder under the given name. -func RegisterLayout(name LayoutType, builder LayoutBuilder) { +func RegisterLayout(name string, builder LayoutBuilder) { layoutRegistry[name] = builder } // NewLayout creates a layout by looking up the registry. Falls back to top-down. -func NewLayout(layoutType LayoutType, state *Peco) (*BasicLayout, error) { +func NewLayout(layoutType string, state *Peco) (*BasicLayout, error) { if builder, ok := layoutRegistry[layoutType]; ok { return builder.Build(state) } - return layoutRegistry[LayoutTypeTopDown].Build(state) -} - -// IsValidLayoutType checks if a string is a supported layout type -func IsValidLayoutType(v LayoutType) bool { - _, ok := layoutRegistry[v] - return ok + return layoutRegistry[config.LayoutTypeTopDown].Build(state) } // IsValidVerticalAnchor checks if the specified anchor is supported @@ -152,8 +137,8 @@ func IsValidVerticalAnchor(anchor VerticalAnchor) bool { // If either has a default color (0), the non-default color wins via OR. // Otherwise, color bits are merged using OR on 0-based indices. // Attribute flags (bold, underline, reverse, true color) are always OR'd. -func mergeAttribute(a, b Attribute) Attribute { - const flagMask = AttrTrueColor | AttrBold | AttrUnderline | AttrReverse +func mergeAttribute(a, b config.Attribute) config.Attribute { + const flagMask = config.AttrTrueColor | config.AttrBold | config.AttrUnderline | config.AttrReverse aColor := a &^ flagMask bColor := b &^ flagMask flags := (a | b) & flagMask @@ -192,9 +177,9 @@ func (as AnchorSettings) AnchorPosition() int { } // NewUserPrompt creates a new UserPrompt struct -func NewUserPrompt(screen Screen, anchor VerticalAnchor, anchorOffset int, prompt string, styles *StyleSet) (*UserPrompt, error) { +func NewUserPrompt(screen Screen, anchor VerticalAnchor, anchorOffset int, prompt string, styles *config.StyleSet) (*UserPrompt, error) { if prompt == "" { // default - prompt = DefaultPrompt + prompt = config.DefaultPrompt } promptLen := runewidth.StringWidth(prompt) @@ -215,16 +200,16 @@ func NewUserPrompt(screen Screen, anchor VerticalAnchor, anchorOffset int, promp // If QueryCursor is explicitly configured, it is used directly. // Otherwise, the Query style's fg/bg are swapped. If both are // ColorDefault, AttrReverse is used as a fallback. -func (u UserPrompt) cursorStyle() (Attribute, Attribute) { +func (u UserPrompt) cursorStyle() (config.Attribute, config.Attribute) { qc := u.styles.QueryCursor - if qc.fg != ColorDefault || qc.bg != ColorDefault { - return qc.fg, qc.bg + if qc.Fg != config.ColorDefault || qc.Bg != config.ColorDefault { + return qc.Fg, qc.Bg } - qfg, qbg := u.styles.Query.fg, u.styles.Query.bg - if qfg != ColorDefault || qbg != ColorDefault { + qfg, qbg := u.styles.Query.Fg, u.styles.Query.Bg + if qfg != config.ColorDefault || qbg != config.ColorDefault { return qbg, qfg } - return ColorDefault | AttrReverse, ColorDefault | AttrReverse + return config.ColorDefault | config.AttrReverse, config.ColorDefault | config.AttrReverse } // Draw draws the query prompt @@ -239,8 +224,8 @@ func (u UserPrompt) Draw(state *Peco) { // print "QUERY>" u.screen.Print(PrintArgs{ Y: location, - Fg: u.styles.Prompt.fg, - Bg: u.styles.Prompt.bg, + Fg: u.styles.Prompt.Fg, + Bg: u.styles.Prompt.Bg, Msg: u.prompt, }) @@ -256,8 +241,8 @@ func (u UserPrompt) Draw(state *Peco) { c.SetPos(ql) } - fg := u.styles.Query.fg - bg := u.styles.Query.bg + fg := u.styles.Query.Fg + bg := u.styles.Query.Bg // Used to notify the screen where our cursor is var posX int @@ -317,8 +302,8 @@ func (u UserPrompt) Draw(state *Peco) { cfgCursor, cbgCursor := u.cursorStyle() prev := int(0) for i, r := range q.RuneSlice() { - fg := u.styles.Query.fg - bg := u.styles.Query.bg + fg := u.styles.Query.Fg + bg := u.styles.Query.Bg if i == c.Pos() { fg = cfgCursor bg = cbgCursor @@ -326,8 +311,8 @@ func (u UserPrompt) Draw(state *Peco) { u.screen.SetCell(queryStartX+prev, location, r, fg, bg) prev += runewidth.RuneWidth(r) } - fg := u.styles.Query.fg - bg := u.styles.Query.bg + fg := u.styles.Query.Fg + bg := u.styles.Query.Bg u.screen.Print(PrintArgs{ X: queryStartX + prev, Y: location, @@ -346,8 +331,8 @@ func (u UserPrompt) Draw(state *Peco) { u.screen.Print(PrintArgs{ X: width - runewidth.StringWidth(pmsg), Y: location, - Fg: u.styles.Basic.fg, - Bg: u.styles.Basic.bg, + Fg: u.styles.Basic.Fg, + Bg: u.styles.Basic.Bg, Msg: pmsg, }) @@ -355,7 +340,7 @@ func (u UserPrompt) Draw(state *Peco) { } // newScreenStatusBar creates a new screenStatusBar struct -func newScreenStatusBar(screen Screen, anchor VerticalAnchor, anchorOffset int, styles *StyleSet) (*screenStatusBar, error) { +func newScreenStatusBar(screen Screen, anchor VerticalAnchor, anchorOffset int, styles *config.StyleSet) (*screenStatusBar, error) { as, err := NewAnchorSettings(screen, anchor, anchorOffset) if err != nil { return nil, fmt.Errorf("failed to create status bar: %w", err) @@ -413,8 +398,8 @@ func (s *screenStatusBar) PrintStatus(msg string, clearDelay time.Duration) { } } - fgAttr := s.styles.Basic.fg - bgAttr := s.styles.Basic.bg + fgAttr := s.styles.Basic.Fg + bgAttr := s.styles.Basic.Bg if w > width { s.screen.Print(PrintArgs{ @@ -429,8 +414,8 @@ func (s *screenStatusBar) PrintStatus(msg string, clearDelay time.Duration) { s.screen.Print(PrintArgs{ X: w - width, Y: location, - Fg: fgAttr | AttrBold | AttrReverse, - Bg: bgAttr | AttrReverse, + Fg: fgAttr | config.AttrBold | config.AttrReverse, + Bg: bgAttr | config.AttrReverse, Msg: msg, }) } @@ -454,7 +439,7 @@ func (l *BasicLayout) PrintStatus(msg string, delay time.Duration) { } // NewListArea creates a new ListArea struct -func NewListArea(screen Screen, anchor VerticalAnchor, anchorOffset int, sortTopDown bool, styles *StyleSet) (*ListArea, error) { +func NewListArea(screen Screen, anchor VerticalAnchor, anchorOffset int, sortTopDown bool, styles *config.StyleSet) (*ListArea, error) { as, err := NewAnchorSettings(screen, anchor, anchorOffset) if err != nil { return nil, fmt.Errorf("failed to create list area: %w", err) @@ -510,7 +495,7 @@ func adjustPageForRunningQuery(loc *Location, linebuf Buffer, parent Layout, sta // renderMatchedLine renders a line with match highlighting, interleaving // matched and non-matched segments with their respective styles. -func (l *ListArea) renderMatchedLine(ml *linepkg.Matched, line string, lineANSIAttrs []ansi.AttrSpan, x, y, xOffset int, fgAttr, bgAttr Attribute) { +func (l *ListArea) renderMatchedLine(ml *linepkg.Matched, line string, lineANSIAttrs []ansi.AttrSpan, x, y, xOffset int, fgAttr, bgAttr config.Attribute) { matches := ml.Indices() prev := x index := 0 @@ -544,8 +529,8 @@ func (l *ListArea) renderMatchedLine(ml *linepkg.Matched, line string, lineANSIA X: prev, Y: y, XOffset: xOffset, - Fg: l.styles.Matched.fg, - Bg: mergeAttribute(bgAttr, l.styles.Matched.bg), + Fg: l.styles.Matched.Fg, + Bg: mergeAttribute(bgAttr, l.styles.Matched.Bg), Msg: c, }) prev += n @@ -648,14 +633,14 @@ func (l *ListArea) Draw(state *Peco, parent Layout, perPage int, options *hub.Dr } l.screen.Print(PrintArgs{ Y: y, - Fg: l.styles.Basic.fg, - Bg: l.styles.Basic.bg, + Fg: l.styles.Basic.Fg, + Bg: l.styles.Basic.Bg, Fill: true, }) } var cached, written int - var fgAttr, bgAttr Attribute + var fgAttr, bgAttr config.Attribute selectionPrefix := state.SelectionPrefix() var prefix string @@ -679,14 +664,14 @@ func (l *ListArea) Draw(state *Peco, parent Layout, perPage int, options *hub.Dr } else { switch { case n+loc.Offset() == loc.LineNumber(): - fgAttr = l.styles.Selected.fg - bgAttr = l.styles.Selected.bg + fgAttr = l.styles.Selected.Fg + bgAttr = l.styles.Selected.Bg case selectionContains(state, n+loc.Offset()): - fgAttr = l.styles.SavedSelection.fg - bgAttr = l.styles.SavedSelection.bg + fgAttr = l.styles.SavedSelection.Fg + bgAttr = l.styles.SavedSelection.Bg default: - fgAttr = l.styles.Basic.fg - bgAttr = l.styles.Basic.bg + fgAttr = l.styles.Basic.Fg + bgAttr = l.styles.Basic.Bg } } @@ -717,9 +702,9 @@ func (l *ListArea) Draw(state *Peco, parent Layout, perPage int, options *hub.Dr // Apply Context style for non-matched surrounding lines if _, isCtx := target.(*ContextLine); isCtx { - if fgAttr == l.styles.Basic.fg && bgAttr == l.styles.Basic.bg { - fgAttr = l.styles.Context.fg - bgAttr = l.styles.Context.bg + if fgAttr == l.styles.Basic.Fg && bgAttr == l.styles.Basic.Bg { + fgAttr = l.styles.Context.Fg + bgAttr = l.styles.Context.Bg } } @@ -731,7 +716,7 @@ func (l *ListArea) Draw(state *Peco, parent Layout, perPage int, options *hub.Dr // non-selected (basic) lines so selection/savedSelection // styling takes precedence. var lineANSIAttrs []ansi.AttrSpan - isBasicStyle := (fgAttr == l.styles.Basic.fg && bgAttr == l.styles.Basic.bg) + isBasicStyle := (fgAttr == l.styles.Basic.Fg && bgAttr == l.styles.Basic.Bg) if isBasicStyle { if al, ok := target.(ansiLiner); ok { lineANSIAttrs = al.ANSIAttrs() @@ -756,7 +741,7 @@ func (l *ListArea) Draw(state *Peco, parent Layout, perPage int, options *hub.Dr X: x, Y: y, XOffset: xOffset, - Fg: fgAttr | AttrBold | AttrReverse, + Fg: fgAttr | config.AttrBold | config.AttrReverse, Bg: bgAttr, Msg: string(prefixes[n]), }) @@ -886,9 +871,9 @@ func (l *BasicLayout) SortTopDown() bool { } func init() { - RegisterLayout(LayoutTypeTopDown, LayoutBuilderFunc(DefaultLayout)) - RegisterLayout(LayoutTypeBottomUp, LayoutBuilderFunc(BottomUpLayout)) - RegisterLayout(LayoutTypeTopDownQueryBottom, LayoutBuilderFunc(TopDownQueryBottomLayout)) + RegisterLayout(config.LayoutTypeTopDown, LayoutBuilderFunc(DefaultLayout)) + RegisterLayout(config.LayoutTypeBottomUp, LayoutBuilderFunc(BottomUpLayout)) + RegisterLayout(config.LayoutTypeTopDownQueryBottom, LayoutBuilderFunc(TopDownQueryBottomLayout)) } func (l *BasicLayout) PurgeDisplayCache() { diff --git a/layout_test.go b/layout_test.go index 166198e..e63d1f9 100644 --- a/layout_test.go +++ b/layout_test.go @@ -5,6 +5,7 @@ import ( "unicode/utf8" "github.com/mattn/go-runewidth" + "github.com/peco/peco/config" "github.com/peco/peco/filter" "github.com/peco/peco/hub" "github.com/peco/peco/line" @@ -13,16 +14,16 @@ import ( func TestLayoutType(t *testing.T) { layouts := []struct { - value LayoutType + value string expectOK bool }{ - {LayoutTypeTopDown, true}, - {LayoutTypeBottomUp, true}, - {LayoutTypeTopDownQueryBottom, true}, + {config.LayoutTypeTopDown, true}, + {config.LayoutTypeBottomUp, true}, + {config.LayoutTypeTopDownQueryBottom, true}, {"foobar", false}, } for _, l := range layouts { - valid := IsValidLayoutType(l.value) + valid := config.IsValidLayoutType(l.value) if valid != l.expectOK { t.Errorf("LayoutType %s, expected IsValidLayoutType to return %t, but got %t", l.value, @@ -44,8 +45,8 @@ func TestPrintScreen(t *testing.T) { screen.Print(PrintArgs{ X: initX, Y: initY, - Fg: ColorDefault, - Bg: ColorDefault, + Fg: config.ColorDefault, + Bg: config.ColorDefault, Msg: msg, Fill: fill, }) @@ -86,7 +87,7 @@ func TestPrintScreen(t *testing.T) { func TestScreenStatusBar(t *testing.T) { screen := NewDummyScreen() - st, err := newScreenStatusBar(screen, AnchorBottom, 0, NewStyleSet()) + st, err := newScreenStatusBar(screen, AnchorBottom, 0, config.NewStyleSet()) require.NoError(t, err) st.PrintStatus("Hello, World!", 0) @@ -109,7 +110,7 @@ func TestNullStatusBar(t *testing.T) { } func TestMergeAttribute(t *testing.T) { - colors := stringToFg + colors := config.StringToFg // merge colors tests := [][]string{ @@ -138,20 +139,20 @@ func TestMergeAttribute(t *testing.T) { } // merge attributes - if m := mergeAttribute(AttrBold|colors["red"], AttrUnderline|colors["cyan"]); m != AttrBold|AttrUnderline|colors["white"] { - t.Errorf("expected %d, got %d", AttrBold|AttrUnderline|colors["white"], m) + if m := mergeAttribute(config.AttrBold|colors["red"], config.AttrUnderline|colors["cyan"]); m != config.AttrBold|config.AttrUnderline|colors["white"] { + t.Errorf("expected %d, got %d", config.AttrBold|config.AttrUnderline|colors["white"], m) } } // TestGHIssue294_PromptStyleUsedForPromptPrefix verifies that UserPrompt.Draw // uses the Prompt style (not Basic) when rendering the prompt prefix string. func TestGHIssue294_PromptStyleUsedForPromptPrefix(t *testing.T) { - styles := NewStyleSet() - styles.Prompt.fg = ColorGreen | AttrBold - styles.Prompt.bg = ColorBlue + styles := config.NewStyleSet() + styles.Prompt.Fg = config.ColorGreen | config.AttrBold + styles.Prompt.Bg = config.ColorBlue // Make sure Basic is different so we can distinguish them. - styles.Basic.fg = ColorDefault - styles.Basic.bg = ColorDefault + styles.Basic.Fg = config.ColorDefault + styles.Basic.Bg = config.ColorDefault screen := NewDummyScreen() prompt, err := NewUserPrompt(screen, AnchorTop, 0, "QUERY>", styles) @@ -177,23 +178,23 @@ func TestGHIssue294_PromptStyleUsedForPromptPrefix(t *testing.T) { ev := events[i] x := ev[0].(int) ch := ev[2].(rune) - fg := ev[3].(Attribute) - bg := ev[4].(Attribute) + fg := ev[3].(config.Attribute) + bg := ev[4].(config.Attribute) require.Equal(t, i, x, "expected x=%d", i) require.Equal(t, rune(promptStr[i]), ch, "expected character %c at position %d", promptStr[i], i) - require.Equal(t, styles.Prompt.fg, fg, - "cell at x=%d should use Prompt.fg, got %v", i, fg) - require.Equal(t, styles.Prompt.bg, bg, - "cell at x=%d should use Prompt.bg, got %v", i, bg) + require.Equal(t, styles.Prompt.Fg, fg, + "cell at x=%d should use Prompt.Fg, got %v", i, fg) + require.Equal(t, styles.Prompt.Bg, bg, + "cell at x=%d should use Prompt.Bg, got %v", i, bg) } // The cells after the prompt should NOT use the Prompt style — // they should use the Query style (for the query text area). if len(events) > promptLen { ev := events[promptLen] - fg := ev[3].(Attribute) - require.NotEqual(t, styles.Prompt.fg, fg, + fg := ev[3].(config.Attribute) + require.NotEqual(t, styles.Prompt.Fg, fg, "cell after prompt should not use Prompt style") } } @@ -201,12 +202,12 @@ func TestGHIssue294_PromptStyleUsedForPromptPrefix(t *testing.T) { // TestGHIssue460_MatchedStyleDoesNotBleedToEndOfLine verifies that matched // text highlighting in ListArea.Draw does not extend to the screen edge. func TestGHIssue460_MatchedStyleDoesNotBleedToEndOfLine(t *testing.T) { - // Use a distinct Matched.bg so we can detect it in SetCell events. - styles := NewStyleSet() - styles.Matched.bg = ColorBlue + // Use a distinct Matched.Bg so we can detect it in SetCell events. + styles := config.NewStyleSet() + styles.Matched.Bg = config.ColorBlue - matchedBg := mergeAttribute(styles.Basic.bg, styles.Matched.bg) // ColorBlue - basicBg := styles.Basic.bg // ColorDefault + matchedBg := mergeAttribute(styles.Basic.Bg, styles.Matched.Bg) // config.ColorBlue + basicBg := styles.Basic.Bg // config.ColorDefault // Helper: set up a Peco state with one matched line and draw it, // returning the SetCell events for the line's row (y=0). @@ -256,7 +257,7 @@ func TestGHIssue460_MatchedStyleDoesNotBleedToEndOfLine(t *testing.T) { for _, ev := range row { x := ev[0].(int) - bg := ev[4].(Attribute) + bg := ev[4].(config.Attribute) if x >= 6 && x <= 10 { require.Equal(t, matchedBg, bg, "cell at x=%d should have matched bg", x) @@ -277,7 +278,7 @@ func TestGHIssue460_MatchedStyleDoesNotBleedToEndOfLine(t *testing.T) { for _, ev := range row { x := ev[0].(int) - bg := ev[4].(Attribute) + bg := ev[4].(config.Attribute) if x >= 6 && x <= 10 { require.Equal(t, matchedBg, bg, "cell at x=%d should have matched bg", x) @@ -383,7 +384,7 @@ func TestNewLayout(t *testing.T) { t.Run("top-down", func(t *testing.T) { state := makeState() - layout, err := NewLayout(LayoutTypeTopDown, state) + layout, err := NewLayout(config.LayoutTypeTopDown, state) require.NoError(t, err) require.True(t, layout.SortTopDown(), "top-down layout should sort top-down") require.Equal(t, AnchorTop, layout.prompt.anchor, "top-down prompt should be anchored at top") @@ -391,7 +392,7 @@ func TestNewLayout(t *testing.T) { t.Run("bottom-up", func(t *testing.T) { state := makeState() - layout, err := NewLayout(LayoutTypeBottomUp, state) + layout, err := NewLayout(config.LayoutTypeBottomUp, state) require.NoError(t, err) require.False(t, layout.SortTopDown(), "bottom-up layout should not sort top-down") require.Equal(t, AnchorBottom, layout.prompt.anchor, "bottom-up prompt should be anchored at bottom") @@ -399,7 +400,7 @@ func TestNewLayout(t *testing.T) { t.Run("top-down-query-bottom", func(t *testing.T) { state := makeState() - layout, err := NewLayout(LayoutTypeTopDownQueryBottom, state) + layout, err := NewLayout(config.LayoutTypeTopDownQueryBottom, state) require.NoError(t, err) require.True(t, layout.SortTopDown(), "top-down-query-bottom layout should sort top-down") require.Equal(t, AnchorBottom, layout.prompt.anchor, "top-down-query-bottom prompt should be anchored at bottom") @@ -443,7 +444,7 @@ func TestTopDownQueryBottomLayout(t *testing.T) { // TestCursorStyle verifies that UserPrompt.cursorStyle returns the correct // fg/bg attributes depending on QueryCursor and Query style configuration. func TestCursorStyle(t *testing.T) { - makePrompt := func(t *testing.T, styles *StyleSet) UserPrompt { + makePrompt := func(t *testing.T, styles *config.StyleSet) UserPrompt { t.Helper() screen := NewDummyScreen() p, err := NewUserPrompt(screen, AnchorTop, 0, "QUERY>", styles) @@ -451,57 +452,57 @@ func TestCursorStyle(t *testing.T) { return *p } - t.Run("both default falls back to AttrReverse", func(t *testing.T) { - styles := NewStyleSet() - // Query and QueryCursor are both ColorDefault (zero value) + t.Run("both default falls back to config.AttrReverse", func(t *testing.T) { + styles := config.NewStyleSet() + // Query and QueryCursor are both config.ColorDefault (zero value) p := makePrompt(t, styles) fg, bg := p.cursorStyle() - require.Equal(t, ColorDefault|AttrReverse, fg) - require.Equal(t, ColorDefault|AttrReverse, bg) + require.Equal(t, config.ColorDefault|config.AttrReverse, fg) + require.Equal(t, config.ColorDefault|config.AttrReverse, bg) }) t.Run("Query has custom colors and QueryCursor is default swaps fg/bg", func(t *testing.T) { - styles := NewStyleSet() - styles.Query.fg = ColorYellow - styles.Query.bg = ColorBlue + styles := config.NewStyleSet() + styles.Query.Fg = config.ColorYellow + styles.Query.Bg = config.ColorBlue p := makePrompt(t, styles) fg, bg := p.cursorStyle() - require.Equal(t, ColorBlue, fg, "should use Query.bg as cursor fg") - require.Equal(t, ColorYellow, bg, "should use Query.fg as cursor bg") + require.Equal(t, config.ColorBlue, fg, "should use Query.Bg as cursor fg") + require.Equal(t, config.ColorYellow, bg, "should use Query.Fg as cursor bg") }) t.Run("QueryCursor explicitly set takes precedence", func(t *testing.T) { - styles := NewStyleSet() - styles.Query.fg = ColorYellow - styles.Query.bg = ColorBlue - styles.QueryCursor.fg = ColorWhite - styles.QueryCursor.bg = ColorRed + styles := config.NewStyleSet() + styles.Query.Fg = config.ColorYellow + styles.Query.Bg = config.ColorBlue + styles.QueryCursor.Fg = config.ColorWhite + styles.QueryCursor.Bg = config.ColorRed p := makePrompt(t, styles) fg, bg := p.cursorStyle() - require.Equal(t, ColorWhite, fg) - require.Equal(t, ColorRed, bg) + require.Equal(t, config.ColorWhite, fg) + require.Equal(t, config.ColorRed, bg) }) t.Run("QueryCursor with only fg set takes precedence", func(t *testing.T) { - styles := NewStyleSet() - styles.Query.fg = ColorYellow - styles.Query.bg = ColorBlue - styles.QueryCursor.fg = ColorGreen - // bg remains ColorDefault + styles := config.NewStyleSet() + styles.Query.Fg = config.ColorYellow + styles.Query.Bg = config.ColorBlue + styles.QueryCursor.Fg = config.ColorGreen + // bg remains config.ColorDefault p := makePrompt(t, styles) fg, bg := p.cursorStyle() - require.Equal(t, ColorGreen, fg) - require.Equal(t, ColorDefault, bg) + require.Equal(t, config.ColorGreen, fg) + require.Equal(t, config.ColorDefault, bg) }) t.Run("Query has only fg set swaps correctly", func(t *testing.T) { - styles := NewStyleSet() - styles.Query.fg = ColorRed - // Query.bg remains ColorDefault + styles := config.NewStyleSet() + styles.Query.Fg = config.ColorRed + // Query.Bg remains config.ColorDefault p := makePrompt(t, styles) fg, bg := p.cursorStyle() - require.Equal(t, ColorDefault, fg, "should use Query.bg (default) as cursor fg") - require.Equal(t, ColorRed, bg, "should use Query.fg as cursor bg") + require.Equal(t, config.ColorDefault, fg, "should use Query.Bg (default) as cursor fg") + require.Equal(t, config.ColorRed, bg, "should use Query.Fg as cursor bg") }) } @@ -518,17 +519,17 @@ func TestInvalidAnchorReturnsError(t *testing.T) { }) t.Run("NewUserPrompt", func(t *testing.T) { - _, err := NewUserPrompt(screen, invalidAnchor, 0, "QUERY>", NewStyleSet()) + _, err := NewUserPrompt(screen, invalidAnchor, 0, "QUERY>", config.NewStyleSet()) require.Error(t, err) }) t.Run("NewListArea", func(t *testing.T) { - _, err := NewListArea(screen, invalidAnchor, 0, true, NewStyleSet()) + _, err := NewListArea(screen, invalidAnchor, 0, true, config.NewStyleSet()) require.Error(t, err) }) t.Run("newScreenStatusBar", func(t *testing.T) { - _, err := newScreenStatusBar(screen, invalidAnchor, 0, NewStyleSet()) + _, err := newScreenStatusBar(screen, invalidAnchor, 0, config.NewStyleSet()) require.Error(t, err) }) } diff --git a/options.go b/options.go index 732e8d9..50acb79 100644 --- a/options.go +++ b/options.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/jessevdk/go-flags" + "github.com/peco/peco/config" ) // CLIOptions holds the command-line flags parsed by go-flags. @@ -53,7 +54,7 @@ func (options *CLIOptions) parse(s []string) ([]string, error) { // Validate checks the parsed CLI options for correctness (e.g., layout type). func (options CLIOptions) Validate() error { if options.OptLayout != "" { - if !IsValidLayoutType(LayoutType(options.OptLayout)) { + if !config.IsValidLayoutType(options.OptLayout) { return errors.New("unknown layout: '" + options.OptLayout + "'") } } diff --git a/peco.go b/peco.go index 5946d2c..ff7d0f1 100644 --- a/peco.go +++ b/peco.go @@ -17,6 +17,7 @@ import ( "context" "github.com/lestrrat-go/pdebug" + "github.com/peco/peco/config" "github.com/peco/peco/filter" "github.com/peco/peco/hub" "github.com/peco/peco/internal/util" @@ -53,7 +54,7 @@ type Peco struct { bufferSize int caret query.Caret // Config contains the values read in from config file - config Config + config config.Config currentLineBuffer Buffer enableSep bool // Enable parsing on separators execOnFinish string @@ -67,7 +68,7 @@ type Peco struct { location Location maxScanBufferSize int mutex sync.Mutex - onCancel OnCancelBehavior + onCancel config.OnCancelBehavior printQuery bool prompt string query query.Text @@ -83,9 +84,9 @@ type Peco struct { selectOneTriggered atomic.Bool selectAllAndExit bool // True if --select-all is enabled singleKeyJump SingleKeyJumpState - heightSpec *HeightSpec + heightSpec *config.HeightSpec configReader ConfigReader - styles StyleSet + styles config.StyleSet enableANSI bool // Enable ANSI color code support fuzzyLongestSort bool @@ -225,7 +226,7 @@ func (p *Peco) Screen() Screen { return p.screen } -func (p *Peco) Styles() *StyleSet { +func (p *Peco) Styles() *config.StyleSet { return &p.styles } @@ -625,7 +626,7 @@ func (p *Peco) parseCommandLine(opts *CLIOptions, args *[]string, argv []string) } if opts.OptRcfile == "" { - if file, err := LocateRcfile(defaultConfigLocator); err == nil { + if file, err := config.LocateRcfile(config.DefaultConfigLocator); err == nil { opts.OptRcfile = file } } @@ -687,23 +688,23 @@ func (p *Peco) SetupSource(ctx context.Context) (s *Source, err error) { // ConfigReader reads configuration from a file into a Config struct. type ConfigReader interface { - ReadConfig(*Config, string) error + ReadConfig(*config.Config, string) error } // ConfigReaderFunc is a function that implements ConfigReader. -type ConfigReaderFunc func(*Config, string) error +type ConfigReaderFunc func(*config.Config, string) error // ReadConfig calls the underlying function. -func (f ConfigReaderFunc) ReadConfig(cfg *Config, filename string) error { +func (f ConfigReaderFunc) ReadConfig(cfg *config.Config, filename string) error { return f(cfg, filename) } // nopConfigReader is a ConfigReader that does nothing. -var nopConfigReader = ConfigReaderFunc(func(*Config, string) error { return nil }) +var nopConfigReader = ConfigReaderFunc(func(*config.Config, string) error { return nil }) // defaultConfigReader loads the configuration from the given filename into cfg. // If filename is empty, no file is read and nil is returned. -var defaultConfigReader = ConfigReaderFunc(func(cfg *Config, filename string) error { +var defaultConfigReader = ConfigReaderFunc(func(cfg *config.Config, filename string) error { if filename != "" { if err := cfg.ReadFilename(filename); err != nil { return fmt.Errorf("failed to read config file: %w", err) @@ -721,7 +722,7 @@ func (p *Peco) ApplyConfig(opts CLIOptions) error { if v := p.config.Layout; v != "" { p.layoutType = v } else { - p.layoutType = DefaultLayoutType + p.layoutType = config.DefaultLayoutType } } @@ -754,7 +755,7 @@ func (p *Peco) ApplyConfig(opts CLIOptions) error { p.onCancel = p.config.OnCancel if p.onCancel == "" { - p.onCancel = OnCancelSuccess + p.onCancel = config.OnCancelSuccess } if opts.OptOnCancel != "" { if err := p.onCancel.UnmarshalText([]byte(opts.OptOnCancel)); err != nil { @@ -786,7 +787,7 @@ func (p *Peco) ApplyConfig(opts CLIOptions) error { heightStr = v } if heightStr != "" { - spec, err := ParseHeightSpec(heightStr) + spec, err := config.ParseHeightSpec(heightStr) if err != nil { return fmt.Errorf("failed to parse height specification: %w", err) } diff --git a/peco_test.go b/peco_test.go index 4959499..ffef318 100644 --- a/peco_test.go +++ b/peco_test.go @@ -15,6 +15,7 @@ import ( "github.com/gdamore/tcell/v2" "github.com/lestrrat-go/pdebug" + "github.com/peco/peco/config" "github.com/peco/peco/hub" "github.com/peco/peco/internal/keyseq" "github.com/peco/peco/internal/util" @@ -151,7 +152,7 @@ func NewDummyScreen() *SimScreen { } } -func (s *SimScreen) Init(_ *Config) error { +func (s *SimScreen) Init(_ *config.Config) error { return nil } @@ -220,7 +221,7 @@ func (s *SimScreen) SendEvent(e Event) { } } -func (s *SimScreen) SetCell(x, y int, ch rune, fg, bg Attribute) { +func (s *SimScreen) SetCell(x, y int, ch rune, fg, bg config.Attribute) { s.mu.Lock() defer s.mu.Unlock() if s.closed { @@ -242,7 +243,7 @@ func (s *SimScreen) Flush() error { return nil } -func (s *SimScreen) PollEvent(ctx context.Context, _ *Config) chan Event { +func (s *SimScreen) PollEvent(ctx context.Context, _ *config.Config) chan Event { evCh := make(chan Event) go func() { defer func() { @@ -330,6 +331,15 @@ func TestPecoHelp(t *testing.T) { require.True(t, util.IsIgnorableError(err), "p.Run() should return error with Ignorable() method, and it should return true") } +func TestOnCancelInvalidCLIOption(t *testing.T) { + p := newPeco() + var opts CLIOptions + opts.OptOnCancel = "bogus" + err := p.ApplyConfig(opts) + require.Error(t, err) + require.Contains(t, err.Error(), "bogus") +} + func TestGHIssue331(t *testing.T) { // Verify fields are populated when Run() initializes config. state, _ := setupPecoTest(t) @@ -392,7 +402,7 @@ func TestApplyConfig(t *testing.T) { require.Equal(t, opts.OptSelect1, p.selectOneAndExit, "p.selectOneAndExit should be equal to opts.OptSelect1") require.Equal(t, opts.OptExitZero, p.exitZeroAndExit, "p.exitZeroAndExit should be equal to opts.OptExitZero") require.Equal(t, opts.OptSelectAll, p.selectAllAndExit, "p.selectAllAndExit should be equal to opts.OptSelectAll") - require.Equal(t, OnCancelBehavior(opts.OptOnCancel), p.onCancel, "p.onCancel should be equal to opts.OptOnCancel") + require.Equal(t, config.OnCancelBehavior(opts.OptOnCancel), p.onCancel, "p.onCancel should be equal to opts.OptOnCancel") require.Equal(t, opts.OptSelectionPrefix, p.selectionPrefix, "p.selectionPrefix should be equal to opts.OptSelectionPrefix") require.Equal(t, opts.OptPrintQuery, p.printQuery, "p.printQuery should be equal to opts.OptPrintQuery") require.Equal(t, opts.OptExec, p.execOnFinish, "p.execOnFinish should be equal to opts.OptExec") @@ -464,12 +474,12 @@ func TestApplyConfig(t *testing.T) { t.Run("Config OnCancel used when CLI option absent", func(t *testing.T) { p := newPeco() - p.config.OnCancel = OnCancelError + p.config.OnCancel = config.OnCancelError var opts CLIOptions require.NoError(t, p.ApplyConfig(opts), "p.ApplyConfig should succeed") - require.Equal(t, OnCancelError, p.onCancel, "p.onCancel should come from config when CLI option is absent") + require.Equal(t, config.OnCancelError, p.onCancel, "p.onCancel should come from config when CLI option is absent") }) t.Run("Config SelectionPrefix used when CLI option absent", func(t *testing.T) { diff --git a/screen.go b/screen.go index 27b344e..1975520 100644 --- a/screen.go +++ b/screen.go @@ -12,6 +12,7 @@ import ( "github.com/gdamore/tcell/v2" pdebug "github.com/lestrrat-go/pdebug" "github.com/mattn/go-runewidth" + "github.com/peco/peco/config" "github.com/peco/peco/internal/ansi" "github.com/peco/peco/internal/keyseq" ) @@ -19,13 +20,13 @@ import ( // Screen hides the terminal library from the consuming code so that // it can be swapped out for testing type Screen interface { - Init(*Config) error + Init(*config.Config) error Close() error Flush() error - PollEvent(context.Context, *Config) chan Event + PollEvent(context.Context, *config.Config) chan Event Print(PrintArgs) int Resume(context.Context) error - SetCell(int, int, rune, Attribute, Attribute) + SetCell(int, int, rune, config.Attribute, config.Attribute) SetCursor(int, int) Size() (int, int) SendEvent(Event) @@ -212,8 +213,8 @@ func tcellEventToEvent(tev tcell.Event) Event { } // attributeToTcellColor converts a peco Attribute to a tcell.Color. -func attributeToTcellColor(attr Attribute) tcell.Color { - if attr&AttrTrueColor != 0 { +func attributeToTcellColor(attr config.Attribute) tcell.Color { + if attr&config.AttrTrueColor != 0 { rgb := attr & 0x00FFFFFF return tcell.NewHexColor(int32(rgb)) } @@ -225,27 +226,27 @@ func attributeToTcellColor(attr Attribute) tcell.Color { } // attributeToTcellStyle converts peco Attribute fg/bg values to a tcell.Style. -func attributeToTcellStyle(fg, bg Attribute) tcell.Style { +func attributeToTcellStyle(fg, bg config.Attribute) tcell.Style { style := tcell.StyleDefault. Foreground(attributeToTcellColor(fg)). Background(attributeToTcellColor(bg)) // Extract style attributes from both fg and bg attrs := fg | bg - if attrs&AttrBold != 0 { + if attrs&config.AttrBold != 0 { style = style.Bold(true) } - if attrs&AttrUnderline != 0 { + if attrs&config.AttrUnderline != 0 { style = style.Underline(true) } - if attrs&AttrReverse != 0 { + if attrs&config.AttrReverse != 0 { style = style.Reverse(true) } return style } -func (t *TcellScreen) Init(_ *Config) error { +func (t *TcellScreen) Init(_ *config.Config) error { screen, err := tcell.NewScreen() if err != nil { return fmt.Errorf("failed to create tcell screen: %w", err) @@ -338,7 +339,7 @@ func (t *TcellScreen) Sync() { // PollEvent returns a channel that you can listen to for // terminal events. The actual polling is done in a // separate goroutine -func (t *TcellScreen) PollEvent(ctx context.Context, cfg *Config) chan Event { +func (t *TcellScreen) PollEvent(ctx context.Context, cfg *config.Config) chan Event { evCh := make(chan Event) go func() { @@ -449,7 +450,7 @@ func (t *TcellScreen) Resume(ctx context.Context) error { } // SetCell writes to the terminal -func (t *TcellScreen) SetCell(x, y int, ch rune, fg, bg Attribute) { +func (t *TcellScreen) SetCell(x, y int, ch rune, fg, bg config.Attribute) { t.mutex.Lock() defer t.mutex.Unlock() if t.screen == nil { @@ -473,8 +474,8 @@ type PrintArgs struct { X int XOffset int Y int - Fg Attribute - Bg Attribute + Fg config.Attribute + Bg config.Attribute Msg string Fill bool ANSIAttrs []ansi.AttrSpan // per-character ANSI attributes for this segment @@ -512,11 +513,11 @@ func screenPrint(t Screen, args PrintArgs) int { efg, ebg := fg, bg if ansiAttrs != nil && spanIdx < len(ansiAttrs) { span := ansiAttrs[spanIdx] - if Attribute(span.Fg) != ColorDefault { - efg = Attribute(span.Fg) + if config.Attribute(span.Fg) != config.ColorDefault { + efg = config.Attribute(span.Fg) } - if Attribute(span.Bg) != ColorDefault { - ebg = Attribute(span.Bg) + if config.Attribute(span.Bg) != config.ColorDefault { + ebg = config.Attribute(span.Bg) } spanPos++ if spanPos >= span.Length { diff --git a/screen_inline.go b/screen_inline.go index 153571c..09d6843 100644 --- a/screen_inline.go +++ b/screen_inline.go @@ -10,6 +10,7 @@ import ( "github.com/gdamore/tcell/v2" pdebug "github.com/lestrrat-go/pdebug" + "github.com/peco/peco/config" ) // InlineScreen implements the Screen interface for rendering peco in a @@ -18,7 +19,7 @@ import ( type InlineScreen struct { mutex sync.Mutex screen tcell.Screen - heightSpec HeightSpec + heightSpec config.HeightSpec height int // resolved line count yOffset int // physical row where inline region starts @@ -30,7 +31,7 @@ type InlineScreen struct { } // NewInlineScreen creates a new InlineScreen with the given height spec. -func NewInlineScreen(spec HeightSpec) *InlineScreen { +func NewInlineScreen(spec config.HeightSpec) *InlineScreen { return &InlineScreen{ heightSpec: spec, errWriter: os.Stderr, @@ -38,7 +39,7 @@ func NewInlineScreen(spec HeightSpec) *InlineScreen { } // Init initializes the tcell screen for inline mode, disabling the alternate screen buffer. -func (s *InlineScreen) Init(_ *Config) error { +func (s *InlineScreen) Init(_ *config.Config) error { // Save and override TCELL_ALTSCREEN to prevent alternate screen buffer s.savedAltscreen = os.Getenv("TCELL_ALTSCREEN") os.Setenv("TCELL_ALTSCREEN", "disable") @@ -119,7 +120,7 @@ func (s *InlineScreen) Close() error { return nil } -func (s *InlineScreen) SetCell(x, y int, ch rune, fg, bg Attribute) { +func (s *InlineScreen) SetCell(x, y int, ch rune, fg, bg config.Attribute) { s.mutex.Lock() defer s.mutex.Unlock() if s.screen == nil { @@ -173,7 +174,7 @@ func (s *InlineScreen) Size() (int, int) { } // PollEvent creates an event channel and polls for terminal events with special resize handling. -func (s *InlineScreen) PollEvent(ctx context.Context, _ *Config) chan Event { +func (s *InlineScreen) PollEvent(ctx context.Context, _ *config.Config) chan Event { evCh := make(chan Event) go func() { diff --git a/screen_inline_test.go b/screen_inline_test.go index ee7b052..29060b6 100644 --- a/screen_inline_test.go +++ b/screen_inline_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/gdamore/tcell/v2" + "github.com/peco/peco/config" "github.com/stretchr/testify/require" ) @@ -17,7 +18,7 @@ func newTestInlineScreen(termWidth, termHeight, inlineHeight int) (*InlineScreen sim.SetSize(termWidth, termHeight) s := &InlineScreen{ - heightSpec: HeightSpec{Value: inlineHeight, IsPercent: false}, + heightSpec: config.HeightSpec{Value: inlineHeight, IsPercent: false}, screen: sim, height: inlineHeight, yOffset: termHeight - inlineHeight, @@ -39,7 +40,7 @@ func TestInlineScreenSetCell(t *testing.T) { defer s.screen.Fini() // SetCell at virtual y=0 should map to physical y=14 (24-10) - s.SetCell(5, 0, 'A', ColorDefault, ColorDefault) + s.SetCell(5, 0, 'A', config.ColorDefault, config.ColorDefault) s.Flush() // Read back from simulation screen at physical coordinates @@ -47,7 +48,7 @@ func TestInlineScreenSetCell(t *testing.T) { require.Equal(t, "A", str) // SetCell at virtual y=9 (last line) should map to physical y=23 - s.SetCell(10, 9, 'Z', ColorDefault, ColorDefault) + s.SetCell(10, 9, 'Z', config.ColorDefault, config.ColorDefault) s.Flush() str, _, _ = sim.Get(10, 23) @@ -110,7 +111,7 @@ func TestInlineScreenNilSafety(t *testing.T) { require.Equal(t, 0, w) require.Equal(t, 0, h) - s.SetCell(0, 0, 'X', ColorDefault, ColorDefault) + s.SetCell(0, 0, 'X', config.ColorDefault, config.ColorDefault) s.SetCursor(0, 0) require.NoError(t, s.Flush()) } diff --git a/screen_test.go b/screen_test.go index 9db6508..fb0bdc0 100644 --- a/screen_test.go +++ b/screen_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/gdamore/tcell/v2" + "github.com/peco/peco/config" "github.com/peco/peco/internal/keyseq" "github.com/stretchr/testify/require" ) @@ -25,18 +26,18 @@ type recordingScreen struct { w, h int } -func (s *recordingScreen) Init(*Config) error { return nil } -func (s *recordingScreen) Close() error { return nil } -func (s *recordingScreen) Flush() error { return nil } -func (s *recordingScreen) PollEvent(context.Context, *Config) chan Event { return nil } -func (s *recordingScreen) Print(args PrintArgs) int { return screenPrint(s, args) } -func (s *recordingScreen) Resume(context.Context) error { return nil } -func (s *recordingScreen) SetCursor(int, int) {} -func (s *recordingScreen) SendEvent(Event) {} -func (s *recordingScreen) Suspend() {} -func (s *recordingScreen) Sync() {} -func (s *recordingScreen) Size() (int, int) { return s.w, s.h } -func (s *recordingScreen) SetCell(x, y int, ch rune, _, _ Attribute) { +func (s *recordingScreen) Init(*config.Config) error { return nil } +func (s *recordingScreen) Close() error { return nil } +func (s *recordingScreen) Flush() error { return nil } +func (s *recordingScreen) PollEvent(context.Context, *config.Config) chan Event { return nil } +func (s *recordingScreen) Print(args PrintArgs) int { return screenPrint(s, args) } +func (s *recordingScreen) Resume(context.Context) error { return nil } +func (s *recordingScreen) SetCursor(int, int) {} +func (s *recordingScreen) SendEvent(Event) {} +func (s *recordingScreen) Suspend() {} +func (s *recordingScreen) Sync() {} +func (s *recordingScreen) Size() (int, int) { return s.w, s.h } +func (s *recordingScreen) SetCell(x, y int, ch rune, _, _ config.Attribute) { s.cells = append(s.cells, setCellCall{x: x, y: y, ch: ch}) } diff --git a/view.go b/view.go index f6d67d4..c6c965b 100644 --- a/view.go +++ b/view.go @@ -15,7 +15,7 @@ type View struct { // NewView creates a new View with the given state and its configured layout. func NewView(state *Peco) (*View, error) { - layout, err := NewLayout(LayoutType(state.LayoutType()), state) + layout, err := NewLayout(state.LayoutType(), state) if err != nil { return nil, fmt.Errorf("failed to create layout: %w", err) }