Add ANSI color support

This commit is contained in:
Daisuke Maki 2026-02-16 20:06:53 +09:00
parent 757abc8b66
commit 49f7153598
24 changed files with 768 additions and 80 deletions

View file

@ -101,6 +101,30 @@ Each scroll moves by half the terminal width.
If your input contains very long lines (e.g. minified files) and they do not appear at all, try increasing `MaxScanBufferSize` in your config. The default is 256 (KB), which limits the maximum length of a single input line.
## ANSI Color Support
When the `--ansi` flag is enabled, peco parses ANSI SGR (Select Graphic Rendition) escape sequences from the input and renders the original colors in the terminal. This lets you pipe colored output from tools like `git log --color`, `rg --color=always`, or `ls --color` through peco while preserving the visual formatting.
```
git log --color=always | peco --ansi
rg --color=always pattern | peco --ansi
ls --color=always | peco --ansi
```
Supported ANSI features:
- Basic 8 foreground and background colors (30-37, 40-47)
- 256-color palette (38;5;N, 48;5;N)
- 24-bit truecolor (38;2;R;G;B, 48;2;R;G;B)
- Bold, underline, and reverse attributes
- Reset sequences
When ANSI mode is enabled:
- Filtering and matching operate against the **stripped** (plain text) version of each line, so escape codes do not interfere with your queries
- ANSI colors are displayed as the **base layer**; peco's own selection and match highlighting take precedence over ANSI colors
- Selected lines' output preserves the **original** ANSI codes, so downstream tools receive colored text
ANSI mode can also be enabled permanently via the configuration file (see [ANSI](#ansi) under Global configuration).
## Selectable Layout
As of v0.2.5, if you would rather not move your eyes off of the bottom of the screen, you can change the screen layout by either providing the `--layout=bottom-up` command line option, or set the `Layout` variable in your configuration file
@ -303,6 +327,12 @@ Upon exiting from the external command, the control goes back to peco where you
To exit out of peco when running in this mode, you must execute the Cancel command, usually the escape key.
### --ansi
Enables ANSI color code support. When this flag is set, peco parses ANSI SGR escape sequences from the input and renders the colors in the terminal UI. Filtering is performed against the plain text with ANSI codes stripped, and selected output preserves the original ANSI codes.
See [ANSI Color Support](#ansi-color-support) in the Features section for details.
# Configuration File
peco by default consults a few locations for the config files.
@ -322,6 +352,7 @@ Below are configuration sections that you may specify in your config file:
* [Prompt](#prompt)
* [InitialMatcher](#initialmatcher)
* [Use256Color](#use256color)
* [ANSI](#ansi)
## Global
@ -404,6 +435,20 @@ very long lines that prohibit peco from reading them, try increasing this number
The same time, the default MaxScanBuferSize is 256kb.
### ANSI
```json
{
"ANSI": true
}
```
Enables ANSI color code support. When set to `true`, peco parses and renders ANSI SGR escape sequences from the input. This is equivalent to using the `--ansi` command line flag. The command line flag takes precedence if both are specified.
Default value for ANSI is `false`.
See [ANSI Color Support](#ansi-color-support) in the Features section for details.
## Keymaps
Example:
@ -834,6 +879,7 @@ Much code stolen from https://github.com/mattn/gof
- [Select Range Of Lines](#select-range-of-lines)
- [Select Filters](#select-filters)
- [Horizontal Scrolling](#horizontal-scrolling)
- [ANSI Color Support](#ansi-color-support)
- [Selectable Layout](#selectable-layout)
- [Works on Windows!](#works-on-windows)
- [Installation](#installation)
@ -861,6 +907,7 @@ Much code stolen from https://github.com/mattn/gof
- [--on-cancel `success|error`](#--on-cancel-successerror)
- [--selection-prefix `string`](#--selection-prefix-string)
- [--exec `string`](#--exec-string)
- [--ansi](#--ansi)
- [Configuration File](#configuration-file)
- [Global](#global)
- [Prompt](#prompt)
@ -871,6 +918,7 @@ Much code stolen from https://github.com/mattn/gof
- [SuppressStatusMsg](#suppressstatusmsg)
- [OnCancel](#oncancel)
- [MaxScanBufferSize](#maxscanbuffersize)
- [ANSI](#ansi)
- [Keymaps](#keymaps)
- [Key sequences](#key-sequences)
- [Combined actions](#combined-actions)

View file

@ -446,11 +446,11 @@ func TestGHIssue574_PreviousSelectionLastLineNotUpdated(t *testing.T) {
// Create lines with known IDs.
// We use IDs 10, 20, 30, 40, 50 for five lines.
lines := []line.Line{
line.NewRaw(10, "line-10", false),
line.NewRaw(20, "line-20", false),
line.NewRaw(30, "line-30", false),
line.NewRaw(40, "line-40", false),
line.NewRaw(50, "line-50", false),
line.NewRaw(10, "line-10", false, false),
line.NewRaw(20, "line-20", false, false),
line.NewRaw(30, "line-30", false, false),
line.NewRaw(40, "line-40", false, false),
line.NewRaw(50, "line-50", false, false),
}
// Build a MemoryBuffer containing those lines.

View file

@ -167,7 +167,7 @@ func loadFromFile(path string) []line.Line {
scanner.Buffer(make([]byte, 256*1024), 256*1024)
var id uint64
for scanner.Scan() {
lines = append(lines, line.NewRaw(id, scanner.Text(), false))
lines = append(lines, line.NewRaw(id, scanner.Text(), false, false))
id++
}
if err := scanner.Err(); err != nil {
@ -228,7 +228,7 @@ func generateLines(cfg benchConfig) []line.Line {
}
}
lines[i] = line.NewRaw(uint64(i), sb.String(), false)
lines[i] = line.NewRaw(uint64(i), sb.String(), false, false)
}
return lines

View file

@ -133,7 +133,7 @@ func (ecf *ExternalCmd) Apply(ctx context.Context, buf []line.Line, out pipeline
if l == nil {
// No original line found (or enableSep is false):
// create a new Raw line as before
l = line.NewRaw(ecf.idgen.Next(), s, ecf.enableSep)
l = line.NewRaw(ecf.idgen.Next(), s, ecf.enableSep, false)
}
select {

View file

@ -46,9 +46,9 @@ func TestExternalCmdFilter_NullSep(t *testing.T) {
// Create lines with null separator: display\0output
lines := []line.Line{
line.NewRaw(idgen.Next(), "apple\x00/fruit/apple", true),
line.NewRaw(idgen.Next(), "banana\x00/fruit/banana", true),
line.NewRaw(idgen.Next(), "apricot\x00/fruit/apricot", true),
line.NewRaw(idgen.Next(), "apple\x00/fruit/apple", true, false),
line.NewRaw(idgen.Next(), "banana\x00/fruit/banana", true, false),
line.NewRaw(idgen.Next(), "apricot\x00/fruit/apricot", true, false),
}
// Verify the lines are set up correctly
@ -88,9 +88,9 @@ func TestExternalCmdFilter_NullSep(t *testing.T) {
idgen := &testIDGen{}
lines := []line.Line{
line.NewRaw(idgen.Next(), "apple", false),
line.NewRaw(idgen.Next(), "banana", false),
line.NewRaw(idgen.Next(), "apricot", false),
line.NewRaw(idgen.Next(), "apple", false, false),
line.NewRaw(idgen.Next(), "banana", false, false),
line.NewRaw(idgen.Next(), "apricot", false, false),
}
ecf := NewExternalCmd("grep", "grep", []string{"ap"}, 0, idgen, false)
@ -122,9 +122,9 @@ func TestExternalCmdFilter_NullSep(t *testing.T) {
// Three lines with the same display text but different outputs
lines := []line.Line{
line.NewRaw(idgen.Next(), "dup\x00first", true),
line.NewRaw(idgen.Next(), "dup\x00second", true),
line.NewRaw(idgen.Next(), "dup\x00third", true),
line.NewRaw(idgen.Next(), "dup\x00first", true, false),
line.NewRaw(idgen.Next(), "dup\x00second", true, false),
line.NewRaw(idgen.Next(), "dup\x00third", true, false),
}
// Use grep to return all lines (match literal "dup")

View file

@ -61,7 +61,7 @@ func testFuzzy(octx context.Context, t *testing.T, filter Filter) {
defer cancel()
ch := make(chan interface{}, 1)
l := line.NewRaw(uint64(i), v.input, false)
l := line.NewRaw(uint64(i), v.input, false, false)
err := filter.Apply(ctx, []line.Line{l}, pipeline.ChanOutput(ch))
if !assert.NoError(t, err, `filter.Apply should succeed`) {
return
@ -189,7 +189,7 @@ func testFuzzyLongest(octx context.Context, t *testing.T, filter Filter) {
var lines []line.Line
for _, raw := range v.input {
lines = append(lines, line.NewRaw(uint64(i), raw, false))
lines = append(lines, line.NewRaw(uint64(i), raw, false, false))
}
var actual []string
@ -318,7 +318,7 @@ func collectFilterResults(t *testing.T, f Filter, query string, inputLines []lin
func makeLines(inputs ...string) []line.Line {
lines := make([]line.Line, len(inputs))
for i, s := range inputs {
lines[i] = line.NewRaw(uint64(i), s, false)
lines[i] = line.NewRaw(uint64(i), s, false, false)
}
return lines
}
@ -540,7 +540,7 @@ func testFuzzyMatch(octx context.Context, t *testing.T, filter Filter) {
lc := make(chan interface{})
ec := make(chan error)
go func() {
ec <- filter.Apply(ctx, []line.Line{line.NewRaw(uint64(i), v.input, false)}, lc)
ec <- filter.Apply(ctx, []line.Line{line.NewRaw(uint64(i), v.input, false, false)}, lc)
}()
OUTER:

View file

@ -42,7 +42,7 @@ func TestParallelFilterProducesSameResults(t *testing.T) {
if i%3 == 0 {
text = fmt.Sprintf("line-%04d matching-pattern test", i)
}
lines[i] = line.NewRaw(uint64(i), text, false)
lines[i] = line.NewRaw(uint64(i), text, false, false)
}
filters := []struct {
@ -107,7 +107,7 @@ func TestParallelFilterContextCancellation(t *testing.T) {
const numLines = 100000
lines := make([]line.Line, numLines)
for i := 0; i < numLines; i++ {
lines[i] = line.NewRaw(uint64(i), fmt.Sprintf("line-%d matching-pattern", i), false)
lines[i] = line.NewRaw(uint64(i), fmt.Sprintf("line-%d matching-pattern", i), false, false)
}
f := NewIgnoreCase()

View file

@ -83,7 +83,7 @@ func TestMemoryBufferSource(t *testing.T) {
expected := []string{"alpha", "bravo", "charlie", "delta", "echo"}
for i, s := range expected {
mb.lines = append(mb.lines, line.NewRaw(uint64(i), s, false))
mb.lines = append(mb.lines, line.NewRaw(uint64(i), s, false, false))
}
// Wrap as source
@ -116,7 +116,7 @@ done:
func TestMemoryBufferSourceCancellation(t *testing.T) {
mb := NewMemoryBuffer(0)
for i := 0; i < 10000; i++ {
mb.lines = append(mb.lines, line.NewRaw(uint64(i), fmt.Sprintf("line-%d", i), false))
mb.lines = append(mb.lines, line.NewRaw(uint64(i), fmt.Sprintf("line-%d", i), false, false))
}
src := NewMemoryBufferSource(mb)
@ -150,12 +150,12 @@ func TestIncrementalFiltering(t *testing.T) {
// Create test lines
allLines := []line.Line{
line.NewRaw(0, "foobar test", false),
line.NewRaw(1, "football game", false),
line.NewRaw(2, "barfoo other", false),
line.NewRaw(3, "something else", false),
line.NewRaw(4, "foobaz entry", false),
line.NewRaw(5, "the foobird flies", false),
line.NewRaw(0, "foobar test", false, false),
line.NewRaw(1, "football game", false, false),
line.NewRaw(2, "barfoo other", false, false),
line.NewRaw(3, "something else", false, false),
line.NewRaw(4, "foobaz entry", false, false),
line.NewRaw(5, "the foobird flies", false, false),
}
f := filter.NewIgnoreCase()

View file

@ -106,6 +106,7 @@ type Peco struct {
singleKeyJumpShowPrefix bool
skipReadConfig bool
styles StyleSet
enableANSI bool // Enable ANSI color code support
use256Color bool
fuzzyLongestSort bool
@ -314,6 +315,7 @@ type Config struct {
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.
@ -424,6 +426,7 @@ type Source struct {
capacity int
enableSep bool
enableANSI bool
idgen line.IDGenerator
in io.Reader
inClosed bool
@ -467,6 +470,7 @@ type CLIOptions struct {
OptSelectionPrefix string `long:"selection-prefix" description:"use a prefix instead of changing line color to indicate currently selected lines.\ndefault is to use colors. This option is experimental"`
OptExec string `long:"exec" description:"execute command instead of finishing/terminating peco.\nPlease note that this command will receive selected line(s) from stdin,\nand will be executed via '/bin/sh -c' or 'cmd /c'"`
OptPrintQuery bool `long:"print-query" description:"print out the current query as first line of output"`
OptANSI bool `long:"ansi" description:"enable ANSI color code support"`
}
type CLI struct {

273
internal/ansi/parser.go Normal file
View file

@ -0,0 +1,273 @@
// Package ansi provides ANSI SGR escape sequence parsing for peco.
// It extracts color/style attributes from input text and produces
// run-length encoded attribute spans alongside stripped plain text.
package ansi
import (
"strconv"
"strings"
"unicode/utf8"
)
// Attribute mirrors peco.Attribute so we avoid an import cycle.
// The values are identical and can be cast directly.
type Attribute = uint32
// Named palette color constants (matching peco.Color* values).
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
)
// basicFgColors maps SGR codes 30-37 to palette colors.
var basicFgColors = [8]Attribute{
ColorBlack, ColorRed, ColorGreen, ColorYellow,
ColorBlue, ColorMagenta, ColorCyan, ColorWhite,
}
// AttrSpan represents a run of characters sharing identical ANSI attributes.
type AttrSpan struct {
Fg Attribute
Bg Attribute
Length int // number of runes
}
// ParseResult contains the output of ANSI parsing.
type ParseResult struct {
Stripped string // text with ANSI codes removed
Attrs []AttrSpan // run-length encoded attributes; nil if no ANSI codes found
}
// Parse parses ANSI SGR sequences from input and returns the stripped text
// along with run-length encoded per-character attributes.
// If no ANSI escape sequences are found, Attrs is nil.
func Parse(input string) ParseResult {
// Fast path: if no ESC character, return as-is
if !strings.ContainsRune(input, '\x1b') {
return ParseResult{Stripped: input, Attrs: nil}
}
var (
out strings.Builder
spans []AttrSpan
curFg = ColorDefault
curBg = ColorDefault
count int // runes in current span
)
out.Grow(len(input))
flush := func() {
if count > 0 {
spans = append(spans, AttrSpan{Fg: curFg, Bg: curBg, Length: count})
count = 0
}
}
i := 0
for i < len(input) {
if input[i] == '\x1b' && i+1 < len(input) && input[i+1] == '[' {
// Found CSI sequence: ESC [
j := i + 2
// Scan for the terminating byte (0x40-0x7E)
for j < len(input) && input[j] >= 0x20 && input[j] <= 0x3F {
j++
}
if j >= len(input) {
// Incomplete sequence at end of string: skip it
i = j
continue
}
terminator := input[j]
if terminator == 'm' {
// SGR sequence
params := input[i+2 : j]
flush()
parseSGR(params, &curFg, &curBg)
}
// Skip the entire sequence (including non-SGR ones)
i = j + 1
continue
}
// Regular character
r, size := utf8.DecodeRuneInString(input[i:])
if r == utf8.RuneError && size == 1 {
r = '?'
}
out.WriteRune(r)
count++
i += size
}
flush()
return ParseResult{
Stripped: out.String(),
Attrs: spans,
}
}
// parseSGR interprets SGR parameters (the part between ESC[ and m).
// It modifies fg and bg in place based on the parameter codes.
func parseSGR(params string, fg, bg *Attribute) {
if params == "" || params == "0" {
// Reset all
*fg = ColorDefault
*bg = ColorDefault
return
}
parts := strings.Split(params, ";")
for i := 0; i < len(parts); i++ {
code, err := strconv.Atoi(parts[i])
if err != nil {
continue
}
switch {
case code == 0:
// Reset
*fg = ColorDefault
*bg = ColorDefault
case code == 1:
*fg |= AttrBold
case code == 4:
*fg |= AttrUnderline
case code == 7:
*fg |= AttrReverse
// Basic foreground colors 30-37
case code >= 30 && code <= 37:
// Preserve attribute flags, set new color
flags := *fg & (AttrBold | AttrUnderline | AttrReverse)
*fg = basicFgColors[code-30] | flags
// Basic background colors 40-47
case code >= 40 && code <= 47:
*bg = basicFgColors[code-40]
// 256-color or truecolor foreground: 38;5;N or 38;2;R;G;B
case code == 38:
if i+1 < len(parts) {
mode, _ := strconv.Atoi(parts[i+1])
switch mode {
case 5: // 256-color: 38;5;N
if i+2 < len(parts) {
n, _ := strconv.Atoi(parts[i+2])
if n >= 0 && n <= 255 {
flags := *fg & (AttrBold | AttrUnderline | AttrReverse)
*fg = Attribute(n+1) | flags
}
i += 2
}
case 2: // Truecolor: 38;2;R;G;B
if i+4 < len(parts) {
r, _ := strconv.Atoi(parts[i+2])
g, _ := strconv.Atoi(parts[i+3])
b, _ := strconv.Atoi(parts[i+4])
flags := *fg & (AttrBold | AttrUnderline | AttrReverse)
*fg = Attribute((r<<16)|(g<<8)|b) | AttrTrueColor | flags
i += 4
}
default:
i++
}
}
// 256-color or truecolor background: 48;5;N or 48;2;R;G;B
case code == 48:
if i+1 < len(parts) {
mode, _ := strconv.Atoi(parts[i+1])
switch mode {
case 5: // 256-color: 48;5;N
if i+2 < len(parts) {
n, _ := strconv.Atoi(parts[i+2])
if n >= 0 && n <= 255 {
*bg = Attribute(n + 1)
}
i += 2
}
case 2: // Truecolor: 48;2;R;G;B
if i+4 < len(parts) {
r, _ := strconv.Atoi(parts[i+2])
g, _ := strconv.Atoi(parts[i+3])
b, _ := strconv.Atoi(parts[i+4])
*bg = Attribute((r<<16)|(g<<8)|b) | AttrTrueColor
i += 4
}
default:
i++
}
}
// Default foreground/background reset
case code == 39:
flags := *fg & (AttrBold | AttrUnderline | AttrReverse)
*fg = ColorDefault | flags
case code == 49:
*bg = ColorDefault
}
}
}
// ExtractSegment extracts ANSI attributes for a rune-range [start, end)
// from the given run-length encoded spans.
// Returns nil if attrs is nil or the segment is empty.
func ExtractSegment(attrs []AttrSpan, start, end int) []AttrSpan {
if attrs == nil || start >= end {
return nil
}
var result []AttrSpan
pos := 0
for _, span := range attrs {
spanEnd := pos + span.Length
if spanEnd <= start {
pos = spanEnd
continue
}
if pos >= end {
break
}
overlapStart := pos
if start > pos {
overlapStart = start
}
overlapEnd := spanEnd
if end < spanEnd {
overlapEnd = end
}
overlapLen := overlapEnd - overlapStart
if overlapLen > 0 {
result = append(result, AttrSpan{
Fg: span.Fg,
Bg: span.Bg,
Length: overlapLen,
})
}
pos = spanEnd
}
return result
}

View file

@ -0,0 +1,210 @@
package ansi
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestParse_NoANSI(t *testing.T) {
r := Parse("Hello World")
require.Equal(t, "Hello World", r.Stripped)
require.Nil(t, r.Attrs)
}
func TestParse_EmptyString(t *testing.T) {
r := Parse("")
require.Equal(t, "", r.Stripped)
require.Nil(t, r.Attrs)
}
func TestParse_BasicForegroundColors(t *testing.T) {
r := Parse("\x1b[31mRed\x1b[0m")
require.Equal(t, "Red", r.Stripped)
require.Len(t, r.Attrs, 1)
require.Equal(t, ColorRed, r.Attrs[0].Fg)
require.Equal(t, ColorDefault, r.Attrs[0].Bg)
require.Equal(t, 3, r.Attrs[0].Length)
}
func TestParse_BasicBackgroundColors(t *testing.T) {
r := Parse("\x1b[42mGreen BG\x1b[0m")
require.Equal(t, "Green BG", r.Stripped)
require.Len(t, r.Attrs, 1)
require.Equal(t, ColorDefault, r.Attrs[0].Fg)
require.Equal(t, ColorGreen, r.Attrs[0].Bg)
require.Equal(t, 8, r.Attrs[0].Length)
}
func TestParse_BoldAttribute(t *testing.T) {
r := Parse("\x1b[1;31mBold Red\x1b[0m")
require.Equal(t, "Bold Red", r.Stripped)
require.Len(t, r.Attrs, 1)
require.Equal(t, ColorRed|AttrBold, r.Attrs[0].Fg)
require.Equal(t, 8, r.Attrs[0].Length)
}
func TestParse_UnderlineAttribute(t *testing.T) {
r := Parse("\x1b[4mUnderline\x1b[0m")
require.Equal(t, "Underline", r.Stripped)
require.Len(t, r.Attrs, 1)
require.Equal(t, AttrUnderline, r.Attrs[0].Fg)
}
func TestParse_ReverseAttribute(t *testing.T) {
r := Parse("\x1b[7mReverse\x1b[0m")
require.Equal(t, "Reverse", r.Stripped)
require.Len(t, r.Attrs, 1)
require.Equal(t, AttrReverse, r.Attrs[0].Fg)
}
func TestParse_MultipleSegments(t *testing.T) {
r := Parse("AAA\x1b[31mBBB\x1b[0mCCC")
require.Equal(t, "AAABBBCCC", r.Stripped)
require.Len(t, r.Attrs, 3)
require.Equal(t, ColorDefault, r.Attrs[0].Fg)
require.Equal(t, 3, r.Attrs[0].Length)
require.Equal(t, ColorRed, r.Attrs[1].Fg)
require.Equal(t, 3, r.Attrs[1].Length)
require.Equal(t, ColorDefault, r.Attrs[2].Fg)
require.Equal(t, 3, r.Attrs[2].Length)
}
func TestParse_256Color(t *testing.T) {
// 38;5;196 = 256-color fg (bright red, index 196)
r := Parse("\x1b[38;5;196mColor256\x1b[0m")
require.Equal(t, "Color256", r.Stripped)
require.Len(t, r.Attrs, 1)
require.Equal(t, Attribute(197), r.Attrs[0].Fg) // 196+1 for palette encoding
}
func TestParse_256ColorBackground(t *testing.T) {
r := Parse("\x1b[48;5;21mBlueBG\x1b[0m")
require.Equal(t, "BlueBG", r.Stripped)
require.Len(t, r.Attrs, 1)
require.Equal(t, Attribute(22), r.Attrs[0].Bg) // 21+1
}
func TestParse_TrueColor(t *testing.T) {
// 38;2;255;128;0 = truecolor fg (orange)
r := Parse("\x1b[38;2;255;128;0mOrange\x1b[0m")
require.Equal(t, "Orange", r.Stripped)
require.Len(t, r.Attrs, 1)
expected := Attribute((255<<16)|(128<<8)) | AttrTrueColor
require.Equal(t, expected, r.Attrs[0].Fg)
}
func TestParse_TrueColorBackground(t *testing.T) {
r := Parse("\x1b[48;2;0;128;255mBG\x1b[0m")
require.Equal(t, "BG", r.Stripped)
require.Len(t, r.Attrs, 1)
expected := Attribute((0<<16)|(128<<8)|255) | AttrTrueColor
require.Equal(t, expected, r.Attrs[0].Bg)
}
func TestParse_Reset(t *testing.T) {
r := Parse("\x1b[1;31mBold Red\x1b[0m Normal")
require.Equal(t, "Bold Red Normal", r.Stripped)
require.Len(t, r.Attrs, 2)
require.Equal(t, ColorRed|AttrBold, r.Attrs[0].Fg)
require.Equal(t, 8, r.Attrs[0].Length)
require.Equal(t, ColorDefault, r.Attrs[1].Fg)
require.Equal(t, 7, r.Attrs[1].Length)
}
func TestParse_MalformedIncomplete(t *testing.T) {
// Incomplete sequence at end
r := Parse("Hello\x1b[")
require.Equal(t, "Hello", r.Stripped)
}
func TestParse_MalformedNoTerminator(t *testing.T) {
// ESC[31W — 'W' is a valid CSI terminator (non-SGR), so it is stripped
// and "orld" remains as regular text
r := Parse("Hello\x1b[31World")
require.Equal(t, "Helloorld", r.Stripped)
}
func TestParse_NonSGRSequence(t *testing.T) {
// ESC[2J is "clear screen" (not SGR) — should be stripped, not rendered
r := Parse("\x1b[2JHello")
require.Equal(t, "Hello", r.Stripped)
}
func TestParse_EmptyReset(t *testing.T) {
// ESC[m is equivalent to ESC[0m
r := Parse("\x1b[31mRed\x1b[mNormal")
require.Equal(t, "RedNormal", r.Stripped)
require.Len(t, r.Attrs, 2)
require.Equal(t, ColorRed, r.Attrs[0].Fg)
require.Equal(t, ColorDefault, r.Attrs[1].Fg)
}
func TestParse_DefaultFgBgReset(t *testing.T) {
r := Parse("\x1b[31;42mColored\x1b[39;49mReset")
require.Equal(t, "ColoredReset", r.Stripped)
require.Len(t, r.Attrs, 2)
require.Equal(t, ColorRed, r.Attrs[0].Fg)
require.Equal(t, ColorGreen, r.Attrs[0].Bg)
// code 39 resets fg, code 49 resets bg
require.Equal(t, ColorDefault, r.Attrs[1].Fg)
require.Equal(t, ColorDefault, r.Attrs[1].Bg)
}
func TestParse_ComplexCombined(t *testing.T) {
// Bold underline red on blue background
r := Parse("\x1b[1;4;31;44mStyled\x1b[0m")
require.Equal(t, "Styled", r.Stripped)
require.Len(t, r.Attrs, 1)
require.Equal(t, ColorRed|AttrBold|AttrUnderline, r.Attrs[0].Fg)
require.Equal(t, ColorBlue, r.Attrs[0].Bg)
}
func TestExtractSegment_Nil(t *testing.T) {
require.Nil(t, ExtractSegment(nil, 0, 5))
}
func TestExtractSegment_EmptyRange(t *testing.T) {
attrs := []AttrSpan{{Fg: ColorRed, Bg: ColorDefault, Length: 10}}
require.Nil(t, ExtractSegment(attrs, 5, 5))
}
func TestExtractSegment_FullSpan(t *testing.T) {
attrs := []AttrSpan{
{Fg: ColorRed, Bg: ColorDefault, Length: 3},
{Fg: ColorDefault, Bg: ColorDefault, Length: 7},
{Fg: ColorBlue, Bg: ColorDefault, Length: 5},
}
result := ExtractSegment(attrs, 0, 15)
require.Equal(t, attrs, result)
}
func TestExtractSegment_MiddleSlice(t *testing.T) {
attrs := []AttrSpan{
{Fg: ColorRed, Bg: ColorDefault, Length: 5},
{Fg: ColorBlue, Bg: ColorDefault, Length: 5},
{Fg: ColorGreen, Bg: ColorDefault, Length: 5},
}
// Extract runes 3..8 — partial Red(2) + full Blue(5) + partial Green(1)
result := ExtractSegment(attrs, 3, 12)
require.Len(t, result, 3)
require.Equal(t, 2, result[0].Length) // Red: runes 3..5
require.Equal(t, ColorRed, result[0].Fg)
require.Equal(t, 5, result[1].Length) // Blue: runes 5..10
require.Equal(t, ColorBlue, result[1].Fg)
require.Equal(t, 2, result[2].Length) // Green: runes 10..12
require.Equal(t, ColorGreen, result[2].Fg)
}
func TestExtractSegment_SingleSpanSlice(t *testing.T) {
attrs := []AttrSpan{
{Fg: ColorRed, Bg: ColorDefault, Length: 10},
}
result := ExtractSegment(attrs, 2, 7)
require.Len(t, result, 1)
require.Equal(t, 5, result[0].Length)
require.Equal(t, ColorRed, result[0].Fg)
}

View file

@ -132,11 +132,11 @@ func TestIssue557_FilterBufSize(t *testing.T) {
// The exact match ("exact") is at index 1099, which with the default buf size of
// 1000 would land in the second chunk.
var lines []line.Line
lines = append(lines, line.NewRaw(0, "e_x_a_c_t filler text", false)) // fuzzy match
lines = append(lines, line.NewRaw(0, "e_x_a_c_t filler text", false, false)) // fuzzy match
for i := 1; i < totalLines-1; i++ {
lines = append(lines, line.NewRaw(uint64(i), fmt.Sprintf("no match line %d", i), false))
lines = append(lines, line.NewRaw(uint64(i), fmt.Sprintf("no match line %d", i), false, false))
}
lines = append(lines, line.NewRaw(uint64(totalLines-1), "exact", false)) // exact match, best result
lines = append(lines, line.NewRaw(uint64(totalLines-1), "exact", false, false)) // exact match, best result
f := filter.NewFuzzy(true)

View file

@ -9,12 +9,18 @@ import (
"github.com/lestrrat-go/pdebug"
"github.com/mattn/go-runewidth"
"github.com/peco/peco/internal/ansi"
"github.com/peco/peco/line"
"github.com/pkg/errors"
)
var extraOffset int = 0
// ansiLiner is an optional interface for lines that carry ANSI color attributes.
type ansiLiner interface {
ANSIAttrs() []ansi.AttrSpan
}
// LayoutFactory is a function that creates a BasicLayout for the given Peco state.
type LayoutFactory func(*Peco) *BasicLayout
@ -515,6 +521,17 @@ func (l *ListArea) Draw(state *Peco, parent Layout, perPage int, options *DrawOp
xOffset := loc.Column()
line := target.DisplayString()
// Extract ANSI attrs if available. Only use them for
// 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)
if isBasicStyle {
if al, ok := target.(ansiLiner); ok {
lineANSIAttrs = al.ANSIAttrs()
}
}
if len := len(prefix); len > 0 {
l.screen.Print(PrintArgs{
X: x,
@ -562,13 +579,14 @@ func (l *ListArea) Draw(state *Peco, parent Layout, perPage int, options *DrawOp
ix, ok := target.(MatchIndexer)
if !ok {
l.screen.Print(PrintArgs{
X: x,
Y: y,
XOffset: xOffset,
Fg: fgAttr,
Bg: bgAttr,
Msg: line,
Fill: true,
X: x,
Y: y,
XOffset: xOffset,
Fg: fgAttr,
Bg: bgAttr,
Msg: line,
Fill: true,
ANSIAttrs: lineANSIAttrs,
})
continue
}
@ -576,23 +594,33 @@ func (l *ListArea) Draw(state *Peco, parent Layout, perPage int, options *DrawOp
matches := ix.Indices()
prev := x
index := 0
runeOffset := 0 // tracks rune position for ANSI ExtractSegment
for _, m := range matches {
if m[0] > index {
c := line[index:m[0]]
runeLen := utf8.RuneCountInString(c)
var segAttrs []ansi.AttrSpan
if lineANSIAttrs != nil {
segAttrs = ansi.ExtractSegment(lineANSIAttrs, runeOffset, runeOffset+runeLen)
}
n := l.screen.Print(PrintArgs{
X: prev,
Y: y,
XOffset: xOffset,
Fg: fgAttr,
Bg: bgAttr,
Msg: c,
X: prev,
Y: y,
XOffset: xOffset,
Fg: fgAttr,
Bg: bgAttr,
Msg: c,
ANSIAttrs: segAttrs,
})
prev += n
index += len(c)
runeOffset += runeLen
}
c := line[m[0]:m[1]]
runeLen := utf8.RuneCountInString(c)
// Match segments: no ANSI attrs, match style overrides
n := l.screen.Print(PrintArgs{
X: prev,
Y: y,
@ -603,17 +631,25 @@ func (l *ListArea) Draw(state *Peco, parent Layout, perPage int, options *DrawOp
})
prev += n
index += len(c)
runeOffset += runeLen
}
if index < len(line) {
c := line[index:]
runeLen := utf8.RuneCountInString(c)
var segAttrs []ansi.AttrSpan
if lineANSIAttrs != nil {
segAttrs = ansi.ExtractSegment(lineANSIAttrs, runeOffset, runeOffset+runeLen)
}
l.screen.Print(PrintArgs{
X: prev,
Y: y,
XOffset: xOffset,
Fg: fgAttr,
Bg: bgAttr,
Msg: line[index:],
Fill: true,
X: prev,
Y: y,
XOffset: xOffset,
Fg: fgAttr,
Bg: bgAttr,
Msg: c,
Fill: true,
ANSIAttrs: segAttrs,
})
} else {
l.screen.Print(PrintArgs{

View file

@ -219,12 +219,12 @@ func TestGHIssue460_MatchedStyleDoesNotBleedToEndOfLine(t *testing.T) {
state.skipReadConfig = true
mb := NewMemoryBuffer(0)
raw := line.NewRaw(0, text, false)
raw := line.NewRaw(0, text, false, false)
matched := line.NewMatched(raw, matches)
mb.lines = append(mb.lines, matched)
// Add a second line so we can set the cursor on it,
// keeping line 0 in Basic (non-selected) style.
mb.lines = append(mb.lines, line.NewRaw(1, "other", false))
mb.lines = append(mb.lines, line.NewRaw(1, "other", false, false))
state.currentLineBuffer = mb
loc := state.Location()
@ -301,7 +301,7 @@ func TestGHIssue455_DrawScreenForceSync(t *testing.T) {
state.Filters().Add(filter.NewIgnoreCase())
mb := NewMemoryBuffer(0)
mb.lines = append(mb.lines, line.NewRaw(0, "line one", false))
mb.lines = append(mb.lines, line.NewRaw(0, "line one", false, false))
state.currentLineBuffer = mb
loc := state.Location()

View file

@ -1,6 +1,9 @@
package line
import "github.com/google/btree"
import (
"github.com/google/btree"
"github.com/peco/peco/internal/ansi"
)
// IDGenerator defines an interface for things that generate
// unique IDs for lines used within peco.
@ -41,6 +44,7 @@ type Raw struct {
buf string
sepLoc int
displayString string
ansiAttrs []ansi.AttrSpan
dirty bool
}

View file

@ -1,5 +1,7 @@
package line
import "github.com/peco/peco/internal/ansi"
// NewMatched creates a new Matched
func NewMatched(rl Line, matches [][]int) *Matched {
return &Matched{rl, matches}
@ -9,3 +11,11 @@ func NewMatched(rl Line, matches [][]int) *Matched {
func (ml Matched) Indices() [][]int {
return ml.indices
}
// ANSIAttrs returns the ANSI attributes from the underlying line, if available.
func (ml Matched) ANSIAttrs() []ansi.AttrSpan {
if r, ok := ml.Line.(*Raw); ok {
return r.ANSIAttrs()
}
return nil
}

View file

@ -4,14 +4,15 @@ import (
"strings"
"github.com/google/btree"
"github.com/peco/peco/internal/ansi"
"github.com/peco/peco/internal/util"
)
// NewRaw creates a new Raw. The `enableSep` flag tells
// it if we should search for a null character to split the
// string to display and the string to emit upon selection of
// of said line
func NewRaw(id uint64, v string, enableSep bool) *Raw {
// of said line. The `enableANSI` flag enables ANSI SGR parsing.
func NewRaw(id uint64, v string, enableSep bool, enableANSI bool) *Raw {
rl := &Raw{
id: id,
buf: v,
@ -20,13 +21,23 @@ func NewRaw(id uint64, v string, enableSep bool) *Raw {
dirty: false,
}
if !enableSep {
return rl
if enableSep {
if i := strings.IndexByte(rl.buf, '\000'); i != -1 {
rl.sepLoc = i
}
}
if i := strings.IndexByte(rl.buf, '\000'); i != -1 {
rl.sepLoc = i
if enableANSI {
// Determine which portion to parse for display
src := rl.buf
if rl.sepLoc > -1 {
src = rl.buf[:rl.sepLoc]
}
r := ansi.Parse(src)
rl.displayString = r.Stripped
rl.ansiAttrs = r.Attrs
}
return rl
}
@ -69,6 +80,12 @@ func (rl *Raw) DisplayString() string {
return rl.displayString
}
// ANSIAttrs returns the run-length encoded ANSI attributes for this line.
// Returns nil if ANSI parsing was not enabled or the line had no ANSI codes.
func (rl *Raw) ANSIAttrs() []ansi.AttrSpan {
return rl.ansiAttrs
}
// Output returns the string to be displayed *after peco is done
func (rl *Raw) Output() string {
if i := rl.sepLoc; i > -1 {

58
line/raw_test.go Normal file
View file

@ -0,0 +1,58 @@
package line
import (
"testing"
"github.com/peco/peco/internal/ansi"
"github.com/stretchr/testify/require"
)
func TestNewRaw_NoANSI(t *testing.T) {
rl := NewRaw(1, "hello world", false, false)
require.Equal(t, "hello world", rl.DisplayString())
require.Nil(t, rl.ANSIAttrs())
require.Equal(t, "hello world", rl.Output())
}
func TestNewRaw_ANSIEnabled_NoEscape(t *testing.T) {
rl := NewRaw(1, "hello world", false, true)
require.Equal(t, "hello world", rl.DisplayString())
require.Nil(t, rl.ANSIAttrs())
require.Equal(t, "hello world", rl.Output())
}
func TestNewRaw_ANSIEnabled_WithEscape(t *testing.T) {
rl := NewRaw(1, "\x1b[31mRed\x1b[0m text", false, true)
require.Equal(t, "Red text", rl.DisplayString())
require.NotNil(t, rl.ANSIAttrs())
require.Len(t, rl.ANSIAttrs(), 2)
require.Equal(t, ansi.ColorRed, rl.ANSIAttrs()[0].Fg)
require.Equal(t, 3, rl.ANSIAttrs()[0].Length)
// Output preserves original ANSI codes
require.Equal(t, "\x1b[31mRed\x1b[0m text", rl.Output())
}
func TestNewRaw_ANSIDisabled_WithEscape(t *testing.T) {
rl := NewRaw(1, "\x1b[31mRed\x1b[0m text", false, false)
// When ANSI is disabled, DisplayString still strips ANSI via StripANSISequence
require.Equal(t, "Red text", rl.DisplayString())
// But no parsed attributes
require.Nil(t, rl.ANSIAttrs())
}
func TestNewRaw_ANSIEnabled_WithSep(t *testing.T) {
rl := NewRaw(1, "\x1b[31mRed\x1b[0m display\x00output part", true, true)
// Display should only show the part before \0, ANSI-stripped
require.Equal(t, "Red display", rl.DisplayString())
// Output should be the part after \0
require.Equal(t, "output part", rl.Output())
// ANSI attrs should come from the display portion only
require.NotNil(t, rl.ANSIAttrs())
}
func TestNewRaw_ANSIEnabled_WithSepNoANSI(t *testing.T) {
rl := NewRaw(1, "display\x00output", true, true)
require.Equal(t, "display", rl.DisplayString())
require.Nil(t, rl.ANSIAttrs())
require.Equal(t, "output", rl.Output())
}

View file

@ -525,7 +525,7 @@ func (p *Peco) SetupSource(ctx context.Context) (s *Source, err error) {
return nil, errors.New("you must supply something to work with via filename or stdin")
}
src := NewSource(filename, in, isInfinite, p.idgen, p.bufferSize, p.enableSep)
src := NewSource(filename, in, isInfinite, p.idgen, p.bufferSize, p.enableSep, p.enableANSI)
// Block until we receive something from `in`
if pdebug.Enabled {
@ -568,6 +568,7 @@ func (p *Peco) ApplyConfig(opts CLIOptions) error {
}
p.enableSep = opts.OptEnableNullSep
p.enableANSI = opts.OptANSI || p.config.ANSI
if i := opts.OptInitialIndex; i >= 0 {
p.Location().SetLineNumber(i)

View file

@ -268,7 +268,7 @@ func TestIDGen(t *testing.T) {
lines := []*line.Raw{}
for i := 0; i < 1000000; i++ {
lines = append(lines, line.NewRaw(idgen.Next(), fmt.Sprintf("%d", i), false))
lines = append(lines, line.NewRaw(idgen.Next(), fmt.Sprintf("%d", i), false, false))
}
sel := NewSelection()

View file

@ -8,6 +8,7 @@ import (
"github.com/gdamore/tcell/v2"
pdebug "github.com/lestrrat-go/pdebug"
"github.com/mattn/go-runewidth"
"github.com/peco/peco/internal/ansi"
"github.com/peco/peco/internal/keyseq"
"github.com/pkg/errors"
)
@ -335,13 +336,14 @@ func (t *Termbox) Size() (int, int) {
}
type PrintArgs struct {
X int
XOffset int
Y int
Fg Attribute
Bg Attribute
Msg string
Fill bool
X int
XOffset int
Y int
Fg Attribute
Bg Attribute
Msg string
Fill bool
ANSIAttrs []ansi.AttrSpan // per-character ANSI attributes for this segment
}
func (t *Termbox) Print(args PrintArgs) int {
@ -357,6 +359,12 @@ func screenPrint(t Screen, args PrintArgs) int {
x := args.X
y := args.Y
xOffset := args.XOffset
// ANSI span tracking
ansiAttrs := args.ANSIAttrs
spanIdx := 0
spanPos := 0
for len(msg) > 0 {
c, w := utf8.DecodeRuneInString(msg)
if c == utf8.RuneError {
@ -364,16 +372,34 @@ func screenPrint(t Screen, args PrintArgs) int {
w = 1
}
msg = msg[w:]
// Determine effective fg/bg for this character
efg, ebg := fg, bg
if ansiAttrs != nil && spanIdx < len(ansiAttrs) {
span := ansiAttrs[spanIdx]
if Attribute(span.Fg) != ColorDefault {
efg = Attribute(span.Fg)
}
if Attribute(span.Bg) != ColorDefault {
ebg = Attribute(span.Bg)
}
spanPos++
if spanPos >= span.Length {
spanIdx++
spanPos = 0
}
}
if c == '\t' {
// In case we found a tab, we draw it as 4 spaces
n := 4 - (x+xOffset)%4
for i := int(0); i <= n; i++ {
t.SetCell(int(x+i), int(y), ' ', fg, bg)
t.SetCell(int(x+i), int(y), ' ', efg, ebg)
}
written += n
x += n
} else {
t.SetCell(int(x), int(y), c, fg, bg)
t.SetCell(int(x), int(y), c, efg, ebg)
n := int(runewidth.RuneWidth(c))
x += n
written += n

View file

@ -10,13 +10,13 @@ func TestSelection(t *testing.T) {
s := NewSelection()
var i uint64 = 0
alice := line.NewRaw(i, "Alice", false)
alice := line.NewRaw(i, "Alice", false, false)
i++
s.Add(alice)
if s.Len() != 1 {
t.Errorf("expected Len = 1, got %d", s.Len())
}
s.Add(line.NewRaw(i, "Bob", false))
s.Add(line.NewRaw(i, "Bob", false, false))
if s.Len() != 2 {
t.Errorf("expected Len = 2, got %d", s.Len())
}

View file

@ -15,7 +15,7 @@ import (
// NewSource creates a new Source. Does not start processing the input until you
// call Setup()
func NewSource(name string, in io.Reader, isInfinite bool, idgen line.IDGenerator, capacity int, enableSep bool) *Source {
func NewSource(name string, in io.Reader, isInfinite bool, idgen line.IDGenerator, capacity int, enableSep bool, enableANSI bool) *Source {
var lines []line.Line
if capacity > 0 {
lines = make([]line.Line, 0, capacity)
@ -24,6 +24,7 @@ func NewSource(name string, in io.Reader, isInfinite bool, idgen line.IDGenerato
name: name,
capacity: capacity,
enableSep: enableSep,
enableANSI: enableANSI,
idgen: idgen,
in: in, // Note that this may be closed, so do not rely on it
inClosed: false,
@ -150,7 +151,7 @@ func (s *Source) Setup(ctx context.Context, state *Peco) {
}
readCount++
s.Append(line.NewRaw(s.idgen.Next(), l, s.enableSep))
s.Append(line.NewRaw(s.idgen.Next(), l, s.enableSep, s.enableANSI))
notify.Do(notifycb)
}
}

View file

@ -43,7 +43,7 @@ func TestSource(t *testing.T) {
go ig.Run(ctx)
r := addReadDelay(strings.NewReader(strings.Join(lines, "\n")), 2*time.Second)
s := NewSource("-", r, false, ig, 0, false)
s := NewSource("-", r, false, ig, 0, false, false)
p := New()
p.hub = nullHub{}
go s.Setup(ctx, p)