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