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:
parent
61aca3fb62
commit
87cab66617
@ -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{
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user