Add support for ligatures, cursor shapes (and images) (#304)

This commit is contained in:
Liam Galvin 2021-08-02 20:55:04 +01:00 committed by GitHub
parent c18b702b61
commit 765a781055
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 784 additions and 387 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
.idea
.vscode
/darktile

View File

@ -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)
<p align="center">
<img src="cursor.gif">
</p>
## 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

BIN
cursor.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -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")

View File

@ -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 {

View File

@ -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,
},
}

View File

@ -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()
}

View File

@ -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 {

View File

@ -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

View File

@ -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)

View File

@ -0,0 +1,13 @@
package popup
import (
"image/color"
"time"
)
type Message struct {
Text string
Expiry time.Time
Foreground color.Color
Background color.Color
}

View File

@ -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,

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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())
}
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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,
)
}
}

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}