Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions color.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func (t *Terminal) handleColorEscape(message string) {
t.currentBG = nil
t.currentFG = nil
t.bold = false
t.blinking = false
return
}
modes := strings.Split(message, ";")
Expand Down Expand Up @@ -80,9 +81,12 @@ func (t *Terminal) handleColorMode(modeStr string) {
case 0:
t.currentBG, t.currentFG = nil, nil
t.bold = false
t.blinking = false
case 1:
t.bold = true
case 4, 24: //italic
case 5:
t.blinking = true
case 7: // reverse
bg, fg := t.currentBG, t.currentFG
if fg == nil {
Expand Down
3 changes: 2 additions & 1 deletion color_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"fyne.io/fyne/v2"
"fyne.io/fyne/v2/widget"
widget2 "github.com/fyne-io/terminal/internal/widget"
"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -913,7 +914,7 @@ func TestHandleOutput_BufferCutoff(t *testing.T) {
term.Resize(termsize)
term.handleOutput([]byte("\x1b[38;5;64"))
term.handleOutput([]byte("m40\x1b[38;5;65m41"))
tg := widget.NewTextGrid()
tg := widget2.NewTermGrid()
tg.Resize(termsize)
c1 := &color.RGBA{R: 95, G: 135, A: 255}
c2 := &color.RGBA{R: 95, G: 135, B: 95, A: 255}
Expand Down
318 changes: 318 additions & 0 deletions internal/widget/termgrid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
package widget

import (
"context"
"image/color"
"math"
"strconv"
"time"

"fyne.io/fyne/v2/widget"

"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/theme"
)

const (
textAreaSpaceSymbol = '·'
textAreaTabSymbol = '→'
textAreaNewLineSymbol = '↵'
blinkingInterval = 500 * time.Millisecond
)

// TermGrid is a monospaced grid of characters.
// This is designed to be used by our terminal emulator.
type TermGrid struct {
widget.TextGrid
}

// CreateRenderer is a private method to Fyne which links this widget to it's renderer
func (t *TermGrid) CreateRenderer() fyne.WidgetRenderer {
t.ExtendBaseWidget(t)
render := &termGridRenderer{text: t}
render.updateCellSize()
// N.B these global variables are not a good idea.
widget.TextGridStyleDefault = &widget.CustomTextGridStyle{}
widget.TextGridStyleWhitespace = &widget.CustomTextGridStyle{FGColor: theme.DisabledColor()}

return render
}

// NewTermGrid creates a new empty TextGrid widget.
func NewTermGrid() *TermGrid {
grid := &TermGrid{}
grid.ExtendBaseWidget(grid)
return grid
}

type termGridRenderer struct {
text *TermGrid

cols, rows int

cellSize fyne.Size
objects []fyne.CanvasObject
current fyne.Canvas
blink bool
shouldBlink bool
tickerCancel context.CancelFunc
}

func (t *termGridRenderer) appendTextCell(str rune) {
text := canvas.NewText(string(str), theme.ForegroundColor())
text.TextStyle.Monospace = true

bg := canvas.NewRectangle(color.Transparent)
t.objects = append(t.objects, bg, text)
}

func (t *termGridRenderer) setCellRune(str rune, pos int, style widget.TextGridStyle) {
if str == 0 {
str = ' '
}
fg := theme.ForegroundColor()
if style != nil && style.TextColor() != nil {
fg = style.TextColor()
}
bg := color.Color(color.Transparent)
if style != nil && style.BackgroundColor() != nil {
bg = style.BackgroundColor()
}

if s, ok := style.(*TermTextGridStyle); ok && s != nil && s.BlinkEnabled {
t.shouldBlink = true
if t.blink {
fg = bg
}
}

text := t.objects[pos*2+1].(*canvas.Text)
text.TextSize = theme.TextSize()

newStr := string(str)
if text.Text != newStr || text.Color != fg {
text.Text = newStr
text.Color = fg
t.refresh(text)
}

rect := t.objects[pos*2].(*canvas.Rectangle)
if rect.FillColor != bg {
rect.FillColor = bg
t.refresh(rect)
}
}

func (t *termGridRenderer) addCellsIfRequired() {
cellCount := t.cols * t.rows
if len(t.objects) == cellCount*2 {
return
}
for i := len(t.objects); i < cellCount*2; i += 2 {
t.appendTextCell(' ')
}
}

func (t *termGridRenderer) refreshGrid() {
line := 1
x := 0
// reset shouldBlink which can be set by setCellRune if a cell with BlinkEnabled is found
t.shouldBlink = false

for rowIndex, row := range t.text.Rows {
i := 0
if t.text.ShowLineNumbers {
lineStr := []rune(strconv.Itoa(line))
pad := t.lineNumberWidth() - len(lineStr)
for ; i < pad; i++ {
t.setCellRune(' ', x, widget.TextGridStyleWhitespace) // padding space
x++
}
for c := 0; c < len(lineStr); c++ {
t.setCellRune(lineStr[c], x, widget.TextGridStyleDefault) // line numbers
i++
x++
}

t.setCellRune('|', x, widget.TextGridStyleWhitespace) // last space
i++
x++
}
for _, r := range row.Cells {
if i >= t.cols { // would be an overflow - bad
continue
}
if t.text.ShowWhitespace && (r.Rune == ' ' || r.Rune == '\t') {
sym := textAreaSpaceSymbol
if r.Rune == '\t' {
sym = textAreaTabSymbol
}

if r.Style != nil && r.Style.BackgroundColor() != nil {
whitespaceBG := &widget.CustomTextGridStyle{FGColor: widget.TextGridStyleWhitespace.TextColor(),
BGColor: r.Style.BackgroundColor()}
t.setCellRune(sym, x, whitespaceBG) // whitespace char
} else {
t.setCellRune(sym, x, widget.TextGridStyleWhitespace) // whitespace char
}
} else {
t.setCellRune(r.Rune, x, r.Style) // regular char
}
i++
x++
}
if t.text.ShowWhitespace && i < t.cols && rowIndex < len(t.text.Rows)-1 {
t.setCellRune(textAreaNewLineSymbol, x, widget.TextGridStyleWhitespace) // newline
i++
x++
}
for ; i < t.cols; i++ {
t.setCellRune(' ', x, widget.TextGridStyleDefault) // blanks
x++
}

line++
}
for ; x < len(t.objects)/2; x++ {
t.setCellRune(' ', x, widget.TextGridStyleDefault) // trailing cells and blank lines
}

switch {
case t.shouldBlink && t.tickerCancel == nil:
t.runBlink()
case !t.shouldBlink && t.tickerCancel != nil:
t.tickerCancel()
t.tickerCancel = nil
}
}

func (t *termGridRenderer) runBlink() {
if t.tickerCancel != nil {
t.tickerCancel()
t.tickerCancel = nil
}
var tickerContext context.Context
tickerContext, t.tickerCancel = context.WithCancel(context.Background())
ticker := time.NewTicker(blinkingInterval)
blinking := false
go func() {
for {
select {
case <-tickerContext.Done():
return
case <-ticker.C:
t.SetBlink(blinking)
blinking = !blinking
t.refreshGrid()
}
}
}()
}

func (t *termGridRenderer) lineNumberWidth() int {
return len(strconv.Itoa(t.rows + 1))
}

func (t *termGridRenderer) updateGridSize(size fyne.Size) {
bufRows := len(t.text.Rows)
bufCols := 0
for _, row := range t.text.Rows {
bufCols = int(math.Max(float64(bufCols), float64(len(row.Cells))))
}
sizeCols := math.Floor(float64(size.Width) / float64(t.cellSize.Width))
sizeRows := math.Floor(float64(size.Height) / float64(t.cellSize.Height))

if t.text.ShowWhitespace {
bufCols++
}
if t.text.ShowLineNumbers {
bufCols += t.lineNumberWidth()
}

t.cols = int(math.Max(sizeCols, float64(bufCols)))
t.rows = int(math.Max(sizeRows, float64(bufRows)))
t.addCellsIfRequired()
}

func (t *termGridRenderer) Layout(size fyne.Size) {
t.updateGridSize(size)

i := 0
cellPos := fyne.NewPos(0, 0)
for y := 0; y < t.rows; y++ {
for x := 0; x < t.cols; x++ {
t.objects[i*2+1].Move(cellPos)

t.objects[i*2].Resize(t.cellSize)
t.objects[i*2].Move(cellPos)
cellPos.X += t.cellSize.Width
i++
}

cellPos.X = 0
cellPos.Y += t.cellSize.Height
}
}

func (t *termGridRenderer) MinSize() fyne.Size {
longestRow := float32(0)
for _, row := range t.text.Rows {
longestRow = fyne.Max(longestRow, float32(len(row.Cells)))
}
return fyne.NewSize(t.cellSize.Width*longestRow,
t.cellSize.Height*float32(len(t.text.Rows)))
}

func (t *termGridRenderer) Refresh() {
// we may be on a new canvas, so just update it to be sure
if fyne.CurrentApp() != nil && fyne.CurrentApp().Driver() != nil {
t.current = fyne.CurrentApp().Driver().CanvasForObject(t.text)
}

// theme could change text size
t.updateCellSize()

widget.TextGridStyleWhitespace = &widget.CustomTextGridStyle{FGColor: theme.DisabledColor()}
t.updateGridSize(t.text.Size())
t.refreshGrid()
}

func (t *termGridRenderer) ApplyTheme() {
}

func (t *termGridRenderer) Objects() []fyne.CanvasObject {
return t.objects
}

func (t *termGridRenderer) Destroy() {
}

func (t *termGridRenderer) refresh(obj fyne.CanvasObject) {
if t.current == nil {
if fyne.CurrentApp() != nil && fyne.CurrentApp().Driver() != nil {
// cache canvas for this widget, so we don't look it up many times for every cell/row refresh!
t.current = fyne.CurrentApp().Driver().CanvasForObject(t.text)
}

if t.current == nil {
return // not yet set up perhaps?
}
}

t.current.Refresh(obj)
}

func (t *termGridRenderer) updateCellSize() {
size := fyne.MeasureText("M", theme.TextSize(), fyne.TextStyle{Monospace: true})

// round it for seamless background
size.Width = float32(math.Round(float64((size.Width))))
size.Height = float32(math.Round(float64((size.Height))))

t.cellSize = size
}

func (t *termGridRenderer) SetBlink(b bool) {
t.blink = b
}
Loading