mirror of
https://github.com/peco/peco.git
synced 2026-09-10 07:16:29 -04:00
375 lines
12 KiB
Go
375 lines
12 KiB
Go
package peco
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/goccy/go-yaml"
|
|
"github.com/peco/peco/internal/util"
|
|
)
|
|
|
|
// OnCancelBehavior specifies what happens when the user cancels peco.
|
|
type OnCancelBehavior string
|
|
|
|
const (
|
|
OnCancelSuccess OnCancelBehavior = "success"
|
|
OnCancelError OnCancelBehavior = "error"
|
|
)
|
|
|
|
// UnmarshalText parses a text value into an OnCancelBehavior, accepting
|
|
// "success" (or empty) and "error" as valid values.
|
|
func (o *OnCancelBehavior) UnmarshalText(b []byte) error {
|
|
switch s := string(b); s {
|
|
case "", "success":
|
|
*o = OnCancelSuccess
|
|
case "error":
|
|
*o = OnCancelError
|
|
default:
|
|
return fmt.Errorf("invalid OnCancel value %q: must be %q or %q", s, OnCancelSuccess, OnCancelError)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Config holds all the data that can be configured in the
|
|
// external configuration file
|
|
type Config struct {
|
|
Action map[string][]string `json:"Action" yaml:"Action"`
|
|
// Keymap used to be directly responsible for dispatching
|
|
// events against user input, but since then this has changed
|
|
// into something that just records the user's config input
|
|
Keymap map[string]string `json:"Keymap" yaml:"Keymap"`
|
|
InitialFilter string `json:"InitialFilter" yaml:"InitialFilter"`
|
|
Style StyleSet `json:"Style" yaml:"Style"`
|
|
Prompt string `json:"Prompt" yaml:"Prompt"`
|
|
Layout string `json:"Layout" yaml:"Layout"`
|
|
OnCancel OnCancelBehavior `json:"OnCancel" yaml:"OnCancel"`
|
|
CustomFilter map[string]CustomFilterConfig `json:"CustomFilter" yaml:"CustomFilter"`
|
|
QueryExecutionDelay int `json:"QueryExecutionDelay" yaml:"QueryExecutionDelay"`
|
|
StickySelection bool `json:"StickySelection" yaml:"StickySelection"`
|
|
MaxScanBufferSize int `json:"MaxScanBufferSize" yaml:"MaxScanBufferSize"`
|
|
FilterBufSize int `json:"FilterBufSize" yaml:"FilterBufSize"`
|
|
FuzzyLongestSort bool `json:"FuzzyLongestSort" yaml:"FuzzyLongestSort"`
|
|
SuppressStatusMsg bool `json:"SuppressStatusMsg" yaml:"SuppressStatusMsg"`
|
|
ANSI bool `json:"ANSI" yaml:"ANSI"`
|
|
|
|
// If this is true, then the prefix for single key jump mode
|
|
// is displayed by default.
|
|
SingleKeyJump SingleKeyJumpConfig `json:"SingleKeyJump" yaml:"SingleKeyJump"`
|
|
|
|
// Use this prefix to denote currently selected line
|
|
SelectionPrefix string `json:"SelectionPrefix" yaml:"SelectionPrefix"`
|
|
|
|
// Height specifies the display height in lines or percentage (e.g. "10", "50%").
|
|
// When set, peco renders inline without using the alternate screen buffer.
|
|
Height string `json:"Height" yaml:"Height"`
|
|
}
|
|
|
|
// SingleKeyJumpConfig holds configuration for single key jump mode.
|
|
type SingleKeyJumpConfig struct {
|
|
ShowPrefix bool `json:"ShowPrefix" yaml:"ShowPrefix"`
|
|
}
|
|
|
|
// CustomFilterConfig is used to specify configuration parameters
|
|
// to CustomFilters
|
|
type CustomFilterConfig struct {
|
|
// Cmd is the name of the command to invoke
|
|
Cmd string `json:"Cmd" yaml:"Cmd"`
|
|
|
|
// TODO: need to check if how we use this is correct
|
|
Args []string `json:"Args" yaml:"Args"`
|
|
|
|
// BufferThreshold defines how many lines peco buffers before
|
|
// invoking the external command. If this value is big, we
|
|
// will execute the external command fewer times, but the
|
|
// results will not be generated for longer periods of time.
|
|
// If this value is small, we will execute the external command
|
|
// more often, but you pay the penalty of invoking that command
|
|
// more times.
|
|
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>"
|
|
|
|
// Init initializes the Config with default values
|
|
func (c *Config) Init() error {
|
|
c.Keymap = make(map[string]string)
|
|
c.Style.Init()
|
|
c.Prompt = DefaultPrompt
|
|
c.Layout = LayoutTypeTopDown
|
|
return nil
|
|
}
|
|
|
|
// ReadFilename reads the config from the given file, and
|
|
// does the appropriate processing, if any
|
|
func (c *Config) ReadFilename(filename string) error {
|
|
f, err := os.Open(filename)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open file %s: %w", filename, err)
|
|
}
|
|
defer f.Close()
|
|
|
|
switch ext := filepath.Ext(filename); ext {
|
|
case ".yaml", ".yml":
|
|
err = yaml.NewDecoder(f).Decode(c)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to decode YAML: %w", err)
|
|
}
|
|
default:
|
|
err = json.NewDecoder(f).Decode(c)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to decode JSON: %w", err)
|
|
}
|
|
}
|
|
|
|
if !IsValidLayoutType(LayoutType(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
|
|
}
|
|
|
|
// This is a variable because we want to change its behavior
|
|
// when we run tests.
|
|
type configLocateFunc func(string) (string, error)
|
|
|
|
var configFilenames = []string{"config.json", "config.yaml", "config.yml"}
|
|
|
|
// locateRcfileIn searches the given directory for a config file with one of
|
|
// the known filenames (config.json, config.yaml, config.yml).
|
|
func locateRcfileIn(dir string) (string, error) {
|
|
for _, basename := range configFilenames {
|
|
file := filepath.Join(dir, basename)
|
|
if _, err := os.Stat(file); err == nil {
|
|
return file, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("config file not found in %s", dir)
|
|
}
|
|
|
|
// LocateRcfile attempts to find the config file in various locations
|
|
func LocateRcfile(locater configLocateFunc) (string, error) {
|
|
// http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html
|
|
//
|
|
// Try in this order:
|
|
// $XDG_CONFIG_HOME/peco/config.{json,yaml,yml}
|
|
// $XDG_CONFIG_DIR/peco/config.{json,yaml,yml} (where XDG_CONFIG_DIR is listed in $XDG_CONFIG_DIRS)
|
|
// ~/.peco/config.{json,yaml,yml}
|
|
|
|
home, uErr := homedirFunc()
|
|
|
|
// Try dir supplied via env var
|
|
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
|
|
if file, err := locater(filepath.Join(dir, "peco")); err == nil {
|
|
return file, nil
|
|
}
|
|
} else if uErr == nil { // silently ignore failure for homedir()
|
|
// Try "default" XDG location, is user is available
|
|
if file, err := locater(filepath.Join(home, ".config", "peco")); err == nil {
|
|
return file, nil
|
|
}
|
|
}
|
|
|
|
// this standard does not take into consideration windows (duh)
|
|
// while the spec says use ":" as the separator, Go provides us
|
|
// with filepath.ListSeparator, so use it
|
|
if dirs := os.Getenv("XDG_CONFIG_DIRS"); dirs != "" {
|
|
for dir := range strings.SplitSeq(dirs, fmt.Sprintf("%c", filepath.ListSeparator)) {
|
|
if file, err := locater(filepath.Join(dir, "peco")); err == nil {
|
|
return file, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
if uErr == nil { // silently ignore failure for homedir()
|
|
if file, err := locater(filepath.Join(home, ".peco")); err == nil {
|
|
return file, nil
|
|
}
|
|
}
|
|
|
|
return "", errors.New("config file not found")
|
|
}
|