mirror of
https://github.com/jesseduffield/lazygit.git
synced 2025-05-11 12:25:47 +02:00
1922 lines
46 KiB
Go
1922 lines
46 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 (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"sync"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/mattn/go-runewidth"
|
|
)
|
|
|
|
// 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
|
|
)
|
|
|
|
// 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
|
|
wx, wy int // Write() offsets
|
|
lines [][]cell // All the data
|
|
outMode OutputMode
|
|
// 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
|
|
|
|
// readBuffer is used for storing unread bytes
|
|
readBuffer []byte
|
|
|
|
// tained is true if the viewLines must be updated
|
|
tainted bool
|
|
|
|
// 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
|
|
|
|
// 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 is to avoid having an extra blank at the end of the view.
|
|
pendingNewline bool
|
|
|
|
// writeMutex protects locks the write process
|
|
writeMutex sync.Mutex
|
|
|
|
// ei is used to decode ESC sequences on Write
|
|
ei *escapeInterpreter
|
|
|
|
// 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 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 rune
|
|
|
|
// Overlaps describes which edges are overlapping with another view's edges
|
|
Overlaps byte
|
|
|
|
// If HasLoader is true, the message will be appended with a spinning loader animation
|
|
HasLoader bool
|
|
|
|
// IgnoreCarriageReturns tells us whether to ignore '\r' characters
|
|
IgnoreCarriageReturns bool
|
|
|
|
// ParentView is the view which catches events bubbled up from the given view if there's no matching handler
|
|
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 in the event of a view resize, or 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
|
|
func (v *View) clearViewLines() {
|
|
v.tainted = true
|
|
v.viewLines = nil
|
|
v.clearHover()
|
|
}
|
|
|
|
type searcher struct {
|
|
searchString string
|
|
searchPositions []SearchPosition
|
|
modelSearchResults []SearchPosition
|
|
currentSearchIndex int
|
|
onSelectItem func(int, int, int) error
|
|
}
|
|
|
|
func (v *View) SetOnSelectItem(onSelectItem func(int, int, int) error) {
|
|
v.searcher.onSelectItem = onSelectItem
|
|
}
|
|
|
|
func (v *View) gotoNextMatch() error {
|
|
if len(v.searcher.searchPositions) == 0 {
|
|
return nil
|
|
}
|
|
if v.searcher.currentSearchIndex >= len(v.searcher.searchPositions)-1 {
|
|
v.searcher.currentSearchIndex = 0
|
|
} else {
|
|
v.searcher.currentSearchIndex++
|
|
}
|
|
return v.SelectSearchResult(v.searcher.currentSearchIndex)
|
|
}
|
|
|
|
func (v *View) gotoPreviousMatch() error {
|
|
if len(v.searcher.searchPositions) == 0 {
|
|
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--
|
|
}
|
|
return v.SelectSearchResult(v.searcher.currentSearchIndex)
|
|
}
|
|
|
|
func (v *View) SelectSearchResult(index int) error {
|
|
itemCount := len(v.searcher.searchPositions)
|
|
if itemCount == 0 {
|
|
return nil
|
|
}
|
|
if index > itemCount-1 {
|
|
index = itemCount - 1
|
|
}
|
|
|
|
y := v.searcher.searchPositions[index].Y
|
|
|
|
v.FocusPoint(v.ox, y)
|
|
if v.searcher.onSelectItem != nil {
|
|
return v.searcher.onSelectItem(y, index, itemCount)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
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) error {
|
|
v.UpdateSearchResults(str, modelSearchResults)
|
|
|
|
if len(v.searcher.searchPositions) > 0 {
|
|
return v.SelectSearchResult(v.searcher.currentSearchIndex)
|
|
}
|
|
|
|
return v.searcher.onSelectItem(-1, -1, 0)
|
|
}
|
|
|
|
func (v *View) ClearSearch() {
|
|
v.searcher.clearSearch()
|
|
}
|
|
|
|
func (v *View) IsSearching() bool {
|
|
return v.searcher.searchString != ""
|
|
}
|
|
|
|
func (v *View) FocusPoint(cx int, cy int) {
|
|
v.writeMutex.Lock()
|
|
defer v.writeMutex.Unlock()
|
|
|
|
v.refreshViewLinesIfNeeded()
|
|
lineCount := len(v.viewLines)
|
|
if cy < 0 || cy > lineCount {
|
|
return
|
|
}
|
|
height := v.InnerHeight()
|
|
|
|
v.oy = calculateNewOrigin(cy, v.oy, lineCount, height)
|
|
v.cx = cx
|
|
v.cy = cy - v.oy
|
|
}
|
|
|
|
func (v *View) SetRangeSelectStart(rangeSelectStartY int) {
|
|
v.rangeSelectStartY = rangeSelectStartY
|
|
}
|
|
|
|
func (v *View) CancelRangeSelect() {
|
|
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.lines
|
|
line []cell
|
|
}
|
|
|
|
type cell struct {
|
|
chr rune
|
|
bgColor, fgColor Attribute
|
|
hyperlink string
|
|
}
|
|
|
|
type lineType []cell
|
|
|
|
// String returns a string from a given cell slice.
|
|
func (l lineType) String() string {
|
|
str := ""
|
|
for _, c := range l {
|
|
str += string(c.chr)
|
|
}
|
|
return str
|
|
}
|
|
|
|
// 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,
|
|
outMode: mode,
|
|
ei: newEscapeInterpreter(mode),
|
|
searcher: &searcher{},
|
|
TextArea: &TextArea{},
|
|
rangeSelectStartY: -1,
|
|
TabWidth: 4,
|
|
}
|
|
|
|
v.FgColor, v.BgColor = ColorDefault, ColorDefault
|
|
v.SelFgColor, v.SelBgColor = ColorDefault, ColorDefault
|
|
v.InactiveViewSelBgColor = ColorDefault
|
|
v.TitleColor, v.FrameColor = ColorDefault, ColorDefault
|
|
return v
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// setRune sets a rune 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) setRune(x, y int, ch rune, fgColor, bgColor Attribute) {
|
|
maxX, maxY := v.Size()
|
|
if x < 0 || x >= maxX || y < 0 || y >= maxY {
|
|
return
|
|
}
|
|
|
|
if v.Mask != 0 {
|
|
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)
|
|
}
|
|
|
|
if y >= rangeSelectStart && y <= rangeSelectEnd {
|
|
// 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 {
|
|
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
|
|
}
|
|
|
|
// Don't display NUL characters
|
|
if ch == 0 {
|
|
ch = ' '
|
|
}
|
|
|
|
tcellSetCell(v.x0+x+1, v.y0+y+1, ch, fgColor, bgColor, v.outMode)
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// SetCursor sets the cursor position of the view at the given point,
|
|
// relative to the view. It checks if the position is valid.
|
|
func (v *View) SetCursor(x, y int) {
|
|
maxX, maxY := v.InnerSize()
|
|
if x < 0 || x >= maxX || y < 0 || y >= maxY {
|
|
return
|
|
}
|
|
v.cx = x
|
|
v.cy = y
|
|
}
|
|
|
|
func (v *View) SetCursorX(x int) {
|
|
maxX := v.InnerWidth()
|
|
if x < 0 || x >= maxX {
|
|
return
|
|
}
|
|
v.cx = x
|
|
}
|
|
|
|
func (v *View) SetCursorY(y int) {
|
|
maxY := v.InnerHeight()
|
|
if y < 0 || y >= maxY {
|
|
return
|
|
}
|
|
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) {
|
|
if x < 0 {
|
|
x = 0
|
|
}
|
|
if y < 0 {
|
|
y = 0
|
|
}
|
|
|
|
v.ox = x
|
|
v.oy = 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.wx = x
|
|
v.wy = y
|
|
|
|
// Changing the write position makes a pending newline obsolete
|
|
v.pendingNewline = false
|
|
}
|
|
|
|
// WritePos returns the current write position of the view's internal buffer.
|
|
func (v *View) WritePos() (x, y int) {
|
|
return v.wx, v.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 (v *View) makeWriteable(x, y int) {
|
|
// TODO: make this more efficient
|
|
|
|
// line `y` must be index-able (that's why `<=`)
|
|
for len(v.lines) <= y {
|
|
if cap(v.lines) > len(v.lines) {
|
|
newLen := cap(v.lines)
|
|
if newLen > y {
|
|
newLen = y + 1
|
|
}
|
|
v.lines = v.lines[:newLen]
|
|
} else {
|
|
v.lines = append(v.lines, nil)
|
|
}
|
|
}
|
|
// 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(v.lines[y]) < x {
|
|
if cap(v.lines[y]) > len(v.lines[y]) {
|
|
newLen := cap(v.lines[y])
|
|
if newLen > x {
|
|
newLen = x
|
|
}
|
|
v.lines[y] = v.lines[y][:newLen]
|
|
} else {
|
|
v.lines[y] = append(v.lines[y], cell{})
|
|
}
|
|
}
|
|
}
|
|
|
|
// writeCells copies []cell to specified location (x, y)
|
|
// !!! caller MUST ensure that specified location (x, y) is writeable by calling makeWriteable
|
|
func (v *View) writeCells(x, y int, cells []cell) {
|
|
var newLen int
|
|
// use maximum len available
|
|
line := v.lines[y][:cap(v.lines[y])]
|
|
maxCopy := len(line) - x
|
|
if maxCopy < len(cells) {
|
|
copy(line[x:], cells[:maxCopy])
|
|
line = append(line, cells[maxCopy:]...)
|
|
newLen = len(line)
|
|
} else { // maxCopy >= len(cells)
|
|
copy(line[x:], cells)
|
|
newLen = x + len(cells)
|
|
if newLen < len(v.lines[y]) {
|
|
newLen = len(v.lines[y])
|
|
}
|
|
}
|
|
v.lines[y] = line[:newLen]
|
|
}
|
|
|
|
// 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.writeRunes(bytes.Runes(p))
|
|
|
|
return len(p), nil
|
|
}
|
|
|
|
func (v *View) WriteRunes(p []rune) {
|
|
v.writeMutex.Lock()
|
|
defer v.writeMutex.Unlock()
|
|
|
|
v.writeRunes(p)
|
|
}
|
|
|
|
// writeRunes copies slice of runes into internal lines buffer.
|
|
func (v *View) writeRunes(p []rune) {
|
|
v.tainted = true
|
|
v.clearHover()
|
|
|
|
// Fill with empty cells, if writing outside current view buffer
|
|
v.makeWriteable(v.wx, v.wy)
|
|
|
|
finishLine := func() {
|
|
v.autoRenderHyperlinksInCurrentLine()
|
|
if v.wx >= len(v.lines[v.wy]) {
|
|
v.writeCells(v.wx, v.wy, []cell{{
|
|
chr: 0,
|
|
fgColor: 0,
|
|
bgColor: 0,
|
|
}})
|
|
}
|
|
}
|
|
|
|
advanceToNextLine := func() {
|
|
v.wx = 0
|
|
v.wy++
|
|
if v.wy >= len(v.lines) {
|
|
v.lines = append(v.lines, nil)
|
|
}
|
|
}
|
|
|
|
if v.pendingNewline {
|
|
advanceToNextLine()
|
|
v.pendingNewline = false
|
|
}
|
|
|
|
until := len(p)
|
|
if !v.Editable && until > 0 && p[until-1] == '\n' {
|
|
v.pendingNewline = true
|
|
until--
|
|
}
|
|
|
|
for _, r := range p[:until] {
|
|
switch r {
|
|
case '\n':
|
|
finishLine()
|
|
advanceToNextLine()
|
|
case '\r':
|
|
finishLine()
|
|
v.wx = 0
|
|
default:
|
|
truncateLine, cells := v.parseInput(r, v.wx, v.wy)
|
|
if cells == nil {
|
|
continue
|
|
}
|
|
v.writeCells(v.wx, v.wy, cells)
|
|
if truncateLine {
|
|
length := v.wx + len(cells)
|
|
v.lines[v.wy] = v.lines[v.wy][:length]
|
|
} else {
|
|
v.wx += len(cells)
|
|
}
|
|
}
|
|
}
|
|
|
|
if v.pendingNewline {
|
|
finishLine()
|
|
} else {
|
|
v.autoRenderHyperlinksInCurrentLine()
|
|
}
|
|
|
|
v.updateSearchPositions()
|
|
}
|
|
|
|
// 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.WriteRunes([]rune(s))
|
|
}
|
|
|
|
func (v *View) writeString(s string) {
|
|
v.writeRunes([]rune(s))
|
|
}
|
|
|
|
func findSubstring(line []cell, substringToFind []rune) int {
|
|
for i := 0; i < len(line)-len(substringToFind); i++ {
|
|
for j := 0; j < len(substringToFind); j++ {
|
|
if line[i+j].chr != substringToFind[j] {
|
|
break
|
|
}
|
|
if j == len(substringToFind)-1 {
|
|
return i
|
|
}
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func (v *View) autoRenderHyperlinksInCurrentLine() {
|
|
if !v.AutoRenderHyperLinks {
|
|
return
|
|
}
|
|
|
|
// 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.
|
|
lineEndCharacters := map[rune]bool{
|
|
'\000': true,
|
|
' ': true,
|
|
'\n': true,
|
|
'>': true,
|
|
'"': true,
|
|
')': true,
|
|
}
|
|
line := v.lines[v.wy]
|
|
start := 0
|
|
for {
|
|
linkStart := findSubstring(line[start:], []rune("https://"))
|
|
if linkStart == -1 {
|
|
break
|
|
}
|
|
linkStart += start
|
|
link := ""
|
|
linkEnd := linkStart
|
|
for ; linkEnd < len(line); linkEnd++ {
|
|
if _, ok := lineEndCharacters[line[linkEnd].chr]; ok {
|
|
break
|
|
}
|
|
link += string(line[linkEnd].chr)
|
|
}
|
|
for i := linkStart; i < linkEnd; i++ {
|
|
v.lines[v.wy][i].hyperlink = link
|
|
}
|
|
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 (v *View) parseInput(ch rune, x int, _ int) (bool, []cell) {
|
|
cells := []cell{}
|
|
truncateLine := false
|
|
|
|
isEscape, err := v.ei.parseOne(ch)
|
|
if err != nil {
|
|
for _, r := range v.ei.runes() {
|
|
c := cell{
|
|
fgColor: v.FgColor,
|
|
bgColor: v.BgColor,
|
|
chr: r,
|
|
}
|
|
cells = append(cells, c)
|
|
}
|
|
v.ei.reset()
|
|
} else {
|
|
repeatCount := 1
|
|
if _, ok := v.ei.instruction.(eraseInLineFromCursor); ok {
|
|
// fill rest of line
|
|
v.ei.instructionRead()
|
|
cx := 0
|
|
for _, cell := range v.lines[v.wy][0:v.wx] {
|
|
cx += runewidth.RuneWidth(cell.chr)
|
|
}
|
|
repeatCount = v.InnerWidth() - cx
|
|
ch = ' '
|
|
truncateLine = true
|
|
} else if isEscape {
|
|
// do not output anything
|
|
return truncateLine, nil
|
|
} else if ch == '\t' {
|
|
// fill tab-sized space
|
|
tabWidth := v.TabWidth
|
|
if tabWidth < 1 {
|
|
tabWidth = 4
|
|
}
|
|
ch = ' '
|
|
repeatCount = tabWidth - (x % tabWidth)
|
|
}
|
|
c := cell{
|
|
fgColor: v.ei.curFgColor,
|
|
bgColor: v.ei.curBgColor,
|
|
hyperlink: v.ei.hyperlink,
|
|
chr: ch,
|
|
}
|
|
for i := 0; i < repeatCount; i++ {
|
|
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.lines) {
|
|
for v.rx < len(v.lines[v.ry]) {
|
|
count := utf8.EncodeRune(buffer, v.lines[v.ry][v.rx].chr)
|
|
copy(p[offset:], buffer[:count])
|
|
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.lines = nil
|
|
v.clearViewLines()
|
|
}
|
|
|
|
// 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()
|
|
|
|
v.clear()
|
|
|
|
v.lines = from.lines
|
|
v.viewLines = from.viewLines
|
|
v.ox = from.ox
|
|
v.oy = 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.lines = nil
|
|
}
|
|
|
|
// This is for when we've done a restart for the sake of avoiding a flicker and
|
|
// we've reached the end of the new content to display: we need to clear the remaining
|
|
// content from the previous round. We do this by setting v.viewLines to nil so that
|
|
// we just render the new content from v.lines directly
|
|
func (v *View) FlushStaleCells() {
|
|
v.writeMutex.Lock()
|
|
defer v.writeMutex.Unlock()
|
|
|
|
v.clearViewLines()
|
|
}
|
|
|
|
func (v *View) rewind() {
|
|
v.ei.reset()
|
|
|
|
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 (v *View) updateSearchPositions() {
|
|
if v.searcher.searchString != "" {
|
|
var normalizeRune func(r rune) rune
|
|
var normalizedSearchStr string
|
|
// if we have any uppercase characters we'll do a case-sensitive search
|
|
if containsUpcaseChar(v.searcher.searchString) {
|
|
normalizeRune = func(r rune) rune { return r }
|
|
normalizedSearchStr = v.searcher.searchString
|
|
} else {
|
|
normalizeRune = unicode.ToLower
|
|
normalizedSearchStr = strings.ToLower(v.searcher.searchString)
|
|
}
|
|
|
|
v.searcher.searchPositions = []SearchPosition{}
|
|
|
|
searchPositionsForLine := func(line []cell, y int) []SearchPosition {
|
|
var result []SearchPosition
|
|
searchStringWidth := runewidth.StringWidth(v.searcher.searchString)
|
|
x := 0
|
|
for startIdx, c := range line {
|
|
found := true
|
|
offset := 0
|
|
for _, c := range normalizedSearchStr {
|
|
if len(line)-1 < startIdx+offset {
|
|
found = false
|
|
break
|
|
}
|
|
if normalizeRune(line[startIdx+offset].chr) != c {
|
|
found = false
|
|
break
|
|
}
|
|
offset += 1
|
|
}
|
|
if found {
|
|
result = append(result, SearchPosition{XStart: x, XEnd: x + searchStringWidth, Y: y})
|
|
}
|
|
x += runewidth.RuneWidth(c.chr)
|
|
}
|
|
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.lines) {
|
|
break
|
|
}
|
|
|
|
// If a view line exists for this line index:
|
|
if v.lines[result.Y] != nil {
|
|
// search this view line for the search string
|
|
positions := searchPositionsForLine(v.lines[result.Y], 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 {
|
|
return v.tainted
|
|
}
|
|
|
|
// draw re-draws the view's contents.
|
|
func (v *View) draw() {
|
|
v.writeMutex.Lock()
|
|
defer v.writeMutex.Unlock()
|
|
|
|
if !v.Visible {
|
|
return
|
|
}
|
|
|
|
v.clearRunes()
|
|
|
|
maxX, maxY := v.InnerSize()
|
|
|
|
if v.Wrap {
|
|
if maxX == 0 {
|
|
return
|
|
}
|
|
v.ox = 0
|
|
}
|
|
|
|
v.refreshViewLinesIfNeeded()
|
|
|
|
visibleViewLinesHeight := v.viewLineLengthIgnoringTrailingBlankLines()
|
|
if v.Autoscroll && visibleViewLinesHeight > maxY {
|
|
v.oy = 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: ' ', fgColor: ColorDefault, bgColor: ColorDefault}
|
|
var prevFgColor Attribute
|
|
|
|
for y, vline := range v.viewLines[start:] {
|
|
if y >= maxY {
|
|
break
|
|
}
|
|
|
|
// 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 := -v.ox
|
|
cellIdx := 0
|
|
|
|
var c cell
|
|
for {
|
|
if x >= maxX {
|
|
break
|
|
}
|
|
|
|
if x < 0 {
|
|
if cellIdx < len(vline.line) {
|
|
x += runewidth.RuneWidth(vline.line[cellIdx].chr)
|
|
cellIdx++
|
|
continue
|
|
} else {
|
|
// no more characters to write so we're only going to be printing empty cells
|
|
// past this point
|
|
x = 0
|
|
}
|
|
}
|
|
|
|
// if we're out of cells to write, we'll just print empty cells.
|
|
if cellIdx > len(vline.line)-1 {
|
|
c = emptyCell
|
|
c.fgColor = prevFgColor
|
|
} else {
|
|
c = vline.line[cellIdx]
|
|
// capturing previous foreground colour so that if we're using the reverse
|
|
// attribute we honour the final character's colour and don't awkwardly switch
|
|
// to a new background colour for the remainder of the line
|
|
prevFgColor = c.fgColor
|
|
}
|
|
|
|
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.setRune(x, y, c.chr, fgColor, bgColor)
|
|
|
|
// Not sure why the previous code was here but it caused problems
|
|
// when typing wide characters in an editor
|
|
x += runewidth.RuneWidth(c.chr)
|
|
cellIdx++
|
|
}
|
|
}
|
|
}
|
|
|
|
func (v *View) refreshViewLinesIfNeeded() {
|
|
if v.tainted {
|
|
maxX := v.InnerWidth()
|
|
lineIdx := 0
|
|
lines := v.lines
|
|
if v.HasLoader {
|
|
lines = v.loaderLines()
|
|
}
|
|
for i, line := range lines {
|
|
wrap := 0
|
|
if v.Wrap {
|
|
wrap = maxX
|
|
}
|
|
|
|
ls := lineWrap(line, wrap)
|
|
for j := range ls {
|
|
vline := viewLine{linesX: j, linesY: i, line: ls[j]}
|
|
|
|
if lineIdx > len(v.viewLines)-1 {
|
|
v.viewLines = append(v.viewLines, vline)
|
|
} else {
|
|
v.viewLines[lineIdx] = vline
|
|
}
|
|
lineIdx++
|
|
}
|
|
}
|
|
if !v.HasLoader {
|
|
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 := 0; x < maxX; x++ {
|
|
for y := 0; y < maxY; y++ {
|
|
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.lines))
|
|
for i, l := range v.lines {
|
|
str := lineType(l).String()
|
|
str = strings.Replace(str, "\x00", "", -1)
|
|
lines[i] = str
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// Buffer returns a string with the contents of the view's internal
|
|
// buffer.
|
|
func (v *View) Buffer() string {
|
|
return linesToString(v.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 {
|
|
str := lineType(l.line).String()
|
|
str = strings.Replace(str, "\x00", "", -1)
|
|
lines[i] = str
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// LinesHeight is the count of view lines (i.e. lines excluding wrapping)
|
|
func (v *View) LinesHeight() int {
|
|
return len(v.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 {
|
|
lines := make([][]cell, len(v.viewLines))
|
|
for i := range v.viewLines {
|
|
lines[i] = v.viewLines[i].line
|
|
}
|
|
|
|
return linesToString(lines)
|
|
}
|
|
|
|
// 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.lines) {
|
|
return "", false
|
|
}
|
|
|
|
return lineType(v.lines[y]).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.lines) || x >= len(v.lines[y]) {
|
|
return "", false
|
|
}
|
|
|
|
str := lineType(v.lines[y]).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
|
|
}
|
|
|
|
// SetHighlight toggles highlighting of separate lines, for custom lists
|
|
// or multiple selection in views.
|
|
func (v *View) SetHighlight(y int, on bool) {
|
|
if y < 0 || y >= len(v.lines) {
|
|
return
|
|
}
|
|
|
|
line := v.lines[y]
|
|
cells := make([]cell, 0)
|
|
for _, c := range line {
|
|
if on {
|
|
c.bgColor = v.SelBgColor
|
|
c.fgColor = v.SelFgColor
|
|
} else {
|
|
c.bgColor = v.BgColor
|
|
c.fgColor = v.FgColor
|
|
}
|
|
cells = append(cells, c)
|
|
}
|
|
v.tainted = true
|
|
v.lines[y] = cells
|
|
v.clearHover()
|
|
}
|
|
|
|
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 := runewidth.RuneWidth(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 += runewidth.RuneWidth(c.chr)
|
|
}
|
|
} 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 [][]cell) string {
|
|
str := make([]string, len(lines))
|
|
for i := range lines {
|
|
rns := make([]rune, 0, len(lines[i]))
|
|
line := lineType(lines[i]).String()
|
|
for _, c := range line {
|
|
if c != '\x00' {
|
|
rns = append(rns, c)
|
|
}
|
|
}
|
|
str[i] = string(rns)
|
|
}
|
|
|
|
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 += runewidth.StringWidth(tab)
|
|
if x <= charX {
|
|
return i
|
|
}
|
|
charX += runewidth.StringWidth(" - ")
|
|
if x <= charX {
|
|
return -1
|
|
}
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
func (v *View) SelectedLineIdx() int {
|
|
_, seletedLineIdx := v.SelectedPoint()
|
|
return seletedLineIdx
|
|
}
|
|
|
|
// expected to only be used in tests
|
|
func (v *View) SelectedLine() string {
|
|
v.writeMutex.Lock()
|
|
defer v.writeMutex.Unlock()
|
|
|
|
if len(v.lines) == 0 {
|
|
return ""
|
|
}
|
|
|
|
return v.lineContentAtIdx(v.SelectedLineIdx())
|
|
}
|
|
|
|
// expected to only be used in tests
|
|
func (v *View) SelectedLines() []string {
|
|
v.writeMutex.Lock()
|
|
defer v.writeMutex.Unlock()
|
|
|
|
if len(v.lines) == 0 {
|
|
return nil
|
|
}
|
|
|
|
startIdx, endIdx := v.SelectedLineRange()
|
|
|
|
lines := make([]string, 0, endIdx-startIdx+1)
|
|
for i := startIdx; i <= endIdx; i++ {
|
|
lines = append(lines, v.lineContentAtIdx(i))
|
|
}
|
|
|
|
return lines
|
|
}
|
|
|
|
func (v *View) lineContentAtIdx(idx int) string {
|
|
line := v.lines[idx]
|
|
str := lineType(line).String()
|
|
return strings.Replace(str, "\x00", "", -1)
|
|
}
|
|
|
|
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
|
|
} else {
|
|
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
|
|
|
|
if cursor > prevOrigin+size {
|
|
newOrigin = cursor - size
|
|
newViewCursor = size
|
|
} 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.wx = 0
|
|
v.wy = y
|
|
v.clearViewLines()
|
|
|
|
lines := strings.Replace(content, "\n", "\x1b[K\n", -1)
|
|
// 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.wx and v.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.wx and v.wy end up
|
|
func (v *View) OverwriteLinesAndClearEverythingElse(y int, content string) {
|
|
v.writeMutex.Lock()
|
|
defer v.writeMutex.Unlock()
|
|
|
|
v.overwriteLines(y, content)
|
|
|
|
for i := 0; i < y; i += 1 {
|
|
v.lines[i] = nil
|
|
}
|
|
|
|
for i := v.wy + 1; i < len(v.lines); i += 1 {
|
|
v.lines[i] = nil
|
|
}
|
|
}
|
|
|
|
func (v *View) SetContentLineCount(lineCount int) {
|
|
if lineCount > 0 {
|
|
v.makeWriteable(0, lineCount-1)
|
|
}
|
|
v.lines = v.lines[:lineCount]
|
|
}
|
|
|
|
func (v *View) ScrollUp(amount int) {
|
|
if amount > v.oy {
|
|
amount = v.oy
|
|
}
|
|
|
|
if amount != 0 {
|
|
v.oy -= amount
|
|
v.cy += amount
|
|
|
|
v.clearHover()
|
|
}
|
|
}
|
|
|
|
// 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.oy += adjustedAmount
|
|
v.cy -= adjustedAmount
|
|
|
|
v.clearHover()
|
|
}
|
|
}
|
|
|
|
func (v *View) ScrollLeft(amount int) {
|
|
newOx := v.ox - amount
|
|
if newOx < 0 {
|
|
newOx = 0
|
|
}
|
|
if newOx != v.ox {
|
|
v.ox = newOx
|
|
|
|
v.clearHover()
|
|
}
|
|
}
|
|
|
|
// not applying any limits to this
|
|
func (v *View) ScrollRight(amount int) {
|
|
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
|
|
} else {
|
|
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
|
|
} else {
|
|
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.lines {
|
|
if containsColoredTextInLine(fgColor, text, line) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func containsColoredTextInLine(fgColorStr string, text string, line []cell) bool {
|
|
fgColor := tcell.GetColor(fgColorStr)
|
|
|
|
currentMatch := ""
|
|
for i := 0; i < len(line); i++ {
|
|
cell := line[i]
|
|
|
|
// stripping attributes by converting to and from hex
|
|
cellColor := tcell.NewHexColor(cell.fgColor.Hex())
|
|
|
|
if cellColor == fgColor {
|
|
currentMatch += string(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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|