mirror of
https://github.com/junegunn/fzf
synced 2026-07-26 17:31:41 +00:00
e58c5e6bd8
Decision must not depend on threshold chain resolution (computePreviewSize consults noSeparatorLine); separator is hidden only when every spec in the chain displays a 'next' preview window with a border line facing the input section. Otherwise err on the side of showing it. - Without active previewer, preview position is resolved from first spec alone, so 'next' in a threshold alternative does not create input window and cannot hide the separator - Empty preview command is undecidable; preview(...) action can display ad-hoc window at 'next' position at any time - change-preview-window relayouts or repaints when separator visibility changes even if the active spec is unchanged, without dropping preview window forced by preview(...) action
8711 lines
242 KiB
Go
8711 lines
242 KiB
Go
package fzf
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"math"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"regexp"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/rivo/uniseg"
|
|
|
|
"github.com/junegunn/fzf/src/tui"
|
|
"github.com/junegunn/fzf/src/util"
|
|
)
|
|
|
|
// import "github.com/pkg/profile"
|
|
|
|
/*
|
|
Placeholder regex is used to extract placeholders from fzf's template
|
|
strings. Acts as input validation for parsePlaceholder function.
|
|
Describes the syntax, but it is fairly lenient.
|
|
|
|
The following pseudo regex has been reverse engineered from the
|
|
implementation. It is overly strict, but better describes what's possible.
|
|
As such it is not useful for validation, but rather to generate test
|
|
cases for example.
|
|
|
|
\\?(?: # escaped type
|
|
{\+?s?f?r?RANGE(?:,RANGE)*} # token type
|
|
{q[:s?RANGE]} # query type
|
|
|{\+?n?f?} # item type (notice no mandatory element inside brackets)
|
|
)
|
|
RANGE = (?:
|
|
(?:-?[0-9]+)?\.\.(?:-?[0-9]+)? # ellipsis syntax for token range (x..y)
|
|
|-?[0-9]+ # shorthand syntax (x..x)
|
|
)
|
|
*/
|
|
var placeholder *regexp.Regexp
|
|
var whiteSuffix *regexp.Regexp
|
|
var offsetComponentRegex *regexp.Regexp
|
|
var offsetTrimCharsRegex *regexp.Regexp
|
|
var passThroughBeginRegex *regexp.Regexp
|
|
var passThroughEndTmuxRegex *regexp.Regexp
|
|
var ttyin *os.File
|
|
|
|
const clearCode string = "\x1b[2J"
|
|
|
|
// Number of maximum focus events to process synchronously
|
|
const maxFocusEvents = 10000
|
|
|
|
// execute-silent and transform* actions will block user input for this duration.
|
|
// After this duration, users can press CTRL-C to terminate the command.
|
|
const blockDuration = 1 * time.Second
|
|
|
|
// Skip exporting FZF_CURRENT_ITEM when the item is larger than this, so a huge
|
|
// item cannot overflow ARG_MAX and break exec for preview and other commands.
|
|
const maxCurrentItemEnvSize = 64 * 1024
|
|
|
|
func init() {
|
|
placeholder = regexp.MustCompile(`\\?(?:{[+*sfr]*[0-9,-.]*}|{q(?::s?[0-9,-.]+)?}|{fzf:(?:query|action|prompt)}|{[+*]?f?nf?})`)
|
|
whiteSuffix = regexp.MustCompile(`\s*$`)
|
|
offsetComponentRegex = regexp.MustCompile(`([+-][0-9]+)|(-?/[1-9][0-9]*)`)
|
|
offsetTrimCharsRegex = regexp.MustCompile(`[^0-9/+-]`)
|
|
|
|
// Parts of the preview output that should be passed through to the terminal
|
|
// * https://github.com/tmux/tmux/wiki/FAQ#what-is-the-passthrough-escape-sequence-and-how-do-i-use-it
|
|
// * https://sw.kovidgoyal.net/kitty/graphics-protocol
|
|
// * https://en.wikipedia.org/wiki/Sixel
|
|
// * https://iterm2.com/documentation-images.html
|
|
/*
|
|
passThroughRegex = regexp.MustCompile(`
|
|
\x1bPtmux;\x1b\x1b .*? [^\x1b]\x1b\\
|
|
| \x1b(_G|P[0-9;]*q) .*? \x1b\\\r?
|
|
| \x1b]1337; .*? (\a|\x1b\\)
|
|
`)
|
|
*/
|
|
passThroughBeginRegex = regexp.MustCompile(`\x1bPtmux;\x1b\x1b|\x1b(_G|P[0-9;]*q)|\x1b]1337;`)
|
|
passThroughEndTmuxRegex = regexp.MustCompile(`[^\x1b]\x1b\\`)
|
|
}
|
|
|
|
type jumpMode int
|
|
|
|
const (
|
|
jumpDisabled jumpMode = iota
|
|
jumpEnabled
|
|
jumpAcceptEnabled
|
|
)
|
|
|
|
type resumableState int
|
|
|
|
const (
|
|
disabledState resumableState = iota
|
|
pausedState
|
|
enabledState
|
|
)
|
|
|
|
func (s resumableState) Enabled() bool {
|
|
return s == enabledState
|
|
}
|
|
|
|
func (s *resumableState) Force(flag bool) {
|
|
if flag {
|
|
*s = enabledState
|
|
} else {
|
|
*s = disabledState
|
|
}
|
|
}
|
|
|
|
func (s *resumableState) Set(flag bool) {
|
|
if *s == disabledState {
|
|
return
|
|
}
|
|
|
|
if flag {
|
|
*s = enabledState
|
|
} else {
|
|
*s = pausedState
|
|
}
|
|
}
|
|
|
|
type commandSpec struct {
|
|
command string
|
|
tempFiles []string
|
|
}
|
|
|
|
type quitSignal struct {
|
|
code int
|
|
err error
|
|
}
|
|
|
|
type waitState struct {
|
|
blocked bool
|
|
blockedAt time.Time
|
|
pending []*action
|
|
searching bool // a search is in progress or the input is still loading
|
|
}
|
|
|
|
type previewer struct {
|
|
version int64
|
|
lines []string
|
|
offset int
|
|
scrollable bool
|
|
final bool
|
|
following resumableState
|
|
spinner string
|
|
bar []bool
|
|
xw [2]int
|
|
}
|
|
|
|
type previewed struct {
|
|
version int64
|
|
numLines int
|
|
offset int
|
|
filled bool
|
|
image bool
|
|
wipe bool
|
|
wireframe bool
|
|
}
|
|
|
|
type eachLine struct {
|
|
line string
|
|
err error
|
|
}
|
|
|
|
type itemLine struct {
|
|
valid bool
|
|
firstLine int
|
|
numLines int
|
|
cy int
|
|
current bool
|
|
selected bool
|
|
label string
|
|
queryLen int
|
|
width int
|
|
hasBar bool
|
|
result Result
|
|
empty bool
|
|
other bool
|
|
hidden bool
|
|
}
|
|
|
|
func (t *Terminal) inListWindow() bool {
|
|
return t.window != t.inputWindow && t.window != t.headerWindow && t.window != t.headerLinesWindow && t.window != t.footerWindow
|
|
}
|
|
|
|
func (t *Terminal) markEmptyLine(line int) {
|
|
if t.inListWindow() {
|
|
t.prevLines[line] = itemLine{valid: true, firstLine: line, empty: true}
|
|
}
|
|
}
|
|
|
|
func (t *Terminal) markOtherLine(line int) {
|
|
if t.inListWindow() {
|
|
t.prevLines[line] = itemLine{valid: true, firstLine: line, other: true}
|
|
}
|
|
}
|
|
|
|
type fitpad struct {
|
|
fit int
|
|
pad int
|
|
}
|
|
|
|
type labelPrinter func(tui.Window, int)
|
|
|
|
type markerClass int
|
|
|
|
const (
|
|
markerSingle markerClass = iota
|
|
markerTop
|
|
markerMiddle
|
|
markerBottom
|
|
)
|
|
|
|
type StatusItem struct {
|
|
Index int `json:"index"`
|
|
Text string `json:"text"`
|
|
Positions []int `json:"positions,omitempty"`
|
|
}
|
|
|
|
type Status struct {
|
|
Reading bool `json:"reading"`
|
|
Progress int `json:"progress"`
|
|
Query string `json:"query"`
|
|
Position int `json:"position"`
|
|
Sort bool `json:"sort"`
|
|
TotalCount int `json:"totalCount"`
|
|
MatchCount int `json:"matchCount"`
|
|
Current *StatusItem `json:"current"`
|
|
Matches []StatusItem `json:"matches"`
|
|
Selected []StatusItem `json:"selected"`
|
|
}
|
|
|
|
type versionedCallback struct {
|
|
version int64
|
|
callback func()
|
|
}
|
|
|
|
type runningCmd struct {
|
|
cmd *exec.Cmd
|
|
tempFiles []string
|
|
}
|
|
|
|
// Terminal represents terminal input/output
|
|
type Terminal struct {
|
|
initDelay time.Duration
|
|
infoCommand string
|
|
infoStyle infoStyle
|
|
infoPrefix string
|
|
wrap bool
|
|
wrapWord bool
|
|
wrapSign string
|
|
wrapSignWidth int
|
|
previewWrapSign string
|
|
previewWrapSignWidth int
|
|
ghost string
|
|
separator labelPrinter
|
|
separatorLen int
|
|
separatorAuto bool
|
|
spinner []string
|
|
promptString string
|
|
prompt func()
|
|
promptLen int
|
|
borderLabel labelPrinter
|
|
borderLabelLen int
|
|
borderLabelOpts labelOpts
|
|
previewLabel labelPrinter
|
|
previewLabelLen int
|
|
previewLabelOpts labelOpts
|
|
inputLabel labelPrinter
|
|
inputLabelLen int
|
|
inputLabelOpts labelOpts
|
|
headerLabel labelPrinter
|
|
headerLabelLen int
|
|
headerLabelOpts labelOpts
|
|
footerLabel labelPrinter
|
|
footerLabelLen int
|
|
footerLabelOpts labelOpts
|
|
gutterReverse bool
|
|
gutterRawReverse bool
|
|
pointer string
|
|
pointerLen int
|
|
pointerEmpty string
|
|
pointerEmptyRaw string
|
|
marker string
|
|
markerLen int
|
|
markerEmpty string
|
|
markerMultiLine [3]string
|
|
queryLen [2]int
|
|
layout layoutType
|
|
fullscreen bool
|
|
keepRight bool
|
|
hscroll bool
|
|
hscrollOff int
|
|
scrollOff int
|
|
gap int
|
|
gapLine labelPrinter
|
|
gapLineLen int
|
|
wordRubout string
|
|
wordNext string
|
|
subWordRubout string
|
|
subWordNext string
|
|
cx int
|
|
cy int
|
|
offset int
|
|
xoffset int
|
|
yanked []rune
|
|
input []rune
|
|
inputOverride *[]rune
|
|
pasting *[]rune
|
|
multi int
|
|
multiLine bool
|
|
sort bool
|
|
toggleSort bool
|
|
track trackOption
|
|
idNth []Range
|
|
trackKey string
|
|
trackBlocked bool
|
|
trackSync bool
|
|
trackKeyCache map[int32]bool
|
|
wait waitState
|
|
pendingSelections map[string]selectedItem
|
|
targetIndex int32
|
|
delimiter Delimiter
|
|
expect map[tui.Event]string
|
|
keymap map[tui.Event][]*action
|
|
keymapOrg map[tui.Event][]*action
|
|
pressed string
|
|
printQueue []string
|
|
printQuery bool
|
|
history *History
|
|
cycle bool
|
|
highlightLine bool
|
|
headerVisible bool
|
|
headerFirst bool
|
|
headerLines int
|
|
header []Item
|
|
header0 []string
|
|
footer []string
|
|
ellipsis string
|
|
scrollbar string
|
|
previewScrollbar string
|
|
ansi bool
|
|
freezeLeft int
|
|
freezeRight int
|
|
nthAttr tui.Attr
|
|
nth []Range
|
|
nthCurrent []Range
|
|
withNthDefault string
|
|
withNthExpr string
|
|
withNthEnabled bool
|
|
acceptNth func([]Token, int32) string
|
|
tabstop int
|
|
margin [4]sizeSpec
|
|
padding [4]sizeSpec
|
|
unicode bool
|
|
listenAddr *listenAddress
|
|
listenPort *int
|
|
listener net.Listener
|
|
listenUnsafe bool
|
|
borderShape tui.BorderShape
|
|
listBorderShape tui.BorderShape
|
|
inputBorderShape tui.BorderShape
|
|
headerBorderShape tui.BorderShape
|
|
headerLinesShape tui.BorderShape
|
|
footerBorderShape tui.BorderShape
|
|
listLabel labelPrinter
|
|
listLabelLen int
|
|
listLabelOpts labelOpts
|
|
cleanExit bool
|
|
executor *util.Executor
|
|
paused bool
|
|
inputless bool
|
|
border tui.Window
|
|
window tui.Window
|
|
inputWindow tui.Window
|
|
inputBorder tui.Window
|
|
headerWindow tui.Window
|
|
headerBorder tui.Window
|
|
headerLinesWindow tui.Window
|
|
headerLinesBorder tui.Window
|
|
footerWindow tui.Window
|
|
footerBorder tui.Window
|
|
wborder tui.Window
|
|
pborder tui.Window
|
|
pwindow tui.Window
|
|
borderWidth int
|
|
count int
|
|
progress int
|
|
hasStartActions bool
|
|
hasResultActions bool
|
|
hasFocusActions bool
|
|
hasLoadActions bool
|
|
hasResizeActions bool
|
|
triggerLoad bool
|
|
pendingReqList bool
|
|
filterSelection bool
|
|
reading bool
|
|
running *util.AtomicBool
|
|
failed *string
|
|
jumping jumpMode
|
|
jumpLabels string
|
|
printer func(string)
|
|
printsep string
|
|
merger *Merger
|
|
passMerger *Merger
|
|
resultMerger *Merger
|
|
matchMap map[int32]Result
|
|
selected map[int32]selectedItem
|
|
version int64
|
|
revision revision
|
|
bgVersion int64
|
|
runningCmds *util.ConcurrentSet[*runningCmd]
|
|
reqBox *util.EventBox
|
|
initialPreviewOpts previewOpts
|
|
previewOpts previewOpts
|
|
activePreviewOpts *previewOpts
|
|
previewer previewer
|
|
previewed previewed
|
|
previewBox *util.EventBox
|
|
eventBox *util.EventBox
|
|
mutex sync.Mutex
|
|
uiMutex sync.Mutex
|
|
initFunc func() error
|
|
prevLines []itemLine
|
|
suppress bool
|
|
startChan chan fitpad
|
|
killChan chan bool
|
|
killedChan chan bool
|
|
serverInputChan chan []*action
|
|
callbackChan chan versionedCallback
|
|
bgQueue map[action][]func(bool)
|
|
bgSemaphore chan struct{}
|
|
bgSemaphores map[action]chan struct{}
|
|
keyChan chan tui.Event
|
|
eventChan chan tui.Event
|
|
timerChan chan tui.Event
|
|
slab *util.Slab
|
|
theme *tui.ColorTheme
|
|
tui tui.Renderer
|
|
ttyDefault string
|
|
ttyin *os.File
|
|
executing *util.AtomicBool
|
|
termSize tui.TermSize
|
|
lastAction actionType
|
|
lastKey string
|
|
lastFocus int32
|
|
areaLines int
|
|
areaColumns int
|
|
forcePreview bool
|
|
clickHeaderLine int
|
|
clickHeaderColumn int
|
|
clickFooterLine int
|
|
clickFooterColumn int
|
|
proxyScript string
|
|
numLinesCache map[int32]numLinesCacheValue
|
|
raw bool
|
|
lastActivity time.Time
|
|
}
|
|
|
|
type numLinesCacheValue struct {
|
|
atMost int
|
|
numLines int
|
|
}
|
|
|
|
type selectedItem struct {
|
|
at time.Time
|
|
item *Item
|
|
}
|
|
|
|
type byTimeOrder []selectedItem
|
|
|
|
func (a byTimeOrder) Len() int {
|
|
return len(a)
|
|
}
|
|
|
|
func (a byTimeOrder) Swap(i, j int) {
|
|
a[i], a[j] = a[j], a[i]
|
|
}
|
|
|
|
func (a byTimeOrder) Less(i, j int) bool {
|
|
return a[i].at.Before(a[j].at)
|
|
}
|
|
|
|
// EventTypes are listed in the order of their priority.
|
|
const (
|
|
reqResize util.EventType = iota
|
|
reqReinit
|
|
reqFullRedraw
|
|
reqRedraw
|
|
|
|
reqJump
|
|
reqPrompt
|
|
reqInfo
|
|
reqHeader
|
|
reqFooter
|
|
reqList
|
|
reqRedrawInputLabel
|
|
reqRedrawHeaderLabel
|
|
reqRedrawFooterLabel
|
|
reqRedrawListLabel
|
|
reqRedrawBorderLabel
|
|
reqRedrawPreviewLabel
|
|
|
|
reqPreviewReady
|
|
reqPreviewEnqueue
|
|
reqPreviewDisplay
|
|
reqPreviewRefresh
|
|
reqPreviewDelayed
|
|
|
|
reqActivate
|
|
reqClose
|
|
reqPrintQuery
|
|
reqBecome
|
|
reqQuit
|
|
reqFatal
|
|
)
|
|
|
|
func isTerminalEvent(et util.EventType) bool {
|
|
switch et {
|
|
case reqClose, reqPrintQuery, reqBecome, reqQuit, reqFatal:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
type action struct {
|
|
t actionType
|
|
a string
|
|
}
|
|
|
|
//go:generate stringer -type=actionType
|
|
type actionType int
|
|
|
|
const (
|
|
actIgnore actionType = iota
|
|
actStart
|
|
actClick
|
|
actInvalid
|
|
actBracketedPasteBegin
|
|
actBracketedPasteEnd
|
|
actChar
|
|
actMouse
|
|
actBeginningOfLine
|
|
actAbort
|
|
actAccept
|
|
actAcceptNonEmpty
|
|
actAcceptOrPrintQuery
|
|
actBackwardChar
|
|
actBackwardDeleteChar
|
|
actBackwardDeleteCharEof
|
|
actBackwardWord
|
|
actBackwardSubWord
|
|
actCancel
|
|
|
|
actChangeBorderLabel
|
|
actChangeGhost
|
|
actChangeHeader
|
|
actChangeHeaderLines
|
|
actChangeFooter
|
|
actChangeHeaderLabel
|
|
actChangeFooterLabel
|
|
actChangeInputLabel
|
|
actChangeListLabel
|
|
actChangeMulti
|
|
actChangeNth
|
|
actChangeWithNth
|
|
actChangePointer
|
|
actChangePreview
|
|
actChangePreviewLabel
|
|
actChangePreviewWindow
|
|
actChangePrompt
|
|
actChangeQuery
|
|
|
|
actClearScreen
|
|
actClearQuery
|
|
actClearSelection
|
|
actClose
|
|
actDeleteChar
|
|
actDeleteCharEof
|
|
actEndOfLine
|
|
actFatal
|
|
actForwardChar
|
|
actForwardWord
|
|
actForwardSubWord
|
|
actKillLine
|
|
actKillWord
|
|
actKillSubWord
|
|
actUnixLineDiscard
|
|
actUnixWordRubout
|
|
actYank
|
|
actBackwardKillWord
|
|
actBackwardKillSubWord
|
|
actSelectAll
|
|
actDeselectAll
|
|
actToggle
|
|
actToggleSearch
|
|
actToggleAll
|
|
actToggleDown
|
|
actToggleUp
|
|
actToggleIn
|
|
actToggleOut
|
|
actToggleTrack
|
|
actToggleTrackCurrent
|
|
actToggleHeader
|
|
actToggleWrap
|
|
actToggleWrapWord
|
|
actToggleMultiLine
|
|
actToggleHscroll
|
|
actToggleRaw
|
|
actEnableRaw
|
|
actDisableRaw
|
|
actTrackCurrent
|
|
actToggleInput
|
|
actHideInput
|
|
actShowInput
|
|
actUntrackCurrent
|
|
actDown
|
|
actDownMatch
|
|
actUp
|
|
actUpMatch
|
|
actPageUp
|
|
actPageDown
|
|
actPosition
|
|
actHalfPageUp
|
|
actHalfPageDown
|
|
actOffsetUp
|
|
actOffsetDown
|
|
actOffsetMiddle
|
|
actJump
|
|
actJumpAccept // XXX Deprecated in favor of jump:accept binding
|
|
actPrintQuery // XXX Deprecated (not very useful, just use --print-query)
|
|
actRefreshPreview
|
|
actReplaceQuery
|
|
actToggleSort
|
|
actShowPreview
|
|
actHidePreview
|
|
actTogglePreview
|
|
actTogglePreviewWrap
|
|
actTogglePreviewWrapWord
|
|
|
|
actTransform
|
|
actTransformBorderLabel
|
|
actTransformGhost
|
|
actTransformHeader
|
|
actTransformHeaderLines
|
|
actTransformFooter
|
|
actTransformHeaderLabel
|
|
actTransformFooterLabel
|
|
actTransformInputLabel
|
|
actTransformListLabel
|
|
actTransformNth
|
|
actTransformWithNth
|
|
actTransformPointer
|
|
actTransformPreviewLabel
|
|
actTransformPrompt
|
|
actTransformQuery
|
|
actTransformSearch
|
|
|
|
actTrigger
|
|
|
|
actBgTransform
|
|
actBgTransformBorderLabel
|
|
actBgTransformGhost
|
|
actBgTransformHeader
|
|
actBgTransformHeaderLines
|
|
actBgTransformFooter
|
|
actBgTransformHeaderLabel
|
|
actBgTransformFooterLabel
|
|
actBgTransformInputLabel
|
|
actBgTransformListLabel
|
|
actBgTransformNth
|
|
actBgTransformWithNth
|
|
actBgTransformPointer
|
|
actBgTransformPreviewLabel
|
|
actBgTransformPrompt
|
|
actBgTransformQuery
|
|
actBgTransformSearch
|
|
|
|
actBgCancel
|
|
|
|
actSearch
|
|
actPreview
|
|
actPreviewTop
|
|
actPreviewBottom
|
|
actPreviewUp
|
|
actPreviewDown
|
|
actPreviewPageUp
|
|
actPreviewPageDown
|
|
actPreviewHalfPageUp
|
|
actPreviewHalfPageDown
|
|
actPrevHistory
|
|
actPrevSelected
|
|
actPrint
|
|
actPut
|
|
actNextHistory
|
|
actNextSelected
|
|
actExecute
|
|
actExecuteSilent
|
|
actExecuteMulti // Deprecated
|
|
actSigStop
|
|
actBest
|
|
actFirst
|
|
actLast
|
|
actReload
|
|
actReloadSync
|
|
actDisableSearch
|
|
actEnableSearch
|
|
actSelect
|
|
actDeselect
|
|
actUnbind
|
|
actRebind
|
|
actToggleBind
|
|
actBecome
|
|
actShowHeader
|
|
actHideHeader
|
|
actBell
|
|
actExclude
|
|
actExcludeMulti
|
|
actAsync
|
|
actWait
|
|
)
|
|
|
|
func (a actionType) Name() string {
|
|
return util.ToKebabCase(a.String()[3:])
|
|
}
|
|
|
|
func processExecution(action actionType) bool {
|
|
switch action {
|
|
case actTransform,
|
|
actTransformBorderLabel,
|
|
actTransformGhost,
|
|
actTransformHeader,
|
|
actTransformHeaderLines,
|
|
actTransformFooter,
|
|
actTransformHeaderLabel,
|
|
actTransformFooterLabel,
|
|
actTransformInputLabel,
|
|
actTransformListLabel,
|
|
actTransformNth,
|
|
actTransformWithNth,
|
|
actTransformPointer,
|
|
actTransformPreviewLabel,
|
|
actTransformPrompt,
|
|
actTransformQuery,
|
|
actTransformSearch,
|
|
actBgTransform,
|
|
actBgTransformBorderLabel,
|
|
actBgTransformGhost,
|
|
actBgTransformHeader,
|
|
actBgTransformHeaderLines,
|
|
actBgTransformFooter,
|
|
actBgTransformHeaderLabel,
|
|
actBgTransformFooterLabel,
|
|
actBgTransformInputLabel,
|
|
actBgTransformListLabel,
|
|
actBgTransformNth,
|
|
actBgTransformWithNth,
|
|
actBgTransformPointer,
|
|
actBgTransformPreviewLabel,
|
|
actBgTransformPrompt,
|
|
actBgTransformQuery,
|
|
actBgTransformSearch,
|
|
actPreview,
|
|
actChangePreview,
|
|
actRefreshPreview,
|
|
actExecute,
|
|
actExecuteSilent,
|
|
actExecuteMulti,
|
|
actReload,
|
|
actReloadSync,
|
|
actBecome:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
type placeholderFlags struct {
|
|
plus bool
|
|
asterisk bool
|
|
preserveSpace bool
|
|
number bool
|
|
forceUpdate bool
|
|
file bool
|
|
raw bool
|
|
}
|
|
|
|
type withNthSpec struct {
|
|
fn func([]Token, int32) string // nil = clear (restore original)
|
|
}
|
|
|
|
type searchRequest struct {
|
|
sort bool
|
|
sync bool
|
|
nth *[]Range
|
|
withNth *withNthSpec
|
|
headerLines *int
|
|
command *commandSpec
|
|
environ []string
|
|
changed bool
|
|
denylist []int32
|
|
revision revision
|
|
}
|
|
|
|
type previewRequest struct {
|
|
template string
|
|
scrollOffset int
|
|
list [3][]*Item // current, select, and all matched items
|
|
env []string
|
|
query string
|
|
}
|
|
|
|
type previewResult struct {
|
|
version int64
|
|
lines []string
|
|
offset int
|
|
spinner string
|
|
}
|
|
|
|
func toActions(types ...actionType) []*action {
|
|
actions := make([]*action, len(types))
|
|
for idx, t := range types {
|
|
actions[idx] = &action{t: t, a: ""}
|
|
}
|
|
return actions
|
|
}
|
|
|
|
func defaultKeymap() map[tui.Event][]*action {
|
|
keymap := make(map[tui.Event][]*action)
|
|
add := func(e tui.EventType, a actionType) {
|
|
keymap[e.AsEvent()] = toActions(a)
|
|
}
|
|
addEvent := func(e tui.Event, a actionType) {
|
|
keymap[e] = toActions(a)
|
|
}
|
|
|
|
add(tui.Fatal, actFatal)
|
|
add(tui.Invalid, actInvalid)
|
|
add(tui.BracketedPasteBegin, actBracketedPasteBegin)
|
|
add(tui.BracketedPasteEnd, actBracketedPasteEnd)
|
|
add(tui.CtrlA, actBeginningOfLine)
|
|
add(tui.CtrlB, actBackwardChar)
|
|
add(tui.CtrlC, actAbort)
|
|
add(tui.CtrlG, actAbort)
|
|
add(tui.CtrlQ, actAbort)
|
|
add(tui.Esc, actAbort)
|
|
add(tui.CtrlD, actDeleteCharEof)
|
|
add(tui.CtrlE, actEndOfLine)
|
|
add(tui.CtrlF, actForwardChar)
|
|
add(tui.Backspace, actBackwardDeleteChar)
|
|
add(tui.CtrlBackspace, actBackwardDeleteChar)
|
|
add(tui.Tab, actToggleDown)
|
|
add(tui.ShiftTab, actToggleUp)
|
|
add(tui.CtrlJ, actDown)
|
|
add(tui.CtrlK, actUp)
|
|
add(tui.CtrlL, actClearScreen)
|
|
add(tui.Enter, actAccept)
|
|
add(tui.CtrlN, actDownMatch)
|
|
add(tui.CtrlP, actUpMatch)
|
|
add(tui.AltDown, actDownMatch)
|
|
add(tui.AltUp, actUpMatch)
|
|
add(tui.CtrlU, actUnixLineDiscard)
|
|
add(tui.CtrlW, actUnixWordRubout)
|
|
add(tui.CtrlY, actYank)
|
|
if !util.IsWindows() {
|
|
add(tui.CtrlZ, actSigStop)
|
|
}
|
|
add(tui.CtrlSlash, actToggleWrapWord)
|
|
addEvent(tui.AltKey('/'), actToggleWrapWord)
|
|
|
|
addEvent(tui.AltKey('b'), actBackwardWord)
|
|
add(tui.ShiftLeft, actBackwardWord)
|
|
add(tui.AltLeft, actBackwardWord)
|
|
addEvent(tui.AltKey('f'), actForwardWord)
|
|
add(tui.ShiftRight, actForwardWord)
|
|
add(tui.AltRight, actForwardWord)
|
|
addEvent(tui.AltKey('d'), actKillWord)
|
|
add(tui.AltBackspace, actBackwardKillWord)
|
|
|
|
add(tui.Up, actUp)
|
|
add(tui.Down, actDown)
|
|
add(tui.Left, actBackwardChar)
|
|
add(tui.Right, actForwardChar)
|
|
|
|
add(tui.Home, actBeginningOfLine)
|
|
add(tui.End, actEndOfLine)
|
|
add(tui.Delete, actDeleteChar)
|
|
add(tui.PageUp, actPageUp)
|
|
add(tui.PageDown, actPageDown)
|
|
|
|
add(tui.ShiftUp, actPreviewUp)
|
|
add(tui.ShiftDown, actPreviewDown)
|
|
|
|
add(tui.Mouse, actMouse)
|
|
add(tui.LeftClick, actClick)
|
|
add(tui.RightClick, actToggle)
|
|
add(tui.SLeftClick, actToggle)
|
|
add(tui.SRightClick, actToggle)
|
|
|
|
add(tui.ScrollUp, actUp)
|
|
add(tui.ScrollDown, actDown)
|
|
keymap[tui.SScrollUp.AsEvent()] = toActions(actToggle, actUp)
|
|
keymap[tui.SScrollDown.AsEvent()] = toActions(actToggle, actDown)
|
|
|
|
add(tui.PreviewScrollUp, actPreviewUp)
|
|
add(tui.PreviewScrollDown, actPreviewDown)
|
|
return keymap
|
|
}
|
|
|
|
func trimQuery(query string) []rune {
|
|
return []rune(strings.ReplaceAll(query, "\t", " "))
|
|
}
|
|
|
|
func mayTriggerPreview(opts *Options) bool {
|
|
if opts.ListenAddr != nil {
|
|
return true
|
|
}
|
|
for _, actions := range opts.Keymap {
|
|
for _, action := range actions {
|
|
switch action.t {
|
|
case actPreview, actChangePreview, actTransform, actBgTransform:
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func makeSpinner(unicode bool) []string {
|
|
if unicode {
|
|
return []string{`⠋`, `⠙`, `⠹`, `⠸`, `⠼`, `⠴`, `⠦`, `⠧`, `⠇`, `⠏`}
|
|
}
|
|
return []string{`-`, `\`, `|`, `/`, `-`, `\`, `|`, `/`}
|
|
}
|
|
|
|
func evaluateHeight(opts *Options, termHeight int) int {
|
|
size := opts.Height.size
|
|
if opts.Height.percent {
|
|
if opts.Height.inverse {
|
|
size = 100 - size
|
|
}
|
|
return max(int(size*float64(termHeight)/100.0), opts.MinHeight)
|
|
}
|
|
if opts.Height.inverse {
|
|
size = float64(termHeight) - size
|
|
}
|
|
return int(size)
|
|
}
|
|
|
|
// NewTerminal returns new Terminal object
|
|
func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor) (*Terminal, error) {
|
|
input := trimQuery(opts.Query)
|
|
var delay time.Duration
|
|
if opts.Sync {
|
|
delay = 0
|
|
} else if opts.Tac {
|
|
delay = initialDelayTac
|
|
} else {
|
|
delay = initialDelay
|
|
}
|
|
var previewBox *util.EventBox
|
|
// We need to start the previewer even when --preview option is not specified
|
|
// * if HTTP server is enabled
|
|
// * if 'preview' or 'change-preview' action is bound to a key
|
|
// * if 'transform' action is bound to a key
|
|
if len(opts.Preview.command) > 0 || mayTriggerPreview(opts) {
|
|
previewBox = util.NewEventBox()
|
|
}
|
|
var renderer tui.Renderer
|
|
fullscreen := !opts.Height.auto && (opts.Height.size == 0 || opts.Height.percent && opts.Height.size == 100)
|
|
var err error
|
|
// Reuse ttyin if available to avoid having multiple file descriptors open
|
|
// when you run fzf multiple times in your Go program. Closing it is known to
|
|
// cause problems with 'become' action and invalid terminal state after exit.
|
|
if ttyin == nil {
|
|
if ttyin, err = tui.TtyIn(opts.TtyDefault); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if fullscreen {
|
|
if tui.HasFullscreenRenderer() {
|
|
renderer = tui.NewFullscreenRenderer(opts.Theme, opts.Black, opts.Mouse, opts.Tabstop)
|
|
} else {
|
|
renderer, err = tui.NewLightRenderer(opts.TtyDefault, ttyin, opts.Theme, opts.Black, opts.Mouse, opts.Tabstop, opts.ClearOnExit,
|
|
true, func(h int) int { return h })
|
|
}
|
|
} else {
|
|
maxHeightFunc := func(termHeight int) int {
|
|
// Minimum height required to render fzf excluding margin and padding
|
|
effectiveMinHeight := minHeight
|
|
if previewBox != nil && opts.Preview.aboveOrBelow() {
|
|
effectiveMinHeight += 1 + borderLines(opts.Preview.Border(opts.Layout))
|
|
}
|
|
if opts.noSeparatorLine() {
|
|
effectiveMinHeight--
|
|
}
|
|
effectiveMinHeight += borderLines(opts.BorderShape)
|
|
return min(termHeight, max(evaluateHeight(opts, termHeight), effectiveMinHeight))
|
|
}
|
|
renderer, err = tui.NewLightRenderer(opts.TtyDefault, ttyin, opts.Theme, opts.Black, opts.Mouse, opts.Tabstop, opts.ClearOnExit, false, maxHeightFunc)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if opts.Inputless {
|
|
renderer.HideCursor()
|
|
}
|
|
wordRubout := "[^\\pL\\pN][\\pL\\pN]"
|
|
wordNext := "[\\pL\\pN][^\\pL\\pN]|(.$)"
|
|
subWordRubout := "[a-z][A-Z]|[^\\pL\\pN][\\pL\\pN]"
|
|
subWordNext := "[a-z][A-Z]|[\\pL\\pN][^\\pL\\pN]|(.$)"
|
|
if opts.FileWord {
|
|
sep := regexp.QuoteMeta(string(os.PathSeparator))
|
|
wordRubout = fmt.Sprintf("%s[^%s]", sep, sep)
|
|
wordNext = fmt.Sprintf("[^%s]%s|(.$)", sep, sep)
|
|
}
|
|
keymapCopy := maps.Clone(opts.Keymap)
|
|
|
|
em := EmptyMerger(revision{})
|
|
t := Terminal{
|
|
initDelay: delay,
|
|
infoCommand: opts.InfoCommand,
|
|
infoStyle: opts.InfoStyle,
|
|
infoPrefix: opts.InfoPrefix,
|
|
ghost: opts.Ghost,
|
|
separator: nil,
|
|
spinner: makeSpinner(opts.Unicode),
|
|
promptString: opts.Prompt,
|
|
queryLen: [2]int{0, 0},
|
|
layout: opts.Layout,
|
|
fullscreen: fullscreen,
|
|
keepRight: opts.KeepRight,
|
|
hscroll: opts.Hscroll,
|
|
hscrollOff: opts.HscrollOff,
|
|
scrollOff: opts.ScrollOff,
|
|
pointer: *opts.Pointer,
|
|
pointerLen: uniseg.StringWidth(*opts.Pointer),
|
|
marker: *opts.Marker,
|
|
markerLen: uniseg.StringWidth(*opts.Marker),
|
|
markerMultiLine: *opts.MarkerMulti,
|
|
wordRubout: wordRubout,
|
|
wordNext: wordNext,
|
|
subWordRubout: subWordRubout,
|
|
subWordNext: subWordNext,
|
|
cx: len(input),
|
|
cy: 0,
|
|
offset: 0,
|
|
xoffset: 0,
|
|
yanked: []rune{},
|
|
input: input,
|
|
multi: opts.Multi,
|
|
multiLine: opts.ReadZero && opts.MultiLine,
|
|
wrap: opts.Wrap,
|
|
wrapWord: opts.WrapWord,
|
|
sort: opts.Sort > 0,
|
|
toggleSort: opts.ToggleSort,
|
|
track: opts.Track,
|
|
idNth: opts.IdNth,
|
|
targetIndex: minItem.Index(),
|
|
delimiter: opts.Delimiter,
|
|
expect: opts.Expect,
|
|
keymap: opts.Keymap,
|
|
keymapOrg: keymapCopy,
|
|
pressed: "",
|
|
printQuery: opts.PrintQuery,
|
|
history: opts.History,
|
|
margin: opts.Margin,
|
|
padding: opts.Padding,
|
|
unicode: opts.Unicode,
|
|
listenAddr: opts.ListenAddr,
|
|
listenUnsafe: opts.Unsafe,
|
|
borderShape: opts.BorderShape,
|
|
listBorderShape: opts.ListBorderShape,
|
|
inputBorderShape: opts.InputBorderShape,
|
|
headerBorderShape: opts.HeaderBorderShape,
|
|
headerLinesShape: opts.HeaderLinesShape,
|
|
footerBorderShape: opts.FooterBorderShape,
|
|
borderWidth: 1,
|
|
listLabel: nil,
|
|
listLabelOpts: opts.ListLabel,
|
|
borderLabel: nil,
|
|
borderLabelOpts: opts.BorderLabel,
|
|
previewLabel: nil,
|
|
previewLabelOpts: opts.PreviewLabel,
|
|
inputLabel: nil,
|
|
inputLabelOpts: opts.InputLabel,
|
|
headerLabel: nil,
|
|
headerLabelOpts: opts.HeaderLabel,
|
|
footerLabel: nil,
|
|
footerLabelOpts: opts.FooterLabel,
|
|
cleanExit: opts.ClearOnExit,
|
|
executor: executor,
|
|
paused: opts.Phony,
|
|
inputless: opts.Inputless,
|
|
cycle: opts.Cycle,
|
|
highlightLine: opts.CursorLine,
|
|
headerVisible: true,
|
|
headerFirst: opts.HeaderFirst,
|
|
headerLines: opts.HeaderLines,
|
|
gap: opts.Gap,
|
|
header: []Item{},
|
|
footer: opts.Footer,
|
|
header0: opts.Header,
|
|
ansi: opts.Ansi,
|
|
freezeLeft: opts.FreezeLeft,
|
|
freezeRight: opts.FreezeRight,
|
|
nthAttr: opts.Theme.Nth.Attr,
|
|
nth: opts.Nth,
|
|
nthCurrent: opts.Nth,
|
|
withNthDefault: opts.WithNthExpr,
|
|
withNthExpr: opts.WithNthExpr,
|
|
withNthEnabled: opts.WithNth != nil,
|
|
tabstop: opts.Tabstop,
|
|
raw: opts.Raw,
|
|
hasStartActions: false,
|
|
hasResultActions: false,
|
|
hasFocusActions: false,
|
|
hasLoadActions: false,
|
|
triggerLoad: false,
|
|
reading: true,
|
|
running: util.NewAtomicBool(true),
|
|
failed: nil,
|
|
jumping: jumpDisabled,
|
|
jumpLabels: opts.JumpLabels,
|
|
printer: opts.Printer,
|
|
printsep: opts.PrintSep,
|
|
proxyScript: opts.ProxyScript,
|
|
merger: em,
|
|
passMerger: em,
|
|
resultMerger: em,
|
|
matchMap: make(map[int32]Result),
|
|
selected: make(map[int32]selectedItem),
|
|
runningCmds: util.NewConcurrentSet[*runningCmd](),
|
|
reqBox: util.NewEventBox(),
|
|
initialPreviewOpts: opts.Preview,
|
|
previewOpts: opts.Preview,
|
|
activePreviewOpts: &opts.Preview,
|
|
previewer: previewer{0, []string{}, 0, false, true, disabledState, "", []bool{}, [2]int{0, 0}},
|
|
previewed: previewed{0, 0, 0, false, false, false, false},
|
|
previewBox: previewBox,
|
|
eventBox: eventBox,
|
|
mutex: sync.Mutex{},
|
|
uiMutex: sync.Mutex{},
|
|
suppress: true,
|
|
slab: util.MakeSlab(slab16Size, slab32Size),
|
|
theme: opts.Theme,
|
|
startChan: make(chan fitpad, 1),
|
|
killChan: make(chan bool),
|
|
killedChan: make(chan bool),
|
|
serverInputChan: make(chan []*action, 100),
|
|
callbackChan: make(chan versionedCallback, maxBgProcesses),
|
|
bgQueue: make(map[action][]func(bool)),
|
|
bgSemaphore: make(chan struct{}, maxBgProcesses),
|
|
bgSemaphores: make(map[action]chan struct{}),
|
|
keyChan: make(chan tui.Event),
|
|
eventChan: make(chan tui.Event, 7), // start | (load + result + result-final + zero|one) | (focus) | (resize)
|
|
timerChan: make(chan tui.Event), // unbuffered: every() ticks coalesce when main loop is busy
|
|
tui: renderer,
|
|
ttyDefault: opts.TtyDefault,
|
|
ttyin: ttyin,
|
|
initFunc: func() error { return renderer.Init() },
|
|
executing: util.NewAtomicBool(false),
|
|
lastAction: actStart,
|
|
lastFocus: minItem.Index(),
|
|
lastActivity: time.Now(),
|
|
numLinesCache: make(map[int32]numLinesCacheValue),
|
|
// The initial load counts as a search in progress ('start:wait').
|
|
// Set before the reader starts so the first final result clears it.
|
|
wait: waitState{searching: true}}
|
|
if opts.AcceptNth != nil {
|
|
t.acceptNth = opts.AcceptNth(t.delimiter)
|
|
}
|
|
|
|
baseTheme := opts.BaseTheme
|
|
if baseTheme == nil {
|
|
baseTheme = renderer.DefaultTheme()
|
|
}
|
|
// If the list border can't host an inline separator, the runtime later coerces
|
|
// BorderInline to BorderLine. Mirror that here so theme color inheritance matches
|
|
// the final rendering rather than the user's requested shape.
|
|
inlineListSupported := opts.ListBorderShape.HasTop() && opts.ListBorderShape.HasBottom()
|
|
headerInline := inlineListSupported && (opts.HeaderBorderShape == tui.BorderInline || opts.HeaderLinesShape == tui.BorderInline)
|
|
footerInline := inlineListSupported && opts.FooterBorderShape == tui.BorderInline
|
|
hasHeader := opts.HeaderBorderShape.Visible() || opts.HeaderLinesShape.Visible()
|
|
// This should be called before accessing tui.Color*
|
|
tui.InitTheme(opts.Theme, baseTheme, opts.Bold, opts.Black, opts.InputBorderShape.Visible(), hasHeader, headerInline, footerInline)
|
|
|
|
// Gutter character
|
|
var gutterChar, gutterRawChar string
|
|
if opts.Gutter != nil {
|
|
gutterChar = *opts.Gutter
|
|
} else if t.unicode {
|
|
gutterChar = "▌"
|
|
} else {
|
|
gutterChar = " "
|
|
t.gutterReverse = true
|
|
}
|
|
|
|
if opts.GutterRaw != nil {
|
|
gutterRawChar = *opts.GutterRaw
|
|
} else if t.unicode {
|
|
gutterRawChar = "▖"
|
|
} else {
|
|
gutterRawChar = ":"
|
|
t.gutterRawReverse = false
|
|
}
|
|
|
|
t.prompt, t.promptLen = t.parsePrompt(opts.Prompt)
|
|
// Pre-calculated empty pointer and marker signs
|
|
if t.pointerLen == 0 {
|
|
t.pointerEmpty = ""
|
|
t.pointerEmptyRaw = ""
|
|
} else {
|
|
t.pointerEmpty = gutterChar + strings.Repeat(" ", max(0, t.pointerLen-1))
|
|
t.pointerEmptyRaw = gutterRawChar + strings.Repeat(" ", max(0, t.pointerLen-1))
|
|
}
|
|
t.markerEmpty = strings.Repeat(" ", t.markerLen)
|
|
|
|
// Labels
|
|
t.listLabel, t.listLabelLen = t.ansiLabelPrinter(opts.ListLabel.label, &tui.ColListLabel, false)
|
|
t.borderLabel, t.borderLabelLen = t.ansiLabelPrinter(opts.BorderLabel.label, &tui.ColBorderLabel, false)
|
|
t.previewLabel, t.previewLabelLen = t.ansiLabelPrinter(opts.PreviewLabel.label, &tui.ColPreviewLabel, false)
|
|
t.inputLabel, t.inputLabelLen = t.ansiLabelPrinter(opts.InputLabel.label, &tui.ColInputLabel, false)
|
|
t.headerLabel, t.headerLabelLen = t.ansiLabelPrinter(opts.HeaderLabel.label, &tui.ColHeaderLabel, false)
|
|
t.footerLabel, t.footerLabelLen = t.ansiLabelPrinter(opts.FooterLabel.label, &tui.ColFooterLabel, false)
|
|
|
|
// Determine border shape
|
|
if t.borderShape == tui.BorderLine {
|
|
if t.fullscreen {
|
|
t.borderShape = tui.BorderNone
|
|
} else {
|
|
t.borderShape = tui.BorderTop
|
|
}
|
|
}
|
|
|
|
// Determine input border shape
|
|
t.inputBorderShape = resolveInputBorderShape(t.layout, t.inputBorderShape)
|
|
|
|
// Inline borders are embedded between the list's top and bottom horizontals.
|
|
// Shapes missing either one (none/phantom/line/single-sided) fall back to a plain
|
|
// horizontal separator (same as BorderLine).
|
|
inlineSupported := t.listBorderShape.HasTop() && t.listBorderShape.HasBottom()
|
|
if !inlineSupported {
|
|
if t.headerBorderShape == tui.BorderInline {
|
|
t.headerBorderShape = tui.BorderLine
|
|
}
|
|
if t.headerLinesShape == tui.BorderInline {
|
|
t.headerLinesShape = tui.BorderLine
|
|
}
|
|
if t.footerBorderShape == tui.BorderInline {
|
|
t.footerBorderShape = tui.BorderLine
|
|
}
|
|
}
|
|
|
|
// Determine header border shape
|
|
if t.headerBorderShape == tui.BorderLine {
|
|
if t.layout == layoutReverse {
|
|
t.headerBorderShape = tui.BorderBottom
|
|
} else {
|
|
t.headerBorderShape = tui.BorderTop
|
|
}
|
|
}
|
|
|
|
// Determine header lines border shape
|
|
if t.headerLinesShape == tui.BorderLine {
|
|
if t.layout == layoutDefault {
|
|
t.headerLinesShape = tui.BorderTop
|
|
} else {
|
|
t.headerLinesShape = tui.BorderBottom
|
|
}
|
|
}
|
|
|
|
// Determine footer border shape
|
|
if t.footerBorderShape == tui.BorderLine {
|
|
if t.layout == layoutReverse {
|
|
t.footerBorderShape = tui.BorderTop
|
|
} else {
|
|
t.footerBorderShape = tui.BorderBottom
|
|
}
|
|
}
|
|
|
|
// The default separator can be suppressed at runtime depending on the
|
|
// window layout (see separatorLength)
|
|
t.separatorAuto = opts.Separator == nil
|
|
bar := "─"
|
|
if opts.Separator != nil {
|
|
bar = *opts.Separator
|
|
} else if !t.unicode {
|
|
bar = "-"
|
|
}
|
|
if len(bar) > 0 {
|
|
t.separator, t.separatorLen = t.ansiLabelPrinter(bar, &tui.ColSeparator, true)
|
|
}
|
|
|
|
// Gap line
|
|
if t.gap > 0 && len(*opts.GapLine) > 0 {
|
|
t.gapLine, t.gapLineLen = t.ansiLabelPrinter(*opts.GapLine, &tui.ColGapLine, true)
|
|
}
|
|
|
|
if opts.Ellipsis != nil {
|
|
t.ellipsis = *opts.Ellipsis
|
|
} else if t.unicode {
|
|
t.ellipsis = "··"
|
|
} else {
|
|
t.ellipsis = ".."
|
|
}
|
|
|
|
if t.unicode {
|
|
t.wrapSign = "↳ "
|
|
t.borderWidth = uniseg.StringWidth("│")
|
|
} else {
|
|
t.wrapSign = "> "
|
|
}
|
|
if opts.WrapSign != nil {
|
|
t.wrapSign = *opts.WrapSign
|
|
}
|
|
t.wrapSign, t.wrapSignWidth = t.processTabsStr(t.wrapSign, 0)
|
|
t.previewWrapSign = t.wrapSign
|
|
t.previewWrapSignWidth = t.wrapSignWidth
|
|
if opts.PreviewWrapSign != nil {
|
|
t.previewWrapSign, t.previewWrapSignWidth = t.processTabsStr(*opts.PreviewWrapSign, 0)
|
|
}
|
|
if opts.Scrollbar == nil {
|
|
if t.unicode && t.borderWidth == 1 {
|
|
t.scrollbar = "│"
|
|
} else {
|
|
t.scrollbar = "|"
|
|
}
|
|
t.previewScrollbar = t.scrollbar
|
|
} else {
|
|
runes := []rune(*opts.Scrollbar)
|
|
if len(runes) > 0 {
|
|
t.scrollbar = string(runes[0])
|
|
t.previewScrollbar = t.scrollbar
|
|
if len(runes) > 1 {
|
|
t.previewScrollbar = string(runes[1])
|
|
}
|
|
}
|
|
}
|
|
|
|
var resizeActions []*action
|
|
resizeActions, t.hasResizeActions = t.keymap[tui.Resize.AsEvent()]
|
|
if t.tui.ShouldEmitResizeEvent() {
|
|
t.keymap[tui.Resize.AsEvent()] = append(toActions(actClearScreen), resizeActions...)
|
|
}
|
|
_, t.hasStartActions = t.keymap[tui.Start.AsEvent()]
|
|
_, t.hasResultActions = t.keymap[tui.Result.AsEvent()]
|
|
if _, prs := t.keymap[tui.ResultFinal.AsEvent()]; prs {
|
|
t.hasResultActions = true
|
|
}
|
|
_, t.hasFocusActions = t.keymap[tui.Focus.AsEvent()]
|
|
_, t.hasLoadActions = t.keymap[tui.Load.AsEvent()]
|
|
|
|
if t.listenAddr != nil {
|
|
listener, port, err := startHttpServer(*t.listenAddr, t.serverInputChan, t.dumpStatus)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.listener = listener
|
|
if port > 0 {
|
|
t.listenPort = &port
|
|
}
|
|
}
|
|
|
|
if t.hasStartActions {
|
|
t.eventChan <- tui.Start.AsEvent()
|
|
}
|
|
|
|
return &t, nil
|
|
}
|
|
|
|
func (t *Terminal) deferActivation() bool {
|
|
return t.initDelay == 0 && (t.hasStartActions || t.hasLoadActions || t.hasResultActions || t.hasFocusActions)
|
|
}
|
|
|
|
func (t *Terminal) environ() []string {
|
|
return t.environImpl(false)
|
|
}
|
|
|
|
func (t *Terminal) environForPreview() []string {
|
|
return t.environImpl(true)
|
|
}
|
|
|
|
func (t *Terminal) environImpl(forPreview bool) []string {
|
|
env := os.Environ()
|
|
if t.listenAddr != nil && len(t.listenAddr.sock) > 0 {
|
|
env = append(env, "FZF_SOCK="+t.listenAddr.sock)
|
|
}
|
|
if t.listenPort != nil {
|
|
env = append(env, fmt.Sprintf("FZF_PORT=%d", *t.listenPort))
|
|
}
|
|
env = append(env, "FZF_QUERY="+string(t.input))
|
|
env = append(env, "FZF_ACTION="+t.lastAction.Name())
|
|
env = append(env, "FZF_KEY="+t.lastKey)
|
|
idleMs := time.Since(t.lastActivity).Milliseconds()
|
|
env = append(env, fmt.Sprintf("FZF_IDLE_TIME=%d", idleMs/1000))
|
|
env = append(env, fmt.Sprintf("FZF_IDLE_TIME_MS=%d", idleMs))
|
|
env = append(env, "FZF_PROMPT="+string(t.promptString))
|
|
env = append(env, "FZF_GHOST="+string(t.ghost))
|
|
env = append(env, "FZF_POINTER="+string(t.pointer))
|
|
env = append(env, "FZF_PREVIEW_LABEL="+t.previewLabelOpts.label)
|
|
env = append(env, "FZF_BORDER_LABEL="+t.borderLabelOpts.label)
|
|
env = append(env, "FZF_LIST_LABEL="+t.listLabelOpts.label)
|
|
env = append(env, "FZF_INPUT_LABEL="+t.inputLabelOpts.label)
|
|
env = append(env, "FZF_HEADER_LABEL="+t.headerLabelOpts.label)
|
|
direction := "down"
|
|
if t.layout == layoutDefault {
|
|
direction = "up"
|
|
}
|
|
env = append(env, "FZF_DIRECTION="+direction)
|
|
if len(t.nthCurrent) > 0 {
|
|
env = append(env, "FZF_NTH="+RangesToString(t.nthCurrent))
|
|
}
|
|
if len(t.withNthExpr) > 0 {
|
|
env = append(env, "FZF_WITH_NTH="+t.withNthExpr)
|
|
}
|
|
if t.raw {
|
|
val := "0"
|
|
if t.isCurrentItemMatch() {
|
|
val = "1"
|
|
}
|
|
env = append(env, "FZF_RAW="+val)
|
|
}
|
|
inputState := "enabled"
|
|
if t.inputless {
|
|
inputState = "hidden"
|
|
} else if t.paused {
|
|
inputState = "disabled"
|
|
}
|
|
if t.wrap {
|
|
if t.wrapWord {
|
|
env = append(env, "FZF_WRAP=word")
|
|
} else {
|
|
env = append(env, "FZF_WRAP=char")
|
|
}
|
|
}
|
|
env = append(env, "FZF_INPUT_STATE="+inputState)
|
|
env = append(env, fmt.Sprintf("FZF_TOTAL_COUNT=%d", t.count))
|
|
env = append(env, fmt.Sprintf("FZF_MATCH_COUNT=%d", t.resultMerger.Length()))
|
|
env = append(env, fmt.Sprintf("FZF_SELECT_COUNT=%d", len(t.selected)))
|
|
env = append(env, fmt.Sprintf("FZF_LINES=%d", t.areaLines))
|
|
env = append(env, fmt.Sprintf("FZF_COLUMNS=%d", t.areaColumns))
|
|
env = append(env, fmt.Sprintf("FZF_POS=%d", min(t.merger.Length(), t.cy+1)))
|
|
if item := t.currentItem(); item != nil {
|
|
// Skip if the value contains a NUL byte (exec(2) would reject the env)
|
|
// or is too large (a huge item can overflow ARG_MAX and break exec
|
|
// entirely for preview and other child commands).
|
|
if s := item.AsString(t.ansi); !strings.ContainsRune(s, 0) && len(s) <= maxCurrentItemEnvSize {
|
|
env = append(env, "FZF_CURRENT_ITEM="+s)
|
|
}
|
|
}
|
|
env = append(env, fmt.Sprintf("FZF_CLICK_HEADER_LINE=%d", t.clickHeaderLine))
|
|
env = append(env, fmt.Sprintf("FZF_CLICK_HEADER_COLUMN=%d", t.clickHeaderColumn))
|
|
env = append(env, fmt.Sprintf("FZF_CLICK_FOOTER_LINE=%d", t.clickFooterLine))
|
|
env = append(env, fmt.Sprintf("FZF_CLICK_FOOTER_COLUMN=%d", t.clickFooterColumn))
|
|
env = t.addClickHeaderWord(env)
|
|
env = t.addClickFooterWord(env)
|
|
|
|
// Add preview environment variables if preview is enabled
|
|
pwindowSize := t.pwindowSize()
|
|
if pwindowSize.Lines > 0 {
|
|
lines := fmt.Sprintf("LINES=%d", pwindowSize.Lines)
|
|
columns := fmt.Sprintf("COLUMNS=%d", pwindowSize.Columns)
|
|
if forPreview {
|
|
env = append(env, lines)
|
|
env = append(env, columns)
|
|
}
|
|
env = append(env, "FZF_PREVIEW_"+lines)
|
|
env = append(env, "FZF_PREVIEW_"+columns)
|
|
env = append(env, fmt.Sprintf("FZF_PREVIEW_TOP=%d", t.tui.Top()+t.pwindow.Top()))
|
|
env = append(env, fmt.Sprintf("FZF_PREVIEW_LEFT=%d", t.pwindow.Left()))
|
|
}
|
|
|
|
return env
|
|
}
|
|
|
|
func borderLines(shape tui.BorderShape) int {
|
|
lines := 0
|
|
if shape.HasTop() {
|
|
lines++
|
|
}
|
|
if shape.HasBottom() {
|
|
lines++
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func borderColumns(shape tui.BorderShape, borderWidth int) int {
|
|
columns := 0
|
|
if shape.HasLeft() {
|
|
columns += 1 + borderWidth
|
|
}
|
|
if shape.HasRight() {
|
|
columns += 1 + borderWidth
|
|
}
|
|
return columns
|
|
}
|
|
|
|
func (t *Terminal) visibleHeaderLines() int {
|
|
if !t.headerVisible {
|
|
return 0
|
|
}
|
|
return len(t.header0) + t.headerLines
|
|
}
|
|
|
|
func (t *Terminal) visibleHeaderLinesInList() int {
|
|
if t.headerWindow != nil || t.headerLinesWindow != nil {
|
|
return 0
|
|
}
|
|
return t.visibleHeaderLines()
|
|
}
|
|
|
|
func (t *Terminal) visibleInputLinesInList() int {
|
|
if t.inputWindow != nil || t.inputless {
|
|
return 0
|
|
}
|
|
if t.noSeparatorLine() {
|
|
return 1
|
|
}
|
|
return 2
|
|
}
|
|
|
|
// Extra number of lines needed to display fzf
|
|
func (t *Terminal) extraLines() int {
|
|
extra := 0
|
|
if !t.inputless {
|
|
extra++
|
|
if !t.noSeparatorLine() {
|
|
extra++
|
|
}
|
|
if t.inputBorderShape.Visible() {
|
|
extra += borderLines(t.inputBorderShape)
|
|
}
|
|
}
|
|
if t.listBorderShape.Visible() {
|
|
extra += borderLines(t.listBorderShape)
|
|
}
|
|
if t.headerVisible {
|
|
if t.hasHeaderWindow() {
|
|
extra += borderLines(t.headerBorderShape)
|
|
}
|
|
extra += len(t.header0)
|
|
if w, shape := t.determineHeaderLinesShape(); w {
|
|
extra += borderLines(shape)
|
|
}
|
|
extra += t.headerLines
|
|
}
|
|
if len(t.footer) > 0 {
|
|
extra += borderLines(t.footerBorderShape)
|
|
extra += len(t.footer)
|
|
}
|
|
return extra
|
|
}
|
|
|
|
func (t *Terminal) MaxFitAndPad() (int, int) {
|
|
_, screenHeight, marginInt, paddingInt := t.adjustMarginAndPadding()
|
|
padHeight := marginInt[0] + marginInt[2] + paddingInt[0] + paddingInt[2]
|
|
fit := screenHeight - padHeight - t.extraLines()
|
|
return fit, padHeight
|
|
}
|
|
|
|
func (t *Terminal) ansiLabelPrinter(str string, color *tui.ColorPair, fill bool) (labelPrinter, int) {
|
|
// Nothing to do
|
|
if len(str) == 0 {
|
|
return nil, 0
|
|
}
|
|
|
|
// Extract ANSI color codes
|
|
str = firstLine(str)
|
|
text, colors, _ := extractColor(str, nil, nil)
|
|
runes := []rune(text)
|
|
|
|
// Simpler printer for strings without ANSI colors or tab characters
|
|
if colors == nil && !strings.ContainsRune(text, '\t') {
|
|
length := util.StringWidth(text)
|
|
if length == 0 {
|
|
return nil, 0
|
|
}
|
|
printFn := func(window tui.Window, limit int) {
|
|
ellipsis := []rune{}
|
|
ellipsisWidth := 0
|
|
if !fill {
|
|
ellipsis, ellipsisWidth = util.Truncate(t.ellipsis, limit)
|
|
}
|
|
if length > limit {
|
|
trimmedRunes, _ := t.trimRight(runes, limit-ellipsisWidth)
|
|
window.CPrint(*color, string(trimmedRunes)+string(ellipsis))
|
|
} else if fill {
|
|
window.CPrint(*color, util.RepeatToFill(text, length, limit))
|
|
} else {
|
|
window.CPrint(*color, text)
|
|
}
|
|
}
|
|
return printFn, length
|
|
}
|
|
|
|
// Printer that correctly handles ANSI color codes and tab characters
|
|
item := &Item{text: util.RunesToChars(runes), colors: colors}
|
|
length := t.displayWidth(runes)
|
|
if length == 0 {
|
|
return nil, 0
|
|
}
|
|
result := Result{item: item}
|
|
var offsets []colorOffset
|
|
printFn := func(window tui.Window, limit int) {
|
|
if offsets == nil {
|
|
// tui.Col* are not initialized until renderer.Init()
|
|
offsets = result.colorOffsets(nil, nil, t.theme, *color, *color, t.nthAttr, 0, false)
|
|
}
|
|
for limit > 0 {
|
|
if length > limit {
|
|
trimmedRunes, _ := t.trimRight(runes, limit)
|
|
t.printColoredString(window, trimmedRunes, offsets, *color)
|
|
break
|
|
} else if fill {
|
|
t.printColoredString(window, runes, offsets, *color)
|
|
limit -= length
|
|
} else {
|
|
t.printColoredString(window, runes, offsets, *color)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return printFn, length
|
|
}
|
|
|
|
// Temporarily switch 'window' so that we can use the existing windows with
|
|
// a different window
|
|
func (t *Terminal) withWindow(w tui.Window, f func()) {
|
|
prevWindow := t.window
|
|
if w != nil {
|
|
t.window = w
|
|
}
|
|
f()
|
|
t.window = prevWindow
|
|
}
|
|
|
|
func (t *Terminal) parsePrompt(prompt string) (func(), int) {
|
|
var state *ansiState
|
|
prompt = firstLine(prompt)
|
|
trimmed, colors, _ := extractColor(prompt, state, nil)
|
|
item := &Item{text: util.ToChars([]byte(trimmed)), colors: colors}
|
|
|
|
// "Prompt> "
|
|
// ------- // Do not apply ANSI attributes to the trailing whitespaces
|
|
// // unless the part has a non-default ANSI state
|
|
loc := whiteSuffix.FindStringIndex(trimmed)
|
|
if loc != nil {
|
|
blankState := ansiOffset{[2]int32{int32(loc[0]), int32(loc[1])}, ansiState{tui.ColPrompt.Fg(), tui.ColPrompt.Bg(), -1, tui.AttrClear, -1, nil}}
|
|
if item.colors != nil {
|
|
lastColor := (*item.colors)[len(*item.colors)-1]
|
|
if lastColor.offset[1] < int32(loc[1]) {
|
|
blankState.offset[0] = lastColor.offset[1]
|
|
colors := append(*item.colors, blankState)
|
|
item.colors = &colors
|
|
}
|
|
} else {
|
|
colors := []ansiOffset{blankState}
|
|
item.colors = &colors
|
|
}
|
|
}
|
|
output := func() {
|
|
wrap := t.wrap
|
|
t.wrap = false
|
|
t.withWindow(t.inputWindow, func() {
|
|
preTask := func(markerClass) int {
|
|
return 1
|
|
}
|
|
t.printHighlighted(
|
|
Result{item: item}, tui.ColPrompt, tui.ColPrompt, false, false, false, 0, 0, true, preTask, nil, 0)
|
|
})
|
|
t.wrap = wrap
|
|
}
|
|
_, promptLen := t.processTabsStr(trimmed, 0)
|
|
|
|
return output, promptLen
|
|
}
|
|
|
|
func (t *Terminal) noSeparatorLine() bool {
|
|
return t.inputless || noSeparatorLine(t.infoStyle, t.separatorLength() > 0)
|
|
}
|
|
|
|
// Effective length of the horizontal separator. The default separator is
|
|
// suppressed when the input section is already visually separated from the
|
|
// list section by a border line.
|
|
func (t *Terminal) separatorLength() int {
|
|
if t.separatorAuto && t.separatedByBorder() {
|
|
return 0
|
|
}
|
|
return t.separatorLen
|
|
}
|
|
|
|
// BorderLine for the input border resolves to a single line on the side
|
|
// facing the list section
|
|
func resolveInputBorderShape(layout layoutType, shape tui.BorderShape) tui.BorderShape {
|
|
if shape != tui.BorderLine {
|
|
return shape
|
|
}
|
|
if layout == layoutReverse {
|
|
return tui.BorderBottom
|
|
}
|
|
return tui.BorderTop
|
|
}
|
|
|
|
// Whether the input border draws a line on the side facing the list section
|
|
func inputBorderFacesList(layout layoutType, shape tui.BorderShape) bool {
|
|
shape = resolveInputBorderShape(layout, shape)
|
|
if layout == layoutReverse {
|
|
return shape.HasBottom()
|
|
}
|
|
return shape.HasTop()
|
|
}
|
|
|
|
// Whether a border line is already drawn between the info line and the
|
|
// section next to it on the list side, making the default horizontal
|
|
// separator redundant
|
|
func (t *Terminal) separatedByBorder() bool {
|
|
// The input border itself draws a line on the side facing the list section
|
|
if inputBorderFacesList(t.layout, t.inputBorderShape) {
|
|
return true
|
|
}
|
|
// A preview window at 'next' position sits right next to the input
|
|
// section, in front of the sections handled below
|
|
anyNext := false
|
|
for po := &t.previewOpts; po != nil; po = po.alternative {
|
|
if po.position == posNext {
|
|
anyNext = true
|
|
break
|
|
}
|
|
}
|
|
if anyNext && t.hasPreviewer() {
|
|
// The separator is hidden only when every spec in the threshold chain
|
|
// displays a preview window at 'next' position with a border line
|
|
// facing the input section. We cannot single out the active spec;
|
|
// which one is active depends on the terminal size, and the
|
|
// resolution (computePreviewSize in resizeWindows) itself consults
|
|
// noSeparatorLine. When not every spec qualifies, err on the side of
|
|
// showing the separator.
|
|
if len(t.previewOpts.command) == 0 {
|
|
// No preview command, so no preview window is normally shown, but
|
|
// the preview(...) action can still display one at any time
|
|
return false
|
|
}
|
|
// NOTE: resizeWindows can clear 'hidden' mid-layout when the
|
|
// preview(...) action forces the window, so the frame being laid out
|
|
// can briefly disagree with this decision until the next relayout
|
|
for po := &t.previewOpts; po != nil; po = po.alternative {
|
|
if po.position != posNext || po.hidden || po.size.size == 0 || !t.borderFacingInput(po.Border(t.layout)) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
hasHeaderLinesWindow, headerLinesShape := t.determineHeaderLinesShape()
|
|
hasHeaderWindow := t.hasHeaderWindow()
|
|
// Mirror of hasInputWindow in resizeWindows; a 'next' preview position
|
|
// gives the input section its own window even when no preview window is
|
|
// shown. A 'next' spec here implies there is no previewer (the block
|
|
// above returns otherwise), and without a previewer the position is
|
|
// resolved from the first spec alone, so a 'next' in a threshold
|
|
// alternative does not count.
|
|
hasInputWindow := t.previewOpts.position == posNext || t.inputBorderShape.Visible() || hasHeaderWindow || hasHeaderLinesWindow
|
|
if !hasInputWindow {
|
|
// The input section is embedded in the list window and no border
|
|
// separates the two
|
|
return false
|
|
}
|
|
|
|
// The input section has its own window. Determine which section it is
|
|
// facing, and check if the border of that section draws a line toward it.
|
|
// NOTE: hasHeaderWindow can be true with no header content when the input
|
|
// border is visible (see Terminal.hasHeaderWindow). An empty header window
|
|
// takes no space, so it cannot be the facing section.
|
|
hasHeaderSection := hasHeaderWindow && t.visibleHeaderLines() > 0
|
|
if hasHeaderSection && !t.headerFirst && t.headerBorderShape != tui.BorderInline {
|
|
// Header section is facing the input section. An inline header is
|
|
// excluded; it is drawn inside the list border and is covered by the
|
|
// branches below.
|
|
return t.borderFacingInput(t.headerBorderShape)
|
|
}
|
|
// Header lines section is facing the input section, except in
|
|
// reverse-list layout where it is on the other side of the list section,
|
|
// or with --header-first and no header section where it is moved past
|
|
// the input section
|
|
if hasHeaderLinesWindow && t.layout != layoutReverseList && (hasHeaderWindow || !t.headerFirst) {
|
|
return t.borderFacingInput(headerLinesShape)
|
|
}
|
|
// The input section sits right next to the list section
|
|
return t.borderFacingInput(t.listBorderShape)
|
|
}
|
|
|
|
// Whether the border of the section next to the input section on the list
|
|
// side draws a horizontal line facing the info line
|
|
func (t *Terminal) borderFacingInput(shape tui.BorderShape) bool {
|
|
if shape == tui.BorderInline {
|
|
// Embedded between the horizontal lines of the list border
|
|
return true
|
|
}
|
|
if t.layout == layoutReverse {
|
|
return shape.HasTop()
|
|
}
|
|
return shape.HasBottom()
|
|
}
|
|
|
|
func getScrollbar(perLine int, total int, height int, offset int) (int, int) {
|
|
if total == 0 || total*perLine <= height {
|
|
return 0, 0
|
|
}
|
|
barLength := max(1, height*height/(total*perLine))
|
|
var barStart int
|
|
if total == height {
|
|
barStart = 0
|
|
} else {
|
|
barStart = min(height-barLength, (height*perLine-barLength)*offset/(total*perLine-height))
|
|
}
|
|
return barLength, barStart
|
|
}
|
|
|
|
func (t *Terminal) barCol() int {
|
|
if len(t.scrollbar) == 0 && !t.listBorderShape.HasRight() && !t.borderShape.HasRight() && !t.hasPreviewWindowOnRight() {
|
|
return 0
|
|
}
|
|
return 1
|
|
}
|
|
|
|
func (t *Terminal) wrapCols() int {
|
|
if !t.wrap {
|
|
return 0 // No wrap
|
|
}
|
|
return max(t.window.Width()-(t.pointerLen+t.markerLen+t.barCol()), 1)
|
|
}
|
|
|
|
func (t *Terminal) clearNumLinesCache() {
|
|
t.numLinesCache = make(map[int32]numLinesCacheValue)
|
|
}
|
|
|
|
// Number of lines the item takes including the gap
|
|
func (t *Terminal) numItemLines(item *Item, atMost int) (int, bool) {
|
|
var numLines int
|
|
if !t.wrap && !t.multiLine {
|
|
numLines = 1 + t.gap
|
|
return numLines, numLines > atMost
|
|
}
|
|
if cached, prs := t.numLinesCache[item.Index()]; prs {
|
|
// Can we use this cache? Let's be conservative.
|
|
if cached.atMost <= atMost {
|
|
return cached.numLines, false
|
|
}
|
|
}
|
|
var overflow bool
|
|
if !t.wrap && t.multiLine {
|
|
numLines, overflow = item.text.NumLines(atMost)
|
|
} else {
|
|
var lines [][]rune
|
|
lines, overflow = item.text.Lines(t.multiLine, atMost, t.wrapCols(), t.wrapSignWidth, t.tabstop, t.wrapWord)
|
|
numLines = len(lines)
|
|
}
|
|
numLines += t.gap
|
|
if !overflow {
|
|
t.numLinesCache[item.Index()] = numLinesCacheValue{atMost, numLines}
|
|
}
|
|
return numLines, overflow || numLines > atMost
|
|
}
|
|
|
|
func (t *Terminal) itemLines(item *Item, atMost int) ([][]rune, bool) {
|
|
if !t.wrap && !t.multiLine {
|
|
text := make([]rune, item.text.Length())
|
|
copy(text, item.text.ToRunes())
|
|
return [][]rune{text}, false
|
|
}
|
|
return item.text.Lines(t.multiLine, atMost, t.wrapCols(), t.wrapSignWidth, t.tabstop, t.wrapWord)
|
|
}
|
|
|
|
// Estimate the average number of lines per item. Instead of going through all
|
|
// items, we only check a few items around the current cursor position.
|
|
func (t *Terminal) avgNumLines() int {
|
|
if !t.wrap && !t.multiLine {
|
|
return 1
|
|
}
|
|
|
|
maxItems := t.maxItems()
|
|
numLines := 0
|
|
count := 0
|
|
total := t.merger.Length()
|
|
offset := max(0, min(t.offset, total-maxItems-1))
|
|
for idx := 0; idx < maxItems && idx+offset < total; idx++ {
|
|
result := t.merger.Get(idx + offset)
|
|
lines, _ := t.numItemLines(result.item, maxItems)
|
|
numLines += lines
|
|
count++
|
|
}
|
|
if count == 0 {
|
|
return 1
|
|
}
|
|
return numLines / count
|
|
}
|
|
|
|
func (t *Terminal) getScrollbar() (int, int) {
|
|
return getScrollbar(t.avgNumLines(), t.merger.Length(), t.maxItems(), t.offset)
|
|
}
|
|
|
|
// Input returns current query string
|
|
func (t *Terminal) Input() (bool, []rune) {
|
|
t.mutex.Lock()
|
|
defer t.mutex.Unlock()
|
|
paused := t.paused
|
|
src := t.input
|
|
if t.inputOverride != nil {
|
|
paused = false
|
|
src = *t.inputOverride
|
|
}
|
|
return paused, copySlice(src)
|
|
}
|
|
|
|
// PauseRendering blocks the terminal from reading items.
|
|
// Must be paired with ResumeRendering.
|
|
func (t *Terminal) PauseRendering() {
|
|
t.mutex.Lock()
|
|
}
|
|
|
|
// ResumeRendering releases the lock acquired by PauseRendering.
|
|
func (t *Terminal) ResumeRendering() {
|
|
t.mutex.Unlock()
|
|
}
|
|
|
|
// UpdateCount updates the count information
|
|
func (t *Terminal) UpdateCount(cnt int, final bool, failedCommand *string) {
|
|
t.mutex.Lock()
|
|
t.count = cnt
|
|
if t.hasLoadActions && t.reading && final {
|
|
t.triggerLoad = true
|
|
}
|
|
t.reading = !final
|
|
t.failed = failedCommand
|
|
suppressed := t.suppress
|
|
t.mutex.Unlock()
|
|
t.reqBox.Set(reqInfo, nil)
|
|
|
|
// We want to defer activating the interface when --sync is used and any of
|
|
// start, load, or result events are bound
|
|
if suppressed && final && !t.deferActivation() {
|
|
t.reqBox.Set(reqActivate, nil)
|
|
}
|
|
}
|
|
|
|
func (t *Terminal) changeHeader(header string) bool {
|
|
var lines []string
|
|
if len(header) > 0 {
|
|
lines = strings.Split(strings.TrimSuffix(header, "\n"), "\n")
|
|
}
|
|
needFullRedraw := len(t.header0) != len(lines)
|
|
t.header0 = lines
|
|
t.clickHeaderLine = 0
|
|
t.clickHeaderColumn = 0
|
|
return needFullRedraw
|
|
}
|
|
|
|
func (t *Terminal) changeFooter(footer string) bool {
|
|
var lines []string
|
|
if len(footer) > 0 {
|
|
lines = strings.Split(strings.TrimSuffix(footer, "\n"), "\n")
|
|
}
|
|
needFullRedraw := len(t.footer) != len(lines)
|
|
t.footer = lines
|
|
t.clickFooterLine = 0
|
|
t.clickFooterColumn = 0
|
|
return needFullRedraw
|
|
}
|
|
|
|
// UpdateHeader updates the header
|
|
func (t *Terminal) UpdateHeader(header []Item) {
|
|
t.mutex.Lock()
|
|
// Pad to t.headerLines so that click coordinate mapping works correctly
|
|
if len(header) < t.headerLines {
|
|
padded := make([]Item, t.headerLines)
|
|
copy(padded, header)
|
|
header = padded
|
|
}
|
|
t.header = header
|
|
t.mutex.Unlock()
|
|
t.reqBox.Set(reqHeader, nil)
|
|
}
|
|
|
|
// UpdateProgress updates the search progress
|
|
func (t *Terminal) UpdateProgress(progress float32) {
|
|
t.mutex.Lock()
|
|
newProgress := int(progress * 100)
|
|
changed := t.progress != newProgress
|
|
t.progress = newProgress
|
|
t.mutex.Unlock()
|
|
|
|
if changed {
|
|
t.reqBox.Set(reqInfo, nil)
|
|
}
|
|
}
|
|
|
|
// UpdateList updates Merger to display the list
|
|
func (t *Terminal) UpdateList(result MatchResult) {
|
|
merger := result.merger
|
|
t.mutex.Lock()
|
|
waitWasBlocked := t.wait.blocked
|
|
wakeUp := false
|
|
if result.final() {
|
|
t.wait.searching = false
|
|
// If waiting, unblock so main loop can execute pending actions.
|
|
// Note: any final result unblocks the wait, not just the one for the
|
|
// search that armed it. Back-to-back searches (--listen, bg-transform
|
|
// callbacks, reload+wait) can therefore unblock early and run the
|
|
// pending actions on the previous result set. Accepted; a per-search
|
|
// generation token through the matcher isn't worth the complexity.
|
|
if t.wait.blocked {
|
|
t.unblockWait()
|
|
wakeUp = len(t.wait.pending) > 0
|
|
}
|
|
}
|
|
prevIndex := minItem.Index()
|
|
newRevision := merger.Revision()
|
|
if t.revision.compatible(newRevision) && t.track != trackDisabled {
|
|
if t.merger.Length() > 0 {
|
|
prevIndex = t.currentIndex()
|
|
} else if merger.Length() > 0 {
|
|
prevIndex = merger.First().item.Index()
|
|
}
|
|
}
|
|
if t.targetIndex != minItem.Index() {
|
|
prevIndex = t.targetIndex
|
|
t.targetIndex = minItem.Index()
|
|
}
|
|
t.progress = 100
|
|
t.merger = merger
|
|
t.resultMerger = merger
|
|
t.passMerger = result.passMerger
|
|
if t.raw {
|
|
t.merger = result.passMerger
|
|
t.matchMap = t.resultMerger.ToMap()
|
|
} else {
|
|
t.merger = result.merger
|
|
t.matchMap = make(map[int32]Result)
|
|
}
|
|
if t.revision != newRevision {
|
|
if !t.revision.compatible(newRevision) {
|
|
// Reloaded: capture selection keys for restoration, then clear (reload-sync only)
|
|
if t.trackSync && len(t.idNth) > 0 && t.multi > 0 && len(t.selected) > 0 {
|
|
t.pendingSelections = make(map[string]selectedItem, len(t.selected))
|
|
for _, sel := range t.selected {
|
|
key := t.trackKeyFor(sel.item, t.idNth)
|
|
t.pendingSelections[key] = sel
|
|
}
|
|
}
|
|
t.selected = make(map[int32]selectedItem)
|
|
t.clearNumLinesCache()
|
|
} else {
|
|
// Trimmed by --tail: filter selection by index
|
|
filtered := make(map[int32]selectedItem)
|
|
minIndex := merger.minIndex
|
|
maxIndex := merger.maxIndex
|
|
for k, v := range t.selected {
|
|
var included bool
|
|
if maxIndex > minIndex {
|
|
included = k >= minIndex && k < maxIndex
|
|
} else if maxIndex < minIndex { // int32 overflow [==> <==]
|
|
included = k >= minIndex || k < maxIndex
|
|
}
|
|
if included {
|
|
filtered[k] = v
|
|
}
|
|
}
|
|
t.selected = filtered
|
|
}
|
|
t.revision = newRevision
|
|
t.version++
|
|
|
|
// Filter out selections that no longer match after with-nth change.
|
|
// Must be inside the revision check so we don't consume the flag
|
|
// on a stale EvtSearchFin from a previous search.
|
|
if t.filterSelection && t.multi > 0 && len(t.selected) > 0 {
|
|
matchMap := t.resultMerger.ToMap()
|
|
filtered := make(map[int32]selectedItem)
|
|
for k, v := range t.selected {
|
|
if _, matched := matchMap[k]; matched {
|
|
filtered[k] = v
|
|
}
|
|
}
|
|
t.selected = filtered
|
|
}
|
|
t.filterSelection = false
|
|
}
|
|
if t.triggerLoad {
|
|
t.triggerLoad = false
|
|
t.pendingReqList = true
|
|
t.eventChan <- tui.Load.AsEvent()
|
|
}
|
|
// Search for the tracked item by nth key
|
|
// - reload (async): search eagerly, unblock as soon as match is found
|
|
// - reload-sync: wait until stream is complete before searching
|
|
trackWasBlocked := t.trackBlocked
|
|
if len(t.trackKey) > 0 && (!t.trackSync || !t.reading) {
|
|
found := false
|
|
for i := 0; i < t.merger.Length(); i++ {
|
|
item := t.merger.Get(i).item
|
|
idx := item.Index()
|
|
match, ok := t.trackKeyCache[idx]
|
|
if !ok {
|
|
match = t.trackKeyFor(item, t.idNth) == t.trackKey
|
|
t.trackKeyCache[idx] = match
|
|
}
|
|
if match {
|
|
t.cy = i
|
|
if t.track.Current() {
|
|
t.track.index = idx
|
|
}
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if found || !t.reading {
|
|
t.unblockTrack()
|
|
}
|
|
} else if prevIndex >= 0 {
|
|
pos := t.cy - t.offset
|
|
count := t.merger.Length()
|
|
i := t.merger.FindIndex(prevIndex)
|
|
if i >= 0 {
|
|
t.cy = i
|
|
t.offset = t.cy - pos
|
|
} else if t.track.Current() {
|
|
t.track = trackDisabled
|
|
t.cy = pos
|
|
t.offset = 0
|
|
} else if t.cy > count {
|
|
// Try to keep the vertical position when the list shrinks
|
|
t.cy = count - min(count, t.maxItems()) + pos
|
|
}
|
|
}
|
|
// Restore selections by id-nth key after reload completes
|
|
if !t.reading && t.pendingSelections != nil {
|
|
for i := 0; i < t.merger.Length() && len(t.pendingSelections) > 0; i++ {
|
|
item := t.merger.Get(i).item
|
|
key := t.trackKeyFor(item, t.idNth)
|
|
if sel, found := t.pendingSelections[key]; found {
|
|
t.selected[item.Index()] = selectedItem{sel.at, item}
|
|
delete(t.pendingSelections, key)
|
|
}
|
|
}
|
|
t.pendingSelections = nil
|
|
}
|
|
needActivation := false
|
|
if !t.reading {
|
|
switch t.resultMerger.Length() {
|
|
case 0:
|
|
zero := tui.Zero.AsEvent()
|
|
if _, prs := t.keymap[zero]; prs {
|
|
t.pendingReqList = true
|
|
t.eventChan <- zero
|
|
}
|
|
// --sync, only 'focus' is bound, but no items to focus
|
|
needActivation = t.suppress && !t.hasResultActions && !t.hasLoadActions && t.hasFocusActions
|
|
case 1:
|
|
one := tui.One.AsEvent()
|
|
if _, prs := t.keymap[one]; prs {
|
|
t.pendingReqList = true
|
|
t.eventChan <- one
|
|
}
|
|
}
|
|
}
|
|
if t.hasResultActions {
|
|
result := tui.Result.AsEvent()
|
|
if _, prs := t.keymap[result]; prs {
|
|
t.pendingReqList = true
|
|
t.eventChan <- result
|
|
}
|
|
if !t.reading {
|
|
resultFinal := tui.ResultFinal.AsEvent()
|
|
if _, prs := t.keymap[resultFinal]; prs {
|
|
t.pendingReqList = true
|
|
t.eventChan <- resultFinal
|
|
}
|
|
}
|
|
}
|
|
updateList := !t.trackBlocked && !t.pendingReqList
|
|
updatePrompt := (trackWasBlocked && !t.trackBlocked) || (waitWasBlocked && !t.wait.blocked)
|
|
t.mutex.Unlock()
|
|
|
|
// Wake up the main loop to execute pending actions after wait unblocks.
|
|
// Send from a goroutine; UpdateList runs inside the event box callback,
|
|
// and an inline send on a full channel would deadlock with the main loop
|
|
// blocking on eventBox.Set while trying to drain the channel.
|
|
if wakeUp {
|
|
go func() {
|
|
t.serverInputChan <- []*action{{t: actIgnore}}
|
|
}()
|
|
}
|
|
|
|
t.reqBox.Set(reqInfo, nil)
|
|
if updateList {
|
|
t.reqBox.Set(reqList, nil)
|
|
}
|
|
if updatePrompt {
|
|
t.reqBox.Set(reqPrompt, nil)
|
|
}
|
|
if needActivation {
|
|
t.reqBox.Set(reqActivate, nil)
|
|
}
|
|
}
|
|
|
|
func (t *Terminal) output() bool {
|
|
if t.printQuery {
|
|
t.printer(string(t.input))
|
|
}
|
|
if len(t.expect) > 0 {
|
|
t.printer(t.pressed)
|
|
}
|
|
for _, s := range t.printQueue {
|
|
t.printer(s)
|
|
}
|
|
transform := func(item *Item) string {
|
|
return item.AsString(t.ansi)
|
|
}
|
|
if t.acceptNth != nil {
|
|
transform = func(item *Item) string {
|
|
return item.acceptNth(t.ansi, t.delimiter, t.acceptNth)
|
|
}
|
|
}
|
|
found := len(t.selected) > 0
|
|
if !found {
|
|
current := t.currentItem()
|
|
if current != nil {
|
|
t.printer(transform(current))
|
|
found = true
|
|
}
|
|
} else {
|
|
for _, sel := range t.sortSelected() {
|
|
t.printer(transform(sel.item))
|
|
}
|
|
}
|
|
return found
|
|
}
|
|
|
|
func (t *Terminal) sortSelected() []selectedItem {
|
|
sels := make([]selectedItem, 0, len(t.selected))
|
|
for _, sel := range t.selected {
|
|
sels = append(sels, sel)
|
|
}
|
|
sort.Sort(byTimeOrder(sels))
|
|
return sels
|
|
}
|
|
|
|
func (t *Terminal) displayWidth(runes []rune) int {
|
|
width, _ := util.RunesWidth(runes, 0, t.tabstop, math.MaxInt32)
|
|
return width
|
|
}
|
|
|
|
func (t *Terminal) displayWidthWithPrefix(str string, prefixWidth int) int {
|
|
width, _ := util.RunesWidth([]rune(str), prefixWidth, t.tabstop, math.MaxInt32)
|
|
return width
|
|
}
|
|
|
|
const (
|
|
minWidth = 4
|
|
minHeight = 3
|
|
)
|
|
|
|
func calculateSize(base int, size sizeSpec, occupied int, minSize int) int {
|
|
max := base - occupied
|
|
if max < minSize {
|
|
max = minSize
|
|
}
|
|
if size.percent {
|
|
return util.Constrain(int(float64(base)*0.01*size.size), minSize, max)
|
|
}
|
|
return util.Constrain(int(size.size)+minSize-1, minSize, max)
|
|
}
|
|
|
|
func (t *Terminal) minPreviewSize(opts *previewOpts) (int, int) {
|
|
border := opts.Border(t.layout)
|
|
minPreviewWidth := 1 + borderColumns(border, t.borderWidth)
|
|
minPreviewHeight := 1 + borderLines(border)
|
|
|
|
switch opts.position {
|
|
case posLeft, posRight:
|
|
if len(t.scrollbar) > 0 && !border.HasRight() {
|
|
// Need a column to show scrollbar
|
|
minPreviewWidth++
|
|
}
|
|
}
|
|
|
|
return minPreviewWidth, minPreviewHeight
|
|
}
|
|
|
|
func (t *Terminal) adjustMarginAndPadding() (int, int, [4]int, [4]int) {
|
|
screenWidth := t.tui.MaxX()
|
|
screenHeight := t.tui.MaxY()
|
|
marginInt := [4]int{} // TRBL
|
|
paddingInt := [4]int{} // TRBL
|
|
sizeSpecToInt := func(index int, spec sizeSpec) int {
|
|
if spec.percent {
|
|
var max float64
|
|
if index%2 == 0 {
|
|
max = float64(screenHeight)
|
|
} else {
|
|
max = float64(screenWidth)
|
|
}
|
|
return int(max * spec.size * 0.01)
|
|
}
|
|
return int(spec.size)
|
|
}
|
|
for idx, sizeSpec := range t.padding {
|
|
paddingInt[idx] = sizeSpecToInt(idx, sizeSpec)
|
|
}
|
|
|
|
bw := t.borderWidth
|
|
extraMargin := [4]int{} // TRBL
|
|
for idx, sizeSpec := range t.margin {
|
|
switch t.borderShape {
|
|
case tui.BorderHorizontal:
|
|
extraMargin[idx] += 1 - idx%2
|
|
case tui.BorderVertical:
|
|
extraMargin[idx] += (1 + bw) * (idx % 2)
|
|
case tui.BorderTop:
|
|
if idx == 0 {
|
|
extraMargin[idx]++
|
|
}
|
|
case tui.BorderRight:
|
|
if idx == 1 {
|
|
extraMargin[idx] += 1 + bw
|
|
}
|
|
case tui.BorderBottom:
|
|
if idx == 2 {
|
|
extraMargin[idx]++
|
|
}
|
|
case tui.BorderLeft:
|
|
if idx == 3 {
|
|
extraMargin[idx] += 1 + bw
|
|
}
|
|
case tui.BorderRounded, tui.BorderSharp, tui.BorderBold, tui.BorderBlock, tui.BorderThinBlock, tui.BorderDouble, tui.BorderDashed:
|
|
extraMargin[idx] += 1 + bw*(idx%2)
|
|
}
|
|
marginInt[idx] = sizeSpecToInt(idx, sizeSpec) + extraMargin[idx]
|
|
}
|
|
|
|
adjust := func(idx1 int, idx2 int, maximum int, minimum int) {
|
|
if minimum > maximum {
|
|
minimum = maximum
|
|
}
|
|
margin := marginInt[idx1] + marginInt[idx2] + paddingInt[idx1] + paddingInt[idx2]
|
|
if maximum-margin < minimum {
|
|
desired := maximum - minimum
|
|
paddingInt[idx1] = desired * paddingInt[idx1] / margin
|
|
paddingInt[idx2] = desired * paddingInt[idx2] / margin
|
|
marginInt[idx1] = max(extraMargin[idx1], desired*marginInt[idx1]/margin)
|
|
marginInt[idx2] = max(extraMargin[idx2], desired*marginInt[idx2]/margin)
|
|
}
|
|
}
|
|
|
|
minAreaWidth := minWidth
|
|
minAreaHeight := minHeight
|
|
if t.inputless {
|
|
minAreaHeight--
|
|
}
|
|
if t.noSeparatorLine() {
|
|
minAreaHeight--
|
|
}
|
|
if t.needPreviewWindow() {
|
|
minPreviewWidth, minPreviewHeight := t.minPreviewSize(t.activePreviewOpts)
|
|
switch t.activePreviewOpts.position {
|
|
case posUp, posDown, posNext:
|
|
minAreaHeight += minPreviewHeight
|
|
minAreaWidth = max(minPreviewWidth, minAreaWidth)
|
|
case posLeft, posRight:
|
|
minAreaWidth += minPreviewWidth
|
|
minAreaHeight = max(minPreviewHeight, minAreaHeight)
|
|
}
|
|
}
|
|
adjust(1, 3, screenWidth, minAreaWidth)
|
|
adjust(0, 2, screenHeight, minAreaHeight)
|
|
|
|
return screenWidth, screenHeight, marginInt, paddingInt
|
|
}
|
|
|
|
func (t *Terminal) forceRerenderList() {
|
|
t.prevLines = make([]itemLine, len(t.prevLines))
|
|
}
|
|
|
|
func (t *Terminal) hasHeaderWindow() bool {
|
|
if !t.headerVisible {
|
|
return false
|
|
}
|
|
if t.hasHeaderLinesWindow() {
|
|
return len(t.header0) > 0
|
|
}
|
|
if t.headerBorderShape.Visible() || t.headerFirst {
|
|
return len(t.header0)+t.headerLines > 0
|
|
}
|
|
return t.inputBorderShape.Visible()
|
|
}
|
|
|
|
func (t *Terminal) hasHeaderLinesWindow() bool {
|
|
w, _ := t.determineHeaderLinesShape()
|
|
return w
|
|
}
|
|
|
|
func (t *Terminal) determineHeaderLinesShape() (bool, tui.BorderShape) {
|
|
if !t.headerVisible || t.headerLines == 0 {
|
|
return false, tui.BorderNone
|
|
}
|
|
|
|
// --header-lines-border is set
|
|
if t.headerLinesShape != tui.BorderUndefined {
|
|
return true, t.headerLinesShape
|
|
}
|
|
|
|
// --header-lines-border is not set, determine if we should use
|
|
// the style of --header-border
|
|
shape := tui.BorderNone
|
|
if len(t.header0) == 0 {
|
|
shape = t.headerBorderShape
|
|
}
|
|
if shape == tui.BorderNone {
|
|
shape = tui.BorderPhantom
|
|
}
|
|
|
|
// --layout reverse-list is set
|
|
if t.layout == layoutReverseList {
|
|
return true, shape
|
|
}
|
|
|
|
// Use header window instead
|
|
if len(t.header0) == 0 {
|
|
if t.headerFirst && shape == tui.BorderPhantom {
|
|
return true, shape
|
|
}
|
|
return false, t.headerBorderShape
|
|
}
|
|
|
|
// We have both types of headers, and we want to separate the two
|
|
if t.headerFirst {
|
|
return true, shape
|
|
}
|
|
|
|
return false, tui.BorderNone
|
|
}
|
|
|
|
// Inline sections live inside wborder rather than consuming shift/shrink/availableLines.
|
|
type inlineRole int
|
|
|
|
const (
|
|
inlineRoleHeader inlineRole = iota
|
|
inlineRoleHeaderLines
|
|
inlineRoleFooter
|
|
)
|
|
|
|
type inlineSlot struct {
|
|
role inlineRole
|
|
windowType tui.WindowType
|
|
contentLines int // 0 when out of budget: a ghost placeholder is created so
|
|
// t.headerWindow / t.footerWindow stay non-nil but no frame is painted.
|
|
label labelPrinter
|
|
labelOpts labelOpts
|
|
labelLen int
|
|
}
|
|
|
|
// inlineMetaFor returns (onTop: top stack vs bottom; windowType; isInner:
|
|
// adjacent to list content) for the given inline role, derived from the layout.
|
|
// Header is outer when paired with header-lines; header-lines is always inner;
|
|
// footer is inner except in reverseList where it sits outside header-lines.
|
|
func (t *Terminal) inlineMetaFor(role inlineRole) (onTop bool, windowType tui.WindowType, isInner bool) {
|
|
switch role {
|
|
case inlineRoleHeader:
|
|
return t.layout == layoutReverse, tui.WindowHeader, false
|
|
case inlineRoleHeaderLines:
|
|
return t.layout != layoutDefault, tui.WindowHeader, true
|
|
case inlineRoleFooter:
|
|
return t.layout != layoutReverse, tui.WindowFooter, t.layout != layoutReverseList
|
|
}
|
|
return false, tui.WindowBase, false
|
|
}
|
|
|
|
func (t *Terminal) placeInlineSection(win tui.Window, role inlineRole) {
|
|
switch role {
|
|
case inlineRoleHeader:
|
|
t.headerWindow = win
|
|
case inlineRoleHeaderLines:
|
|
t.headerLinesWindow = win
|
|
case inlineRoleFooter:
|
|
t.footerWindow = win
|
|
}
|
|
}
|
|
|
|
// placeInlineStack walks `slots` in outer-to-inner order. Only the outermost
|
|
// slot (index 0) claims the adjacent wborder edge; later slots paint side-only
|
|
// frames so the T-junction separator lands between sections.
|
|
func (t *Terminal) placeInlineStack(slots []inlineSlot, startRow int, onTop bool) {
|
|
firstEdge := tui.SectionEdgeTop
|
|
if !onTop {
|
|
firstEdge = tui.SectionEdgeBottom
|
|
}
|
|
noBorder := tui.MakeBorderStyle(tui.BorderNone, t.unicode)
|
|
subLeft := t.window.Left()
|
|
subWidth := t.window.Width()
|
|
cursor := startRow
|
|
for i, s := range slots {
|
|
var windowTop, sepRow int
|
|
if onTop {
|
|
windowTop = cursor
|
|
sepRow = cursor + s.contentLines - t.wborder.Top()
|
|
} else {
|
|
windowTop = cursor - s.contentLines + 1
|
|
sepRow = windowTop - 1 - t.wborder.Top()
|
|
}
|
|
// 0-height placeholder keeps t.headerWindow / t.footerWindow non-nil.
|
|
win := t.tui.NewWindow(windowTop, subLeft, subWidth, s.contentLines, s.windowType, noBorder, true)
|
|
t.placeInlineSection(win, s.role)
|
|
if s.contentLines == 0 {
|
|
continue
|
|
}
|
|
secTop := windowTop - t.wborder.Top()
|
|
secBottom := secTop + s.contentLines - 1
|
|
edge := tui.SectionEdgeNone
|
|
if i == 0 {
|
|
edge = firstEdge
|
|
}
|
|
t.wborder.PaintSectionFrame(secTop, secBottom, s.windowType, edge)
|
|
// useBottom=onTop so the separator always hugs the list (inner side).
|
|
// Matters for thinblock/block where top != bottom char.
|
|
t.wborder.DrawHSeparator(sepRow, s.windowType, onTop)
|
|
t.printLabelAt(t.wborder, s.label, s.labelOpts, s.labelLen, sepRow)
|
|
if onTop {
|
|
cursor += s.contentLines + 1
|
|
} else {
|
|
cursor -= s.contentLines + 1
|
|
}
|
|
}
|
|
}
|
|
|
|
func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
|
|
t.clearNumLinesCache()
|
|
t.forcePreview = forcePreview
|
|
screenWidth, screenHeight, marginInt, paddingInt := t.adjustMarginAndPadding()
|
|
width := screenWidth - marginInt[1] - marginInt[3]
|
|
height := screenHeight - marginInt[0] - marginInt[2]
|
|
|
|
t.prevLines = make([]itemLine, max(1, screenHeight))
|
|
if t.border != nil && redrawBorder {
|
|
t.border = nil
|
|
}
|
|
if t.window != nil {
|
|
t.window = nil
|
|
}
|
|
if t.wborder != nil {
|
|
t.wborder = nil
|
|
}
|
|
if t.headerWindow != nil {
|
|
t.headerWindow = nil
|
|
}
|
|
if t.headerBorder != nil {
|
|
t.headerBorder = nil
|
|
}
|
|
if t.footerWindow != nil {
|
|
t.footerWindow = nil
|
|
}
|
|
if t.footerBorder != nil {
|
|
t.footerBorder = nil
|
|
}
|
|
if t.headerLinesWindow != nil {
|
|
t.headerLinesWindow = nil
|
|
}
|
|
if t.headerLinesBorder != nil {
|
|
t.headerLinesBorder = nil
|
|
}
|
|
if t.inputWindow != nil {
|
|
t.inputWindow = nil
|
|
}
|
|
if t.inputBorder != nil {
|
|
t.inputBorder = nil
|
|
}
|
|
if t.pborder != nil {
|
|
t.pborder = nil
|
|
}
|
|
hadPreviewWindow := t.hasPreviewWindow()
|
|
if hadPreviewWindow {
|
|
t.pwindow = nil
|
|
}
|
|
// Reset preview version so that full redraw occurs
|
|
t.previewed.version = 0
|
|
|
|
bw := t.borderWidth
|
|
offsets := [4]int{} // TRWH
|
|
if t.borderShape.HasTop() {
|
|
offsets[0] -= 1
|
|
offsets[3] += 1
|
|
}
|
|
if t.borderShape.HasRight() {
|
|
offsets[2] += 1 + bw
|
|
}
|
|
if t.borderShape.HasBottom() {
|
|
offsets[3] += 1
|
|
}
|
|
if t.borderShape.HasLeft() {
|
|
offsets[1] -= 1 + bw
|
|
offsets[2] += 1 + bw
|
|
}
|
|
if t.border == nil && t.borderShape.Visible() {
|
|
t.border = t.tui.NewWindow(
|
|
marginInt[0]+offsets[0], marginInt[3]+offsets[1], width+offsets[2], height+offsets[3],
|
|
tui.WindowBase, tui.MakeBorderStyle(t.borderShape, t.unicode), true)
|
|
}
|
|
|
|
// Add padding to margin
|
|
for idx, val := range paddingInt {
|
|
marginInt[idx] += val
|
|
}
|
|
width -= paddingInt[1] + paddingInt[3]
|
|
height -= paddingInt[0] + paddingInt[2]
|
|
|
|
// Adjust position and size of the list window if input border is set
|
|
inputBorderHeight := 0
|
|
availableLines := height
|
|
|
|
shift := 0
|
|
shrink := 0
|
|
hasHeaderWindow := t.hasHeaderWindow()
|
|
hasFooterWindow := len(t.footer) > 0
|
|
hasHeaderLinesWindow, headerLinesShape := t.determineHeaderLinesShape()
|
|
// computePreviewSize returns the size resizePreviewWindows will compute
|
|
// for opts and the minimum size for that axis: height/minPreviewHeight
|
|
// for vertical positions, width/minPreviewWidth for horizontal.
|
|
computePreviewSize := func(opts *previewOpts) (int, int) {
|
|
minPreviewWidth, minPreviewHeight := t.minPreviewSize(opts)
|
|
switch opts.position {
|
|
case posUp, posDown, posNext:
|
|
minWindowHeight := minHeight
|
|
if t.inputless {
|
|
minWindowHeight--
|
|
}
|
|
if t.noSeparatorLine() {
|
|
minWindowHeight--
|
|
}
|
|
return calculateSize(height, opts.size, minWindowHeight, minPreviewHeight), minPreviewHeight
|
|
case posLeft, posRight:
|
|
minListWidth := minWidth
|
|
if t.listBorderShape.HasLeft() {
|
|
minListWidth += 2
|
|
}
|
|
if t.listBorderShape.HasRight() {
|
|
minListWidth++
|
|
}
|
|
return calculateSize(width, opts.size, minListWidth, minPreviewWidth), minPreviewWidth
|
|
}
|
|
return 0, 0
|
|
}
|
|
// Walk the threshold chain to determine the previewOpts that
|
|
// resizePreviewWindows will actually settle on. We need this here
|
|
// because hasInputWindow and the availableLines adjustment below run
|
|
// before resizePreviewWindows, and t.activePreviewOpts still holds the
|
|
// previous frame's resolution.
|
|
effectivePreviewOpts := &t.previewOpts
|
|
if t.needPreviewWindow() {
|
|
opts := &t.previewOpts
|
|
for {
|
|
if opts.size.size == 0 || opts.threshold <= 0 || opts.alternative == nil {
|
|
break
|
|
}
|
|
if actual, _ := computePreviewSize(opts); actual >= opts.threshold {
|
|
break
|
|
}
|
|
opts = opts.alternative
|
|
if opts.hidden {
|
|
break
|
|
}
|
|
}
|
|
effectivePreviewOpts = opts
|
|
}
|
|
hasInputWindow := !t.inputless && (t.inputBorderShape.Visible() || hasHeaderWindow || hasHeaderLinesWindow || effectivePreviewOpts.position == posNext)
|
|
inputWindowHeight := 2
|
|
if t.noSeparatorLine() {
|
|
inputWindowHeight--
|
|
}
|
|
if hasInputWindow {
|
|
inputBorderHeight = util.Constrain(borderLines(t.inputBorderShape)+inputWindowHeight, 0, availableLines)
|
|
if t.layout == layoutReverse {
|
|
shift = inputBorderHeight
|
|
shrink = inputBorderHeight
|
|
} else {
|
|
shrink = inputBorderHeight
|
|
}
|
|
availableLines -= inputBorderHeight
|
|
} else if !t.inputless {
|
|
availableLines -= inputWindowHeight
|
|
}
|
|
|
|
// FIXME: Needed?
|
|
if t.needPreviewWindow() {
|
|
switch effectivePreviewOpts.position {
|
|
case posUp, posDown, posNext:
|
|
_, minPreviewHeight := t.minPreviewSize(effectivePreviewOpts)
|
|
availableLines -= minPreviewHeight
|
|
}
|
|
}
|
|
|
|
// Slices are ordered outer-to-inner: index 0 touches wborder's edge.
|
|
var inlineTop []inlineSlot
|
|
var inlineBottom []inlineSlot
|
|
|
|
// Caps contentLines against remaining wborder space. Oversized requests (e.g. a
|
|
// huge --header-lines) become 0-height placeholders rather than pushing the list
|
|
// window to negative height.
|
|
addInline := func(contentLines int, role inlineRole) {
|
|
onTop, windowType, isInner := t.inlineMetaFor(role)
|
|
used := 0
|
|
for _, s := range inlineTop {
|
|
if s.contentLines > 0 {
|
|
used += s.contentLines + 1
|
|
}
|
|
}
|
|
for _, s := range inlineBottom {
|
|
if s.contentLines > 0 {
|
|
used += s.contentLines + 1
|
|
}
|
|
}
|
|
remaining := availableLines - borderLines(t.listBorderShape) - used - 1
|
|
if remaining < 2 {
|
|
contentLines = 0
|
|
} else {
|
|
contentLines = util.Constrain(contentLines, 1, remaining-1)
|
|
}
|
|
slot := inlineSlot{role: role, windowType: windowType, contentLines: contentLines}
|
|
switch role {
|
|
case inlineRoleHeader:
|
|
slot.label, slot.labelOpts, slot.labelLen = t.headerLabel, t.headerLabelOpts, t.headerLabelLen
|
|
case inlineRoleFooter:
|
|
slot.label, slot.labelOpts, slot.labelLen = t.footerLabel, t.footerLabelOpts, t.footerLabelLen
|
|
}
|
|
target := &inlineTop
|
|
if !onTop {
|
|
target = &inlineBottom
|
|
}
|
|
if isInner {
|
|
*target = append(*target, slot)
|
|
} else {
|
|
*target = append([]inlineSlot{slot}, *target...)
|
|
}
|
|
}
|
|
|
|
// Adjust position and size of the list window if header border is set
|
|
headerBorderHeight := 0
|
|
if hasHeaderWindow {
|
|
headerWindowHeight := t.visibleHeaderLines()
|
|
if hasHeaderLinesWindow {
|
|
headerWindowHeight -= t.headerLines
|
|
}
|
|
if t.headerBorderShape == tui.BorderInline {
|
|
addInline(headerWindowHeight, inlineRoleHeader)
|
|
} else {
|
|
headerBorderHeight = util.Constrain(borderLines(t.headerBorderShape)+headerWindowHeight, 0, availableLines)
|
|
if t.layout == layoutReverse {
|
|
shift += headerBorderHeight
|
|
shrink += headerBorderHeight
|
|
} else {
|
|
shrink += headerBorderHeight
|
|
}
|
|
availableLines -= headerBorderHeight
|
|
}
|
|
}
|
|
|
|
headerLinesHeight := 0
|
|
if hasHeaderLinesWindow {
|
|
if headerLinesShape == tui.BorderInline {
|
|
addInline(t.headerLines, inlineRoleHeaderLines)
|
|
} else {
|
|
headerLinesHeight = util.Constrain(borderLines(headerLinesShape)+t.headerLines, 0, availableLines)
|
|
if t.layout != layoutDefault {
|
|
shift += headerLinesHeight
|
|
shrink += headerLinesHeight
|
|
} else {
|
|
shrink += headerLinesHeight
|
|
}
|
|
availableLines -= headerLinesHeight
|
|
}
|
|
}
|
|
|
|
footerBorderHeight := 0
|
|
if hasFooterWindow {
|
|
if t.footerBorderShape == tui.BorderInline {
|
|
addInline(len(t.footer), inlineRoleFooter)
|
|
} else {
|
|
// Footer lines should not take all available lines
|
|
footerBorderHeight = util.Constrain(borderLines(t.footerBorderShape)+len(t.footer), 0, availableLines)
|
|
shrink += footerBorderHeight
|
|
if t.layout != layoutReverse {
|
|
shift += footerBorderHeight
|
|
}
|
|
availableLines -= footerBorderHeight
|
|
}
|
|
}
|
|
|
|
inlineTopLines := 0
|
|
for _, s := range inlineTop {
|
|
if s.contentLines > 0 {
|
|
inlineTopLines += s.contentLines + 1
|
|
}
|
|
}
|
|
inlineBottomLines := 0
|
|
for _, s := range inlineBottom {
|
|
if s.contentLines > 0 {
|
|
inlineBottomLines += s.contentLines + 1
|
|
}
|
|
}
|
|
|
|
// Set up list border
|
|
hasListBorder := t.listBorderShape.Visible()
|
|
innerWidth := width
|
|
innerHeight := height
|
|
innerMarginInt := marginInt
|
|
innerBorderFn := func(top int, left int, width int, height int) {
|
|
if hasListBorder {
|
|
t.wborder = t.tui.NewWindow(
|
|
top+shift, left, width, height-shrink, tui.WindowList, tui.MakeBorderStyle(t.listBorderShape, t.unicode), false)
|
|
}
|
|
}
|
|
if hasListBorder {
|
|
if t.listBorderShape.HasTop() {
|
|
innerHeight--
|
|
innerMarginInt[0]++
|
|
}
|
|
if t.listBorderShape.HasBottom() {
|
|
innerHeight--
|
|
}
|
|
if t.listBorderShape.HasLeft() {
|
|
innerWidth -= 2
|
|
innerMarginInt[3] += 2
|
|
}
|
|
if t.listBorderShape.HasRight() {
|
|
innerWidth--
|
|
}
|
|
}
|
|
|
|
t.areaLines = height
|
|
t.areaColumns = width
|
|
|
|
// If none of the inner borders has the right side, but the outer border does, increase the list width by 1 column
|
|
listStickToRight := t.borderShape.HasRight() && !t.listBorderShape.HasRight() && !t.inputBorderShape.HasRight() &&
|
|
(!t.headerVisible || !t.headerBorderShape.HasRight() || t.visibleHeaderLines() == 0)
|
|
|
|
// Set up preview window
|
|
noBorder := tui.MakeBorderStyle(tui.BorderNone, t.unicode)
|
|
cleanLeft := []int{}
|
|
// previewNextSize is pheight when the preview is placed adjacent to
|
|
// the input (position == "next"); inputBorderTop() reads it through the
|
|
// closure to push input past the preview band.
|
|
previewNextSize := 0
|
|
// inputBorderTop returns the canvas Y at which the input border window
|
|
// should be placed. It depends on wborder/t.window (set by the preview
|
|
// case), the layout, --header-first, and previewNextSize (set when
|
|
// posNext is active). Used both for placing the preview adjacent to
|
|
// input and later for placing the input window itself.
|
|
inputBorderTop := func() int {
|
|
w := t.wborder
|
|
if w == nil {
|
|
w = t.window
|
|
}
|
|
hasSeparateHeader := hasHeaderWindow && t.headerBorderShape != tui.BorderInline
|
|
hasSeparateHeaderLines := hasHeaderLinesWindow && headerLinesShape != tui.BorderInline
|
|
if (hasSeparateHeader || hasSeparateHeaderLines) && t.headerFirst {
|
|
switch t.layout {
|
|
case layoutDefault:
|
|
btop := w.Top() + w.Height() + previewNextSize
|
|
if hasHeaderWindow && hasHeaderLinesWindow {
|
|
btop += headerLinesHeight
|
|
}
|
|
return btop
|
|
case layoutReverse:
|
|
btop := w.Top() - inputBorderHeight - previewNextSize
|
|
if hasHeaderWindow && hasHeaderLinesWindow {
|
|
btop -= headerLinesHeight
|
|
}
|
|
return btop
|
|
case layoutReverseList:
|
|
return w.Top() + w.Height() + previewNextSize
|
|
}
|
|
}
|
|
switch t.layout {
|
|
case layoutDefault:
|
|
return w.Top() + w.Height() + headerBorderHeight + headerLinesHeight + previewNextSize
|
|
case layoutReverse:
|
|
return w.Top() - shrink + footerBorderHeight - previewNextSize
|
|
case layoutReverseList:
|
|
return w.Top() + w.Height() + headerBorderHeight + previewNextSize
|
|
}
|
|
return 0
|
|
}
|
|
if forcePreview || t.needPreviewWindow() {
|
|
var resizePreviewWindows func(previewOpts *previewOpts)
|
|
resizePreviewWindows = func(previewOpts *previewOpts) {
|
|
t.activePreviewOpts = previewOpts
|
|
if previewOpts.size.size == 0 {
|
|
return
|
|
}
|
|
hasThreshold := previewOpts.threshold > 0 && previewOpts.alternative != nil
|
|
createPreviewWindow := func(y int, x int, w int, h int) {
|
|
pwidth := w
|
|
pheight := h
|
|
shape := previewOpts.Border(t.layout)
|
|
previewBorder := tui.MakeBorderStyle(shape, t.unicode)
|
|
t.pborder = t.tui.NewWindow(y, x, w, h, tui.WindowPreview, previewBorder, false)
|
|
pwidth -= borderColumns(shape, bw)
|
|
pheight -= borderLines(shape)
|
|
if shape.HasLeft() {
|
|
x += 1 + bw
|
|
}
|
|
if shape.HasTop() {
|
|
y += 1
|
|
}
|
|
if len(t.scrollbar) > 0 && !shape.HasRight() {
|
|
// Need a column to show scrollbar
|
|
pwidth -= 1
|
|
}
|
|
t.pwindow = t.tui.NewWindow(y, x, pwidth, pheight, tui.WindowPreview, noBorder, true)
|
|
t.pwindow.SetWrapSign(t.previewWrapSign, t.previewWrapSignWidth)
|
|
if !hadPreviewWindow {
|
|
t.pwindow.Erase()
|
|
}
|
|
}
|
|
// Shared boilerplate for vertical positions (posUp/posDown/posNext):
|
|
// compute pheight, apply the threshold alternative, honor hidden,
|
|
// and update listStickToRight. Returns (pheight, true) when the
|
|
// caller should return early.
|
|
computeVerticalSize := func() (int, bool) {
|
|
pheight, minPreviewHeight := computePreviewSize(previewOpts)
|
|
if hasThreshold && pheight < previewOpts.threshold {
|
|
t.activePreviewOpts = previewOpts.alternative
|
|
if forcePreview {
|
|
previewOpts.alternative.hidden = false
|
|
}
|
|
if !previewOpts.alternative.hidden {
|
|
resizePreviewWindows(previewOpts.alternative)
|
|
}
|
|
return 0, true
|
|
}
|
|
if forcePreview {
|
|
previewOpts.hidden = false
|
|
}
|
|
if previewOpts.hidden {
|
|
return 0, true
|
|
}
|
|
listStickToRight = listStickToRight && !previewOpts.Border(t.layout).HasRight()
|
|
if listStickToRight {
|
|
innerWidth++
|
|
width++
|
|
}
|
|
return util.Constrain(pheight, minPreviewHeight, availableLines), false
|
|
}
|
|
switch previewOpts.position {
|
|
case posUp, posDown:
|
|
pheight, done := computeVerticalSize()
|
|
if done {
|
|
return
|
|
}
|
|
|
|
if previewOpts.position == posUp {
|
|
innerBorderFn(marginInt[0]+pheight, marginInt[3], width, height-pheight)
|
|
t.window = t.tui.NewWindow(
|
|
innerMarginInt[0]+pheight+shift+inlineTopLines, innerMarginInt[3], innerWidth, innerHeight-pheight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true)
|
|
createPreviewWindow(marginInt[0], marginInt[3], width, pheight)
|
|
} else {
|
|
innerBorderFn(marginInt[0], marginInt[3], width, height-pheight)
|
|
t.window = t.tui.NewWindow(
|
|
innerMarginInt[0]+shift+inlineTopLines, innerMarginInt[3], innerWidth, innerHeight-pheight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true)
|
|
createPreviewWindow(marginInt[0]+height-pheight, marginInt[3], width, pheight)
|
|
}
|
|
case posNext:
|
|
pheight, done := computeVerticalSize()
|
|
if done {
|
|
return
|
|
}
|
|
previewNextSize = pheight
|
|
|
|
if t.layout == layoutReverse {
|
|
// [(header)][input][preview]([header])[list]: reuse posUp's
|
|
// wborder/list math; input is pulled back up by pheight in
|
|
// its positioning. Preview sits directly below input.
|
|
innerBorderFn(marginInt[0]+pheight, marginInt[3], width, height-pheight)
|
|
t.window = t.tui.NewWindow(
|
|
innerMarginInt[0]+pheight+shift+inlineTopLines, innerMarginInt[3], innerWidth, innerHeight-pheight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true)
|
|
createPreviewWindow(inputBorderTop()+inputBorderHeight, marginInt[3], width, pheight)
|
|
} else {
|
|
// [list]([header])[preview][input][(header)]: reuse posDown's
|
|
// wborder/list math; input is pushed down by pheight in its
|
|
// positioning. Preview sits directly above input.
|
|
innerBorderFn(marginInt[0], marginInt[3], width, height-pheight)
|
|
t.window = t.tui.NewWindow(
|
|
innerMarginInt[0]+shift+inlineTopLines, innerMarginInt[3], innerWidth, innerHeight-pheight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true)
|
|
createPreviewWindow(inputBorderTop()-pheight, marginInt[3], width, pheight)
|
|
}
|
|
case posLeft, posRight:
|
|
pwidth, _ := computePreviewSize(previewOpts)
|
|
if hasThreshold && pwidth < previewOpts.threshold {
|
|
t.activePreviewOpts = previewOpts.alternative
|
|
if forcePreview {
|
|
previewOpts.alternative.hidden = false
|
|
}
|
|
if !previewOpts.alternative.hidden {
|
|
resizePreviewWindows(previewOpts.alternative)
|
|
}
|
|
return
|
|
}
|
|
if forcePreview {
|
|
previewOpts.hidden = false
|
|
}
|
|
if previewOpts.hidden {
|
|
return
|
|
}
|
|
if previewOpts.position == posLeft {
|
|
// Put scrollbar closer to the right border for consistent look
|
|
if t.borderShape.HasRight() && !hasListBorder {
|
|
innerWidth++
|
|
}
|
|
// Add a 1-column margin between the preview window and the main window
|
|
m := 0
|
|
if !hasListBorder {
|
|
m = 1
|
|
}
|
|
t.window = t.tui.NewWindow(
|
|
innerMarginInt[0]+shift+inlineTopLines, innerMarginInt[3]+pwidth+m, innerWidth-pwidth-m, innerHeight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true)
|
|
|
|
// Clear characters on the margin
|
|
// fzf --bind 'space:toggle-preview' --preview ':' --preview-window left,1,border-none --footer-border --footer f --header h --header-border
|
|
if !previewOpts.Border(t.layout).HasRight() {
|
|
cleanLeft = append(cleanLeft, -2)
|
|
}
|
|
// fzf --bind 'space:toggle-preview' --preview ':' --preview-window left,1 --footer-border --footer f --header h --header-border
|
|
if !hasListBorder {
|
|
cleanLeft = append(cleanLeft, -1)
|
|
}
|
|
|
|
innerBorderFn(marginInt[0], marginInt[3]+pwidth, width-pwidth, height)
|
|
createPreviewWindow(marginInt[0], marginInt[3], pwidth, height)
|
|
} else {
|
|
// NOTE: Relaxed condition for the following cases
|
|
// fzf --preview 'seq 500' --preview-window border-left --border
|
|
// fzf --preview 'seq 500' --preview-window border-left --border --list-border
|
|
// fzf --preview 'seq 500' --preview-window border-left --border --input-border
|
|
listStickToRight = t.borderShape.HasRight() && !previewOpts.Border(t.layout).HasRight()
|
|
if listStickToRight {
|
|
innerWidth++
|
|
width++
|
|
}
|
|
innerBorderFn(marginInt[0], marginInt[3], width-pwidth, height)
|
|
t.window = t.tui.NewWindow(
|
|
innerMarginInt[0]+shift+inlineTopLines, innerMarginInt[3], innerWidth-pwidth, innerHeight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true)
|
|
x := marginInt[3] + width - pwidth
|
|
createPreviewWindow(marginInt[0], x, pwidth, height)
|
|
}
|
|
}
|
|
}
|
|
resizePreviewWindows(&t.previewOpts)
|
|
|
|
if t.borderShape.HasRight() && !listStickToRight {
|
|
// Need to clear the extra margin between the borders
|
|
// fzf --preview 'seq 1000' --preview-window border-left --bind space:change-preview-window:border-rounded --border vertical
|
|
// fzf --preview 'seq 1000' --preview-window up,hidden --bind space:toggle-preview --border vertical
|
|
y := 0
|
|
if t.borderShape.HasTop() {
|
|
y++
|
|
}
|
|
maxY := t.border.Height()
|
|
if t.borderShape.HasBottom() {
|
|
maxY--
|
|
}
|
|
for ; y < maxY; y++ {
|
|
t.border.Move(y, t.border.Width()-2)
|
|
t.border.Print(" ")
|
|
}
|
|
}
|
|
} else {
|
|
t.activePreviewOpts = &t.previewOpts
|
|
}
|
|
|
|
// Without preview window
|
|
if t.window == nil {
|
|
if listStickToRight {
|
|
// Put scrollbar closer to the right border for consistent look
|
|
innerWidth++
|
|
width++
|
|
}
|
|
innerBorderFn(marginInt[0], marginInt[3], width, height)
|
|
t.window = t.tui.NewWindow(
|
|
innerMarginInt[0]+shift+inlineTopLines,
|
|
innerMarginInt[3],
|
|
innerWidth,
|
|
innerHeight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true)
|
|
}
|
|
|
|
if len(inlineTop)+len(inlineBottom) > 0 && t.wborder != nil {
|
|
t.placeInlineStack(inlineTop, t.window.Top()-inlineTopLines, true)
|
|
t.placeInlineStack(inlineBottom, t.window.Top()+t.window.Height()+inlineBottomLines-1, false)
|
|
}
|
|
|
|
if len(t.scrollbar) == 0 {
|
|
for y := 0; y < t.window.Height(); y++ {
|
|
t.window.Move(y, t.window.Width()-1)
|
|
t.window.Print(" ")
|
|
}
|
|
}
|
|
|
|
createInnerWindow := func(b tui.Window, shape tui.BorderShape, windowType tui.WindowType, shift int) tui.Window {
|
|
top := b.Top()
|
|
left := b.Left() + shift
|
|
if shape.HasTop() {
|
|
top++
|
|
}
|
|
if shape.HasLeft() {
|
|
left += t.borderWidth + 1
|
|
}
|
|
width := b.Width() - borderColumns(shape, t.borderWidth) - shift
|
|
if shape.HasRight() {
|
|
width++
|
|
}
|
|
// Make sure that the width does not exceed the list width
|
|
width = min(t.window.Width()+t.headerIndentImpl(0, shape), width)
|
|
height := b.Height() - borderLines(shape)
|
|
return t.tui.NewWindow(top, left, width, height, windowType, noBorder, true)
|
|
}
|
|
|
|
// Set up input border
|
|
w := t.wborder
|
|
if t.wborder == nil {
|
|
w = t.window
|
|
}
|
|
|
|
if hasInputWindow {
|
|
btop := inputBorderTop()
|
|
shift := 0
|
|
if !t.inputBorderShape.HasLeft() && t.listBorderShape.HasLeft() {
|
|
shift += t.borderWidth + 1
|
|
}
|
|
t.inputBorder = t.tui.NewWindow(
|
|
btop,
|
|
w.Left(),
|
|
w.Width(),
|
|
inputBorderHeight, tui.WindowInput, tui.MakeBorderStyle(t.inputBorderShape, t.unicode), true)
|
|
if shift > 0 && !t.inputBorderShape.Visible() {
|
|
// Small box on the left to erase the residue
|
|
// e.g.
|
|
// fzf --list-border --header-border --bind 'space:change-header(hello),enter:change-header()'
|
|
t.tui.NewWindow(btop, w.Left(), shift, inputBorderHeight, tui.WindowInput, noBorder, false).Erase()
|
|
}
|
|
t.inputWindow = createInnerWindow(t.inputBorder, t.inputBorderShape, tui.WindowInput, shift)
|
|
}
|
|
|
|
// Set up header border
|
|
if hasHeaderWindow && t.headerBorderShape != tui.BorderInline {
|
|
var btop int
|
|
if hasInputWindow && t.headerFirst {
|
|
if t.layout == layoutReverse {
|
|
btop = w.Top() - shrink + footerBorderHeight - previewNextSize
|
|
} else if t.layout == layoutReverseList {
|
|
btop = w.Top() + w.Height() + inputBorderHeight + previewNextSize
|
|
} else {
|
|
btop = w.Top() + w.Height() + inputBorderHeight + headerLinesHeight + previewNextSize
|
|
}
|
|
} else {
|
|
if t.layout == layoutReverse {
|
|
btop = w.Top() - headerBorderHeight - headerLinesHeight
|
|
} else if t.layout == layoutReverseList {
|
|
btop = w.Top() + w.Height()
|
|
} else {
|
|
btop = w.Top() + w.Height() + headerLinesHeight
|
|
}
|
|
}
|
|
t.headerBorder = t.tui.NewWindow(
|
|
btop,
|
|
w.Left(),
|
|
w.Width(),
|
|
headerBorderHeight, tui.WindowHeader, tui.MakeBorderStyle(t.headerBorderShape, t.unicode), true)
|
|
t.headerWindow = createInnerWindow(t.headerBorder, t.headerBorderShape, tui.WindowHeader, 0)
|
|
}
|
|
|
|
// Set up header lines border
|
|
if hasHeaderLinesWindow && headerLinesShape != tui.BorderInline {
|
|
var btop int
|
|
// NOTE: We still have to handle --header-first here in case
|
|
// --header-lines-border is set. Can't we just use header window instead
|
|
// with the style? So we can display header label.
|
|
// fzf --header-lines 3 --header-label hello --header-border
|
|
// fzf --header-lines 3 --header-label hello --header-lines-border
|
|
headerFirst := t.headerFirst && len(t.header0) == 0
|
|
|
|
if headerFirst {
|
|
if t.layout == layoutDefault {
|
|
btop = w.Top() + w.Height() + inputBorderHeight + previewNextSize
|
|
} else if t.layout == layoutReverse {
|
|
btop = w.Top() - headerLinesHeight - inputBorderHeight
|
|
} else {
|
|
btop = w.Top() - headerLinesHeight
|
|
}
|
|
} else {
|
|
if t.layout != layoutDefault {
|
|
btop = w.Top() - headerLinesHeight
|
|
} else {
|
|
btop = w.Top() + w.Height()
|
|
}
|
|
}
|
|
t.headerLinesBorder = t.tui.NewWindow(
|
|
btop,
|
|
w.Left(),
|
|
w.Width(),
|
|
headerLinesHeight, tui.WindowHeader, tui.MakeBorderStyle(headerLinesShape, t.unicode), true)
|
|
t.headerLinesWindow = createInnerWindow(t.headerLinesBorder, headerLinesShape, tui.WindowHeader, 0)
|
|
}
|
|
|
|
// Set up footer
|
|
if hasFooterWindow && t.footerBorderShape != tui.BorderInline {
|
|
var btop int
|
|
if t.layout == layoutReverse {
|
|
btop = w.Top() + w.Height()
|
|
} else if t.layout == layoutReverseList {
|
|
btop = w.Top() - footerBorderHeight - headerLinesHeight
|
|
} else {
|
|
btop = w.Top() - footerBorderHeight
|
|
}
|
|
t.footerBorder = t.tui.NewWindow(
|
|
btop,
|
|
w.Left(),
|
|
w.Width(),
|
|
footerBorderHeight, tui.WindowFooter, tui.MakeBorderStyle(t.footerBorderShape, t.unicode), true)
|
|
t.footerWindow = createInnerWindow(t.footerBorder, t.footerBorderShape, tui.WindowFooter, 0)
|
|
}
|
|
|
|
for _, w := range []tui.Window{t.window, t.headerBorder, t.headerLinesBorder, t.footerBorder, t.inputBorder} {
|
|
if w == nil {
|
|
continue
|
|
}
|
|
|
|
for y := 0; y < w.Height(); y++ {
|
|
for _, left := range cleanLeft {
|
|
w.Move(y, left)
|
|
w.Print(" ")
|
|
}
|
|
}
|
|
}
|
|
|
|
// When the list label lands on an edge owned by an inline section, swap its bg
|
|
// so the label reads as part of that section's frame. Fg stays at list-label.
|
|
listLabel, listLabelLen := t.listLabel, t.listLabelLen
|
|
var adjacentSection *inlineSlot
|
|
if t.listLabelOpts.bottom && len(inlineBottom) > 0 {
|
|
adjacentSection = &inlineBottom[0]
|
|
} else if !t.listLabelOpts.bottom && len(inlineTop) > 0 {
|
|
adjacentSection = &inlineTop[0]
|
|
}
|
|
if adjacentSection != nil {
|
|
bg := tui.BorderColor(adjacentSection.windowType).Bg()
|
|
custom := tui.ColListLabel.WithBg(tui.ColorAttr{Color: bg, Attr: tui.AttrUndefined})
|
|
listLabel, listLabelLen = t.ansiLabelPrinter(t.listLabelOpts.label, &custom, false)
|
|
}
|
|
t.printLabel(t.wborder, listLabel, t.listLabelOpts, listLabelLen, t.listBorderShape, false)
|
|
t.printLabel(t.border, t.borderLabel, t.borderLabelOpts, t.borderLabelLen, t.borderShape, false)
|
|
t.printLabel(t.pborder, t.previewLabel, t.previewLabelOpts, t.previewLabelLen, t.activePreviewOpts.Border(t.layout), false)
|
|
t.printLabel(t.inputBorder, t.inputLabel, t.inputLabelOpts, t.inputLabelLen, t.inputBorderShape, false)
|
|
t.printLabel(t.headerBorder, t.headerLabel, t.headerLabelOpts, t.headerLabelLen, t.headerBorderShape, false)
|
|
t.printLabel(t.footerBorder, t.footerLabel, t.footerLabelOpts, t.footerLabelLen, t.footerBorderShape, false)
|
|
}
|
|
|
|
// printLabelAt positions and renders a label at the given row of `window`. Shared by
|
|
// printLabel (which computes row from the border shape) and the inline-section label
|
|
// code (which uses an explicit separator row).
|
|
func (t *Terminal) printLabelAt(window tui.Window, render labelPrinter, opts labelOpts, length int, row int) {
|
|
if window == nil || render == nil || window.Height() == 0 {
|
|
return
|
|
}
|
|
var col int
|
|
if opts.column == 0 {
|
|
col = max(0, (window.Width()-length)/2)
|
|
} else if opts.column < 0 {
|
|
col = max(0, window.Width()+opts.column+1-length)
|
|
} else {
|
|
col = min(opts.column-1, window.Width()-length)
|
|
}
|
|
window.Move(row, col)
|
|
render(window, window.Width())
|
|
}
|
|
|
|
func (t *Terminal) printLabel(window tui.Window, render labelPrinter, opts labelOpts, length int, borderShape tui.BorderShape, redrawBorder bool) {
|
|
if window == nil || window.Height() == 0 {
|
|
return
|
|
}
|
|
switch borderShape {
|
|
case tui.BorderHorizontal, tui.BorderTop, tui.BorderBottom, tui.BorderRounded, tui.BorderSharp, tui.BorderBold, tui.BorderBlock, tui.BorderThinBlock, tui.BorderDouble, tui.BorderDashed:
|
|
if redrawBorder {
|
|
window.DrawHBorder()
|
|
}
|
|
row := 0
|
|
if borderShape == tui.BorderBottom || opts.bottom {
|
|
row = window.Height() - 1
|
|
}
|
|
t.printLabelAt(window, render, opts, length, row)
|
|
}
|
|
}
|
|
|
|
func (t *Terminal) move(y int, x int, clear bool) {
|
|
h := t.window.Height()
|
|
|
|
switch t.layout {
|
|
case layoutDefault:
|
|
y = h - y - 1
|
|
case layoutReverseList:
|
|
if !t.inListWindow() && t.window != t.headerLinesWindow {
|
|
// From bottom to top
|
|
y = h - y - 1
|
|
} else {
|
|
/*
|
|
* List 1
|
|
* List 2
|
|
* Header 1
|
|
* Header 2
|
|
* Input 2
|
|
* Input 1
|
|
*/
|
|
i := t.visibleInputLinesInList()
|
|
n := t.visibleHeaderLinesInList()
|
|
if i > 0 && y < i {
|
|
y = h - y - 1
|
|
} else if n > 0 && y < i+n {
|
|
y = h - y - 1
|
|
} else {
|
|
// Top to bottom
|
|
y -= n + i
|
|
}
|
|
}
|
|
}
|
|
|
|
if clear {
|
|
t.window.MoveAndClear(y, x)
|
|
} else {
|
|
t.window.Move(y, x)
|
|
}
|
|
}
|
|
|
|
func (t *Terminal) truncateQuery() {
|
|
// We're limiting the length of the query not to make fzf unresponsive when
|
|
// the user accidentally pastes a huge chunk of text. Therefore, we're not
|
|
// interested in the exact display width of the query. We just limit the
|
|
// number of runes.
|
|
t.input = t.input[:min(len(t.input), maxPatternLength)]
|
|
t.cx = util.Constrain(t.cx, 0, len(t.input))
|
|
}
|
|
|
|
func (t *Terminal) updatePromptOffset() ([]rune, []rune) {
|
|
w := t.window
|
|
if t.inputWindow != nil {
|
|
w = t.inputWindow
|
|
}
|
|
maxWidth := max(1, w.Width()-t.promptLen-1)
|
|
|
|
_, overflow := t.trimLeft(t.input[:t.cx], maxWidth, 0)
|
|
minOffset := int(overflow)
|
|
maxOffset := minOffset + (maxWidth-max(0, maxWidth-t.cx))/2
|
|
t.xoffset = util.Constrain(t.xoffset, minOffset, maxOffset)
|
|
before, _ := t.trimLeft(t.input[t.xoffset:t.cx], maxWidth, 0)
|
|
beforeLen := t.displayWidth(before)
|
|
after, _ := t.trimRight(t.input[t.cx:], maxWidth-beforeLen)
|
|
afterLen := t.displayWidth(after)
|
|
t.queryLen = [2]int{beforeLen, afterLen}
|
|
return before, after
|
|
}
|
|
|
|
func (t *Terminal) placeCursor() {
|
|
if t.inputless {
|
|
return
|
|
}
|
|
x := t.promptLen + t.queryLen[0]
|
|
if t.inputWindow != nil {
|
|
y := t.inputWindow.Height() - 1
|
|
if t.layout == layoutReverse {
|
|
y = 0
|
|
}
|
|
x = min(x, t.inputWindow.Width()-1)
|
|
t.inputWindow.Move(y, x)
|
|
return
|
|
}
|
|
x = min(x, t.window.Width()-1)
|
|
t.move(0, x, false)
|
|
}
|
|
|
|
func (t *Terminal) printPrompt() {
|
|
if t.inputless {
|
|
return
|
|
}
|
|
w := t.window
|
|
if t.inputWindow != nil {
|
|
w = t.inputWindow
|
|
}
|
|
if w.Height() == 0 {
|
|
return
|
|
}
|
|
t.prompt()
|
|
|
|
before, after := t.updatePromptOffset()
|
|
if len(before) == 0 && len(after) == 0 && len(t.ghost) > 0 {
|
|
maxWidth := max(1, w.Width()-t.promptLen-1)
|
|
runes, _ := t.trimRight([]rune(t.ghost), maxWidth)
|
|
w.CPrint(tui.ColGhost, string(runes))
|
|
return
|
|
}
|
|
|
|
color := tui.ColInput
|
|
if t.paused {
|
|
color = tui.ColDisabled
|
|
} else if t.trackBlocked || t.waitFeedback() {
|
|
color = color.WithAttr(tui.Dim)
|
|
}
|
|
w.CPrint(color, string(before))
|
|
w.CPrint(color, string(after))
|
|
}
|
|
|
|
func (t *Terminal) trimMessage(message string, maxWidth int) string {
|
|
if len(message) <= maxWidth {
|
|
return message
|
|
}
|
|
runes, _ := t.trimRight([]rune(message), maxWidth-2)
|
|
return string(runes) + strings.Repeat(".", util.Constrain(maxWidth, 0, 2))
|
|
}
|
|
|
|
func (t *Terminal) printInfo() {
|
|
if t.inputless {
|
|
return
|
|
}
|
|
t.withWindow(t.inputWindow, func() { t.printInfoImpl() })
|
|
}
|
|
|
|
func (t *Terminal) printInfoImpl() {
|
|
if t.window.Width() <= 1 || t.window.Height() == 0 {
|
|
return
|
|
}
|
|
pos := 0
|
|
line := 0
|
|
separatorLen := t.separatorLength()
|
|
maxHeight := t.window.Height()
|
|
move := func(y int, x int, clear bool) bool {
|
|
if y < 0 || y >= maxHeight {
|
|
return false
|
|
}
|
|
t.move(y, x, clear)
|
|
t.markOtherLine(y)
|
|
return true
|
|
}
|
|
printSpinner := func() {
|
|
if t.reading {
|
|
duration := int64(spinnerDuration)
|
|
idx := (time.Now().UnixNano() % (duration * int64(len(t.spinner)))) / duration
|
|
t.window.CPrint(tui.ColSpinner, t.spinner[idx])
|
|
} else {
|
|
t.window.Print(" ") // Clear spinner
|
|
}
|
|
}
|
|
printInfoPrefix := func() {
|
|
str := t.infoPrefix
|
|
maxWidth := t.window.Width() - pos
|
|
width := util.StringWidth(str)
|
|
if width > maxWidth {
|
|
trimmed, _ := t.trimRight([]rune(str), maxWidth)
|
|
str = string(trimmed)
|
|
width = maxWidth
|
|
}
|
|
move(line, pos, separatorLen == 0)
|
|
if t.reading {
|
|
t.window.CPrint(tui.ColSpinner, str)
|
|
} else {
|
|
t.window.CPrint(tui.ColPrompt, str)
|
|
}
|
|
pos += width
|
|
}
|
|
printSeparator := func(fillLength int, pad bool) {
|
|
if separatorLen > 0 {
|
|
t.separator(t.window, fillLength)
|
|
t.window.Print(" ")
|
|
} else if pad {
|
|
t.window.Print(strings.Repeat(" ", fillLength+1))
|
|
}
|
|
}
|
|
|
|
if t.infoStyle == infoHidden {
|
|
if separatorLen > 0 {
|
|
if !move(line+1, 0, false) {
|
|
return
|
|
}
|
|
printSeparator(t.window.Width()-1, false)
|
|
}
|
|
return
|
|
}
|
|
|
|
found := t.resultMerger.Length()
|
|
total := max(found, t.count)
|
|
output := fmt.Sprintf("%d/%d", found, total)
|
|
if t.multi > 0 {
|
|
if t.multi == maxMulti {
|
|
output += fmt.Sprintf(" (%d)", len(t.selected))
|
|
} else {
|
|
output += fmt.Sprintf(" (%d/%d)", len(t.selected), t.multi)
|
|
}
|
|
}
|
|
if t.toggleSort {
|
|
if t.sort {
|
|
output += " +S"
|
|
} else {
|
|
output += " -S"
|
|
}
|
|
}
|
|
if t.track.Global() {
|
|
if t.trackBlocked {
|
|
output += " +T*"
|
|
} else {
|
|
output += " +T"
|
|
}
|
|
} else if t.track.Current() {
|
|
if t.trackBlocked {
|
|
output += " +t*"
|
|
} else {
|
|
output += " +t"
|
|
}
|
|
}
|
|
if t.waitFeedback() {
|
|
output += " (..)"
|
|
}
|
|
// Keep the search progress at the end so the other indicators don't shift
|
|
// as it appears and disappears.
|
|
if t.progress > 0 && t.progress < 100 {
|
|
output += fmt.Sprintf(" (%d%%)", t.progress)
|
|
}
|
|
if t.failed != nil && t.count == 0 {
|
|
output = fmt.Sprintf("[Command failed: %s]", *t.failed)
|
|
}
|
|
var outputPrinter labelPrinter
|
|
outputLen := len(output)
|
|
if t.infoCommand != "" {
|
|
output = t.executeCommand(t.infoCommand, false, true, true, true, output)
|
|
outputPrinter, outputLen = t.ansiLabelPrinter(output, &tui.ColInfo, false)
|
|
}
|
|
|
|
shiftLen := t.queryLen[0] + t.queryLen[1] + 1
|
|
if shiftLen == 1 && len(t.ghost) > 0 {
|
|
shiftLen = util.StringWidth(t.ghost)
|
|
}
|
|
switch t.infoStyle {
|
|
case infoDefault:
|
|
if !move(line+1, 0, separatorLen == 0) {
|
|
return
|
|
}
|
|
printSpinner()
|
|
t.window.Print(" ") // Margin
|
|
pos = 2
|
|
case infoRight:
|
|
if !move(line+1, 0, false) {
|
|
return
|
|
}
|
|
case infoInlineRight:
|
|
pos = t.promptLen + shiftLen
|
|
case infoInline:
|
|
pos = t.promptLen + shiftLen
|
|
printInfoPrefix()
|
|
}
|
|
|
|
if t.infoStyle == infoRight {
|
|
maxWidth := t.window.Width() - 1
|
|
if t.reading {
|
|
// Need space for spinner and a margin column
|
|
maxWidth -= 2
|
|
}
|
|
var fillLength int
|
|
if outputPrinter == nil {
|
|
output = t.trimMessage(output, maxWidth)
|
|
fillLength = t.window.Width() - len(output) - 2
|
|
} else {
|
|
fillLength = t.window.Width() - outputLen - 2
|
|
}
|
|
if t.reading {
|
|
if fillLength >= 2 {
|
|
printSeparator(fillLength-2, true)
|
|
}
|
|
printSpinner()
|
|
t.window.Print(" ")
|
|
} else if fillLength >= 0 {
|
|
printSeparator(fillLength, true)
|
|
}
|
|
if outputPrinter == nil {
|
|
t.window.CPrint(tui.ColInfo, output)
|
|
} else {
|
|
outputPrinter(t.window, maxWidth-1)
|
|
}
|
|
if fillLength >= 0 {
|
|
t.window.Print(" ") // Margin
|
|
}
|
|
return
|
|
}
|
|
|
|
if t.infoStyle == infoInlineRight {
|
|
if len(t.infoPrefix) == 0 {
|
|
move(line, pos, false)
|
|
newPos := max(pos, t.window.Width()-outputLen-3)
|
|
t.window.Print(strings.Repeat(" ", newPos-pos))
|
|
pos = newPos
|
|
if pos < t.window.Width() {
|
|
printSpinner()
|
|
pos++
|
|
}
|
|
if pos < t.window.Width()-1 {
|
|
t.window.Print(" ")
|
|
pos++
|
|
}
|
|
} else {
|
|
pos = max(pos, t.window.Width()-outputLen-util.StringWidth(t.infoPrefix)-1)
|
|
printInfoPrefix()
|
|
}
|
|
}
|
|
|
|
maxWidth := t.window.Width() - pos - 1
|
|
if outputPrinter == nil {
|
|
output = t.trimMessage(output, maxWidth)
|
|
t.window.CPrint(tui.ColInfo, output)
|
|
} else {
|
|
outputPrinter(t.window, maxWidth)
|
|
}
|
|
if t.infoStyle == infoInline && outputLen < maxWidth-1 && t.reading {
|
|
t.window.Print(" ")
|
|
printSpinner()
|
|
outputLen += 2
|
|
}
|
|
|
|
if t.infoStyle == infoInlineRight {
|
|
if separatorLen > 0 {
|
|
if !move(line+1, 0, false) {
|
|
return
|
|
}
|
|
printSeparator(t.window.Width()-1, false)
|
|
}
|
|
return
|
|
}
|
|
|
|
fillLength := maxWidth - outputLen - 1
|
|
if fillLength > 0 {
|
|
t.window.CPrint(tui.ColSeparator, " ")
|
|
printSeparator(fillLength, false)
|
|
}
|
|
}
|
|
|
|
func (t *Terminal) resizeIfNeeded() bool {
|
|
// Check if input border is used and input has changed
|
|
if t.inputBorderShape.Visible() && t.inputWindow == nil && !t.inputless || t.inputWindow != nil && t.inputless {
|
|
t.printAll()
|
|
return true
|
|
}
|
|
|
|
// Inline sections are budget-capped inside wborder, so window.Height() may be
|
|
// smaller than the requested content length. Treat "capped" (height < want) as
|
|
// a no-op to avoid triggering a full redraw on every info/header/footer event
|
|
// when the user has requested more content than fits.
|
|
mismatch := func(shape tui.BorderShape, height, want int) bool {
|
|
if shape == tui.BorderInline && height < want {
|
|
return false
|
|
}
|
|
return height != want
|
|
}
|
|
|
|
// Check footer window
|
|
if len(t.footer) > 0 && (t.footerWindow == nil || mismatch(t.footerBorderShape, t.footerWindow.Height(), len(t.footer))) ||
|
|
len(t.footer) == 0 && t.footerWindow != nil {
|
|
t.printAll()
|
|
return true
|
|
}
|
|
|
|
// Check if the header borders are used and header has changed
|
|
allHeaderLines := t.visibleHeaderLines()
|
|
primaryHeaderLines := allHeaderLines
|
|
needHeaderWindow := t.hasHeaderWindow()
|
|
needHeaderLinesWindow := t.hasHeaderLinesWindow()
|
|
if needHeaderLinesWindow {
|
|
primaryHeaderLines -= t.headerLines
|
|
}
|
|
if (needHeaderWindow && t.headerWindow == nil) ||
|
|
(!needHeaderWindow && t.headerWindow != nil) ||
|
|
(needHeaderWindow && t.headerWindow != nil && mismatch(t.headerBorderShape, t.headerWindow.Height(), primaryHeaderLines)) ||
|
|
(needHeaderLinesWindow && t.headerLinesWindow == nil) ||
|
|
(!needHeaderLinesWindow && t.headerLinesWindow != nil) ||
|
|
(needHeaderLinesWindow && t.headerLinesWindow != nil && mismatch(t.headerLinesShape, t.headerLinesWindow.Height(), t.headerLines)) {
|
|
t.printAll()
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (t *Terminal) printHeader() {
|
|
if !t.headerVisible {
|
|
return
|
|
}
|
|
|
|
// headerWindow is nil when hasHeaderWindow() returned false at resize time,
|
|
// e.g. --header-border=inline combined with empty header content. Don't
|
|
// delegate to printHeaderImpl because its nil-window branch folds the header
|
|
// into the list window, which isn't valid for inline. A nil window is only
|
|
// legitimate when the shape is NOT inline (e.g. header combined with the
|
|
// list when --no-list-border is in effect).
|
|
if !(t.headerBorderShape == tui.BorderInline && t.headerWindow == nil) {
|
|
t.withWindow(t.headerWindow, func() {
|
|
var headerItems []Item
|
|
if !t.hasHeaderLinesWindow() {
|
|
headerItems = t.header
|
|
}
|
|
t.printHeaderImpl(t.headerWindow, t.headerBorderShape, t.header0, headerItems)
|
|
})
|
|
}
|
|
if w, shape := t.determineHeaderLinesShape(); w &&
|
|
!(shape == tui.BorderInline && t.headerLinesWindow == nil) {
|
|
t.withWindow(t.headerLinesWindow, func() {
|
|
t.printHeaderImpl(t.headerLinesWindow, shape, nil, t.header)
|
|
})
|
|
}
|
|
}
|
|
|
|
func (t *Terminal) printFooter() {
|
|
if len(t.footer) == 0 {
|
|
return
|
|
}
|
|
indentSize := t.headerIndent(t.footerBorderShape)
|
|
indent := strings.Repeat(" ", indentSize)
|
|
max := min(len(t.footer), t.footerWindow.Height())
|
|
|
|
// Wrapping is not supported for footer
|
|
wrap := t.wrap
|
|
t.wrap = false
|
|
t.withWindow(t.footerWindow, func() {
|
|
var state *ansiState
|
|
for idx, lineStr := range t.footer[:max] {
|
|
line := idx
|
|
if t.layout != layoutReverse {
|
|
line = max - idx - 1
|
|
}
|
|
trimmed, colors, newState := extractColor(lineStr, state, nil)
|
|
state = newState
|
|
item := &Item{
|
|
text: util.ToChars([]byte(trimmed)),
|
|
colors: colors}
|
|
|
|
t.printHighlighted(Result{item: item},
|
|
tui.ColFooter, tui.ColFooter, false, false, false, line, line, true,
|
|
func(markerClass) int {
|
|
t.footerWindow.Print(indent)
|
|
return indentSize
|
|
}, nil, 0)
|
|
}
|
|
})
|
|
t.wrap = wrap
|
|
}
|
|
|
|
func (t *Terminal) headerIndent(borderShape tui.BorderShape) int {
|
|
return t.headerIndentImpl(t.pointerLen+t.markerLen, borderShape)
|
|
}
|
|
|
|
func (t *Terminal) headerIndentImpl(base int, borderShape tui.BorderShape) int {
|
|
indentSize := base
|
|
if t.listBorderShape.HasLeft() {
|
|
indentSize += 1 + t.borderWidth
|
|
}
|
|
// Section borders with their own left side skip past the list border's left column.
|
|
// Inline sections also skip it, but only when the list border actually has a left,
|
|
// since otherwise the inline window starts flush with the list window.
|
|
if borderShape.HasLeft() || (borderShape == tui.BorderInline && t.listBorderShape.HasLeft()) {
|
|
indentSize -= 1 + t.borderWidth
|
|
if indentSize < 0 {
|
|
indentSize = 0
|
|
}
|
|
}
|
|
return indentSize
|
|
}
|
|
|
|
func (t *Terminal) printHeaderImpl(window tui.Window, borderShape tui.BorderShape, lines1 []string, lines2 []Item) {
|
|
max := t.window.Height()
|
|
var state *ansiState
|
|
needReverse := false
|
|
switch t.layout {
|
|
case layoutDefault, layoutReverseList:
|
|
needReverse = true
|
|
}
|
|
// Wrapping is not supported for header
|
|
wrap := t.wrap
|
|
|
|
// Align header with the list
|
|
// fzf --header-lines 3 --style full --no-list-border
|
|
// fzf --header-lines 3 --style full --no-header-border
|
|
// fzf --header-lines 3 --style full --no-header-border --no-input-border
|
|
indentSize := t.pointerLen + t.markerLen
|
|
if window != nil {
|
|
indentSize = t.headerIndent(borderShape)
|
|
}
|
|
indent := strings.Repeat(" ", indentSize)
|
|
t.wrap = false
|
|
totalLines := len(lines1) + len(lines2)
|
|
for idx := 0; idx < totalLines; idx++ {
|
|
line := idx
|
|
if needReverse && idx < len(lines1) {
|
|
line = len(lines1) - idx - 1
|
|
}
|
|
if !t.inputless && t.inputWindow == nil && window == nil && !t.headerFirst {
|
|
line++
|
|
if !t.noSeparatorLine() {
|
|
line++
|
|
}
|
|
}
|
|
if line >= max {
|
|
continue
|
|
}
|
|
|
|
var item *Item
|
|
if idx < len(lines1) {
|
|
trimmed, colors, newState := extractColor(lines1[idx], state, nil)
|
|
state = newState
|
|
item = &Item{
|
|
text: util.ToChars([]byte(trimmed)),
|
|
colors: colors}
|
|
} else {
|
|
headerItem := lines2[idx-len(lines1)]
|
|
item = &headerItem
|
|
}
|
|
|
|
t.printHighlighted(Result{item: item},
|
|
tui.ColHeader, tui.ColHeader, false, false, false, line, line, true,
|
|
func(markerClass) int {
|
|
t.window.Print(indent)
|
|
return indentSize
|
|
}, nil, 0)
|
|
}
|
|
t.wrap = wrap
|
|
}
|
|
|
|
func (t *Terminal) canSpanMultiLines() bool {
|
|
return (t.multiLine || t.wrap || t.gap > 0) && t.inListWindow()
|
|
}
|
|
|
|
func (t *Terminal) renderBar(line int, barRange [2]int) {
|
|
// If the screen is not filled with the list in non-multi-line mode,
|
|
// scrollbar is not visible at all. But in multi-line mode, we may need
|
|
// to redraw the scrollbar character at the end.
|
|
if t.canSpanMultiLines() {
|
|
t.prevLines[line].hasBar = t.printBar(line, true, barRange)
|
|
}
|
|
}
|
|
|
|
func (t *Terminal) renderEmptyLine(line int, barRange [2]int) {
|
|
t.move(line, 0, true)
|
|
t.markEmptyLine(line)
|
|
t.renderBar(line, barRange)
|
|
}
|
|
|
|
func (t *Terminal) gutter(current bool, alt bool) {
|
|
var color tui.ColorPair
|
|
if current {
|
|
color = tui.ColCurrentPointerEmpty
|
|
} else if !t.raw && t.gutterReverse || t.raw && t.gutterRawReverse {
|
|
if alt {
|
|
color = tui.ColAltPointerEmpty
|
|
} else {
|
|
color = tui.ColPointerEmpty
|
|
}
|
|
} else {
|
|
if alt {
|
|
color = tui.ColAltPointerEmptyChar
|
|
} else {
|
|
color = tui.ColPointerEmptyChar
|
|
}
|
|
}
|
|
gutter := t.pointerEmpty
|
|
if t.raw {
|
|
gutter = t.pointerEmptyRaw
|
|
}
|
|
t.window.CPrint(color, gutter)
|
|
}
|
|
|
|
func (t *Terminal) renderGapLine(line int, barRange [2]int, drawLine bool) {
|
|
t.move(line, 0, false)
|
|
t.gutter(false, false)
|
|
t.window.Print(t.markerEmpty)
|
|
x := t.pointerLen + t.markerLen
|
|
|
|
width := t.window.Width() - x - 1
|
|
if drawLine && t.gapLine != nil {
|
|
t.gapLine(t.window, width)
|
|
} else {
|
|
t.move(line, x, true)
|
|
}
|
|
t.markOtherLine(line)
|
|
t.renderBar(line, barRange)
|
|
t.prevLines[line].width = width
|
|
}
|
|
|
|
func (t *Terminal) printList() {
|
|
t.constrain()
|
|
barLength, barStart := t.getScrollbar()
|
|
|
|
maxy := t.maxItems() - 1
|
|
count := t.merger.Length() - t.offset
|
|
|
|
// Start line
|
|
startLine := t.promptLines() + t.visibleHeaderLinesInList()
|
|
maxy += startLine
|
|
|
|
barRange := [2]int{startLine + barStart, startLine + barStart + barLength}
|
|
for line, itemCount := startLine, 0; line <= maxy; line, itemCount = line+1, itemCount+1 {
|
|
if itemCount < count {
|
|
item := t.merger.Get(itemCount + t.offset)
|
|
current := itemCount == t.cy-t.offset
|
|
line = t.printItem(item, line, maxy, itemCount, current, barRange)
|
|
} else if !t.prevLines[line].empty {
|
|
t.renderEmptyLine(line, barRange)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (t *Terminal) printBar(lineNum int, forceRedraw bool, barRange [2]int) bool {
|
|
hasBar := lineNum >= barRange[0] && lineNum < barRange[1]
|
|
if (hasBar != t.prevLines[lineNum].hasBar || forceRedraw) && t.window.Width() > 0 {
|
|
if len(t.scrollbar) > 0 {
|
|
t.move(lineNum, t.window.Width()-1, true)
|
|
if hasBar {
|
|
t.window.CPrint(tui.ColScrollbar, t.scrollbar)
|
|
}
|
|
}
|
|
}
|
|
return hasBar
|
|
}
|
|
|
|
func (t *Terminal) printItem(result Result, line int, maxLine int, index int, current bool, barRange [2]int) int {
|
|
item := result.item
|
|
matched := true
|
|
var matchResult Result
|
|
if t.raw {
|
|
if matchResult, matched = t.matchMap[item.Index()]; matched {
|
|
result = matchResult
|
|
}
|
|
}
|
|
|
|
_, selected := t.selected[item.Index()]
|
|
label := ""
|
|
extraWidth := 0
|
|
alt := false
|
|
altBg := t.theme.AltBg
|
|
selectedBg := selected && t.theme.SelectedBg != t.theme.ListBg
|
|
if t.jumping != jumpDisabled {
|
|
if index < len(t.jumpLabels) {
|
|
// Striped
|
|
if !altBg.IsColorDefined() {
|
|
altBg = t.theme.DarkBg
|
|
alt = index%2 == 0
|
|
} else {
|
|
alt = index%2 == 1
|
|
}
|
|
label = t.jumpLabels[index:index+1] + strings.Repeat(" ", max(0, t.pointerLen-1))
|
|
if t.pointerLen == 0 {
|
|
extraWidth = 1
|
|
}
|
|
}
|
|
} else {
|
|
if current {
|
|
label = t.pointer
|
|
}
|
|
alt = !selectedBg && altBg.IsColorDefined() && index%2 == 1
|
|
}
|
|
|
|
// Avoid unnecessary redraw
|
|
numLines, _ := t.numItemLines(item, maxLine-line+1)
|
|
newLine := itemLine{valid: true, firstLine: line, numLines: numLines, cy: index + t.offset, current: current, selected: selected, label: label,
|
|
result: result, queryLen: len(t.input), width: 0, hasBar: line >= barRange[0] && line < barRange[1], hidden: !matched}
|
|
prevLine := t.prevLines[line]
|
|
forceRedraw := !prevLine.valid || prevLine.other || prevLine.firstLine != newLine.firstLine
|
|
printBar := func(lineNum int, forceRedraw bool) bool {
|
|
return t.printBar(lineNum, forceRedraw, barRange)
|
|
}
|
|
|
|
if !forceRedraw &&
|
|
prevLine.hidden == newLine.hidden &&
|
|
prevLine.numLines == newLine.numLines &&
|
|
prevLine.current == newLine.current &&
|
|
prevLine.selected == newLine.selected &&
|
|
prevLine.label == newLine.label &&
|
|
prevLine.queryLen == newLine.queryLen &&
|
|
prevLine.result == newLine.result {
|
|
t.prevLines[line].hasBar = printBar(line, false)
|
|
return line + numLines - 1
|
|
}
|
|
|
|
maxWidth := t.window.Width() - (t.pointerLen + t.markerLen + t.barCol())
|
|
postTask := func(lineNum int, width int, wrapped bool, forceRedraw bool, lbg tui.ColorPair) {
|
|
width += extraWidth
|
|
if (current || selected || alt) && t.highlightLine || lbg.IsFullBgMarker() {
|
|
color := tui.ColSelected
|
|
if lbg.IsFullBgMarker() {
|
|
color = lbg
|
|
} else if current {
|
|
color = tui.ColCurrent
|
|
} else if alt {
|
|
color = color.WithBg(altBg)
|
|
}
|
|
fillSpaces := maxWidth - width
|
|
if wrapped {
|
|
fillSpaces -= t.wrapSignWidth
|
|
}
|
|
if fillSpaces > 0 {
|
|
t.window.CPrint(color, strings.Repeat(" ", fillSpaces))
|
|
}
|
|
newLine.width = maxWidth
|
|
} else {
|
|
var fillSpaces int
|
|
if forceRedraw {
|
|
fillSpaces = maxWidth - width
|
|
} else {
|
|
fillSpaces = t.prevLines[lineNum].width - width
|
|
}
|
|
if wrapped {
|
|
fillSpaces -= t.wrapSignWidth
|
|
}
|
|
if fillSpaces > 0 {
|
|
t.window.Print(strings.Repeat(" ", fillSpaces))
|
|
}
|
|
newLine.width = width
|
|
if wrapped {
|
|
newLine.width += t.wrapSignWidth
|
|
}
|
|
}
|
|
// When width is 0, line is completely cleared. We need to redraw scrollbar
|
|
newLine.hasBar = printBar(lineNum, forceRedraw || width == 0)
|
|
t.prevLines[lineNum] = newLine
|
|
}
|
|
|
|
var finalLineNum int
|
|
markerFor := func(markerClass markerClass) string {
|
|
marker := t.marker
|
|
switch markerClass {
|
|
case markerTop:
|
|
marker = t.markerMultiLine[0]
|
|
case markerMiddle:
|
|
marker = t.markerMultiLine[1]
|
|
case markerBottom:
|
|
marker = t.markerMultiLine[2]
|
|
}
|
|
return marker
|
|
}
|
|
indentSize := t.pointerLen + t.markerLen
|
|
if current {
|
|
preTask := func(marker markerClass) int {
|
|
w := t.window.Width() - t.pointerLen
|
|
if w < 0 {
|
|
return indentSize
|
|
}
|
|
if len(label) == 0 {
|
|
t.gutter(true, false)
|
|
} else {
|
|
t.window.CPrint(tui.ColCurrentPointer, label)
|
|
}
|
|
if w-t.markerLen < 0 {
|
|
return indentSize
|
|
}
|
|
if selected {
|
|
t.window.CPrint(tui.ColCurrentMarker, markerFor(marker))
|
|
} else {
|
|
t.window.CPrint(tui.ColCurrentSelectedEmpty, t.markerEmpty)
|
|
}
|
|
return indentSize
|
|
}
|
|
colCurrent := tui.ColCurrent
|
|
nthOverlay := t.theme.NthCurrentAttr
|
|
if selected {
|
|
nthOverlay = t.theme.NthSelectedAttr.Merge(t.theme.NthCurrentAttr)
|
|
baseAttr := tui.ColNormal.Attr().Merge(t.theme.NthSelectedAttr).Merge(t.theme.NthCurrentAttr)
|
|
colCurrent = colCurrent.WithNewAttr(baseAttr)
|
|
}
|
|
finalLineNum = t.printHighlighted(result, colCurrent, tui.ColCurrentMatch, true, true, !matched, line, maxLine, forceRedraw, preTask, postTask, nthOverlay)
|
|
} else {
|
|
preTask := func(marker markerClass) int {
|
|
w := t.window.Width() - t.pointerLen
|
|
if w < 0 {
|
|
return indentSize
|
|
}
|
|
if len(label) == 0 {
|
|
t.gutter(false, index%2 == 1)
|
|
} else {
|
|
t.window.CPrint(tui.ColPointer, label)
|
|
}
|
|
if w-t.markerLen < 0 {
|
|
return indentSize
|
|
}
|
|
if selected {
|
|
t.window.CPrint(tui.ColMarker, markerFor(marker))
|
|
} else {
|
|
t.window.Print(t.markerEmpty)
|
|
}
|
|
return indentSize
|
|
}
|
|
var base, match tui.ColorPair
|
|
if selected {
|
|
base = tui.ColSelected
|
|
match = tui.ColSelectedMatch
|
|
} else {
|
|
base = tui.ColNormal
|
|
match = tui.ColMatch
|
|
}
|
|
if alt {
|
|
base = base.WithBg(altBg)
|
|
match = match.WithBg(altBg)
|
|
}
|
|
var nthOverlay tui.Attr
|
|
if selected {
|
|
nthOverlay = t.theme.NthSelectedAttr
|
|
}
|
|
finalLineNum = t.printHighlighted(result, base, match, false, true, !matched, line, maxLine, forceRedraw, preTask, postTask, nthOverlay)
|
|
}
|
|
for i := 0; i < t.gap && finalLineNum < maxLine; i++ {
|
|
finalLineNum++
|
|
t.renderGapLine(finalLineNum, barRange, i == t.gap-1)
|
|
}
|
|
return finalLineNum
|
|
}
|
|
|
|
func (t *Terminal) trimRight(runes []rune, width int) ([]rune, bool) {
|
|
// We start from the beginning to handle tab characters
|
|
_, overflowIdx := util.RunesWidth(runes, 0, t.tabstop, width)
|
|
if overflowIdx >= 0 {
|
|
return runes[:overflowIdx], true
|
|
}
|
|
return runes, false
|
|
}
|
|
|
|
func (t *Terminal) displayWidthWithLimit(runes []rune, prefixWidth int, limit int) int {
|
|
width, _ := util.RunesWidth(runes, prefixWidth, t.tabstop, limit)
|
|
return width
|
|
}
|
|
|
|
func (t *Terminal) trimLeft(runes []rune, width int, ellipsisWidth int) ([]rune, int32) {
|
|
width = max(0, width)
|
|
var trimmed int32
|
|
|
|
str := string(runes)
|
|
runningSum := 0
|
|
runningSumAdjusted := 0
|
|
// We can't just subtract the width on each segment because there might be
|
|
// a tab character afterwards. For example, with the tabstop = 8:
|
|
// 1234____5678
|
|
// 234_____5678
|
|
// 34______5678
|
|
// 4_______5678
|
|
// ________5678
|
|
// 5678
|
|
// 678
|
|
// 78
|
|
// 8
|
|
// We need to look ahead, but not to the end to avoid performance hit.
|
|
type queuedSegment struct {
|
|
rs []rune
|
|
w int
|
|
}
|
|
allQueue := []queuedSegment{}
|
|
queuedWidth := 0
|
|
limit := width - ellipsisWidth
|
|
processQueue := func() {
|
|
for idx, item := range allQueue {
|
|
if runningSumAdjusted <= limit {
|
|
allQueue = allQueue[idx:]
|
|
return
|
|
}
|
|
runningSumAdjusted -= item.w
|
|
runes = runes[len(item.rs):]
|
|
trimmed += int32(len(item.rs))
|
|
}
|
|
allQueue = []queuedSegment{}
|
|
}
|
|
|
|
gr := uniseg.NewGraphemes(str)
|
|
queue := []queuedSegment{}
|
|
for gr.Next() {
|
|
s := gr.Str()
|
|
rs := gr.Runes()
|
|
|
|
var w int
|
|
if len(rs) == 1 && rs[0] == '\t' {
|
|
w = t.tabstop - runningSum%t.tabstop
|
|
} else {
|
|
w = util.StringWidth(string(rs))
|
|
}
|
|
runningSum += w
|
|
runningSumAdjusted += w
|
|
queue = append(queue, queuedSegment{rs: rs, w: w})
|
|
queuedWidth += w
|
|
if queuedWidth >= t.tabstop || s == "\t" {
|
|
queuedWidth = 0
|
|
|
|
if s == "\t" {
|
|
queue[len(queue)-1].w = t.tabstop
|
|
for idx := range queue[:len(queue)-1] {
|
|
queue[idx].w = 0
|
|
}
|
|
}
|
|
allQueue = append(allQueue, queue...)
|
|
queue = []queuedSegment{}
|
|
processQueue()
|
|
}
|
|
}
|
|
allQueue = append(allQueue, queue...)
|
|
processQueue()
|
|
return runes, trimmed
|
|
}
|
|
|
|
func (t *Terminal) overflow(runes []rune, max int) bool {
|
|
return t.displayWidthWithLimit(runes, 0, max) > max
|
|
}
|
|
|
|
func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMatch tui.ColorPair, current bool, match bool, hidden bool, lineNum int, maxLineNum int, forceRedraw bool, preTask func(markerClass) int, postTask func(int, int, bool, bool, tui.ColorPair), nthOverlay tui.Attr) int {
|
|
var displayWidth int
|
|
item := result.item
|
|
matchOffsets := []Offset{}
|
|
var pos *[]int
|
|
if match && t.resultMerger.pattern != nil {
|
|
_, matchOffsets, pos = t.resultMerger.pattern.MatchItem(item, true, t.slab)
|
|
}
|
|
charOffsets := matchOffsets
|
|
if pos != nil {
|
|
runes := item.text.ToRunes()
|
|
charOffsets = make([]Offset, len(*pos))
|
|
for idx, p := range *pos {
|
|
gr := uniseg.NewGraphemes(string(runes[p:]))
|
|
w := 1
|
|
for gr.Next() {
|
|
w = len(gr.Runes())
|
|
break
|
|
}
|
|
offset := Offset{int32(p), int32(p + w)}
|
|
charOffsets[idx] = offset
|
|
}
|
|
slices.SortFunc(charOffsets, compareOffsets)
|
|
}
|
|
|
|
// When postTask is nil, we're printing header lines. No need to care about nth.
|
|
var nthOffsets []Offset
|
|
if postTask != nil {
|
|
wholeCovered := len(t.nthCurrent) == 0
|
|
for _, nth := range t.nthCurrent {
|
|
// Do we still want to apply a different style when the current nth
|
|
// covers the whole string? Probably not. And we can simplify the logic.
|
|
if nth.IsFull() {
|
|
wholeCovered = true
|
|
break
|
|
}
|
|
}
|
|
if wholeCovered && t.nthAttr&tui.AttrRegular > 0 {
|
|
// But if 'nth' is set to 'regular', it's a sign that you're applying
|
|
// a different style to the rest of the string. e.g. 'nth:regular,fg:dim'
|
|
// In this case, we still need to apply it to clear the style.
|
|
fgAttr := tui.ColNormal.Attr()
|
|
nthAttrFinal := fgAttr.Merge(t.nthAttr).Merge(nthOverlay)
|
|
colBase = colBase.WithNewAttr(nthAttrFinal)
|
|
}
|
|
if !wholeCovered && t.nthAttr > 0 {
|
|
var tokens []Token
|
|
if item.transformed != nil && item.transformed.revision == t.resultMerger.revision {
|
|
tokens = item.transformed.tokens
|
|
} else {
|
|
tokens = Transform(Tokenize(item.text.ToString(), t.delimiter), t.nthCurrent)
|
|
}
|
|
nthOffsets = make([]Offset, len(tokens))
|
|
for i, token := range tokens {
|
|
start := token.prefixLength
|
|
length := token.text.Length() - token.text.TrailingWhitespaces()
|
|
end := start + int32(length)
|
|
nthOffsets[i] = Offset{int32(start), int32(end)}
|
|
}
|
|
slices.SortFunc(nthOffsets, compareOffsets)
|
|
}
|
|
}
|
|
allOffsets := result.colorOffsets(charOffsets, nthOffsets, t.theme, colBase, colMatch, t.nthAttr, nthOverlay, hidden)
|
|
|
|
// Determine split offset for horizontal scrolling with freeze
|
|
splitOffset1 := -1
|
|
splitOffset2 := -1
|
|
if t.hscroll && !t.wrap {
|
|
var tokens []Token
|
|
if t.freezeLeft > 0 || t.freezeRight > 0 {
|
|
tokens = Tokenize(item.text.ToString(), t.delimiter)
|
|
}
|
|
|
|
// 0 | 1 | 2 | 3 | 4 | 5
|
|
// ------> <------
|
|
if t.freezeLeft > 0 {
|
|
if len(tokens) > 0 {
|
|
token := tokens[min(t.freezeLeft, len(tokens))-1]
|
|
splitOffset1 = int(token.prefixLength) + token.text.Length() - token.text.TrailingWhitespaces()
|
|
}
|
|
}
|
|
if t.freezeRight > 0 {
|
|
index := max(t.freezeLeft-1, len(tokens)-t.freezeRight-1)
|
|
if index < 0 {
|
|
splitOffset2 = 0
|
|
} else if index >= t.freezeLeft {
|
|
token := tokens[index]
|
|
delimiter := strings.TrimLeftFunc(GetLastDelimiter(token.text.ToString(), t.delimiter), unicode.IsSpace)
|
|
splitOffset2 = int(token.prefixLength) + token.text.Length() - len([]rune(delimiter))
|
|
}
|
|
splitOffset2 = max(splitOffset2, splitOffset1)
|
|
}
|
|
}
|
|
|
|
maxLines := 1
|
|
if t.canSpanMultiLines() {
|
|
maxLines = maxLineNum - lineNum + 1
|
|
}
|
|
lines, overflow := t.itemLines(item, maxLines)
|
|
numItemLines := len(lines)
|
|
|
|
finalLineNum := lineNum
|
|
topCutoff := false
|
|
skipLines := 0
|
|
wrapped := false
|
|
if t.canSpanMultiLines() {
|
|
// Cut off the upper lines in the 'default' layout
|
|
if t.layout == layoutDefault && !current && maxLines == numItemLines && overflow {
|
|
lines, _ = t.itemLines(item, math.MaxInt)
|
|
|
|
// To see if the first visible line is wrapped, we need to check the last cut-off line
|
|
prevLine := lines[len(lines)-maxLines-1]
|
|
if len(prevLine) == 0 || prevLine[len(prevLine)-1] != '\n' {
|
|
wrapped = true
|
|
}
|
|
|
|
skipLines = len(lines) - maxLines
|
|
topCutoff = true
|
|
}
|
|
}
|
|
from := 0
|
|
for lineOffset := 0; lineOffset < len(lines) && (lineNum <= maxLineNum || maxLineNum == 0); lineOffset++ {
|
|
line := lines[lineOffset]
|
|
finalLineNum = lineNum
|
|
offsets := []colorOffset{}
|
|
lbg := tui.NoColorPair()
|
|
for idx, offset := range allOffsets {
|
|
lineEnd := int32(from + len(line))
|
|
if offset.offset[0] >= lineEnd {
|
|
if offset.IsFullBgMarker(lineEnd) {
|
|
lbg = offset.color
|
|
}
|
|
allOffsets = allOffsets[idx:]
|
|
break
|
|
}
|
|
|
|
if offset.offset[0] < int32(from) {
|
|
continue
|
|
}
|
|
|
|
if offset.offset[1] < lineEnd {
|
|
offset.offset[0] -= int32(from)
|
|
offset.offset[1] -= int32(from)
|
|
offsets = append(offsets, offset)
|
|
} else {
|
|
if idx < len(allOffsets)-1 {
|
|
next := allOffsets[idx+1]
|
|
if next.IsFullBgMarker(lineEnd) {
|
|
lbg = next.color
|
|
idx++
|
|
}
|
|
}
|
|
dupe := offset
|
|
dupe.offset[0] = lineEnd
|
|
|
|
offset.offset[0] -= int32(from)
|
|
offset.offset[1] = lineEnd
|
|
offsets = append(offsets, offset)
|
|
|
|
allOffsets = append([]colorOffset{dupe}, allOffsets[idx+1:]...)
|
|
break
|
|
}
|
|
}
|
|
splitOffsetLeft := 0
|
|
if splitOffset1 >= 0 && splitOffset1 > from && splitOffset1 < from+len(line) {
|
|
splitOffsetLeft = splitOffset1 - from
|
|
}
|
|
splitOffsetRight := -1
|
|
if splitOffset2 >= 0 && splitOffset2 >= from && splitOffset2 < from+len(line) {
|
|
splitOffsetRight = splitOffset2 - from
|
|
}
|
|
from += len(line)
|
|
if lineOffset < skipLines {
|
|
continue
|
|
}
|
|
actualLineOffset := lineOffset - skipLines
|
|
|
|
var maxEnd int
|
|
for _, offset := range offsets {
|
|
if offset.match {
|
|
maxEnd = max(maxEnd, int(offset.offset[1]))
|
|
}
|
|
}
|
|
|
|
actualLineNum := lineNum
|
|
if t.layout == layoutDefault {
|
|
actualLineNum = (lineNum - actualLineOffset) + (numItemLines - actualLineOffset) - 1
|
|
}
|
|
t.move(actualLineNum, 0, forceRedraw && postTask == nil)
|
|
|
|
indentSize := t.pointerLen + t.markerLen
|
|
if preTask != nil {
|
|
var marker markerClass
|
|
if numItemLines == 1 {
|
|
if !overflow {
|
|
marker = markerSingle
|
|
} else if topCutoff {
|
|
marker = markerBottom
|
|
} else {
|
|
marker = markerTop
|
|
}
|
|
} else {
|
|
if actualLineOffset == 0 { // First line
|
|
if topCutoff {
|
|
marker = markerMiddle
|
|
} else {
|
|
marker = markerTop
|
|
}
|
|
} else if actualLineOffset == numItemLines-1 { // Last line
|
|
if topCutoff || !overflow {
|
|
marker = markerBottom
|
|
} else {
|
|
marker = markerMiddle
|
|
}
|
|
} else {
|
|
marker = markerMiddle
|
|
}
|
|
}
|
|
|
|
indentSize = preTask(marker)
|
|
}
|
|
|
|
maxWidth := t.window.Width() - (indentSize + t.barCol())
|
|
wasWrapped := false
|
|
if wrapped {
|
|
wrapSign := t.wrapSign
|
|
if maxWidth < t.wrapSignWidth {
|
|
runes, _ := util.Truncate(wrapSign, maxWidth)
|
|
wrapSign = string(runes)
|
|
maxWidth = 0
|
|
} else {
|
|
maxWidth -= t.wrapSignWidth
|
|
}
|
|
t.window.CPrint(colBase.WithAttr(tui.Dim), wrapSign)
|
|
wrapped = false
|
|
wasWrapped = true
|
|
}
|
|
|
|
if len(line) > 0 && line[len(line)-1] == '\n' && lineOffset < len(lines)-1 {
|
|
line = line[:len(line)-1]
|
|
} else {
|
|
wrapped = true
|
|
}
|
|
|
|
frozenLeft := line[:splitOffsetLeft]
|
|
middle := line[splitOffsetLeft:]
|
|
frozenRight := []rune{}
|
|
if splitOffsetRight >= splitOffsetLeft {
|
|
middle = line[splitOffsetLeft:splitOffsetRight]
|
|
frozenRight = line[splitOffsetRight:]
|
|
}
|
|
displayWidthSum := 0
|
|
displayWidthLeft := 0
|
|
todo := [3]func(){}
|
|
for fidx, runes := range [][]rune{frozenLeft, frozenRight, middle} {
|
|
if len(runes) == 0 {
|
|
continue
|
|
}
|
|
shift := 0
|
|
maxe := maxEnd
|
|
offs := make([]colorOffset, len(offsets))
|
|
for idx := range offsets {
|
|
offs[idx] = offsets[idx]
|
|
if fidx == 1 && splitOffsetRight > 0 {
|
|
shift = splitOffsetRight
|
|
} else if fidx == 2 && splitOffsetLeft > 0 {
|
|
shift = splitOffsetLeft
|
|
}
|
|
offs[idx].offset[0] -= int32(shift)
|
|
offs[idx].offset[1] -= int32(shift)
|
|
}
|
|
maxe -= shift
|
|
ellipsis, ellipsisWidth := util.Truncate(t.ellipsis, maxWidth)
|
|
adjustedMaxWidth := maxWidth
|
|
if fidx < 2 {
|
|
// For frozen parts, reserve space for the ellipsis in the middle part
|
|
adjustedMaxWidth -= ellipsisWidth
|
|
}
|
|
var prefixWidth int
|
|
if fidx == 2 {
|
|
prefixWidth = displayWidthLeft
|
|
}
|
|
displayWidth = t.displayWidthWithLimit(runes, prefixWidth, adjustedMaxWidth)
|
|
if !t.wrap && displayWidth > adjustedMaxWidth {
|
|
maxe = util.Constrain(maxe+min(maxWidth/2-ellipsisWidth, t.hscrollOff), 0, len(runes))
|
|
transformOffsets := func(diff int32) {
|
|
for idx := range offs {
|
|
offs[idx].offset[0] -= diff
|
|
offs[idx].offset[1] -= diff
|
|
}
|
|
}
|
|
if t.hscroll {
|
|
if fidx == 1 || fidx == 2 && t.keepRight && pos == nil {
|
|
trimmed, diff := t.trimLeft(runes, maxWidth, ellipsisWidth)
|
|
transformOffsets(diff - int32(len(ellipsis)))
|
|
runes = append(ellipsis, trimmed...)
|
|
} else if fidx == 0 || !t.overflow(runes[:maxe], maxWidth-ellipsisWidth) {
|
|
// Stri..
|
|
runes, _ = t.trimRight(runes, maxWidth-ellipsisWidth)
|
|
runes = append(runes, ellipsis...)
|
|
} else {
|
|
// Stri..
|
|
if t.overflow(runes[maxe:], ellipsisWidth) {
|
|
runes = append(runes[:maxe], ellipsis...)
|
|
}
|
|
// ..ri..
|
|
var diff int32
|
|
runes, diff = t.trimLeft(runes, maxWidth, ellipsisWidth)
|
|
|
|
// Transform offsets
|
|
transformOffsets(diff - int32(len(ellipsis)))
|
|
runes = append(ellipsis, runes...)
|
|
}
|
|
} else {
|
|
runes, _ = t.trimRight(runes, maxWidth-ellipsisWidth)
|
|
runes = append(runes, ellipsis...)
|
|
}
|
|
displayWidth = t.displayWidthWithLimit(runes, 0, maxWidth)
|
|
}
|
|
displayWidthSum += displayWidth
|
|
if fidx == 0 {
|
|
displayWidthLeft = displayWidth
|
|
}
|
|
|
|
if maxWidth > 0 {
|
|
color := colBase
|
|
if hidden {
|
|
color = color.WithFg(t.theme.Nomatch)
|
|
}
|
|
todo[fidx] = func() {
|
|
t.printColoredString(t.window, runes, offs, color, prefixWidth)
|
|
}
|
|
} else {
|
|
break
|
|
}
|
|
maxWidth -= displayWidth
|
|
}
|
|
if todo[0] != nil {
|
|
todo[0]()
|
|
}
|
|
if todo[2] != nil {
|
|
todo[2]()
|
|
}
|
|
if todo[1] != nil {
|
|
todo[1]()
|
|
}
|
|
if postTask != nil {
|
|
postTask(actualLineNum, displayWidthSum, wasWrapped, forceRedraw, lbg)
|
|
} else {
|
|
t.markOtherLine(actualLineNum)
|
|
}
|
|
lineNum += 1
|
|
}
|
|
|
|
return finalLineNum
|
|
}
|
|
|
|
func (t *Terminal) printColoredString(window tui.Window, text []rune, offsets []colorOffset, colBase tui.ColorPair, initialPrefixWidth ...int) {
|
|
var index int32
|
|
var substr string
|
|
var prefixWidth int
|
|
if len(initialPrefixWidth) > 0 {
|
|
prefixWidth = initialPrefixWidth[0]
|
|
}
|
|
maxOffset := int32(len(text))
|
|
var url *url
|
|
for _, offset := range offsets {
|
|
b := util.Constrain(offset.offset[0], index, maxOffset)
|
|
e := util.Constrain(offset.offset[1], index, maxOffset)
|
|
if url != nil && offset.url != url {
|
|
url = nil
|
|
window.LinkEnd()
|
|
}
|
|
|
|
substr, prefixWidth = t.processTabs(text[index:b], prefixWidth)
|
|
window.CPrint(colBase, substr)
|
|
|
|
if b < e {
|
|
substr, prefixWidth = t.processTabs(text[b:e], prefixWidth)
|
|
if url == nil && offset.url != nil {
|
|
url = offset.url
|
|
window.LinkBegin(url.uri, url.params)
|
|
}
|
|
window.CPrint(offset.color, substr)
|
|
}
|
|
|
|
index = e
|
|
if index >= maxOffset {
|
|
break
|
|
}
|
|
}
|
|
if url != nil {
|
|
window.LinkEnd()
|
|
}
|
|
if index < maxOffset {
|
|
substr, _ = t.processTabs(text[index:], prefixWidth)
|
|
window.CPrint(colBase, substr)
|
|
}
|
|
}
|
|
|
|
func (t *Terminal) renderPreviewSpinner() {
|
|
numLines := len(t.previewer.lines)
|
|
spin := t.previewer.spinner
|
|
if len(spin) > 0 || t.previewer.scrollable {
|
|
maxWidth := t.pwindow.Width()
|
|
if !t.previewer.scrollable || !t.activePreviewOpts.info {
|
|
if maxWidth > 0 {
|
|
t.pwindow.Move(0, maxWidth-1)
|
|
t.pwindow.CPrint(tui.ColPreviewSpinner, spin)
|
|
}
|
|
} else {
|
|
offsetString := fmt.Sprintf("%d/%d", t.previewer.offset+1, numLines)
|
|
if len(spin) > 0 {
|
|
spin += " "
|
|
maxWidth -= 2
|
|
}
|
|
offsetRunes, _ := t.trimRight([]rune(offsetString), maxWidth)
|
|
pos := maxWidth - t.displayWidth(offsetRunes)
|
|
t.pwindow.Move(0, pos)
|
|
if maxWidth > 0 {
|
|
t.pwindow.CPrint(tui.ColPreviewSpinner, spin)
|
|
t.pwindow.CPrint(tui.ColInfo.WithAttr(tui.Reverse), string(offsetRunes))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (t *Terminal) renderPreviewArea(unchanged bool) {
|
|
if t.previewed.wipe && t.previewed.version != t.previewer.version {
|
|
t.previewed.wipe = false
|
|
t.pwindow.Erase()
|
|
} else if unchanged {
|
|
t.pwindow.MoveAndClear(0, 0) // Clear scroll offset display
|
|
} else {
|
|
t.previewed.filled = false
|
|
// We don't erase the window here to avoid flickering during scroll.
|
|
// However, tcell renderer uses double-buffering technique and there's no
|
|
// flickering. So we just erase the window and make the rest of the code
|
|
// simpler.
|
|
if !t.pwindow.EraseMaybe() {
|
|
t.pwindow.DrawBorder()
|
|
t.pwindow.Move(0, 0)
|
|
}
|
|
}
|
|
|
|
height := t.pwindow.Height()
|
|
body := t.previewer.lines
|
|
headerLines := t.activePreviewOpts.headerLines
|
|
// Do not enable preview header lines if it's value is too large
|
|
if headerLines > 0 && headerLines < min(len(body), height) {
|
|
header := t.previewer.lines[0:headerLines]
|
|
body = t.previewer.lines[headerLines:]
|
|
// Always redraw header
|
|
t.renderPreviewText(height, header, 0, false)
|
|
t.pwindow.MoveAndClear(t.pwindow.Y(), 0)
|
|
}
|
|
t.renderPreviewText(height, body, -t.previewer.offset+headerLines, unchanged)
|
|
|
|
if !unchanged {
|
|
t.pwindow.FinishFill()
|
|
}
|
|
|
|
if len(t.scrollbar) == 0 {
|
|
return
|
|
}
|
|
|
|
effectiveHeight := height - headerLines
|
|
barLength, barStart := getScrollbar(1, len(body), effectiveHeight, min(len(body)-effectiveHeight, t.previewer.offset-headerLines))
|
|
t.renderPreviewScrollbar(headerLines, barLength, barStart)
|
|
}
|
|
|
|
func (t *Terminal) makeImageBorder(width int, top bool) string {
|
|
tl := "┌"
|
|
tr := "┐"
|
|
v := "╎"
|
|
h := "╌"
|
|
if !t.unicode {
|
|
tl = "+"
|
|
tr = "+"
|
|
h = "-"
|
|
v = "|"
|
|
}
|
|
repeat := max(0, width-2)
|
|
if top {
|
|
return tl + strings.Repeat(h, repeat) + tr
|
|
}
|
|
return v + strings.Repeat(" ", repeat) + v
|
|
}
|
|
|
|
func findPassThrough(line string) []int {
|
|
loc := passThroughBeginRegex.FindStringIndex(line)
|
|
if loc == nil {
|
|
return nil
|
|
}
|
|
|
|
rest := line[loc[0]:]
|
|
after := line[loc[1]:]
|
|
if strings.HasPrefix(rest, "\x1bPtmux") { // Tmux
|
|
eloc := passThroughEndTmuxRegex.FindStringIndex(after)
|
|
if eloc == nil {
|
|
return nil
|
|
}
|
|
return []int{loc[0], loc[1] + eloc[1]}
|
|
} else if strings.HasPrefix(rest, "\x1b]1337;") { // iTerm2
|
|
index := loc[1]
|
|
for {
|
|
after := line[index:]
|
|
pos := strings.IndexAny(after, "\x1b\a")
|
|
if pos < 0 {
|
|
return nil
|
|
}
|
|
if after[pos] == '\a' {
|
|
return []int{loc[0], index + pos + 1}
|
|
}
|
|
if pos < len(after)-1 && after[pos+1] == '\\' {
|
|
return []int{loc[0], index + pos + 2}
|
|
}
|
|
index += pos + 1
|
|
}
|
|
}
|
|
// Kitty
|
|
pos := strings.Index(after, "\x1b\\")
|
|
if pos < 0 {
|
|
return nil
|
|
}
|
|
if pos < len(after)-2 && after[pos+2] == '\r' {
|
|
return []int{loc[0], loc[1] + pos + 3}
|
|
}
|
|
return []int{loc[0], loc[1] + pos + 2}
|
|
}
|
|
|
|
func extractPassThroughs(line string) ([]string, string) {
|
|
passThroughs := []string{}
|
|
transformed := ""
|
|
index := 0
|
|
for {
|
|
rest := line[index:]
|
|
loc := findPassThrough(rest)
|
|
if loc == nil {
|
|
transformed += rest
|
|
break
|
|
}
|
|
passThroughs = append(passThroughs, rest[loc[0]:loc[1]])
|
|
transformed += line[index : index+loc[0]]
|
|
index += loc[1]
|
|
}
|
|
|
|
return passThroughs, transformed
|
|
}
|
|
|
|
// followOffset computes the correct content-line offset for follow mode,
|
|
// accounting for line wrapping in the preview window.
|
|
func (t *Terminal) followOffset() int {
|
|
lines := t.previewer.lines
|
|
headerLines := t.activePreviewOpts.headerLines
|
|
height := t.pwindow.Height() - headerLines
|
|
if height <= 0 || len(lines) <= headerLines {
|
|
return headerLines
|
|
}
|
|
|
|
body := lines[headerLines:]
|
|
if !t.activePreviewOpts.wrap {
|
|
return max(t.previewer.offset, headerLines+len(body)-height)
|
|
}
|
|
|
|
maxWidth := t.pwindow.Width()
|
|
visualLines := 0
|
|
for i := len(body) - 1; i >= 0; i-- {
|
|
h := t.previewLineHeight(body[i], maxWidth)
|
|
if visualLines+h > height {
|
|
return min(len(lines)-1, headerLines+i+1)
|
|
}
|
|
visualLines += h
|
|
}
|
|
return headerLines
|
|
}
|
|
|
|
// previewLineHeight estimates the number of visual lines a preview content line
|
|
// occupies when wrapping is enabled.
|
|
func (t *Terminal) previewLineHeight(line string, maxWidth int) int {
|
|
if maxWidth <= 0 {
|
|
return 1
|
|
}
|
|
|
|
// For word-wrap mode, count the sub-lines produced by word wrapping.
|
|
// Each sub-line may still char-wrap if it contains a word longer than the width.
|
|
if t.activePreviewOpts.wrapWord {
|
|
subLines := t.wordWrapAnsiLine(line, maxWidth, t.previewWrapSignWidth)
|
|
total := 0
|
|
for i, sub := range subLines {
|
|
prefixWidth := 0
|
|
cols := maxWidth
|
|
if i > 0 {
|
|
prefixWidth = t.previewWrapSignWidth
|
|
cols -= t.previewWrapSignWidth
|
|
}
|
|
w := t.ansiLineWidth(sub, prefixWidth)
|
|
if cols <= 0 {
|
|
cols = 1
|
|
}
|
|
total += max(1, (w+cols-1)/cols)
|
|
}
|
|
return total
|
|
}
|
|
|
|
// For char-wrap, compute visible width and divide by available width.
|
|
w := t.ansiLineWidth(line, 0)
|
|
if w <= maxWidth {
|
|
return 1
|
|
}
|
|
remaining := w - maxWidth
|
|
contWidth := max(1, maxWidth-t.previewWrapSignWidth)
|
|
return 1 + (remaining+contWidth-1)/contWidth
|
|
}
|
|
|
|
// ansiLineWidth computes the display width of a string, skipping ANSI escape sequences.
|
|
// prefixWidth is the visual offset where the content starts (e.g. wrap sign width for
|
|
// continuation lines), used for correct tab stop alignment.
|
|
func (t *Terminal) ansiLineWidth(line string, prefixWidth int) int {
|
|
line = strings.TrimSuffix(line, "\n")
|
|
trimmed, _, _ := extractColor(line, nil, nil)
|
|
_, width := t.processTabsStr(trimmed, prefixWidth)
|
|
return width - prefixWidth
|
|
}
|
|
|
|
func (t *Terminal) wordWrapAnsiLine(line string, maxWidth int, wrapSignWidth int) []string {
|
|
if maxWidth <= 0 {
|
|
return []string{line}
|
|
}
|
|
|
|
var result []string
|
|
lineStart := 0
|
|
width := 0
|
|
lastSpaceStart := -1
|
|
lastSpaceEnd := -1
|
|
widthBeforeLastSpace := 0
|
|
lastSpaceWidth := 0
|
|
max := maxWidth
|
|
pos := 0
|
|
|
|
for pos < len(line) {
|
|
// Find next ANSI escape sequence
|
|
start, end := nextAnsiEscapeSequence(line[pos:])
|
|
|
|
// Determine the end of printable text before the next escape
|
|
var printableEnd int
|
|
if start < 0 {
|
|
printableEnd = len(line)
|
|
} else {
|
|
printableEnd = pos + start
|
|
}
|
|
|
|
// Process printable characters using grapheme clusters
|
|
gr := uniseg.NewGraphemes(line[pos:printableEnd])
|
|
for gr.Next() {
|
|
gStart, gEnd := gr.Positions()
|
|
w := gr.Width()
|
|
str := gr.Str()
|
|
|
|
if str == "\t" {
|
|
w = t.tabstop - (width % t.tabstop)
|
|
}
|
|
|
|
if str == " " || str == "\t" {
|
|
lastSpaceStart = pos + gStart
|
|
lastSpaceEnd = pos + gEnd
|
|
widthBeforeLastSpace = width
|
|
lastSpaceWidth = w
|
|
}
|
|
|
|
width += w
|
|
|
|
if width > max && lastSpaceEnd > lineStart {
|
|
result = append(result, line[lineStart:lastSpaceStart])
|
|
lineStart = lastSpaceEnd
|
|
width -= widthBeforeLastSpace + lastSpaceWidth
|
|
lastSpaceStart = -1
|
|
lastSpaceEnd = -1
|
|
widthBeforeLastSpace = 0
|
|
max = maxWidth - wrapSignWidth
|
|
}
|
|
}
|
|
pos = printableEnd
|
|
|
|
// Skip the ANSI escape sequence
|
|
if start >= 0 {
|
|
pos += end - start
|
|
}
|
|
}
|
|
|
|
result = append(result, line[lineStart:])
|
|
return result
|
|
}
|
|
|
|
func (t *Terminal) renderPreviewText(height int, lines []string, lineNo int, unchanged bool) {
|
|
maxWidth := t.pwindow.Width()
|
|
var ansi *ansiState
|
|
spinnerRedraw := t.pwindow.Y() == 0
|
|
wiped := false
|
|
image := false
|
|
wireframe := false
|
|
var index int
|
|
var line string
|
|
Loop:
|
|
for index, line = range lines {
|
|
var lbg tui.Color = -1
|
|
if ansi != nil {
|
|
ansi.lbg = -1
|
|
}
|
|
|
|
passThroughs, line := extractPassThroughs(line)
|
|
line = strings.TrimLeft(strings.TrimRight(line, "\r\n"), "\r")
|
|
|
|
if lineNo >= height || t.pwindow.Y() == height-1 && t.pwindow.X() > 0 {
|
|
t.previewed.filled = true
|
|
t.previewer.scrollable = true
|
|
break
|
|
} else if lineNo >= 0 {
|
|
x := t.pwindow.X()
|
|
y := t.pwindow.Y()
|
|
if spinnerRedraw && lineNo > 0 {
|
|
spinnerRedraw = false
|
|
t.renderPreviewSpinner()
|
|
t.pwindow.Move(y, x)
|
|
}
|
|
for idx, passThrough := range passThroughs {
|
|
// Handling Sixel/iTerm image
|
|
requiredLines := 0
|
|
isSixel := strings.HasPrefix(passThrough, "\x1bP")
|
|
isItermImage := strings.HasPrefix(passThrough, "\x1b]1337;")
|
|
isImage := isSixel || isItermImage
|
|
if isImage {
|
|
t.previewed.wipe = true
|
|
// NOTE: We don't have a good way to get the height of an iTerm image,
|
|
// so we assume that it requires the full height of the preview
|
|
// window.
|
|
requiredLines = height
|
|
|
|
if isSixel && t.termSize.PxHeight > 0 {
|
|
rows := strings.Count(passThrough, "-")
|
|
requiredLines = int(math.Ceil(float64(rows*6*t.termSize.Lines) / float64(t.termSize.PxHeight)))
|
|
}
|
|
}
|
|
|
|
// Render wireframe when the image cannot be displayed entirely
|
|
if requiredLines > 0 && y+requiredLines > height {
|
|
top := true
|
|
for ; y < height; y++ {
|
|
t.pwindow.MoveAndClear(y, 0)
|
|
t.pwindow.CFill(tui.ColPreview.Fg(), tui.ColPreview.Bg(), -1, tui.AttrRegular, t.makeImageBorder(maxWidth, top))
|
|
top = false
|
|
}
|
|
wireframe = true
|
|
t.previewed.filled = true
|
|
t.previewer.scrollable = true
|
|
break Loop
|
|
}
|
|
|
|
// Clear previous wireframe or any other text
|
|
if (t.previewed.wireframe || isImage && !t.previewed.image) && !wiped {
|
|
wiped = true
|
|
for i := y + 1; i < height; i++ {
|
|
t.pwindow.MoveAndClear(i, 0)
|
|
}
|
|
}
|
|
image = image || isImage
|
|
if idx == 0 {
|
|
t.pwindow.MoveAndClear(y, x)
|
|
} else {
|
|
t.pwindow.Move(y, x)
|
|
}
|
|
t.tui.PassThrough(passThrough)
|
|
|
|
if requiredLines > 0 {
|
|
if y+requiredLines == height {
|
|
t.pwindow.Move(height-1, maxWidth-1)
|
|
t.previewed.filled = true
|
|
break Loop
|
|
}
|
|
t.pwindow.MoveAndClear(y+requiredLines, 0)
|
|
}
|
|
}
|
|
|
|
if len(passThroughs) > 0 && len(line) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Pre-split line into sub-lines for word wrapping
|
|
var subLines []string
|
|
if t.activePreviewOpts.wrapWord {
|
|
subLines = t.wordWrapAnsiLine(line, maxWidth, t.previewWrapSignWidth)
|
|
} else {
|
|
subLines = []string{line}
|
|
}
|
|
|
|
var fillRet tui.FillReturn
|
|
wrap := t.activePreviewOpts.wrap
|
|
printWrapSign := func() {
|
|
if t.pwindow.CFill(tui.ColPreview.Fg(), tui.ColPreview.Bg(), -1, tui.Dim, t.previewWrapSign) == tui.FillNextLine {
|
|
t.pwindow.Move(t.pwindow.Y()-1, t.pwindow.Width())
|
|
}
|
|
fillRet = tui.FillContinue
|
|
}
|
|
for subIdx, subLine := range subLines {
|
|
// Render wrap sign for continuation sub-lines
|
|
if subIdx > 0 {
|
|
if fillRet == tui.FillContinue {
|
|
fillRet = t.pwindow.Fill("\n")
|
|
if fillRet == tui.FillSuspend {
|
|
t.previewed.filled = true
|
|
break Loop
|
|
}
|
|
}
|
|
printWrapSign()
|
|
}
|
|
|
|
prefixWidth := t.pwindow.X()
|
|
var url *url
|
|
_, _, ansi = extractColor(subLine, ansi, func(str string, ansi *ansiState) bool {
|
|
if len(str) > 0 && fillRet == tui.FillNextLine {
|
|
printWrapSign()
|
|
prefixWidth = t.pwindow.X()
|
|
}
|
|
trimmed := []rune(str)
|
|
isTrimmed := false
|
|
if !wrap {
|
|
trimmed, isTrimmed = t.trimRight(trimmed, maxWidth-t.pwindow.X())
|
|
}
|
|
if url == nil && ansi != nil && ansi.url != nil {
|
|
url = ansi.url
|
|
t.pwindow.LinkBegin(url.uri, url.params)
|
|
}
|
|
if url != nil && (ansi == nil || ansi.url == nil) {
|
|
url = nil
|
|
t.pwindow.LinkEnd()
|
|
}
|
|
if ansi != nil {
|
|
lbg = ansi.lbg
|
|
} else {
|
|
lbg = -1
|
|
}
|
|
str, width := t.processTabs(trimmed, prefixWidth)
|
|
if width > prefixWidth {
|
|
prefixWidth = width
|
|
colored := ansi != nil && ansi.colored()
|
|
if t.theme.Colored && colored {
|
|
fillRet = t.pwindow.CFill(ansi.fg, ansi.bg, ansi.ul, ansi.attr, str)
|
|
} else {
|
|
attr := tui.AttrRegular
|
|
if colored {
|
|
attr = ansi.attr
|
|
}
|
|
fillRet = t.pwindow.CFill(tui.ColPreview.Fg(), tui.ColPreview.Bg(), -1, attr, str)
|
|
}
|
|
}
|
|
return !isTrimmed &&
|
|
(fillRet == tui.FillContinue || wrap && fillRet == tui.FillNextLine)
|
|
})
|
|
if url != nil {
|
|
t.pwindow.LinkEnd()
|
|
}
|
|
|
|
if fillRet == tui.FillSuspend {
|
|
t.previewed.filled = true
|
|
break Loop
|
|
}
|
|
}
|
|
|
|
t.previewer.scrollable = t.previewer.scrollable || t.pwindow.Y() == height-1 && t.pwindow.X() == t.pwindow.Width() || t.previewed.filled
|
|
if fillRet == tui.FillNextLine {
|
|
continue
|
|
} else if fillRet == tui.FillSuspend {
|
|
t.previewed.filled = true
|
|
break
|
|
}
|
|
if unchanged && lineNo == 0 {
|
|
break
|
|
}
|
|
if t.theme.Colored && lbg >= 0 {
|
|
fillRet = t.pwindow.CFill(-1, lbg, -1, tui.AttrRegular,
|
|
strings.Repeat(" ", t.pwindow.Width()-t.pwindow.X())+"\n")
|
|
} else {
|
|
fillRet = t.pwindow.Fill("\n")
|
|
}
|
|
if fillRet == tui.FillSuspend {
|
|
t.previewed.filled = true
|
|
break
|
|
}
|
|
}
|
|
lineNo++
|
|
}
|
|
t.previewer.scrollable = t.previewer.scrollable || t.previewed.filled || index < len(lines)-1
|
|
t.previewed.image = image
|
|
t.previewed.wireframe = wireframe
|
|
}
|
|
|
|
func (t *Terminal) renderPreviewScrollbar(yoff int, barLength int, barStart int) {
|
|
height := t.pwindow.Height()
|
|
w := t.pborder.Width()
|
|
xw := [2]int{t.pwindow.Left(), t.pwindow.Width()}
|
|
redraw := false
|
|
if len(t.previewer.bar) != height || t.previewer.xw != xw || t.previewed.version != t.previewer.version {
|
|
redraw = true
|
|
t.previewer.bar = make([]bool, height)
|
|
t.previewer.xw = xw
|
|
}
|
|
xshift := -1 - t.borderWidth
|
|
if !t.activePreviewOpts.Border(t.layout).HasRight() {
|
|
xshift = -1
|
|
}
|
|
yshift := 1
|
|
if !t.activePreviewOpts.Border(t.layout).HasTop() {
|
|
yshift = 0
|
|
}
|
|
for i := yoff; i < height; i++ {
|
|
x := w + xshift
|
|
y := i + yshift
|
|
|
|
// Avoid unnecessary redraws
|
|
bar := i >= yoff+barStart && i < yoff+barStart+barLength
|
|
if !redraw && bar == t.previewer.bar[i] && !t.tui.NeedScrollbarRedraw() {
|
|
continue
|
|
}
|
|
|
|
t.previewer.bar[i] = bar
|
|
t.pborder.Move(y, x)
|
|
if i >= yoff+barStart && i < yoff+barStart+barLength {
|
|
t.pborder.CPrint(tui.ColPreviewScrollbar, t.previewScrollbar)
|
|
} else {
|
|
t.pborder.CPrint(tui.ColPreviewScrollbar, " ")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (t *Terminal) printPreview() {
|
|
if !t.hasPreviewWindow() || t.pwindow.Height() == 0 {
|
|
return
|
|
}
|
|
numLines := len(t.previewer.lines)
|
|
height := t.pwindow.Height()
|
|
unchanged := (t.previewed.filled || numLines == t.previewed.numLines) &&
|
|
t.previewer.version == t.previewed.version &&
|
|
t.previewer.offset == t.previewed.offset
|
|
t.previewer.scrollable = t.previewer.offset > t.activePreviewOpts.headerLines || numLines > height
|
|
t.renderPreviewArea(unchanged)
|
|
t.renderPreviewSpinner()
|
|
t.previewed.numLines = numLines
|
|
t.previewed.version = t.previewer.version
|
|
t.previewed.offset = t.previewer.offset
|
|
}
|
|
|
|
func (t *Terminal) printPreviewDelayed() {
|
|
if !t.hasPreviewWindow() || len(t.previewer.lines) > 0 && t.previewed.version == t.previewer.version {
|
|
return
|
|
}
|
|
|
|
t.previewer.scrollable = false
|
|
t.renderPreviewArea(true)
|
|
|
|
message := t.trimMessage("Loading ..", t.pwindow.Width())
|
|
pos := t.pwindow.Width() - len(message)
|
|
t.pwindow.Move(0, pos)
|
|
t.pwindow.CPrint(tui.ColInfo.WithAttr(tui.Reverse), message)
|
|
}
|
|
|
|
func (t *Terminal) processTabsStr(input string, prefixWidth int) (string, int) {
|
|
var strbuf strings.Builder
|
|
l := prefixWidth
|
|
gr := uniseg.NewGraphemes(input)
|
|
for gr.Next() {
|
|
rs := gr.Runes()
|
|
str := string(rs)
|
|
var w int
|
|
if len(rs) == 1 && rs[0] == '\t' {
|
|
w = t.tabstop - l%t.tabstop
|
|
strbuf.WriteString(strings.Repeat(" ", w))
|
|
} else {
|
|
w = util.StringWidth(str)
|
|
strbuf.WriteString(str)
|
|
}
|
|
l += w
|
|
}
|
|
return strbuf.String(), l
|
|
}
|
|
|
|
func (t *Terminal) processTabs(runes []rune, prefixWidth int) (string, int) {
|
|
return t.processTabsStr(string(runes), prefixWidth)
|
|
}
|
|
|
|
func (t *Terminal) printAll() {
|
|
t.resizeWindows(t.forcePreview, true)
|
|
t.printList()
|
|
t.printPrompt()
|
|
t.printInfo()
|
|
t.printHeader()
|
|
t.printFooter()
|
|
t.printPreview()
|
|
}
|
|
|
|
func (t *Terminal) flush() {
|
|
t.placeCursor()
|
|
if !t.suppress {
|
|
windows := make([]tui.Window, 0, 9)
|
|
if t.border != nil {
|
|
windows = append(windows, t.border)
|
|
}
|
|
if t.pborder != nil {
|
|
windows = append(windows, t.pborder)
|
|
}
|
|
if t.pwindow != nil {
|
|
windows = append(windows, t.pwindow)
|
|
}
|
|
if t.wborder != nil {
|
|
windows = append(windows, t.wborder)
|
|
}
|
|
if t.window != nil {
|
|
windows = append(windows, t.window)
|
|
}
|
|
if t.headerBorder != nil {
|
|
windows = append(windows, t.headerBorder)
|
|
}
|
|
if t.headerWindow != nil {
|
|
windows = append(windows, t.headerWindow)
|
|
}
|
|
if t.headerLinesBorder != nil {
|
|
windows = append(windows, t.headerLinesBorder)
|
|
}
|
|
if t.headerLinesWindow != nil {
|
|
windows = append(windows, t.headerLinesWindow)
|
|
}
|
|
if t.inputBorder != nil {
|
|
windows = append(windows, t.inputBorder)
|
|
}
|
|
if t.inputWindow != nil {
|
|
windows = append(windows, t.inputWindow)
|
|
}
|
|
t.tui.RefreshWindows(windows)
|
|
}
|
|
}
|
|
|
|
func (t *Terminal) delChar() bool {
|
|
if len(t.input) > 0 && t.cx < len(t.input) {
|
|
t.input = append(t.input[:t.cx], t.input[t.cx+1:]...)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func findLastMatch(pattern string, str string) int {
|
|
rx, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
return -1
|
|
}
|
|
locs := rx.FindAllStringIndex(str, -1)
|
|
if locs == nil {
|
|
return -1
|
|
}
|
|
prefix := []rune(str[:locs[len(locs)-1][0]])
|
|
return len(prefix)
|
|
}
|
|
|
|
func findFirstMatch(pattern string, str string) int {
|
|
rx, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
return -1
|
|
}
|
|
loc := rx.FindStringIndex(str)
|
|
if loc == nil {
|
|
return -1
|
|
}
|
|
prefix := []rune(str[:loc[0]])
|
|
return len(prefix)
|
|
}
|
|
|
|
func copySlice(slice []rune) []rune {
|
|
ret := make([]rune, len(slice))
|
|
copy(ret, slice)
|
|
return ret
|
|
}
|
|
|
|
func (t *Terminal) rubout(pattern string) {
|
|
pcx := t.cx
|
|
after := t.input[t.cx:]
|
|
t.cx = findLastMatch(pattern, string(t.input[:t.cx])) + 1
|
|
t.yanked = copySlice(t.input[t.cx:pcx])
|
|
t.input = append(t.input[:t.cx], after...)
|
|
}
|
|
|
|
func keyMatch(key tui.Event, event tui.Event) bool {
|
|
return event.Type == key.Type && event.Char == key.Char ||
|
|
key.Type == tui.DoubleClick && event.Type == tui.Mouse && event.MouseEvent.Double
|
|
}
|
|
|
|
func parsePlaceholder(match string) (bool, string, placeholderFlags) {
|
|
flags := placeholderFlags{}
|
|
|
|
if match[0] == '\\' {
|
|
// Escaped placeholder pattern
|
|
return true, match[1:], flags
|
|
}
|
|
|
|
if strings.HasPrefix(match, "{fzf:") {
|
|
// {fzf:*} are not determined by the current item
|
|
flags.forceUpdate = true
|
|
return false, match, flags
|
|
}
|
|
|
|
trimmed := ""
|
|
for _, char := range match[1:] {
|
|
switch char {
|
|
case '*':
|
|
flags.asterisk = true
|
|
case '+':
|
|
flags.plus = true
|
|
case 's':
|
|
flags.preserveSpace = true
|
|
case 'n':
|
|
flags.number = true
|
|
case 'f':
|
|
flags.file = true
|
|
case 'r':
|
|
flags.raw = true
|
|
case 'q':
|
|
flags.forceUpdate = true
|
|
trimmed += string(char)
|
|
default:
|
|
trimmed += string(char)
|
|
}
|
|
}
|
|
|
|
matchWithoutFlags := "{" + trimmed
|
|
|
|
return false, matchWithoutFlags, flags
|
|
}
|
|
|
|
func hasPreviewFlags(template string) (slot bool, plus bool, asterisk bool, forceUpdate bool) {
|
|
for _, match := range placeholder.FindAllString(template, -1) {
|
|
escaped, _, flags := parsePlaceholder(match)
|
|
if escaped {
|
|
continue
|
|
}
|
|
slot = true
|
|
plus = plus || flags.plus
|
|
asterisk = asterisk || flags.asterisk
|
|
forceUpdate = forceUpdate || flags.forceUpdate
|
|
}
|
|
return
|
|
}
|
|
|
|
type replacePlaceholderParams struct {
|
|
template string
|
|
stripAnsi bool
|
|
delimiter Delimiter
|
|
printsep string
|
|
forcePlus bool
|
|
query string
|
|
allItems [3][]*Item // current, select, and all matched items
|
|
lastAction actionType
|
|
prompt string
|
|
executor *util.Executor
|
|
}
|
|
|
|
func (t *Terminal) replacePlaceholderInInitialCommand(template string) (string, []string) {
|
|
return t.replacePlaceholder(template, false, string(t.input), [3][]*Item{nil, nil, nil})
|
|
}
|
|
|
|
func (t *Terminal) replacePlaceholder(template string, forcePlus bool, input string, list [3][]*Item) (string, []string) {
|
|
return replacePlaceholder(replacePlaceholderParams{
|
|
template: template,
|
|
stripAnsi: t.ansi,
|
|
delimiter: t.delimiter,
|
|
printsep: t.printsep,
|
|
forcePlus: forcePlus,
|
|
query: input,
|
|
allItems: list,
|
|
lastAction: t.lastAction,
|
|
prompt: t.promptString,
|
|
executor: t.executor,
|
|
})
|
|
}
|
|
|
|
func (t *Terminal) evaluateScrollOffset() int {
|
|
if t.pwindow == nil {
|
|
return 0
|
|
}
|
|
|
|
// We only need the current item to calculate the scroll offset
|
|
current := []*Item{t.currentItem()}
|
|
if current[0] == nil {
|
|
current = nil
|
|
}
|
|
replaced, tempFiles := t.replacePlaceholder(t.activePreviewOpts.scroll, false, "", [3][]*Item{current, nil, nil})
|
|
removeFiles(tempFiles)
|
|
offsetExpr := offsetTrimCharsRegex.ReplaceAllString(replaced, "")
|
|
|
|
atoi := func(s string) int {
|
|
n, e := strconv.Atoi(s)
|
|
if e != nil {
|
|
return 0
|
|
}
|
|
return n
|
|
}
|
|
|
|
base := -1
|
|
height := max(0, t.pwindow.Height()-t.activePreviewOpts.headerLines)
|
|
for _, component := range offsetComponentRegex.FindAllString(offsetExpr, -1) {
|
|
if strings.HasPrefix(component, "-/") {
|
|
component = component[1:]
|
|
}
|
|
if component[0] == '/' {
|
|
denom := atoi(component[1:])
|
|
if denom != 0 {
|
|
base -= height / denom
|
|
}
|
|
break
|
|
}
|
|
base += atoi(component)
|
|
}
|
|
return max(0, base)
|
|
}
|
|
|
|
func replacePlaceholder(params replacePlaceholderParams) (string, []string) {
|
|
tempFiles := []string{}
|
|
current := params.allItems[0]
|
|
selected := params.allItems[1]
|
|
matched := params.allItems[2]
|
|
|
|
// replace placeholders one by one
|
|
replaced := placeholder.ReplaceAllStringFunc(params.template, func(match string) string {
|
|
escaped, match, flags := parsePlaceholder(match)
|
|
|
|
// this function implements the effects a placeholder has on items
|
|
var replace func(*Item) string
|
|
|
|
// placeholder types (escaped, query type, item type, token type)
|
|
switch {
|
|
case escaped:
|
|
return match
|
|
case match == "{q}" || match == "{fzf:query}":
|
|
return params.executor.QuoteEntry(params.query)
|
|
case strings.HasPrefix(match, "{q:"):
|
|
if nth, err := splitNth(match[3 : len(match)-1]); err == nil {
|
|
elems, prefixLength := awkTokenizer(params.query)
|
|
tokens := withPrefixLengths(elems, prefixLength)
|
|
trans := Transform(tokens, nth)
|
|
result := JoinTokens(trans)
|
|
if !flags.preserveSpace {
|
|
result = strings.TrimSpace(result)
|
|
}
|
|
return params.executor.QuoteEntry(result)
|
|
}
|
|
|
|
return match
|
|
case match == "{}":
|
|
replace = func(item *Item) string {
|
|
switch {
|
|
case flags.number:
|
|
n := item.text.Index
|
|
if n == minItem.Index() {
|
|
// NOTE: Item index should normally be positive, but if there's no
|
|
// match, it will be set to math.MinInt32, and we don't want to
|
|
// show that value. However, int32 can overflow, especially when
|
|
// `--tail` is used with an endless input stream, and the index of
|
|
// an item actually can be math.MinInt32. In that case, you're
|
|
// getting an incorrect value, but we're going to ignore that for
|
|
// now.
|
|
return "''"
|
|
}
|
|
return strconv.Itoa(int(n))
|
|
case flags.file || flags.raw:
|
|
return item.AsString(params.stripAnsi)
|
|
default:
|
|
return params.executor.QuoteEntry(item.AsString(params.stripAnsi))
|
|
}
|
|
}
|
|
case match == "{fzf:action}":
|
|
return params.lastAction.Name()
|
|
case match == "{fzf:prompt}":
|
|
return params.executor.QuoteEntry(params.prompt)
|
|
default:
|
|
// token type and also failover (below)
|
|
rangeExpressions := strings.Split(match[1:len(match)-1], ",")
|
|
ranges := make([]Range, len(rangeExpressions))
|
|
for idx, s := range rangeExpressions {
|
|
r, ok := ParseRange(&s) // ellipsis (x..y) and shorthand (x..x) range syntax
|
|
if !ok {
|
|
// Invalid expression, just return the original string in the template
|
|
return match
|
|
}
|
|
ranges[idx] = r
|
|
}
|
|
|
|
replace = func(item *Item) string {
|
|
tokens := Tokenize(item.AsString(params.stripAnsi), params.delimiter)
|
|
trans := Transform(tokens, ranges)
|
|
str := JoinTokens(trans)
|
|
|
|
// trim the last delimiter
|
|
if params.delimiter.str != nil {
|
|
str = strings.TrimSuffix(str, *params.delimiter.str)
|
|
} else if params.delimiter.regex != nil {
|
|
delims := params.delimiter.regex.FindAllStringIndex(str, -1)
|
|
// make sure the delimiter is at the very end of the string
|
|
if len(delims) > 0 && delims[len(delims)-1][1] == len(str) {
|
|
str = str[:delims[len(delims)-1][0]]
|
|
}
|
|
}
|
|
|
|
if !flags.preserveSpace {
|
|
str = strings.TrimSpace(str)
|
|
}
|
|
if !flags.file && !flags.raw {
|
|
str = params.executor.QuoteEntry(str)
|
|
}
|
|
return str
|
|
}
|
|
}
|
|
|
|
// apply 'replace' function over proper set of items and return result
|
|
|
|
items := current
|
|
if flags.asterisk {
|
|
items = matched
|
|
} else if flags.plus || params.forcePlus {
|
|
items = selected
|
|
}
|
|
replacements := make([]string, len(items))
|
|
|
|
for idx, item := range items {
|
|
replacements[idx] = replace(item)
|
|
}
|
|
|
|
if flags.file {
|
|
file := WriteTemporaryFile(replacements, params.printsep)
|
|
tempFiles = append(tempFiles, file)
|
|
return file
|
|
}
|
|
return strings.Join(replacements, " ")
|
|
})
|
|
|
|
return replaced, tempFiles
|
|
}
|
|
|
|
func (t *Terminal) fullRedraw() {
|
|
t.tui.Clear()
|
|
t.tui.Refresh()
|
|
t.printAll()
|
|
}
|
|
|
|
func (t *Terminal) captureLine(template string) string {
|
|
return t.executeCommand(template, false, true, true, true, "")
|
|
}
|
|
|
|
func (t *Terminal) captureLines(template string) string {
|
|
return t.executeCommand(template, false, true, true, false, "")
|
|
}
|
|
|
|
func (t *Terminal) captureAsync(a action, firstLineOnly bool, callback func(string)) {
|
|
_, list := t.buildPlusList(a.a, false)
|
|
command, tempFiles := t.replacePlaceholder(a.a, false, string(t.input), list)
|
|
version := t.bgVersion
|
|
cmd := t.executor.ExecCommand(command, true)
|
|
cmd.Env = t.environ()
|
|
item := func(proceed bool) {
|
|
if proceed {
|
|
out, _ := cmd.StdoutPipe()
|
|
reader := bufio.NewReader(out)
|
|
var output string
|
|
if err := cmd.Start(); err == nil {
|
|
runningCmd := runningCmd{cmd, tempFiles}
|
|
t.runningCmds.Add(&runningCmd)
|
|
if firstLineOnly {
|
|
output, _ = reader.ReadString('\n')
|
|
output = strings.TrimRight(output, "\r\n")
|
|
} else {
|
|
bytes, _ := io.ReadAll(reader)
|
|
output = string(bytes)
|
|
}
|
|
cmd.Wait()
|
|
t.runningCmds.Remove(&runningCmd)
|
|
}
|
|
t.callbackChan <- versionedCallback{version, func() { callback(output) }}
|
|
}
|
|
removeFiles(tempFiles)
|
|
|
|
}
|
|
queue, prs := t.bgQueue[a]
|
|
if !prs {
|
|
queue = []func(bool){}
|
|
}
|
|
queue = append(queue, item)
|
|
t.bgQueue[a] = queue
|
|
}
|
|
|
|
func (t *Terminal) dispatchAsync() {
|
|
Loop:
|
|
for a, queue := range t.bgQueue {
|
|
delete(t.bgQueue, a)
|
|
if len(queue) == 0 {
|
|
continue
|
|
}
|
|
|
|
semaphore, prs := t.bgSemaphores[a]
|
|
if !prs {
|
|
semaphore = make(chan struct{}, maxBgProcessesPerAction)
|
|
t.bgSemaphores[a] = semaphore
|
|
}
|
|
for _, item := range queue {
|
|
select {
|
|
// Acquire local semaphore
|
|
case semaphore <- struct{}{}:
|
|
default:
|
|
// Failed to acquire local semaphore, putting only the last one back to the queue
|
|
for _, item := range queue[:len(queue)-1] {
|
|
item(false)
|
|
}
|
|
t.bgQueue[a] = queue[len(queue)-1:]
|
|
continue Loop
|
|
}
|
|
todo := item
|
|
go func() {
|
|
// Acquire global semaphore
|
|
t.bgSemaphore <- struct{}{}
|
|
|
|
todo(true)
|
|
// Release local semaphore
|
|
<-semaphore
|
|
// Release global semaphore
|
|
<-t.bgSemaphore
|
|
}()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (t *Terminal) executeCommand(template string, forcePlus bool, background bool, capture bool, firstLineOnly bool, info string) string {
|
|
line := ""
|
|
valid, list := t.buildPlusList(template, forcePlus)
|
|
// 'capture' is used for transform-* and we don't want to
|
|
// return an empty string in those cases
|
|
if !valid && !capture {
|
|
return line
|
|
}
|
|
command, tempFiles := t.replacePlaceholder(template, forcePlus, string(t.input), list)
|
|
cmd := t.executor.ExecCommand(command, false)
|
|
cmd.Env = t.environ()
|
|
if len(info) > 0 {
|
|
cmd.Env = append(cmd.Env, "FZF_INFO="+info)
|
|
}
|
|
t.executing.Set(true)
|
|
if !background {
|
|
// Open a separate handle for tty input
|
|
if in, _ := tui.TtyIn(t.ttyDefault); in != nil {
|
|
cmd.Stdin = in
|
|
if in != os.Stdin {
|
|
defer in.Close()
|
|
}
|
|
}
|
|
|
|
cmd.Stdout = os.Stdout
|
|
if !util.IsTty(os.Stdout) {
|
|
if out, _ := tui.TtyOut(t.ttyDefault); out != nil {
|
|
cmd.Stdout = out
|
|
defer out.Close()
|
|
}
|
|
}
|
|
|
|
cmd.Stderr = os.Stderr
|
|
if !util.IsTty(os.Stderr) {
|
|
if out, _ := tui.TtyOut(t.ttyDefault); out != nil {
|
|
cmd.Stderr = out
|
|
defer out.Close()
|
|
}
|
|
}
|
|
|
|
t.mutex.Unlock()
|
|
if len(info) == 0 {
|
|
t.uiMutex.Lock()
|
|
}
|
|
t.tui.Pause(true)
|
|
cmd.Run()
|
|
t.tui.Resume(true, false)
|
|
t.mutex.Lock()
|
|
// NOTE: Using t.reqBox.Set(reqFullRedraw...) instead can cause a deadlock
|
|
t.fullRedraw()
|
|
t.flush()
|
|
} else {
|
|
t.mutex.Unlock()
|
|
if len(info) == 0 {
|
|
t.uiMutex.Lock()
|
|
}
|
|
paused := atomic.Int32{}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go func() {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(blockDuration):
|
|
if paused.CompareAndSwap(0, 1) {
|
|
t.tui.Pause(false)
|
|
}
|
|
}
|
|
}()
|
|
if capture {
|
|
out, _ := cmd.StdoutPipe()
|
|
reader := bufio.NewReader(out)
|
|
cmd.Start()
|
|
if firstLineOnly {
|
|
line, _ = reader.ReadString('\n')
|
|
line = strings.TrimRight(line, "\r\n")
|
|
} else {
|
|
bytes, _ := io.ReadAll(reader)
|
|
line = string(bytes)
|
|
}
|
|
cmd.Wait()
|
|
} else {
|
|
cmd.Run()
|
|
}
|
|
cancel()
|
|
if paused.CompareAndSwap(1, 2) {
|
|
t.tui.Resume(false, false)
|
|
}
|
|
t.mutex.Lock()
|
|
|
|
// Redraw prompt in case the user has typed something after blockDuration
|
|
if paused.Load() > 0 {
|
|
// NOTE: Using t.reqBox.Set(reqXXX...) instead can cause a deadlock
|
|
t.printPrompt()
|
|
if t.infoStyle == infoInline || t.infoStyle == infoInlineRight {
|
|
t.printInfo()
|
|
}
|
|
}
|
|
}
|
|
if len(info) == 0 {
|
|
t.uiMutex.Unlock()
|
|
}
|
|
t.executing.Set(false)
|
|
removeFiles(tempFiles)
|
|
return line
|
|
}
|
|
|
|
func (t *Terminal) hasPreviewer() bool {
|
|
return t.previewBox != nil
|
|
}
|
|
|
|
func (t *Terminal) needPreviewWindow() bool {
|
|
return t.hasPreviewer() && len(t.previewOpts.command) > 0 && t.activePreviewOpts.Visible()
|
|
}
|
|
|
|
// Check if previewer is currently in action (invisible previewer with size 0 or visible previewer)
|
|
func (t *Terminal) canPreview() bool {
|
|
return t.hasPreviewer() && (!t.activePreviewOpts.Visible() && !t.activePreviewOpts.hidden || t.hasPreviewWindow())
|
|
}
|
|
|
|
func (t *Terminal) hasPreviewWindow() bool {
|
|
return t.pwindow != nil
|
|
}
|
|
|
|
func (t *Terminal) hasPreviewWindowOnRight() bool {
|
|
return t.hasPreviewWindow() && t.activePreviewOpts.position == posRight
|
|
}
|
|
|
|
func (t *Terminal) currentItem() *Item {
|
|
cnt := t.merger.Length()
|
|
if t.cy >= 0 && cnt > 0 && cnt > t.cy {
|
|
return t.merger.Get(t.cy).item
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (t *Terminal) isCurrentItemMatch() bool {
|
|
cnt := t.merger.Length()
|
|
if t.cy >= 0 && cnt > 0 && cnt > t.cy {
|
|
if !t.raw {
|
|
return true
|
|
}
|
|
item := t.merger.Get(t.cy).item
|
|
return t.isItemMatch(item)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (t *Terminal) isItemMatch(item *Item) bool {
|
|
_, matched := t.matchMap[item.Index()]
|
|
return matched
|
|
}
|
|
|
|
func (t *Terminal) filterSelected() {
|
|
filtered := make(map[int32]selectedItem)
|
|
for k, v := range t.selected {
|
|
if t.isItemMatch(v.item) {
|
|
filtered[k] = v
|
|
}
|
|
}
|
|
t.selected = filtered
|
|
}
|
|
|
|
func (t *Terminal) buildPlusList(template string, forcePlus bool) (bool, [3][]*Item) {
|
|
current := t.currentItem()
|
|
slot, plus, asterisk, forceUpdate := hasPreviewFlags(template)
|
|
if !(!slot || forceUpdate || asterisk || (forcePlus || plus) && len(t.selected) > 0) {
|
|
if current == nil {
|
|
// Invalid
|
|
return false, [3][]*Item{nil, nil, nil}
|
|
}
|
|
return true, [3][]*Item{{current}, {current}, nil}
|
|
}
|
|
|
|
// We would still want to update preview window even if there is no match if
|
|
// 1. the command template contains {q}
|
|
// 2. or it contains {+} and we have more than one item already selected.
|
|
// To do so, we pass an empty Item instead of nil to trigger an update.
|
|
if current == nil {
|
|
current = &minItem
|
|
}
|
|
|
|
var all []*Item
|
|
if asterisk {
|
|
cnt := t.merger.Length()
|
|
all = make([]*Item, cnt)
|
|
for i := range cnt {
|
|
all[i] = t.merger.Get(i).item
|
|
}
|
|
}
|
|
|
|
var sels []*Item
|
|
if len(t.selected) == 0 {
|
|
sels = []*Item{current}
|
|
} else if len(t.selected) > 0 {
|
|
sels = make([]*Item, len(t.selected))
|
|
for i, sel := range t.sortSelected() {
|
|
sels[i] = sel.item
|
|
}
|
|
}
|
|
return true, [3][]*Item{{current}, sels, all}
|
|
}
|
|
|
|
func (t *Terminal) selectItem(item *Item) bool {
|
|
if len(t.selected) >= t.multi {
|
|
return false
|
|
}
|
|
if _, found := t.selected[item.Index()]; found {
|
|
return true
|
|
}
|
|
|
|
t.selected[item.Index()] = selectedItem{time.Now(), item}
|
|
t.version++
|
|
|
|
return true
|
|
}
|
|
|
|
func (t *Terminal) selectItemChanged(item *Item) bool {
|
|
if _, found := t.selected[item.Index()]; found {
|
|
return false
|
|
}
|
|
return t.selectItem(item)
|
|
}
|
|
|
|
func (t *Terminal) deselectItem(item *Item) {
|
|
delete(t.selected, item.Index())
|
|
t.version++
|
|
}
|
|
|
|
func (t *Terminal) deselectItemChanged(item *Item) bool {
|
|
if _, found := t.selected[item.Index()]; found {
|
|
t.deselectItem(item)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (t *Terminal) toggleItem(item *Item) bool {
|
|
if _, found := t.selected[item.Index()]; !found {
|
|
return t.selectItem(item)
|
|
}
|
|
t.deselectItem(item)
|
|
return true
|
|
}
|
|
|
|
func (t *Terminal) killPreview() {
|
|
select {
|
|
case t.killChan <- true:
|
|
<-t.killedChan
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (t *Terminal) cancelPreview() {
|
|
select {
|
|
case t.killChan <- false:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (t *Terminal) pwindowSize() tui.TermSize {
|
|
if t.pwindow == nil {
|
|
return tui.TermSize{}
|
|
}
|
|
size := tui.TermSize{Lines: t.pwindow.Height(), Columns: t.pwindow.Width()}
|
|
|
|
if t.termSize.PxWidth > 0 {
|
|
size.PxWidth = size.Columns * t.termSize.PxWidth / t.termSize.Columns
|
|
size.PxHeight = size.Lines * t.termSize.PxHeight / t.termSize.Lines
|
|
}
|
|
return size
|
|
}
|
|
|
|
func (t *Terminal) currentIndex() int32 {
|
|
if currentItem := t.currentItem(); currentItem != nil {
|
|
return currentItem.Index()
|
|
}
|
|
return minItem.Index()
|
|
}
|
|
|
|
func (t *Terminal) trackKeyFor(item *Item, nth []Range) string {
|
|
tokens := Tokenize(item.AsString(t.ansi), t.delimiter)
|
|
return StripLastDelimiter(JoinTokens(Transform(tokens, nth)), t.delimiter)
|
|
}
|
|
|
|
func (t *Terminal) unblockTrack() {
|
|
if t.trackBlocked {
|
|
t.trackBlocked = false
|
|
t.trackKey = ""
|
|
t.trackKeyCache = nil
|
|
// Keep the cursor hidden if the wait feedback is still showing it
|
|
if !t.inputless && !t.waitFeedback() {
|
|
t.tui.ShowCursor()
|
|
}
|
|
}
|
|
}
|
|
|
|
// The wait state machine. Invariant: arming captures every action after
|
|
// 'wait' at any nesting level into wait.pending; while blocked, actions are
|
|
// dropped unless they are results of work started before the block
|
|
// (bg-transform callbacks, bracketed paste bookkeeping); abort/cancel
|
|
// discards everything.
|
|
|
|
// blockWait blocks action execution and defers the given actions until the
|
|
// current search completes (see UpdateList)
|
|
func (t *Terminal) blockWait(pending []*action) {
|
|
t.wait.blocked = true
|
|
t.wait.blockedAt = time.Now()
|
|
// Clone so that later joins don't append into the backing array of the
|
|
// bound action list
|
|
t.wait.pending = slices.Clone(pending)
|
|
// Show the waiting feedback only if the search takes long enough,
|
|
// so that quick searches don't cause flickering
|
|
go func() {
|
|
timer := time.NewTimer(progressMinDuration)
|
|
<-timer.C
|
|
t.mutex.Lock()
|
|
blocked := t.wait.blocked
|
|
t.mutex.Unlock()
|
|
if blocked {
|
|
t.reqBox.Set(reqPrompt, nil)
|
|
t.reqBox.Set(reqInfo, nil)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// unblockWait lifts the block, leaving the pending actions to the caller:
|
|
// UpdateList keeps them for the main loop to replay, cancelWait discards them
|
|
func (t *Terminal) unblockWait() {
|
|
t.wait.blocked = false
|
|
// Restore the cursor unless it's still hidden for another reason
|
|
if !t.inputless && !t.trackBlocked {
|
|
t.tui.ShowCursor()
|
|
}
|
|
}
|
|
|
|
// cancelWait unblocks and discards the pending actions (user abort)
|
|
func (t *Terminal) cancelWait() {
|
|
t.unblockWait()
|
|
t.wait.pending = nil
|
|
}
|
|
|
|
// Debounce visual feedback so quick searches don't cause flashing
|
|
func (t *Terminal) waitFeedback() bool {
|
|
return t.wait.blocked && time.Since(t.wait.blockedAt) > progressMinDuration
|
|
}
|
|
|
|
func (t *Terminal) addClickHeaderWord(env []string) []string {
|
|
/*
|
|
* echo $'HL1\nHL2' | fzf --header-lines 3 --header $'H1\nH2' --header-lines-border --bind 'click-header:preview:env | grep FZF_CLICK'
|
|
*
|
|
* REVERSE DEFAULT REVERSE-LIST
|
|
* H1 1 1 HL1 1
|
|
* H2 2 HL2 2 HL2 2
|
|
* ------- HL1 3 3
|
|
* HL1 3 ------- -------
|
|
* HL2 4 H1 4 H1 4
|
|
* 5 H2 5 H2 5
|
|
*/
|
|
clickHeaderLine := t.clickHeaderLine - 1
|
|
if clickHeaderLine < 0 {
|
|
// Never clicked on the header
|
|
return env
|
|
}
|
|
|
|
nthBase := 0
|
|
// Convert header items to strings for click handling
|
|
headerStrs := make([]string, len(t.header))
|
|
for i, item := range t.header {
|
|
headerStrs[i] = item.text.ToString()
|
|
}
|
|
headers := [2][]string{headerStrs, t.header0}
|
|
if t.layout == layoutReverse {
|
|
headers[0], headers[1] = headers[1], headers[0]
|
|
}
|
|
var trimmedLine string
|
|
var words []Token
|
|
var lineNum int
|
|
for lineNum = 0; lineNum <= clickHeaderLine; lineNum++ {
|
|
currentLine := lineNum == clickHeaderLine
|
|
var line string
|
|
if lineNum < len(headers[0]) {
|
|
index := lineNum
|
|
if t.layout == layoutDefault {
|
|
index = len(headers[0]) - index - 1
|
|
}
|
|
line = headers[0][index]
|
|
} else if lineNum-len(headers[0]) < len(headers[1]) {
|
|
line = headers[1][lineNum-len(headers[0])]
|
|
}
|
|
if currentLine && len(line) == 0 {
|
|
return env
|
|
}
|
|
|
|
// NOTE: We can't expand tabs here because the delimiter can contain tabs.
|
|
trimmedLine, _, _ = extractColor(line, nil, nil)
|
|
words = Tokenize(trimmedLine, t.delimiter)
|
|
if currentLine {
|
|
break
|
|
} else {
|
|
// TODO: Counting can be incorrect when the delimiter contains new line
|
|
// characters, and there are empty lines in the header.
|
|
nthBase += len(words)
|
|
}
|
|
}
|
|
|
|
colNum := t.clickHeaderColumn - 1
|
|
prefixWidth, prefixLength := 0, 0
|
|
for idx, token := range words {
|
|
prefixWidth += t.displayWidthWithPrefix(trimmedLine[prefixLength:token.prefixLength], prefixWidth)
|
|
prefixLength = int(token.prefixLength)
|
|
|
|
word, _ := t.processTabs(token.text.ToRunes(), prefixWidth)
|
|
trimmed := strings.TrimRightFunc(word, unicode.IsSpace)
|
|
trimWidth := t.displayWidthWithPrefix(trimmed, prefixWidth)
|
|
|
|
// Find the position of the first non-space character in the word
|
|
minPos := strings.IndexFunc(trimmed, func(r rune) bool {
|
|
return !unicode.IsSpace(r)
|
|
})
|
|
if colNum >= minPos && colNum >= prefixWidth && colNum < prefixWidth+trimWidth {
|
|
env = append(env, fmt.Sprintf("FZF_CLICK_HEADER_WORD=%s", trimmed))
|
|
nth := fmt.Sprintf("FZF_CLICK_HEADER_NTH=%d", nthBase+idx+1)
|
|
if lineNum == len(t.header)+len(t.header0)-1 && idx == len(words)-1 {
|
|
nth += ".."
|
|
}
|
|
env = append(env, nth)
|
|
return env
|
|
}
|
|
}
|
|
return env
|
|
}
|
|
|
|
func (t *Terminal) addClickFooterWord(env []string) []string {
|
|
clickFooterLine := t.clickFooterLine - 1
|
|
if clickFooterLine < 0 || clickFooterLine >= len(t.footer) {
|
|
// Never clicked on the footer
|
|
return env
|
|
}
|
|
|
|
// NOTE: Unlike in click-header, we don't use --delimiter here, since we're
|
|
// only interested in the word, not nth. Does this make sense?
|
|
trimmed, _, _ := extractColor(t.footer[clickFooterLine], nil, nil)
|
|
trimmed, _ = t.processTabsStr(trimmed, 0)
|
|
words := Tokenize(trimmed, Delimiter{})
|
|
colNum := t.clickFooterColumn - 1
|
|
for _, token := range words {
|
|
prefixWidth := int(token.prefixLength)
|
|
word := token.text.ToString()
|
|
trimmed := strings.TrimRightFunc(word, unicode.IsSpace)
|
|
trimWidth := t.displayWidthWithPrefix(trimmed, prefixWidth)
|
|
|
|
// Find the position of the first non-space character in the word
|
|
minPos := strings.IndexFunc(trimmed, func(r rune) bool {
|
|
return !unicode.IsSpace(r)
|
|
})
|
|
if colNum >= minPos && colNum >= prefixWidth && colNum < prefixWidth+trimWidth {
|
|
env = append(env, fmt.Sprintf("FZF_CLICK_FOOTER_WORD=%s", trimmed))
|
|
return env
|
|
}
|
|
}
|
|
return env
|
|
}
|
|
|
|
// startTimers spawns a goroutine per every() bind event. Forwarding ticks
|
|
// onto the unbuffered timerChan lets the ticker drop overlapping ticks
|
|
// while the main loop is busy.
|
|
func (t *Terminal) startTimers(ctx context.Context) {
|
|
for evt := range t.keymap {
|
|
switch evt.Type {
|
|
case tui.Every:
|
|
d := time.Duration(evt.Char) * time.Millisecond
|
|
evt := evt
|
|
go func() {
|
|
ticker := time.NewTicker(d)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case t.timerChan <- evt:
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Loop is called to start Terminal I/O
|
|
func (t *Terminal) Loop() error {
|
|
// prof := profile.Start(profile.ProfilePath("/tmp/"))
|
|
fitpad := <-t.startChan
|
|
fit := fitpad.fit
|
|
if fit >= 0 {
|
|
pad := fitpad.pad
|
|
t.tui.Resize(func(termHeight int) int {
|
|
contentHeight := fit + t.extraLines()
|
|
if t.needPreviewWindow() {
|
|
if t.activePreviewOpts.aboveOrBelow() {
|
|
if t.activePreviewOpts.size.percent {
|
|
newContentHeight := int(float64(contentHeight) * 100. / (100. - t.activePreviewOpts.size.size))
|
|
contentHeight = max(contentHeight+1+borderLines(t.activePreviewOpts.Border(t.layout)), newContentHeight)
|
|
} else {
|
|
contentHeight += int(t.activePreviewOpts.size.size) + borderLines(t.activePreviewOpts.Border(t.layout))
|
|
}
|
|
} else {
|
|
// Minimum height if preview window can appear
|
|
contentHeight = max(contentHeight, 1+borderLines(t.activePreviewOpts.Border(t.layout)))
|
|
}
|
|
}
|
|
return min(termHeight, contentHeight+pad)
|
|
})
|
|
}
|
|
|
|
// Context
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
{ // Late initialization
|
|
intChan := make(chan os.Signal, 1)
|
|
signal.Notify(intChan, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case s := <-intChan:
|
|
// Don't quit by SIGINT while executing because it should be for the executing command and not for fzf itself
|
|
if !(s == os.Interrupt && t.executing.Get()) {
|
|
t.reqBox.Set(reqQuit, nil)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
if !t.tui.ShouldEmitResizeEvent() {
|
|
resizeChan := make(chan os.Signal, 1)
|
|
notifyOnResize(resizeChan) // Non-portable
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-resizeChan:
|
|
t.reqBox.Set(reqResize, nil)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
t.mutex.Lock()
|
|
if err := t.initFunc(); err != nil {
|
|
t.mutex.Unlock()
|
|
cancel()
|
|
t.eventBox.Set(EvtQuit, quitSignal{ExitError, err})
|
|
return err
|
|
}
|
|
t.termSize = t.tui.Size()
|
|
t.resizeWindows(false, false)
|
|
t.window.Erase()
|
|
t.mutex.Unlock()
|
|
|
|
t.reqBox.Set(reqPrompt, nil)
|
|
t.reqBox.Set(reqInfo, nil)
|
|
t.reqBox.Set(reqHeader, nil)
|
|
t.reqBox.Set(reqFooter, nil)
|
|
if t.initDelay > 0 {
|
|
go func() {
|
|
timer := time.NewTimer(t.initDelay)
|
|
<-timer.C
|
|
t.reqBox.Set(reqActivate, nil)
|
|
}()
|
|
}
|
|
|
|
// Keep the spinner spinning
|
|
go func() {
|
|
for t.running.Get() {
|
|
t.mutex.Lock()
|
|
reading := t.reading
|
|
t.mutex.Unlock()
|
|
time.Sleep(spinnerDuration)
|
|
if reading {
|
|
t.reqBox.Set(reqInfo, nil)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
if t.hasPreviewer() {
|
|
go func() {
|
|
var version int64
|
|
stop := false
|
|
t.previewBox.WaitFor(reqPreviewReady)
|
|
for {
|
|
requested := false
|
|
var items [3][]*Item
|
|
var commandTemplate string
|
|
var env []string
|
|
var query string
|
|
initialOffset := 0
|
|
t.previewBox.Wait(func(events *util.Events) {
|
|
for req, value := range *events {
|
|
switch req {
|
|
case reqQuit:
|
|
stop = true
|
|
return
|
|
case reqPreviewEnqueue:
|
|
request := value.(previewRequest)
|
|
commandTemplate = request.template
|
|
initialOffset = request.scrollOffset
|
|
items = request.list
|
|
env = request.env
|
|
query = request.query
|
|
requested = true
|
|
}
|
|
}
|
|
events.Clear()
|
|
})
|
|
if stop {
|
|
break
|
|
}
|
|
if !requested {
|
|
continue
|
|
}
|
|
version++
|
|
// We don't display preview window if no match
|
|
if items[0] != nil {
|
|
command, tempFiles := t.replacePlaceholder(commandTemplate, false, query, items)
|
|
cmd := t.executor.ExecCommand(command, true)
|
|
cmd.Env = env
|
|
|
|
out, _ := cmd.StdoutPipe()
|
|
cmd.Stderr = cmd.Stdout
|
|
reader := bufio.NewReader(out)
|
|
eofChan := make(chan bool)
|
|
finishChan := make(chan bool, 1)
|
|
err := cmd.Start()
|
|
if err == nil {
|
|
reapChan := make(chan bool)
|
|
lineChan := make(chan eachLine)
|
|
// Goroutine 1 reads process output
|
|
go func() {
|
|
for {
|
|
line, err := reader.ReadString('\n')
|
|
lineChan <- eachLine{line, err}
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
eofChan <- true
|
|
}()
|
|
|
|
// Goroutine 2 periodically requests rendering
|
|
rendered := util.NewAtomicBool(false)
|
|
go func(version int64) {
|
|
lines := []string{}
|
|
spinner := makeSpinner(t.unicode)
|
|
spinnerIndex := -1 // Delay initial rendering by an extra tick
|
|
ticker := time.NewTicker(previewChunkDelay)
|
|
offset := initialOffset
|
|
Loop:
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
if len(lines) > 0 && len(lines) >= initialOffset {
|
|
if spinnerIndex >= 0 {
|
|
spin := spinner[spinnerIndex%len(spinner)]
|
|
t.reqBox.Set(reqPreviewDisplay, previewResult{version, lines, offset, spin})
|
|
rendered.Set(true)
|
|
offset = -1
|
|
}
|
|
spinnerIndex++
|
|
}
|
|
case eachLine := <-lineChan:
|
|
line := eachLine.line
|
|
err := eachLine.err
|
|
if len(line) > 0 {
|
|
clearIndex := strings.Index(line, clearCode)
|
|
if clearIndex >= 0 {
|
|
lines = []string{}
|
|
line = line[clearIndex+len(clearCode):]
|
|
version--
|
|
offset = 0
|
|
}
|
|
lines = append(lines, line)
|
|
}
|
|
if err != nil {
|
|
t.reqBox.Set(reqPreviewDisplay, previewResult{version, lines, offset, ""})
|
|
rendered.Set(true)
|
|
break Loop
|
|
}
|
|
}
|
|
}
|
|
ticker.Stop()
|
|
reapChan <- true
|
|
}(version)
|
|
|
|
// Goroutine 3 is responsible for cancelling running preview command
|
|
go func(version int64) {
|
|
timer := time.NewTimer(previewDelayed)
|
|
Loop:
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
break Loop
|
|
case <-timer.C:
|
|
t.reqBox.Set(reqPreviewDelayed, version)
|
|
case immediately := <-t.killChan:
|
|
if immediately {
|
|
util.KillCommand(cmd)
|
|
t.killedChan <- true
|
|
} else {
|
|
// We can immediately kill a long-running preview program
|
|
// once we started rendering its partial output
|
|
delay := previewCancelWait
|
|
if rendered.Get() {
|
|
delay = 0
|
|
}
|
|
timer := time.NewTimer(delay)
|
|
select {
|
|
case <-timer.C:
|
|
util.KillCommand(cmd)
|
|
case <-finishChan:
|
|
}
|
|
timer.Stop()
|
|
}
|
|
break Loop
|
|
case <-finishChan:
|
|
break Loop
|
|
}
|
|
}
|
|
timer.Stop()
|
|
reapChan <- true
|
|
}(version)
|
|
|
|
<-eofChan // Goroutine 1 finished
|
|
cmd.Wait() // NOTE: We should not call Wait before EOF
|
|
finishChan <- true // Tell Goroutine 3 to stop
|
|
<-reapChan // Goroutine 2 and 3 finished
|
|
<-reapChan
|
|
removeFiles(tempFiles)
|
|
} else {
|
|
// Failed to start the command. Report the error immediately.
|
|
t.reqBox.Set(reqPreviewDisplay, previewResult{version, []string{err.Error()}, 0, ""})
|
|
}
|
|
} else {
|
|
t.reqBox.Set(reqPreviewDisplay, previewResult{version, nil, 0, ""})
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
refreshPreview := func(command string) {
|
|
if len(command) > 0 && t.canPreview() {
|
|
_, list := t.buildPlusList(command, false)
|
|
t.cancelPreview()
|
|
t.previewBox.Set(reqPreviewEnqueue, previewRequest{command, t.evaluateScrollOffset(), list, t.environForPreview(), string(t.input)})
|
|
}
|
|
}
|
|
|
|
go func() { // Render loop
|
|
var focusedIndex = minItem.Index()
|
|
var version int64 = -1
|
|
running := true
|
|
code := ExitError
|
|
exit := func(getCode func() int) {
|
|
if t.hasPreviewer() {
|
|
t.previewBox.Set(reqQuit, nil)
|
|
}
|
|
if t.listener != nil {
|
|
t.listener.Close()
|
|
}
|
|
t.tui.Close()
|
|
code = getCode()
|
|
if code <= ExitNoMatch && t.history != nil {
|
|
t.history.append(string(t.input))
|
|
}
|
|
t.runningCmds.ForEach(func(cmd *runningCmd) {
|
|
util.KillCommand(cmd.cmd)
|
|
removeFiles(cmd.tempFiles)
|
|
})
|
|
running = false
|
|
t.mutex.Unlock()
|
|
}
|
|
|
|
for running {
|
|
t.reqBox.Wait(func(events *util.Events) {
|
|
defer events.Clear()
|
|
|
|
// Sort events.
|
|
// e.g. Make sure that reqPrompt is processed before reqInfo
|
|
keys := make([]int, 0, len(*events))
|
|
for key := range *events {
|
|
keys = append(keys, int(key))
|
|
}
|
|
sort.Ints(keys)
|
|
|
|
// t.uiMutex must be locked first to avoid deadlock. Execute actions
|
|
// will 1. unlock t.mutex to allow GET endpoint and 2. lock t.uiMutex
|
|
// to block rendering during the execution.
|
|
//
|
|
// T1 T2 (good) | T1 T2 (bad)
|
|
// L t.uiMutex |
|
|
// L t.mutex | L t.mutex
|
|
// U t.mutex | U t.mutex
|
|
// L t.mutex | L t.mutex
|
|
// U t.mutex | L t.uiMutex
|
|
// U t.uiMutex | L t.uiMutex!!
|
|
// L t.uiMutex |
|
|
// | L t.mutex!!
|
|
// L t.mutex | U t.uiMutex
|
|
// U t.uiMutex |
|
|
t.uiMutex.Lock()
|
|
t.mutex.Lock()
|
|
info := false
|
|
header := false
|
|
footer := false
|
|
for _, key := range keys {
|
|
req := util.EventType(key)
|
|
value := (*events)[req]
|
|
switch req {
|
|
case reqPrompt:
|
|
t.printPrompt()
|
|
if t.infoStyle == infoInline || t.infoStyle == infoInlineRight {
|
|
info = true
|
|
}
|
|
case reqInfo:
|
|
info = true
|
|
case reqList:
|
|
t.printList()
|
|
currentIndex := t.currentIndex()
|
|
if t.track.Current() && t.track.index != currentIndex {
|
|
t.track = trackDisabled
|
|
info = true
|
|
}
|
|
focusChanged := focusedIndex != currentIndex
|
|
if (t.hasFocusActions || t.infoCommand != "") && focusChanged && currentIndex != t.lastFocus {
|
|
t.lastFocus = currentIndex
|
|
t.eventChan <- tui.Focus.AsEvent()
|
|
if t.infoCommand != "" {
|
|
info = true
|
|
}
|
|
}
|
|
if focusChanged || version != t.version {
|
|
version = t.version
|
|
focusedIndex = currentIndex
|
|
refreshPreview(t.previewOpts.command)
|
|
}
|
|
case reqJump:
|
|
if t.merger.Length() == 0 {
|
|
t.jumping = jumpDisabled
|
|
}
|
|
t.printList()
|
|
case reqHeader:
|
|
header = true
|
|
case reqFooter:
|
|
footer = true
|
|
case reqActivate:
|
|
t.suppress = false
|
|
if t.hasPreviewer() {
|
|
t.previewBox.Set(reqPreviewReady, nil)
|
|
}
|
|
case reqRedrawInputLabel:
|
|
t.printLabel(t.inputBorder, t.inputLabel, t.inputLabelOpts, t.inputLabelLen, t.inputBorderShape, true)
|
|
case reqRedrawHeaderLabel:
|
|
if t.headerBorderShape == tui.BorderInline {
|
|
// Inline labels sit on the separator inside wborder; re-run the
|
|
// full layout to repaint the separator + label together.
|
|
t.printAll()
|
|
} else {
|
|
t.printLabel(t.headerBorder, t.headerLabel, t.headerLabelOpts, t.headerLabelLen, t.headerBorderShape, true)
|
|
}
|
|
case reqRedrawFooterLabel:
|
|
if t.footerBorderShape == tui.BorderInline {
|
|
t.printAll()
|
|
} else {
|
|
t.printLabel(t.footerBorder, t.footerLabel, t.footerLabelOpts, t.footerLabelLen, t.footerBorderShape, true)
|
|
}
|
|
case reqRedrawListLabel:
|
|
// When inline sections are active, the label's bg depends on which
|
|
// section owns the adjacent edge. Rerun the layout to reuse that
|
|
// logic rather than duplicating it here.
|
|
if t.headerBorderShape == tui.BorderInline || t.headerLinesShape == tui.BorderInline || t.footerBorderShape == tui.BorderInline {
|
|
t.printAll()
|
|
} else {
|
|
t.printLabel(t.wborder, t.listLabel, t.listLabelOpts, t.listLabelLen, t.listBorderShape, true)
|
|
}
|
|
case reqRedrawBorderLabel:
|
|
t.printLabel(t.border, t.borderLabel, t.borderLabelOpts, t.borderLabelLen, t.borderShape, true)
|
|
case reqRedrawPreviewLabel:
|
|
t.printLabel(t.pborder, t.previewLabel, t.previewLabelOpts, t.previewLabelLen, t.activePreviewOpts.Border(t.layout), true)
|
|
case reqReinit, reqResize, reqFullRedraw, reqRedraw:
|
|
if req == reqReinit {
|
|
t.tui.Resume(t.fullscreen, true)
|
|
}
|
|
if req == reqResize {
|
|
t.termSize = t.tui.Size()
|
|
}
|
|
wasHidden := t.pwindow == nil
|
|
if req == reqRedraw {
|
|
t.printAll()
|
|
} else {
|
|
t.fullRedraw()
|
|
}
|
|
if wasHidden && t.hasPreviewWindow() {
|
|
refreshPreview(t.previewOpts.command)
|
|
}
|
|
if req == reqResize && t.hasResizeActions {
|
|
t.eventChan <- tui.Resize.AsEvent()
|
|
}
|
|
case reqClose:
|
|
exit(func() int {
|
|
if t.output() {
|
|
return ExitOk
|
|
}
|
|
return ExitNoMatch
|
|
})
|
|
return
|
|
case reqPreviewDisplay:
|
|
result := value.(previewResult)
|
|
if t.previewer.version != result.version {
|
|
t.previewer.version = result.version
|
|
t.previewer.following.Force(t.activePreviewOpts.follow)
|
|
if t.previewer.following.Enabled() {
|
|
t.previewer.offset = 0
|
|
}
|
|
}
|
|
t.previewer.lines = result.lines
|
|
t.previewer.spinner = result.spinner
|
|
if t.hasPreviewWindow() && t.previewer.following.Enabled() {
|
|
t.previewer.offset = t.followOffset()
|
|
} else if result.offset >= 0 {
|
|
t.previewer.offset = util.Constrain(result.offset, t.activePreviewOpts.headerLines, len(t.previewer.lines)-1)
|
|
}
|
|
t.printPreview()
|
|
case reqPreviewRefresh:
|
|
t.printPreview()
|
|
case reqPreviewDelayed:
|
|
t.previewer.version = value.(int64)
|
|
t.printPreviewDelayed()
|
|
case reqPrintQuery:
|
|
exit(func() int {
|
|
t.printer(string(t.input))
|
|
return ExitOk
|
|
})
|
|
return
|
|
case reqBecome:
|
|
exit(func() int { return ExitBecome })
|
|
return
|
|
case reqQuit:
|
|
exit(func() int { return ExitInterrupt })
|
|
return
|
|
case reqFatal:
|
|
exit(func() int { return ExitError })
|
|
return
|
|
}
|
|
}
|
|
if (info || header || footer) && !t.resizeIfNeeded() {
|
|
if info {
|
|
t.printInfo()
|
|
}
|
|
if header {
|
|
t.printHeader()
|
|
}
|
|
if footer {
|
|
t.printFooter()
|
|
}
|
|
}
|
|
// Hide the cursor while the debounced waiting feedback is shown
|
|
if !t.inputless && !t.trackBlocked && t.waitFeedback() {
|
|
t.tui.HideCursor()
|
|
}
|
|
t.flush()
|
|
t.mutex.Unlock()
|
|
t.uiMutex.Unlock()
|
|
})
|
|
}
|
|
|
|
t.running.Set(false)
|
|
t.killPreview()
|
|
cancel()
|
|
t.eventBox.Set(EvtQuit, quitSignal{code, nil})
|
|
}()
|
|
|
|
looping := true
|
|
barrier := make(chan bool)
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-barrier:
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case t.keyChan <- t.tui.GetChar(t.listenAddr != nil):
|
|
}
|
|
}
|
|
}()
|
|
t.startTimers(ctx)
|
|
previewDraggingPos := -1
|
|
barDragging := false
|
|
pbarDragging := false
|
|
pborderDragging := -1
|
|
wasDown := false
|
|
pmx, pmy := -1, -1
|
|
needBarrier := true
|
|
|
|
// If an action is bound to 'start', we're going to process it before reading
|
|
// user input.
|
|
if !t.hasStartActions {
|
|
barrier <- true
|
|
needBarrier = false
|
|
}
|
|
|
|
// These variables are defined outside the loop to be accessible from closures.
|
|
// In particular, async bg-transform callbacks run in a later iteration than
|
|
// the one that scheduled them, but still need to mutate state that the
|
|
// loop-end reload/event dispatch reads.
|
|
events := []util.EventType{}
|
|
changed := false
|
|
var newNth *[]Range
|
|
var newWithNth *withNthSpec
|
|
var newHeaderLines *int
|
|
var newCommand *commandSpec
|
|
var reloadSync bool
|
|
var denylist []int32
|
|
// True while running bg-transform callbacks. Declared outside the loop
|
|
// because callbacks invoke the doActions closure of the iteration that
|
|
// scheduled them, not the one executing actAsync.
|
|
inBgCallback := false
|
|
req := func(evts ...util.EventType) {
|
|
for _, event := range evts {
|
|
events = append(events, event)
|
|
if isTerminalEvent(event) {
|
|
looping = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// The main event loop
|
|
for loopIndex := int64(0); looping; loopIndex++ {
|
|
events = []util.EventType{}
|
|
changed = false
|
|
newNth = nil
|
|
newWithNth = nil
|
|
newHeaderLines = nil
|
|
newCommand = nil
|
|
reloadSync = false
|
|
denylist = nil
|
|
beof := false
|
|
queryChanged := false
|
|
|
|
// Special handling of --sync. Activate the interface on the second tick.
|
|
if loopIndex == 1 && t.deferActivation() {
|
|
t.reqBox.Set(reqActivate, nil)
|
|
}
|
|
|
|
if loopIndex > 0 && needBarrier {
|
|
barrier <- true
|
|
needBarrier = false
|
|
}
|
|
|
|
var event tui.Event
|
|
actions := []*action{}
|
|
callbacks := []versionedCallback{}
|
|
select {
|
|
case event = <-t.keyChan:
|
|
needBarrier = true
|
|
case event = <-t.timerChan:
|
|
case event = <-t.eventChan:
|
|
// Drain channel to process all queued events at once without rendering
|
|
// the intermediate states
|
|
Drain:
|
|
for {
|
|
if eventActions, prs := t.keymap[event]; prs {
|
|
actions = append(actions, eventActions...)
|
|
}
|
|
for {
|
|
select {
|
|
case event = <-t.eventChan:
|
|
continue Drain
|
|
default:
|
|
break Drain
|
|
}
|
|
}
|
|
}
|
|
case serverActions := <-t.serverInputChan:
|
|
event = tui.Invalid.AsEvent()
|
|
if t.listenAddr == nil || t.listenAddr.IsLocal() || t.listenUnsafe {
|
|
actions = serverActions
|
|
} else {
|
|
for _, action := range serverActions {
|
|
if !processExecution(action.t) {
|
|
actions = append(actions, action)
|
|
}
|
|
}
|
|
}
|
|
for _, action := range actions {
|
|
if action.t == actExecute {
|
|
t.tui.CancelGetChar()
|
|
break
|
|
}
|
|
}
|
|
|
|
case callback := <-t.callbackChan:
|
|
event = tui.Invalid.AsEvent()
|
|
actions = append(actions, &action{t: actAsync})
|
|
callbacks = append(callbacks, callback)
|
|
DrainCallback:
|
|
for {
|
|
select {
|
|
case callback = <-t.callbackChan:
|
|
callbacks = append(callbacks, callback)
|
|
continue DrainCallback
|
|
default:
|
|
break DrainCallback
|
|
}
|
|
}
|
|
}
|
|
|
|
t.mutex.Lock()
|
|
// Ignore --expect keys while wait-blocked like the rest of the input
|
|
if !t.wait.blocked {
|
|
for key, ret := range t.expect {
|
|
if keyMatch(key, event) {
|
|
t.pressed = ret
|
|
t.mutex.Unlock()
|
|
t.reqBox.Set(reqClose, nil)
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
triggering := map[tui.Event]struct{}{}
|
|
previousInput := t.input
|
|
previousCx := t.cx
|
|
previousVersion := t.version
|
|
if event.Type < tui.Invalid {
|
|
t.lastKey = event.KeyName()
|
|
t.lastActivity = time.Now()
|
|
}
|
|
updatePreviewWindow := func(forcePreview bool) {
|
|
t.resizeWindows(forcePreview, false)
|
|
req(reqPrompt, reqList, reqInfo, reqHeader, reqFooter)
|
|
}
|
|
toggle := func() bool {
|
|
current := t.currentItem()
|
|
if current != nil && t.toggleItem(current) {
|
|
req(reqInfo)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
scrollPreviewTo := func(newOffset int) {
|
|
if !t.previewer.scrollable {
|
|
return
|
|
}
|
|
numLines := len(t.previewer.lines)
|
|
headerLines := t.activePreviewOpts.headerLines
|
|
if t.activePreviewOpts.cycle {
|
|
offsetRange := numLines - headerLines
|
|
newOffset = ((newOffset-headerLines)+offsetRange)%offsetRange + headerLines
|
|
}
|
|
newOffset = util.Constrain(newOffset, headerLines, numLines-1)
|
|
if t.previewer.offset != newOffset {
|
|
t.previewer.offset = newOffset
|
|
t.previewer.following.Set(t.previewer.offset >= numLines-(t.pwindow.Height()-headerLines))
|
|
req(reqPreviewRefresh)
|
|
}
|
|
}
|
|
scrollPreviewBy := func(amount int) {
|
|
scrollPreviewTo(t.previewer.offset + amount)
|
|
}
|
|
|
|
actionsFor := func(eventType tui.EventType) []*action {
|
|
return t.keymap[eventType.AsEvent()]
|
|
}
|
|
|
|
var doAction func(*action) bool
|
|
doActions := func(actions []*action) bool {
|
|
// Snapshot to detect query edits in this batch that trigger a
|
|
// search at loop end without setting 'changed'. String copy:
|
|
// edits mutate t.input in place.
|
|
queryBefore := string(t.input)
|
|
for iter := 0; iter <= maxFocusEvents; iter++ {
|
|
currentIndex := t.currentIndex()
|
|
for i, action := range actions {
|
|
if action.t == actWait {
|
|
// Already waiting. Actions parsed from a bg-transform
|
|
// result join the current wait; user input can't reset
|
|
// the blocked state
|
|
if t.wait.blocked {
|
|
if inBgCallback {
|
|
t.wait.pending = append(t.wait.pending, actions[i+1:]...)
|
|
}
|
|
return true
|
|
}
|
|
// Block if search is in progress or will be triggered
|
|
if changed || newCommand != nil || t.wait.searching || queryBefore != string(t.input) {
|
|
t.blockWait(actions[i+1:])
|
|
return true
|
|
}
|
|
// No search, wait is a no-op; continue to next action
|
|
continue
|
|
}
|
|
blockedBefore := t.wait.blocked
|
|
if !doAction(action) {
|
|
return false
|
|
}
|
|
// If this action armed the wait through a nested list
|
|
// (e.g. via trigger), defer the rest of this list too
|
|
if !blockedBefore && t.wait.blocked {
|
|
t.wait.pending = append(t.wait.pending, actions[i+1:]...)
|
|
return true
|
|
}
|
|
// A terminal action performed. We should stop processing more.
|
|
if !looping {
|
|
break
|
|
}
|
|
}
|
|
|
|
if onFocus, prs := t.keymap[tui.Focus.AsEvent()]; prs && iter < maxFocusEvents {
|
|
if newIndex := t.currentIndex(); newIndex != currentIndex {
|
|
t.lastFocus = newIndex
|
|
if t.infoCommand != "" {
|
|
req(reqInfo)
|
|
}
|
|
actions = onFocus
|
|
continue
|
|
}
|
|
}
|
|
break
|
|
}
|
|
return true
|
|
}
|
|
doAction = func(a *action) bool {
|
|
// Keep track of the current query before the action is executed,
|
|
// so we can restore it when the input section is hidden (--no-input).
|
|
// * By doing this, we don't have to add a conditional branch to each
|
|
// query modifying action.
|
|
// * We restore the query after each action instead of after a set of
|
|
// actions to allow changing the query even when the input is hidden
|
|
// e.g. fzf --no-input --bind 'space:show-input+change-query(foo)+hide-input'
|
|
currentInput := t.input
|
|
capture := func(firstLineOnly bool, callback func(string)) {
|
|
if a.t >= actBgTransform {
|
|
// bg-transform-*
|
|
t.captureAsync(*a, firstLineOnly, callback)
|
|
} else if a.t >= actTransform {
|
|
// transform-*
|
|
if firstLineOnly {
|
|
callback(t.captureLine(a.a))
|
|
} else {
|
|
callback(t.captureLines(a.a))
|
|
}
|
|
} else {
|
|
// change-*
|
|
callback(a.a)
|
|
}
|
|
}
|
|
// Actions that run even while wait/track-blocked: bg-transform
|
|
// callbacks and their parsed actions (results of processes
|
|
// started before the block), and bracketed paste bookkeeping (a
|
|
// swallowed paste-end would leave t.pasting set forever).
|
|
passthrough := inBgCallback || a.t == actAsync ||
|
|
a.t == actBracketedPasteBegin || a.t == actBracketedPasteEnd
|
|
// When wait-blocked, only allow abort/cancel
|
|
if t.wait.blocked && !passthrough {
|
|
if a.t == actAbort || a.t == actCancel {
|
|
t.cancelWait()
|
|
req(reqPrompt, reqInfo)
|
|
}
|
|
return true
|
|
}
|
|
// When track-blocked, only allow abort/cancel and track-disabling actions
|
|
if t.trackBlocked && !passthrough && a.t != actToggleTrack && a.t != actToggleTrackCurrent && a.t != actUntrackCurrent {
|
|
if a.t == actAbort || a.t == actCancel {
|
|
t.unblockTrack()
|
|
req(reqPrompt, reqInfo)
|
|
}
|
|
return true
|
|
}
|
|
Action:
|
|
switch a.t {
|
|
case actIgnore, actStart, actClick:
|
|
case actAsync:
|
|
inBgCallback = true
|
|
for _, callback := range callbacks {
|
|
if t.bgVersion == callback.version {
|
|
callback.callback()
|
|
}
|
|
}
|
|
inBgCallback = false
|
|
case actBecome:
|
|
valid, list := t.buildPlusList(a.a, false)
|
|
if valid {
|
|
// We do not remove temp files in this case
|
|
command, _ := t.replacePlaceholder(a.a, false, string(t.input), list)
|
|
t.tui.Close()
|
|
if t.history != nil {
|
|
t.history.append(string(t.input))
|
|
}
|
|
|
|
if len(t.proxyScript) > 0 {
|
|
data := strings.Join(append([]string{command}, t.environ()...), "\x00")
|
|
os.WriteFile(t.proxyScript+becomeSuffix, []byte(data), 0600)
|
|
req(reqBecome)
|
|
} else {
|
|
t.executor.Become(t.ttyin, t.environ(), command)
|
|
}
|
|
}
|
|
case actBell:
|
|
t.tui.Bell()
|
|
case actExcludeMulti:
|
|
if len(t.selected) > 0 {
|
|
for _, item := range t.sortSelected() {
|
|
denylist = append(denylist, item.item.Index())
|
|
}
|
|
// Clear selected items
|
|
t.selected = make(map[int32]selectedItem)
|
|
t.version++
|
|
} else {
|
|
item := t.currentItem()
|
|
if item != nil {
|
|
denylist = append(denylist, item.Index())
|
|
}
|
|
}
|
|
changed = true
|
|
case actExclude:
|
|
if item := t.currentItem(); item != nil {
|
|
denylist = append(denylist, item.Index())
|
|
t.deselectItem(item)
|
|
changed = true
|
|
}
|
|
case actExecute, actExecuteSilent:
|
|
t.executeCommand(a.a, false, a.t == actExecuteSilent, false, false, "")
|
|
case actExecuteMulti:
|
|
t.executeCommand(a.a, true, false, false, false, "")
|
|
case actInvalid:
|
|
t.mutex.Unlock()
|
|
return false
|
|
case actBracketedPasteBegin:
|
|
// Clone: []rune(t.input) would alias t.input, and in-place
|
|
// query edits during the paste would corrupt the snapshot
|
|
current := slices.Clone(t.input)
|
|
t.pasting = ¤t
|
|
case actBracketedPasteEnd:
|
|
if t.pasting != nil {
|
|
queryChanged = string(t.input) != string(*t.pasting)
|
|
t.pasting = nil
|
|
}
|
|
case actTogglePreview, actShowPreview, actHidePreview:
|
|
var act bool
|
|
switch a.t {
|
|
case actShowPreview:
|
|
act = !t.hasPreviewWindow() && len(t.previewOpts.command) > 0
|
|
case actHidePreview:
|
|
act = t.hasPreviewWindow()
|
|
case actTogglePreview:
|
|
act = t.hasPreviewWindow() || len(t.previewOpts.command) > 0
|
|
}
|
|
if act {
|
|
t.activePreviewOpts.Toggle()
|
|
updatePreviewWindow(false)
|
|
if t.canPreview() {
|
|
valid, list := t.buildPlusList(t.previewOpts.command, false)
|
|
if valid {
|
|
t.cancelPreview()
|
|
t.previewBox.Set(reqPreviewEnqueue,
|
|
previewRequest{t.previewOpts.command, t.evaluateScrollOffset(), list, t.environForPreview(), string(t.input)})
|
|
}
|
|
} else {
|
|
// Discard the preview content so that it won't accidentally appear
|
|
// when preview window is re-enabled and previewDelay is triggered
|
|
t.previewer.lines = nil
|
|
|
|
// Also kill the preview process if it's still running
|
|
t.cancelPreview()
|
|
}
|
|
}
|
|
case actTogglePreviewWrap, actTogglePreviewWrapWord:
|
|
if t.hasPreviewWindow() {
|
|
if a.t == actTogglePreviewWrapWord {
|
|
t.activePreviewOpts.wrapWord = !t.activePreviewOpts.wrapWord
|
|
t.activePreviewOpts.wrap = t.activePreviewOpts.wrapWord
|
|
} else {
|
|
t.activePreviewOpts.wrap = !t.activePreviewOpts.wrap
|
|
if !t.activePreviewOpts.wrap {
|
|
t.activePreviewOpts.wrapWord = false
|
|
}
|
|
}
|
|
// Reset preview version so that full redraw occurs
|
|
t.previewed.version = 0
|
|
req(reqPreviewRefresh)
|
|
}
|
|
case actTransformPrompt, actBgTransformPrompt:
|
|
capture(true, func(prompt string) {
|
|
t.promptString = prompt
|
|
t.prompt, t.promptLen = t.parsePrompt(prompt)
|
|
req(reqPrompt)
|
|
})
|
|
case actTransformQuery, actBgTransformQuery:
|
|
capture(true, func(query string) {
|
|
t.input = []rune(query)
|
|
t.cx = len(t.input)
|
|
})
|
|
case actToggleSort:
|
|
t.sort = !t.sort
|
|
changed = true
|
|
case actPreviewTop:
|
|
if t.hasPreviewWindow() {
|
|
scrollPreviewTo(0)
|
|
}
|
|
case actPreviewBottom:
|
|
if t.hasPreviewWindow() {
|
|
scrollPreviewTo(len(t.previewer.lines) - t.pwindow.Height())
|
|
}
|
|
case actPreviewUp:
|
|
if t.hasPreviewWindow() {
|
|
scrollPreviewBy(-1)
|
|
}
|
|
case actPreviewDown:
|
|
if t.hasPreviewWindow() {
|
|
scrollPreviewBy(1)
|
|
}
|
|
case actPreviewPageUp:
|
|
if t.hasPreviewWindow() {
|
|
scrollPreviewBy(-t.pwindow.Height())
|
|
}
|
|
case actPreviewPageDown:
|
|
if t.hasPreviewWindow() {
|
|
scrollPreviewBy(t.pwindow.Height())
|
|
}
|
|
case actPreviewHalfPageUp:
|
|
if t.hasPreviewWindow() {
|
|
scrollPreviewBy(-t.pwindow.Height() / 2)
|
|
}
|
|
case actPreviewHalfPageDown:
|
|
if t.hasPreviewWindow() {
|
|
scrollPreviewBy(t.pwindow.Height() / 2)
|
|
}
|
|
case actBeginningOfLine:
|
|
t.cx = 0
|
|
t.xoffset = 0
|
|
case actBackwardChar:
|
|
if t.cx > 0 {
|
|
t.cx--
|
|
}
|
|
case actPrintQuery:
|
|
req(reqPrintQuery)
|
|
case actChangeHeaderLines, actTransformHeaderLines, actBgTransformHeaderLines:
|
|
capture(true, func(expr string) {
|
|
if n, err := strconv.Atoi(expr); err == nil && n >= 0 && n != t.headerLines {
|
|
t.headerLines = n
|
|
newHeaderLines = &n
|
|
changed = true
|
|
// Deselect items that are now part of the header
|
|
for idx := range t.selected {
|
|
if int(idx) < n {
|
|
delete(t.selected, idx)
|
|
}
|
|
}
|
|
// Tell UpdateList to reposition cursor to the current item
|
|
t.targetIndex = t.currentIndex()
|
|
req(reqList, reqPrompt, reqInfo, reqHeader)
|
|
}
|
|
})
|
|
case actChangeMulti:
|
|
multi := t.multi
|
|
if a.a == "" {
|
|
multi = maxMulti
|
|
} else if n, e := strconv.Atoi(a.a); e == nil && n >= 0 {
|
|
multi = n
|
|
}
|
|
if t.multi > 0 && multi != t.multi {
|
|
t.selected = make(map[int32]selectedItem)
|
|
t.version++
|
|
}
|
|
t.multi = multi
|
|
req(reqList, reqInfo)
|
|
case actChangeNth, actTransformNth, actBgTransformNth:
|
|
capture(true, func(expr string) {
|
|
// Split nth expression
|
|
tokens := strings.Split(expr, "|")
|
|
if nth, err := splitNth(tokens[0]); err == nil || len(expr) == 0 {
|
|
// Changed
|
|
newNth = &nth
|
|
} else {
|
|
// The default
|
|
newNth = &t.nth
|
|
}
|
|
// Cycle
|
|
if len(tokens) > 1 {
|
|
a.a = strings.Join(append(tokens[1:], tokens[0]), "|")
|
|
}
|
|
if !compareRanges(t.nthCurrent, *newNth) {
|
|
changed = true
|
|
t.nthCurrent = *newNth
|
|
t.forceRerenderList()
|
|
}
|
|
})
|
|
case actChangeWithNth, actTransformWithNth, actBgTransformWithNth:
|
|
if !t.withNthEnabled {
|
|
break Action
|
|
}
|
|
capture(true, func(expr string) {
|
|
tokens := strings.Split(expr, "|")
|
|
withNthExpr := tokens[0]
|
|
if len(tokens) > 1 {
|
|
a.a = strings.Join(append(tokens[1:], tokens[0]), "|")
|
|
}
|
|
// Empty value restores the default --with-nth
|
|
if len(withNthExpr) == 0 {
|
|
withNthExpr = t.withNthDefault
|
|
}
|
|
if withNthExpr != t.withNthExpr {
|
|
if factory, err := nthTransformer(withNthExpr); err == nil {
|
|
newWithNth = &withNthSpec{fn: factory(t.delimiter)}
|
|
} else {
|
|
return
|
|
}
|
|
t.withNthExpr = withNthExpr
|
|
t.filterSelection = true
|
|
changed = true
|
|
t.clearNumLinesCache()
|
|
t.forceRerenderList()
|
|
}
|
|
})
|
|
case actChangeQuery:
|
|
t.input = []rune(a.a)
|
|
t.cx = len(t.input)
|
|
case actChangeHeader, actTransformHeader, actBgTransformHeader:
|
|
capture(false, func(header string) {
|
|
if t.changeHeader(header) {
|
|
// resizeIfNeeded() tolerates a shorter-than-wanted inline
|
|
// window, so a length change can leave the inline slot
|
|
// stale. Force a redraw to re-run the layout. Non-inline
|
|
// shapes are handled by resizeIfNeeded.
|
|
if t.headerBorderShape == tui.BorderInline {
|
|
req(reqRedraw)
|
|
} else {
|
|
req(reqList, reqPrompt, reqInfo)
|
|
}
|
|
}
|
|
req(reqHeader)
|
|
})
|
|
case actChangeFooter, actTransformFooter, actBgTransformFooter:
|
|
capture(false, func(footer string) {
|
|
if t.changeFooter(footer) && t.footerBorderShape == tui.BorderInline {
|
|
// resizeIfNeeded() tolerates a shorter-than-wanted inline
|
|
// window, so a length change can leave the inline slot
|
|
// stale. Force a redraw to re-run the layout.
|
|
req(reqRedraw)
|
|
}
|
|
req(reqFooter)
|
|
})
|
|
case actChangeHeaderLabel, actTransformHeaderLabel, actBgTransformHeaderLabel:
|
|
capture(true, func(label string) {
|
|
t.headerLabelOpts.label = label
|
|
t.headerLabel, t.headerLabelLen = t.ansiLabelPrinter(label, &tui.ColHeaderLabel, false)
|
|
req(reqRedrawHeaderLabel)
|
|
})
|
|
case actChangeFooterLabel, actTransformFooterLabel, actBgTransformFooterLabel:
|
|
capture(true, func(label string) {
|
|
t.footerLabelOpts.label = label
|
|
t.footerLabel, t.footerLabelLen = t.ansiLabelPrinter(label, &tui.ColFooterLabel, false)
|
|
req(reqRedrawFooterLabel)
|
|
})
|
|
case actChangeInputLabel, actTransformInputLabel, actBgTransformInputLabel:
|
|
capture(true, func(label string) {
|
|
t.inputLabelOpts.label = label
|
|
if t.inputBorder != nil {
|
|
t.inputLabel, t.inputLabelLen = t.ansiLabelPrinter(label, &tui.ColInputLabel, false)
|
|
req(reqRedrawInputLabel)
|
|
}
|
|
})
|
|
case actChangeListLabel, actTransformListLabel, actBgTransformListLabel:
|
|
capture(true, func(label string) {
|
|
t.listLabelOpts.label = label
|
|
if t.wborder != nil {
|
|
t.listLabel, t.listLabelLen = t.ansiLabelPrinter(label, &tui.ColListLabel, false)
|
|
req(reqRedrawListLabel)
|
|
}
|
|
})
|
|
case actChangeBorderLabel, actTransformBorderLabel, actBgTransformBorderLabel:
|
|
capture(true, func(label string) {
|
|
t.borderLabelOpts.label = label
|
|
if t.border != nil {
|
|
t.borderLabel, t.borderLabelLen = t.ansiLabelPrinter(label, &tui.ColBorderLabel, false)
|
|
req(reqRedrawBorderLabel)
|
|
}
|
|
})
|
|
case actChangePreviewLabel, actTransformPreviewLabel, actBgTransformPreviewLabel:
|
|
capture(true, func(label string) {
|
|
t.previewLabelOpts.label = label
|
|
if t.pborder != nil {
|
|
t.previewLabel, t.previewLabelLen = t.ansiLabelPrinter(label, &tui.ColPreviewLabel, false)
|
|
req(reqRedrawPreviewLabel)
|
|
}
|
|
})
|
|
case actTransform, actBgTransform:
|
|
capture(false, func(body string) {
|
|
// Allow 'put' if the triggering key is a printable character
|
|
if actions, err := parseSingleActionList(strings.Trim(body, "\r\n"), event.Printable()); err == nil {
|
|
// NOTE: We're not properly passing the return value here
|
|
doActions(actions)
|
|
}
|
|
})
|
|
case actBgCancel:
|
|
t.bgVersion++
|
|
t.runningCmds.ForEach(func(cmd *runningCmd) {
|
|
util.KillCommand(cmd.cmd)
|
|
})
|
|
case actChangePrompt:
|
|
t.promptString = a.a
|
|
t.prompt, t.promptLen = t.parsePrompt(a.a)
|
|
req(reqPrompt)
|
|
case actPreview:
|
|
if !t.hasPreviewWindow() {
|
|
updatePreviewWindow(true)
|
|
}
|
|
refreshPreview(a.a)
|
|
case actRefreshPreview:
|
|
refreshPreview(t.previewOpts.command)
|
|
case actReplaceQuery:
|
|
current := t.currentItem()
|
|
if current != nil {
|
|
t.input = current.text.ToRunes()
|
|
t.cx = len(t.input)
|
|
}
|
|
case actFatal:
|
|
req(reqFatal)
|
|
case actAbort:
|
|
req(reqQuit)
|
|
case actDeleteChar:
|
|
t.delChar()
|
|
case actDeleteCharEof:
|
|
if !t.delChar() && t.cx == 0 {
|
|
req(reqQuit)
|
|
}
|
|
case actEndOfLine:
|
|
t.cx = len(t.input)
|
|
case actCancel:
|
|
if len(t.input) == 0 {
|
|
req(reqQuit)
|
|
} else {
|
|
t.yanked = t.input
|
|
t.input = []rune{}
|
|
t.cx = 0
|
|
}
|
|
case actBackwardDeleteCharEof:
|
|
if len(t.input) == 0 {
|
|
req(reqQuit)
|
|
} else if t.cx > 0 {
|
|
t.input = append(t.input[:t.cx-1], t.input[t.cx:]...)
|
|
t.cx--
|
|
}
|
|
case actForwardChar:
|
|
if t.cx < len(t.input) {
|
|
t.cx++
|
|
}
|
|
case actBackwardDeleteChar:
|
|
beof = len(t.input) == 0
|
|
if t.cx > 0 {
|
|
t.input = append(t.input[:t.cx-1], t.input[t.cx:]...)
|
|
t.cx--
|
|
}
|
|
case actSelectAll:
|
|
if t.multi > 0 {
|
|
// Limit the scope only to the matching items
|
|
for i := 0; i < t.resultMerger.Length(); i++ {
|
|
if !t.selectItem(t.resultMerger.Get(i).item) {
|
|
break
|
|
}
|
|
}
|
|
req(reqList, reqInfo)
|
|
}
|
|
case actDeselectAll:
|
|
if t.multi > 0 {
|
|
// Also limit the scope only to the matching items, while this may
|
|
// not be straightforward in raw mode.
|
|
for i := 0; i < t.resultMerger.Length() && len(t.selected) > 0; i++ {
|
|
t.deselectItem(t.resultMerger.Get(i).item)
|
|
}
|
|
req(reqList, reqInfo)
|
|
}
|
|
case actClose:
|
|
if t.hasPreviewWindow() {
|
|
t.activePreviewOpts.Toggle()
|
|
updatePreviewWindow(false)
|
|
} else {
|
|
req(reqQuit)
|
|
}
|
|
case actSelect:
|
|
current := t.currentItem()
|
|
if t.multi > 0 && current != nil && t.selectItemChanged(current) {
|
|
req(reqList, reqInfo)
|
|
}
|
|
case actDeselect:
|
|
current := t.currentItem()
|
|
if t.multi > 0 && current != nil && t.deselectItemChanged(current) {
|
|
req(reqList, reqInfo)
|
|
}
|
|
case actToggle:
|
|
if t.multi > 0 && t.merger.Length() > 0 && toggle() {
|
|
req(reqList)
|
|
}
|
|
case actToggleAll:
|
|
if t.multi > 0 {
|
|
prevIndexes := make(map[int]struct{})
|
|
for i := 0; i < t.resultMerger.Length() && len(t.selected) > 0; i++ {
|
|
item := t.resultMerger.Get(i).item
|
|
if _, found := t.selected[item.Index()]; found {
|
|
prevIndexes[i] = struct{}{}
|
|
t.deselectItem(item)
|
|
}
|
|
}
|
|
|
|
for i := 0; i < t.resultMerger.Length(); i++ {
|
|
if _, found := prevIndexes[i]; !found {
|
|
item := t.resultMerger.Get(i).item
|
|
if !t.selectItem(item) {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
req(reqList, reqInfo)
|
|
}
|
|
case actToggleIn:
|
|
if t.layout != layoutDefault {
|
|
return doAction(&action{t: actToggleUp})
|
|
}
|
|
return doAction(&action{t: actToggleDown})
|
|
case actToggleOut:
|
|
if t.layout != layoutDefault {
|
|
return doAction(&action{t: actToggleDown})
|
|
}
|
|
return doAction(&action{t: actToggleUp})
|
|
case actToggleDown:
|
|
if t.multi > 0 && t.merger.Length() > 0 && toggle() {
|
|
t.vmove(-1, true)
|
|
req(reqList)
|
|
}
|
|
case actToggleUp:
|
|
if t.multi > 0 && t.merger.Length() > 0 && toggle() {
|
|
t.vmove(1, true)
|
|
req(reqList)
|
|
}
|
|
case actDown, actDownMatch, actUp, actUpMatch:
|
|
dir := -1
|
|
if a.t == actUp || a.t == actUpMatch {
|
|
dir = 1
|
|
}
|
|
if t.raw && (a.t == actDownMatch || a.t == actUpMatch) {
|
|
if t.resultMerger.Length() > 0 {
|
|
prevCy := t.cy
|
|
for t.vmove(dir, true) && !t.isCurrentItemMatch() {
|
|
}
|
|
if !t.isCurrentItemMatch() {
|
|
t.vset(prevCy)
|
|
}
|
|
}
|
|
} else {
|
|
t.vmove(dir, true)
|
|
}
|
|
req(reqList)
|
|
case actToggleRaw, actEnableRaw, actDisableRaw:
|
|
prevRaw := t.raw
|
|
newRaw := t.raw
|
|
switch a.t {
|
|
case actEnableRaw:
|
|
newRaw = true
|
|
case actDisableRaw:
|
|
newRaw = false
|
|
case actToggleRaw:
|
|
newRaw = !t.raw
|
|
}
|
|
if prevRaw == newRaw {
|
|
break
|
|
}
|
|
prevPos := t.cy - t.offset
|
|
prevIndex := t.currentIndex()
|
|
if newRaw {
|
|
// Build matchMap if not available
|
|
if len(t.matchMap) == 0 {
|
|
t.matchMap = t.resultMerger.ToMap()
|
|
}
|
|
t.merger = t.passMerger
|
|
} else {
|
|
// Find the closest matching item
|
|
if !t.isCurrentItemMatch() && t.resultMerger.Length() > 1 {
|
|
distance := 0
|
|
Loop:
|
|
for {
|
|
distance++
|
|
checks := 0
|
|
for _, cy := range []int{t.cy + distance, t.cy - distance} {
|
|
if cy >= 0 && cy < t.merger.Length() {
|
|
checks++
|
|
item := t.merger.Get(cy).item
|
|
if t.isItemMatch(item) {
|
|
prevIndex = item.Index()
|
|
break Loop
|
|
}
|
|
}
|
|
}
|
|
if checks == 0 {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
t.merger = t.resultMerger
|
|
|
|
// Need to remove non-matching items from the selection
|
|
if t.multi > 0 && len(t.selected) > 0 {
|
|
t.filterSelected()
|
|
req(reqInfo)
|
|
}
|
|
}
|
|
t.raw = newRaw
|
|
|
|
// Try to retain position
|
|
if prevIndex != minItem.Index() {
|
|
t.cy = max(0, t.merger.FindIndex(prevIndex))
|
|
t.offset = t.cy - prevPos
|
|
}
|
|
|
|
// List needs to be rerendered
|
|
t.forceRerenderList()
|
|
req(reqList)
|
|
case actAccept:
|
|
req(reqClose)
|
|
case actAcceptNonEmpty:
|
|
if len(t.selected) > 0 || t.merger.Length() > 0 || !t.reading && t.count == 0 {
|
|
req(reqClose)
|
|
}
|
|
case actAcceptOrPrintQuery:
|
|
if len(t.selected) > 0 || t.merger.Length() > 0 {
|
|
req(reqClose)
|
|
} else {
|
|
req(reqPrintQuery)
|
|
}
|
|
case actClearScreen:
|
|
req(reqFullRedraw)
|
|
case actClearQuery:
|
|
t.input = []rune{}
|
|
t.cx = 0
|
|
case actClearSelection:
|
|
if t.multi > 0 {
|
|
t.selected = make(map[int32]selectedItem)
|
|
t.version++
|
|
req(reqList, reqInfo)
|
|
}
|
|
case actFirst, actBest:
|
|
if t.raw && a.t == actBest {
|
|
if t.resultMerger.Length() > 0 {
|
|
t.vset(t.merger.FindIndex(t.resultMerger.Get(0).item.Index()))
|
|
}
|
|
} else {
|
|
t.vset(0)
|
|
}
|
|
t.constrain()
|
|
req(reqList)
|
|
case actLast:
|
|
t.vset(t.merger.Length() - 1)
|
|
t.constrain()
|
|
req(reqList)
|
|
case actPosition:
|
|
if n, e := strconv.Atoi(a.a); e == nil {
|
|
if n > 0 {
|
|
n--
|
|
} else if n < 0 {
|
|
n += t.merger.Length()
|
|
}
|
|
t.vset(n)
|
|
t.constrain()
|
|
req(reqList)
|
|
}
|
|
case actPut:
|
|
str := []rune(a.a)
|
|
suffix := copySlice(t.input[t.cx:])
|
|
t.input = append(append(t.input[:t.cx], str...), suffix...)
|
|
t.cx += len(str)
|
|
case actPrint:
|
|
t.printQueue = append(t.printQueue, a.a)
|
|
case actUnixLineDiscard:
|
|
beof = len(t.input) == 0
|
|
if t.cx > 0 {
|
|
t.yanked = copySlice(t.input[:t.cx])
|
|
t.input = t.input[t.cx:]
|
|
t.cx = 0
|
|
}
|
|
case actUnixWordRubout:
|
|
beof = len(t.input) == 0
|
|
if t.cx > 0 {
|
|
t.rubout("\\s\\S")
|
|
}
|
|
case actBackwardKillWord:
|
|
beof = len(t.input) == 0
|
|
if t.cx > 0 {
|
|
t.rubout(t.wordRubout)
|
|
}
|
|
case actBackwardKillSubWord:
|
|
beof = len(t.input) == 0
|
|
if t.cx > 0 {
|
|
t.rubout(t.subWordRubout)
|
|
}
|
|
case actYank:
|
|
suffix := copySlice(t.input[t.cx:])
|
|
t.input = append(append(t.input[:t.cx], t.yanked...), suffix...)
|
|
t.cx += len(t.yanked)
|
|
case actPageUp, actPageDown, actHalfPageUp, actHalfPageDown:
|
|
// Calculate the number of lines to move
|
|
maxItems := t.maxItems()
|
|
linesToMove := maxItems - 1
|
|
if a.t == actHalfPageUp || a.t == actHalfPageDown {
|
|
linesToMove = maxItems / 2
|
|
}
|
|
// Move at least one line even in a very short window
|
|
linesToMove = max(1, linesToMove)
|
|
|
|
// Determine the direction of the movement
|
|
direction := -1
|
|
if a.t == actPageUp || a.t == actHalfPageUp {
|
|
direction = 1
|
|
}
|
|
|
|
// In non-default layout, items are listed from top to bottom
|
|
if t.layout != layoutDefault {
|
|
direction *= -1
|
|
}
|
|
|
|
// We can simply add the number of lines to the current position in
|
|
// single-line mode
|
|
if !t.canSpanMultiLines() {
|
|
t.vset(t.cy + direction*linesToMove)
|
|
req(reqList)
|
|
break
|
|
}
|
|
|
|
// But in multi-line mode, we need to carefully limit the amount of
|
|
// vertical movement so that items are not skipped. In order to do
|
|
// this, we calculate the minimum or maximum offset based on the
|
|
// direction of the movement and the number of lines of the items
|
|
// around the current scroll offset.
|
|
var minOffset, maxOffset, lineSum int
|
|
if direction > 0 {
|
|
maxOffset = t.offset
|
|
for ; maxOffset < t.merger.Length(); maxOffset++ {
|
|
itemLines, _ := t.numItemLines(t.merger.Get(maxOffset).item, maxItems)
|
|
lineSum += itemLines
|
|
if lineSum >= maxItems {
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
minOffset = t.offset
|
|
for ; minOffset >= 0 && minOffset < t.merger.Length(); minOffset-- {
|
|
itemLines, _ := t.numItemLines(t.merger.Get(minOffset).item, maxItems)
|
|
lineSum += itemLines
|
|
if lineSum >= maxItems {
|
|
if lineSum > maxItems {
|
|
minOffset++
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
for i := 0; i < linesToMove; i++ {
|
|
cy, offset := t.cy, t.offset
|
|
t.vset(cy + direction)
|
|
t.constrain()
|
|
if cy == t.cy {
|
|
break
|
|
}
|
|
if i > 0 && (direction > 0 && t.offset > maxOffset ||
|
|
direction < 0 && t.offset < minOffset) {
|
|
t.cy, t.offset = cy, offset
|
|
break
|
|
}
|
|
}
|
|
req(reqList)
|
|
case actOffsetUp, actOffsetDown:
|
|
diff := 1
|
|
if a.t == actOffsetDown {
|
|
diff = -1
|
|
}
|
|
if t.layout != layoutDefault {
|
|
diff *= -1
|
|
}
|
|
t.offset += diff
|
|
before := t.offset
|
|
t.constrain()
|
|
if before != t.offset {
|
|
t.offset = before
|
|
if t.layout != layoutDefault {
|
|
diff *= -1
|
|
}
|
|
t.vmove(diff, false)
|
|
}
|
|
req(reqList)
|
|
case actOffsetMiddle:
|
|
soff := t.scrollOff
|
|
t.scrollOff = t.window.Height()
|
|
t.constrain()
|
|
t.scrollOff = soff
|
|
req(reqList)
|
|
case actJump:
|
|
t.jumping = jumpEnabled
|
|
req(reqJump)
|
|
case actJumpAccept:
|
|
t.jumping = jumpAcceptEnabled
|
|
req(reqJump)
|
|
case actBackwardWord:
|
|
t.cx = findLastMatch(t.wordRubout, string(t.input[:t.cx])) + 1
|
|
case actForwardWord:
|
|
t.cx += findFirstMatch(t.wordNext, string(t.input[t.cx:])) + 1
|
|
case actBackwardSubWord:
|
|
t.cx = findLastMatch(t.subWordRubout, string(t.input[:t.cx])) + 1
|
|
case actForwardSubWord:
|
|
t.cx += findFirstMatch(t.subWordNext, string(t.input[t.cx:])) + 1
|
|
case actKillWord:
|
|
ncx := t.cx +
|
|
findFirstMatch(t.wordNext, string(t.input[t.cx:])) + 1
|
|
if ncx > t.cx {
|
|
t.yanked = copySlice(t.input[t.cx:ncx])
|
|
t.input = append(t.input[:t.cx], t.input[ncx:]...)
|
|
}
|
|
case actKillSubWord:
|
|
ncx := t.cx +
|
|
findFirstMatch(t.subWordNext, string(t.input[t.cx:])) + 1
|
|
if ncx > t.cx {
|
|
t.yanked = copySlice(t.input[t.cx:ncx])
|
|
t.input = append(t.input[:t.cx], t.input[ncx:]...)
|
|
}
|
|
case actKillLine:
|
|
if t.cx < len(t.input) {
|
|
t.yanked = copySlice(t.input[t.cx:])
|
|
t.input = t.input[:t.cx]
|
|
}
|
|
case actChar:
|
|
prefix := copySlice(t.input[:t.cx])
|
|
t.input = append(append(prefix, event.Char), t.input[t.cx:]...)
|
|
t.cx++
|
|
case actPrevHistory:
|
|
if t.history != nil {
|
|
t.history.override(string(t.input))
|
|
t.input = trimQuery(t.history.previous())
|
|
t.cx = len(t.input)
|
|
}
|
|
case actNextHistory:
|
|
if t.history != nil {
|
|
t.history.override(string(t.input))
|
|
t.input = trimQuery(t.history.next())
|
|
t.cx = len(t.input)
|
|
}
|
|
case actToggleSearch:
|
|
t.paused = !t.paused
|
|
changed = !t.paused
|
|
req(reqPrompt)
|
|
case actToggleTrack:
|
|
switch t.track {
|
|
case trackEnabled:
|
|
t.track = trackDisabled
|
|
case trackDisabled:
|
|
t.track = trackEnabled
|
|
}
|
|
t.unblockTrack()
|
|
req(reqPrompt, reqInfo)
|
|
case actToggleTrackCurrent:
|
|
if t.track.Current() {
|
|
t.track = trackDisabled
|
|
} else if t.track.Disabled() {
|
|
t.track = trackCurrent(t.currentIndex())
|
|
}
|
|
t.unblockTrack()
|
|
req(reqPrompt, reqInfo)
|
|
case actShowHeader:
|
|
t.headerVisible = true
|
|
req(reqList, reqInfo, reqPrompt, reqHeader)
|
|
case actHideHeader:
|
|
t.headerVisible = false
|
|
req(reqList, reqInfo, reqPrompt, reqHeader)
|
|
case actToggleHeader:
|
|
t.headerVisible = !t.headerVisible
|
|
req(reqList, reqInfo, reqPrompt, reqHeader)
|
|
case actToggleWrap, actToggleWrapWord:
|
|
if a.t == actToggleWrapWord {
|
|
t.wrapWord = !t.wrapWord
|
|
t.wrap = t.wrapWord
|
|
} else {
|
|
t.wrap = !t.wrap
|
|
if !t.wrap {
|
|
t.wrapWord = false
|
|
}
|
|
}
|
|
t.clearNumLinesCache()
|
|
req(reqList, reqHeader)
|
|
case actToggleMultiLine:
|
|
t.multiLine = !t.multiLine
|
|
t.clearNumLinesCache()
|
|
req(reqList)
|
|
case actToggleHscroll:
|
|
// Force re-rendering of the list
|
|
t.forceRerenderList()
|
|
t.hscroll = !t.hscroll
|
|
req(reqList)
|
|
case actToggleInput, actShowInput, actHideInput:
|
|
switch a.t {
|
|
case actToggleInput:
|
|
t.inputless = !t.inputless
|
|
case actShowInput:
|
|
if !t.inputless {
|
|
break Action
|
|
}
|
|
t.inputless = false
|
|
case actHideInput:
|
|
if t.inputless {
|
|
break Action
|
|
}
|
|
t.inputless = true
|
|
}
|
|
t.forceRerenderList()
|
|
if t.inputless {
|
|
t.tui.HideCursor()
|
|
} else {
|
|
t.tui.ShowCursor()
|
|
}
|
|
req(reqList, reqInfo, reqPrompt, reqHeader)
|
|
case actTrackCurrent:
|
|
// Global tracking has higher priority
|
|
if !t.track.Global() {
|
|
t.track = trackCurrent(t.currentIndex())
|
|
}
|
|
req(reqInfo)
|
|
case actUntrackCurrent:
|
|
if t.track.Current() {
|
|
t.track = trackDisabled
|
|
}
|
|
t.unblockTrack()
|
|
req(reqPrompt, reqInfo)
|
|
case actSearch:
|
|
override := []rune(a.a)
|
|
t.inputOverride = &override
|
|
changed = true
|
|
case actTransformSearch, actBgTransformSearch:
|
|
capture(true, func(query string) {
|
|
override := []rune(query)
|
|
t.inputOverride = &override
|
|
changed = true
|
|
})
|
|
case actEnableSearch:
|
|
t.paused = false
|
|
changed = true
|
|
req(reqPrompt)
|
|
case actDisableSearch:
|
|
t.paused = true
|
|
req(reqPrompt)
|
|
case actTrigger:
|
|
if _, chords, err := parseKeyChords(a.a, ""); err == nil {
|
|
blockedBefore := t.wait.blocked
|
|
for ci, chord := range chords {
|
|
if _, prs := triggering[chord]; prs {
|
|
// Avoid recursive triggering
|
|
continue
|
|
}
|
|
if acts, prs := t.keymap[chord]; prs {
|
|
triggering[chord] = struct{}{}
|
|
doActions(acts)
|
|
delete(triggering, chord)
|
|
}
|
|
// If this chord armed the wait, defer the remaining chords
|
|
if !blockedBefore && t.wait.blocked {
|
|
for _, rest := range chords[ci+1:] {
|
|
if _, prs := triggering[rest]; prs {
|
|
// Avoid recursive triggering
|
|
continue
|
|
}
|
|
if acts, prs := t.keymap[rest]; prs {
|
|
t.wait.pending = append(t.wait.pending, acts...)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
case actSigStop:
|
|
p, err := os.FindProcess(os.Getpid())
|
|
if err == nil {
|
|
t.tui.Clear()
|
|
t.tui.Pause(t.fullscreen)
|
|
notifyStop(p)
|
|
t.mutex.Unlock()
|
|
t.reqBox.Set(reqReinit, nil)
|
|
return false
|
|
}
|
|
case actMouse:
|
|
me := event.MouseEvent
|
|
mx, my := me.X, me.Y
|
|
click := !wasDown && me.Down
|
|
clicked := wasDown && !me.Down && (mx == pmx && my == pmy)
|
|
wasDown = me.Down
|
|
if click {
|
|
pmx, pmy = mx, my
|
|
}
|
|
if !me.Down {
|
|
barDragging = false
|
|
pmx, pmy = -1, -1
|
|
}
|
|
if !me.Down || !t.hasPreviewWindow() {
|
|
pbarDragging = false
|
|
pborderDragging = -1
|
|
previewDraggingPos = -1
|
|
}
|
|
|
|
// Scrolling
|
|
if me.S != 0 {
|
|
if t.window.Enclose(my, mx) && t.merger.Length() > 0 {
|
|
evt := tui.ScrollUp
|
|
if me.Mod() {
|
|
evt = tui.SScrollUp
|
|
}
|
|
if me.S < 0 {
|
|
evt = tui.ScrollDown
|
|
if me.Mod() {
|
|
evt = tui.SScrollDown
|
|
}
|
|
}
|
|
return doActions(actionsFor(evt))
|
|
} else if t.hasPreviewWindow() && t.pwindow.Enclose(my, mx) {
|
|
evt := tui.PreviewScrollUp
|
|
if me.S < 0 {
|
|
evt = tui.PreviewScrollDown
|
|
}
|
|
return doActions(actionsFor(evt))
|
|
}
|
|
break
|
|
}
|
|
|
|
// Preview dragging
|
|
if t.hasPreviewWindow() && me.Down && (previewDraggingPos >= 0 || click && t.pwindow.Enclose(my, mx)) {
|
|
if previewDraggingPos > 0 {
|
|
scrollPreviewBy(previewDraggingPos - my)
|
|
}
|
|
previewDraggingPos = my
|
|
break
|
|
}
|
|
|
|
// Preview scrollbar dragging
|
|
headerLines := t.activePreviewOpts.headerLines
|
|
pbarDragging = t.hasPreviewWindow() && me.Down && (pbarDragging || click && my >= t.pwindow.Top()+headerLines && my < t.pwindow.Top()+t.pwindow.Height() && mx == t.pwindow.Left()+t.pwindow.Width())
|
|
if pbarDragging {
|
|
effectiveHeight := t.pwindow.Height() - headerLines
|
|
numLines := len(t.previewer.lines) - headerLines
|
|
barLength, _ := getScrollbar(1, numLines, effectiveHeight, min(numLines-effectiveHeight, t.previewer.offset-headerLines))
|
|
if barLength > 0 {
|
|
y := my - t.pwindow.Top() - headerLines - barLength/2
|
|
y = util.Constrain(y, 0, effectiveHeight-barLength)
|
|
// offset = (total - maxItems) * barStart / (maxItems - barLength)
|
|
t.previewer.offset = headerLines + int(math.Ceil(float64(y)*float64(numLines-effectiveHeight)/float64(effectiveHeight-barLength)))
|
|
t.previewer.following.Set(t.previewer.offset >= numLines-effectiveHeight)
|
|
req(reqPreviewRefresh)
|
|
}
|
|
break
|
|
}
|
|
|
|
// Preview border dragging (resizing)
|
|
if t.hasPreviewWindow() && pborderDragging < 0 && click {
|
|
switch t.activePreviewOpts.position {
|
|
case posUp:
|
|
if t.pborder.Enclose(my, mx) && my == t.pborder.Top()+t.pborder.Height()-1 {
|
|
pborderDragging = 0
|
|
} else if t.listBorderShape.HasTop() && t.pborder.EncloseX(mx) && my == t.wborder.Top() {
|
|
pborderDragging = 1
|
|
}
|
|
case posDown:
|
|
if t.pborder.Enclose(my, mx) && my == t.pborder.Top() {
|
|
pborderDragging = 0
|
|
} else if t.listBorderShape.HasBottom() && t.pborder.EncloseX(mx) && my == t.wborder.Top()+t.wborder.Height()-1 {
|
|
pborderDragging = 1
|
|
}
|
|
case posLeft:
|
|
if t.pborder.Enclose(my, mx) && mx == t.pborder.Left()+t.pborder.Width()-1 {
|
|
pborderDragging = 0
|
|
} else if t.listBorderShape.HasLeft() && t.pborder.EncloseY(my) && mx == t.wborder.Left() {
|
|
pborderDragging = 1
|
|
}
|
|
case posRight:
|
|
if t.pborder.Enclose(my, mx) && mx == t.pborder.Left() {
|
|
pborderDragging = 0
|
|
} else if t.listBorderShape.HasRight() && t.pborder.EncloseY(my) && mx == t.wborder.Left()+t.wborder.Width()-1 {
|
|
pborderDragging = 1
|
|
}
|
|
case posNext:
|
|
if t.layout == layoutReverse {
|
|
if t.pborder.Enclose(my, mx) && my == t.pborder.Top()+t.pborder.Height()-1 {
|
|
pborderDragging = 0
|
|
} else if t.listBorderShape.HasTop() && t.pborder.EncloseX(mx) && my == t.wborder.Top() {
|
|
pborderDragging = 1
|
|
}
|
|
} else {
|
|
if t.pborder.Enclose(my, mx) && my == t.pborder.Top() {
|
|
pborderDragging = 0
|
|
} else if t.listBorderShape.HasBottom() && t.pborder.EncloseX(mx) && my == t.wborder.Top()+t.wborder.Height()-1 {
|
|
pborderDragging = 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if t.hasPreviewWindow() && pborderDragging >= 0 {
|
|
var newSize int
|
|
var prevSize int
|
|
switch t.activePreviewOpts.position {
|
|
case posLeft:
|
|
prevSize = t.pwindow.Width()
|
|
diff := t.pborder.Width() - prevSize
|
|
newSize = mx - t.pborder.Left() - diff + 1
|
|
case posUp:
|
|
prevSize = t.pwindow.Height()
|
|
diff := t.pborder.Height() - prevSize
|
|
newSize = my - t.pborder.Top() - diff + 1
|
|
case posDown:
|
|
prevSize = t.pwindow.Height()
|
|
offset := my - t.pborder.Top()
|
|
newSize = prevSize - offset
|
|
case posRight:
|
|
prevSize = t.pwindow.Width()
|
|
offset := mx - t.pborder.Left()
|
|
newSize = prevSize - offset
|
|
case posNext:
|
|
prevSize = t.pwindow.Height()
|
|
// In posNext, header/header-lines sections may sit
|
|
// between preview and list. When the list border is
|
|
// dragged (pborderDragging == 1), subtract that gap
|
|
// so the initial click does not jump.
|
|
headerGap := 0
|
|
if pborderDragging == 1 && t.wborder != nil {
|
|
if t.layout == layoutReverse {
|
|
headerGap = t.wborder.Top() - (t.pborder.Top() + t.pborder.Height())
|
|
} else {
|
|
headerGap = t.pborder.Top() - (t.wborder.Top() + t.wborder.Height())
|
|
}
|
|
}
|
|
if t.layout == layoutReverse {
|
|
diff := t.pborder.Height() - prevSize
|
|
newSize = my - t.pborder.Top() - diff + 1 - headerGap
|
|
} else {
|
|
offset := my - t.pborder.Top()
|
|
newSize = prevSize - offset - headerGap
|
|
}
|
|
}
|
|
newSize -= pborderDragging
|
|
if newSize < 1 {
|
|
newSize = 1
|
|
}
|
|
|
|
if prevSize == newSize {
|
|
break
|
|
}
|
|
|
|
t.activePreviewOpts.size = sizeSpec{float64(newSize), false}
|
|
updatePreviewWindow(true)
|
|
req(reqPreviewRefresh)
|
|
break
|
|
}
|
|
|
|
// Inside the input window
|
|
if t.inputWindow != nil && t.inputWindow.Enclose(my, mx) {
|
|
mx -= t.inputWindow.Left()
|
|
my -= t.inputWindow.Top()
|
|
y := t.inputWindow.Height() - 1
|
|
if t.layout == layoutReverse {
|
|
y = 0
|
|
}
|
|
mxCons := util.Constrain(mx-t.promptLen, 0, len(t.input))
|
|
if my == y && mxCons >= 0 {
|
|
t.cx = mxCons + t.xoffset
|
|
}
|
|
break
|
|
}
|
|
|
|
// Inside the header window
|
|
if clicked && t.headerVisible && t.headerWindow != nil && t.headerWindow.Enclose(my, mx) {
|
|
mx -= t.headerWindow.Left() + t.headerIndent(t.headerBorderShape)
|
|
my -= t.headerWindow.Top()
|
|
if mx < 0 {
|
|
break
|
|
}
|
|
t.clickHeaderLine = my + 1
|
|
if t.layout != layoutReverse && t.headerLinesWindow != nil {
|
|
t.clickHeaderLine += t.headerLines
|
|
}
|
|
t.clickHeaderColumn = mx + 1
|
|
return doActions(actionsFor(tui.ClickHeader))
|
|
}
|
|
|
|
if clicked && t.headerVisible && t.headerLinesWindow != nil && t.headerLinesWindow.Enclose(my, mx) {
|
|
_, shape := t.determineHeaderLinesShape()
|
|
mx -= t.headerLinesWindow.Left() + t.headerIndent(shape)
|
|
my -= t.headerLinesWindow.Top()
|
|
if mx < 0 {
|
|
break
|
|
}
|
|
t.clickHeaderLine = my + 1
|
|
if t.layout == layoutReverse {
|
|
t.clickHeaderLine += len(t.header0)
|
|
}
|
|
t.clickHeaderColumn = mx + 1
|
|
return doActions(actionsFor(tui.ClickHeader))
|
|
}
|
|
|
|
// Inside the footer window
|
|
if clicked && t.footerWindow != nil && t.footerWindow.Enclose(my, mx) {
|
|
mx -= t.footerWindow.Left() + t.headerIndent(t.footerBorderShape)
|
|
my -= t.footerWindow.Top()
|
|
if mx < 0 {
|
|
break
|
|
}
|
|
t.clickFooterLine = my + 1
|
|
t.clickFooterColumn = mx + 1
|
|
return doActions(actionsFor(tui.ClickFooter))
|
|
}
|
|
|
|
// Ignored
|
|
if !t.window.Enclose(my, mx) && !barDragging {
|
|
break
|
|
}
|
|
|
|
// Translate coordinates
|
|
mx -= t.window.Left()
|
|
my -= t.window.Top()
|
|
min := t.promptLines() + t.visibleHeaderLinesInList()
|
|
h := t.window.Height()
|
|
switch t.layout {
|
|
case layoutDefault:
|
|
my = h - my - 1
|
|
case layoutReverseList:
|
|
if my < h-min {
|
|
my += min
|
|
} else {
|
|
my = h - my - 1
|
|
}
|
|
}
|
|
|
|
// Scrollbar dragging
|
|
barDragging = me.Down && (barDragging || click && my >= min && mx == t.window.Width()-1)
|
|
if barDragging {
|
|
barLength, barStart := t.getScrollbar()
|
|
if barLength > 0 {
|
|
maxItems := t.maxItems()
|
|
if newBarStart := util.Constrain(my-min-barLength/2, 0, maxItems-barLength); newBarStart != barStart {
|
|
total := t.merger.Length()
|
|
prevOffset := t.offset
|
|
// barStart = (maxItems - barLength) * t.offset / (total - maxItems)
|
|
perLine := t.avgNumLines()
|
|
t.offset = int(math.Ceil(float64(newBarStart) * float64(total*perLine-maxItems) / float64(maxItems*perLine-barLength)))
|
|
t.cy = t.offset + t.cy - prevOffset
|
|
req(reqList)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
// There can be empty lines after the list in multi-line mode
|
|
prevLine := t.prevLines[my]
|
|
if prevLine.empty {
|
|
break
|
|
}
|
|
|
|
// Double-click on an item
|
|
cy := prevLine.cy
|
|
if me.Double && mx < t.window.Width()-1 {
|
|
// Double-click
|
|
if my >= min {
|
|
if t.vset(cy) && t.cy < t.merger.Length() {
|
|
return doActions(actionsFor(tui.DoubleClick))
|
|
}
|
|
}
|
|
}
|
|
|
|
if me.Down {
|
|
mxCons := util.Constrain(mx-t.promptLen, 0, len(t.input))
|
|
if !t.inputless && t.inputWindow == nil && my == 0 && mxCons >= 0 {
|
|
// Prompt
|
|
t.cx = mxCons + t.xoffset
|
|
} else if my >= min {
|
|
t.vset(cy)
|
|
req(reqList)
|
|
evt := tui.RightClick
|
|
if me.Mod() {
|
|
evt = tui.SRightClick
|
|
}
|
|
if me.Left {
|
|
evt = tui.LeftClick
|
|
if me.Mod() {
|
|
evt = tui.SLeftClick
|
|
}
|
|
}
|
|
return doActions(actionsFor(evt))
|
|
}
|
|
}
|
|
if clicked && t.headerVisible && t.headerWindow == nil {
|
|
// Header
|
|
numLines := t.visibleHeaderLinesInList()
|
|
lineOffset := 0
|
|
if !t.inputless && t.inputWindow == nil && !t.headerFirst {
|
|
// offset for info line
|
|
if t.noSeparatorLine() {
|
|
lineOffset = 1
|
|
} else {
|
|
lineOffset = 2
|
|
}
|
|
}
|
|
my -= lineOffset
|
|
mx -= t.pointerLen + t.markerLen
|
|
if my >= 0 && my < numLines && mx >= 0 {
|
|
if t.layout == layoutReverse {
|
|
t.clickHeaderLine = my + 1
|
|
} else {
|
|
t.clickHeaderLine = numLines - my
|
|
}
|
|
t.clickHeaderColumn = mx + 1
|
|
return doActions(actionsFor(tui.ClickHeader))
|
|
}
|
|
}
|
|
case actReload, actReloadSync:
|
|
t.failed = nil
|
|
|
|
valid, list := t.buildPlusList(a.a, false)
|
|
if !valid {
|
|
// We run the command even when there's no match
|
|
// 1. If the template doesn't have any slots
|
|
// 2. If the template has {q}
|
|
slot, _, _, forceUpdate := hasPreviewFlags(a.a)
|
|
valid = !slot || forceUpdate
|
|
}
|
|
if valid {
|
|
command, tempFiles := t.replacePlaceholder(a.a, false, string(t.input), list)
|
|
newCommand = &commandSpec{command, tempFiles}
|
|
reloadSync = a.t == actReloadSync
|
|
t.reading = true
|
|
|
|
if len(t.idNth) > 0 {
|
|
t.trackSync = reloadSync
|
|
}
|
|
// Capture tracking key before reload
|
|
if !t.track.Disabled() && len(t.idNth) > 0 {
|
|
if item := t.currentItem(); item != nil {
|
|
t.trackKey = t.trackKeyFor(item, t.idNth)
|
|
t.trackKeyCache = make(map[int32]bool)
|
|
t.trackBlocked = true
|
|
if !t.inputless {
|
|
t.tui.HideCursor()
|
|
}
|
|
req(reqPrompt, reqInfo)
|
|
}
|
|
}
|
|
}
|
|
case actUnbind:
|
|
if keys, _, err := parseKeyChords(a.a, "PANIC"); err == nil {
|
|
for key := range keys {
|
|
delete(t.keymap, key)
|
|
}
|
|
}
|
|
case actRebind:
|
|
if keys, _, err := parseKeyChords(a.a, "PANIC"); err == nil {
|
|
for key := range keys {
|
|
if originalAction, found := t.keymapOrg[key]; found {
|
|
t.keymap[key] = originalAction
|
|
}
|
|
}
|
|
}
|
|
case actToggleBind:
|
|
if keys, _, err := parseKeyChords(a.a, "PANIC"); err == nil {
|
|
for key := range keys {
|
|
if _, bound := t.keymap[key]; bound {
|
|
delete(t.keymap, key)
|
|
} else if originalAction, found := t.keymapOrg[key]; found {
|
|
t.keymap[key] = originalAction
|
|
}
|
|
}
|
|
}
|
|
case actChangeGhost, actTransformGhost, actBgTransformGhost:
|
|
capture(true, func(ghost string) {
|
|
t.ghost = ghost
|
|
if len(t.input) == 0 {
|
|
req(reqPrompt)
|
|
}
|
|
})
|
|
case actChangePointer, actTransformPointer, actBgTransformPointer:
|
|
capture(true, func(pointer string) {
|
|
length := uniseg.StringWidth(pointer)
|
|
if length <= 2 {
|
|
if length != t.pointerLen {
|
|
t.forceRerenderList()
|
|
}
|
|
t.pointer = pointer
|
|
t.pointerLen = length
|
|
t.pointerEmpty = strings.Repeat(" ", t.pointerLen)
|
|
req(reqList)
|
|
}
|
|
})
|
|
case actChangePreview:
|
|
if t.previewOpts.command != a.a {
|
|
t.previewOpts.command = a.a
|
|
updatePreviewWindow(false)
|
|
refreshPreview(t.previewOpts.command)
|
|
}
|
|
case actChangePreviewWindow:
|
|
// NOTE: We intentionally use "previewOpts" instead of "activePreviewOpts" here
|
|
currentPreviewOpts := t.previewOpts
|
|
wasNoSeparatorLine := t.noSeparatorLine()
|
|
wasSeparatorLength := t.separatorLength()
|
|
|
|
// Reset preview options and apply the additional options
|
|
t.previewOpts = t.initialPreviewOpts
|
|
t.previewOpts.command = currentPreviewOpts.command
|
|
|
|
// Carry over toggle-driven state so toggle-preview-wrap survives
|
|
// a change-preview-window. Tokens below can still override.
|
|
t.previewOpts.wrap = currentPreviewOpts.wrap
|
|
t.previewOpts.wrapWord = currentPreviewOpts.wrapWord
|
|
|
|
// Split window options
|
|
tokens := strings.Split(a.a, "|")
|
|
if len(tokens[0]) > 0 && t.initialPreviewOpts.hidden {
|
|
t.previewOpts.hidden = false
|
|
}
|
|
parsePreviewWindow(&t.previewOpts, tokens[0])
|
|
if len(tokens) > 1 {
|
|
a.a = strings.Join(append(tokens[1:], tokens[0]), "|")
|
|
}
|
|
|
|
// Do not drop a preview window forced by the preview(...) action
|
|
keepForcedPreview := t.hasPreviewWindow() && !t.activePreviewOpts.hidden
|
|
|
|
// The default separator is decided from the whole threshold
|
|
// chain (separatedByBorder), which compare does not fully
|
|
// inspect, so it can change even when compare finds no
|
|
// layout difference
|
|
checkSeparator := func() {
|
|
if t.noSeparatorLine() != wasNoSeparatorLine {
|
|
// Number of lines changed; relayout
|
|
updatePreviewWindow(keepForcedPreview)
|
|
req(reqPreviewRefresh)
|
|
} else if t.separatorLength() != wasSeparatorLength {
|
|
// Only the content of the info line changed
|
|
req(reqInfo)
|
|
}
|
|
}
|
|
|
|
// Full redraw
|
|
switch currentPreviewOpts.compare(t.activePreviewOpts, &t.previewOpts) {
|
|
case previewOptsSame:
|
|
checkSeparator()
|
|
case previewOptsDifferentLayout:
|
|
// Preview command can be running in the background if the size of
|
|
// the preview window is 0 but not 'hidden'
|
|
wasHidden := currentPreviewOpts.hidden
|
|
|
|
// FIXME: One-time preview window can't reappear once hidden
|
|
// fzf --bind space:preview:ls --bind 'enter:change-preview-window:down|left|up|hidden|'
|
|
updatePreviewWindow(keepForcedPreview)
|
|
if wasHidden && t.hasPreviewWindow() {
|
|
// Restart
|
|
refreshPreview(t.previewOpts.command)
|
|
} else if t.activePreviewOpts.hidden {
|
|
// Cancel
|
|
t.cancelPreview()
|
|
} else {
|
|
// Refresh
|
|
req(reqPreviewRefresh)
|
|
}
|
|
case previewOptsDifferentContentLayout:
|
|
t.previewed.version = 0
|
|
req(reqPreviewRefresh)
|
|
checkSeparator()
|
|
}
|
|
|
|
// Adjust scroll offset
|
|
if t.hasPreviewWindow() && currentPreviewOpts.scroll != t.activePreviewOpts.scroll {
|
|
scrollPreviewTo(t.evaluateScrollOffset())
|
|
}
|
|
|
|
// Resume following
|
|
t.previewer.following.Force(t.previewOpts.follow)
|
|
case actNextSelected, actPrevSelected:
|
|
if len(t.selected) > 0 {
|
|
total := t.merger.Length()
|
|
for i := 1; i < total; i++ {
|
|
y := (t.cy + i) % total
|
|
if t.layout == layoutDefault && a.t == actNextSelected ||
|
|
t.layout != layoutDefault && a.t == actPrevSelected {
|
|
y = (t.cy - i + total) % total
|
|
}
|
|
if _, found := t.selected[t.merger.Get(y).item.Index()]; found {
|
|
t.vset(y)
|
|
req(reqList)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !processExecution(a.t) {
|
|
t.lastAction = a.t
|
|
}
|
|
|
|
if t.inputless {
|
|
// Always just discard the change
|
|
t.input = currentInput
|
|
t.cx = len(t.input)
|
|
beof = false
|
|
} else if string(t.input) != string(currentInput) {
|
|
t.inputOverride = nil
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Execute pending actions if wait just unblocked. Capture the jump
|
|
// state first so that a pending 'jump' action isn't cancelled right
|
|
// away by the wake-up event below.
|
|
jumpingBefore := t.jumping
|
|
if len(t.wait.pending) > 0 && !t.wait.blocked {
|
|
pending := t.wait.pending
|
|
t.wait.pending = nil
|
|
if !doActions(pending) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
if jumpingBefore == jumpDisabled || len(actions) > 0 {
|
|
// Break out of jump mode if any action is submitted to the server
|
|
if jumpingBefore != jumpDisabled {
|
|
t.jumping = jumpDisabled
|
|
if acts, prs := t.keymap[tui.JumpCancel.AsEvent()]; prs && !doActions(acts) {
|
|
continue
|
|
}
|
|
req(reqList)
|
|
}
|
|
if len(actions) == 0 {
|
|
actions = t.keymap[event.Comparable()]
|
|
}
|
|
if len(actions) == 0 && event.Type == tui.Rune {
|
|
doAction(&action{t: actChar})
|
|
} else if !doActions(actions) {
|
|
continue
|
|
}
|
|
if !t.inputless {
|
|
t.truncateQuery()
|
|
}
|
|
queryChanged = queryChanged || t.pasting == nil && string(previousInput) != string(t.input)
|
|
changed = changed || queryChanged
|
|
if onChanges, prs := t.keymap[tui.Change.AsEvent()]; queryChanged && prs && !doActions(onChanges) {
|
|
continue
|
|
}
|
|
if onEOFs, prs := t.keymap[tui.BackwardEOF.AsEvent()]; beof && prs && !doActions(onEOFs) {
|
|
continue
|
|
}
|
|
if onMultis, prs := t.keymap[tui.Multi.AsEvent()]; t.version != previousVersion && prs && !doActions(onMultis) {
|
|
continue
|
|
}
|
|
} else {
|
|
jumpEvent := tui.JumpCancel
|
|
if event.Type == tui.Rune {
|
|
if idx := strings.IndexRune(t.jumpLabels, event.Char); idx >= 0 && idx < t.maxItems() && idx < t.merger.Length() {
|
|
jumpEvent = tui.Jump
|
|
t.cy = idx + t.offset
|
|
if t.jumping == jumpAcceptEnabled {
|
|
req(reqClose)
|
|
}
|
|
}
|
|
}
|
|
t.jumping = jumpDisabled
|
|
if acts, prs := t.keymap[jumpEvent.AsEvent()]; prs && !doActions(acts) {
|
|
continue
|
|
}
|
|
req(reqList)
|
|
}
|
|
|
|
if queryChanged && t.canPreview() && len(t.previewOpts.command) > 0 {
|
|
_, _, _, forceUpdate := hasPreviewFlags(t.previewOpts.command)
|
|
if forceUpdate {
|
|
t.version++
|
|
}
|
|
}
|
|
|
|
if queryChanged || t.cx != previousCx {
|
|
req(reqPrompt)
|
|
}
|
|
|
|
reload := changed || newCommand != nil
|
|
if reload {
|
|
t.wait.searching = true
|
|
}
|
|
var reloadRequest *searchRequest
|
|
if reload {
|
|
reloadRequest = &searchRequest{sort: t.sort, sync: reloadSync, nth: newNth, withNth: newWithNth, headerLines: newHeaderLines, command: newCommand, environ: t.environ(), changed: changed, denylist: denylist, revision: t.resultMerger.Revision()}
|
|
}
|
|
|
|
// Dispatch queued background requests
|
|
t.dispatchAsync()
|
|
|
|
if t.pendingReqList {
|
|
t.pendingReqList = false
|
|
req(reqList)
|
|
}
|
|
|
|
t.mutex.Unlock() // Must be unlocked before touching reqBox
|
|
|
|
if reload {
|
|
t.eventBox.Set(EvtSearchNew, *reloadRequest)
|
|
}
|
|
for _, event := range events {
|
|
t.reqBox.Set(event, nil)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (t *Terminal) constrain() {
|
|
// count of items to display allowed by filtering
|
|
count := t.merger.Length()
|
|
maxLines := t.maxItems()
|
|
|
|
// May need to try again after adjusting the offset
|
|
t.offset = util.Constrain(t.offset, 0, count)
|
|
for range maxLines {
|
|
numItems := maxLines
|
|
// How many items can be fit on screen including the current item?
|
|
if t.canSpanMultiLines() && t.merger.Length() > 0 {
|
|
numItemsFound := 0
|
|
linesSum := 0
|
|
|
|
add := func(i int) bool {
|
|
lines, overflow := t.numItemLines(t.merger.Get(i).item, numItems-linesSum)
|
|
linesSum += lines
|
|
if linesSum >= numItems {
|
|
/*
|
|
# Should show all 3 items
|
|
printf "file1\0file2\0file3\0" | fzf --height=5 --read0 --bind load:last --reverse
|
|
|
|
# Should not truncate the last item
|
|
printf "file\n1\0file\n2\0file\n3\0" | fzf --height=5 --read0 --bind load:last --reverse
|
|
*/
|
|
if numItemsFound == 0 || !overflow {
|
|
numItemsFound++
|
|
}
|
|
return false
|
|
}
|
|
numItemsFound++
|
|
return true
|
|
}
|
|
|
|
for i := t.offset; i < t.merger.Length(); i++ {
|
|
if !add(i) {
|
|
break
|
|
}
|
|
}
|
|
|
|
// We can possibly fit more items "before" the offset on screen
|
|
if linesSum < numItems {
|
|
for i := t.offset - 1; i >= 0; i-- {
|
|
if !add(i) {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
numItems = numItemsFound
|
|
}
|
|
|
|
t.cy = util.Constrain(t.cy, 0, max(0, count-1))
|
|
minOffset := max(t.cy-numItems+1, 0)
|
|
maxOffset := max(min(count-numItems, t.cy), 0)
|
|
prevOffset := t.offset
|
|
t.offset = util.Constrain(t.offset, minOffset, maxOffset)
|
|
if t.scrollOff > 0 {
|
|
scrollOff := min(maxLines/2, t.scrollOff)
|
|
newOffset := t.offset
|
|
// 2-phase adjustment to avoid infinite loop of alternating between moving up and down
|
|
for phase := range 2 {
|
|
for {
|
|
prevOffset := newOffset
|
|
numItems := t.merger.Length()
|
|
itemLines := 1 + t.gap
|
|
if t.canSpanMultiLines() && t.cy < numItems {
|
|
itemLines, _ = t.numItemLines(t.merger.Get(t.cy).item, maxLines)
|
|
}
|
|
linesBefore := t.cy - newOffset
|
|
if t.canSpanMultiLines() {
|
|
linesBefore = 0
|
|
for i := newOffset; i < t.cy && i < numItems; i++ {
|
|
lines, _ := t.numItemLines(t.merger.Get(i).item, maxLines-linesBefore-itemLines)
|
|
linesBefore += lines
|
|
}
|
|
}
|
|
linesAfter := maxLines - (linesBefore + itemLines)
|
|
|
|
// Stuck in the middle, nothing to do
|
|
if linesBefore < scrollOff && linesAfter < scrollOff {
|
|
break
|
|
}
|
|
|
|
if phase == 0 && linesBefore < scrollOff {
|
|
newOffset = max(minOffset, newOffset-1)
|
|
} else if phase == 1 && linesAfter < scrollOff {
|
|
newOffset = min(maxOffset, newOffset+1)
|
|
}
|
|
if newOffset == prevOffset {
|
|
break
|
|
}
|
|
}
|
|
t.offset = newOffset
|
|
}
|
|
}
|
|
if t.offset == prevOffset {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Returns true if the cursor position is successfully updated
|
|
func (t *Terminal) vmove(o int, allowCycle bool) bool {
|
|
if t.layout != layoutDefault {
|
|
o *= -1
|
|
}
|
|
dest := t.cy + o
|
|
if t.cycle && allowCycle {
|
|
max := t.merger.Length() - 1
|
|
if dest > max {
|
|
if t.cy == max {
|
|
dest = 0
|
|
}
|
|
} else if dest < 0 {
|
|
if t.cy == 0 {
|
|
dest = max
|
|
}
|
|
}
|
|
}
|
|
return t.vset(dest)
|
|
}
|
|
|
|
func (t *Terminal) vset(o int) bool {
|
|
t.cy = util.Constrain(o, 0, t.merger.Length()-1)
|
|
return t.cy == o
|
|
}
|
|
|
|
// Number of prompt lines in the list window
|
|
func (t *Terminal) promptLines() int {
|
|
if t.inputless {
|
|
return 0
|
|
}
|
|
if t.inputWindow != nil {
|
|
return 0
|
|
}
|
|
if t.noSeparatorLine() {
|
|
return 1
|
|
}
|
|
return 2
|
|
}
|
|
|
|
// Number of item lines in the list window
|
|
func (t *Terminal) maxItems() int {
|
|
maximum := t.window.Height() - t.visibleHeaderLinesInList() - t.promptLines()
|
|
return max(maximum, 0)
|
|
}
|
|
|
|
func (t *Terminal) dumpItem(i *Item) StatusItem {
|
|
if i == nil {
|
|
return StatusItem{}
|
|
}
|
|
item := StatusItem{
|
|
Index: int(i.Index()),
|
|
Text: i.AsString(t.ansi),
|
|
}
|
|
if t.resultMerger.pattern != nil {
|
|
_, _, pos := t.resultMerger.pattern.MatchItem(i, true, t.slab)
|
|
if pos != nil {
|
|
sort.Ints(*pos)
|
|
item.Positions = *pos
|
|
}
|
|
}
|
|
return item
|
|
}
|
|
|
|
func (t *Terminal) tryLock(timeout time.Duration) bool {
|
|
sleepDuration := 10 * time.Millisecond
|
|
|
|
for {
|
|
if t.mutex.TryLock() {
|
|
return true
|
|
}
|
|
|
|
timeout -= sleepDuration
|
|
if timeout <= 0 {
|
|
break
|
|
}
|
|
time.Sleep(sleepDuration)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (t *Terminal) dumpStatus(params getParams) string {
|
|
if !t.tryLock(channelTimeout) {
|
|
return ""
|
|
}
|
|
defer t.mutex.Unlock()
|
|
|
|
selectedItems := t.sortSelected()
|
|
selected := make([]StatusItem, max(0, min(params.limit, len(selectedItems)-params.offset)))
|
|
for i := range selected {
|
|
selected[i] = t.dumpItem(selectedItems[i+params.offset].item)
|
|
}
|
|
|
|
matches := make([]StatusItem, max(0, min(params.limit, t.resultMerger.Length()-params.offset)))
|
|
for i := range matches {
|
|
matches[i] = t.dumpItem(t.resultMerger.Get(i + params.offset).item)
|
|
}
|
|
|
|
var current *StatusItem
|
|
currentItem := t.currentItem()
|
|
if currentItem != nil {
|
|
item := t.dumpItem(currentItem)
|
|
current = &item
|
|
}
|
|
|
|
dump := Status{
|
|
Reading: t.reading,
|
|
Progress: t.progress,
|
|
Query: string(t.input),
|
|
Position: t.cy,
|
|
Sort: t.sort,
|
|
TotalCount: t.count,
|
|
MatchCount: t.resultMerger.Length(),
|
|
Current: current,
|
|
Matches: matches,
|
|
Selected: selected,
|
|
}
|
|
bytes, _ := json.Marshal(&dump) // TODO: Errors?
|
|
return string(bytes)
|
|
}
|