diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f411708..5bd791fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG ### New features +- Added `--margin` option - Added options for sticky header - `--header-file` - `--header-lines` diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 0c6b375e..1448eb95 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -131,6 +131,31 @@ Use black background .B "--reverse" Reverse orientation .TP +.BI "--margin=" MARGIN +Comma-separated expression for margins around the finder. +.br +.R "" +.br +.RS +.BR TRBL " Same margin for top, right, bottom, and left" +.br +.BR TB,RL " Vertical, horizontal margin" +.br +.BR T,RL,B " Top, horizontal, bottom margin" +.br +.BR T,R,B,L " Top, right, bottom, left margin" +.br +.R "" +.br +Each part can be given in absolute number or in percentage relative to the +terminal size with \fB%\fR suffix. +.br +.R "" +.br +e.g. \fBfzf --margin 10%\fR + \fBfzf --margin 1,5%\fR +.RE +.TP .B "--cycle" Enable cyclic scroll .TP diff --git a/shell/completion.bash b/shell/completion.bash index abe3363c..63de5463 100644 --- a/shell/completion.bash +++ b/shell/completion.bash @@ -50,7 +50,8 @@ _fzf_opts_completion() { --history --history-size --header-file - --header-lines" + --header-lines + --margin" case "${prev}" in --tiebreak) diff --git a/src/options.go b/src/options.go index 983a7d3d..b2360a10 100644 --- a/src/options.go +++ b/src/options.go @@ -38,6 +38,7 @@ const usage = `usage: fzf [options] --color=COLSPEC Base scheme (dark|light|16|bw) and/or custom colors --black Use black background --reverse Reverse orientation + --margin=MARGIN Screen margin (TRBL / TB,RL / T,RL,B / T,R,B,L) --cycle Enable cyclic scroll --no-hscroll Disable horizontal scroll --inline-info Display finder info inline with the query @@ -93,6 +94,10 @@ const ( byIndex ) +func defaultMargin() [4]string { + return [4]string{"0", "0", "0", "0"} +} + // Options stores the values of command-line options type Options struct { Mode Mode @@ -127,6 +132,7 @@ type Options struct { History *History Header []string HeaderLines int + Margin [4]string Version bool } @@ -171,6 +177,7 @@ func defaultOptions() *Options { History: nil, Header: make([]string, 0), HeaderLines: 0, + Margin: defaultMargin(), Version: false} } @@ -218,6 +225,14 @@ func atoi(str string) int { return num } +func atof(str string) float64 { + num, err := strconv.ParseFloat(str, 64) + if err != nil { + errorExit("not a valid number: " + str) + } + return num +} + func nextInt(args []string, i *int, message string) int { if len(args) > *i+1 { *i++ @@ -592,6 +607,48 @@ func readHeaderFile(filename string) []string { return strings.Split(strings.TrimSuffix(string(content), "\n"), "\n") } +func parseMargin(margin string) [4]string { + margins := strings.Split(margin, ",") + checked := func(str string) string { + if strings.HasSuffix(str, "%") { + val := atof(str[:len(str)-1]) + if val < 0 { + errorExit("margin must be non-negative") + } + if val > 100 { + errorExit("margin too large") + } + } else { + val := atoi(str) + if val < 0 { + errorExit("margin must be non-negative") + } + } + return str + } + switch len(margins) { + case 1: + m := checked(margins[0]) + return [4]string{m, m, m, m} + case 2: + tb := checked(margins[0]) + rl := checked(margins[1]) + return [4]string{tb, rl, tb, rl} + case 3: + t := checked(margins[0]) + rl := checked(margins[1]) + b := checked(margins[2]) + return [4]string{t, rl, b, rl} + case 4: + return [4]string{ + checked(margins[0]), checked(margins[1]), + checked(margins[2]), checked(margins[3])} + default: + errorExit("invalid margin: " + margin) + } + return defaultMargin() +} + func parseOptions(opts *Options, allArgs []string) { keymap := make(map[int]actionType) var historyMax int @@ -743,6 +800,11 @@ func parseOptions(opts *Options, allArgs []string) { opts.Header = []string{} opts.HeaderLines = atoi( nextString(allArgs, &i, "number of header lines required")) + case "--no-margin": + opts.Margin = defaultMargin() + case "--margin": + opts.Margin = parseMargin( + nextString(allArgs, &i, "margin required (TRBL / TB,RL / T,RL,B / T,R,B,L)")) case "--version": opts.Version = true default: @@ -782,6 +844,8 @@ func parseOptions(opts *Options, allArgs []string) { } else if match, value := optString(arg, "--header-lines="); match { opts.Header = []string{} opts.HeaderLines = atoi(value) + } else if match, value := optString(arg, "--margin="); match { + opts.Margin = parseMargin(value) } else { errorExit("unknown option: " + arg) } diff --git a/src/terminal.go b/src/terminal.go index 18b37d5a..070d0a90 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -8,6 +8,7 @@ import ( "os/signal" "regexp" "sort" + "strconv" "strings" "sync" "syscall" @@ -41,6 +42,8 @@ type Terminal struct { history *History cycle bool header []string + margin [4]string + marginInt [4]int count int progress int reading bool @@ -200,6 +203,8 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal { pressed: "", printQuery: opts.PrintQuery, history: opts.History, + margin: opts.Margin, + marginInt: [4]int{0, 0, 0, 0}, cycle: opts.Cycle, header: opts.Header, reading: true, @@ -317,10 +322,50 @@ func displayWidth(runes []rune) int { return l } +const minWidth = 16 +const minHeight = 4 + +func (t *Terminal) calculateMargins() { + screenWidth := C.MaxX() + screenHeight := C.MaxY() + for idx, str := range t.margin { + if str == "0" { + t.marginInt[idx] = 0 + } else if strings.HasSuffix(str, "%") { + num, _ := strconv.ParseFloat(str[:len(str)-1], 64) + var val float64 + if idx%2 == 0 { + val = float64(screenHeight) + } else { + val = float64(screenWidth) + } + t.marginInt[idx] = int(val * num * 0.01) + } else { + num, _ := strconv.Atoi(str) + t.marginInt[idx] = num + } + } + adjust := func(idx1 int, idx2 int, max int, min int) { + if max >= min { + margin := t.marginInt[idx1] + t.marginInt[idx2] + if max-margin < min { + desired := max - min + t.marginInt[idx1] = desired * t.marginInt[idx1] / margin + t.marginInt[idx2] = desired * t.marginInt[idx2] / margin + } + } + } + adjust(1, 3, screenWidth, minWidth) + adjust(0, 2, screenHeight, minHeight) +} + func (t *Terminal) move(y int, x int, clear bool) { + x += t.marginInt[3] maxy := C.MaxY() if !t.reverse { - y = maxy - y - 1 + y = maxy - y - 1 - t.marginInt[2] + } else { + y += t.marginInt[0] } if clear { @@ -375,11 +420,15 @@ func (t *Terminal) printInfo() { C.CPrint(C.ColInfo, false, output) } +func (t *Terminal) maxHeight() int { + return C.MaxY() - t.marginInt[0] - t.marginInt[2] +} + func (t *Terminal) printHeader() { if len(t.header) == 0 { return } - max := C.MaxY() + max := t.maxHeight() var state *ansiState for idx, lineStr := range t.header { if !t.reverse { @@ -490,7 +539,7 @@ func (t *Terminal) printHighlighted(item *Item, bold bool, col1 int, col2 int, c // Overflow text := []rune(*item.text) offsets := item.colorOffsets(col2, bold, current) - maxWidth := C.MaxX() - 3 + maxWidth := C.MaxX() - 3 - t.marginInt[1] - t.marginInt[3] fullWidth := displayWidth(text) if fullWidth > maxWidth { if t.hscroll { @@ -573,6 +622,7 @@ func processTabs(runes []rune, prefixWidth int) (string, int) { } func (t *Terminal) printAll() { + t.calculateMargins() t.printList() t.printPrompt() t.printInfo() @@ -652,6 +702,7 @@ func (t *Terminal) Loop() { { // Late initialization t.mutex.Lock() t.initFunc() + t.calculateMargins() t.printPrompt() t.placeCursor() C.Refresh() @@ -942,40 +993,46 @@ func (t *Terminal) Loop() { } case actMouse: me := event.MouseEvent - mx, my := util.Constrain(me.X-len(t.prompt), 0, len(t.input)), me.Y - if !t.reverse { - my = C.MaxY() - my - 1 - } - min := 2 + len(t.header) - if t.inlineInfo { - min -= 1 - } - if me.S != 0 { - // Scroll - if t.merger.Length() > 0 { - if t.multi && me.Mod { - toggle() - } - t.vmove(me.S) - req(reqList) + mx, my := me.X, me.Y + if mx >= t.marginInt[3] && mx < C.MaxX()-t.marginInt[1] && + my >= t.marginInt[0] && my < C.MaxY()-t.marginInt[2] { + mx -= t.marginInt[3] + my -= t.marginInt[0] + mx = util.Constrain(mx-len(t.prompt), 0, len(t.input)) + if !t.reverse { + my = t.maxHeight() - my - 1 } - } else if me.Double { - // Double-click - if my >= min { - if t.vset(t.offset+my-min) && t.cy < t.merger.Length() { - req(reqClose) - } + min := 2 + len(t.header) + if t.inlineInfo { + min -= 1 } - } else if me.Down { - if my == 0 && mx >= 0 { - // Prompt - t.cx = mx - } else if my >= min { - // List - if t.vset(t.offset+my-min) && t.multi && me.Mod { - toggle() + if me.S != 0 { + // Scroll + if t.merger.Length() > 0 { + if t.multi && me.Mod { + toggle() + } + t.vmove(me.S) + req(reqList) + } + } else if me.Double { + // Double-click + if my >= min { + if t.vset(t.offset+my-min) && t.cy < t.merger.Length() { + req(reqClose) + } + } + } else if me.Down { + if my == 0 && mx >= 0 { + // Prompt + t.cx = mx + } else if my >= min { + // List + if t.vset(t.offset+my-min) && t.multi && me.Mod { + toggle() + } + req(reqList) } - req(reqList) } } } @@ -1040,7 +1097,7 @@ func (t *Terminal) vset(o int) bool { } func (t *Terminal) maxItems() int { - max := C.MaxY() - 2 - len(t.header) + max := t.maxHeight() - 2 - len(t.header) if t.inlineInfo { max += 1 } diff --git a/test/test_go.rb b/test/test_go.rb index ad2150e0..f702efcb 100644 --- a/test/test_go.rb +++ b/test/test_go.rb @@ -731,6 +731,18 @@ class TestGoFZF < TestBase tmux.prepare end + def test_margin + tmux.send_keys "yes | head -1000 | #{fzf "--margin 5,3"}", :Enter + tmux.until { |lines| lines[4] == '' && lines[5] == ' y' } + tmux.send_keys :Enter + end + + def test_margin_reverse + tmux.send_keys "seq 1000 | #{fzf "--margin 7,5 --reverse"}", :Enter + tmux.until { |lines| lines[1 + 7] == ' 1000/1000' } + tmux.send_keys :Enter + end + private def writelines path, lines File.unlink path while File.exists? path