mirror of
https://github.com/peco/peco.git
synced 2026-09-10 07:16:29 -04:00
Add ANSI color support
This commit is contained in:
parent
757abc8b66
commit
49f7153598
48
README.md
48
README.md
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
273
internal/ansi/parser.go
Normal 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
|
||||
}
|
||||
210
internal/ansi/parser_test.go
Normal file
210
internal/ansi/parser_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
76
layout.go
76
layout.go
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
29
line/raw.go
29
line/raw.go
|
|
@ -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
58
line/raw_test.go
Normal 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())
|
||||
}
|
||||
3
peco.go
3
peco.go
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
44
screen.go
44
screen.go
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue