Skip to content

Commit

Permalink
Add blinking
Browse files Browse the repository at this point in the history
Add the ability to process blinking text
  • Loading branch information
mgazza authored and andydotxyz committed Apr 22, 2024
1 parent 599d4d6 commit 6a6996b
Show file tree
Hide file tree
Showing 8 changed files with 353 additions and 23 deletions.
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

0 comments on commit 6a6996b

Please sign in to comment.