Merge branch 'master' into gh-398

This commit is contained in:
Daisuke Maki 2026-02-16 20:57:37 +09:00
commit d8f557e108
17 changed files with 1701 additions and 104 deletions

142
README.md
View file

@ -84,6 +84,60 @@ The Fuzzy filter allows you to find matches using partial patterns. For example,
![Executed `ps aux | peco`, then typed `google`, which matches the Chrome.app under IgnoreCase filter type. When you change it to Regexp filter, this is no longer the case. But you can type `(?i)google` instead to toggle case-insensitive mode](http://peco.github.io/images/peco-demo-matcher.gif)
## Multi-Stage Filtering (Freeze Results)
You can "freeze" the current filter results, clear the query, and continue filtering on top of the frozen results. This enables multi-stage filtering workflows -- for example, first filter by file extension, freeze, then filter by filename.
Use `peco.FreezeResults` to snapshot the current results and clear the query. Use `peco.UnfreezeResults` to discard the frozen results and revert to the original input. These actions are **not bound to any key by default** -- you need to add keybindings in your config file:
```json
{
"Keymap": {
"M-f": "peco.FreezeResults",
"M-u": "peco.UnfreezeResults"
}
}
```
You can freeze multiple times to progressively narrow down results. Unfreezing always reverts back to the original unfiltered input.
**Example:** Given this input via `ls | peco`:
```
QUERY>
app.go
app_test.go
filter.go
filter_test.go
main.go
readme.md
```
Type `_test` to filter:
```
QUERY> _test
app_test.go
filter_test.go
```
Press `M-f` to freeze. The two test files become the new base and the query clears:
```
QUERY>
app_test.go
filter_test.go
```
Now type `filter` to search within the frozen results:
```
QUERY> filter
filter_test.go
```
Press `Enter` to select `filter_test.go`, or press `M-u` to unfreeze and return to the original full list.
## Horizontal Scrolling
When input lines are longer than the terminal width, they are clipped at the edge of the screen. You can scroll horizontally to reveal the rest of the line using the `peco.ScrollLeft` and `peco.ScrollRight` actions. These actions are **not bound to any key by default** -- you need to add keybindings in your config file:
@ -125,12 +179,54 @@ When ANSI mode is enabled:
ANSI mode can also be enabled permanently via the configuration file (see [ANSI](#ansi) under Global configuration).
## Context Lines (Zoom In/Out)
When filtering results (e.g. searching for "error" in a log file), you often need to see the surrounding lines to understand the context. peco supports expanding filtered results to show context lines around each match, similar to `grep -C`.
Two actions are available:
- **`peco.ZoomIn`** — Expands the current filtered view by showing 3 lines of context (before and after) around every matched line. Overlapping context ranges are merged automatically. Context lines are displayed with the `Context` style (bold by default) to visually distinguish them from matched lines.
- **`peco.ZoomOut`** — Collapses back to the original filtered view, restoring the cursor position.
These actions are **not bound to any key by default**. Add keybindings in your config file:
```json
{
"Keymap": {
"C-o": "peco.ZoomIn",
"C-i": "peco.ZoomOut"
}
}
```
Notes:
- ZoomIn only works when there is an active filter query. If you are viewing the unfiltered source, it is a no-op.
- You cannot zoom in twice — zooming in while already zoomed shows a status message.
- The cursor position is preserved: after ZoomIn, the cursor stays on the same matched line; after ZoomOut, it returns to where it was before zooming.
- Context lines cannot be selected — only the original matched lines participate in selection.
- The `Context` style can be customized in the config file (see [Styles](#styles)).
## 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
![Executed `ps -ef | peco --layout=bottom-up` to toggle inverted layout mode](http://peco.github.io/images/peco-demo-layout-bottom-up.gif)
## Inline Mode (--height)
By default peco takes over the entire terminal screen using the alternate screen buffer. With `--height`, peco renders inline at the bottom of the terminal, preserving your scroll history above. This is similar to fzf's `--height` option.
```
# Render with 5 result lines at the bottom of the terminal
ls | peco --height 5
# Use 40% of the terminal height
ls | peco --height 40%
```
All layout modes (`top-down`, `bottom-up`, `top-down-query-bottom`) work with `--height`. See [--height](#--height-numpercentage) for details.
## Works on Windows!
I have been told that peco even works on windows :) Look ma! I'm not lying!
@ -333,6 +429,27 @@ Enables ANSI color code support. When this flag is set, peco parses ANSI SGR esc
See [ANSI Color Support](#ansi-color-support) in the Features section for details.
### --height `num|percentage`
When specified, peco renders inline at the bottom of the terminal using only the requested number of lines, instead of taking over the full screen. This preserves your terminal scroll history above the peco interface.
The value can be:
- An absolute number of **result lines** (e.g. `--height 5`). The prompt and status bar are added automatically, so `--height 5` uses 7 terminal rows total (5 result lines + prompt + status bar).
- A percentage of the terminal height (e.g. `--height 50%`). This refers to the total height including prompt and status bar.
The minimum effective height is 3 rows (1 result line + prompt + status bar). Values that exceed the terminal height are clamped.
```
# Show 5 result lines inline
ls | peco --height 5
# Use 40% of the terminal
ls | peco --height 40%
```
Without `--height`, peco uses the full terminal screen (default behavior, unchanged).
# Configuration File
peco by default consults a few locations for the config files.
@ -449,6 +566,16 @@ Default value for ANSI is `false`.
See [ANSI Color Support](#ansi-color-support) in the Features section for details.
### Height
```json
{
"Height": "10"
}
```
`Height` is equivalent to using `--height` on the command line. When set, peco renders inline at the bottom of the terminal instead of using the full screen. The value is the number of result lines (e.g. `"10"`) or a percentage of terminal height (e.g. `"50%"`). The command line `--height` option takes precedence over this config value.
## Keymaps
Example:
@ -613,6 +740,10 @@ Some keys just... don't map correctly / too easily for various reasons. Here, we
| peco.CancelRangeMode | Finish selecting by range and cancel range selection |
| peco.RotateMatcher | (DEPRECATED) Use peco.RotateFilter |
| peco.RotateFilter | Rotate between filters (by default, ignore-case/no-ignore-case)|
| peco.FreezeResults | Freeze current results and clear the query to start a new filter on top |
| peco.UnfreezeResults | Discard frozen results and revert to the original input |
| peco.ZoomIn | Expand filtered results with context lines around each match |
| peco.ZoomOut | Collapse back to the filtered view (undo ZoomIn) |
| peco.Finish | Exits from peco with success status |
| peco.Cancel | Exits from peco with failure status, or cancel select mode |
@ -651,7 +782,7 @@ Note: If in case below keymap seems wrong, check the source code in [keymap.go](
## Styles
For now, styles of following 6 items can be customized in `config.json`.
For now, styles of following 7 items can be customized in `config.json`.
```json
{
@ -661,7 +792,8 @@ For now, styles of following 6 items can be customized in `config.json`.
"Selected": ["underline", "on_cyan", "black"],
"Query": ["yellow", "bold"],
"Matched": ["red", "on_blue"],
"Prompt": ["green", "bold"]
"Prompt": ["green", "bold"],
"Context": ["bold"]
}
}
```
@ -672,6 +804,7 @@ For now, styles of following 6 items can be customized in `config.json`.
- `Query` for a query line
- `Matched` for a query matched word
- `Prompt` for the query prompt prefix (e.g., `QUERY>`)
- `Context` for context lines shown by ZoomIn (default: bold)
### Foreground Colors
@ -878,9 +1011,12 @@ Much code stolen from https://github.com/mattn/gof
- [Select Multiple Lines](#select-multiple-lines)
- [Select Range Of Lines](#select-range-of-lines)
- [Select Filters](#select-filters)
- [Multi-Stage Filtering (Freeze Results)](#multi-stage-filtering-freeze-results)
- [Horizontal Scrolling](#horizontal-scrolling)
- [ANSI Color Support](#ansi-color-support)
- [Context Lines (Zoom In/Out)](#context-lines-zoom-inout)
- [Selectable Layout](#selectable-layout)
- [Inline Mode (--height)](#inline-mode---height)
- [Works on Windows!](#works-on-windows)
- [Installation](#installation)
- [Just want the binary?](#just-want-the-binary)
@ -908,6 +1044,7 @@ Much code stolen from https://github.com/mattn/gof
- [--selection-prefix `string`](#--selection-prefix-string)
- [--exec `string`](#--exec-string)
- [--ansi](#--ansi)
- [--height `num|percentage`](#--height-numpercentage)
- [Configuration File](#configuration-file)
- [Global](#global)
- [Prompt](#prompt)
@ -919,6 +1056,7 @@ Much code stolen from https://github.com/mattn/gof
- [OnCancel](#oncancel)
- [MaxScanBufferSize](#maxscanbuffersize)
- [ANSI](#ansi)
- [Height](#height)
- [Keymaps](#keymaps)
- [Key sequences](#key-sequences)
- [Combined actions](#combined-actions)

137
action.go
View file

@ -129,6 +129,12 @@ func init() {
ActionFunc(doToggleViewArround).Register("ViewArround", keyseq.KeyCtrlV)
ActionFunc(doFreezeResults).Register("FreezeResults")
ActionFunc(doUnfreezeResults).Register("UnfreezeResults")
ActionFunc(doZoomIn).Register("ZoomIn")
ActionFunc(doZoomOut).Register("ZoomOut")
ActionFunc(doGoToNextSelection).Register("GoToNextSelection")
ActionFunc(doGoToPreviousSelection).Register("GoToPreviousSelection", keyseq.KeyCtrlJ)
@ -894,6 +900,137 @@ func doGoToPreviousSelection(ctx context.Context, state *Peco, _ Event) {
}
}
func doFreezeResults(ctx context.Context, state *Peco, _ Event) {
if pdebug.Enabled {
g := pdebug.Marker("doFreezeResults")
defer g.End()
}
b := state.CurrentLineBuffer()
if b.Size() == 0 {
state.Hub().SendStatusMsg(ctx, "Nothing to freeze")
return
}
frozen := NewMemoryBuffer(b.Size())
for i := 0; i < b.Size(); i++ {
if l, err := b.LineAt(i); err == nil {
frozen.AppendLine(l)
}
}
close(frozen.done)
state.SetFrozenSource(frozen)
state.Query().Reset()
state.Caret().SetPos(0)
if !state.config.StickySelection {
state.Selection().Reset()
}
state.SetCurrentLineBuffer(frozen)
state.Hub().SendStatusMsg(ctx, "Results frozen")
state.Hub().SendDrawPrompt(ctx)
}
func doUnfreezeResults(ctx context.Context, state *Peco, _ Event) {
if pdebug.Enabled {
g := pdebug.Marker("doUnfreezeResults")
defer g.End()
}
if state.FrozenSource() == nil {
state.Hub().SendStatusMsg(ctx, "No frozen results")
return
}
state.ClearFrozenSource()
state.Query().Reset()
state.Caret().SetPos(0)
if !state.config.StickySelection {
state.Selection().Reset()
}
state.ResetCurrentLineBuffer()
state.Hub().SendStatusMsg(ctx, "Results unfrozen")
state.Hub().SendDrawPrompt(ctx)
}
func doZoomIn(ctx context.Context, state *Peco, _ Event) {
if pdebug.Enabled {
g := pdebug.Marker("doZoomIn")
defer g.End()
}
// Already zoomed in?
if state.PreZoomBuffer() != nil {
state.Hub().SendStatusMsg(ctx, "Already zoomed in")
return
}
// Get the current line buffer
currentBuf := state.CurrentLineBuffer()
// If the current buffer is the source (no active filter), nothing to zoom into
if currentBuf == state.source {
state.Hub().SendStatusMsg(ctx, "Nothing to zoom into")
return
}
source := state.source
contextSize := 3
contextBuf := NewContextBuffer(currentBuf, source, contextSize)
if contextBuf.Size() == 0 {
state.Hub().SendStatusMsg(ctx, "Nothing to zoom into")
return
}
// Save current state for ZoomOut
loc := state.Location()
curLineNo := loc.LineNumber()
state.SetPreZoomState(currentBuf, curLineNo)
// Map cursor to the new context buffer position
newLineNo := 0
indices := contextBuf.MatchEntryIndices()
if curLineNo >= 0 && curLineNo < len(indices) && indices[curLineNo] >= 0 {
newLineNo = indices[curLineNo]
}
state.mutex.Lock()
state.currentLineBuffer = contextBuf
state.mutex.Unlock()
loc.SetLineNumber(newLineNo)
state.Hub().SendDraw(ctx, &DrawOptions{DisableCache: true})
}
func doZoomOut(ctx context.Context, state *Peco, _ Event) {
if pdebug.Enabled {
g := pdebug.Marker("doZoomOut")
defer g.End()
}
preZoom := state.PreZoomBuffer()
if preZoom == nil {
state.Hub().SendStatusMsg(ctx, "Not zoomed in")
return
}
loc := state.Location()
savedLineNo := state.PreZoomLineNo()
state.mutex.Lock()
state.currentLineBuffer = preZoom
state.mutex.Unlock()
loc.SetLineNumber(savedLineNo)
state.ClearPreZoomState()
state.Hub().SendDraw(ctx, &DrawOptions{DisableCache: true})
}
func doSingleKeyJump(ctx context.Context, state *Peco, e Event) {
if pdebug.Enabled {
g := pdebug.Marker("doSingleKeyJump %c", e.Ch)

View file

@ -1,6 +1,7 @@
package peco
import (
"fmt"
"sync"
"testing"
"time"
@ -110,6 +111,10 @@ func TestActionNames(t *testing.T) {
"peco.RotateMatcher",
"peco.Finish",
"peco.Cancel",
"peco.FreezeResults",
"peco.UnfreezeResults",
"peco.ZoomIn",
"peco.ZoomOut",
}
for _, name := range names {
if _, ok := nameToActions[name]; !ok {
@ -610,3 +615,385 @@ func TestGHIssue455_RefreshScreenSendsForceSync(t *testing.T) {
require.True(t, opts.DisableCache, "DisableCache should be true")
require.True(t, opts.ForceSync, "ForceSync should be true for screen refresh")
}
func TestDoFreezeResults(t *testing.T) {
ctx := context.Background()
makeLines := func(values ...string) []line.Line {
lines := make([]line.Line, len(values))
for i, v := range values {
lines[i] = line.NewRaw(uint64(i), v, false, false)
}
return lines
}
t.Run("freeze captures current buffer", func(t *testing.T) {
rHub := &recordingHub{}
state := New()
state.hub = rHub
state.selection = NewSelection()
lines := makeLines("alpha", "beta", "gamma")
mb := NewMemoryBuffer(0)
mb.lines = lines
state.currentLineBuffer = mb
state.Query().Set("test")
state.Caret().SetPos(4)
doFreezeResults(ctx, state, Event{})
fs := state.FrozenSource()
require.NotNil(t, fs, "frozenSource should be set")
require.Equal(t, 3, fs.Size(), "frozen buffer should have 3 lines")
for i, expected := range []string{"alpha", "beta", "gamma"} {
l, err := fs.LineAt(i)
require.NoError(t, err)
require.Equal(t, expected, l.Buffer())
}
require.Equal(t, 0, state.Query().Len(), "query should be cleared")
require.Equal(t, 0, state.Caret().Pos(), "caret should be at 0")
statusMsgs := rHub.getStatusMsgs()
require.Contains(t, statusMsgs, "Results frozen")
})
t.Run("ResetCurrentLineBuffer uses frozen source", func(t *testing.T) {
rHub := &recordingHub{}
state := New()
state.hub = rHub
state.selection = NewSelection()
state.source = &Source{}
lines := makeLines("frozen1", "frozen2")
frozen := NewMemoryBuffer(0)
frozen.lines = lines
close(frozen.done)
state.SetFrozenSource(frozen)
state.ResetCurrentLineBuffer()
buf := state.CurrentLineBuffer()
require.Equal(t, 2, buf.Size(), "should use frozen source")
l, err := buf.LineAt(0)
require.NoError(t, err)
require.Equal(t, "frozen1", l.Buffer())
})
t.Run("unfreeze reverts to original source", func(t *testing.T) {
rHub := &recordingHub{}
state := New()
state.hub = rHub
state.selection = NewSelection()
origLines := makeLines("orig1", "orig2", "orig3")
origSource := &Source{}
origSource.lines = origLines
state.source = origSource
frozen := NewMemoryBuffer(0)
frozen.lines = makeLines("frozen1")
close(frozen.done)
state.SetFrozenSource(frozen)
state.currentLineBuffer = frozen
state.Query().Set("test")
state.Caret().SetPos(4)
doUnfreezeResults(ctx, state, Event{})
require.Nil(t, state.FrozenSource(), "frozenSource should be nil")
require.Equal(t, 0, state.Query().Len(), "query should be cleared")
require.Equal(t, 0, state.Caret().Pos(), "caret should be at 0")
statusMsgs := rHub.getStatusMsgs()
require.Contains(t, statusMsgs, "Results unfrozen")
})
t.Run("freeze with empty buffer does nothing", func(t *testing.T) {
rHub := &recordingHub{}
state := New()
state.hub = rHub
state.selection = NewSelection()
state.currentLineBuffer = NewMemoryBuffer(0)
doFreezeResults(ctx, state, Event{})
require.Nil(t, state.FrozenSource(), "frozenSource should not be set")
statusMsgs := rHub.getStatusMsgs()
require.Contains(t, statusMsgs, "Nothing to freeze")
})
t.Run("unfreeze when not frozen does nothing", func(t *testing.T) {
rHub := &recordingHub{}
state := New()
state.hub = rHub
state.selection = NewSelection()
doUnfreezeResults(ctx, state, Event{})
require.Nil(t, state.FrozenSource(), "frozenSource should remain nil")
statusMsgs := rHub.getStatusMsgs()
require.Contains(t, statusMsgs, "No frozen results")
})
}
func TestContextBuffer(t *testing.T) {
// Helper: create source lines with sequential IDs (0-based, matching line.ID())
makeSource := func(n int) *MemoryBuffer {
mb := NewMemoryBuffer(n)
for i := 0; i < n; i++ {
mb.lines = append(mb.lines, line.NewRaw(uint64(i), fmt.Sprintf("line-%d", i), false, false))
}
return mb
}
// Helper: create a filtered buffer with specific source indices as matches
makeFiltered := func(source *MemoryBuffer, indices []int) *MemoryBuffer {
mb := NewMemoryBuffer(len(indices))
for _, idx := range indices {
l, _ := source.LineAt(idx)
mb.lines = append(mb.lines, l)
}
return mb
}
t.Run("single match in middle", func(t *testing.T) {
source := makeSource(10)
filtered := makeFiltered(source, []int{5})
cb := NewContextBuffer(filtered, source, 2)
// Should have lines 3,4,5,6,7 (5 entries: 2 context before, match, 2 context after)
require.Equal(t, 5, cb.Size())
// Lines 3,4 should be ContextLine
l0, _ := cb.LineAt(0)
_, isCtx0 := l0.(*ContextLine)
require.True(t, isCtx0, "line 0 should be ContextLine")
require.Equal(t, uint64(3), l0.ID())
l1, _ := cb.LineAt(1)
_, isCtx1 := l1.(*ContextLine)
require.True(t, isCtx1, "line 1 should be ContextLine")
require.Equal(t, uint64(4), l1.ID())
// Line 5 should be the matched line (not ContextLine)
l2, _ := cb.LineAt(2)
_, isCtx2 := l2.(*ContextLine)
require.False(t, isCtx2, "line 2 should be the matched line, not ContextLine")
require.Equal(t, uint64(5), l2.ID())
// Lines 6,7 should be ContextLine
l3, _ := cb.LineAt(3)
_, isCtx3 := l3.(*ContextLine)
require.True(t, isCtx3, "line 3 should be ContextLine")
l4, _ := cb.LineAt(4)
_, isCtx4 := l4.(*ContextLine)
require.True(t, isCtx4, "line 4 should be ContextLine")
// matchEntryIndices: filtered index 0 -> entry index 2
require.Equal(t, 2, cb.MatchEntryIndices()[0])
})
t.Run("overlapping context merges", func(t *testing.T) {
source := makeSource(10)
// Two matches close together: indices 3 and 5 with context=2
// Ranges: [1,5] and [3,7] -> merged: [1,7]
filtered := makeFiltered(source, []int{3, 5})
cb := NewContextBuffer(filtered, source, 2)
// Should have lines 1,2,3,4,5,6,7 (7 entries)
require.Equal(t, 7, cb.Size())
// Check matched lines are not ContextLine
l2, _ := cb.LineAt(2) // source index 3
_, isCtx := l2.(*ContextLine)
require.False(t, isCtx, "matched line at source index 3 should not be ContextLine")
require.Equal(t, uint64(3), l2.ID())
l4, _ := cb.LineAt(4) // source index 5
_, isCtx2 := l4.(*ContextLine)
require.False(t, isCtx2, "matched line at source index 5 should not be ContextLine")
require.Equal(t, uint64(5), l4.ID())
// matchEntryIndices: filtered 0 -> entry 2, filtered 1 -> entry 4
require.Equal(t, 2, cb.MatchEntryIndices()[0])
require.Equal(t, 4, cb.MatchEntryIndices()[1])
})
t.Run("match at boundary", func(t *testing.T) {
source := makeSource(5)
// Match at index 0 with context=3 -> range [0, 3] (clamped start)
filtered := makeFiltered(source, []int{0})
cb := NewContextBuffer(filtered, source, 3)
// Should have lines 0,1,2,3 (4 entries)
require.Equal(t, 4, cb.Size())
// First line should be the match (not context)
l0, _ := cb.LineAt(0)
_, isCtx := l0.(*ContextLine)
require.False(t, isCtx, "line 0 should be matched, not context")
require.Equal(t, uint64(0), l0.ID())
// Lines 1-3 should be context
for i := 1; i < 4; i++ {
l, _ := cb.LineAt(i)
_, isCtx := l.(*ContextLine)
require.True(t, isCtx, "line %d should be ContextLine", i)
}
})
t.Run("match at end boundary", func(t *testing.T) {
source := makeSource(5)
// Match at index 4 (last) with context=3 -> range [1, 4] (clamped end)
filtered := makeFiltered(source, []int{4})
cb := NewContextBuffer(filtered, source, 3)
// Should have lines 1,2,3,4 (4 entries)
require.Equal(t, 4, cb.Size())
// Last line should be the match
l3, _ := cb.LineAt(3)
_, isCtx := l3.(*ContextLine)
require.False(t, isCtx, "last line should be matched, not context")
require.Equal(t, uint64(4), l3.ID())
})
t.Run("empty filtered buffer", func(t *testing.T) {
source := makeSource(10)
filtered := NewMemoryBuffer(0)
cb := NewContextBuffer(filtered, source, 3)
require.Equal(t, 0, cb.Size())
})
}
func TestDoZoomInOut(t *testing.T) {
ctx := context.Background()
// Build a source with 10 lines (IDs 0-9)
makeState := func() (*Peco, *recordingHub, *MemoryBuffer, *MemoryBuffer) {
source := NewMemoryBuffer(10)
for i := 0; i < 10; i++ {
source.lines = append(source.lines, line.NewRaw(uint64(i), fmt.Sprintf("line-%d", i), false, false))
}
// Filtered buffer: matches at indices 3 and 7
filtered := NewMemoryBuffer(2)
l3, _ := source.LineAt(3)
l7, _ := source.LineAt(7)
filtered.lines = append(filtered.lines, l3, l7)
rHub := &recordingHub{}
state := New()
state.hub = rHub
state.selection = NewSelection()
state.source = &Source{}
state.source.lines = source.lines
state.currentLineBuffer = filtered
return state, rHub, source, filtered
}
t.Run("ZoomIn with filtered results", func(t *testing.T) {
state, rHub, _, filtered := makeState()
state.Location().SetLineNumber(0) // cursor on first match
doZoomIn(ctx, state, Event{})
// Should have set a context buffer
buf := state.CurrentLineBuffer()
_, isCtx := buf.(*ContextBuffer)
require.True(t, isCtx, "current buffer should be ContextBuffer after ZoomIn")
// Pre-zoom state should be saved
require.Equal(t, filtered, state.PreZoomBuffer(), "preZoomBuffer should be the filtered buffer")
require.Equal(t, 0, state.PreZoomLineNo(), "preZoomLineNo should be 0")
// Should have sent a draw
drawArgs := rHub.getDrawArgs()
require.NotEmpty(t, drawArgs, "should have sent a draw")
// Context buffer should have entries around matches 3 and 7
ctxBuf := buf.(*ContextBuffer)
require.True(t, ctxBuf.Size() > 2, "context buffer should have more entries than just matches")
})
t.Run("ZoomOut restores state", func(t *testing.T) {
state, rHub, _, filtered := makeState()
state.Location().SetLineNumber(0)
// ZoomIn first
doZoomIn(ctx, state, Event{})
rHub.reset()
// ZoomOut
doZoomOut(ctx, state, Event{})
// Buffer should be restored
require.Equal(t, filtered, state.CurrentLineBuffer(), "buffer should be restored to filtered")
// Cursor should be restored
require.Equal(t, 0, state.Location().LineNumber(), "cursor should be restored")
// Pre-zoom state should be cleared
require.Nil(t, state.PreZoomBuffer(), "preZoomBuffer should be nil after ZoomOut")
// Should have sent a draw
drawArgs := rHub.getDrawArgs()
require.NotEmpty(t, drawArgs, "should have sent a draw")
})
t.Run("ZoomIn when not filtered (source buffer)", func(t *testing.T) {
state, rHub, _, _ := makeState()
// Set current buffer to source
state.currentLineBuffer = state.source
doZoomIn(ctx, state, Event{})
// Should be a no-op with status message
statusMsgs := rHub.getStatusMsgs()
require.NotEmpty(t, statusMsgs)
require.Equal(t, "Nothing to zoom into", statusMsgs[0])
// PreZoom should not be set
require.Nil(t, state.PreZoomBuffer())
})
t.Run("ZoomOut when not zoomed", func(t *testing.T) {
state, rHub, _, _ := makeState()
doZoomOut(ctx, state, Event{})
statusMsgs := rHub.getStatusMsgs()
require.NotEmpty(t, statusMsgs)
require.Equal(t, "Not zoomed in", statusMsgs[0])
})
t.Run("ZoomIn when already zoomed", func(t *testing.T) {
state, rHub, _, _ := makeState()
state.Location().SetLineNumber(0)
// ZoomIn first
doZoomIn(ctx, state, Event{})
rHub.reset()
// ZoomIn again
doZoomIn(ctx, state, Event{})
statusMsgs := rHub.getStatusMsgs()
require.NotEmpty(t, statusMsgs)
require.Equal(t, "Already zoomed in", statusMsgs[0])
})
}

144
buffer.go
View file

@ -222,6 +222,150 @@ func bufferLineAt(lines []line.Line, n int) (line.Line, error) {
return lines[n], nil
}
// ContextBuffer holds an expanded view of a filtered buffer, inserting
// context lines from the source around each match (similar to grep -C).
type ContextBuffer struct {
entries []line.Line
maxcols int
filtered Buffer // original filtered buffer, for reference
matchEntryIndices []int // maps filtered buffer index -> entries index
}
// NewContextBuffer builds a ContextBuffer by expanding the filtered buffer
// with contextSize lines of surrounding context from the source.
func NewContextBuffer(filtered Buffer, source Buffer, contextSize int) *ContextBuffer {
cb := &ContextBuffer{
filtered: filtered,
}
filteredSize := filtered.Size()
if filteredSize == 0 {
return cb
}
sourceSize := source.Size()
// Step 1: Collect source indices of matched lines
type matchInfo struct {
srcIdx int
filteredIdx int
line line.Line
}
matches := make([]matchInfo, 0, filteredSize)
for i := 0; i < filteredSize; i++ {
l, err := filtered.LineAt(i)
if err != nil {
continue
}
srcIdx := int(l.ID())
matches = append(matches, matchInfo{srcIdx: srcIdx, filteredIdx: i, line: l})
}
if len(matches) == 0 {
return cb
}
// Step 2: Compute context ranges and merge overlapping ones
type contextRange struct {
start int // inclusive
end int // inclusive
}
ranges := make([]contextRange, 0, len(matches))
for _, m := range matches {
start := m.srcIdx - contextSize
if start < 0 {
start = 0
}
end := m.srcIdx + contextSize
if end >= sourceSize {
end = sourceSize - 1
}
ranges = append(ranges, contextRange{start: start, end: end})
}
// Merge overlapping/adjacent ranges
merged := []contextRange{ranges[0]}
for i := 1; i < len(ranges); i++ {
last := &merged[len(merged)-1]
if ranges[i].start <= last.end+1 {
if ranges[i].end > last.end {
last.end = ranges[i].end
}
} else {
merged = append(merged, ranges[i])
}
}
// Step 3: Build a set of matched source indices for quick lookup
matchedSet := make(map[int]int, len(matches)) // srcIdx -> matches slice index
for i, m := range matches {
matchedSet[m.srcIdx] = i
}
// Step 4: Build entries
cb.entries = make([]line.Line, 0, merged[len(merged)-1].end-merged[0].start+1)
cb.matchEntryIndices = make([]int, filteredSize)
// Initialize to -1 so unmapped entries are obvious
for i := range cb.matchEntryIndices {
cb.matchEntryIndices[i] = -1
}
for _, r := range merged {
for idx := r.start; idx <= r.end; idx++ {
if mi, ok := matchedSet[idx]; ok {
// Use the filtered (matched) line, preserving match highlighting
cb.matchEntryIndices[matches[mi].filteredIdx] = len(cb.entries)
cb.entries = append(cb.entries, matches[mi].line)
} else {
// Context line from source
srcLine, err := source.LineAt(idx)
if err != nil {
continue
}
cb.entries = append(cb.entries, &ContextLine{srcLine})
}
}
}
// Step 5: Compute maxcols
for _, l := range cb.entries {
cols := runewidth.StringWidth(l.DisplayString())
if cols > cb.maxcols {
cb.maxcols = cols
}
}
return cb
}
// Size returns the number of lines in the context buffer.
func (cb *ContextBuffer) Size() int {
return len(cb.entries)
}
// LineAt returns the line at index i.
func (cb *ContextBuffer) LineAt(i int) (line.Line, error) {
if i < 0 || i >= len(cb.entries) {
return nil, errors.Errorf("specified index %d is out of range (size=%d)", i, len(cb.entries))
}
return cb.entries[i], nil
}
func (cb *ContextBuffer) linesInRange(start, end int) []line.Line {
return cb.entries[start:end]
}
// MaxColumn returns the max column size for horizontal scrolling.
func (cb *ContextBuffer) MaxColumn() int {
return cb.maxcols
}
// MatchEntryIndices returns the mapping from filtered buffer index to
// entries index. Used by ZoomIn to map the cursor position.
func (cb *ContextBuffer) MatchEntryIndices() []int {
return cb.matchEntryIndices
}
// MemoryBufferSource wraps a completed MemoryBuffer as a pipeline.Source,
// allowing previous filter results to be reused as the input for
// incremental filtering.

View file

@ -125,6 +125,8 @@ func (ss *StyleSet) Init() {
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.

View file

@ -42,6 +42,10 @@ var expectedConfig = Config{
fg: ColorGreen | AttrBold,
bg: ColorDefault,
},
Context: Style{
fg: ColorDefault | AttrBold,
bg: ColorDefault,
},
},
}

View file

@ -212,6 +212,80 @@ matches the Chrome.app under IgnoreCase filter type.
When you change it to Regexp filter, this is no longer the case.
But you can type \f[V](?i)google\f[R] instead to toggle case-insensitive
mode
.SS Multi-Stage Filtering (Freeze Results)
.PP
You can \[lq]freeze\[rq] the current filter results, clear the query,
and continue filtering on top of the frozen results.
This enables multi-stage filtering workflows \[en] for example, first
filter by file extension, freeze, then filter by filename.
.PP
Use \f[V]peco.FreezeResults\f[R] to snapshot the current results and
clear the query.
Use \f[V]peco.UnfreezeResults\f[R] to discard the frozen results and
revert to the original input.
These actions are \f[B]not bound to any key by default\f[R] \[en] you
need to add keybindings in your config file:
.IP
.nf
\f[C]
{
\[dq]Keymap\[dq]: {
\[dq]M-f\[dq]: \[dq]peco.FreezeResults\[dq],
\[dq]M-u\[dq]: \[dq]peco.UnfreezeResults\[dq]
}
}
\f[R]
.fi
.PP
You can freeze multiple times to progressively narrow down results.
Unfreezing always reverts back to the original unfiltered input.
.PP
\f[B]Example:\f[R] Given this input via \f[V]ls | peco\f[R]:
.IP
.nf
\f[C]
QUERY>
app.go
app_test.go
filter.go
filter_test.go
main.go
readme.md
\f[R]
.fi
.PP
Type \f[V]_test\f[R] to filter:
.IP
.nf
\f[C]
QUERY> _test
app_test.go
filter_test.go
\f[R]
.fi
.PP
Press \f[V]M-f\f[R] to freeze.
The two test files become the new base and the query clears:
.IP
.nf
\f[C]
QUERY>
app_test.go
filter_test.go
\f[R]
.fi
.PP
Now type \f[V]filter\f[R] to search within the frozen results:
.IP
.nf
\f[C]
QUERY> filter
filter_test.go
\f[R]
.fi
.PP
Press \f[V]Enter\f[R] to select \f[V]filter_test.go\f[R], or press
\f[V]M-u\f[R] to unfreeze and return to the original full list.
.SS Horizontal Scrolling
.PP
When input lines are longer than the terminal width, they are clipped at
@ -239,6 +313,51 @@ do not appear at all, try increasing \f[V]MaxScanBufferSize\f[R] in your
config.
The default is 256 (KB), which limits the maximum length of a single
input line.
.SS Context Lines (Zoom In/Out)
.PP
When filtering results (e.g.\ searching for \[lq]error\[rq] in a log
file), you often need to see the surrounding lines to understand the
context.
peco supports expanding filtered results to show context lines around
each match, similar to \f[V]grep -C\f[R].
.PP
Two actions are available:
.IP \[bu] 2
\f[B]\f[VB]peco.ZoomIn\f[B]\f[R] \[em] Expands the current filtered view
by showing 3 lines of context (before and after) around every matched
line.
Overlapping context ranges are merged automatically.
Context lines are displayed with the \f[V]Context\f[R] style (bold by
default) to visually distinguish them from matched lines.
.IP \[bu] 2
\f[B]\f[VB]peco.ZoomOut\f[B]\f[R] \[em] Collapses back to the original
filtered view, restoring the cursor position.
.PP
These actions are \f[B]not bound to any key by default\f[R].
Add keybindings in your config file:
.IP
.nf
\f[C]
{
\[dq]Keymap\[dq]: {
\[dq]C-o\[dq]: \[dq]peco.ZoomIn\[dq],
\[dq]C-i\[dq]: \[dq]peco.ZoomOut\[dq]
}
}
\f[R]
.fi
.PP
Notes: - ZoomIn only works when there is an active filter query.
If you are viewing the unfiltered source, it is a no-op.
- You cannot zoom in twice \[em] zooming in while already zoomed shows a
status message.
- The cursor position is preserved: after ZoomIn, the cursor stays on
the same matched line; after ZoomOut, it returns to where it was before
zooming.
- Context lines cannot be selected \[em] only the original matched lines
participate in selection.
- The \f[V]Context\f[R] style can be customized in the config file (see
Styles).
.SS Selectable Layout
.PP
As of v0.2.5, if you would rather not move your eyes off of the bottom
@ -250,6 +369,27 @@ inverted layout
mode (http://peco.github.io/images/peco-demo-layout-bottom-up.gif)]
Executed \f[V]ps -ef | peco --layout=bottom-up\f[R] to toggle inverted
layout mode
.SS Inline Mode (\[en]height)
.PP
By default peco takes over the entire terminal screen using the
alternate screen buffer.
With \f[V]--height\f[R], peco renders inline at the bottom of the
terminal, preserving your scroll history above.
This is similar to fzf\[cq]s \f[V]--height\f[R] option.
.IP
.nf
\f[C]
# Render with 5 result lines at the bottom of the terminal
ls | peco --height 5
# Use 40% of the terminal height
ls | peco --height 40%
\f[R]
.fi
.PP
All layout modes (\f[V]top-down\f[R], \f[V]bottom-up\f[R],
\f[V]top-down-query-bottom\f[R]) work with \f[V]--height\f[R].
See \[en]height for details.
.SS Works on Windows!
.PP
I have been told that peco even works on windows :) Look ma!
@ -512,6 +652,40 @@ your external command repeatedly afterwards.
.PP
To exit out of peco when running in this mode, you must execute the
Cancel command, usually the escape key.
.SS \[en]height \f[V]num|percentage\f[R]
.PP
When specified, peco renders inline at the bottom of the terminal using
only the requested number of lines, instead of taking over the full
screen.
This preserves your terminal scroll history above the peco interface.
.PP
The value can be:
.IP \[bu] 2
An absolute number of \f[B]result lines\f[R]
(e.g.\ \f[V]--height 5\f[R]).
The prompt and status bar are added automatically, so
\f[V]--height 5\f[R] uses 7 terminal rows total (5 result lines + prompt
+ status bar).
.IP \[bu] 2
A percentage of the terminal height (e.g.\ \f[V]--height 50%\f[R]).
This refers to the total height including prompt and status bar.
.PP
The minimum effective height is 3 rows (1 result line + prompt + status
bar).
Values that exceed the terminal height are clamped.
.IP
.nf
\f[C]
# Show 5 result lines inline
ls | peco --height 5
# Use 40% of the terminal
ls | peco --height 40%
\f[R]
.fi
.PP
Without \f[V]--height\f[R], peco uses the full terminal screen (default
behavior, unchanged).
.SH Configuration File
.PP
peco by default consults a few locations for the config files.
@ -636,6 +810,24 @@ If you believe that your input has very long lines that prohibit peco
from reading them, try increasing this number.
.PP
The same time, the default MaxScanBuferSize is 256kb.
.SS Height
.IP
.nf
\f[C]
{
\[dq]Height\[dq]: \[dq]10\[dq]
}
\f[R]
.fi
.PP
\f[V]Height\f[R] is equivalent to using \f[V]--height\f[R] on the
command line.
When set, peco renders inline at the bottom of the terminal instead of
using the full screen.
The value is the number of result lines (e.g.\ \f[V]\[dq]10\[dq]\f[R])
or a percentage of terminal height (e.g.\ \f[V]\[dq]50%\[dq]\f[R]).
The command line \f[V]--height\f[R] option takes precedence over this
config value.
.SS Keymaps
.PP
Example:
@ -1120,6 +1312,26 @@ T}@T{
Rotate between filters (by default, ignore-case/no-ignore-case)
T}
T{
peco.FreezeResults
T}@T{
Freeze current results and clear the query to start a new filter on top
T}
T{
peco.UnfreezeResults
T}@T{
Discard frozen results and revert to the original input
T}
T{
peco.ZoomIn
T}@T{
Expand filtered results with context lines around each match
T}
T{
peco.ZoomOut
T}@T{
Collapse back to the filtered view (undo ZoomIn)
T}
T{
peco.Finish
T}@T{
Exits from peco with success status
@ -1273,7 +1485,7 @@ T}
.TE
.SS Styles
.PP
For now, styles of following 6 items can be customized in
For now, styles of following 7 items can be customized in
\f[V]config.json\f[R].
.IP
.nf
@ -1285,7 +1497,8 @@ For now, styles of following 6 items can be customized in
\[dq]Selected\[dq]: [\[dq]underline\[dq], \[dq]on_cyan\[dq], \[dq]black\[dq]],
\[dq]Query\[dq]: [\[dq]yellow\[dq], \[dq]bold\[dq]],
\[dq]Matched\[dq]: [\[dq]red\[dq], \[dq]on_blue\[dq]],
\[dq]Prompt\[dq]: [\[dq]green\[dq], \[dq]bold\[dq]]
\[dq]Prompt\[dq]: [\[dq]green\[dq], \[dq]bold\[dq]],
\[dq]Context\[dq]: [\[dq]bold\[dq]]
}
}
\f[R]
@ -1302,6 +1515,8 @@ For now, styles of following 6 items can be customized in
\f[V]Matched\f[R] for a query matched word
.IP \[bu] 2
\f[V]Prompt\f[R] for the query prompt prefix (e.g., \f[V]QUERY>\f[R])
.IP \[bu] 2
\f[V]Context\f[R] for context lines shown by ZoomIn (default: bold)
.SS Foreground Colors
.IP \[bu] 2
\f[V]\[dq]black\[dq]\f[R] for \f[V]termbox.ColorBlack\f[R]
@ -1601,10 +1816,16 @@ Select Range Of Lines
.IP \[bu] 2
Select Filters
.IP \[bu] 2
Multi-Stage Filtering (Freeze Results)
.IP \[bu] 2
Horizontal Scrolling
.IP \[bu] 2
Context Lines (Zoom In/Out)
.IP \[bu] 2
Selectable Layout
.IP \[bu] 2
Inline Mode (\[en]height)
.IP \[bu] 2
Works on Windows!
.RE
.IP \[bu] 2
@ -1661,6 +1882,8 @@ Command Line Options
\[en]selection-prefix \f[V]string\f[R]
.IP \[bu] 2
\[en]exec \f[V]string\f[R]
.IP \[bu] 2
\[en]height \f[V]num|percentage\f[R]
.RE
.IP \[bu] 2
Configuration File
@ -1684,6 +1907,8 @@ SuppressStatusMsg
OnCancel
.IP \[bu] 2
MaxScanBufferSize
.IP \[bu] 2
Height
.RE
.IP \[bu] 2
Keymaps

View file

@ -453,9 +453,14 @@ func (f *Filter) Work(ctx context.Context, q hub.Payload) {
f.prevMu.Unlock()
if src == nil {
src = state.Source()
if sizer, ok := src.(interface{ Size() int }); ok {
srcSize = sizer.Size()
if fs := state.FrozenSource(); fs != nil {
src = NewMemoryBufferSource(fs)
srcSize = fs.Size()
} else {
src = state.Source()
if sizer, ok := src.(interface{ Size() int }); ok {
srcSize = sizer.Size()
}
}
}
p.SetSource(src)

78
height.go Normal file
View file

@ -0,0 +1,78 @@
package peco
import (
"fmt"
"strconv"
"strings"
)
// HeightSpec represents a height specification that can be either
// an absolute number of lines or a percentage of terminal height.
type HeightSpec struct {
Value int
IsPercent bool
}
// ParseHeightSpec parses a height specification string.
// Valid formats: "10" (absolute lines), "50%" (percentage).
func ParseHeightSpec(s string) (HeightSpec, error) {
s = strings.TrimSpace(s)
if s == "" {
return HeightSpec{}, fmt.Errorf("empty height specification")
}
if strings.HasSuffix(s, "%") {
numStr := s[:len(s)-1]
if strings.Contains(numStr, "%") {
return HeightSpec{}, fmt.Errorf("invalid height specification: %q", s)
}
v, err := strconv.Atoi(numStr)
if err != nil {
return HeightSpec{}, fmt.Errorf("invalid height specification: %q", s)
}
if v <= 0 {
return HeightSpec{}, fmt.Errorf("height percentage must be positive: %q", s)
}
if v > 100 {
return HeightSpec{}, fmt.Errorf("height percentage must not exceed 100: %q", s)
}
return HeightSpec{Value: v, IsPercent: true}, nil
}
v, err := strconv.Atoi(s)
if err != nil {
return HeightSpec{}, fmt.Errorf("invalid height specification: %q", s)
}
if v <= 0 {
return HeightSpec{}, fmt.Errorf("height must be positive: %q", s)
}
return HeightSpec{Value: v, IsPercent: false}, nil
}
// chromLines is the number of lines used by the prompt and status bar.
const chromLines = 2
// Resolve converts the HeightSpec to an absolute number of screen rows,
// clamped to [chromLines+1, termHeight].
//
// For absolute values, Value is the number of result lines — the prompt
// and status bar are added automatically (total = Value + 2).
// For percentages, Value is the percentage of the full terminal height
// (prompt and status bar are included in that total).
func (h HeightSpec) Resolve(termHeight int) int {
var height int
if h.IsPercent {
height = termHeight * h.Value / 100
} else {
height = h.Value + chromLines
}
minHeight := chromLines + 1 // at least 1 result line
if height < minHeight {
height = minHeight
}
if height > termHeight {
height = termHeight
}
return height
}

140
height_test.go Normal file
View file

@ -0,0 +1,140 @@
package peco
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestParseHeightSpec(t *testing.T) {
t.Run("valid absolute", func(t *testing.T) {
spec, err := ParseHeightSpec("10")
require.NoError(t, err)
require.Equal(t, 10, spec.Value)
require.False(t, spec.IsPercent)
})
t.Run("valid percentage", func(t *testing.T) {
spec, err := ParseHeightSpec("50%")
require.NoError(t, err)
require.Equal(t, 50, spec.Value)
require.True(t, spec.IsPercent)
})
t.Run("valid 100%", func(t *testing.T) {
spec, err := ParseHeightSpec("100%")
require.NoError(t, err)
require.Equal(t, 100, spec.Value)
require.True(t, spec.IsPercent)
})
t.Run("valid with whitespace", func(t *testing.T) {
spec, err := ParseHeightSpec(" 10 ")
require.NoError(t, err)
require.Equal(t, 10, spec.Value)
require.False(t, spec.IsPercent)
})
t.Run("invalid empty", func(t *testing.T) {
_, err := ParseHeightSpec("")
require.Error(t, err)
})
t.Run("invalid whitespace only", func(t *testing.T) {
_, err := ParseHeightSpec(" ")
require.Error(t, err)
})
t.Run("invalid abc", func(t *testing.T) {
_, err := ParseHeightSpec("abc")
require.Error(t, err)
})
t.Run("invalid negative", func(t *testing.T) {
_, err := ParseHeightSpec("-5")
require.Error(t, err)
})
t.Run("invalid zero", func(t *testing.T) {
_, err := ParseHeightSpec("0")
require.Error(t, err)
})
t.Run("invalid zero percent", func(t *testing.T) {
_, err := ParseHeightSpec("0%")
require.Error(t, err)
})
t.Run("invalid double percent", func(t *testing.T) {
_, err := ParseHeightSpec("50%%")
require.Error(t, err)
})
t.Run("invalid percent over 100", func(t *testing.T) {
_, err := ParseHeightSpec("150%")
require.Error(t, err)
})
}
func TestHeightSpecResolve(t *testing.T) {
// For absolute values: Value is result lines, total = Value + chromLines(2)
t.Run("absolute adds chrome", func(t *testing.T) {
spec := HeightSpec{Value: 10, IsPercent: false}
// 10 result lines + 2 chrome = 12 total
require.Equal(t, 12, spec.Resolve(24))
})
t.Run("absolute 1 result line", func(t *testing.T) {
spec := HeightSpec{Value: 1, IsPercent: false}
// 1 result line + 2 chrome = 3 total
require.Equal(t, 3, spec.Resolve(24))
})
t.Run("absolute 2 result lines", func(t *testing.T) {
spec := HeightSpec{Value: 2, IsPercent: false}
// 2 result lines + 2 chrome = 4 total
require.Equal(t, 4, spec.Resolve(24))
})
t.Run("absolute clamped to terminal height", func(t *testing.T) {
spec := HeightSpec{Value: 50, IsPercent: false}
// 50 + 2 = 52, clamped to 24
require.Equal(t, 24, spec.Resolve(24))
})
t.Run("absolute clamped to minimum", func(t *testing.T) {
// This shouldn't happen via ParseHeightSpec (rejects <= 0),
// but Resolve handles it defensively.
spec := HeightSpec{Value: 0, IsPercent: false}
// 0 + 2 = 2, clamped to min 3
require.Equal(t, chromLines+1, spec.Resolve(24))
})
// For percentages: Value is percentage of total terminal height
t.Run("percentage", func(t *testing.T) {
spec := HeightSpec{Value: 50, IsPercent: true}
require.Equal(t, 12, spec.Resolve(24))
})
t.Run("percentage 100%", func(t *testing.T) {
spec := HeightSpec{Value: 100, IsPercent: true}
require.Equal(t, 24, spec.Resolve(24))
})
t.Run("percentage clamp to min", func(t *testing.T) {
spec := HeightSpec{Value: 1, IsPercent: true}
// 1% of 24 = 0, clamped to chromLines+1 = 3
require.Equal(t, chromLines+1, spec.Resolve(24))
})
t.Run("percentage clamp to terminal height", func(t *testing.T) {
spec := HeightSpec{Value: 100, IsPercent: true}
require.Equal(t, 5, spec.Resolve(5))
})
t.Run("small terminal clamps absolute", func(t *testing.T) {
spec := HeightSpec{Value: 10, IsPercent: false}
// 10 + 2 = 12, clamped to 3
require.Equal(t, 3, spec.Resolve(3))
})
}

View file

@ -24,7 +24,10 @@ func (i *Input) Loop(ctx context.Context, cancel func()) error {
select {
case <-ctx.Done():
return nil
case ev := <-i.evsrc:
case ev, ok := <-i.evsrc:
if !ok {
return nil
}
if err := i.handleInputEvent(ctx, ev); err != nil {
return nil
}

View file

@ -104,6 +104,7 @@ type Peco struct {
singleKeyJumpPrefixes []rune
singleKeyJumpPrefixMap map[rune]uint
singleKeyJumpShowPrefix bool
heightSpec *HeightSpec
skipReadConfig bool
styles StyleSet
enableANSI bool // Enable ANSI color code support
@ -114,12 +115,29 @@ type Peco struct {
// executed.
source *Source
// frozenSource holds a snapshot of filter results when the user
// "freezes" the current results to filter on top of them.
frozenSource *MemoryBuffer
// preZoomBuffer holds the filtered buffer before ZoomIn was applied,
// so ZoomOut can restore it. nil means not zoomed.
preZoomBuffer Buffer
// preZoomLineNo holds the cursor position before ZoomIn was applied.
preZoomLineNo int
// cancelFunc is called for Exit()
cancelFunc func()
// Errors are stored here
err error
}
// ContextLine wraps a line.Line to mark it as a context line (non-matched
// surrounding line shown during ZoomIn). Detected via type assertion in
// ListArea.Draw() to apply the Context style.
type ContextLine struct {
line.Line
}
type MatchIndexer interface {
// Indices return the matched portion(s) of a string after filtering.
// Note that while Indices may return nil, that just means that there are
@ -323,6 +341,10 @@ type Config struct {
// 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"`
}
type SingleKeyJumpConfig struct {
@ -356,6 +378,7 @@ type StyleSet struct {
Query Style `json:"Query" yaml:"Query"`
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
@ -471,6 +494,7 @@ type CLIOptions struct {
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"`
OptHeight string `long:"height" description:"display height in lines or percentage (e.g. '10', '50%')"`
}
type CLI struct {

View file

@ -517,6 +517,14 @@ func (l *ListArea) Draw(state *Peco, parent Layout, perPage int, options *DrawOp
written++
l.displayCache[n] = target
// Apply Context style for non-matched surrounding lines
if _, isCtx := target.(*ContextLine); isCtx {
if fgAttr == l.styles.Basic.fg && bgAttr == l.styles.Basic.bg {
fgAttr = l.styles.Context.fg
bgAttr = l.styles.Context.bg
}
}
x := -1 * loc.Column()
xOffset := loc.Column()
line := target.DisplayString()

74
peco.go
View file

@ -224,6 +224,54 @@ func (p *Peco) Source() pipeline.Source {
return p.source
}
func (p *Peco) FrozenSource() *MemoryBuffer {
p.mutex.Lock()
defer p.mutex.Unlock()
return p.frozenSource
}
func (p *Peco) SetFrozenSource(buf *MemoryBuffer) {
p.mutex.Lock()
defer p.mutex.Unlock()
p.frozenSource = buf
}
func (p *Peco) ClearFrozenSource() {
p.mutex.Lock()
defer p.mutex.Unlock()
p.frozenSource = nil
}
// PreZoomBuffer returns the saved buffer from before ZoomIn, or nil if not zoomed.
func (p *Peco) PreZoomBuffer() Buffer {
p.mutex.Lock()
defer p.mutex.Unlock()
return p.preZoomBuffer
}
// SetPreZoomState saves the current buffer and cursor position before zooming in.
func (p *Peco) SetPreZoomState(buf Buffer, lineNo int) {
p.mutex.Lock()
defer p.mutex.Unlock()
p.preZoomBuffer = buf
p.preZoomLineNo = lineNo
}
// ClearPreZoomState clears the saved zoom state.
func (p *Peco) ClearPreZoomState() {
p.mutex.Lock()
defer p.mutex.Unlock()
p.preZoomBuffer = nil
p.preZoomLineNo = 0
}
// PreZoomLineNo returns the saved cursor position from before ZoomIn.
func (p *Peco) PreZoomLineNo() int {
p.mutex.Lock()
defer p.mutex.Unlock()
return p.preZoomLineNo
}
func (p *Peco) Filters() *filter.Set {
return &p.filters
}
@ -378,6 +426,11 @@ func (p *Peco) Run(ctx context.Context) (err error) {
}
p.source = src
// If --height is specified, use InlineScreen instead of the default Termbox screen
if p.heightSpec != nil {
p.screen = NewInlineScreen(*p.heightSpec)
}
go func() {
<-p.source.Ready()
// screen.Init must be called within Run() because we
@ -611,6 +664,21 @@ func (p *Peco) ApplyConfig(opts CLIOptions) error {
}
p.fuzzyLongestSort = p.config.FuzzyLongestSort
// Height: CLI option overrides config
var heightStr string
if v := opts.OptHeight; v != "" {
heightStr = v
} else if v := p.config.Height; v != "" {
heightStr = v
}
if heightStr != "" {
spec, err := ParseHeightSpec(heightStr)
if err != nil {
return errors.Wrap(err, "failed to parse height specification")
}
p.heightSpec = &spec
}
if err := p.populateFilters(); err != nil {
return errors.Wrap(err, "failed to populate filters")
}
@ -711,7 +779,11 @@ func (p *Peco) SetCurrentLineBuffer(b Buffer) {
}
func (p *Peco) ResetCurrentLineBuffer() {
p.SetCurrentLineBuffer(p.source)
if fs := p.FrozenSource(); fs != nil {
p.SetCurrentLineBuffer(fs)
} else {
p.SetCurrentLineBuffer(p.source)
}
}
func (p *Peco) sendQuery(ctx context.Context, q string, nextFunc func()) {

View file

@ -613,6 +613,26 @@ func TestExitZero(t *testing.T) {
})
}
// runPecoSelectAll is a helper that runs peco in a goroutine and waits for
// it to complete. It uses a simple buffered channel to avoid goroutine
// scheduling races between the result send and the context cancellation.
func runPecoSelectAll(t *testing.T, p *Peco, ctx context.Context) {
t.Helper()
resultCh := make(chan error, 1)
go func() {
resultCh <- p.Run(ctx)
}()
select {
case <-ctx.Done():
t.Fatal("timeout reached")
case err := <-resultCh:
require.True(t, util.IsCollectResultsError(err), "isCollectResultsError")
p.PrintResults()
}
}
func TestSelectAll(t *testing.T) {
t.Run("Multiple lines outputs all lines", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@ -624,25 +644,7 @@ func TestSelectAll(t *testing.T) {
var out bytes.Buffer
p.Stdout = &out
resultCh := make(chan error)
go func() {
defer close(resultCh)
select {
case <-ctx.Done():
return
case resultCh <- p.Run(ctx):
return
}
}()
select {
case <-ctx.Done():
t.Errorf("timeout reached")
return
case err := <-resultCh:
require.True(t, util.IsCollectResultsError(err), "isCollectResultsError")
p.PrintResults()
}
runPecoSelectAll(t, p, ctx)
require.Equal(t, "foo\nbar\nbaz\n", out.String(), "output should match")
})
@ -657,25 +659,7 @@ func TestSelectAll(t *testing.T) {
var out bytes.Buffer
p.Stdout = &out
resultCh := make(chan error)
go func() {
defer close(resultCh)
select {
case <-ctx.Done():
return
case resultCh <- p.Run(ctx):
return
}
}()
select {
case <-ctx.Done():
t.Errorf("timeout reached")
return
case err := <-resultCh:
require.True(t, util.IsCollectResultsError(err), "isCollectResultsError")
p.PrintResults()
}
runPecoSelectAll(t, p, ctx)
require.Equal(t, "only\n", out.String(), "output should match")
})
@ -690,25 +674,7 @@ func TestSelectAll(t *testing.T) {
var out bytes.Buffer
p.Stdout = &out
resultCh := make(chan error)
go func() {
defer close(resultCh)
select {
case <-ctx.Done():
return
case resultCh <- p.Run(ctx):
return
}
}()
select {
case <-ctx.Done():
t.Errorf("timeout reached")
return
case err := <-resultCh:
require.True(t, util.IsCollectResultsError(err), "isCollectResultsError")
p.PrintResults()
}
runPecoSelectAll(t, p, ctx)
require.Empty(t, out.String(), "output should be empty")
})
@ -723,25 +689,7 @@ func TestSelectAll(t *testing.T) {
var out bytes.Buffer
p.Stdout = &out
resultCh := make(chan error)
go func() {
defer close(resultCh)
select {
case <-ctx.Done():
return
case resultCh <- p.Run(ctx):
return
}
}()
select {
case <-ctx.Done():
t.Errorf("timeout reached")
return
case err := <-resultCh:
require.True(t, util.IsCollectResultsError(err), "isCollectResultsError")
p.PrintResults()
}
runPecoSelectAll(t, p, ctx)
require.Equal(t, "test\n", out.String(), "output should have query and no matching lines")
})
@ -756,25 +704,7 @@ func TestSelectAll(t *testing.T) {
var out bytes.Buffer
p.Stdout = &out
resultCh := make(chan error)
go func() {
defer close(resultCh)
select {
case <-ctx.Done():
return
case resultCh <- p.Run(ctx):
return
}
}()
select {
case <-ctx.Done():
t.Errorf("timeout reached")
return
case err := <-resultCh:
require.True(t, util.IsCollectResultsError(err), "isCollectResultsError")
p.PrintResults()
}
runPecoSelectAll(t, p, ctx)
require.Equal(t, "foo\nfoobar\n", out.String(), "output should contain only matching lines")
})

220
screen_inline.go Normal file
View file

@ -0,0 +1,220 @@
package peco
import (
"context"
"fmt"
"os"
"sync"
"github.com/gdamore/tcell/v2"
pdebug "github.com/lestrrat-go/pdebug"
"github.com/pkg/errors"
)
// InlineScreen implements the Screen interface for rendering peco in a
// portion of the terminal without using the alternate screen buffer.
// This preserves terminal scroll history above the inline region.
type InlineScreen struct {
mutex sync.Mutex
screen tcell.Screen
heightSpec HeightSpec
height int // resolved line count
yOffset int // physical row where inline region starts
// savedAltscreen holds the original TCELL_ALTSCREEN value so we can
// restore it on Close.
savedAltscreen string
}
// NewInlineScreen creates a new InlineScreen with the given height spec.
func NewInlineScreen(spec HeightSpec) *InlineScreen {
return &InlineScreen{
heightSpec: spec,
}
}
func (s *InlineScreen) Init(cfg *Config) error {
// Save and override TCELL_ALTSCREEN to prevent alternate screen buffer
s.savedAltscreen = os.Getenv("TCELL_ALTSCREEN")
os.Setenv("TCELL_ALTSCREEN", "disable")
screen, err := tcell.NewScreen()
if err != nil {
os.Setenv("TCELL_ALTSCREEN", s.savedAltscreen)
return errors.Wrap(err, "failed to create tcell screen")
}
if err := screen.Init(); err != nil {
os.Setenv("TCELL_ALTSCREEN", s.savedAltscreen)
return errors.Wrap(err, "failed to initialize tcell screen")
}
s.mutex.Lock()
s.screen = screen
s.mutex.Unlock()
termWidth, termHeight := screen.Size()
s.height = s.heightSpec.Resolve(termHeight)
s.yOffset = termHeight - s.height
// Push existing terminal content up by writing newlines to the TTY,
// then move cursor back up to the start of our region.
if tty, ok := screen.Tty(); ok {
buf := make([]byte, s.height)
for i := range buf {
buf[i] = '\n'
}
tty.Write(buf)
// Move cursor up to the start of our inline region
fmt.Fprintf(tty, "\033[%dA", s.height)
}
// Lock the region above our area so tcell won't overwrite it
screen.LockRegion(0, 0, termWidth, s.yOffset, true)
// Clear our region
for y := 0; y < s.height; y++ {
for x := 0; x < termWidth; x++ {
screen.SetContent(x, s.yOffset+y, ' ', nil, tcell.StyleDefault)
}
}
screen.Show()
return nil
}
func (s *InlineScreen) Close() error {
if pdebug.Enabled {
pdebug.Printf("InlineScreen: Close")
}
s.mutex.Lock()
scr := s.screen
s.screen = nil
s.mutex.Unlock()
if scr != nil {
// Clear our region and position cursor at the start of it
if tty, ok := scr.Tty(); ok {
// Move cursor to the start of our inline region and clear from there down
fmt.Fprintf(tty, "\033[%d;1H", s.yOffset+1) // 1-based row
tty.Write([]byte("\033[J")) // clear from cursor to end of screen
}
scr.Fini()
}
// Restore original TCELL_ALTSCREEN
if s.savedAltscreen == "" {
os.Unsetenv("TCELL_ALTSCREEN")
} else {
os.Setenv("TCELL_ALTSCREEN", s.savedAltscreen)
}
return nil
}
func (s *InlineScreen) SetCell(x, y int, ch rune, fg, bg Attribute) {
s.mutex.Lock()
defer s.mutex.Unlock()
if s.screen == nil {
return
}
style := attributeToTcellStyle(fg, bg)
s.screen.SetContent(x, y+s.yOffset, ch, nil, style)
}
func (s *InlineScreen) SetCursor(x, y int) {
s.mutex.Lock()
defer s.mutex.Unlock()
if s.screen == nil {
return
}
s.screen.ShowCursor(x, y+s.yOffset)
}
func (s *InlineScreen) Print(args PrintArgs) int {
return screenPrint(s, args)
}
func (s *InlineScreen) Flush() error {
s.mutex.Lock()
defer s.mutex.Unlock()
if s.screen == nil {
return nil
}
s.screen.Show()
return nil
}
func (s *InlineScreen) Sync() {
s.mutex.Lock()
defer s.mutex.Unlock()
if s.screen == nil {
return
}
s.screen.Sync()
}
// Size returns the constrained dimensions (full width, inline height).
func (s *InlineScreen) Size() (int, int) {
s.mutex.Lock()
defer s.mutex.Unlock()
if s.screen == nil {
return 0, 0
}
w, _ := s.screen.Size()
return w, s.height
}
func (s *InlineScreen) PollEvent(ctx context.Context, cfg *Config) chan Event {
evCh := make(chan Event)
go func() {
defer func() { recover() }()
defer func() { close(evCh) }()
for {
s.mutex.Lock()
scr := s.screen
s.mutex.Unlock()
if scr == nil {
return
}
ev := scr.PollEvent()
if ev == nil {
return
}
// On resize, recalculate height and yOffset
if _, ok := ev.(*tcell.EventResize); ok {
s.mutex.Lock()
if s.screen != nil {
termWidth, termHeight := s.screen.Size()
s.height = s.heightSpec.Resolve(termHeight)
s.yOffset = termHeight - s.height
// Re-lock the region above
s.screen.LockRegion(0, 0, termWidth, s.yOffset, true)
}
s.mutex.Unlock()
}
pecoEv := tcellEventToEvent(ev)
select {
case <-ctx.Done():
return
case evCh <- pecoEv:
}
}
}()
return evCh
}
// SendEvent is a no-op for InlineScreen (same as Termbox).
func (s *InlineScreen) SendEvent(_ Event) {}
// Suspend is a no-op for inline mode.
func (s *InlineScreen) Suspend() {}
// Resume is a no-op for inline mode.
func (s *InlineScreen) Resume() {}

80
screen_inline_test.go Normal file
View file

@ -0,0 +1,80 @@
package peco
import (
"testing"
"github.com/gdamore/tcell/v2"
"github.com/stretchr/testify/require"
)
// newTestInlineScreen creates an InlineScreen backed by a SimulationScreen
// for testing coordinate translation and size behavior.
func newTestInlineScreen(termWidth, termHeight, inlineHeight int) (*InlineScreen, tcell.SimulationScreen) {
sim := tcell.NewSimulationScreen("")
sim.Init()
sim.SetSize(termWidth, termHeight)
s := &InlineScreen{
heightSpec: HeightSpec{Value: inlineHeight, IsPercent: false},
screen: sim,
height: inlineHeight,
yOffset: termHeight - inlineHeight,
}
return s, sim
}
func TestInlineScreenSize(t *testing.T) {
s, _ := newTestInlineScreen(80, 24, 10)
defer s.screen.Fini()
w, h := s.Size()
require.Equal(t, 80, w)
require.Equal(t, 10, h)
}
func TestInlineScreenSetCell(t *testing.T) {
s, sim := newTestInlineScreen(80, 24, 10)
defer s.screen.Fini()
// SetCell at virtual y=0 should map to physical y=14 (24-10)
s.SetCell(5, 0, 'A', ColorDefault, ColorDefault)
s.Flush()
// Read back from simulation screen at physical coordinates
str, _, _ := sim.Get(5, 14)
require.Equal(t, "A", str)
// SetCell at virtual y=9 (last line) should map to physical y=23
s.SetCell(10, 9, 'Z', ColorDefault, ColorDefault)
s.Flush()
str, _, _ = sim.Get(10, 23)
require.Equal(t, "Z", str)
}
func TestInlineScreenSetCursor(t *testing.T) {
s, sim := newTestInlineScreen(80, 24, 10)
defer s.screen.Fini()
// SetCursor at virtual (3, 2) should map to physical (3, 16)
s.SetCursor(3, 2)
s.Flush()
cx, cy, visible := sim.GetCursor()
require.True(t, visible)
require.Equal(t, 3, cx)
require.Equal(t, 16, cy) // 2 + (24-10) = 16
}
func TestInlineScreenNilSafety(t *testing.T) {
s := &InlineScreen{}
// All operations should be safe on a nil screen
w, h := s.Size()
require.Equal(t, 0, w)
require.Equal(t, 0, h)
s.SetCell(0, 0, 'X', ColorDefault, ColorDefault)
s.SetCursor(0, 0)
require.NoError(t, s.Flush())
}