mirror of
https://github.com/peco/peco.git
synced 2026-09-10 07:16:29 -04:00
Merge branch 'master' into gh-398
This commit is contained in:
commit
d8f557e108
142
README.md
142
README.md
|
|
@ -84,6 +84,60 @@ The Fuzzy filter allows you to find matches using partial patterns. For example,
|
|||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
## 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
137
action.go
|
|
@ -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)
|
||||
|
|
|
|||
387
action_test.go
387
action_test.go
|
|
@ -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
144
buffer.go
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ var expectedConfig = Config{
|
|||
fg: ColorGreen | AttrBold,
|
||||
bg: ColorDefault,
|
||||
},
|
||||
Context: Style{
|
||||
fg: ColorDefault | AttrBold,
|
||||
bg: ColorDefault,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
11
filter.go
11
filter.go
|
|
@ -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
78
height.go
Normal 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
140
height_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
5
input.go
5
input.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
24
interface.go
24
interface.go
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
74
peco.go
|
|
@ -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()) {
|
||||
|
|
|
|||
120
peco_test.go
120
peco_test.go
|
|
@ -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
220
screen_inline.go
Normal 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
80
screen_inline_test.go
Normal 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())
|
||||
}
|
||||
Loading…
Reference in a new issue