Files
lazygit/pkg/gocui/view.go

2726 lines
79 KiB
Go

// Copyright 2014 The gocui Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package gocui
import (
"fmt"
"io"
"slices"
"strings"
"sync"
"unicode"
"unicode/utf8"
"github.com/gdamore/tcell/v3"
"github.com/rivo/uniseg"
)
// Constants for overlapping edges
const (
TOP = 1 // view is overlapping at top edge
BOTTOM = 2 // view is overlapping at bottom edge
LEFT = 4 // view is overlapping at left edge
RIGHT = 8 // view is overlapping at right edge
)
// viewBuffer holds a view's content as cells, together with the cursor and
// escape-sequence decoder state used to turn incoming bytes into those cells.
// A view normally has a single buffer (the one it displays), but bundling this
// state lets a re-render build a second, off-screen buffer and swap it in
// atomically once the new content is ready, so no reader ever sees a
// half-written buffer.
type viewBuffer struct {
// the view's content: one []cell per unwrapped line
lines []lineType
// write cursor into lines
wx, wy int
// decodes ESC sequences as bytes are written
ei *escapeInterpreter
// If the last character written was a newline, we don't write it but instead
// set pendingNewline to true. If more text is written, we write the newline
// then. This avoids an extra blank line at the end of the view.
pendingNewline bool
}
// A View is a window. It maintains its own internal buffer and cursor
// position.
type View struct {
name string
x0, y0, x1, y1 int // left top right bottom
ox, oy int // view offsets
cx, cy int // cursor position
rx, ry int // Read() offsets
outMode OutputMode
// buf bundles the view's cell buffer and the cursor / escape-parser state
// used to write into it (see the viewBuffer type). It is the buffer every
// reader sees.
buf *viewBuffer
// While non-nil, writes go here instead of buf, so an async re-render can
// build its new content without disturbing what readers (draw, clicks,
// scrolling, …) see. The task swaps it into buf once it has read enough to
// paint (SwapInOffscreenRender), so the displayed content jumps straight
// from the previous render to the new one with no half-written frame in
// between. nil during normal (non-async) writes.
offscreen *viewBuffer
// The y position of the first line of a range selection.
// This is not relative to the view's origin: it is relative to the first line
// of the view's content, so you can scroll the view and this value will remain
// the same, unlike the view's cy value.
// A value of -1 means that there is no range selection.
// This value can be greater than the selected line index, in the event that
// a user starts a range select and then moves the cursor up.
rangeSelectStartY int
// The view line whose selection-width bar is temporarily reversed. A value
// of -1 means that no line is flashing.
lineFlashY int
// readBuffer is used for storing unread bytes
readBuffer []byte
// tained is true if the viewLines must be updated
tainted bool
// needsRedraw is true if the view's current state has not been drawn to the
// screen yet. A tainted view always needs a redraw, but draw-only state can
// require one without invalidating viewLines.
needsRedraw bool
// firstDirtyLine is the index of the lowest line in `lines` that has been
// written to or highlighted since viewLines was last refreshed, and whose
// cached wrapping (lineType.wrappedCells) may therefore be stale. Lines
// below it are unchanged and can reuse their cached wrapping instead of
// being re-wrapped, which keeps refreshViewLinesIfNeeded cheap while
// scrolling appends new lines to a long buffer.
firstDirtyLine int
// the last position that the mouse was hovering over; nil if the mouse is outside of
// this view, or not hovering over a cell
lastHoverPosition *pos
// the location of the hyperlink that the mouse is currently hovering over; nil if none
hoveredHyperlink *SearchPosition
// internal representation of the view's buffer. We will keep viewLines around
// from a previous render until we explicitly set them to nil, allowing us to
// render the same content twice without flicker. Wherever we want to render
// something without any chance of old content appearing (e.g. when actually
// rendering new content or if the view is resized) we should set tainted to
// true and viewLines to nil
viewLines []viewLine
// While a re-render is loading new content (see offscreen), the displayed
// buffer is only partially filled once we've swapped the off-screen render
// in: the task keeps appending lines after the first paint, up to the count
// needed for an accurate scrollbar. Sizing the scrollbar from that partial
// view-line count would make the thumb shrink and snap back as the rest
// streams in. So while a load is in progress we hold the scrollbar's height
// at this value — the height the view had when the load began — and let it
// grow only if the new content turns out taller. Zero means no load is in
// progress and the scrollbar tracks the content directly.
scrollbarHeightFloor int
// writeMutex protects locks the write process
writeMutex sync.Mutex
// Visible specifies whether the view is visible.
Visible bool
// BgColor and FgColor allow to configure the background and foreground
// colors of the View.
BgColor, FgColor Attribute
// SelBgColor and SelFgColor are used to configure the background and
// foreground colors of the selected line, when it is highlighted.
SelBgColor, SelFgColor Attribute
// InactiveViewSelBgColor is used to configure the background color of the
// selected line, when it is highlighted but the view doesn't have the
// focus.
InactiveViewSelBgColor Attribute
// If Editable is true, keystrokes will be added to the view's internal
// buffer at the cursor position.
Editable bool
// Editor allows to define the editor that manages the editing mode,
// including keybindings or cursor behaviour. DefaultEditor is used by
// default.
Editor Editor
// Overwrite enables or disables the overwrite mode of the view.
Overwrite bool
// If Highlight is true, Sel{Bg,Fg}Colors will be used
// for the line under the cursor position.
Highlight bool
// If HighlightInactive is true, InavtiveViewSel{Bg,Fg}Colors will be used
// instead of Sel{Bg,Fg}Colors for highlighting selected lines.
HighlightInactive bool
// If SelectedLineColorWidth is greater than zero, a highlighted line is painted
// in the selection colors on that many columns at its left edge only, rather
// than across its whole width, leaving the line's own colors to show through.
// For content that conveys meaning by color of its own.
SelectedLineColorWidth int
// InclusionGutterMarker is the glyph the inclusion gutter draws on a marked line
// (see SetInclusionGutter), and InclusionGutterMarkerColor its color. Both are
// set once, when the view is created.
InclusionGutterMarker string
InclusionGutterMarkerColor Attribute
// showInclusionGutter reserves the gutter's columns at the left of every line,
// and inclusionGutterMarks, indexed by line of the content, says which lines get
// the marker. Set together, via SetInclusionGutter.
showInclusionGutter bool
inclusionGutterMarks []bool
// If Frame is true, a border will be drawn around the view.
Frame bool
// FrameColor allow to configure the color of the Frame when it is not highlighted.
FrameColor Attribute
// FrameRunes allows to define custom runes for the frame edges.
// The rune slice can be defined with 3 different lengths.
// If slice doesn't match these lengths, default runes will be used instead of missing one.
//
// 2 runes with only horizontal and vertical edges.
// []rune{'─', '│'}
// []rune{'═','║'}
// 6 runes with horizontal, vertical edges and top-left, top-right, bottom-left, bottom-right cornes.
// []rune{'─', '│', '┌', '┐', '└', '┘'}
// []rune{'═','║','╔','╗','╚','╝'}
// 11 runes which can be used with `gocui.Gui.SupportOverlaps` property.
// []rune{'─', '│', '┌', '┐', '└', '┘', '├', '┤', '┬', '┴', '┼'}
// []rune{'═','║','╔','╗','╚','╝','╠','╣','╦','╩','╬'}
FrameRunes []rune
// If Wrap is true, the content that is written to this View is
// automatically wrapped when it is longer than its width. If true the
// view's x-origin will be ignored.
Wrap bool
// If Autoscroll is true, the View will automatically scroll down when the
// text overflows. If true the view's y-origin will be ignored.
Autoscroll bool
// If Frame is true, Title allows to configure a title for the view.
Title string
// If non-empty, TitlePrefix is prepended to the title of a view regardless on
// the the currently selected tab (if any.)
TitlePrefix string
Tabs []string
TabIndex int
// TitleColor allow to configure the color of title and subtitle for the view.
TitleColor Attribute
// If Frame is true, Subtitle allows to configure a subtitle for the view.
Subtitle string
// If Mask is true, the View will display the mask instead of the real
// content
Mask string
// Overlaps describes which edges are overlapping with another view's edges
Overlaps byte
// ParentView is the view which catches events bubbled up from the given view if there's no matching handler.
// Views related this way are also drawn as a single focused unit: while one of
// them is the current view, they all get the focused frame and title colors.
ParentView *View
searcher *searcher
// KeybindOnEdit should be set to true when you want to execute keybindings even when the view is editable
// (this is usually not the case)
KeybindOnEdit bool
TextArea *TextArea
// something like '1 of 20' for a list view
Footer string
// if true, the user can scroll all the way past the last item until it appears at the top of the view
CanScrollPastBottom bool
// if true, the view will automatically recognize https: URLs in the content written to it and render
// them as hyperlinks
AutoRenderHyperLinks bool
// if true, the view will underline hyperlinks only when the cursor is on
// them; otherwise, they will always be underlined
UnderlineHyperLinksOnlyOnHover bool
// number of spaces per \t character, defaults to 4
TabWidth int
}
type pos struct {
x, y int
}
// call this if you want to render new content without the chance of old content
// still appearing, or if you want to remove a line from the existing content. For
// a view whose size has changed, whose content is the same but has to be wrapped
// afresh, call RewrapContent instead.
func (v *View) clearViewLines() {
v.markViewLinesDirty()
v.viewLines = nil
v.clearHover()
}
// markViewLinesDirty records that the cached viewLines no longer represent the
// view's buffer or wrapping, so both rebuilding and redrawing are required.
func (v *View) markViewLinesDirty() {
v.tainted = true
v.needsRedraw = true
}
// RewrapContent wraps the view's content for the size the view has now, and puts
// the positions into that content — the scroll offset, the cursor, a range's
// anchor — back on the lines they were on. They are all view lines, which count
// the segments each line is wrapped into, so wrapping the content at another
// width leaves every one of them pointing at a different line.
//
// Call it on the UI thread whenever the view's size changes; a task goroutine may
// be writing the content concurrently, and all of this is state writeMutex
// protects.
func (v *View) RewrapContent() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshViewLinesIfNeeded()
origin := v.contentPosOf(v.oy)
cursor := v.contentPosOf(v.oy + v.cy)
anchor := v.contentPosOf(v.rangeSelectStartY)
cursorRow := v.cy
v.clearViewLines()
v.refreshViewLinesIfNeeded()
if !origin.ok {
return
}
cursorLine, cursorOk := v.viewLineOf(cursor)
if anchorLine, ok := v.viewLineOf(anchor); ok {
v.rangeSelectStartY = anchorLine
if cursorOk {
// A range covers lines of content, not the segments they are drawn as,
// so its ends go back on the outermost segments of their lines: a line
// the range covered the whole of stays covered whole.
cursorLine = v.viewLineOfRangeEnd(cursor, anchor)
v.rangeSelectStartY = v.viewLineOfRangeEnd(anchor, cursor)
}
}
// The line the cursor is on keeps the row it was drawn on, so that it doesn't
// move under the user; with no cursor on screen the view keeps its own place
// in the content instead.
if v.Highlight && cursorOk && cursorRow >= 0 && cursorRow < v.InnerHeight() {
v.SetOriginY(cursorLine - cursorRow)
} else if originLine, ok := v.viewLineOf(origin); ok {
v.SetOriginY(originLine)
}
if cursorOk {
v.cy = cursorLine - v.oy
}
}
// contentPos is a position in a view's content in terms that survive the content
// being wrapped again: which line of it, and which of that line's segments.
type contentPos struct {
line, segment int
ok bool
}
// contentPosOf returns where the given view line sits in the content. Only call
// this with a lock on writeMutex, and with the view lines up to date.
func (v *View) contentPosOf(viewLine int) contentPos {
if viewLine < 0 || viewLine >= len(v.viewLines) {
return contentPos{}
}
return contentPos{
line: v.viewLines[viewLine].linesY,
segment: v.viewLines[viewLine].linesX,
ok: true,
}
}
// viewLineOf returns the view line drawing the given position in the content,
// on the nearest segment its line still has. Only call this with a lock on
// writeMutex, and with the view lines up to date.
func (v *View) viewLineOf(pos contentPos) (int, bool) {
first, last, ok := v.segmentSpanOf(pos)
if !ok {
return 0, false
}
return min(first+pos.segment, last), true
}
// viewLineOfRangeEnd returns the view line for one end of a range selection: the
// outermost segment of its line, so that the range covers that line whole. other
// is the range's other end, which says which way is outward. Both ends have to be
// positions whose lines are drawn, which viewLineOf answers.
func (v *View) viewLineOfRangeEnd(pos contentPos, other contentPos) int {
first, last, _ := v.segmentSpanOf(pos)
if pos.line <= other.line {
return first
}
return last
}
// segmentSpanOf returns the first and last view line drawing the given position's
// line of the content. ok is false when the position was never taken, or its line
// isn't drawn at all.
func (v *View) segmentSpanOf(pos contentPos) (int, int, bool) {
if !pos.ok {
return 0, 0, false
}
return v.viewLineSpanOfBufferLine(pos.line)
}
// viewLineSpanOfBufferLine returns the first and last view line drawing the given
// buffer line — the segments it is wrapped into, which are the same view line when
// it doesn't wrap. ok is false when the line isn't drawn at all. Only call this
// with a lock on writeMutex, and with the view lines up to date.
func (v *View) viewLineSpanOfBufferLine(bufferLine int) (int, int, bool) {
first, last := -1, -1
for i, vline := range v.viewLines {
if vline.linesY == bufferLine {
if first == -1 {
first = i
}
last = i
} else if first != -1 {
break
}
}
return first, last, first != -1
}
type searcher struct {
searchString string
searchPositions []SearchPosition
modelSearchResults []SearchPosition
currentSearchIndex int
onSelectItem func(*View, int)
renderSearchStatus func(*View, int, int)
}
func (v *View) setRenderSearchStatus(renderSearchStatus func(*View, int, int)) {
v.searcher.renderSearchStatus = renderSearchStatus
}
func (v *View) setOnSelectResult(onSelectItem func(*View, int)) {
v.searcher.onSelectItem = onSelectItem
}
func (v *View) renderSearchStatus(index int, itemCount int) {
if v.searcher.renderSearchStatus != nil {
v.searcher.renderSearchStatus(v, index, itemCount)
}
}
func (v *View) gotoNextMatch() error {
if len(v.searcher.searchPositions) == 0 {
return nil
}
if v.Highlight && v.oy+v.cy < v.searcher.searchPositions[v.searcher.currentSearchIndex].Y {
// If the selection is before the current match, just jump to the current match and return.
// This can only happen if the user has moved the cursor to before the first match.
v.SelectSearchResult(v.searcher.currentSearchIndex)
return nil
}
if v.searcher.currentSearchIndex >= len(v.searcher.searchPositions)-1 {
v.searcher.currentSearchIndex = 0
} else {
v.searcher.currentSearchIndex++
}
v.SelectSearchResult(v.searcher.currentSearchIndex)
return nil
}
func (v *View) gotoPreviousMatch() error {
if len(v.searcher.searchPositions) == 0 {
return nil
}
if v.Highlight && v.oy+v.cy > v.searcher.searchPositions[v.searcher.currentSearchIndex].Y {
// If the selection is after the current match, just jump to the current match and return.
// This happens if the user has moved the cursor down from the current match.
v.SelectSearchResult(v.searcher.currentSearchIndex)
return nil
}
if v.searcher.currentSearchIndex == 0 {
if len(v.searcher.searchPositions) > 0 {
v.searcher.currentSearchIndex = len(v.searcher.searchPositions) - 1
}
} else {
v.searcher.currentSearchIndex--
}
v.SelectSearchResult(v.searcher.currentSearchIndex)
return nil
}
func (v *View) SelectSearchResult(index int) {
itemCount := len(v.searcher.searchPositions)
if itemCount == 0 {
return
}
if index > itemCount-1 {
index = itemCount - 1
}
y := v.searcher.searchPositions[index].Y
v.FocusPoint(v.ox, y, true)
v.renderSearchStatus(index, itemCount)
if v.searcher.onSelectItem != nil {
v.searcher.onSelectItem(v, y)
}
}
// Returns <current match index>, <total matches>
func (v *View) GetSearchStatus() (int, int) {
return v.searcher.currentSearchIndex, len(v.searcher.searchPositions)
}
// modelSearchResults is optional; pass nil to search the view. If non-nil,
// these positions will be used for highlighting search results. Even in this
// case the view will still be searched on a per-line basis, so that the caller
// doesn't have to make assumptions where in the rendered line the search result
// is. The XStart and XEnd values in the modelSearchResults are only used in
// case the search string is not found in the given line, which can happen if
// the view renders an abbreviated version of some of the model data.
//
// Mind the difference between nil and empty slice: nil means we're not
// searching the model, empty slice means we *are* searching the model but we
// didn't find any matches.
func (v *View) UpdateSearchResults(str string, modelSearchResults []SearchPosition) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.searcher.search(str, modelSearchResults)
v.updateSearchPositions()
if len(v.searcher.searchPositions) > 0 {
// get the first result past the current cursor
currentIndex := 0
if v.Highlight {
// ...but only if we're showing the highlighted line
adjustedY := v.oy + v.cy
adjustedX := v.ox + v.cx
for i, pos := range v.searcher.searchPositions {
if pos.Y > adjustedY || (pos.Y == adjustedY && pos.XStart > adjustedX) {
currentIndex = i
break
}
}
}
v.searcher.currentSearchIndex = currentIndex
}
}
func (v *View) Search(str string, modelSearchResults []SearchPosition) {
v.UpdateSearchResults(str, modelSearchResults)
if len(v.searcher.searchPositions) > 0 {
v.SelectSearchResult(v.searcher.currentSearchIndex)
} else {
v.renderSearchStatus(0, 0)
}
}
func (v *View) ClearSearch() {
v.searcher.clearSearch()
}
func (v *View) IsSearching() bool {
return v.searcher.searchString != ""
}
func (v *View) nearestSearchPosition() int {
currentLineIndex := v.cy + v.oy
lastSearchPos := 0
for i, pos := range v.searcher.searchPositions {
if pos.Y == currentLineIndex {
return i
}
if pos.Y > currentLineIndex {
break
}
lastSearchPos = i
}
return lastSearchPos
}
func (v *View) SetNearestSearchPosition() {
if len(v.searcher.searchPositions) > 0 {
newPos := v.nearestSearchPosition()
if newPos != v.searcher.currentSearchIndex {
v.searcher.currentSearchIndex = newPos
v.renderSearchStatus(newPos, len(v.searcher.searchPositions))
}
}
}
func (v *View) FocusPoint(cx int, cy int, scrollIntoView bool) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshViewLinesIfNeeded()
lineCount := len(v.viewLines)
if cy < 0 || cy > lineCount {
return
}
if scrollIntoView {
height := v.InnerHeight()
v.SetOriginY(calculateNewOrigin(cy, v.oy, lineCount, height))
}
v.cx = cx
v.cy = cy - v.oy
}
func (v *View) SetRangeSelectStart(rangeSelectStartY int) {
v.rangeSelectStartY = rangeSelectStartY
}
// RangeSelectStartY returns the view line the range selection is anchored on,
// or -1 when there is no range.
func (v *View) RangeSelectStartY() int {
return v.rangeSelectStartY
}
func (v *View) CancelRangeSelect() {
v.rangeSelectStartY = -1
}
// HasRangeSelect reports whether a range selection is anchored, as opposed to the
// view showing a plain cursor. A range whose ends are on the same view line is still
// one, which SelectedLineRange alone can't tell you.
func (v *View) HasRangeSelect() bool {
return v.rangeSelectStartY != -1
}
func calculateNewOrigin(selectedLine int, oldOrigin int, lineCount int, viewHeight int) int {
if viewHeight >= lineCount {
return 0
} else if selectedLine < oldOrigin || selectedLine >= oldOrigin+viewHeight {
// If the selected line is outside the visible area, scroll the view so
// that the selected line is in the middle.
newOrigin := selectedLine - viewHeight/2
// However, take care not to overflow if the total line count is less
// than the view height.
maxOrigin := lineCount - viewHeight
if newOrigin > maxOrigin {
newOrigin = maxOrigin
}
if newOrigin < 0 {
newOrigin = 0
}
return newOrigin
}
return oldOrigin
}
func (s *searcher) search(str string, modelSearchResults []SearchPosition) {
s.searchString = str
s.searchPositions = []SearchPosition{}
s.modelSearchResults = modelSearchResults
s.currentSearchIndex = 0
}
func (s *searcher) clearSearch() {
s.searchString = ""
s.searchPositions = []SearchPosition{}
s.currentSearchIndex = 0
}
type SearchPosition struct {
XStart int
XEnd int
Y int
}
type viewLine struct {
linesX, linesY int // coordinates relative to v.buf.lines
line []cell
// Colors used to extend the bg past this wrapped segment's content.
// Derived at wrap time from the source line — see refreshViewLinesIfNeeded
// for the per-segment rule.
trailingFillAttributes *trailingFillAttributes
}
// lineType is one of v.buf.lines: the cells of a source line, plus optional
// trailingFillAttributes recording the colors used to extend the bg
// past the line's content when the writer emitted '\x1b[K'.
type lineType struct {
cells cells
trailingFillAttributes *trailingFillAttributes
// wrappedCells caches the result of wrapping `cells` to `wrappedColumns`
// columns, so that unchanged lines don't have to be re-wrapped on every
// refreshViewLinesIfNeeded (which runs on every scroll event, via
// ViewLinesHeight). Wrapping measures every cell's width and allocates, so
// for a long buffer that dominates the cost of scrolling. The cache is used
// only for lines below View.firstDirtyLine whose wrappedColumns still
// matches the current width; nil means nothing is cached yet.
wrappedCells [][]cell
wrappedColumns int
}
// trailingFillAttributes describes the fg/bg colors that draw() should
// use for cells past the end of a wrapped segment's content. On a source
// line this records what the writer asked for via '\x1b[K' (and so opts
// the line in to trailing fill at all); the per-segment values on each
// viewLine are derived from it at wrap time.
type trailingFillAttributes struct {
fg, bg Attribute
}
type cell struct {
chr string // a grapheme cluster
width int // number of terminal cells occupied by chr (always 1 or 2)
bgColor, fgColor Attribute
hyperlink string
// the OSC 1717 payload in effect when the cell was written, i.e. what the
// diff renderer said about the diff line this cell is part of
metadata string
}
type cells []cell
func characterEquals(chr []byte, b byte) bool {
return len(chr) == 1 && chr[0] == b
}
func isCRLF(chr []byte) bool {
return len(chr) == 2 && chr[0] == '\r' && chr[1] == '\n'
}
// String returns a string from a given cell slice.
func (l cells) String() string {
var str strings.Builder
for _, c := range l {
str.WriteString(c.chr)
}
return str.String()
}
// NewView returns a new View object.
func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View {
v := &View{
name: name,
x0: x0,
y0: y0,
x1: x1,
y1: y1,
Visible: true,
Frame: true,
Editor: DefaultEditor,
tainted: true,
needsRedraw: true,
outMode: mode,
buf: &viewBuffer{ei: newEscapeInterpreter(mode)},
searcher: &searcher{},
TextArea: &TextArea{},
rangeSelectStartY: -1,
lineFlashY: -1,
TabWidth: 4,
}
v.FgColor, v.BgColor = ColorDefault, ColorDefault
v.SelFgColor, v.SelBgColor = ColorDefault, ColorDefault
v.InactiveViewSelBgColor = ColorDefault
v.TitleColor, v.FrameColor = ColorDefault, ColorDefault
v.buf.ei.screenColMax = v.InnerWidth()
return v
}
// SetContentWidth tells the view the screen width that content written to it
// should count soft-wraps against (see escapeInterpreter.notifyCellsWritten).
// Callers pass the view's InnerWidth; it's a separate call, made on the UI
// thread when a render starts, so that the task goroutine that streams the
// content can consult this snapshot instead of reading the view's live
// dimensions (which the UI thread mutates during layout).
func (v *View) SetContentWidth(width int) {
v.buf.ei.screenColMax = width
}
// Dimensions returns the dimensions of the View
func (v *View) Dimensions() (int, int, int, int) {
return v.x0, v.y0, v.x1, v.y1
}
// Size returns the number of visible columns and rows in the View, including
// the frame if any
func (v *View) Size() (x, y int) {
return v.Width(), v.Height()
}
// InnerSize returns the number of usable columns and rows in the View, excluding
// the frame if any
func (v *View) InnerSize() (x, y int) {
return v.InnerWidth(), v.InnerHeight()
}
func (v *View) Width() int {
return v.x1 - v.x0 + 1
}
func (v *View) Height() int {
return v.y1 - v.y0 + 1
}
// The writeable area of the view is always two less then the view's size,
// because if it has a frame, we need to subtract that, but if it doesn't, the
// view is made 1 larger on all sides. I'd like to clean this up at some point,
// but for now we live with this weirdness.
func (v *View) InnerWidth() int {
innerWidth := v.Width() - 2
if innerWidth < 0 {
return 0
}
return innerWidth
}
func (v *View) InnerHeight() int {
innerHeight := v.Height() - 2
if innerHeight < 0 {
return 0
}
return innerHeight
}
// Name returns the name of the view.
func (v *View) Name() string {
return v.name
}
// SetInclusionGutter shows or hides a column reserved at the left of every line, in
// which marks — indexed by line of the content — say which lines get
// InclusionGutterMarker drawn, on every segment of a line the view wrapped. The
// content is drawn shifted past it.
//
// It is drawn over the content rather than written into it, so the content itself —
// and with it what each line of the view means, where a click lands, and how the
// lines wrap — is untouched but for the width the gutter takes.
func (v *View) SetInclusionGutter(show bool, marks []bool) {
v.writeMutex.Lock()
changed := v.showInclusionGutter != show
v.showInclusionGutter = show
v.inclusionGutterMarks = marks
v.writeMutex.Unlock()
if changed {
// The gutter takes its columns from the content, so what is left of it wraps
// differently, and everything pointing into it has to come along.
v.RewrapContent()
}
}
// inclusionGutterWidth is how many columns the inclusion gutter takes while it is
// shown — the marker plus a column of space before the content — and 0 while it is
// not. Only call this with a lock on writeMutex.
func (v *View) inclusionGutterWidth() int {
if !v.showInclusionGutter {
return 0
}
return uniseg.StringWidth(v.InclusionGutterMarker) + 1
}
// setCharacter sets a character (grapheme cluster) at the given point relative to the view. It applies
// the specified colors, taking into account if the cell must be highlighted. Also, it checks if the
// position is valid.
func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute, isWindowFocused bool) {
maxX, maxY := v.Size()
if x < 0 || x >= maxX || y < 0 || y >= maxY {
return
}
if v.Mask != "" {
fgColor = v.FgColor
bgColor = v.BgColor
ch = v.Mask
} else if v.Highlight {
rangeSelectStart := v.cy
rangeSelectEnd := v.cy
if v.rangeSelectStartY != -1 {
relativeRangeSelectStart := v.rangeSelectStartY - v.oy
rangeSelectStart = min(relativeRangeSelectStart, v.cy)
rangeSelectEnd = max(relativeRangeSelectStart, v.cy)
}
colorWidth := v.SelectedLineColorWidth
if y >= rangeSelectStart && y <= rangeSelectEnd && (colorWidth == 0 || x < colorWidth) {
// this ensures we use the bright variant of a colour upon highlight
fgColorComponent := fgColor & ^AttrAll
if fgColorComponent >= AttrIsValidColor && fgColorComponent < AttrIsValidColor+8 {
fgColor += 8
}
fgColor = fgColor | AttrBold
if v.HighlightInactive || !isWindowFocused {
bgColor = (bgColor & AttrStyleBits) | v.InactiveViewSelBgColor
} else {
bgColor = (bgColor & AttrStyleBits) | v.SelBgColor
}
}
}
if matched, selected := v.isPatternMatchedRune(x, y); matched {
fgColor = ColorBlack
if selected {
bgColor = ColorCyan
} else {
bgColor = ColorYellow
}
}
if v.isHoveredHyperlink(x, y) {
fgColor |= AttrUnderline
}
if v.lineFlashY == v.oy+y && (v.SelectedLineColorWidth == 0 || x < v.SelectedLineColorWidth) {
fgColor ^= AttrReverse
}
// Don't display empty characters
if ch == "" {
ch = " "
}
tcellSetCell(v.x0+x+1, v.y0+y+1, ch, fgColor, bgColor, v.outMode)
}
// SetCursor sets the cursor position of the view at the given point,
// relative to the view. It is allowed to set the position to a point outside
// the visible portion of the view, or even outside the content of the view.
// Clients are responsible for clamping to valid positions.
func (v *View) SetCursor(x, y int) {
v.cx = x
v.cy = y
}
func (v *View) SetCursorX(x int) {
v.cx = x
}
func (v *View) SetCursorY(y int) {
v.cy = y
}
// Cursor returns the cursor position of the view.
func (v *View) Cursor() (x, y int) {
return v.cx, v.cy
}
func (v *View) CursorX() int {
return v.cx
}
func (v *View) CursorY() int {
return v.cy
}
// SetOrigin sets the origin position of the view's internal buffer,
// so the buffer starts to be printed from this point, which means that
// it is linked with the origin point of view. It can be used to
// implement Horizontal and Vertical scrolling with just incrementing
// or decrementing ox and oy.
func (v *View) SetOrigin(x, y int) {
v.SetOriginX(x)
v.SetOriginY(y)
}
func (v *View) SetOriginX(x int) {
if x < 0 {
x = 0
}
v.ox = x
}
func (v *View) SetOriginY(y int) {
if y < 0 {
y = 0
}
v.oy = y
}
// Origin returns the origin position of the view.
func (v *View) Origin() (x, y int) {
return v.OriginX(), v.OriginY()
}
func (v *View) OriginX() int {
return v.ox
}
func (v *View) OriginY() int {
return v.oy
}
// SetWritePos sets the write position of the view's internal buffer.
// So the next Write call would write directly to the specified position.
func (v *View) SetWritePos(x, y int) {
if x < 0 {
x = 0
}
if y < 0 {
y = 0
}
v.buf.wx = x
v.buf.wy = y
// Changing the write position makes a pending newline obsolete
v.buf.pendingNewline = false
}
// WritePos returns the current write position of the view's internal buffer.
func (v *View) WritePos() (x, y int) {
return v.buf.wx, v.buf.wy
}
// SetReadPos sets the read position of the view's internal buffer.
// So the next Read call would read from the specified position.
func (v *View) SetReadPos(x, y int) {
if x < 0 {
x = 0
}
if y < 0 {
y = 0
}
v.readBuffer = nil
v.rx = x
v.ry = y
}
// ReadPos returns the current read position of the view's internal buffer.
func (v *View) ReadPos() (x, y int) {
return v.rx, v.ry
}
// makeWriteable creates empty cells if required to make position (x, y) writeable.
func (b *viewBuffer) makeWriteable(x, y int) {
// TODO: make this more efficient
// line `y` must be index-able (that's why `<=`)
for len(b.lines) <= y {
if cap(b.lines) > len(b.lines) {
newLen := cap(b.lines)
if newLen > y {
newLen = y + 1
}
b.lines = b.lines[:newLen]
} else {
b.lines = append(b.lines, lineType{})
}
}
// cell `x` need not be index-able (that's why `<`)
// append should be used by `lines[y]` user if he wants to write beyond `x`
for len(b.lines[y].cells) < x {
if cap(b.lines[y].cells) > len(b.lines[y].cells) {
newLen := cap(b.lines[y].cells)
if newLen > x {
newLen = x
}
b.lines[y].cells = b.lines[y].cells[:newLen]
} else {
b.lines[y].cells = append(b.lines[y].cells, cell{})
}
}
}
// writeCells copies []cell to (b.wx, b.wy), and advances b.wx accordingly.
// !!! caller MUST ensure that specified location (x, y) is writeable by calling makeWriteable
func (b *viewBuffer) writeCells(cells []cell) {
var newLen int
// use maximum len available
line := b.lines[b.wy].cells[:cap(b.lines[b.wy].cells)]
maxCopy := len(line) - b.wx
if maxCopy < len(cells) {
copy(line[b.wx:], cells[:maxCopy])
line = append(line, cells[maxCopy:]...)
newLen = len(line)
} else { // maxCopy >= len(cells)
copy(line[b.wx:], cells)
newLen = b.wx + len(cells)
if newLen < len(b.lines[b.wy].cells) {
newLen = len(b.lines[b.wy].cells)
}
}
b.lines[b.wy].cells = line[:newLen]
b.wx += len(cells)
}
// Write appends a byte slice into the view's internal buffer. Because
// View implements the io.Writer interface, it can be passed as parameter
// of functions like fmt.Fprintf, fmt.Fprintln, io.Copy, etc. Clear must
// be called to clear the view's buffer.
func (v *View) Write(p []byte) (n int, err error) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.write(p)
return len(p), nil
}
func (v *View) write(p []byte) {
// An async re-render builds into the off-screen buffer (see View.offscreen)
// until it swaps in; until then the displayed buffer, and so everything
// readers see, is left untouched.
if v.offscreen != nil {
v.offscreen.write(v, p)
return
}
v.markViewLinesDirty()
// write only ever touches lines from v.buf.wy onwards, so any cached wrapping
// below that stays valid.
v.firstDirtyLine = min(v.firstDirtyLine, v.buf.wy)
v.clearHover()
v.buf.write(v, p)
v.updateSearchPositions()
}
// write parses p into cells and appends them to the buffer at its write cursor.
// It only touches the buffer; the View wrapper above handles display-side
// effects (tainting, hover, search). v supplies render config (Editable, colors,
// width, tab width, hyperlink auto-rendering).
func (b *viewBuffer) write(v *View, p []byte) {
// Fill with empty cells, if writing outside current view buffer
b.makeWriteable(b.wx, b.wy)
finishLine := func() {
b.autoRenderHyperlinksInCurrentLine(v)
// A record that reached the line's end without covering a cell still
// belongs to the line: an orphan (see escapeInterpreter.orphanedMetadata),
// or the record of a changed line that is empty, which a renderer emits
// with nothing but the newline after it. Give each a cell of its own, so
// that the line is still recognizable as the diff line it renders rather
// than as nothing at all.
for _, payload := range b.ei.takeOrphanedMetadata() {
b.writeCells([]cell{{metadata: payload}})
}
if b.ei.metadata.Len() > 0 && !b.ei.metadataConsumed {
b.writeCells([]cell{{metadata: b.ei.metadata.String()}})
b.ei.metadataConsumed = true
}
}
advanceToNextLine := func() {
b.wx = 0
b.wy++
if b.wy >= len(b.lines) {
b.lines = append(b.lines, lineType{})
}
// An OSC 1717 record describes the line it precedes and is never
// closed, so it stops applying at the line's end; a renderer emits a
// fresh one for each line it has something to say about.
b.ei.metadata.Reset()
}
if b.pendingNewline {
advanceToNextLine()
b.ei.notifyRowAdvance()
b.pendingNewline = false
}
until := len(p)
if !v.Editable && until > 0 && p[until-1] == '\n' {
b.pendingNewline = true
until--
}
state := -1
var chr []byte
var width int
remaining := p[:until]
for len(remaining) > 0 {
chr, remaining, width, state = uniseg.FirstGraphemeCluster(remaining, state)
switch {
case characterEquals(chr, '\n') || isCRLF(chr):
finishLine()
advanceToNextLine()
b.ei.notifyRowAdvance()
case characterEquals(chr, '\r'):
finishLine()
b.wx = 0
b.ei.notifyColumnReset()
default:
truncateLine, cells := b.parseInput(v, chr, width, b.wx, b.wy)
if cd, ok := b.ei.instruction.(cursorDown); ok {
b.ei.instructionRead()
for range cd.n {
b.autoRenderHyperlinksInCurrentLine(v)
advanceToNextLine()
}
}
if cells == nil {
continue
}
b.writeCells(cells)
if truncateLine {
b.lines[b.wy].cells = b.lines[b.wy].cells[:b.wx]
}
// Soft-wrap tracking. truncateLine is true exactly when the
// cells are from \x1b[K filling to end of line — ConPTY
// doesn't advance the cursor for that, so we shouldn't count
// it toward wraps either.
if !truncateLine {
totalWidth := 0
for _, c := range cells {
totalWidth += c.width
}
b.ei.notifyCellsWritten(totalWidth)
}
}
}
if b.pendingNewline {
finishLine()
} else {
b.autoRenderHyperlinksInCurrentLine(v)
}
}
// exported functions use the mutex. Non-exported functions are for internal use
// and a calling function should use a mutex
func (v *View) WriteString(s string) {
_, _ = v.Write([]byte(s))
}
func (v *View) writeString(s string) {
v.write([]byte(s))
}
var linkStartChars = []string{"h", "t", "t", "p", "s", ":", "/", "/"}
func findLinkStart(line []cell) int {
for i := range len(line) - len(linkStartChars) {
for j := range linkStartChars {
if line[i+j].chr != linkStartChars[j] {
break
}
if j == len(linkStartChars)-1 {
return i
}
}
}
return -1
}
// We need a heuristic to find the end of a hyperlink. Searching for the
// first character that is not a valid URI character is not quite good
// enough, because in markdown it's common to have a hyperlink followed by a
// ')', so we want to stop there. Hopefully URLs containing ')' are uncommon
// enough that this is not a problem.
var lineEndCharacters = map[string]bool{
"": true,
" ": true,
"\n": true,
">": true,
"\"": true,
")": true,
}
func (b *viewBuffer) autoRenderHyperlinksInCurrentLine(v *View) {
if !v.AutoRenderHyperLinks {
return
}
line := b.lines[b.wy].cells
start := 0
for {
linkStart := findLinkStart(line[start:])
if linkStart == -1 {
break
}
linkStart += start
var link strings.Builder
linkEnd := linkStart
for ; linkEnd < len(line); linkEnd++ {
if _, ok := lineEndCharacters[line[linkEnd].chr]; ok {
break
}
link.WriteString(line[linkEnd].chr)
}
for i := linkStart; i < linkEnd; i++ {
b.lines[b.wy].cells[i].hyperlink = link.String()
}
start = linkEnd
}
}
// parseInput parses char by char the input written to the View. It returns nil
// while processing ESC sequences. Otherwise, it returns a cell slice that
// contains the processed data.
func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bool, []cell) {
cells := []cell{}
truncateLine := false
isEscape, err := b.ei.parseOne(ch)
// A record that the next one superseded before any cell took it still
// belongs to this line (see escapeInterpreter.orphanedMetadata); give each
// a cell of its own, in the order they were emitted, ahead of whatever this
// character produces.
for _, payload := range b.ei.takeOrphanedMetadata() {
cells = append(cells, cell{metadata: payload})
}
if err != nil {
for _, chr := range b.ei.characters() {
c := cell{
fgColor: v.FgColor,
bgColor: v.BgColor,
chr: chr,
width: uniseg.StringWidth(chr),
}
cells = append(cells, c)
}
b.ei.reset()
} else {
repeatCount := 1
if _, ok := b.ei.instruction.(eraseInLineFromCursor); ok {
// Discard any old content past the cursor and record the
// fill colors so draw() paints the trailing area with them.
// This extends the bg to the right edge in both the
// content-fits and content-wraps cases — for the latter,
// the metadata is what reaches every wrapped segment past
// the last word.
b.ei.instructionRead()
truncateLine = true
b.lines[b.wy].trailingFillAttributes = &trailingFillAttributes{
fg: b.ei.curFgColor,
bg: b.ei.curBgColor,
}
return truncateLine, cells
} else if cf, ok := b.ei.instruction.(cursorForward); ok {
// emit `n` space cells under the parser-tracked SGR — used
// to materialize ConPTY's compressed runs of spaces (which
// it emits as ECH+CUF instead of literal whitespace).
b.ei.instructionRead()
repeatCount = cf.n
ch = []byte{' '}
width = 1
} else if isEscape {
// the escape itself outputs nothing, but any cells carrying an
// orphaned record still need writing
if len(cells) == 0 {
return truncateLine, nil
}
return truncateLine, cells
} else if characterEquals(ch, '\t') {
// fill tab-sized space
tabWidth := v.TabWidth
if tabWidth < 1 {
tabWidth = 4
}
ch = []byte{' '}
width = 1
repeatCount = tabWidth - (x % tabWidth)
}
c := cell{
fgColor: b.ei.curFgColor,
bgColor: b.ei.curBgColor,
hyperlink: b.ei.hyperlink.String(),
metadata: b.ei.metadata.String(),
chr: string(ch),
width: width,
}
if c.metadata != "" {
b.ei.metadataConsumed = true
}
for range repeatCount {
cells = append(cells, c)
}
}
return truncateLine, cells
}
// Read reads data into p from the current reading position set by SetReadPos.
// It returns the number of bytes read into p.
// At EOF, err will be io.EOF.
func (v *View) Read(p []byte) (n int, err error) {
buffer := make([]byte, utf8.UTFMax)
offset := 0
if v.readBuffer != nil {
copy(p, v.readBuffer)
if len(v.readBuffer) >= len(p) {
if len(v.readBuffer) > len(p) {
v.readBuffer = v.readBuffer[len(p):]
}
return len(p), nil
}
v.readBuffer = nil
}
for v.ry < len(v.buf.lines) {
for v.rx < len(v.buf.lines[v.ry].cells) {
s := v.buf.lines[v.ry].cells[v.rx].chr
count := len(s)
copy(p[offset:], s)
v.rx++
newOffset := offset + count
if newOffset >= len(p) {
if newOffset > len(p) {
v.readBuffer = buffer[newOffset-len(p):]
}
return len(p), nil
}
offset += count
}
v.rx = 0
v.ry++
}
return offset, io.EOF
}
// only use this if the calling function has a lock on writeMutex
func (v *View) clear() {
v.rewind()
v.buf.lines = nil
v.clearViewLines()
// Abandon any in-progress off-screen render: a synchronous SetContent/Clear
// is taking over the displayed buffer, so writes must go there, not into a
// stale off-screen buffer left by a stopped task.
v.offscreen = nil
// Likewise release any held scrollbar height: the new content is defined
// synchronously (e.g. a string render superseding a still-loading diff), so
// there's no async growth left to smooth over and the scrollbar should track
// the new content directly.
v.scrollbarHeightFloor = 0
}
// Clear empties the view's internal buffer.
// And resets reading and writing offsets.
func (v *View) Clear() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.clear()
}
func (v *View) SetContent(str string) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.clear()
v.writeString(str)
}
func (v *View) CopyContent(from *View) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
// A background task may be streaming output into the source view's buffer
// via Write, so read it under its own lock. The source is always a
// different view than the destination — its callers hand content from one
// view to another — and no other code holds two view write locks at once, so
// this can't deadlock.
from.writeMutex.Lock()
defer from.writeMutex.Unlock()
v.clear()
// Clone the row slices rather than sharing them: the source view stays
// live (its streaming task keeps appending rows, and refreshViewLinesIfNeeded
// fills each row's wrapping cache in place via &lines[i]), so sharing the
// backing arrays would race those writes against this view's own rendering.
// This is a shallow clone -- the per-row cell data is immutable once written
// and stays shared, so the cost is proportional to the number of rows, not
// their contents.
v.buf.lines = slices.Clone(from.buf.lines)
v.viewLines = slices.Clone(from.viewLines)
v.SetOriginX(from.ox)
v.SetOriginY(from.oy)
v.cx = from.cx
v.cy = from.cy
}
// Rewind sets read and write pos to (0, 0).
func (v *View) Rewind() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.rewind()
}
// similar to Rewind but clears lines. Also similar to Clear but doesn't reset
// viewLines
func (v *View) Reset() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.rewind()
v.buf.lines = nil
// As in clear(): abandon any in-progress off-screen render so writes after a
// reset go to the displayed buffer.
v.offscreen = nil
}
// BeginOffscreenRender starts building a re-render into an off-screen buffer.
// Until SwapInOffscreenRender promotes it, writes go to that buffer and the
// displayed buffer — what every reader sees — is left as it was. This is how an
// async re-render avoids exposing a half-written buffer: it accumulates
// off-screen and swaps in once it has read enough to paint.
func (v *View) BeginOffscreenRender() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
ei := newEscapeInterpreter(v.outMode)
// The screen width content is wrapped at is render configuration set by
// SetContentWidth, not per-buffer state, so the off-screen buffer's parser
// needs it too — otherwise it counts no soft wraps and cursor-positioning
// escapes land on the wrong rows.
ei.screenColMax = v.buf.ei.screenColMax
v.offscreen = &viewBuffer{ei: ei}
}
// SwapInOffscreenRender promotes the off-screen buffer (see BeginOffscreenRender)
// to the displayed buffer in one step, so the view jumps straight from the
// previous render to the new one with no half-written frame. Writes after this
// append to the now-displayed buffer directly. It is a no-op if no off-screen
// render is in progress, so it is safe to call more than once (e.g. again at EOF
// after an earlier paint already swapped).
func (v *View) SwapInOffscreenRender() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if v.offscreen == nil {
return
}
v.buf = v.offscreen
v.offscreen = nil
v.markViewLinesDirty()
v.clearHover()
}
// FreezeScrollbarHeight records the view's current content height so the
// scrollbar keeps that size while a re-render loads, instead of shrinking and
// snapping back as the partially-loaded content streams in past the first paint
// (see scrollbarHeightFloor). Call it when a load begins, while the view still
// shows the previous render; UnfreezeScrollbarHeight clears it when the load
// ends.
func (v *View) FreezeScrollbarHeight() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshViewLinesIfNeeded()
v.scrollbarHeightFloor = len(v.viewLines)
}
// UnfreezeScrollbarHeight clears the height held by FreezeScrollbarHeight, so
// the scrollbar tracks the view's content directly again. Call it when a load
// ends.
func (v *View) UnfreezeScrollbarHeight() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.scrollbarHeightFloor = 0
}
// scrollbarContentHeight is the view-line height the scrollbar is sized from.
// While a re-render is loading it is held at the height the view had when the
// load began (see FreezeScrollbarHeight), so the thumb doesn't shrink and jump
// as partially-loaded content streams in.
func (v *View) scrollbarContentHeight() int {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshViewLinesIfNeeded()
return max(len(v.viewLines), v.scrollbarHeightFloor)
}
func (v *View) rewind() {
v.buf.ei.reset()
v.buf.ei.resetScreenCursor()
v.SetReadPos(0, 0)
v.SetWritePos(0, 0)
}
func containsUpcaseChar(str string) bool {
for _, ch := range str {
if unicode.IsUpper(ch) {
return true
}
}
return false
}
func stringToGraphemes(s string) []string {
var graphemes []string
state := -1
for s != "" {
var chr string
chr, s, _, state = uniseg.FirstGraphemeClusterInString(s, state)
graphemes = append(graphemes, chr)
}
return graphemes
}
func (v *View) updateSearchPositions() {
if v.searcher.searchString != "" {
var normalizeRune func(s string) string
var normalizedSearchStr string
// if we have any uppercase characters we'll do a case-sensitive search
if containsUpcaseChar(v.searcher.searchString) {
normalizeRune = func(s string) string { return s }
normalizedSearchStr = v.searcher.searchString
} else {
normalizeRune = strings.ToLower
normalizedSearchStr = strings.ToLower(v.searcher.searchString)
}
searchStrGraphemes := stringToGraphemes(normalizedSearchStr)
v.searcher.searchPositions = []SearchPosition{}
searchPositionsForLine := func(line []cell, y int) []SearchPosition {
var result []SearchPosition
searchStringWidth := uniseg.StringWidth(v.searcher.searchString)
x := 0
for startIdx, cell := range line {
found := true
for i, c := range searchStrGraphemes {
if len(line)-1 < startIdx+i {
found = false
break
}
if normalizeRune(line[startIdx+i].chr) != c {
found = false
break
}
}
if found {
result = append(result, SearchPosition{XStart: x, XEnd: x + searchStringWidth, Y: y})
}
x += cell.width
}
return result
}
if v.searcher.modelSearchResults != nil {
for _, result := range v.searcher.modelSearchResults {
// This code only works when v.Wrap is false.
if result.Y >= len(v.buf.lines) {
break
}
// If a view line exists for this line index:
if v.buf.lines[result.Y].cells != nil {
// search this view line for the search string
positions := searchPositionsForLine(v.buf.lines[result.Y].cells, result.Y)
if len(positions) > 0 {
// If we found any occurrences, add them
v.searcher.searchPositions = append(v.searcher.searchPositions, positions...)
} else {
// Otherwise, the search string was found in the model
// but not in the view line; this can happen if the view
// renders only truncated versions of the model strings.
// In this case, add one search position with what the
// model search function returned.
v.searcher.searchPositions = append(v.searcher.searchPositions, result)
}
} else {
// We don't have a view line for this line index. Add a
// searchPosition anyway, just for the sake of being able to
// show the "n of m" search status. The X positions don't
// matter in this case.
v.searcher.searchPositions = append(v.searcher.searchPositions, SearchPosition{XStart: -1, XEnd: -1, Y: result.Y})
}
}
} else {
v.refreshViewLinesIfNeeded()
for y, line := range v.viewLines {
v.searcher.searchPositions = append(v.searcher.searchPositions, searchPositionsForLine(line.line, y)...)
}
}
}
}
// IsTainted tells us if the view is tainted
func (v *View) IsTainted() bool {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return v.tainted
}
func (v *View) NeedsRedraw() bool {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return v.needsRedraw
}
// draw re-draws the view's contents.
func (v *View) draw(isWindowFocused bool) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if !v.Visible {
return
}
defer func() { v.needsRedraw = false }()
v.clearRunes()
maxX, maxY := v.InnerSize()
if v.Wrap {
if maxX == 0 {
return
}
v.SetOriginX(0)
}
v.refreshViewLinesIfNeeded()
visibleViewLinesHeight := v.viewLineLengthIgnoringTrailingBlankLines()
if v.Autoscroll && visibleViewLinesHeight > maxY {
v.SetOriginY(visibleViewLinesHeight - maxY)
}
if len(v.viewLines) == 0 {
return
}
start := v.oy
if start > len(v.viewLines)-1 {
start = len(v.viewLines) - 1
}
emptyCell := cell{chr: " ", width: 1, fgColor: ColorDefault, bgColor: ColorDefault}
gutterWidth := v.inclusionGutterWidth()
for y, vline := range v.viewLines[start:] {
if y >= maxY {
break
}
// Decide the colors used for cells past the end of vline.line:
// the source line's trailingFillAttributes (set by '\x1b[K') if
// any, otherwise plain defaults.
trailingCell := emptyCell
if attrs := vline.trailingFillAttributes; attrs != nil {
trailingCell.fgColor = attrs.fg
trailingCell.bgColor = attrs.bg
}
// The inclusion gutter is blank but for the marker on a marked line, and the
// content begins after it. The blanks go through setCharacter like everything
// else, so that a selection reaching the left edge covers the gutter too.
for gx := range gutterWidth {
v.setCharacter(gx, y, " ", v.FgColor, v.BgColor, isWindowFocused)
}
if gutterWidth > 0 && vline.linesY < len(v.inclusionGutterMarks) && v.inclusionGutterMarks[vline.linesY] {
v.setCharacter(0, y, v.InclusionGutterMarker, v.InclusionGutterMarkerColor, v.BgColor, isWindowFocused)
}
// x tracks the current x position in the view, and cellIdx tracks the
// index of the cell. If we print a double-sized rune, we increment cellIdx
// by one but x by two.
x := gutterWidth - v.ox
cellIdx := 0
var c cell
for x < maxX {
if x < 0 {
if cellIdx < len(vline.line) {
x += uniseg.StringWidth(vline.line[cellIdx].chr)
cellIdx++
continue
}
// no more characters to write so we're only going to be printing empty cells
// past this point
x = gutterWidth
}
// if we're out of cells to write, we'll just print empty cells.
if cellIdx > len(vline.line)-1 {
c = trailingCell
} else {
c = vline.line[cellIdx]
}
fgColor := c.fgColor
if fgColor == ColorDefault {
fgColor = v.FgColor
}
bgColor := c.bgColor
if bgColor == ColorDefault {
bgColor = v.BgColor
}
if c.hyperlink != "" && !v.UnderlineHyperLinksOnlyOnHover {
fgColor |= AttrUnderline
}
v.setCharacter(x, y, c.chr, fgColor, bgColor, isWindowFocused)
x += c.width
cellIdx++
}
}
}
func (v *View) refreshViewLinesIfNeeded() {
if !v.tainted {
return
}
wrap := 0
if v.Wrap {
// The inclusion gutter, while it is shown, takes its columns out of the width
// the content has to wrap in.
wrap = max(0, v.InnerWidth()-v.inclusionGutterWidth())
}
lineIdx := 0
lines := v.buf.lines
for i := range lines {
line := &lines[i]
// Reuse the previously wrapped result for lines that haven't changed
// since the last refresh (i.e. below firstDirtyLine) and were wrapped at
// the current width. Wrapping is expensive and this loop runs on every
// scroll event, so only the lines that were actually just read (or
// re-highlighted) should be wrapped afresh.
if line.wrappedCells == nil || line.wrappedColumns != wrap || i >= v.firstDirtyLine {
line.wrappedCells = lineWrap(line.cells, wrap)
line.wrappedColumns = wrap
}
ls := line.wrappedCells
for j := range ls {
// Per-segment trailing fill. When the source line opted in
// via '\x1b[K', the LAST wrapped segment uses those colors
// directly; earlier segments use the colors of their own
// last cell, so the trailing area matches the bg active
// where that segment ended rather than bleeding the
// '\x1b[K' bg back across color changes in the line.
var attrs *trailingFillAttributes
if line.trailingFillAttributes != nil {
if j == len(ls)-1 {
attrs = line.trailingFillAttributes
} else if len(ls[j]) > 0 {
last := ls[j][len(ls[j])-1]
attrs = &trailingFillAttributes{fg: last.fgColor, bg: last.bgColor}
}
}
vline := viewLine{
linesX: j, linesY: i, line: ls[j],
trailingFillAttributes: attrs,
}
if lineIdx > len(v.viewLines)-1 {
v.viewLines = append(v.viewLines, vline)
} else {
v.viewLines[lineIdx] = vline
}
lineIdx++
}
}
v.firstDirtyLine = len(lines)
// Truncate any entries left over from a previous, longer render. An async
// re-render builds its content off-screen and swaps it in whole (see
// View.offscreen), so the buffer this rebuilds from is always a complete
// render — there is no half-loaded shorter buffer whose tail we'd need to
// keep showing to avoid a flicker, and a leftover tail would just be stale
// lines mapping to the wrong buffer rows.
v.viewLines = v.viewLines[:lineIdx]
v.tainted = false
}
// if autoscroll is enabled but we only have a single row of cells shown to the
// user, we don't want to scroll to the final line if it contains no text. So
// this tells us the view lines height when we ignore any trailing blank lines
func (v *View) viewLineLengthIgnoringTrailingBlankLines() int {
for i := len(v.viewLines) - 1; i >= 0; i-- {
if len(v.viewLines[i].line) > 0 {
return i + 1
}
}
return 0
}
func (v *View) isPatternMatchedRune(x, y int) (bool, bool) {
for i, pos := range v.searcher.searchPositions {
adjustedY := y + v.oy
adjustedX := x + v.ox
if adjustedY == pos.Y && adjustedX >= pos.XStart && adjustedX < pos.XEnd {
return true, i == v.searcher.currentSearchIndex
}
}
return false, false
}
func (v *View) isHoveredHyperlink(x, y int) bool {
if v.UnderlineHyperLinksOnlyOnHover && v.hoveredHyperlink != nil {
adjustedY := y + v.oy
adjustedX := x + v.ox
return adjustedY == v.hoveredHyperlink.Y && adjustedX >= v.hoveredHyperlink.XStart && adjustedX < v.hoveredHyperlink.XEnd
}
return false
}
// realPosition returns the position in the internal buffer corresponding to the
// point (x, y) of the view.
func (v *View) realPosition(vx, vy int) (x, y int, ok bool) {
vx = v.ox + vx
vy = v.oy + vy
if vx < 0 || vy < 0 {
return 0, 0, false
}
if len(v.viewLines) == 0 {
return vx, vy, true
}
if vy < len(v.viewLines) {
vline := v.viewLines[vy]
x = vline.linesX + vx
y = vline.linesY
} else {
vline := v.viewLines[len(v.viewLines)-1]
x = vx
y = vline.linesY + vy - len(v.viewLines) + 1
}
return x, y, true
}
// clearRunes erases all the cells in the view.
func (v *View) clearRunes() {
maxX, maxY := v.InnerSize()
for x := range maxX {
for y := range maxY {
tcellSetCell(v.x0+x+1, v.y0+y+1, " ", v.FgColor, v.BgColor, v.outMode)
}
}
}
// BufferLines returns the lines in the view's internal
// buffer.
func (v *View) BufferLines() []string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
lines := make([]string, len(v.buf.lines))
for i, l := range v.buf.lines {
lines[i] = l.cells.String()
}
return lines
}
// MarkedLines returns the lines of the view's content that the inclusion gutter is
// marking (see SetInclusionGutter), in the order they appear. Empty while the gutter
// is hidden.
func (v *View) MarkedLines() []string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if !v.showInclusionGutter {
return nil
}
lines := []string{}
for i, line := range v.buf.lines {
if i < len(v.inclusionGutterMarks) && v.inclusionGutterMarks[i] {
lines = append(lines, line.cells.String())
}
}
return lines
}
// DiffLineContent is what one line of a rendered diff offers to a reader trying
// to recover which line of which file it came from: the line's text, which can
// be parsed as a unified diff when the rendering preserves one, and the OSC 1717
// records a diff renderer attached to it, which state it outright.
type DiffLineContent struct {
Text string
// The distinct OSC 1717 payloads carried by the line's cells, in
// left-to-right order. A single-column rendering tags every cell of a line
// with the same payload, so there is one; a side-by-side rendering tags
// each side separately, so a line showing a deletion beside the addition
// that replaces it carries both.
Metadata []string
}
// DiffLineContents returns the per-line material a diff-line reader works from
// (see DiffLineContent), indexed by unwrapped buffer line. Text and records are
// snapshotted in a single locked pass, so they stay consistent with each other
// and with the buffer they came from even while a re-render rebuilds it.
func (v *View) DiffLineContents() []DiffLineContent {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return diffLineContentsFrom(v.buf, 0)
}
// OffscreenDiffLineContents is DiffLineContents for the content of a re-render in
// progress (see BeginOffscreenRender), which is what a reader that wants to say
// where the new content should be shown has to work from: it has to answer before
// the swap, since after it the content is already on screen. Returns nil when no
// re-render is underway.
func (v *View) OffscreenDiffLineContents() []DiffLineContent {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if v.offscreen == nil {
return nil
}
return diffLineContentsFrom(v.offscreen, 0)
}
// OffscreenDiffLineContentsFrom is OffscreenDiffLineContents restricted to the lines
// from index `from` on (so result[0] is buffer line `from`). It lets a reader that
// follows a re-render as it loads look at each line once, rather than snapshotting
// the whole buffer again on every line — the difference between an O(n) and an O(n²)
// scan of a large diff. Returns nil when no re-render is underway, or when `from` is
// past the lines read so far.
func (v *View) OffscreenDiffLineContentsFrom(from int) []DiffLineContent {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if v.offscreen == nil || from < 0 || from >= len(v.offscreen.lines) {
return nil
}
return diffLineContentsFrom(v.offscreen, from)
}
// OffscreenLineCount returns the number of unwrapped lines a re-render in progress
// has read so far, or 0 when none is underway. It tells a reader waiting for a
// particular line, cheaply, when a screenful below it has arrived too — so that the
// swap shows that line with content under it rather than at the bottom edge of a
// half-filled view.
func (v *View) OffscreenLineCount() int {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if v.offscreen == nil {
return 0
}
return len(v.offscreen.lines)
}
func diffLineContentsFrom(buf *viewBuffer, from int) []DiffLineContent {
lines := buf.lines[from:]
contents := make([]DiffLineContent, len(lines))
for i, line := range lines {
var metadata []string
for _, c := range line.cells {
if c.metadata != "" && !slices.Contains(metadata, c.metadata) {
metadata = append(metadata, c.metadata)
}
}
contents[i] = DiffLineContent{Text: line.cells.String(), Metadata: metadata}
}
return contents
}
// BufferLineForViewLine maps a view line index (which counts wrapped lines) to
// the index of the corresponding line in the unwrapped internal buffer (as
// returned by BufferLines). Several view lines map to the same buffer line when
// that line wraps. Returns false if the view line is out of range.
func (v *View) BufferLineForViewLine(y int) (int, bool) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return v.bufferLineForViewLine(y)
}
// ViewLineForBufferLine maps an unwrapped buffer line index to the index of the
// first view line that renders it — the inverse of BufferLineForViewLine, for
// turning a line found by examining the buffer into a line to scroll to or
// select. Returns false if the buffer line isn't rendered into any view line.
func (v *View) ViewLineForBufferLine(bufferLineIdx int) (int, bool) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshViewLinesIfNeeded()
first, _, ok := v.viewLineSpanOfBufferLine(bufferLineIdx)
return first, ok
}
// LastViewLineForBufferLine maps an unwrapped buffer line index to the index of
// the last view line that renders it, which for a line that doesn't wrap is the
// same as the first. It is where the far end of a range goes: a range is over
// buffer lines, so it has to cover the last one of them to its final segment
// rather than stopping where that line begins.
func (v *View) LastViewLineForBufferLine(bufferLineIdx int) (int, bool) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshViewLinesIfNeeded()
_, last, ok := v.viewLineSpanOfBufferLine(bufferLineIdx)
return last, ok
}
// Buffer returns a string with the contents of the view's internal
// buffer.
func (v *View) Buffer() string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return linesToString(v.buf.lines)
}
// ViewBufferLines returns the lines in the view's internal
// buffer that is shown to the user.
func (v *View) ViewBufferLines() []string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshViewLinesIfNeeded()
lines := make([]string, len(v.viewLines))
for i, l := range v.viewLines {
lines[i] = cells(l.line).String()
}
return lines
}
// LinesHeight is the count of view lines (i.e. lines excluding wrapping)
func (v *View) LinesHeight() int {
return len(v.buf.lines)
}
// ViewLinesHeight is the count of view lines (i.e. lines including wrapping)
func (v *View) ViewLinesHeight() int {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshViewLinesIfNeeded()
return len(v.viewLines)
}
// ViewBuffer returns a string with the contents of the view's buffer that is
// shown to the user.
func (v *View) ViewBuffer() string {
strs := make([]string, len(v.viewLines))
for i := range v.viewLines {
strs[i] = cells(v.viewLines[i].line).String()
}
return strings.Join(strs, "\n")
}
// Line returns a string with the line of the view's internal buffer
// at the position corresponding to the point (x, y).
func (v *View) Line(y int) (string, bool) {
_, y, ok := v.realPosition(0, y)
if !ok {
return "", false
}
if y < 0 || y >= len(v.buf.lines) {
return "", false
}
return v.buf.lines[y].cells.String(), true
}
// Word returns a string with the word of the view's internal buffer
// at the position corresponding to the point (x, y).
func (v *View) Word(x, y int) (string, bool) {
x, y, ok := v.realPosition(x, y)
if !ok {
return "", false
}
if x < 0 || y < 0 || y >= len(v.buf.lines) || x >= len(v.buf.lines[y].cells) {
return "", false
}
str := v.buf.lines[y].cells.String()
nl := strings.LastIndexFunc(str[:x], indexFunc)
if nl == -1 {
nl = 0
} else {
nl = nl + 1
}
nr := strings.IndexFunc(str[x:], indexFunc)
if nr == -1 {
nr = len(str)
} else {
nr = nr + x
}
return str[nl:nr], true
}
// indexFunc allows to split lines by words taking into account spaces
// and 0.
func indexFunc(r rune) bool {
return r == ' ' || r == 0
}
// SetLineFlash temporarily marks a view line without moving or changing the
// selection. The caller owns the lifetime and clears it with ClearLineFlash.
func (v *View) SetLineFlash(viewLine int) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.lineFlashY = viewLine
v.needsRedraw = true
}
func (v *View) ClearLineFlash() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.lineFlashY = -1
v.needsRedraw = true
}
func lineWrap(line []cell, columns int) [][]cell {
if columns == 0 {
return [][]cell{line}
}
var n int
var offset int
lastWhitespaceIndex := -1
lines := make([][]cell, 0, 1)
for i := range line {
currChr := line[i].chr
rw := uniseg.StringWidth(currChr)
n += rw
// if currChr == 'g' {
// panic(n)
// }
if n > columns {
// This code is convoluted but we've got comprehensive tests so feel free to do whatever you want
// to the code to simplify it so long as our tests still pass.
if currChr == " " {
// if the line ends in a space, we'll omit it. This means there'll be no
// way to distinguish between a clean break and a mid-word break, but
// I think it's worth it.
lines = append(lines, line[offset:i])
offset = i + 1
n = 0
} else if currChr == "-" {
// if the last character is hyphen and the width of line is equal to the columns
lines = append(lines, line[offset:i])
offset = i
n = rw
} else if lastWhitespaceIndex != -1 {
// if there is a space in the line and the line is not breaking at a space/hyphen
if line[lastWhitespaceIndex].chr == "-" {
// if break occurs at hyphen, we'll retain the hyphen
lines = append(lines, line[offset:lastWhitespaceIndex+1])
} else {
// if break occurs at space, we'll omit the space
lines = append(lines, line[offset:lastWhitespaceIndex])
}
// Either way, continue *after* the break
offset = lastWhitespaceIndex + 1
n = 0
for _, c := range line[offset : i+1] {
n += c.width
}
} else {
// in this case we're breaking mid-word
lines = append(lines, line[offset:i])
offset = i
n = rw
}
lastWhitespaceIndex = -1
} else if line[i].chr == " " || line[i].chr == "-" {
lastWhitespaceIndex = i
}
}
lines = append(lines, line[offset:])
return lines
}
func linesToString(lines []lineType) string {
str := make([]string, len(lines))
for i := range lines {
str[i] = lines[i].cells.String()
}
return strings.Join(str, "\n")
}
// GetClickedTabIndex tells us which tab was clicked
func (v *View) GetClickedTabIndex(x int) int {
if len(v.Tabs) <= 1 {
return 0
}
charX := len(v.TitlePrefix) + 1
if v.TitlePrefix != "" {
charX += 1
}
if x <= charX {
return -1
}
for i, tab := range v.Tabs {
charX += uniseg.StringWidth(tab)
if x <= charX {
return i
}
charX += uniseg.StringWidth(" - ")
if x <= charX {
return -1
}
}
return -1
}
func (v *View) SelectedLineIdx() int {
_, seletedLineIdx := v.SelectedPoint()
return seletedLineIdx
}
// IsLineVisible reports whether the given view line is one of those on screen.
func (v *View) IsLineVisible(viewLine int) bool {
return viewLine >= v.OriginY() && viewLine < v.OriginY()+v.InnerHeight()
}
// MiddleVisibleLineIdx returns the view line halfway down the visible content. It
// stands in for a cursor in a view that has none: of the lines on screen, the one in
// the middle is the likeliest to be the one being read.
func (v *View) MiddleVisibleLineIdx() int {
top := v.OriginY()
bottom := min(top+v.InnerHeight(), v.ViewLinesHeight())
return (top + bottom) / 2
}
// expected to only be used in tests
func (v *View) SelectedLine() string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
idx, ok := v.bufferLineForViewLine(v.SelectedLineIdx())
if !ok {
return ""
}
return v.lineContentAtIdx(idx)
}
// expected to only be used in tests
func (v *View) SelectedLines() []string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if len(v.buf.lines) == 0 {
return nil
}
startIdx, endIdx := v.SelectedLineRange()
lines := make([]string, 0, endIdx-startIdx+1)
previous := -1
for i := startIdx; i <= endIdx; i++ {
// The selection is in view lines, which count the segments a wrapped line
// is drawn as; a line the selection covers several segments of is still
// the one line it is.
idx, ok := v.bufferLineForViewLine(i)
if !ok || idx == previous {
continue
}
previous = idx
lines = append(lines, v.lineContentAtIdx(idx))
}
return lines
}
func (v *View) lineContentAtIdx(idx int) string {
return v.buf.lines[idx].cells.String()
}
// bufferLineForViewLine maps a view line index, which counts the wrapped
// segments of the lines it draws, to the index of the line of content it is a
// segment of. Only call this with a lock on writeMutex.
func (v *View) bufferLineForViewLine(y int) (int, bool) {
v.refreshViewLinesIfNeeded()
if y < 0 || y >= len(v.viewLines) {
return 0, false
}
return v.viewLines[y].linesY, true
}
func (v *View) SelectedPoint() (int, int) {
cx, cy := v.Cursor()
ox, oy := v.Origin()
return cx + ox, cy + oy
}
func (v *View) SelectedLineRange() (int, int) {
_, cy := v.Cursor()
_, oy := v.Origin()
start := cy + oy
if v.rangeSelectStartY == -1 {
return start, start
}
end := v.rangeSelectStartY
if start > end {
return end, start
}
return start, end
}
func (v *View) RenderTextArea() {
v.Clear()
fmt.Fprint(v, v.TextArea.GetContent())
cursorX, cursorY := v.TextArea.GetCursorXY()
prevOriginX, prevOriginY := v.Origin()
width, height := v.InnerWidth(), v.InnerHeight()
newViewCursorX, newOriginX := updatedCursorAndOrigin(prevOriginX, width, cursorX)
newViewCursorY, newOriginY := updatedCursorAndOrigin(prevOriginY, height, cursorY)
v.SetCursor(newViewCursorX, newViewCursorY)
v.SetOrigin(newOriginX, newOriginY)
}
func updatedCursorAndOrigin(prevOrigin int, size int, cursor int) (int, int) {
var newViewCursor int
newOrigin := prevOrigin
usableSize := size - 1
if cursor > prevOrigin+usableSize {
newOrigin = cursor - usableSize
newViewCursor = usableSize
} else if cursor < prevOrigin {
newOrigin = cursor
newViewCursor = 0
} else {
newViewCursor = cursor - prevOrigin
}
return newViewCursor, newOrigin
}
func (v *View) ClearTextArea() {
v.Clear()
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.TextArea.Clear()
v.SetOrigin(0, 0)
v.SetCursor(0, 0)
}
func (v *View) overwriteLines(y int, content string) {
// break by newline, then for each line, write it, then add that erase command
v.buf.wx = 0
v.buf.wy = y
v.clearViewLines()
lines := strings.ReplaceAll(content, "\n", "\x1b[K\n")
// If the last line doesn't end with a linefeed, add the erase command at
// the end too
if !strings.HasSuffix(lines, "\n") {
lines += "\x1b[K"
}
v.writeString(lines)
}
// only call this function if you don't care where v.buf.wx and v.buf.wy end up
func (v *View) OverwriteLines(y int, content string) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.overwriteLines(y, content)
}
// only call this function if you don't care where v.buf.wx and v.buf.wy end up
func (v *View) OverwriteLinesAndClearEverythingElse(lineCount int, y int, content string) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.setContentLineCount(lineCount)
v.overwriteLines(y, content)
for i := range y {
v.buf.lines[i] = lineType{}
}
for i := v.buf.wy + 1; i < len(v.buf.lines); i += 1 {
v.buf.lines[i] = lineType{}
}
}
func (v *View) setContentLineCount(lineCount int) {
if lineCount > 0 {
v.buf.makeWriteable(0, lineCount-1)
}
v.buf.lines = v.buf.lines[:lineCount]
}
// If the current search result is no longer visible after a scroll up, select the last search
// result that is visible in the view, if any, or the first one that is below the view if none is
// visible.
func (v *View) selectVisibleSearchResultAfterScrollUp() {
if !v.Highlight && len(v.searcher.searchPositions) != 0 {
windowBottom := v.oy + v.InnerHeight()
if v.searcher.searchPositions[v.searcher.currentSearchIndex].Y >= windowBottom {
newSearchIndex := v.searcher.currentSearchIndex
for newSearchIndex > 0 &&
v.searcher.searchPositions[newSearchIndex-1].Y >= v.oy {
newSearchIndex--
if v.searcher.searchPositions[newSearchIndex].Y < windowBottom {
break
}
}
if v.searcher.currentSearchIndex != newSearchIndex {
v.searcher.currentSearchIndex = newSearchIndex
v.renderSearchStatus(newSearchIndex, len(v.searcher.searchPositions))
}
}
}
}
// If the current search result is no longer visible after a scroll down, select the first search
// result that is visible in the view, if any, or the last one that is above the view if none is
// visible.
func (v *View) selectVisibleSearchResultAfterScrollDown() {
if !v.Highlight && len(v.searcher.searchPositions) != 0 {
if v.searcher.searchPositions[v.searcher.currentSearchIndex].Y < v.oy {
newSearchIndex := v.searcher.currentSearchIndex
windowBottom := v.oy + v.InnerHeight()
for newSearchIndex+1 < len(v.searcher.searchPositions) &&
v.searcher.searchPositions[newSearchIndex+1].Y < windowBottom {
newSearchIndex++
if v.searcher.searchPositions[newSearchIndex].Y >= v.oy {
break
}
}
if v.searcher.currentSearchIndex != newSearchIndex {
v.searcher.currentSearchIndex = newSearchIndex
v.renderSearchStatus(newSearchIndex, len(v.searcher.searchPositions))
}
}
}
}
func (v *View) ScrollUp(amount int) {
if amount > v.oy {
amount = v.oy
}
if amount != 0 {
v.SetOriginY(v.oy - amount)
v.cy += amount
v.clearHover()
v.selectVisibleSearchResultAfterScrollUp()
}
}
// ensures we don't scroll past the end of the view's content
func (v *View) ScrollDown(amount int) {
adjustedAmount := v.adjustDownwardScrollAmount(amount)
if adjustedAmount > 0 {
v.SetOriginY(v.oy + adjustedAmount)
v.cy -= adjustedAmount
v.clearHover()
v.selectVisibleSearchResultAfterScrollDown()
}
}
func (v *View) ScrollLeft(amount int) {
newOx := v.ox - amount
if newOx < 0 {
newOx = 0
}
if newOx != v.ox {
v.SetOriginX(newOx)
v.clearHover()
}
}
// not applying any limits to this
func (v *View) ScrollRight(amount int) {
v.SetOriginX(v.ox + amount)
v.clearHover()
}
func (v *View) adjustDownwardScrollAmount(scrollHeight int) int {
_, oy := v.Origin()
y := oy
if !v.CanScrollPastBottom {
sy := v.InnerHeight()
y += sy
}
scrollableLines := v.ViewLinesHeight() - y
if scrollableLines < 0 {
return 0
}
margin := v.scrollMargin()
if scrollableLines-margin < scrollHeight {
scrollHeight = scrollableLines - margin
}
if oy+scrollHeight < 0 {
return 0
}
return scrollHeight
}
// scrollMargin is about how many lines must still appear if you scroll
// all the way down. We'll subtract this from the total amount of scrollable lines
func (v *View) scrollMargin() int {
if v.CanScrollPastBottom {
// Setting to 2 because of the newline at the end of the file that we're likely showing.
// If we want to scroll past bottom outside the context of reading a file's contents,
// we should make this into a field on the view to be configured by the client.
// For now we're hardcoding it.
return 2
}
return 0
}
// Returns true if the view contains a line containing the given text with the given
// foreground color
func (v *View) ContainsColoredText(fgColor string, text string) bool {
for _, line := range v.buf.lines {
if containsColoredTextInLine(fgColor, text, line.cells) {
return true
}
}
return false
}
func containsColoredTextInLine(fgColorStr string, text string, line []cell) bool {
fgColor := tcell.GetColor(fgColorStr)
currentMatch := ""
for i := range line {
cell := line[i]
// stripping attributes by converting to and from hex
cellColor := tcell.NewHexColor(cell.fgColor.Hex())
if cellColor == fgColor {
currentMatch += cell.chr
} else if currentMatch != "" {
if strings.Contains(currentMatch, text) {
return true
}
currentMatch = ""
}
}
return strings.Contains(currentMatch, text)
}
func (v *View) onMouseMove(x int, y int) {
if v.Editable || !v.UnderlineHyperLinksOnlyOnHover {
return
}
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
// newCx and newCy are relative to the view port, i.e. to the visible area of the view
newCx := x - v.x0 - 1
newCy := y - v.y0 - 1
// newX and newY are relative to the view's content, independent of its scroll position
newX := newCx + v.ox
newY := newCy + v.oy
if newY >= 0 && newY <= len(v.viewLines)-1 && newX >= 0 && newX <= len(v.viewLines[newY].line)-1 {
if v.lastHoverPosition == nil || v.lastHoverPosition.x != newX || v.lastHoverPosition.y != newY {
v.hoveredHyperlink = v.findHyperlinkAt(newX, newY)
}
v.lastHoverPosition = &pos{x: newX, y: newY}
} else {
v.lastHoverPosition = nil
v.hoveredHyperlink = nil
}
}
// hyperlinkAt returns the hyperlink at the given position of the view's
// content, or an empty string if there is none.
func (v *View) hyperlinkAt(x, y int) string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if y < 0 || y >= len(v.viewLines) || x < 0 || x >= len(v.viewLines[y].line) {
return ""
}
return v.viewLines[y].line[x].hyperlink
}
func (v *View) findHyperlinkAt(x, y int) *SearchPosition {
linkStr := v.viewLines[y].line[x].hyperlink
if linkStr == "" {
return nil
}
xStart := x
for xStart > 0 && v.viewLines[y].line[xStart-1].hyperlink == linkStr {
xStart--
}
xEnd := x + 1
for xEnd < len(v.viewLines[y].line) && v.viewLines[y].line[xEnd].hyperlink == linkStr {
xEnd++
}
return &SearchPosition{XStart: xStart, XEnd: xEnd, Y: y}
}
func (v *View) clearHover() {
v.hoveredHyperlink = nil
v.lastHoverPosition = nil
}