extract config package

This commit is contained in:
Daisuke Maki 2026-02-20 18:42:24 +09:00
parent 7b54585c95
commit 11754d722b
18 changed files with 460 additions and 434 deletions

View file

@ -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"
@ -444,7 +445,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)
@ -461,7 +462,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)

View file

@ -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:

View file

@ -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) {

View file

@ -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
}

View file

@ -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) {

24
config/layout.go Normal file
View file

@ -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
}

184
config/style.go Normal file
View file

@ -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
}

View file

@ -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)

123
layout.go
View file

@ -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() {

View file

@ -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)
})
}

View file

@ -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 + "'")
}
}

29
peco.go
View file

@ -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)
}

View file

@ -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) {

View file

@ -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 {

View file

@ -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() {

View file

@ -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())
}

View file

@ -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})
}

View file

@ -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)
}