//go:generate stringer -type VerticalAnchor -output vertical_anchor_gen.go . package peco import ( "errors" "fmt" "strings" "sync" "time" "unicode/utf8" "github.com/lestrrat-go/pdebug" "github.com/mattn/go-runewidth" "github.com/peco/peco/hub" "github.com/peco/peco/internal/ansi" linepkg "github.com/peco/peco/line" ) // LayoutType describes the types of layout that peco can take type LayoutType string const ( DefaultLayoutType = LayoutTypeTopDown // LayoutTypeTopDown makes the layout so the items read from top to bottom LayoutTypeTopDown = "top-down" // LayoutTypeTopDown displays prompt at top, list top-to-bottom LayoutTypeBottomUp = "bottom-up" // LayoutTypeBottomUp displays prompt at bottom, list bottom-to-top LayoutTypeTopDownQueryBottom = "top-down-query-bottom" // LayoutTypeTopDownQueryBottom displays list top-to-bottom, prompt at bottom ) // VerticalAnchor describes the direction to which elements in the // layout are anchored to type VerticalAnchor int const ( AnchorTop VerticalAnchor = iota + 1 // AnchorTop anchors elements towards the top of the screen AnchorBottom // AnchorBottom anchors elements towards the bottom of the screen ) // Layout represents the component that controls where elements are placed on screen type Layout interface { PrintStatus(string, time.Duration) DrawPrompt(*Peco) DrawScreen(*Peco, *hub.DrawOptions) MovePage(*Peco, hub.PagingRequest) (moved bool) PurgeDisplayCache() SortTopDown() bool } // AnchorSettings groups items that are required to control // where an anchored item is actually placed type AnchorSettings struct { anchor VerticalAnchor // AnchorTop or AnchorBottom anchorOffset int // offset this many lines from the anchor screen Screen } // UserPrompt draws the prompt line type UserPrompt struct { *AnchorSettings prompt string promptLen int styles *StyleSet } // StatusBar is the interface for printing status messages type StatusBar interface { PrintStatus(string, time.Duration) } // screenStatusBar draws the status message bar on screen type screenStatusBar struct { *AnchorSettings clearTimer *time.Timer styles *StyleSet timerMutex sync.Mutex } // nullStatusBar is a no-op status bar used when SuppressStatusMsg is true type nullStatusBar struct{} // ListArea represents the area where the actual line buffer is // displayed in the screen type ListArea struct { *AnchorSettings sortTopDown bool displayCache []linepkg.Line dirty bool styles *StyleSet } // BasicLayout is... the basic layout :) At this point this is the // only struct for layouts, which means that while the position // of components may be configurable, the actual types of components // that are used are set and static type BasicLayout struct { statusBar StatusBar screen Screen prompt *UserPrompt list *ListArea } // extraOffset is a platform-specific constant that adds extra vertical // space to layout anchors. On Windows, terminals need an additional line // reserved at the bottom. Set per-platform via layout_windows.go and // layout_notwindows.go. // ansiLiner is an optional interface for lines that carry ANSI color attributes. type ansiLiner interface { ANSIAttrs() []ansi.AttrSpan } // LayoutBuilder creates a BasicLayout for the given Peco state. type LayoutBuilder interface { Build(*Peco) (*BasicLayout, error) } // LayoutBuilderFunc is a function that implements LayoutBuilder. type LayoutBuilderFunc func(*Peco) (*BasicLayout, error) // Build calls the underlying function. func (f LayoutBuilderFunc) Build(state *Peco) (*BasicLayout, error) { return f(state) } var layoutRegistry = map[LayoutType]LayoutBuilder{} // RegisterLayout registers a layout builder under the given name. func RegisterLayout(name LayoutType, builder LayoutBuilder) { layoutRegistry[name] = builder } // NewLayout creates a layout by looking up the registry. Falls back to top-down. func NewLayout(layoutType LayoutType, state *Peco) (*BasicLayout, error) { if builder, ok := layoutRegistry[layoutType]; ok { return builder.Build(state) } return layoutRegistry[LayoutTypeTopDown].Build(state) } // IsValidLayoutType checks if a string is a supported layout type func IsValidLayoutType(v LayoutType) bool { _, ok := layoutRegistry[v] return ok } // IsValidVerticalAnchor checks if the specified anchor is supported func IsValidVerticalAnchor(anchor VerticalAnchor) bool { return anchor == AnchorTop || anchor == AnchorBottom } // mergeAttribute merges two Attribute values (typically backgrounds). // If either has a default color (0), the non-default color wins via OR. // Otherwise, color bits are merged using OR on 0-based indices. // Attribute flags (bold, underline, reverse, true color) are always OR'd. func mergeAttribute(a, b Attribute) Attribute { const flagMask = AttrTrueColor | AttrBold | AttrUnderline | AttrReverse aColor := a &^ flagMask bColor := b &^ flagMask flags := (a | b) & flagMask if aColor == 0 || bColor == 0 { return (aColor | bColor) | flags } return (((aColor - 1) | (bColor - 1)) + 1) | flags } // NewAnchorSettings creates a new AnchorSetting struct. func NewAnchorSettings(screen Screen, anchor VerticalAnchor, offset int) (*AnchorSettings, error) { if !IsValidVerticalAnchor(anchor) { return nil, fmt.Errorf("invalid vertical anchor: %d", anchor) } return &AnchorSettings{ anchor: anchor, anchorOffset: offset, screen: screen, }, nil } // AnchorPosition returns the starting y-offset, based on the // anchor type and offset func (as AnchorSettings) AnchorPosition() int { switch as.anchor { case AnchorTop: return as.anchorOffset case AnchorBottom: _, h := as.screen.Size() return h - as.anchorOffset - 1 // -1 is required because y is 0 base, but h is 1 base default: return 0 } } // NewUserPrompt creates a new UserPrompt struct func NewUserPrompt(screen Screen, anchor VerticalAnchor, anchorOffset int, prompt string, styles *StyleSet) (*UserPrompt, error) { if prompt == "" { // default prompt = DefaultPrompt } promptLen := runewidth.StringWidth(prompt) as, err := NewAnchorSettings(screen, anchor, anchorOffset) if err != nil { return nil, fmt.Errorf("failed to create user prompt: %w", err) } return &UserPrompt{ AnchorSettings: as, prompt: prompt, promptLen: promptLen, styles: styles, }, nil } // cursorStyle returns the fg/bg attributes for the query cursor. // If QueryCursor is explicitly configured, it is used directly. // Otherwise, the Query style's fg/bg are swapped. If both are // ColorDefault, AttrReverse is used as a fallback. func (u UserPrompt) cursorStyle() (Attribute, Attribute) { qc := u.styles.QueryCursor if qc.fg != ColorDefault || qc.bg != ColorDefault { return qc.fg, qc.bg } qfg, qbg := u.styles.Query.fg, u.styles.Query.bg if qfg != ColorDefault || qbg != ColorDefault { return qbg, qfg } return ColorDefault | AttrReverse, ColorDefault | AttrReverse } // Draw draws the query prompt func (u UserPrompt) Draw(state *Peco) { if pdebug.Enabled { g := pdebug.Marker("UserPrompt.Draw") defer g.End() } location := u.AnchorPosition() // print "QUERY>" u.screen.Print(PrintArgs{ Y: location, Fg: u.styles.Prompt.fg, Bg: u.styles.Prompt.bg, Msg: u.prompt, }) c := state.Caret() if c.Pos() <= 0 { // XXX Do we really need this? c.SetPos(0) // sanity } q := state.Query() qs := q.String() ql := q.Len() if c.Pos() > ql { // XXX Do we really need this? c.SetPos(ql) } fg := u.styles.Query.fg bg := u.styles.Query.bg // Used to notify the screen where our cursor is var posX int // queryStartX is where the query text begins, one space after the prompt queryStartX := u.promptLen + 1 switch ql { case 0: u.screen.Print(PrintArgs{ X: u.promptLen, Y: location, Fg: fg, Bg: bg, Fill: true, }) posX = queryStartX cfgCursor, cbgCursor := u.cursorStyle() u.screen.Print(PrintArgs{ X: queryStartX, Y: location, Fg: cfgCursor, Bg: cbgCursor, Msg: " ", Fill: false, }) case c.Pos(): // the entire string + the caret after the string u.screen.Print(PrintArgs{ X: u.promptLen, Y: location, Fg: fg, Bg: bg, Fill: true, }) u.screen.Print(PrintArgs{ X: queryStartX, Y: location, Fg: fg, Bg: bg, Msg: qs, Fill: false, }) posX = queryStartX + runewidth.StringWidth(qs) cfgCursor, cbgCursor := u.cursorStyle() u.screen.Print(PrintArgs{ X: posX, Y: location, Fg: cfgCursor, Bg: cbgCursor, Msg: " ", Fill: false, }) default: posX = c.Pos() + queryStartX // the caret is in the middle of the string cfgCursor, cbgCursor := u.cursorStyle() prev := int(0) for i, r := range q.RuneSlice() { fg := u.styles.Query.fg bg := u.styles.Query.bg if i == c.Pos() { fg = cfgCursor bg = cbgCursor } u.screen.SetCell(queryStartX+prev, location, r, fg, bg) prev += runewidth.RuneWidth(r) } fg := u.styles.Query.fg bg := u.styles.Query.bg u.screen.Print(PrintArgs{ X: queryStartX + prev, Y: location, Fg: fg, Bg: bg, Fill: true, }) } u.screen.SetCursor(posX, location) width, _ := u.screen.Size() loc := state.Location() pmsg := fmt.Sprintf("%s [%d (%d/%d)]", state.Filters().Current().String(), loc.Total(), loc.Page(), loc.MaxPage()) u.screen.Print(PrintArgs{ X: width - runewidth.StringWidth(pmsg), Y: location, Fg: u.styles.Basic.fg, Bg: u.styles.Basic.bg, Msg: pmsg, }) u.screen.Flush() } // newScreenStatusBar creates a new screenStatusBar struct func newScreenStatusBar(screen Screen, anchor VerticalAnchor, anchorOffset int, styles *StyleSet) (*screenStatusBar, error) { as, err := NewAnchorSettings(screen, anchor, anchorOffset) if err != nil { return nil, fmt.Errorf("failed to create status bar: %w", err) } return &screenStatusBar{ AnchorSettings: as, clearTimer: nil, styles: styles, }, nil } // stopTimer stops and drains the clear timer, preventing stale events from firing. func (s *screenStatusBar) stopTimer() { s.timerMutex.Lock() defer s.timerMutex.Unlock() if t := s.clearTimer; t != nil { t.Stop() s.clearTimer = nil } } // setClearTimer sets or resets the timer that will clear the status message after a delay. func (s *screenStatusBar) setClearTimer(t *time.Timer) { s.timerMutex.Lock() defer s.timerMutex.Unlock() s.clearTimer = t } // PrintStatus prints a new status message. This also resets the // timer created by ClearStatus() func (s *screenStatusBar) PrintStatus(msg string, clearDelay time.Duration) { if pdebug.Enabled { g := pdebug.Marker("screenStatusBar.PrintStatus") defer g.End() } s.stopTimer() location := s.AnchorPosition() w, _ := s.screen.Size() width := runewidth.StringWidth(msg) for width > w { _, rw := utf8.DecodeRuneInString(msg) width = width - rw msg = msg[rw:] } var pad []byte if w > width { pad = make([]byte, w-width) for i := range w - width { pad[i] = ' ' } } fgAttr := s.styles.Basic.fg bgAttr := s.styles.Basic.bg if w > width { s.screen.Print(PrintArgs{ Y: location, Fg: fgAttr, Bg: bgAttr, Msg: string(pad), }) } if width > 0 { s.screen.Print(PrintArgs{ X: w - width, Y: location, Fg: fgAttr | AttrBold | AttrReverse, Bg: bgAttr | AttrReverse, Msg: msg, }) } s.screen.Flush() // if everything is successful AND the clearDelay timer is specified, // then set a timer to clear the status if clearDelay != 0 { s.setClearTimer(time.AfterFunc(clearDelay, func() { s.PrintStatus("", 0) })) } } // PrintStatus on nullStatusBar is a no-op func (nullStatusBar) PrintStatus(_ string, _ time.Duration) {} // PrintStatus on BasicLayout delegates to the StatusBar func (l *BasicLayout) PrintStatus(msg string, delay time.Duration) { l.statusBar.PrintStatus(msg, delay) } // NewListArea creates a new ListArea struct func NewListArea(screen Screen, anchor VerticalAnchor, anchorOffset int, sortTopDown bool, styles *StyleSet) (*ListArea, error) { as, err := NewAnchorSettings(screen, anchor, anchorOffset) if err != nil { return nil, fmt.Errorf("failed to create list area: %w", err) } return &ListArea{ AnchorSettings: as, displayCache: []linepkg.Line{}, dirty: false, sortTopDown: sortTopDown, styles: styles, }, nil } func (l *ListArea) purgeDisplayCache() { l.displayCache = []linepkg.Line{} } func (l *ListArea) IsDirty() bool { return l.dirty } func (l *ListArea) SetDirty(dirty bool) { l.dirty = dirty } // selectionContains reports whether the line at index n is in the current selection. func selectionContains(state *Peco, n int) bool { if l, err := state.CurrentLineBuffer().LineAt(n); err == nil { return state.Selection().Has(l) } return false } // adjustPageForRunningQuery adjusts the page number during a running query // so that the screen is never empty when the buffer shrinks below the current page. func adjustPageForRunningQuery(loc *Location, linebuf Buffer, parent Layout, state *Peco) { bufsiz := linebuf.Size() page := loc.Page() for page > 1 { if (loc.PerPage()*(page-1) < bufsiz) && (loc.PerPage()*page) >= bufsiz { break } page-- } if loc.Page() != page { loc.SetPage(page) parent.DrawPrompt(state) } } // renderMatchedLine renders a line with match highlighting, interleaving // matched and non-matched segments with their respective styles. func (l *ListArea) renderMatchedLine(ml *linepkg.Matched, line string, lineANSIAttrs []ansi.AttrSpan, x, y, xOffset int, fgAttr, bgAttr Attribute) { matches := ml.Indices() prev := x index := 0 runeOffset := 0 for _, m := range matches { if m[0] > index { c := line[index:m[0]] runeLen := utf8.RuneCountInString(c) var segAttrs []ansi.AttrSpan if lineANSIAttrs != nil { segAttrs = ansi.ExtractSegment(lineANSIAttrs, runeOffset, runeOffset+runeLen) } n := l.screen.Print(PrintArgs{ X: prev, Y: y, XOffset: xOffset, Fg: fgAttr, Bg: bgAttr, Msg: c, ANSIAttrs: segAttrs, }) prev += n index += len(c) runeOffset += runeLen } c := line[m[0]:m[1]] runeLen := utf8.RuneCountInString(c) n := l.screen.Print(PrintArgs{ X: prev, Y: y, XOffset: xOffset, Fg: l.styles.Matched.fg, Bg: mergeAttribute(bgAttr, l.styles.Matched.bg), Msg: c, }) prev += n index += len(c) runeOffset += runeLen } if index < len(line) { c := line[index:] runeLen := utf8.RuneCountInString(c) var segAttrs []ansi.AttrSpan if lineANSIAttrs != nil { segAttrs = ansi.ExtractSegment(lineANSIAttrs, runeOffset, runeOffset+runeLen) } l.screen.Print(PrintArgs{ X: prev, Y: y, XOffset: xOffset, Fg: fgAttr, Bg: bgAttr, Msg: c, Fill: true, ANSIAttrs: segAttrs, }) } else { l.screen.Print(PrintArgs{ X: prev, Y: y, XOffset: xOffset, Fg: fgAttr, Bg: bgAttr, Msg: "", Fill: true, }) } } // Draw displays the ListArea on the screen func (l *ListArea) Draw(state *Peco, parent Layout, perPage int, options *hub.DrawOptions) { if pdebug.Enabled { g := pdebug.Marker("ListArea.Draw pp = %d, options = %#v", perPage, options) defer g.End() } if perPage < 1 { return } loc := state.Location() linebuf := state.CurrentLineBuffer() if options != nil && options.RunningQuery { adjustPageForRunningQuery(loc, linebuf, parent, state) } pf := loc.PageCrop() if pdebug.Enabled { pdebug.Printf("Cropping linebuf which contains %d lines at page %d (%d entries per page)", linebuf.Size(), pf.currentPage, pf.perPage) } buf := pf.Crop(linebuf) bufsiz := buf.Size() // This protects us from losing the selected line in case our selected // line is greater than the buffer if lbufsiz := linebuf.Size(); lbufsiz > 0 && loc.LineNumber() >= lbufsiz { loc.SetLineNumber(lbufsiz - 1) } // The max column size is calculated by buf. we check against where the // loc variable thinks we should be scrolling to, and make sure that this // falls in range with what we got width, _ := state.Screen().Size() if maxCol := max(buf.MaxColumn()-width, 0); loc.Column() > maxCol { loc.SetColumn(maxCol) } // previously drawn lines are cached. first, truncate the cache // to current size of the drawable area if ldc := len(l.displayCache); ldc != perPage { newCache := make([]linepkg.Line, perPage) copy(newCache, l.displayCache) l.displayCache = newCache } else if perPage > bufsiz { l.displayCache = l.displayCache[:bufsiz] } var y int start := l.AnchorPosition() // If our buffer is smaller than perPage, we may need to clear some lines if pdebug.Enabled { pdebug.Printf("ListArea.Draw: buffer size is %d, our view area is %d", bufsiz, perPage) } for n := bufsiz; n < perPage; n++ { if l.sortTopDown { y = n + start } else { y = start - n } l.screen.Print(PrintArgs{ Y: y, Fg: l.styles.Basic.fg, Bg: l.styles.Basic.bg, Fill: true, }) } var cached, written int var fgAttr, bgAttr Attribute selectionPrefix := state.SelectionPrefix() var prefix string var prefixCurrentSelection, prefixSavedSelection, prefixDefault string if plen := len(selectionPrefix); plen > 0 { prefixCurrentSelection = selectionPrefix + " " prefixSavedSelection = "*" + strings.Repeat(" ", plen) prefixDefault = strings.Repeat(" ", plen+1) } for n := range perPage { if len(selectionPrefix) > 0 { switch { case n+loc.Offset() == loc.LineNumber(): prefix = prefixCurrentSelection case selectionContains(state, n+loc.Offset()): prefix = prefixSavedSelection default: prefix = prefixDefault } } else { switch { case n+loc.Offset() == loc.LineNumber(): fgAttr = l.styles.Selected.fg bgAttr = l.styles.Selected.bg case selectionContains(state, n+loc.Offset()): fgAttr = l.styles.SavedSelection.fg bgAttr = l.styles.SavedSelection.bg default: fgAttr = l.styles.Basic.fg bgAttr = l.styles.Basic.bg } } if n >= bufsiz { break } if l.sortTopDown { y = n + start } else { y = start - n } target, err := buf.LineAt(n) if err != nil { break } if (options != nil && options.DisableCache) || l.IsDirty() || target.IsDirty() { target.SetDirty(false) } else if l.displayCache[n] == target { cached++ continue } written++ l.displayCache[n] = target // Apply Context style for non-matched surrounding lines if _, isCtx := target.(*ContextLine); isCtx { if fgAttr == l.styles.Basic.fg && bgAttr == l.styles.Basic.bg { fgAttr = l.styles.Context.fg bgAttr = l.styles.Context.bg } } x := -1 * loc.Column() xOffset := loc.Column() line := target.DisplayString() // Extract ANSI attrs if available. Only use them for // non-selected (basic) lines so selection/savedSelection // styling takes precedence. var lineANSIAttrs []ansi.AttrSpan isBasicStyle := (fgAttr == l.styles.Basic.fg && bgAttr == l.styles.Basic.bg) if isBasicStyle { if al, ok := target.(ansiLiner); ok { lineANSIAttrs = al.ANSIAttrs() } } if plen := len(prefix); plen > 0 { l.screen.Print(PrintArgs{ X: x, Y: y, XOffset: xOffset, Fg: fgAttr, Bg: bgAttr, Msg: prefix, }) x += plen } if state.SingleKeyJump().Mode() || state.SingleKeyJump().ShowPrefix() { prefixes := state.SingleKeyJump().Prefixes() if n < len(prefixes) { l.screen.Print(PrintArgs{ X: x, Y: y, XOffset: xOffset, Fg: fgAttr | AttrBold | AttrReverse, Bg: bgAttr, Msg: string(prefixes[n]), }) l.screen.Print(PrintArgs{ X: x + 1, Y: y, XOffset: xOffset, Fg: fgAttr, Bg: bgAttr, Msg: " ", }) } else { l.screen.Print(PrintArgs{ X: x, Y: y, XOffset: xOffset, Fg: fgAttr, Bg: bgAttr, Msg: " ", }) } x += 2 } ml, ok := target.(*linepkg.Matched) if !ok { l.screen.Print(PrintArgs{ X: x, Y: y, XOffset: xOffset, Fg: fgAttr, Bg: bgAttr, Msg: line, Fill: true, ANSIAttrs: lineANSIAttrs, }) continue } l.renderMatchedLine(ml, line, lineANSIAttrs, x, y, xOffset, fgAttr, bgAttr) } l.SetDirty(false) if pdebug.Enabled { pdebug.Printf("ListArea.Draw: Written total of %d lines (%d cached)", written+cached, cached) } } // newStatusBar returns a StatusBar appropriate for the configuration. // If SuppressStatusMsg is true, a nullStatusBar (no-op) is returned. func newStatusBar(state *Peco) (StatusBar, error) { if state.SuppressStatusMsg() { return nullStatusBar{}, nil } return newScreenStatusBar(state.Screen(), AnchorBottom, 0+extraOffset, state.Styles()) } // DefaultLayout creates a Layout in the default format (top-down). func DefaultLayout(state *Peco) (*BasicLayout, error) { sb, err := newStatusBar(state) if err != nil { return nil, err } prompt, err := NewUserPrompt(state.Screen(), AnchorTop, 0, state.Prompt(), state.Styles()) if err != nil { return nil, err } list, err := NewListArea(state.Screen(), AnchorTop, 1, true, state.Styles()) if err != nil { return nil, err } return &BasicLayout{ statusBar: sb, screen: state.Screen(), prompt: prompt, list: list, }, nil } // BottomUpLayout creates a Layout in bottom-up format. func BottomUpLayout(state *Peco) (*BasicLayout, error) { sb, err := newStatusBar(state) if err != nil { return nil, err } prompt, err := NewUserPrompt(state.Screen(), AnchorBottom, 1+extraOffset, state.Prompt(), state.Styles()) if err != nil { return nil, err } list, err := NewListArea(state.Screen(), AnchorBottom, 2+extraOffset, false, state.Styles()) if err != nil { return nil, err } return &BasicLayout{ statusBar: sb, screen: state.Screen(), prompt: prompt, list: list, }, nil } // TopDownQueryBottomLayout creates a Layout with list top-to-bottom // and the query prompt at the bottom. func TopDownQueryBottomLayout(state *Peco) (*BasicLayout, error) { sb, err := newStatusBar(state) if err != nil { return nil, err } prompt, err := NewUserPrompt(state.Screen(), AnchorBottom, 1+extraOffset, state.Prompt(), state.Styles()) if err != nil { return nil, err } list, err := NewListArea(state.Screen(), AnchorTop, 0, true, state.Styles()) if err != nil { return nil, err } return &BasicLayout{ statusBar: sb, screen: state.Screen(), prompt: prompt, list: list, }, nil } // SortTopDown returns whether this layout sorts lines from top to bottom. func (l *BasicLayout) SortTopDown() bool { return l.list.sortTopDown } func init() { RegisterLayout(LayoutTypeTopDown, LayoutBuilderFunc(DefaultLayout)) RegisterLayout(LayoutTypeBottomUp, LayoutBuilderFunc(BottomUpLayout)) RegisterLayout(LayoutTypeTopDownQueryBottom, LayoutBuilderFunc(TopDownQueryBottomLayout)) } func (l *BasicLayout) PurgeDisplayCache() { l.list.purgeDisplayCache() } // CalculatePage calculates which page we're displaying func (l *BasicLayout) CalculatePage(state *Peco, perPage int) error { if pdebug.Enabled { g := pdebug.Marker("BasicLayout.Calculate %d", perPage) defer g.End() } buf := state.CurrentLineBuffer() loc := state.Location() loc.SetPage((loc.LineNumber() / perPage) + 1) loc.SetOffset((loc.Page() - 1) * perPage) loc.SetPerPage(perPage) loc.SetTotal(buf.Size()) if loc.Total() == 0 { loc.SetMaxPage(1) } else { loc.SetMaxPage((loc.Total() + perPage - 1) / perPage) } if loc.MaxPage() < loc.Page() { if buf.Size() == 0 { // wait for targets return errors.New("no targets or query. nothing to do") } loc.SetLineNumber(loc.Offset()) } return nil } // DrawPrompt draws the prompt to the terminal func (l *BasicLayout) DrawPrompt(state *Peco) { l.prompt.Draw(state) } // DrawScreen draws the entire screen func (l *BasicLayout) DrawScreen(state *Peco, options *hub.DrawOptions) { if pdebug.Enabled { g := pdebug.Marker("BasicLayout.DrawScreen") defer g.End() } perPage := l.linesPerPage() if err := l.CalculatePage(state, perPage); err != nil { return } l.DrawPrompt(state) l.list.Draw(state, l, perPage, options) if options != nil && options.ForceSync { type syncer interface{ Sync() } if s, ok := l.screen.(syncer); ok { s.Sync() return } } if err := l.screen.Flush(); err != nil { return } } // linesPerPage calculates the number of visible lines per page based on terminal height. func (l *BasicLayout) linesPerPage() int { _, height := l.screen.Size() // list area is always the display area - 2 lines for prompt and status reservedLines := 2 + extraOffset pp := height - reservedLines if pp < 1 { // This is an error condition, and while we probably should handle this // error more gracefully, the consumers of this method do not really // do anything with this error. I think it's just safe to "2", which just // means no space left to draw anything if pdebug.Enabled { pdebug.Printf( "linesPerPage is < 1 (height = %d, reservedLines = %d), forcing return value of 2", height, reservedLines, ) } return 2 } return pp } // MovePage scrolls the screen func (l *BasicLayout) MovePage(state *Peco, p hub.PagingRequest) (moved bool) { switch p.Type() { case hub.ToScrollLeft, hub.ToScrollRight: moved = horizontalScroll(state, l, p) default: moved = verticalScroll(state, l, p) } return } // computeNewLineNumber calculates the new line number based on the paging // request, layout direction, and buffer size. It handles wrapping around // the ends of the buffer. func computeNewLineNumber(loc *Location, p hub.PagingRequest, sortTopDown bool, lineno, lcur, lpp int) int { lineBefore := lineno if sortTopDown { switch p.Type() { case hub.ToLineAbove: lineno-- case hub.ToLineBelow: lineno++ case hub.ToScrollPageDown: lineno += lpp if loc.Page() == loc.MaxPage()-1 && lcur < lineno && (lcur-lineBefore) < lpp { lineno = lcur - 1 } case hub.ToScrollPageUp: lineno -= lpp case hub.ToLineInPage: if jlr, ok := p.(hub.JumpToLineRequest); ok { lineno = loc.PerPage()*(loc.Page()-1) + jlr.Line() } case hub.ToScrollFirstItem: lineno = 0 case hub.ToScrollLastItem: lineno = lcur - 1 } } else { switch p.Type() { case hub.ToLineAbove: lineno++ case hub.ToLineBelow: lineno-- case hub.ToScrollPageDown: lineno -= lpp case hub.ToScrollPageUp: lineno += lpp case hub.ToLineInPage: if jlr, ok := p.(hub.JumpToLineRequest); ok { lineno = loc.PerPage()*(loc.Page()-1) - jlr.Line() } } } // Wrap around buffer boundaries if lineno < 0 { if lcur > 0 { lineno = lcur - 1 } else { lineno = 0 } } else if lcur > 0 && lineno >= lcur { lineno = 0 } return lineno } // updateRangeSelection updates the selection state when moving in range mode. // It adds lines between the range start and the current position, and removes // lines that were previously selected but are no longer in the range. func updateRangeSelection(state *Peco, buf Buffer, loc *Location, lineBefore, lcur int) { r := state.SelectionRangeStart() if !r.Valid() { return } sel := state.Selection() curLine := loc.LineNumber() if curLine < r.Value() { for lineno := curLine; lineno <= r.Value(); lineno++ { if line, err := buf.LineAt(lineno); err == nil { sel.Add(line) } } switch { case r.Value() <= lineBefore: for lineno := r.Value(); lineno <= lcur && lineno < lineBefore; lineno++ { if line, err := buf.LineAt(lineno); err == nil { sel.Remove(line) } } case lineBefore < curLine: for lineno := lineBefore; lineno < curLine; lineno++ { if line, err := buf.LineAt(lineno); err == nil { sel.Remove(line) } } } } else { for lineno := r.Value(); lineno <= lcur && lineno <= curLine; lineno++ { if line, err := buf.LineAt(lineno); err == nil { sel.Add(line) } } switch { case lineBefore <= r.Value(): for lineno := lineBefore; lineno < r.Value(); lineno++ { if line, err := buf.LineAt(lineno); err == nil { sel.Remove(line) } } case curLine < lineBefore: for lineno := curLine; lineno <= lineBefore; lineno++ { if line, err := buf.LineAt(lineno); err == nil { sel.Remove(line) } } } } } // verticalScroll moves the cursor position vertically func verticalScroll(state *Peco, l *BasicLayout, p hub.PagingRequest) bool { loc := state.Location() lineBefore := loc.LineNumber() if pdebug.Enabled { defer func() { pdebug.Printf("currentLine changed from %d -> %d", lineBefore, state.Location().LineNumber()) }() } buf := state.CurrentLineBuffer() lcur := buf.Size() defer func() { for _, lno := range []int{lineBefore, loc.LineNumber()} { if oldLine, err := buf.LineAt(lno); err == nil { oldLine.SetDirty(true) } } }() lpp := l.linesPerPage() lineno := computeNewLineNumber(loc, p, l.list.sortTopDown, lineBefore, lcur, lpp) loc.SetLineNumber(lineno) if l.list.sortTopDown { updateRangeSelection(state, buf, loc, lineBefore, lcur) } return true } // horizontalScroll scrolls screen horizontal func horizontalScroll(state *Peco, l *BasicLayout, p hub.PagingRequest) bool { width, _ := state.Screen().Size() loc := state.Location() if p.Type() == hub.ToScrollRight { loc.SetColumn(loc.Column() + width/2) } else if loc.Column() > 0 { loc.SetColumn(loc.Column() - width/2) if loc.Column() < 0 { loc.SetColumn(0) } } else { return false } l.list.SetDirty(true) return true }