diff --git a/.gitignore b/.gitignore index 5ed0bb8..cf7a9a7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea +.vscode /darktile diff --git a/README.md b/README.md index dc51b8e..2273106 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,19 @@ Darktile is a GPU rendered terminal emulator designed for tiling window managers - GPU rendering - Unicode support +- Variety of themes available (or build your own!) - Compiled-in powerline font -- Configurable/customisable, supports custom themes, fonts etc. -- Hints: Context-aware overlays e.g. hex colour viewer +- Works with your favourite monospaced TTF/OTF fonts +- Font ligatures (turn it off if you're not a ligature fan) +- Hints: Context-aware overlays e.g. hex colour viewer, octal permission annotation - Take screenshots with a single key-binding -- Sixel support -- Transparency +- Sixels +- Window transparency (0-100%) +- Customisable cursor (most popular image formats supported) + +

+ +

## Installation @@ -43,11 +50,14 @@ Darktile will use sensible defaults if no config/theme files are available. The Found in the config directory (see above) inside `config.yaml`. ```yaml -opacity: 1.0 # window opacity: 0.0 is fully transparent, 1.0 is fully opaque +opacity: 1.0 # Window opacity: 0.0 is fully transparent, 1.0 is fully opaque font: - family: "" # Find possible values for this by running 'darktile list-fonts' - size: 16 - dpi: 72 + family: "" # Font family. Find possible values for this by running 'darktile list-fonts' + size: 16 # Font size + dpi: 72 # DPI + ligatures: true # Enable font ligatures e.g. render '≡' instead of '===' +cursor: + image: "" # Path to an image to render as your cursor (defaults to standard rectangular cursor) ``` ### Example Theme diff --git a/cursor.gif b/cursor.gif new file mode 100644 index 0000000..ec66f63 Binary files /dev/null and b/cursor.gif differ diff --git a/internal/app/darktile/cmd/root.go b/internal/app/darktile/cmd/root.go index 0df4279..1ed007e 100644 --- a/internal/app/darktile/cmd/root.go +++ b/internal/app/darktile/cmd/root.go @@ -3,6 +3,7 @@ package cmd import ( "errors" "fmt" + "image" "os" "time" @@ -48,6 +49,8 @@ var rootCmd = &cobra.Command{ if _, err := conf.Save(); err != nil { return fmt.Errorf("failed to write config file: %w", err) } + fmt.Println("Config written.") + return nil } var theme *termutil.Theme @@ -91,6 +94,16 @@ var rootCmd = &cobra.Command{ gui.WithFontSize(conf.Font.Size), gui.WithFontFamily(conf.Font.Family), gui.WithOpacity(conf.Opacity), + gui.WithLigatures(conf.Font.Ligatures), + } + + if conf.Cursor.Image != "" { + img, err := getImageFromFilePath(conf.Cursor.Image) + if err != nil { + startupErrors = append(startupErrors, err) + } else { + options = append(options, gui.WithCursorImage(img)) + } } if screenshotAfterMS > 0 { @@ -118,6 +131,16 @@ var rootCmd = &cobra.Command{ }, } +func getImageFromFilePath(filePath string) (image.Image, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer f.Close() + image, _, err := image.Decode(f) + return image, err +} + func Execute() error { rootCmd.Flags().BoolVar(&showVersion, "version", showVersion, "Show darktile version information and exit") rootCmd.Flags().BoolVar(&rewriteConfig, "rewrite-config", rewriteConfig, "Write the resultant config after parsing config files and merging with defauls back to the config file") diff --git a/internal/app/darktile/config/config.go b/internal/app/darktile/config/config.go index 044c29f..0c7d97e 100644 --- a/internal/app/darktile/config/config.go +++ b/internal/app/darktile/config/config.go @@ -12,12 +12,18 @@ import ( type Config struct { Opacity float64 Font Font + Cursor Cursor } type Font struct { - Family string - Size float64 - DPI float64 + Family string + Size float64 + DPI float64 + Ligatures bool +} + +type Cursor struct { + Image string } type ErrorFileNotFound struct { diff --git a/internal/app/darktile/config/default.go b/internal/app/darktile/config/default.go index e225c5b..55d2183 100644 --- a/internal/app/darktile/config/default.go +++ b/internal/app/darktile/config/default.go @@ -11,9 +11,10 @@ import ( var defaultConfig = Config{ Opacity: 1.0, Font: Font{ - Family: "", // internally packed font will be loaded by default - Size: 18.0, - DPI: 72.0, + Family: "", // internally packed font will be loaded by default + Size: 18.0, + DPI: 72.0, + Ligatures: true, }, } diff --git a/internal/app/darktile/gui/draw.go b/internal/app/darktile/gui/draw.go index 1d93780..48673c9 100644 --- a/internal/app/darktile/gui/draw.go +++ b/internal/app/darktile/gui/draw.go @@ -1,322 +1,17 @@ package gui import ( - "image/color" - "strings" - "github.com/hajimehoshi/ebiten/v2" - "github.com/hajimehoshi/ebiten/v2/ebitenutil" - "github.com/hajimehoshi/ebiten/v2/text" - "github.com/liamg/darktile/internal/app/darktile/termutil" - imagefont "golang.org/x/image/font" + "github.com/liamg/darktile/internal/app/darktile/gui/render" ) // Draw renders the terminal GUI to the ebtien window. Required to implement the ebiten interface. func (g *GUI) Draw(screen *ebiten.Image) { - - tmp := ebiten.NewImage(g.size.X, g.size.Y) - - cellSize := g.fontManager.CharSize() - dotDepth := g.fontManager.DotDepth() - - buffer := g.terminal.GetActiveBuffer() - - regularFace := g.fontManager.RegularFontFace() - boldFace := g.fontManager.BoldFontFace() - italicFace := g.fontManager.ItalicFontFace() - boldItalicFace := g.fontManager.BoldItalicFontFace() - - var useFace imagefont.Face - - defBg := g.terminal.Theme().DefaultBackground() - defFg := g.terminal.Theme().DefaultForeground() - - var colour color.Color - - endX := float64(cellSize.X * int(buffer.ViewWidth())) - endY := float64(cellSize.Y * int(buffer.ViewHeight())) - extraW := float64(g.size.X) - endX - extraH := float64(g.size.Y) - endY - if extraW > 0 { - ebitenutil.DrawRect(tmp, endX, 0, extraW, endY, defBg) - } - if extraH > 0 { - ebitenutil.DrawRect(tmp, 0, endY, float64(g.size.X), extraH, defBg) - } - - var inHighlight bool - var highlightRendered bool - var highlightMin termutil.Position - highlightMin.Col = uint16(g.size.X) - highlightMin.Line = uint64(g.size.Y) - var highlightMax termutil.Position - - for y := int(buffer.ViewHeight() - 1); y >= 0; y-- { - py := cellSize.Y * y - - ebitenutil.DrawRect(tmp, 0, float64(py), float64(g.size.X), float64(cellSize.Y), defBg) - inHighlight = false - for x := uint16(0); x < buffer.ViewWidth(); x++ { - cell := buffer.GetCell(x, uint16(y)) - px := cellSize.X * int(x) - if cell != nil { - colour = cell.Bg() - } else { - colour = defBg - } - isCursor := g.terminal.GetActiveBuffer().IsCursorVisible() && int(buffer.CursorLine()) == y && buffer.CursorColumn() == x - if isCursor { - colour = g.terminal.Theme().CursorBackground() - } else if buffer.InSelection(termutil.Position{ - Line: uint64(y), - Col: x, - }) { - colour = g.terminal.Theme().SelectionBackground() - } else if colour == nil { - colour = defBg - } - - ebitenutil.DrawRect(tmp, float64(px), float64(py), float64(cellSize.X), float64(cellSize.Y), colour) - - if buffer.IsHighlighted(termutil.Position{ - Line: uint64(y), - Col: x, - }) { - - if !inHighlight { - highlightRendered = true - } - - if uint64(y) < highlightMin.Line { - highlightMin.Col = uint16(g.size.X) - highlightMin.Line = uint64(y) - } - if uint64(y) > highlightMax.Line { - highlightMax.Line = uint64(y) - } - if uint64(y) == highlightMax.Line && x > highlightMax.Col { - highlightMax.Col = x - } - if uint64(y) == highlightMin.Line && x < highlightMin.Col { - highlightMin.Col = x - } - - inHighlight = true - - } else if inHighlight { - inHighlight = false - } - - if isCursor && !ebiten.IsFocused() { - ebitenutil.DrawRect(tmp, float64(px)+1, float64(py)+1, float64(cellSize.X)-2, float64(cellSize.Y)-2, g.terminal.Theme().DefaultBackground()) - } - } - for x := uint16(0); x < buffer.ViewWidth(); x++ { - cell := buffer.GetCell(x, uint16(y)) - if cell == nil || cell.Rune().Rune == 0 { - continue - } - - px := cellSize.X * int(x) - colour = cell.Fg() - if g.terminal.GetActiveBuffer().IsCursorVisible() && int(buffer.CursorLine()) == y && buffer.CursorColumn() == x { - colour = g.terminal.Theme().CursorForeground() - } else if buffer.InSelection(termutil.Position{ - Line: uint64(y), - Col: x, - }) { - colour = g.terminal.Theme().SelectionForeground() - } else if colour == nil { - colour = defFg - } - - useFace = regularFace - if cell.Bold() && cell.Italic() { - useFace = boldItalicFace - } else if cell.Bold() { - useFace = boldFace - } else if cell.Italic() { - useFace = italicFace - } - - if cell.Underline() { - uly := float64(py + (dotDepth+cellSize.Y)/2) - ebitenutil.DrawLine(tmp, float64(px), uly, float64(px+cellSize.X), uly, colour) - } - - text.Draw(tmp, string(cell.Rune().Rune), useFace, px, py+dotDepth, colour) - - if cell.Strikethrough() { - ebitenutil.DrawLine(tmp, float64(px), float64(py+(cellSize.Y/2)), float64(px+cellSize.X), float64(py+(cellSize.Y/2)), colour) - } - - } - } - - for _, sixel := range buffer.GetVisibleSixels() { - sx := float64(int(sixel.Sixel.X) * cellSize.X) - sy := float64(sixel.ViewLineOffset * cellSize.Y) - - op := &ebiten.DrawImageOptions{} - op.GeoM.Translate(sx, sy) - tmp.DrawImage( - ebiten.NewImageFromImage(sixel.Sixel.Image), - op, - ) - } - - // draw annotations and overlays - if highlightRendered { - if annotation := buffer.GetHighlightAnnotation(); annotation != nil { - - if highlightMin.Col == uint16(g.size.X) { - highlightMin.Col = 0 - } - if highlightMin.Line == uint64(g.size.Y) { - highlightMin.Line = 0 - } - - mx, _ := ebiten.CursorPosition() - padding := float64(cellSize.X) / 2 - lineX := float64(mx) - var lineY float64 - var lineHeight float64 - annotationX := mx - cellSize.X*2 - var annotationY float64 - annotationWidth := float64(cellSize.X) * annotation.Width - var annotationHeight float64 - - if annotationX+int(annotationWidth)+int(padding*2) > g.size.X { - annotationX = g.size.X - (int(annotationWidth) + int(padding*2)) - } - if annotationX < int(padding) { - annotationX = int(padding) - } - - if (highlightMin.Line + (highlightMax.Line-highlightMin.Line)/2) < uint64(buffer.ViewHeight()/2) { - // annotate underneath max - - pixelsUnderHighlight := float64(g.size.Y) - float64((highlightMax.Line+1)*uint64(cellSize.Y)) - // we need to reserve at least one cell height for the label line - pixelsAvailableY := pixelsUnderHighlight - float64(cellSize.Y) - annotationHeight = annotation.Height * float64(cellSize.Y) - if annotationHeight > pixelsAvailableY { - annotationHeight = pixelsAvailableY - } - - lineHeight = pixelsUnderHighlight - padding - annotationHeight - if lineHeight > annotationHeight { - if annotationHeight > float64(cellSize.Y)*3 { - lineHeight = annotationHeight - } else { - lineHeight = float64(cellSize.Y) * 3 - } - } - annotationY = float64((highlightMax.Line+1)*uint64(cellSize.Y)) + lineHeight + float64(padding) - lineY = float64((highlightMax.Line + 1) * uint64(cellSize.Y)) - - } else { - //annotate above min - - pixelsAboveHighlight := float64((highlightMin.Line) * uint64(cellSize.Y)) - // we need to reserve at least one cell height for the label line - pixelsAvailableY := pixelsAboveHighlight - float64(cellSize.Y) - annotationHeight = annotation.Height * float64(cellSize.Y) - if annotationHeight > pixelsAvailableY { - annotationHeight = pixelsAvailableY - } - - lineHeight = pixelsAboveHighlight - annotationHeight - if lineHeight > annotationHeight { - if annotationHeight > float64(cellSize.Y)*3 { - lineHeight = annotationHeight - } else { - lineHeight = float64(cellSize.Y) * 3 - } - } - annotationY = float64((highlightMin.Line)*uint64(cellSize.Y)) - lineHeight - float64(padding*2) - annotationHeight - lineY = annotationY + annotationHeight + +padding - } - - // draw opaque box below and above highlighted line(s) - ebitenutil.DrawRect(tmp, 0, float64(highlightMin.Line*uint64(cellSize.Y)), float64(cellSize.X*int(highlightMin.Col)), float64(cellSize.Y), color.RGBA{A: 0x80}) - ebitenutil.DrawRect(tmp, float64((cellSize.X)*int(highlightMax.Col+1)), float64(highlightMax.Line*uint64(cellSize.Y)), float64(g.size.X), float64(cellSize.Y), color.RGBA{A: 0x80}) - ebitenutil.DrawRect(tmp, 0, 0, float64(g.size.X), float64(highlightMin.Line*uint64(cellSize.Y)), color.RGBA{A: 0x80}) - afterLineY := float64((1 + highlightMax.Line) * uint64(cellSize.Y)) - ebitenutil.DrawRect(tmp, 0, afterLineY, float64(g.size.X), float64(g.size.Y)-afterLineY, color.RGBA{A: 0x80}) - - // annotation border - ebitenutil.DrawRect(tmp, float64(annotationX)-padding, annotationY-padding, float64(annotationWidth)+(padding*2), annotationHeight+(padding*2), g.terminal.Theme().SelectionBackground()) - // annotation background - ebitenutil.DrawRect(tmp, 1+float64(annotationX)-padding, 1+annotationY-padding, float64(annotationWidth)+(padding*2)-2, annotationHeight+(padding*2)-2, g.terminal.Theme().DefaultBackground()) - - // vertical line - ebitenutil.DrawLine(tmp, lineX, float64(lineY), lineX, lineY+lineHeight, g.terminal.Theme().SelectionBackground()) - - var tY int - var tX int - - if annotation.Image != nil { - tY += annotation.Image.Bounds().Dy() + cellSize.Y/2 - - op := &ebiten.DrawImageOptions{} - op.GeoM.Translate(float64(annotationX), annotationY) - tmp.DrawImage( - ebiten.NewImageFromImage(annotation.Image), - op, - ) - } - - for _, r := range annotation.Text { - if r == '\n' { - tY += cellSize.Y - tX = 0 - continue - } - text.Draw(tmp, string(r), regularFace, annotationX+tX, int(annotationY)+dotDepth+tY, g.terminal.Theme().DefaultForeground()) - tX += cellSize.X - } - - } - } - - if len(g.popupMessages) > 0 { - pad := cellSize.Y / 2 // horizontal and vertical padding - msgEndY := endY - for _, msg := range g.popupMessages { - - lines := strings.Split(msg.Text, "\n") - - msgX := pad - - msgY := msgEndY - float64(pad*3) - float64(cellSize.Y*len(lines)) - - msgText := msg.Text - - boxWidth := float64(pad*2) + float64(cellSize.X*len(msgText)) - boxHeight := float64(pad*2) + float64(cellSize.Y*len(lines)) - - if boxWidth < endX/8 { - boxWidth = endX / 8 - } - - ebitenutil.DrawRect(tmp, float64(msgX-1), msgY-1, boxWidth+2, boxHeight+2, msg.Foreground) - ebitenutil.DrawRect(tmp, float64(msgX), msgY, boxWidth, boxHeight, msg.Background) - for y, line := range lines { - for x, r := range line { - text.Draw(tmp, string(r), regularFace, msgX+pad+(x*cellSize.X), pad+(y*cellSize.Y)+int(msgY)+dotDepth, msg.Foreground) - } - } - msgEndY = msgEndY - float64(pad*4) - float64(len(lines)*g.CellSize().Y) - } - } + render. + New(screen, g.terminal, g.fontManager, g.popupMessages, g.opacity, g.enableLigatures, g.cursorImage). + Draw() if g.screenshotRequested { - g.takeScreenshot(tmp) + g.takeScreenshot(screen) } - - opt := &ebiten.DrawImageOptions{} - opt.ColorM.Scale(1, 1, 1, g.opacity) - screen.DrawImage(tmp, opt) - tmp.Dispose() } diff --git a/internal/app/darktile/gui/gui.go b/internal/app/darktile/gui/gui.go index 63b436b..cbc64b7 100644 --- a/internal/app/darktile/gui/gui.go +++ b/internal/app/darktile/gui/gui.go @@ -3,13 +3,13 @@ package gui import ( "fmt" "image" - "image/color" "math/rand" "os" "strings" "time" "github.com/liamg/darktile/internal/app/darktile/font" + "github.com/liamg/darktile/internal/app/darktile/gui/popup" "github.com/liamg/darktile/internal/app/darktile/hinters" "github.com/liamg/darktile/internal/app/darktile/termutil" @@ -34,19 +34,14 @@ type GUI struct { mousePos termutil.Position hinters []hinters.Hinter activeHinter int - popupMessages []PopupMessage + popupMessages []popup.Message screenshotRequested bool screenshotFilename string startupFuncs []func(g *GUI) keyState *keyState opacity float64 -} - -type PopupMessage struct { - Text string - Expiry time.Time - Foreground color.Color - Background color.Color + enableLigatures bool + cursorImage *ebiten.Image } type MouseState uint8 @@ -59,12 +54,13 @@ const ( func New(terminal *termutil.Terminal, options ...Option) (*GUI, error) { g := &GUI{ - terminal: terminal, - size: image.Point{80, 30}, - updateChan: make(chan struct{}), - fontManager: font.NewManager(), - activeHinter: -1, - keyState: newKeyState(), + terminal: terminal, + size: image.Point{80, 30}, + updateChan: make(chan struct{}), + fontManager: font.NewManager(), + activeHinter: -1, + keyState: newKeyState(), + enableLigatures: true, } for _, option := range options { diff --git a/internal/app/darktile/gui/input.go b/internal/app/darktile/gui/input.go index 6cd159c..7122c5a 100644 --- a/internal/app/darktile/gui/input.go +++ b/internal/app/darktile/gui/input.go @@ -219,11 +219,7 @@ func (g *GUI) handleInput() error { return g.terminal.WriteToPty([]byte(fmt.Sprintf("\x1b[6%s~", g.getModifierStr()))) default: input := ebiten.AppendInputChars(nil) - for _, runePressed := range input { - if err := g.terminal.WriteToPty([]byte(string(runePressed))); err != nil { - return err - } - } + return g.terminal.WriteToPty([]byte(string(input))) } return nil diff --git a/internal/app/darktile/gui/options.go b/internal/app/darktile/gui/options.go index 7a0fe50..2f26f93 100644 --- a/internal/app/darktile/gui/options.go +++ b/internal/app/darktile/gui/options.go @@ -1,5 +1,11 @@ package gui +import ( + "image" + + "github.com/hajimehoshi/ebiten/v2" +) + type Option func(g *GUI) error func WithFontFamily(family string) func(g *GUI) error { @@ -29,6 +35,20 @@ func WithFontDPI(dpi float64) func(g *GUI) error { } } +func WithLigatures(enable bool) func(g *GUI) error { + return func(g *GUI) error { + g.enableLigatures = enable + return nil + } +} + +func WithCursorImage(img image.Image) func(g *GUI) error { + return func(g *GUI) error { + g.cursorImage = ebiten.NewImageFromImage(img) + return nil + } +} + func WithStartupFunc(f func(g *GUI)) Option { return func(g *GUI) error { g.startupFuncs = append(g.startupFuncs, f) diff --git a/internal/app/darktile/gui/popup/popup.go b/internal/app/darktile/gui/popup/popup.go new file mode 100644 index 0000000..83ae5f2 --- /dev/null +++ b/internal/app/darktile/gui/popup/popup.go @@ -0,0 +1,13 @@ +package popup + +import ( + "image/color" + "time" +) + +type Message struct { + Text string + Expiry time.Time + Foreground color.Color + Background color.Color +} diff --git a/internal/app/darktile/gui/popups.go b/internal/app/darktile/gui/popups.go index b2c77d6..2c732d7 100644 --- a/internal/app/darktile/gui/popups.go +++ b/internal/app/darktile/gui/popups.go @@ -4,6 +4,8 @@ import ( "fmt" "image/color" "time" + + "github.com/liamg/darktile/internal/app/darktile/gui/popup" ) const ( @@ -12,7 +14,7 @@ const ( ) func (g *GUI) ShowPopup(msg string, fg color.Color, bg color.Color, duration time.Duration) { - g.popupMessages = append(g.popupMessages, PopupMessage{ + g.popupMessages = append(g.popupMessages, popup.Message{ Text: msg, Expiry: time.Now().Add(duration), Foreground: fg, diff --git a/internal/app/darktile/gui/render/annotation.go b/internal/app/darktile/gui/render/annotation.go new file mode 100644 index 0000000..2a461b8 --- /dev/null +++ b/internal/app/darktile/gui/render/annotation.go @@ -0,0 +1,162 @@ +package render + +import ( + "image/color" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/ebitenutil" + "github.com/hajimehoshi/ebiten/v2/text" +) + +func (r *Render) drawAnnotation() { + + // 1. check if we have anything to highlight/annotate + highlightStart, highlightEnd, ok := r.buffer.GetViewHighlight() + if !ok { + return + } + + // 2. make everything outside of the highlighted area opaque + dimColour := color.RGBA{A: 0x80} // 50% alpha black overlay to dim non-highlighted area + for line := 0; line < int(r.buffer.ViewHeight()); line++ { + if line < int(highlightStart.Line) || line > int(highlightEnd.Line) { + ebitenutil.DrawRect( + r.frame, + 0, + float64(line*r.font.CellSize.Y), + float64(r.pixelWidth), + float64(r.font.CellSize.Y), + dimColour, // 50% alpha black overlay to dim non-highlighted area + ) + continue + } + + if line == int(highlightStart.Line) && highlightStart.Col > 0 { + // we need to dim some content on this line before the highlight starts + ebitenutil.DrawRect( + r.frame, + 0, + float64(line*r.font.CellSize.Y), + float64(int(highlightStart.Col)*r.font.CellSize.X), + float64(r.font.CellSize.Y), + dimColour, + ) + } + + if line == int(highlightEnd.Line) && highlightEnd.Col < r.buffer.ViewWidth()-2 { + // we need to dim some content on this line after the highlight ends + ebitenutil.DrawRect( + r.frame, + float64(int(highlightEnd.Col+1)*r.font.CellSize.X), + float64(line*r.font.CellSize.Y), + float64(int(r.buffer.ViewWidth()-(highlightEnd.Col+1))*r.font.CellSize.X), + float64(r.font.CellSize.Y), + dimColour, + ) + } + } + + // 3. annotate the highlighted area (if there is an annotation) + annotation := r.buffer.GetHighlightAnnotation() + if annotation == nil { + return + } + + mousePixelX, _ := ebiten.CursorPosition() + padding := float64(r.font.CellSize.X) / 2 + + var lineY float64 + var lineHeight float64 + var annotationY float64 + var annotationHeight float64 + + if (highlightStart.Line + (highlightEnd.Line-highlightStart.Line)/2) < uint64(r.buffer.ViewHeight()/2) { + // annotate underneath max + + pixelsUnderHighlight := float64(r.pixelHeight) - float64((highlightEnd.Line+1)*uint64(r.font.CellSize.Y)) + // we need to reserve at least one cell height for the label line + pixelsAvailableY := pixelsUnderHighlight - float64(r.font.CellSize.Y) + annotationHeight = annotation.Height * float64(r.font.CellSize.Y) + if annotationHeight > pixelsAvailableY { + annotationHeight = pixelsAvailableY + } + + lineHeight = pixelsUnderHighlight - padding - annotationHeight + if lineHeight > annotationHeight { + if annotationHeight > float64(r.font.CellSize.Y)*3 { + lineHeight = annotationHeight + } else { + lineHeight = float64(r.font.CellSize.Y) * 3 + } + } + annotationY = float64((highlightEnd.Line+1)*uint64(r.font.CellSize.Y)) + lineHeight + float64(padding) + lineY = float64((highlightEnd.Line + 1) * uint64(r.font.CellSize.Y)) + + } else { + //annotate above min + + pixelsAboveHighlight := float64((highlightStart.Line) * uint64(r.font.CellSize.Y)) + // we need to reserve at least one cell height for the label line + pixelsAvailableY := pixelsAboveHighlight - float64(r.font.CellSize.Y) + annotationHeight = annotation.Height * float64(r.font.CellSize.Y) + if annotationHeight > pixelsAvailableY { + annotationHeight = pixelsAvailableY + } + + lineHeight = pixelsAboveHighlight - annotationHeight + if lineHeight > annotationHeight { + if annotationHeight > float64(r.font.CellSize.Y)*3 { + lineHeight = annotationHeight + } else { + lineHeight = float64(r.font.CellSize.Y) * 3 + } + } + annotationY = float64((highlightStart.Line)*uint64(r.font.CellSize.Y)) - lineHeight - float64(padding*2) - annotationHeight + lineY = annotationY + annotationHeight + +padding + } + + annotationX := mousePixelX - r.font.CellSize.X*2 + annotationWidth := float64(r.font.CellSize.X) * annotation.Width + + // if the annotation box goes off the right side of the terminal, align it against the right side + if annotationX+int(annotationWidth)+int(padding*2) > r.pixelWidth { + annotationX = r.pixelWidth - (int(annotationWidth) + int(padding*2)) + } + + // if the annotation is too far left, align it against the left side + if annotationX < int(padding) { + annotationX = int(padding) + } + + // annotation border + ebitenutil.DrawRect(r.frame, float64(annotationX)-padding, annotationY-padding, float64(annotationWidth)+(padding*2), annotationHeight+(padding*2), r.theme.SelectionBackground()) + // annotation background + ebitenutil.DrawRect(r.frame, 1+float64(annotationX)-padding, 1+annotationY-padding, float64(annotationWidth)+(padding*2)-2, annotationHeight+(padding*2)-2, r.theme.DefaultBackground()) + + // vertical line + ebitenutil.DrawLine(r.frame, float64(mousePixelX), float64(lineY), float64(mousePixelX), lineY+lineHeight, r.theme.SelectionBackground()) + + var tY int + var tX int + + if annotation.Image != nil { + tY += annotation.Image.Bounds().Dy() + r.font.CellSize.Y/2 + op := &ebiten.DrawImageOptions{} + op.GeoM.Translate(float64(annotationX), annotationY) + r.frame.DrawImage( + ebiten.NewImageFromImage(annotation.Image), + op, + ) + } + + for _, ch := range annotation.Text { + if ch == '\n' { + tY += r.font.CellSize.Y + tX = 0 + continue + } + text.Draw(r.frame, string(ch), r.font.Regular, annotationX+tX, int(annotationY)+r.font.DotDepth+tY, r.theme.DefaultForeground()) + tX += r.font.CellSize.X + } + +} diff --git a/internal/app/darktile/gui/render/content.go b/internal/app/darktile/gui/render/content.go new file mode 100644 index 0000000..40490c5 --- /dev/null +++ b/internal/app/darktile/gui/render/content.go @@ -0,0 +1,10 @@ +package render + +func (r *Render) drawContent() { + // draw base content for each row + defBg := r.theme.DefaultBackground() + defFg := r.theme.DefaultForeground() + for viewY := int(r.buffer.ViewHeight() - 1); viewY >= 0; viewY-- { + r.drawRow(viewY, defBg, defFg) + } +} diff --git a/internal/app/darktile/gui/render/cursor.go b/internal/app/darktile/gui/render/cursor.go new file mode 100644 index 0000000..ba66aaf --- /dev/null +++ b/internal/app/darktile/gui/render/cursor.go @@ -0,0 +1,67 @@ +package render + +import ( + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/ebitenutil" + "github.com/hajimehoshi/ebiten/v2/text" + "github.com/liamg/darktile/internal/app/darktile/termutil" +) + +func (r *Render) drawCursor() { + //draw cursor + if !r.buffer.IsCursorVisible() { + return + } + + pixelX := float64(int(r.buffer.CursorColumn()) * r.font.CellSize.X) + pixelY := float64(int(r.buffer.CursorLine()) * r.font.CellSize.Y) + cell := r.buffer.GetCell(r.buffer.CursorColumn(), r.buffer.CursorLine()) + + useFace := r.font.Regular + if cell != nil { + if cell.Bold() && cell.Italic() { + useFace = r.font.BoldItalic + } else if cell.Bold() { + useFace = r.font.Bold + } else if cell.Italic() { + useFace = r.font.Italic + } + } + + pixelW, pixelH := float64(r.font.CellSize.X), float64(r.font.CellSize.Y) + + // empty rect without focus + if !ebiten.IsFocused() { + ebitenutil.DrawRect(r.frame, pixelX, pixelY, pixelW, pixelH, r.theme.CursorBackground()) + ebitenutil.DrawRect(r.frame, pixelX+1, pixelY+1, pixelW-2, pixelH-2, r.theme.CursorForeground()) + return + } + + // draw the cursor shape + switch r.buffer.GetCursorShape() { + case termutil.CursorShapeBlinkingBar, termutil.CursorShapeSteadyBar: + ebitenutil.DrawRect(r.frame, pixelX, pixelY, 2, pixelH, r.theme.CursorBackground()) + case termutil.CursorShapeBlinkingUnderline, termutil.CursorShapeSteadyUnderline: + ebitenutil.DrawRect(r.frame, pixelX, pixelY+pixelH-2, pixelW, 2, r.theme.CursorBackground()) + default: + // draw a custom cursor if we have one and there are no characters in the way + if r.cursorImage != nil && (cell == nil || cell.Rune().Rune == 0) { + opt := &ebiten.DrawImageOptions{} + _, h := r.cursorImage.Size() + ratio := 1 / (float64(h) / float64(r.font.CellSize.Y)) + actualHeight := float64(h) * ratio + offsetY := (float64(r.font.CellSize.Y) - actualHeight) / 2 + opt.GeoM.Scale(ratio, ratio) + opt.GeoM.Translate(pixelX, pixelY+offsetY) + r.frame.DrawImage(r.cursorImage, opt) + return + } + + ebitenutil.DrawRect(r.frame, pixelX, pixelY, pixelW, pixelH, r.theme.CursorBackground()) + + // we've drawn over the cell contents, so we need to draw it again in the cursor colours + if cell != nil && cell.Rune().Rune > 0 { + text.Draw(r.frame, string(cell.Rune().Rune), useFace, int(pixelX), int(pixelY)+r.font.DotDepth, r.theme.CursorForeground()) + } + } +} diff --git a/internal/app/darktile/gui/render/ligatures.go b/internal/app/darktile/gui/render/ligatures.go new file mode 100644 index 0000000..9be18b3 --- /dev/null +++ b/internal/app/darktile/gui/render/ligatures.go @@ -0,0 +1,45 @@ +package render + +import ( + "image/color" + + "github.com/hajimehoshi/ebiten/v2/text" + imagefont "golang.org/x/image/font" +) + +var ligatures = map[string]rune{ + ":=": '≔', + "===": '≡', + "!=": '≠', + "!==": '≢', + "<=": '≤', + ">=": '≥', + "=>": '⇒', + "->": '→', + "<-": '←', + "<>": '≷', +} + +func (r *Render) handleLigatures(sx uint16, sy uint16, face imagefont.Face, colour color.Color) (length int) { + + var candidate string + for x := sx; x <= sx+2; x++ { + cell := r.buffer.GetCell(x, sy) + if cell == nil || cell.Rune().Rune == 0 { + break + } + candidate += string(cell.Rune().Rune) + } + + for len(candidate) > 1 { + if ru, ok := ligatures[candidate]; ok { + // draw ligature + ligX := (int(sx) * r.font.CellSize.X) + (((len(candidate) - 1) * r.font.CellSize.X) / 2) + text.Draw(r.frame, string(ru), face, ligX, (int(sy)*r.font.CellSize.Y)+r.font.DotDepth, colour) + return len(candidate) + } + candidate = candidate[:len(candidate)-1] + } + + return 0 +} diff --git a/internal/app/darktile/gui/render/popups.go b/internal/app/darktile/gui/render/popups.go new file mode 100644 index 0000000..c722580 --- /dev/null +++ b/internal/app/darktile/gui/render/popups.go @@ -0,0 +1,42 @@ +package render + +import ( + "strings" + + "github.com/hajimehoshi/ebiten/v2/ebitenutil" + "github.com/hajimehoshi/ebiten/v2/text" +) + +func (r *Render) drawPopups() { + + if len(r.popups) == 0 { + return + } + + pad := r.font.CellSize.Y / 2 // horizontal and vertical padding + maxPixelX := float64(r.font.CellSize.X * int(r.buffer.ViewWidth())) + maxPixelY := float64(r.font.CellSize.Y * int(r.buffer.ViewHeight())) + + for _, msg := range r.popups { + + lines := strings.Split(msg.Text, "\n") + msgX := pad + msgY := maxPixelY - float64(pad*3) - float64(r.font.CellSize.Y*len(lines)) + boxWidth := float64(pad*2) + float64(r.font.CellSize.X*len(msg.Text)) + boxHeight := float64(pad*2) + float64(r.font.CellSize.Y*len(lines)) + + if boxWidth < maxPixelX/8 { + boxWidth = maxPixelX / 8 + } + + ebitenutil.DrawRect(r.frame, float64(msgX-1), msgY-1, boxWidth+2, boxHeight+2, msg.Foreground) + ebitenutil.DrawRect(r.frame, float64(msgX), msgY, boxWidth, boxHeight, msg.Background) + for y, line := range lines { + for x, c := range line { + text.Draw(r.frame, string(c), r.font.Regular, msgX+pad+(x*r.font.CellSize.X), pad+(y*r.font.CellSize.Y)+int(msgY)+r.font.DotDepth, msg.Foreground) + } + } + maxPixelY = maxPixelY - float64(pad*4) - float64(len(lines)*r.font.CellSize.Y) + } + +} diff --git a/internal/app/darktile/gui/render/render.go b/internal/app/darktile/gui/render/render.go new file mode 100644 index 0000000..359e069 --- /dev/null +++ b/internal/app/darktile/gui/render/render.go @@ -0,0 +1,97 @@ +package render + +import ( + "image" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/liamg/darktile/internal/app/darktile/font" + "github.com/liamg/darktile/internal/app/darktile/gui/popup" + "github.com/liamg/darktile/internal/app/darktile/termutil" + imagefont "golang.org/x/image/font" +) + +type Render struct { + frame *ebiten.Image + screen *ebiten.Image + terminal *termutil.Terminal + buffer *termutil.Buffer + theme *termutil.Theme + fontManager *font.Manager + pixelWidth int + pixelHeight int + font Font + opacity float64 + popups []popup.Message + enableLigatures bool + cursorImage *ebiten.Image +} + +type Font struct { + Regular imagefont.Face + Bold imagefont.Face + Italic imagefont.Face + BoldItalic imagefont.Face + CellSize image.Point + DotDepth int +} + +func New(screen *ebiten.Image, terminal *termutil.Terminal, fontManager *font.Manager, popups []popup.Message, opacity float64, enableLigatures bool, cursorImage *ebiten.Image) *Render { + w, h := screen.Size() + return &Render{ + screen: screen, + frame: ebiten.NewImage(w, h), + terminal: terminal, + buffer: terminal.GetActiveBuffer(), + theme: terminal.Theme(), + fontManager: fontManager, + pixelWidth: w, + pixelHeight: h, + font: Font{ + Regular: fontManager.RegularFontFace(), + Bold: fontManager.BoldFontFace(), + Italic: fontManager.ItalicFontFace(), + BoldItalic: fontManager.BoldItalicFontFace(), + CellSize: fontManager.CharSize(), + DotDepth: fontManager.DotDepth(), + }, + opacity: opacity, + popups: popups, + enableLigatures: enableLigatures, + cursorImage: cursorImage, + } +} + +func (r *Render) Draw() { + + // 1. fill frame with default background colour + r.frame.Fill(r.theme.DefaultBackground()) + + // 2. draw content (each row, each cell) + r.drawContent() + + // 3. draw cursor + r.drawCursor() + + // // 4. draw sixels + r.drawSixels() + + // // 5. draw selection + r.drawSelection() + + // // 6. draw highlight/annotations + r.drawAnnotation() + + // // 7. draw popups + r.drawPopups() + + // // 8. apply effects (e.g. transparency) + r.finalise() + +} + +func (r *Render) finalise() { + defer r.frame.Dispose() + opt := &ebiten.DrawImageOptions{} + opt.ColorM.Scale(1, 1, 1, r.opacity) + r.screen.DrawImage(r.frame, opt) +} diff --git a/internal/app/darktile/gui/render/row.go b/internal/app/darktile/gui/render/row.go new file mode 100644 index 0000000..3ddbc14 --- /dev/null +++ b/internal/app/darktile/gui/render/row.go @@ -0,0 +1,94 @@ +package render + +import ( + "image/color" + + "github.com/hajimehoshi/ebiten/v2/ebitenutil" + "github.com/hajimehoshi/ebiten/v2/text" + imagefont "golang.org/x/image/font" +) + +func (r *Render) drawRow(viewY int, defaultBackgroundColour color.Color, defaultForegroundColour color.Color) { + + pixelY := r.font.CellSize.Y * viewY + + // draw a default colour background image across the entire row background + ebitenutil.DrawRect(r.frame, 0, float64(pixelY), float64(r.pixelWidth), float64(r.font.CellSize.Y), defaultBackgroundColour) + + var colour color.Color + + // draw background for each cell in row + for viewX := uint16(0); viewX < r.buffer.ViewWidth(); viewX++ { + cell := r.buffer.GetCell(viewX, uint16(viewY)) + pixelX := r.font.CellSize.X * int(viewX) + if cell != nil { + colour = cell.Bg() + } + if colour == nil { + colour = defaultBackgroundColour + } + + ebitenutil.DrawRect(r.frame, float64(pixelX), float64(pixelY), float64(r.font.CellSize.X), float64(r.font.CellSize.Y), colour) + } + + var useFace imagefont.Face + var skipRunes int + + // draw text content of each cell in row + for viewX := uint16(0); viewX < r.buffer.ViewWidth(); viewX++ { + + cell := r.buffer.GetCell(viewX, uint16(viewY)) + + // we don't need to draw empty cells + if cell == nil || cell.Rune().Rune == 0 { + continue + } + colour = cell.Fg() + if colour == nil { + colour = defaultForegroundColour + } + + // pick a font face for the cell + if !cell.Bold() && !cell.Italic() { + useFace = r.font.Regular + } else if cell.Bold() && cell.Italic() { + useFace = r.font.Italic + } else if cell.Bold() { + useFace = r.font.Bold + } else if cell.Italic() { + useFace = r.font.Italic + } + + pixelX := r.font.CellSize.X * int(viewX) + + // underline the cell content if required + if cell.Underline() { + underlinePixelY := float64(pixelY + (r.font.DotDepth+r.font.CellSize.Y)/2) + ebitenutil.DrawLine(r.frame, float64(pixelX), underlinePixelY, float64(pixelX+r.font.CellSize.X), underlinePixelY, colour) + } + + // strikethrough the cell if required + if cell.Strikethrough() { + ebitenutil.DrawLine( + r.frame, + float64(pixelX), + float64(pixelY+(r.font.CellSize.Y/2)), + float64(pixelX+r.font.CellSize.X), + float64(pixelY+(r.font.CellSize.Y/2)), + colour, + ) + } + + if r.enableLigatures && skipRunes == 0 { + skipRunes = r.handleLigatures(viewX, uint16(viewY), useFace, colour) + } + + if skipRunes > 0 { + skipRunes-- + continue + } + + // draw the text for the cell + text.Draw(r.frame, string(cell.Rune().Rune), useFace, pixelX, pixelY+r.font.DotDepth, colour) + } +} diff --git a/internal/app/darktile/gui/render/selection.go b/internal/app/darktile/gui/render/selection.go new file mode 100644 index 0000000..b7c7b64 --- /dev/null +++ b/internal/app/darktile/gui/render/selection.go @@ -0,0 +1,35 @@ +package render + +import ( + "github.com/hajimehoshi/ebiten/v2/ebitenutil" + "github.com/hajimehoshi/ebiten/v2/text" +) + +func (r *Render) drawSelection() { + _, selection := r.buffer.GetSelection() + if selection == nil { + // nothing selected + return + } + + bg, fg := r.theme.SelectionBackground(), r.theme.SelectionForeground() + + for y := selection.Start.Line; y <= selection.End.Line; y++ { + xStart, xEnd := 0, int(r.buffer.ViewWidth()) + if y == selection.Start.Line { + xStart = int(selection.Start.Col) + } + if y == selection.End.Line { + xEnd = int(selection.End.Col) + } + for x := xStart; x <= xEnd; x++ { + pX, pY := float64(x*r.font.CellSize.X), float64(y*uint64(r.font.CellSize.Y)) + ebitenutil.DrawRect(r.frame, pX, pY, float64(r.font.CellSize.X), float64(r.font.CellSize.Y), bg) + cell := r.buffer.GetCell(uint16(x), uint16(y)) + if cell == nil || cell.Rune().Rune == 0 { + continue + } + text.Draw(r.frame, string(cell.Rune().Rune), r.font.Regular, int(pX), int(pY)+r.font.DotDepth, fg) + } + } +} diff --git a/internal/app/darktile/gui/render/sixels.go b/internal/app/darktile/gui/render/sixels.go new file mode 100644 index 0000000..18c75d5 --- /dev/null +++ b/internal/app/darktile/gui/render/sixels.go @@ -0,0 +1,17 @@ +package render + +import "github.com/hajimehoshi/ebiten/v2" + +func (r *Render) drawSixels() { + for _, sixel := range r.buffer.GetVisibleSixels() { + op := &ebiten.DrawImageOptions{} + op.GeoM.Translate( + float64(int(sixel.Sixel.X)*r.font.CellSize.X), + float64(sixel.ViewLineOffset*r.font.CellSize.Y), + ) + r.frame.DrawImage( + ebiten.NewImageFromImage(sixel.Sixel.Image), + op, + ) + } +} diff --git a/internal/app/darktile/gui/update.go b/internal/app/darktile/gui/update.go index 36ad019..a546eaa 100644 --- a/internal/app/darktile/gui/update.go +++ b/internal/app/darktile/gui/update.go @@ -4,6 +4,7 @@ import ( "time" "github.com/hajimehoshi/ebiten/v2" + "github.com/liamg/darktile/internal/app/darktile/gui/popup" ) func (g *GUI) getModifierStr() string { @@ -40,7 +41,7 @@ func (g *GUI) Update() error { } func (g *GUI) filterPopupMessages() { - var filtered []PopupMessage + var filtered []popup.Message for _, msg := range g.popupMessages { if time.Since(msg.Expiry) >= 0 { continue diff --git a/internal/app/darktile/termutil/buffer.go b/internal/app/darktile/termutil/buffer.go index 5b38bd5..7c7ed6d 100644 --- a/internal/app/darktile/termutil/buffer.go +++ b/internal/app/darktile/termutil/buffer.go @@ -3,14 +3,28 @@ package termutil import ( "image" "image/color" + "sync" ) const TabSize = 8 +type CursorShape uint8 + +const ( + CursorShapeBlinkingBlock CursorShape = iota + CursorShapeDefault + CursorShapeSteadyBlock + CursorShapeBlinkingUnderline + CursorShapeSteadyUnderline + CursorShapeBlinkingBar + CursorShapeSteadyBar +) + type Buffer struct { lines []Line savedCursorPos Position savedCursorAttr *CellAttributes + cursorShape CursorShape savedCharsets []*map[rune]rune savedCurrentCharset int topMargin uint // see DECSTBM docs - this is for scrollable regions @@ -31,6 +45,7 @@ type Buffer struct { highlightEnd *Position highlightAnnotation *Annotation sixels []Sixel + selectionMu sync.Mutex } type Annotation struct { @@ -70,10 +85,19 @@ func NewBuffer(width, height uint16, maxLines uint64, fg color.Color, bg color.C ShowCursor: true, SixelScrolling: true, }, + cursorShape: CursorShapeDefault, } return b } +func (buffer *Buffer) SetCursorShape(shape CursorShape) { + buffer.cursorShape = shape +} + +func (buffer *Buffer) GetCursorShape() CursorShape { + return buffer.cursorShape +} + func (buffer *Buffer) IsCursorVisible() bool { return buffer.modes.ShowCursor } diff --git a/internal/app/darktile/termutil/csi.go b/internal/app/darktile/termutil/csi.go index 757b67c..df2471d 100644 --- a/internal/app/darktile/termutil/csi.go +++ b/internal/app/darktile/termutil/csi.go @@ -45,13 +45,6 @@ func (t *Terminal) handleCSI(readChan chan MeasuredRune) (renderRequired bool) { t.log("CSI P(%q) I(%q) %c", strings.Join(params, ";"), string(intermediate), final) - for _, b := range intermediate { - t.processRunes(MeasuredRune{ - Rune: b, - Width: 1, - }) - } - switch final { case 'c': return t.csiSendDeviceAttributesHandler(params) @@ -73,6 +66,10 @@ func (t *Terminal) handleCSI(readChan chan MeasuredRune) (renderRequired bool) { return t.csiSetMarginsHandler(params) case 't': return t.csiWindowManipulation(params) + case 'q': + if string(intermediate) == " " { + return t.csiCursorSelection(params) + } case 'A': return t.csiCursorUpHandler(params) case 'B': @@ -112,15 +109,22 @@ func (t *Terminal) handleCSI(readChan chan MeasuredRune) (renderRequired bool) { return t.csiSoftResetHandler(params) } return false - default: - // TODO review this: - // if this is an unknown CSI sequence, write it to stdout as we can't handle it? - //_ = t.writeToRealStdOut(append([]rune{0x1b, '['}, raw...)...) - _ = raw - t.log("UNKNOWN CSI P(%s) I(%s) %c", strings.Join(params, ";"), string(intermediate), final) - return false } + for _, b := range intermediate { + t.processRunes(MeasuredRune{ + Rune: b, + Width: 1, + }) + } + + // TODO review this: + // if this is an unknown CSI sequence, write it to stdout as we can't handle it? + //_ = t.writeToRealStdOut(append([]rune{0x1b, '['}, raw...)...) + _ = raw + t.log("UNKNOWN CSI P(%s) I(%s) %c", strings.Join(params, ";"), string(intermediate), final) + return false + } type WindowState uint8 @@ -963,6 +967,12 @@ func (t *Terminal) sgrSequenceHandler(params []string) bool { } } + x := t.GetActiveBuffer().CursorColumn() + y := t.GetActiveBuffer().CursorLine() + if cell := t.GetActiveBuffer().GetCell(x, y); cell != nil { + cell.attr = t.GetActiveBuffer().cursorAttr + } + return false } @@ -970,3 +980,15 @@ func (t *Terminal) csiSoftResetHandler(params []string) bool { t.reset() return true } + +func (t *Terminal) csiCursorSelection(params []string) (renderRequired bool) { + if len(params) == 0 { + return false + } + i, err := strconv.Atoi(params[0]) + if err != nil { + return false + } + t.GetActiveBuffer().SetCursorShape(CursorShape(i)) + return true +} diff --git a/internal/app/darktile/termutil/options.go b/internal/app/darktile/termutil/options.go index 9667199..501e1a3 100644 --- a/internal/app/darktile/termutil/options.go +++ b/internal/app/darktile/termutil/options.go @@ -8,6 +8,10 @@ type Option func(t *Terminal) func WithLogFile(path string) Option { return func(t *Terminal) { + if path == "-" { + t.logFile = os.Stdout + return + } t.logFile, _ = os.Create(path) } } diff --git a/internal/app/darktile/termutil/selection.go b/internal/app/darktile/termutil/selection.go index 9c0e4be..c4acbae 100644 --- a/internal/app/darktile/termutil/selection.go +++ b/internal/app/darktile/termutil/selection.go @@ -1,6 +1,8 @@ package termutil func (buffer *Buffer) ClearSelection() { + buffer.selectionMu.Lock() + defer buffer.selectionMu.Unlock() buffer.selectionStart = nil buffer.selectionEnd = nil } @@ -13,6 +15,9 @@ func (buffer *Buffer) GetBoundedTextAtPosition(pos Position) (start Position, en // if the selection is invalid - e.g. lines are selected that no longer exist in the buffer func (buffer *Buffer) fixSelection() bool { + buffer.selectionMu.Lock() + defer buffer.selectionMu.Unlock() + if buffer.selectionStart == nil || buffer.selectionEnd == nil { return false } @@ -44,6 +49,9 @@ func (buffer *Buffer) ExtendSelectionToEntireLines() { return } + buffer.selectionMu.Lock() + defer buffer.selectionMu.Unlock() + buffer.selectionStart.Col = 0 buffer.selectionEnd.Col = uint16(len(buffer.lines[buffer.selectionEnd.Line].cells)) - 1 } @@ -150,6 +158,8 @@ FORWARD: } func (buffer *Buffer) SetSelectionStart(pos Position) { + buffer.selectionMu.Lock() + defer buffer.selectionMu.Unlock() buffer.selectionStart = &Position{ Col: pos.Col, Line: buffer.convertViewLineToRawLine(uint16(pos.Line)), @@ -157,10 +167,14 @@ func (buffer *Buffer) SetSelectionStart(pos Position) { } func (buffer *Buffer) setRawSelectionStart(pos Position) { + buffer.selectionMu.Lock() + defer buffer.selectionMu.Unlock() buffer.selectionStart = &pos } func (buffer *Buffer) SetSelectionEnd(pos Position) { + buffer.selectionMu.Lock() + defer buffer.selectionMu.Unlock() buffer.selectionEnd = &Position{ Col: pos.Col, Line: buffer.convertViewLineToRawLine(uint16(pos.Line)), @@ -168,6 +182,8 @@ func (buffer *Buffer) SetSelectionEnd(pos Position) { } func (buffer *Buffer) setRawSelectionEnd(pos Position) { + buffer.selectionMu.Lock() + defer buffer.selectionMu.Unlock() buffer.selectionEnd = &pos } @@ -176,6 +192,9 @@ func (buffer *Buffer) GetSelection() (string, *Selection) { return "", nil } + buffer.selectionMu.Lock() + defer buffer.selectionMu.Unlock() + start := *buffer.selectionStart end := *buffer.selectionEnd @@ -187,6 +206,9 @@ func (buffer *Buffer) GetSelection() (string, *Selection) { var text string for y := start.Line; y <= end.Line; y++ { + if y >= uint64(len(buffer.lines)) { + break + } line := buffer.lines[y] startX := 0 endX := len(line.cells) - 1 @@ -200,7 +222,13 @@ func (buffer *Buffer) GetSelection() (string, *Selection) { text += "\n" } for x := startX; x <= endX; x++ { + if x >= len(line.cells) { + break + } mr := line.cells[x].Rune() + if mr.Width == 0 { + continue + } x += mr.Width - 1 text += string(mr.Rune) } @@ -221,6 +249,8 @@ func (buffer *Buffer) InSelection(pos Position) bool { if !buffer.fixSelection() { return false } + buffer.selectionMu.Lock() + defer buffer.selectionMu.Unlock() start := *buffer.selectionStart end := *buffer.selectionEnd @@ -256,31 +286,30 @@ func (buffer *Buffer) GetHighlightAnnotation() *Annotation { return buffer.highlightAnnotation } -// takes view coords -func (buffer *Buffer) IsHighlighted(pos Position) bool { +func (buffer *Buffer) GetViewHighlight() (start Position, end Position, exists bool) { if buffer.highlightStart == nil || buffer.highlightEnd == nil { - return false + return } if buffer.highlightStart.Line >= uint64(len(buffer.lines)) { - return false + return } if buffer.highlightEnd.Line >= uint64(len(buffer.lines)) { - return false + return } if buffer.highlightStart.Col >= uint16(len(buffer.lines[buffer.highlightStart.Line].cells)) { - return false + return } if buffer.highlightEnd.Col >= uint16(len(buffer.lines[buffer.highlightEnd.Line].cells)) { - return false + return } - start := *buffer.highlightStart - end := *buffer.highlightEnd + start = *buffer.highlightStart + end = *buffer.highlightEnd if end.Line < start.Line || (end.Line == start.Line && end.Col < start.Col) { swap := end @@ -288,23 +317,8 @@ func (buffer *Buffer) IsHighlighted(pos Position) bool { start = swap } - rY := buffer.convertViewLineToRawLine(uint16(pos.Line)) - if rY < start.Line { - return false - } - if rY > end.Line { - return false - } - if rY == start.Line { - if pos.Col < start.Col { - return false - } - } - if rY == end.Line { - if pos.Col > end.Col { - return false - } - } + start.Line = uint64(buffer.convertRawLineToViewLine(start.Line)) + end.Line = uint64(buffer.convertRawLineToViewLine(end.Line)) - return true + return start, end, true }