1
0
mirror of https://github.com/mum4k/termdash.git synced 2025-05-01 22:17:51 +08:00

Simplifying the text widget.

This results in a better line wrapping abstraction which now works on
cells. Cells contain both the rune and the cell options which were
awkward to track separately.
This commit is contained in:
Jakub Sobon 2019-02-28 00:50:16 -05:00
parent 61aca3fb62
commit 87cab66617
No known key found for this signature in database
GPG Key ID: F2451A77FB05D3B7
6 changed files with 329 additions and 225 deletions

View File

@ -24,6 +24,15 @@ import (
"github.com/mum4k/termdash/internal/runewidth" "github.com/mum4k/termdash/internal/runewidth"
) )
// NewCells breaks the provided text into cells and applies the options.
func NewCells(text string, opts ...cell.Option) []*Cell {
var res []*Cell
for _, r := range text {
res = append(res, NewCell(r, opts...))
}
return res
}
// Cell represents a single cell on the terminal. // Cell represents a single cell on the terminal.
type Cell struct { type Cell struct {
// Rune is the rune stored in the cell. // Rune is the rune stored in the cell.
@ -33,6 +42,11 @@ type Cell struct {
Opts *cell.Options Opts *cell.Options
} }
// String implements fmt.Stringer.
func (c *Cell) String() string {
return fmt.Sprintf("{%q}", c.Rune)
}
// NewCell returns a new cell. // NewCell returns a new cell.
func NewCell(r rune, opts ...cell.Option) *Cell { func NewCell(r rune, opts ...cell.Option) *Cell {
return &Cell{ return &Cell{

View File

@ -15,6 +15,7 @@
package buffer package buffer
import ( import (
"fmt"
"image" "image"
"testing" "testing"
@ -22,23 +23,68 @@ import (
"github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/cell"
) )
func TestNewCells(t *testing.T) {
tests := []struct {
desc string
text string
opts []cell.Option
want []*Cell
}{
{
desc: "no cells for empty text",
},
{
desc: "cells created from text with default options",
text: "hello",
want: []*Cell{
NewCell('h'),
NewCell('e'),
NewCell('l'),
NewCell('l'),
NewCell('o'),
},
},
{
desc: "cells with options",
text: "ha",
opts: []cell.Option{
cell.FgColor(cell.ColorCyan),
cell.BgColor(cell.ColorMagenta),
},
want: []*Cell{
NewCell('h', cell.FgColor(cell.ColorCyan), cell.BgColor(cell.ColorMagenta)),
NewCell('a', cell.FgColor(cell.ColorCyan), cell.BgColor(cell.ColorMagenta)),
},
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
got := NewCells(tc.text, tc.opts...)
if diff := pretty.Compare(tc.want, got); diff != "" {
t.Errorf("NewCells => unexpected diff (-want, +got):\n%s", diff)
}
})
}
}
func TestNewCell(t *testing.T) { func TestNewCell(t *testing.T) {
tests := []struct { tests := []struct {
desc string desc string
r rune r rune
opts []cell.Option opts []cell.Option
want Cell want *Cell
}{ }{
{ {
desc: "creates empty cell with default options", desc: "creates empty cell with default options",
want: Cell{ want: &Cell{
Opts: &cell.Options{}, Opts: &cell.Options{},
}, },
}, },
{ {
desc: "cell with the specified rune", desc: "cell with the specified rune",
r: 'X', r: 'X',
want: Cell{ want: &Cell{
Rune: 'X', Rune: 'X',
Opts: &cell.Options{}, Opts: &cell.Options{},
}, },
@ -50,7 +96,7 @@ func TestNewCell(t *testing.T) {
cell.FgColor(cell.ColorCyan), cell.FgColor(cell.ColorCyan),
cell.BgColor(cell.ColorMagenta), cell.BgColor(cell.ColorMagenta),
}, },
want: Cell{ want: &Cell{
Rune: 'X', Rune: 'X',
Opts: &cell.Options{ Opts: &cell.Options{
FgColor: cell.ColorCyan, FgColor: cell.ColorCyan,
@ -67,7 +113,7 @@ func TestNewCell(t *testing.T) {
BgColor: cell.ColorBlue, BgColor: cell.ColorBlue,
}, },
}, },
want: Cell{ want: &Cell{
Rune: 'X', Rune: 'X',
Opts: &cell.Options{ Opts: &cell.Options{
FgColor: cell.ColorBlack, FgColor: cell.ColorBlack,
@ -80,8 +126,9 @@ func TestNewCell(t *testing.T) {
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) { t.Run(tc.desc, func(t *testing.T) {
got := NewCell(tc.r, tc.opts...) got := NewCell(tc.r, tc.opts...)
t.Logf(fmt.Sprintf("%v", got))
if diff := pretty.Compare(tc.want, got); diff != "" { if diff := pretty.Compare(tc.want, got); diff != "" {
t.Errorf("New => unexpected diff (-want, +got):\n%s", diff) t.Errorf("NewCell => unexpected diff (-want, +got):\n%s", diff)
} }
}) })
} }

View File

@ -16,9 +16,7 @@
package wrap package wrap
import ( import (
"strings" "github.com/mum4k/termdash/internal/canvas/buffer"
"text/scanner"
"github.com/mum4k/termdash/internal/runewidth" "github.com/mum4k/termdash/internal/runewidth"
) )
@ -49,7 +47,9 @@ const (
AtRunes AtRunes
// AtWords is a wrapping mode where if the width of the text crosses the // AtWords is a wrapping mode where if the width of the text crosses the
// width of the canvas, wrapping is performed at rune boundaries. // width of the canvas, wrapping is performed at word boundaries. The
// wrapping still switches back to the AtRunes mode for any words that are
// longer than the width.
AtWords AtWords
) )
@ -58,119 +58,139 @@ const (
// This will always return false if no options are provided, since the default // This will always return false if no options are provided, since the default
// behavior is to not wrap the text. // behavior is to not wrap the text.
func needed(r rune, posX, width int, m Mode) bool { func needed(r rune, posX, width int, m Mode) bool {
if r == '\n' {
// Don't wrap for newline characters as they aren't printed on the
// canvas, i.e. they take no horizontal space.
return false
}
rw := runewidth.RuneWidth(r) rw := runewidth.RuneWidth(r)
return posX > width-rw && m == AtRunes return posX > width-rw && m == AtRunes
} }
// Lines finds the starting positions of all lines in the text when the // Cells returns the cells wrapped into individual lines according to the
// text is drawn on a canvas of the provided width and the specified wrapping // specified width and wrapping mode.
// mode. //
func Lines(text string, width int, m Mode) []int { // This function consumes any cells that contain newline characters and uses
if width <= 0 || len(text) == 0 { // them to start new lines.
//
// If the mode is AtWords, this function also drops cells with leading space
// character before a word at which the wrap occurs.
func Cells(cells []*buffer.Cell, width int, m Mode) [][]*buffer.Cell {
if width <= 0 || len(cells) == 0 {
return nil return nil
} }
ls := newLineScanner(text, width, m) cs := newCellScanner(cells, width, m)
for state := scanStart; state != nil; state = state(ls) { for state := scanCellLine; state != nil; state = state(cs) {
} }
return ls.lines return cs.lines
} }
// lineScanner tracks the progress of scanning the input text when finding // cellScannerState is a state in the FSM that scans the input text and identifies
// lines. Lines are identified when newline characters are encountered in the // newlines.
// input text or when the canvas width and configuration requires line type cellScannerState func(*cellScanner) cellScannerState
// wrapping.
type lineScanner struct { // cellScanner tracks the progress of scanning the input cells when finding
// scanner is a lexer of the input text. // lines.
scanner *scanner.Scanner type cellScanner struct {
// cells are the cells being scanned.
cells []*buffer.Cell
// nextIdx is the index of the cell that will be returned by next.
nextIdx int
// width is the width of the canvas the text will be drawn on. // width is the width of the canvas the text will be drawn on.
width int width int
// posX tracks the horizontal position of the current character on the // posX tracks the horizontal position of the current cell on the canvas.
// canvas.
posX int posX int
// mode is the wrapping mode. // mode is the wrapping mode.
mode Mode mode Mode
// lines are the starting points of the identified lines. // lines are the identified lines.
lines []int lines [][]*buffer.Cell
// line is the current line.
line []*buffer.Cell
} }
// newLineScanner returns a new line scanner of the provided text. // newCellScanner returns a scanner of the provided cells.
func newLineScanner(text string, width int, m Mode) *lineScanner { func newCellScanner(cells []*buffer.Cell, width int, m Mode) *cellScanner {
var s scanner.Scanner return &cellScanner{
s.Init(strings.NewReader(text)) cells: cells,
s.Whitespace = 0 // Don't ignore any whitespace. width: width,
s.Mode = scanner.ScanIdents mode: m,
s.IsIdentRune = func(ch rune, i int) bool {
return i == 0 && ch == '\n'
}
return &lineScanner{
scanner: &s,
width: width,
mode: m,
} }
} }
// scannerState is a state in the FSM that scans the input text and identifies // next returns the next cell and advances the scanner.
// newlines. // Returns nil when there are no more cells to scan.
type scannerState func(*lineScanner) scannerState func (cs *cellScanner) next() *buffer.Cell {
c := cs.peek()
if c != nil {
cs.nextIdx++
}
return c
}
// scanStart records the starting location of the current line. // peek returns the next cell without advancing the scanner's position.
func scanStart(ls *lineScanner) scannerState { // Returns nil when there are no more cells to peek at.
switch tok := ls.scanner.Peek(); { func (cs *cellScanner) peek() *buffer.Cell {
case tok == scanner.EOF: if cs.nextIdx >= len(cs.cells) {
return nil return nil
default:
ls.lines = append(ls.lines, ls.scanner.Position.Offset)
return scanLine
} }
return cs.cells[cs.nextIdx]
} }
// scanLine scans a line until it finds its end. // peekPrev returns the previous cell without changing the scanner's position.
func scanLine(ls *lineScanner) scannerState { // Returns nil if the scanner is at the first cell.
func (cs *cellScanner) peekPrev() *buffer.Cell {
if cs.nextIdx == 0 {
return nil
}
return cs.cells[cs.nextIdx-1]
}
// scanCellLine scans a line until it finds its end due to a newline character
// or the specified width.
func scanCellLine(cs *cellScanner) cellScannerState {
for { for {
switch tok := ls.scanner.Scan(); {
case tok == scanner.EOF: cell := cs.next()
if cell == nil {
if len(cs.line) > 0 || cs.peekPrev().Rune == '\n' {
cs.lines = append(cs.lines, cs.line)
}
return nil return nil
}
case tok == scanner.Ident: switch r := cell.Rune; {
return scanLineBreak case r == '\n':
return scanCellLineBreak
case needed(tok, ls.posX, ls.width, ls.mode): case needed(r, cs.posX, cs.width, cs.mode):
return scanLineWrap return scanCellLineWrap
default: default:
// Move horizontally within the line for each scanned character. // Move horizontally within the line for each scanned cell.
ls.posX += runewidth.RuneWidth(tok) cs.posX += runewidth.RuneWidth(r)
// Copy the cell into the current line.
cs.line = append(cs.line, cell)
} }
} }
} }
// scanLineBreak processes a newline character in the input text. // scanCellLineBreak processes a newline character cell.
func scanLineBreak(ls *lineScanner) scannerState { func scanCellLineBreak(cs *cellScanner) cellScannerState {
// Newline characters aren't printed, the following character starts the line. cs.lines = append(cs.lines, cs.line)
if ls.scanner.Peek() != scanner.EOF { cs.posX = 0
ls.posX = 0 cs.line = nil
ls.lines = append(ls.lines, ls.scanner.Position.Offset+1) return scanCellLine
}
return scanLine
} }
// scanLineWrap processes a line wrap due to canvas width. // scanCellLineWrap processes a line wrap due to canvas width.
func scanLineWrap(ls *lineScanner) scannerState { func scanCellLineWrap(cs *cellScanner) cellScannerState {
// The character on which we wrapped will be printed and is the start of // The character on which we wrapped will be printed and is the start of
// new line. // new line.
ls.posX = runewidth.StringWidth(ls.scanner.TokenText()) cs.lines = append(cs.lines, cs.line)
ls.lines = append(ls.lines, ls.scanner.Position.Offset) cs.posX = runewidth.RuneWidth(cs.peekPrev().Rune)
return scanLine cs.line = []*buffer.Cell{cs.peekPrev()}
return scanCellLine
} }

View File

@ -15,9 +15,11 @@
package wrap package wrap
import ( import (
"fmt"
"testing" "testing"
"github.com/kylelemons/godebug/pretty" "github.com/kylelemons/godebug/pretty"
"github.com/mum4k/termdash/internal/canvas/buffer"
) )
func TestNeeded(t *testing.T) { func TestNeeded(t *testing.T) {
@ -101,142 +103,230 @@ func TestNeeded(t *testing.T) {
} }
} }
func TestLines(t *testing.T) { func TestCells(t *testing.T) {
tests := []struct { tests := []struct {
desc string desc string
text string cells []*buffer.Cell
// width is the width of the canvas. // width is the width of the canvas.
width int width int
mode Mode mode Mode
want []int want [][]*buffer.Cell
}{ }{
{ {
desc: "zero text", desc: "zero text",
text: "",
width: 1, width: 1,
want: nil,
}, },
{ {
desc: "zero canvas width", desc: "zero canvas width",
text: "hello", cells: buffer.NewCells("hello"),
width: 0, width: 0,
want: nil, want: nil,
}, },
{ {
desc: "wrapping disabled, no newlines, fits in canvas width", desc: "wrapping disabled, no newlines, fits in canvas width",
text: "hello", cells: buffer.NewCells("hello"),
width: 5, width: 5,
want: []int{0}, want: [][]*buffer.Cell{
buffer.NewCells("hello"),
},
}, },
{ {
desc: "wrapping disabled, no newlines, doesn't fits in canvas width", desc: "wrapping disabled, no newlines, doesn't fits in canvas width",
text: "hello", cells: buffer.NewCells("hello"),
width: 4, width: 4,
want: []int{0}, want: [][]*buffer.Cell{
buffer.NewCells("hello"),
},
}, },
{ {
desc: "wrapping disabled, newlines, fits in canvas width", desc: "wrapping disabled, newlines, fits in canvas width",
text: "hello\nworld", cells: buffer.NewCells("hello\nworld"),
width: 5, width: 5,
want: []int{0, 6}, want: [][]*buffer.Cell{
buffer.NewCells("hello"),
buffer.NewCells("world"),
},
}, },
{ {
desc: "wrapping disabled, newlines, doesn't fit in canvas width", desc: "wrapping disabled, newlines, doesn't fit in canvas width",
text: "hello\nworld", cells: buffer.NewCells("hello\nworld"),
width: 4, width: 4,
want: []int{0, 6}, want: [][]*buffer.Cell{
buffer.NewCells("hello"),
buffer.NewCells("world"),
},
}, },
{ {
desc: "wrapping enabled, no newlines, fits in canvas width", desc: "wrapping enabled, no newlines, fits in canvas width",
text: "hello", cells: buffer.NewCells("hello"),
width: 5, width: 5,
mode: AtRunes, mode: AtRunes,
want: []int{0}, want: [][]*buffer.Cell{
buffer.NewCells("hello"),
},
}, },
{ {
desc: "wrapping enabled, no newlines, doesn't fit in canvas width", desc: "wrapping enabled, no newlines, doesn't fit in canvas width",
text: "hello", cells: buffer.NewCells("hello"),
width: 4, width: 4,
mode: AtRunes, mode: AtRunes,
want: []int{0, 4}, want: [][]*buffer.Cell{
buffer.NewCells("hell"),
buffer.NewCells("o"),
},
}, },
{ {
desc: "wrapping enabled, newlines, fits in canvas width", desc: "wrapping enabled, newlines, fits in canvas width",
text: "hello\nworld", cells: buffer.NewCells("hello\nworld"),
width: 5, width: 5,
mode: AtRunes, mode: AtRunes,
want: []int{0, 6}, want: [][]*buffer.Cell{
buffer.NewCells("hello"),
buffer.NewCells("world"),
},
}, },
{ {
desc: "wrapping enabled, newlines, doesn't fit in canvas width", desc: "wrapping enabled, newlines, doesn't fit in canvas width",
text: "hello\nworld", cells: buffer.NewCells("hello\nworld"),
width: 4, width: 4,
mode: AtRunes, mode: AtRunes,
want: []int{0, 4, 6, 10}, want: [][]*buffer.Cell{
buffer.NewCells("hell"),
buffer.NewCells("o"),
buffer.NewCells("worl"),
buffer.NewCells("d"),
},
}, },
{ {
desc: "wrapping enabled, newlines, doesn't fit in canvas width, unicode characters", desc: "wrapping enabled, newlines, doesn't fit in canvas width, unicode characters",
text: "⇧\n…\n⇩", cells: buffer.NewCells("⇧\n…\n⇩"),
width: 1, width: 1,
mode: AtRunes, mode: AtRunes,
want: []int{0, 4, 8}, want: [][]*buffer.Cell{
buffer.NewCells("⇧"),
buffer.NewCells("…"),
buffer.NewCells("⇩"),
},
}, },
{ {
desc: "wrapping enabled, newlines, doesn't fit in width, full-width unicode characters", desc: "wrapping enabled, newlines, doesn't fit in width, full-width unicode characters",
text: "你好\n世界", cells: buffer.NewCells("你好\n世界"),
width: 2, width: 2,
mode: AtRunes, mode: AtRunes,
want: []int{0, 3, 7, 10}, want: [][]*buffer.Cell{
buffer.NewCells("你"),
buffer.NewCells("好"),
buffer.NewCells("世"),
buffer.NewCells("界"),
},
}, },
{ {
desc: "wraps before a full-width character that starts in and falls out", desc: "wraps before a full-width character that starts in and falls out",
text: "a你b", cells: buffer.NewCells("a你b"),
width: 2, width: 2,
mode: AtRunes, mode: AtRunes,
want: []int{0, 1, 4}, want: [][]*buffer.Cell{
buffer.NewCells("a"),
buffer.NewCells("你"),
buffer.NewCells("b"),
},
}, },
{ {
desc: "wraps before a full-width character that falls out", desc: "wraps before a full-width character that falls out",
text: "ab你b", cells: buffer.NewCells("ab你b"),
width: 2, width: 2,
mode: AtRunes, mode: AtRunes,
want: []int{0, 2, 5}, want: [][]*buffer.Cell{
buffer.NewCells("ab"),
buffer.NewCells("你"),
buffer.NewCells("b"),
},
}, },
{ {
desc: "handles leading and trailing newlines", desc: "handles leading and trailing newlines",
text: "\n\n\nhello\n\n\n", cells: buffer.NewCells("\n\n\nhello\n\n\n"),
width: 4, width: 4,
mode: AtRunes, mode: AtRunes,
want: []int{0, 1, 2, 3, 7, 9, 10}, want: [][]*buffer.Cell{
buffer.NewCells(""),
buffer.NewCells(""),
buffer.NewCells(""),
buffer.NewCells("hell"),
buffer.NewCells("o"),
buffer.NewCells(""),
buffer.NewCells(""),
buffer.NewCells(""),
},
}, },
{ {
desc: "handles multiple newlines in the middle", desc: "handles multiple newlines in the middle",
text: "hello\n\n\nworld", cells: buffer.NewCells("hello\n\n\nworld"),
width: 5, width: 5,
mode: AtRunes, mode: AtRunes,
want: []int{0, 6, 7, 8}, want: [][]*buffer.Cell{
buffer.NewCells("hello"),
buffer.NewCells(""),
buffer.NewCells(""),
buffer.NewCells("world"),
},
}, },
{ {
desc: "handles multiple newlines in the middle and wraps", desc: "handles multiple newlines in the middle and wraps",
text: "hello\n\n\nworld", cells: buffer.NewCells("hello\n\n\nworld"),
width: 2, width: 2,
mode: AtRunes, mode: AtRunes,
want: []int{0, 2, 4, 6, 7, 8, 10, 12}, want: [][]*buffer.Cell{
buffer.NewCells("he"),
buffer.NewCells("ll"),
buffer.NewCells("o"),
buffer.NewCells(""),
buffer.NewCells(""),
buffer.NewCells("wo"),
buffer.NewCells("rl"),
buffer.NewCells("d"),
},
}, },
{ {
desc: "contains only newlines", desc: "contains only newlines",
text: "\n\n\n", cells: buffer.NewCells("\n\n\n"),
width: 4, width: 4,
mode: AtRunes, mode: AtRunes,
want: []int{0, 1, 2}, want: [][]*buffer.Cell{
buffer.NewCells(""),
buffer.NewCells(""),
buffer.NewCells(""),
buffer.NewCells(""),
},
}, },
/*
{
desc: "wraps at words, all fit individually",
text: "aaa bb cc ddddd",
width: 5,
mode: AtRunes,
want: []int{0, 4, 7, 10},
},*/
// wraps at words - handles newline characters
// wraps at words - handles leading and trailing newlines
// wraps at words - handles continuous newlines
// wraps at words, no need to wrap
// wraps at words all individually fit within width
// wraps at words, no spaces so goes back to AtRunes
// wraps at words, one doesn't fit so goes back to AtRunes
// wraps at words - full width runes - fit exactly
// wraps at words - full width runes - cause a wrap
// weird cases with multiple spaces between words
// preserves cell options
// Inserted cells have the same cell options ?
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) { t.Run(tc.desc, func(t *testing.T) {
got := Lines(tc.text, tc.width, tc.mode) t.Logf(fmt.Sprintf("Mode: %v", tc.mode))
got := Cells(tc.cells, tc.width, tc.mode)
if diff := pretty.Compare(tc.want, got); diff != "" { if diff := pretty.Compare(tc.want, got); diff != "" {
t.Errorf("Lines => unexpected diff (-want, +got):\n%s", diff) t.Errorf("Cells =>\n got:%v\nwant:%v\nunexpected diff (-want, +got):\n%s", got, tc.want, diff)
} }
}) })
} }

View File

@ -16,15 +16,14 @@
package text package text
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"image" "image"
"sync" "sync"
"unicode" "unicode"
"github.com/mum4k/termdash/internal/attrrange"
"github.com/mum4k/termdash/internal/canvas" "github.com/mum4k/termdash/internal/canvas"
"github.com/mum4k/termdash/internal/canvas/buffer"
"github.com/mum4k/termdash/internal/widgetapi" "github.com/mum4k/termdash/internal/widgetapi"
"github.com/mum4k/termdash/internal/wrap" "github.com/mum4k/termdash/internal/wrap"
"github.com/mum4k/termdash/terminal/terminalapi" "github.com/mum4k/termdash/terminal/terminalapi"
@ -41,12 +40,11 @@ import (
// //
// Implements widgetapi.Widget. This object is thread-safe. // Implements widgetapi.Widget. This object is thread-safe.
type Text struct { type Text struct {
// buff contains the text to be displayed in the widget. // content is the text content that will be displayed in the widget as
buff bytes.Buffer // provided by the caller (i.e. not wrapped or pre-processed).
// givenWOpts are write options given for the text. content []*buffer.Cell
givenWOpts []*writeOptions // wrapped is the content wrapped to the current width of the canvas.
// wOptsTracker tracks the positions in a buff to which the givenWOpts apply. wrapped [][]*buffer.Cell
wOptsTracker *attrrange.Tracker
// scroll tracks scrolling the position. // scroll tracks scrolling the position.
scroll *scrollTracker scroll *scrollTracker
@ -58,15 +56,6 @@ type Text struct {
// the last drawing. Used to determine if the previous line wrapping was // the last drawing. Used to determine if the previous line wrapping was
// invalidated. // invalidated.
contentChanged bool contentChanged bool
// lines stores the starting locations in bytes of all the lines in the
// buffer. I.e. positions of newline characters and of any calculated line wraps.
// The indexes in this slice are the line numbers.
lines []int
// lineStartToIdx maps the rune positions where line starts are to indexes,
// the line numbers.
// This is the same data as in lines, but available for quick lookup based
// on character index.
lineStartToIdx map[int]int
// mu protects the Text widget. // mu protects the Text widget.
mu sync.Mutex mu sync.Mutex
@ -82,9 +71,8 @@ func New(opts ...Option) (*Text, error) {
return nil, err return nil, err
} }
return &Text{ return &Text{
wOptsTracker: attrrange.NewTracker(), scroll: newScrollTracker(opt),
scroll: newScrollTracker(opt), opts: opt,
opts: opt,
}, nil }, nil
} }
@ -97,14 +85,11 @@ func (t *Text) Reset() {
// reset implements Reset, caller must hold t.mu. // reset implements Reset, caller must hold t.mu.
func (t *Text) reset() { func (t *Text) reset() {
t.buff.Reset() t.content = nil
t.givenWOpts = nil t.wrapped = nil
t.wOptsTracker = attrrange.NewTracker()
t.scroll = newScrollTracker(t.opts) t.scroll = newScrollTracker(t.opts)
t.lastWidth = 0 t.lastWidth = 0
t.contentChanged = true t.contentChanged = true
t.lines = nil
t.lineStartToIdx = map[int]int{}
} }
// Write writes text for the widget to display. Multiple calls append // Write writes text for the widget to display. Multiple calls append
@ -125,15 +110,8 @@ func (t *Text) Write(text string, wOpts ...WriteOption) error {
if opts.replace { if opts.replace {
t.reset() t.reset()
} }
for _, r := range text {
pos := t.buff.Len() t.content = append(t.content, buffer.NewCell(r, opts.cellOpts))
t.givenWOpts = append(t.givenWOpts, opts)
wOptsIdx := len(t.givenWOpts) - 1
if err := t.wOptsTracker.Add(pos, pos+len(text), wOptsIdx); err != nil {
return err
}
if _, err := t.buff.WriteString(text); err != nil {
return err
} }
t.contentChanged = true t.contentChanged = true
return nil return nil
@ -166,7 +144,7 @@ func (t *Text) drawScrollUp(cvs *canvas.Canvas, cur image.Point, fromLine int) (
// the marker was drawn. // the marker was drawn.
func (t *Text) drawScrollDown(cvs *canvas.Canvas, cur image.Point, fromLine int) (bool, error) { func (t *Text) drawScrollDown(cvs *canvas.Canvas, cur image.Point, fromLine int) (bool, error) {
height := cvs.Area().Dy() height := cvs.Area().Dy()
lines := len(t.lines) lines := len(t.wrapped)
if cur.Y == height-1 && height >= minLinesForMarkers && height < lines-fromLine { if cur.Y == height-1 && height >= minLinesForMarkers && height < lines-fromLine {
cells, err := cvs.SetCell(cur, '⇩') cells, err := cvs.SetCell(cur, '⇩')
if err != nil { if err != nil {
@ -180,32 +158,13 @@ func (t *Text) drawScrollDown(cvs *canvas.Canvas, cur image.Point, fromLine int)
return false, nil return false, nil
} }
// isLineStart asserts whether a rune from the text at the specified position
// should be placed on a new line.
// Argument fromLine indicates the starting line we are drawing the text from
// and is needed, because this function must return false for the very first
// line drawn. The first line is already a new line.
func (t *Text) isLineStart(pos, fromLine int) bool {
idx, ok := t.lineStartToIdx[pos]
return ok && idx != fromLine
}
// draw draws the text context on the canvas starting at the specified line. // draw draws the text context on the canvas starting at the specified line.
func (t *Text) draw(text string, cvs *canvas.Canvas) error { func (t *Text) draw(cvs *canvas.Canvas) error {
var cur image.Point // Tracks the current drawing position on the canvas. var cur image.Point // Tracks the current drawing position on the canvas.
height := cvs.Area().Dy() height := cvs.Area().Dy()
fromLine := t.scroll.firstLine(len(t.lines), height) fromLine := t.scroll.firstLine(len(t.wrapped), height)
optRange, err := t.wOptsTracker.ForPosition(0) // Text options for the current byte.
if err != nil {
return err
}
startPos := t.lines[fromLine]
var drawnScrollUp bool // Indicates if a scroll up marker was drawn.
for i, r := range text {
if i < startPos {
continue
}
for _, line := range t.wrapped[fromLine:] {
// Scroll up marker. // Scroll up marker.
scrlUp, err := t.drawScrollUp(cvs, cur, fromLine) scrlUp, err := t.drawScrollUp(cvs, cur, fromLine)
if err != nil { if err != nil {
@ -213,57 +172,36 @@ func (t *Text) draw(text string, cvs *canvas.Canvas) error {
} }
if scrlUp { if scrlUp {
cur = image.Point{0, cur.Y + 1} // Move to the next line. cur = image.Point{0, cur.Y + 1} // Move to the next line.
startPos = t.lines[fromLine+1] // Skip one line of text, the marker replaced it. // Skip one line of text, the marker replaced it.
drawnScrollUp = true
continue continue
} }
// Line wrapping.
fr := fromLine
if drawnScrollUp {
// The scroll marker inserted a line so we are off-by-one when
// looking up new lines.
fr++
}
if t.isLineStart(i, fr) {
cur = image.Point{0, cur.Y + 1} // Move to the next line.
}
// Scroll down marker. // Scroll down marker.
scrlDown, err := t.drawScrollDown(cvs, cur, fromLine) scrlDown, err := t.drawScrollDown(cvs, cur, fromLine)
if err != nil { if err != nil {
return err return err
} }
if scrlDown || cur.Y >= height { if scrlDown || cur.Y >= height {
break // Trim all lines falling after the canvas. break // Skip all lines falling after (under) the canvas.
} }
tr, err := lineTrim(cvs, cur, r, t.opts) for _, cell := range line {
if err != nil { tr, err := lineTrim(cvs, cur, cell.Rune, t.opts)
return err
}
cur = tr.curPoint
if tr.trimmed {
continue // Skip over any characters trimmed on the current line.
}
if r == '\n' {
continue // Don't print the newline runes, just interpret them above.
}
if i >= optRange.High { // Get the next write options.
or, err := t.wOptsTracker.ForPosition(i)
if err != nil { if err != nil {
return err return err
} }
optRange = or cur = tr.curPoint
if tr.trimmed {
break // Skip over any characters trimmed on the current line.
}
cells, err := cvs.SetCell(cur, cell.Rune, cell.Opts)
if err != nil {
return err
}
cur = image.Point{cur.X + cells, cur.Y} // Move within the same line.
} }
wOpts := t.givenWOpts[optRange.AttrIdx] cur = image.Point{0, cur.Y + 1} // Move to the next line.
cells, err := cvs.SetCell(cur, r, wOpts.cellOpts)
if err != nil {
return err
}
cur = image.Point{cur.X + cells, cur.Y} // Move within the same line.
} }
return nil return nil
} }
@ -274,24 +212,19 @@ func (t *Text) Draw(cvs *canvas.Canvas) error {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
text := t.buff.String()
width := cvs.Area().Dx() width := cvs.Area().Dx()
if t.contentChanged || t.lastWidth != width { if t.contentChanged || t.lastWidth != width {
// The previous text preprocessing (line wrapping) is invalidated when // The previous text preprocessing (line wrapping) is invalidated when
// new text is added or the width of the canvas changed. // new text is added or the width of the canvas changed.
t.lines = wrap.Lines(text, width, t.opts.wrapMode) t.wrapped = wrap.Cells(t.content, width, t.opts.wrapMode)
t.lineStartToIdx = map[int]int{}
for idx, start := range t.lines {
t.lineStartToIdx[start] = idx
}
} }
t.lastWidth = width t.lastWidth = width
if len(t.lines) == 0 { if len(t.wrapped) == 0 {
return nil // Nothing to draw if there's no text. return nil // Nothing to draw if there's no text.
} }
if err := t.draw(text, cvs); err != nil { if err := t.draw(cvs); err != nil {
return err return err
} }
t.contentChanged = false t.contentChanged = false

View File

@ -199,7 +199,7 @@ func TestTextDraws(t *testing.T) {
if err := widget.Write("red\n", WriteCellOpts(cell.FgColor(cell.ColorRed))); err != nil { if err := widget.Write("red\n", WriteCellOpts(cell.FgColor(cell.ColorRed))); err != nil {
return err return err
} }
return widget.Write("blue\n", WriteCellOpts(cell.FgColor(cell.ColorBlue))) return widget.Write("blue", WriteCellOpts(cell.FgColor(cell.ColorBlue)))
}, },
want: func(size image.Point) *faketerm.Terminal { want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size) ft := faketerm.MustNew(size)