1
0
mirror of https://github.com/mum4k/termdash.git synced 2025-04-27 13:48:49 +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"
)
// 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.
type Cell struct {
// Rune is the rune stored in the cell.
@ -33,6 +42,11 @@ type Cell struct {
Opts *cell.Options
}
// String implements fmt.Stringer.
func (c *Cell) String() string {
return fmt.Sprintf("{%q}", c.Rune)
}
// NewCell returns a new cell.
func NewCell(r rune, opts ...cell.Option) *Cell {
return &Cell{

View File

@ -15,6 +15,7 @@
package buffer
import (
"fmt"
"image"
"testing"
@ -22,23 +23,68 @@ import (
"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) {
tests := []struct {
desc string
r rune
opts []cell.Option
want Cell
want *Cell
}{
{
desc: "creates empty cell with default options",
want: Cell{
want: &Cell{
Opts: &cell.Options{},
},
},
{
desc: "cell with the specified rune",
r: 'X',
want: Cell{
want: &Cell{
Rune: 'X',
Opts: &cell.Options{},
},
@ -50,7 +96,7 @@ func TestNewCell(t *testing.T) {
cell.FgColor(cell.ColorCyan),
cell.BgColor(cell.ColorMagenta),
},
want: Cell{
want: &Cell{
Rune: 'X',
Opts: &cell.Options{
FgColor: cell.ColorCyan,
@ -67,7 +113,7 @@ func TestNewCell(t *testing.T) {
BgColor: cell.ColorBlue,
},
},
want: Cell{
want: &Cell{
Rune: 'X',
Opts: &cell.Options{
FgColor: cell.ColorBlack,
@ -80,8 +126,9 @@ func TestNewCell(t *testing.T) {
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
got := NewCell(tc.r, tc.opts...)
t.Logf(fmt.Sprintf("%v", got))
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
import (
"strings"
"text/scanner"
"github.com/mum4k/termdash/internal/canvas/buffer"
"github.com/mum4k/termdash/internal/runewidth"
)
@ -49,7 +47,9 @@ const (
AtRunes
// 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
)
@ -58,119 +58,139 @@ const (
// This will always return false if no options are provided, since the default
// behavior is to not wrap the text.
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)
return posX > width-rw && m == AtRunes
}
// Lines finds the starting positions of all lines in the text when the
// text is drawn on a canvas of the provided width and the specified wrapping
// mode.
func Lines(text string, width int, m Mode) []int {
if width <= 0 || len(text) == 0 {
// Cells returns the cells wrapped into individual lines according to the
// specified width and wrapping mode.
//
// This function consumes any cells that contain newline characters and uses
// 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
}
ls := newLineScanner(text, width, m)
for state := scanStart; state != nil; state = state(ls) {
cs := newCellScanner(cells, width, m)
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
// lines. Lines are identified when newline characters are encountered in the
// input text or when the canvas width and configuration requires line
// wrapping.
type lineScanner struct {
// scanner is a lexer of the input text.
scanner *scanner.Scanner
// cellScannerState is a state in the FSM that scans the input text and identifies
// newlines.
type cellScannerState func(*cellScanner) cellScannerState
// cellScanner tracks the progress of scanning the input cells when finding
// lines.
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 int
// posX tracks the horizontal position of the current character on the
// canvas.
// posX tracks the horizontal position of the current cell on the canvas.
posX int
// mode is the wrapping mode.
mode Mode
// lines are the starting points of the identified lines.
lines []int
// lines are the identified lines.
lines [][]*buffer.Cell
// line is the current line.
line []*buffer.Cell
}
// newLineScanner returns a new line scanner of the provided text.
func newLineScanner(text string, width int, m Mode) *lineScanner {
var s scanner.Scanner
s.Init(strings.NewReader(text))
s.Whitespace = 0 // Don't ignore any whitespace.
s.Mode = scanner.ScanIdents
s.IsIdentRune = func(ch rune, i int) bool {
return i == 0 && ch == '\n'
}
return &lineScanner{
scanner: &s,
width: width,
mode: m,
// newCellScanner returns a scanner of the provided cells.
func newCellScanner(cells []*buffer.Cell, width int, m Mode) *cellScanner {
return &cellScanner{
cells: cells,
width: width,
mode: m,
}
}
// scannerState is a state in the FSM that scans the input text and identifies
// newlines.
type scannerState func(*lineScanner) scannerState
// next returns the next cell and advances the scanner.
// Returns nil when there are no more cells to scan.
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.
func scanStart(ls *lineScanner) scannerState {
switch tok := ls.scanner.Peek(); {
case tok == scanner.EOF:
// peek returns the next cell without advancing the scanner's position.
// Returns nil when there are no more cells to peek at.
func (cs *cellScanner) peek() *buffer.Cell {
if cs.nextIdx >= len(cs.cells) {
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.
func scanLine(ls *lineScanner) scannerState {
// peekPrev returns the previous cell without changing the scanner's position.
// 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 {
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
}
case tok == scanner.Ident:
return scanLineBreak
switch r := cell.Rune; {
case r == '\n':
return scanCellLineBreak
case needed(tok, ls.posX, ls.width, ls.mode):
return scanLineWrap
case needed(r, cs.posX, cs.width, cs.mode):
return scanCellLineWrap
default:
// Move horizontally within the line for each scanned character.
ls.posX += runewidth.RuneWidth(tok)
// Move horizontally within the line for each scanned cell.
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.
func scanLineBreak(ls *lineScanner) scannerState {
// Newline characters aren't printed, the following character starts the line.
if ls.scanner.Peek() != scanner.EOF {
ls.posX = 0
ls.lines = append(ls.lines, ls.scanner.Position.Offset+1)
}
return scanLine
// scanCellLineBreak processes a newline character cell.
func scanCellLineBreak(cs *cellScanner) cellScannerState {
cs.lines = append(cs.lines, cs.line)
cs.posX = 0
cs.line = nil
return scanCellLine
}
// scanLineWrap processes a line wrap due to canvas width.
func scanLineWrap(ls *lineScanner) scannerState {
// scanCellLineWrap processes a line wrap due to canvas width.
func scanCellLineWrap(cs *cellScanner) cellScannerState {
// The character on which we wrapped will be printed and is the start of
// new line.
ls.posX = runewidth.StringWidth(ls.scanner.TokenText())
ls.lines = append(ls.lines, ls.scanner.Position.Offset)
return scanLine
cs.lines = append(cs.lines, cs.line)
cs.posX = runewidth.RuneWidth(cs.peekPrev().Rune)
cs.line = []*buffer.Cell{cs.peekPrev()}
return scanCellLine
}

View File

@ -15,9 +15,11 @@
package wrap
import (
"fmt"
"testing"
"github.com/kylelemons/godebug/pretty"
"github.com/mum4k/termdash/internal/canvas/buffer"
)
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 {
desc string
text string
desc string
cells []*buffer.Cell
// width is the width of the canvas.
width int
mode Mode
want []int
want [][]*buffer.Cell
}{
{
desc: "zero text",
text: "",
width: 1,
want: nil,
},
{
desc: "zero canvas width",
text: "hello",
cells: buffer.NewCells("hello"),
width: 0,
want: nil,
},
{
desc: "wrapping disabled, no newlines, fits in canvas width",
text: "hello",
cells: buffer.NewCells("hello"),
width: 5,
want: []int{0},
want: [][]*buffer.Cell{
buffer.NewCells("hello"),
},
},
{
desc: "wrapping disabled, no newlines, doesn't fits in canvas width",
text: "hello",
cells: buffer.NewCells("hello"),
width: 4,
want: []int{0},
want: [][]*buffer.Cell{
buffer.NewCells("hello"),
},
},
{
desc: "wrapping disabled, newlines, fits in canvas width",
text: "hello\nworld",
cells: buffer.NewCells("hello\nworld"),
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",
text: "hello\nworld",
cells: buffer.NewCells("hello\nworld"),
width: 4,
want: []int{0, 6},
want: [][]*buffer.Cell{
buffer.NewCells("hello"),
buffer.NewCells("world"),
},
},
{
desc: "wrapping enabled, no newlines, fits in canvas width",
text: "hello",
cells: buffer.NewCells("hello"),
width: 5,
mode: AtRunes,
want: []int{0},
want: [][]*buffer.Cell{
buffer.NewCells("hello"),
},
},
{
desc: "wrapping enabled, no newlines, doesn't fit in canvas width",
text: "hello",
cells: buffer.NewCells("hello"),
width: 4,
mode: AtRunes,
want: []int{0, 4},
want: [][]*buffer.Cell{
buffer.NewCells("hell"),
buffer.NewCells("o"),
},
},
{
desc: "wrapping enabled, newlines, fits in canvas width",
text: "hello\nworld",
cells: buffer.NewCells("hello\nworld"),
width: 5,
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",
text: "hello\nworld",
cells: buffer.NewCells("hello\nworld"),
width: 4,
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",
text: "⇧\n…\n⇩",
cells: buffer.NewCells("⇧\n…\n⇩"),
width: 1,
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",
text: "你好\n世界",
cells: buffer.NewCells("你好\n世界"),
width: 2,
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",
text: "a你b",
cells: buffer.NewCells("a你b"),
width: 2,
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",
text: "ab你b",
cells: buffer.NewCells("ab你b"),
width: 2,
mode: AtRunes,
want: []int{0, 2, 5},
want: [][]*buffer.Cell{
buffer.NewCells("ab"),
buffer.NewCells("你"),
buffer.NewCells("b"),
},
},
{
desc: "handles leading and trailing newlines",
text: "\n\n\nhello\n\n\n",
cells: buffer.NewCells("\n\n\nhello\n\n\n"),
width: 4,
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",
text: "hello\n\n\nworld",
cells: buffer.NewCells("hello\n\n\nworld"),
width: 5,
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",
text: "hello\n\n\nworld",
cells: buffer.NewCells("hello\n\n\nworld"),
width: 2,
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",
text: "\n\n\n",
cells: buffer.NewCells("\n\n\n"),
width: 4,
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 {
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 != "" {
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
import (
"bytes"
"errors"
"fmt"
"image"
"sync"
"unicode"
"github.com/mum4k/termdash/internal/attrrange"
"github.com/mum4k/termdash/internal/canvas"
"github.com/mum4k/termdash/internal/canvas/buffer"
"github.com/mum4k/termdash/internal/widgetapi"
"github.com/mum4k/termdash/internal/wrap"
"github.com/mum4k/termdash/terminal/terminalapi"
@ -41,12 +40,11 @@ import (
//
// Implements widgetapi.Widget. This object is thread-safe.
type Text struct {
// buff contains the text to be displayed in the widget.
buff bytes.Buffer
// givenWOpts are write options given for the text.
givenWOpts []*writeOptions
// wOptsTracker tracks the positions in a buff to which the givenWOpts apply.
wOptsTracker *attrrange.Tracker
// content is the text content that will be displayed in the widget as
// provided by the caller (i.e. not wrapped or pre-processed).
content []*buffer.Cell
// wrapped is the content wrapped to the current width of the canvas.
wrapped [][]*buffer.Cell
// scroll tracks scrolling the position.
scroll *scrollTracker
@ -58,15 +56,6 @@ type Text struct {
// the last drawing. Used to determine if the previous line wrapping was
// invalidated.
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 sync.Mutex
@ -82,9 +71,8 @@ func New(opts ...Option) (*Text, error) {
return nil, err
}
return &Text{
wOptsTracker: attrrange.NewTracker(),
scroll: newScrollTracker(opt),
opts: opt,
scroll: newScrollTracker(opt),
opts: opt,
}, nil
}
@ -97,14 +85,11 @@ func (t *Text) Reset() {
// reset implements Reset, caller must hold t.mu.
func (t *Text) reset() {
t.buff.Reset()
t.givenWOpts = nil
t.wOptsTracker = attrrange.NewTracker()
t.content = nil
t.wrapped = nil
t.scroll = newScrollTracker(t.opts)
t.lastWidth = 0
t.contentChanged = true
t.lines = nil
t.lineStartToIdx = map[int]int{}
}
// 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 {
t.reset()
}
pos := t.buff.Len()
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
for _, r := range text {
t.content = append(t.content, buffer.NewCell(r, opts.cellOpts))
}
t.contentChanged = true
return nil
@ -166,7 +144,7 @@ func (t *Text) drawScrollUp(cvs *canvas.Canvas, cur image.Point, fromLine int) (
// the marker was drawn.
func (t *Text) drawScrollDown(cvs *canvas.Canvas, cur image.Point, fromLine int) (bool, error) {
height := cvs.Area().Dy()
lines := len(t.lines)
lines := len(t.wrapped)
if cur.Y == height-1 && height >= minLinesForMarkers && height < lines-fromLine {
cells, err := cvs.SetCell(cur, '⇩')
if err != nil {
@ -180,32 +158,13 @@ func (t *Text) drawScrollDown(cvs *canvas.Canvas, cur image.Point, fromLine int)
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.
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.
height := cvs.Area().Dy()
fromLine := t.scroll.firstLine(len(t.lines), 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
}
fromLine := t.scroll.firstLine(len(t.wrapped), height)
for _, line := range t.wrapped[fromLine:] {
// Scroll up marker.
scrlUp, err := t.drawScrollUp(cvs, cur, fromLine)
if err != nil {
@ -213,57 +172,36 @@ func (t *Text) draw(text string, cvs *canvas.Canvas) error {
}
if scrlUp {
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.
drawnScrollUp = true
// Skip one line of text, the marker replaced it.
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.
scrlDown, err := t.drawScrollDown(cvs, cur, fromLine)
if err != nil {
return err
}
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)
if err != nil {
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)
for _, cell := range line {
tr, err := lineTrim(cvs, cur, cell.Rune, t.opts)
if err != nil {
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]
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.
cur = image.Point{0, cur.Y + 1} // Move to the next line.
}
return nil
}
@ -274,24 +212,19 @@ func (t *Text) Draw(cvs *canvas.Canvas) error {
t.mu.Lock()
defer t.mu.Unlock()
text := t.buff.String()
width := cvs.Area().Dx()
if t.contentChanged || t.lastWidth != width {
// The previous text preprocessing (line wrapping) is invalidated when
// new text is added or the width of the canvas changed.
t.lines = wrap.Lines(text, width, t.opts.wrapMode)
t.lineStartToIdx = map[int]int{}
for idx, start := range t.lines {
t.lineStartToIdx[start] = idx
}
t.wrapped = wrap.Cells(t.content, width, t.opts.wrapMode)
}
t.lastWidth = width
if len(t.lines) == 0 {
if len(t.wrapped) == 0 {
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
}
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 {
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 {
ft := faketerm.MustNew(size)