From 3b68dcdd81394f1ac9f743e1f74ff754f95eef9e Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Tue, 10 Jun 2025 00:26:57 +0900 Subject: Add footer Options: --footer=STR String to print as footer --footer-border[=STYLE] Draw border around the footer section [rounded|sharp|bold|block|thinblock|double|horizontal|vertical| top|bottom|left|right|line|none] (default: line) --footer-label=LABEL Label to print on the footer border --footer-label-pos=COL Position of the footer label [POSITIVE_INTEGER: columns from left| NEGATIVE_INTEGER: columns from right][:bottom] (default: 0 or center) The default border type for footer is 'line', which draws a single separator between the footer and the list. It changes its position depending on `--layout`, so you don't have to manually switch between 'top' and 'bottom' The 'line' style is now supported by other border types as well. `--list-border` is the only exception. --- src/terminal.go | 232 ++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 210 insertions(+), 22 deletions(-) (limited to 'src/terminal.go') diff --git a/src/terminal.go b/src/terminal.go index a89551da..898d34c7 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -180,14 +180,18 @@ type itemLine struct { other 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.window != t.inputWindow && t.window != t.headerWindow { + if t.inListWindow() { t.prevLines[line] = itemLine{valid: true, firstLine: line, empty: true} } } func (t *Terminal) markOtherLine(line int) { - if t.window != t.inputWindow && t.window != t.headerWindow { + if t.inListWindow() { t.prevLines[line] = itemLine{valid: true, firstLine: line, other: true} } } @@ -254,6 +258,9 @@ type Terminal struct { headerLabel labelPrinter headerLabelLen int headerLabelOpts labelOpts + footerLabel labelPrinter + footerLabelLen int + footerLabelOpts labelOpts pointer string pointerLen int pointerEmpty string @@ -301,6 +308,7 @@ type Terminal struct { headerLines int header []string header0 []string + footer []string ellipsis string scrollbar string previewScrollbar string @@ -322,6 +330,7 @@ type Terminal struct { inputBorderShape tui.BorderShape headerBorderShape tui.BorderShape headerLinesShape tui.BorderShape + footerBorderShape tui.BorderShape listLabel labelPrinter listLabelLen int listLabelOpts labelOpts @@ -337,6 +346,8 @@ type Terminal struct { headerBorder tui.Window headerLinesWindow tui.Window headerLinesBorder tui.Window + footerWindow tui.Window + footerBorder tui.Window wborder tui.Window pborder tui.Window pwindow tui.Window @@ -426,6 +437,7 @@ const ( reqPrompt util.EventType = iota reqInfo reqHeader + reqFooter reqList reqJump reqActivate @@ -434,6 +446,7 @@ const ( reqResize reqRedrawInputLabel reqRedrawHeaderLabel + reqRedrawFooterLabel reqRedrawListLabel reqRedrawBorderLabel reqRedrawPreviewLabel @@ -479,7 +492,9 @@ const ( actChangeBorderLabel actChangeGhost actChangeHeader + actChangeFooter actChangeHeaderLabel + actChangeFooterLabel actChangeInputLabel actChangeListLabel actChangeMulti @@ -550,7 +565,9 @@ const ( actTransformBorderLabel actTransformGhost actTransformHeader + actTransformFooter actTransformHeaderLabel + actTransformFooterLabel actTransformInputLabel actTransformListLabel actTransformNth @@ -907,6 +924,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor inputBorderShape: opts.InputBorderShape, headerBorderShape: opts.HeaderBorderShape, headerLinesShape: opts.HeaderLinesShape, + footerBorderShape: opts.FooterBorderShape, borderWidth: 1, listLabel: nil, listLabelOpts: opts.ListLabel, @@ -918,6 +936,8 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor inputLabelOpts: opts.InputLabel, headerLabel: nil, headerLabelOpts: opts.HeaderLabel, + footerLabel: nil, + footerLabelOpts: opts.FooterLabel, cleanExit: opts.ClearOnExit, executor: executor, paused: opts.Phony, @@ -929,6 +949,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor headerLines: opts.HeaderLines, gap: opts.Gap, header: []string{}, + footer: opts.Footer, header0: opts.Header, ansi: opts.Ansi, nthAttr: opts.Theme.Nth.Attr, @@ -992,6 +1013,52 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor 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 + if t.inputBorderShape == tui.BorderLine { + if t.layout == layoutReverse { + t.inputBorderShape = tui.BorderBottom + } else { + t.inputBorderShape = tui.BorderTop + } + } + + // 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 + } + } // Disable separator by default if input border is set if opts.Separator == nil && !t.inputBorderShape.Visible() || opts.Separator != nil && len(*opts.Separator) > 0 { @@ -1208,6 +1275,10 @@ func (t *Terminal) extraLines() int { } extra += t.headerLines } + if len(t.footer) > 0 { + extra += borderLines(t.footerBorderShape) + extra += len(t.footer) + } return extra } @@ -1475,6 +1546,16 @@ func (t *Terminal) changeHeader(header string) bool { 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 + return needFullRedraw +} + // UpdateHeader updates the header func (t *Terminal) UpdateHeader(header []string) { t.mutex.Lock() @@ -1835,6 +1916,12 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { 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 } @@ -1889,17 +1976,19 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { // 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() hasInputWindow := !t.inputless && (t.inputBorderShape.Visible() || hasHeaderWindow || hasHeaderLinesWindow) + inputWindowHeight := 2 + if t.noSeparatorLine() { + inputWindowHeight-- + } if hasInputWindow { - inputWindowHeight := 2 - if t.noSeparatorLine() { - inputWindowHeight-- - } - inputBorderHeight = util.Min(availableLines, borderLines(t.inputBorderShape)+inputWindowHeight) + inputBorderHeight = util.Constrain(borderLines(t.inputBorderShape)+inputWindowHeight, 0, availableLines) if t.layout == layoutReverse { shift = inputBorderHeight shrink = inputBorderHeight @@ -1907,6 +1996,17 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { shrink = inputBorderHeight } availableLines -= inputBorderHeight + } else if !t.inputless { + availableLines -= inputWindowHeight + } + + // FIXME: Needed? + if t.needPreviewWindow() { + _, minPreviewHeight := t.minPreviewSize(t.activePreviewOpts) + switch t.activePreviewOpts.position { + case posUp, posDown: + availableLines -= minPreviewHeight + } } // Adjust position and size of the list window if header border is set @@ -1916,7 +2016,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { if hasHeaderLinesWindow { headerWindowHeight -= t.headerLines } - headerBorderHeight = util.Min(availableLines, borderLines(t.headerBorderShape)+headerWindowHeight) + headerBorderHeight = util.Constrain(borderLines(t.headerBorderShape)+headerWindowHeight, 0, availableLines) if t.layout == layoutReverse { shift += headerBorderHeight shrink += headerBorderHeight @@ -1928,7 +2028,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { headerLinesHeight := 0 if hasHeaderLinesWindow { - headerLinesHeight = util.Min(availableLines, borderLines(headerLinesShape)+t.headerLines) + headerLinesHeight = util.Constrain(borderLines(headerLinesShape)+t.headerLines, 0, availableLines) if t.layout != layoutDefault { shift += headerLinesHeight shrink += headerLinesHeight @@ -1938,6 +2038,17 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { availableLines -= headerLinesHeight } + footerBorderHeight := 0 + if hasFooterWindow { + // 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 + } + // Set up list border hasListBorder := t.listBorderShape.Visible() innerWidth := width @@ -2041,13 +2152,8 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { width++ } - maxPreviewLines := availableLines - if t.wborder != nil { - maxPreviewLines -= t.wborder.Height() - } else { - maxPreviewLines -= util.Max(0, innerHeight-pheight-shrink) - } - pheight = util.Min(pheight, maxPreviewLines) + pheight = util.Constrain(pheight, minPreviewHeight, availableLines) + if previewOpts.position == posUp { innerBorderFn(marginInt[0]+pheight, marginInt[3], width, height-pheight) t.window = t.tui.NewWindow( @@ -2210,7 +2316,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { case layoutDefault: btop = w.Top() + w.Height() + headerBorderHeight + headerLinesHeight case layoutReverse: - btop = w.Top() - shrink + btop = w.Top() - shrink + footerBorderHeight case layoutReverseList: btop = w.Top() + w.Height() + headerBorderHeight } @@ -2238,7 +2344,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { var btop int if hasInputWindow && t.headerFirst { if t.layout == layoutReverse { - btop = w.Top() - shrink + btop = w.Top() - shrink + footerBorderHeight } else if t.layout == layoutReverseList { btop = w.Top() + w.Height() + inputBorderHeight } else { @@ -2294,12 +2400,31 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { t.headerLinesWindow = createInnerWindow(t.headerLinesBorder, headerLinesShape, tui.WindowHeader, 0) } + // Set up footer + if hasFooterWindow { + 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) + } + // Print border label t.printLabel(t.wborder, t.listLabel, t.listLabelOpts, t.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(), 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) } func (t *Terminal) printLabel(window tui.Window, render labelPrinter, opts labelOpts, length int, borderShape tui.BorderShape, redrawBorder bool) { @@ -2343,7 +2468,7 @@ func (t *Terminal) move(y int, x int, clear bool) { case layoutDefault: y = h - y - 1 case layoutReverseList: - if t.window == t.inputWindow || t.window == t.headerWindow { + if !t.inListWindow() && t.window != t.headerLinesWindow { // From bottom to top y = h - y - 1 } else { @@ -2690,8 +2815,10 @@ func (t *Terminal) resizeIfNeeded() bool { if t.hasHeaderLinesWindow() { primaryHeaderLines -= t.headerLines } + // FIXME: Full redraw is triggered if there are too many lines in the header + // so that the header window cannot display all of them. needHeaderLinesWindow := t.hasHeaderLinesWindow() - if (t.headerBorderShape.Visible() || t.hasHeaderLinesWindow()) && + if (t.headerBorderShape.Visible() || needHeaderLinesWindow) && (t.headerWindow == nil && primaryHeaderLines > 0 || t.headerWindow != nil && primaryHeaderLines != t.headerWindow.Height()) || needHeaderLinesWindow && (t.headerLinesWindow == nil || t.headerLinesWindow != nil && t.headerLines != t.headerLinesWindow.Height()) || !needHeaderLinesWindow && t.headerLinesWindow != nil { @@ -2720,6 +2847,41 @@ func (t *Terminal) printHeader() { } } +func (t *Terminal) printFooter() { + if len(t.footer) == 0 { + return + } + indentSize := t.headerIndent(t.footerBorderShape) + indent := strings.Repeat(" ", indentSize) + max := util.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, line, line, true, + func(markerClass) int { + t.footerWindow.Print(indent) + return indentSize + }, nil) + } + }) + t.wrap = wrap +} + func (t *Terminal) headerIndent(borderShape tui.BorderShape) int { indentSize := t.pointerLen + t.markerLen if t.listBorderShape.HasLeft() { @@ -2792,7 +2954,7 @@ func (t *Terminal) printHeaderImpl(window tui.Window, borderShape tui.BorderShap } func (t *Terminal) canSpanMultiLines() bool { - return t.multiLine || t.wrap || t.gap > 0 + return (t.multiLine || t.wrap || t.gap > 0) && t.inListWindow() } func (t *Terminal) renderBar(line int, barRange [2]int) { @@ -3767,6 +3929,7 @@ func (t *Terminal) printAll() { t.printPrompt() t.printInfo() t.printHeader() + t.printFooter() t.printPreview() } @@ -4515,6 +4678,7 @@ func (t *Terminal) Loop() error { 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) @@ -4797,6 +4961,10 @@ func (t *Terminal) Loop() error { if !t.resizeIfNeeded() { t.printHeader() } + case reqFooter: + if !t.resizeIfNeeded() { + t.printFooter() + } case reqActivate: t.suppress = false if t.hasPreviewer() { @@ -4806,6 +4974,8 @@ func (t *Terminal) Loop() error { t.printLabel(t.inputBorder, t.inputLabel, t.inputLabelOpts, t.inputLabelLen, t.inputBorderShape, true) case reqRedrawHeaderLabel: t.printLabel(t.headerBorder, t.headerLabel, t.headerLabelOpts, t.headerLabelLen, t.headerBorderShape, true) + case reqRedrawFooterLabel: + t.printLabel(t.footerBorder, t.footerLabel, t.footerLabelOpts, t.footerLabelLen, t.footerBorderShape, true) case reqRedrawListLabel: t.printLabel(t.wborder, t.listLabel, t.listLabelOpts, t.listLabelLen, t.listBorderShape, true) case reqRedrawBorderLabel: @@ -4996,7 +5166,7 @@ func (t *Terminal) Loop() error { } updatePreviewWindow := func(forcePreview bool) { t.resizeWindows(forcePreview, false) - req(reqPrompt, reqList, reqInfo, reqHeader) + req(reqPrompt, reqList, reqInfo, reqHeader, reqFooter) } toggle := func() bool { current := t.currentItem() @@ -5271,6 +5441,16 @@ func (t *Terminal) Loop() error { } else { req(reqHeader) } + case actChangeFooter, actTransformFooter: + footer := a.a + if a.t == actTransformFooter { + footer = t.captureLines(a.a) + } + if t.changeFooter(footer) { + req(reqFullRedraw) + } else { + req(reqFooter) + } case actChangeHeaderLabel, actTransformHeaderLabel: label := a.a if a.t == actTransformHeaderLabel { @@ -5279,6 +5459,14 @@ func (t *Terminal) Loop() error { t.headerLabelOpts.label = label t.headerLabel, t.headerLabelLen = t.ansiLabelPrinter(label, &tui.ColHeaderLabel, false) req(reqRedrawHeaderLabel) + case actChangeFooterLabel, actTransformFooterLabel: + label := a.a + if a.t == actTransformFooterLabel { + label = t.captureLine(a.a) + } + t.footerLabelOpts.label = label + t.footerLabel, t.footerLabelLen = t.ansiLabelPrinter(label, &tui.ColFooterLabel, false) + req(reqRedrawFooterLabel) case actChangeInputLabel, actTransformInputLabel: label := a.a if a.t == actTransformInputLabel { -- cgit v1.2.3