diff --git a/attrrange/attrrange.go b/attrrange/attrrange.go index 70f5a06..736cafe 100644 --- a/attrrange/attrrange.go +++ b/attrrange/attrrange.go @@ -18,7 +18,6 @@ package attrrange import ( - "errors" "fmt" "sort" ) @@ -72,10 +71,6 @@ func (t *Tracker) Add(low, high, attrIdx int) error { return nil } -// ErrNotFound indicates that the requested position wasn't found in any of the -// known ranges. -var ErrNotFound = errors.New("range not found") - // ForPosition returns attribute index that apply to the specified position. // Returns ErrNotFound when the requested position wasn't found in any of the // known ranges. @@ -102,7 +97,7 @@ func (t *Tracker) ForPosition(pos int) (*AttrRange, error) { } if res == nil { - return nil, ErrNotFound + return nil, fmt.Errorf("did not find attribute range for position %d", pos) } return res, nil } diff --git a/attrrange/attrrange_test.go b/attrrange/attrrange_test.go index aedae14..13031ee 100644 --- a/attrrange/attrrange_test.go +++ b/attrrange/attrrange_test.go @@ -61,13 +61,13 @@ func TestForPosition(t *testing.T) { update func(*Tracker) error pos int want *AttrRange - wantErr error + wantErr bool wantUpdateErr bool }{ { desc: "fails when no ranges given", pos: 0, - wantErr: ErrNotFound, + wantErr: true, }, { desc: "fails to add a duplicate", @@ -88,7 +88,7 @@ func TestForPosition(t *testing.T) { return tr.Add(5, 10, 41) }, pos: 1, - wantErr: ErrNotFound, + wantErr: true, }, { desc: "multiple given options, position falls on the lower", @@ -132,7 +132,7 @@ func TestForPosition(t *testing.T) { return tr.Add(5, 10, 41) }, pos: 10, - wantErr: ErrNotFound, + wantErr: true, }, } @@ -150,7 +150,7 @@ func TestForPosition(t *testing.T) { } got, err := tr.ForPosition(tc.pos) - if err != tc.wantErr { + if (err != nil) != tc.wantErr { t.Errorf("ForPosition => unexpected error:%v, wantErr:%v", err, tc.wantErr) } if err != nil { diff --git a/canvas/testcanvas/testcanvas.go b/canvas/testcanvas/testcanvas.go index 86692e9..375e066 100644 --- a/canvas/testcanvas/testcanvas.go +++ b/canvas/testcanvas/testcanvas.go @@ -50,3 +50,11 @@ func MustSetCell(c *canvas.Canvas, p image.Point, r rune, opts ...cell.Option) i } return cells } + +// MustCopyTo copies the content of the source canvas onto the destination +// canvas or panics. +func MustCopyTo(src, dst *canvas.Canvas) { + if err := src.CopyTo(dst); err != nil { + panic(fmt.Sprintf("canvas.CopyTo => unexpected error: %v", err)) + } +} diff --git a/draw/segdisp/sixteen/testsixteen/testsixteen.go b/draw/segdisp/sixteen/testsixteen/testsixteen.go new file mode 100644 index 0000000..b32ea58 --- /dev/null +++ b/draw/segdisp/sixteen/testsixteen/testsixteen.go @@ -0,0 +1,23 @@ +// Package testsixteen provides helpers for tests that use the sixteen package. +package testsixteen + +import ( + "fmt" + + "github.com/mum4k/termdash/canvas" + "github.com/mum4k/termdash/draw/segdisp/sixteen" +) + +// MustSetCharacter sets the character on the display or panics. +func MustSetCharacter(d *sixteen.Display, c rune) { + if err := d.SetCharacter(c); err != nil { + panic(fmt.Errorf("sixteen.Display.SetCharacter => unexpected error: %v", err)) + } +} + +// MustDraw draws the display onto the canvas or panics. +func MustDraw(d *sixteen.Display, cvs *canvas.Canvas, opts ...sixteen.Option) { + if err := d.Draw(cvs, opts...); err != nil { + panic(fmt.Errorf("sixteen.Display.Draw => unexpected error: %v", err)) + } +} diff --git a/widgets/segmentdisplay/options.go b/widgets/segmentdisplay/options.go index f859881..38f3257 100644 --- a/widgets/segmentdisplay/options.go +++ b/widgets/segmentdisplay/options.go @@ -39,11 +39,6 @@ type options struct { maximizeSegSize bool } -// validate validates the provided options. -func (o *options) validate() error { - return nil -} - // newOptions returns options with the default values set. func newOptions() *options { return &options{ @@ -73,7 +68,7 @@ func AlignVertical(v align.Vertical) Option { // When this option is set and the user has provided more text than we can fit // on the canvas, the widget will prefer to maximize height of individual // characters which will result in earlier trimming of the text. -func MaximizeSegmentHeight(v align.Vertical) Option { +func MaximizeSegmentHeight() Option { return option(func(opts *options) { opts.maximizeSegSize = true }) @@ -85,7 +80,7 @@ func MaximizeSegmentHeight(v align.Vertical) Option { // on the canvas, the widget will prefer to decrease the height of individual // characters and fit more of them on the canvas. // This is the default behavior. -func MaximizeDisplayedText(v align.Vertical) Option { +func MaximizeDisplayedText() Option { return option(func(opts *options) { opts.maximizeSegSize = false }) diff --git a/widgets/segmentdisplay/segmentdisplay.go b/widgets/segmentdisplay/segmentdisplay.go index a01a866..803a85d 100644 --- a/widgets/segmentdisplay/segmentdisplay.go +++ b/widgets/segmentdisplay/segmentdisplay.go @@ -19,9 +19,12 @@ package segmentdisplay import ( "bytes" "errors" + "fmt" "image" "sync" + "github.com/mum4k/termdash/align" + "github.com/mum4k/termdash/attrrange" "github.com/mum4k/termdash/canvas" "github.com/mum4k/termdash/draw/segdisp/sixteen" "github.com/mum4k/termdash/terminalapi" @@ -42,6 +45,11 @@ type SegmentDisplay struct { // buff contains the text to be displayed. buff bytes.Buffer + // givenWOpts are write options given for the text in buff. + givenWOpts []*writeOptions + // wOptsTracker tracks the positions in a buff to which the givenWOpts apply. + wOptsTracker *attrrange.Tracker + // mu protects the widget. mu sync.Mutex @@ -50,26 +58,63 @@ type SegmentDisplay struct { } // New returns a new SegmentDisplay. -func New(opts ...Option) (*SegmentDisplay, error) { +func New(opts ...Option) *SegmentDisplay { opt := newOptions() for _, o := range opts { o.set(opt) } - if err := opt.validate(); err != nil { - return nil, err - } return &SegmentDisplay{ - opts: opt, - }, nil + wOptsTracker: attrrange.NewTracker(), + opts: opt, + } } -// Write writes text for the widget to display. Multiple calls append -// additional text. +// TextChunk is a part of or the full text that will be displayed. +type TextChunk struct { + text string + wOpts *writeOptions +} + +// NewChunk creates a new text chunk. +func NewChunk(text string, wOpts ...WriteOption) *TextChunk { + return &TextChunk{ + text: text, + wOpts: newWriteOptions(wOpts...), + } +} + +// Write writes text for the widget to display. Subsequent calls replace text +// written previously. All the provided text chunks are broken into characters +// and each character is displayed in one segment. +// // The provided write options determine the behavior when text contains -// unsupported characters. -func (sd *SegmentDisplay) Write(text string /* TODO wOpts ...WriteOption */) error { +// unsupported characters and set cell options for cells that contain +// individual display segments. +// +// Each of the text chunks can have its own options. At least one chunk must be +// specified. +func (sd *SegmentDisplay) Write(chunks ...*TextChunk) error { sd.mu.Lock() defer sd.mu.Unlock() + + if len(chunks) == 0 { + return errors.New("at least one text chunk must be specified") + } + + for i, tc := range chunks { + if ok, badRunes := sixteen.SupportsChars(tc.text); !ok && tc.wOpts.errOnUnsupported { + return fmt.Errorf("text chunk[%d] contains unsupported characters %v, clean the text or provide the WriteSanitize option", i, badRunes) + } + text := sixteen.Sanitize(tc.text) + + pos := sd.buff.Len() + sd.givenWOpts = append(sd.givenWOpts, tc.wOpts) + wOptsIdx := len(sd.givenWOpts) - 1 + if err := sd.wOptsTracker.Add(pos, pos+len(text), wOptsIdx); err != nil { + return err + } + sd.buff.WriteString(text) + } return nil } @@ -77,6 +122,76 @@ func (sd *SegmentDisplay) Write(text string /* TODO wOpts ...WriteOption */) err func (sd *SegmentDisplay) Reset() { sd.mu.Lock() defer sd.mu.Unlock() + + sd.buff.Reset() + sd.givenWOpts = nil + sd.wOptsTracker = attrrange.NewTracker() +} + +// segArea given an area available for drawing returns the area required for a +// single segment and the number of segments we can fit. +func (sd *SegmentDisplay) segArea(ar image.Rectangle) (image.Rectangle, int, error) { + segAr, err := sixteen.Required(ar) + if err != nil { + return image.ZR, 0, fmt.Errorf("sixteen.Required => %v", err) + } + + canFit := ar.Dx() / segAr.Dx() + return segAr, canFit, nil +} + +// maximizeFit finds the largest individual segment size that enables us to fit +// the most characters onto a canvas with the provided area. Returns the area +// required for a single segment and the number of segments we can fit. +func (sd *SegmentDisplay) maximizeFit(ar image.Rectangle) (image.Rectangle, int, error) { + bestSegAr := image.ZR + bestCanFit := 0 + need := sd.buff.Len() + for height := ar.Dy(); height >= sixteen.MinRows; height-- { + ar := image.Rect(ar.Min.X, ar.Min.Y, ar.Max.X, ar.Min.Y+height) + segAr, canFit, err := sd.segArea(ar) + if err != nil { + return image.ZR, 0, err + } + + if canFit >= need { + return segAr, canFit, nil + } + bestSegAr = segAr + bestCanFit = canFit + } + + if bestSegAr.Eq(image.ZR) || bestCanFit == 0 { + return image.ZR, 0, fmt.Errorf("failed to maximize character fit for area: %v", ar) + } + return bestSegAr, bestCanFit, nil +} + +// preprocess determines the size of individual segments maximizing their +// height or the amount of displayed characters based on the specified options. +// Returns the area required for a single segment and the text that we can fit. +func (sd *SegmentDisplay) preprocess(cvsAr image.Rectangle) (image.Rectangle, string, error) { + segAr, canFit, err := sd.segArea(cvsAr) + if err != nil { + return image.ZR, "", err + } + + text := sd.buff.String() + need := len(text) + + if need <= canFit { + return segAr, text, nil + } + + if sd.opts.maximizeSegSize { + return segAr, text[:canFit], nil + } + + bestAr, bestFit, err := sd.maximizeFit(cvsAr) + if err != nil { + return image.ZR, "", err + } + return bestAr, text[:bestFit], nil } // Draw draws the SegmentDisplay widget onto the canvas. @@ -85,7 +200,60 @@ func (sd *SegmentDisplay) Draw(cvs *canvas.Canvas) error { sd.mu.Lock() defer sd.mu.Unlock() - return errors.New("unimplemented") + if sd.buff.Len() == 0 { + return nil + } + + segAr, text, err := sd.preprocess(cvs.Area()) + if err != nil { + return err + } + + needAr := image.Rect(0, 0, segAr.Dx()*len(text), segAr.Dy()) + aligned, err := align.Rectangle(cvs.Area(), needAr, sd.opts.hAlign, sd.opts.vAlign) + if err != nil { + return fmt.Errorf("align.Rectangle => %v", err) + } + + optRange, err := sd.wOptsTracker.ForPosition(0) // Text options for the current byte. + if err != nil { + return err + } + + for i, c := range text { + disp := sixteen.New() + if err := disp.SetCharacter(c); err != nil { + return fmt.Errorf("disp.SetCharacter => %v", err) + } + + ar := image.Rect( + aligned.Min.X+segAr.Dx()*i, aligned.Min.Y, + aligned.Min.X+segAr.Dx()*(i+1), aligned.Max.Y, + ) + + dCvs, err := canvas.New(ar) + if err != nil { + return fmt.Errorf("canvas.New => %v", err) + } + + if i >= optRange.High { // Get the next write options. + or, err := sd.wOptsTracker.ForPosition(i) + if err != nil { + return err + } + optRange = or + } + wOpts := sd.givenWOpts[optRange.AttrIdx] + + if err := disp.Draw(dCvs, sixteen.CellOpts(wOpts.cellOpts...)); err != nil { + return fmt.Errorf("disp.Draw => %v", err) + } + + if err := dCvs.CopyTo(cvs); err != nil { + return fmt.Errorf("dCvs.CopyTo => %v", err) + } + } + return nil } // Keyboard input isn't supported on the SegmentDisplay widget. @@ -102,8 +270,6 @@ func (*SegmentDisplay) Mouse(m *terminalapi.Mouse) error { func (sd *SegmentDisplay) Options() widgetapi.Options { return widgetapi.Options{ // The smallest supported size of a display segment. - // - // TODO: Return size required based on the text length and options. MinimumSize: image.Point{sixteen.MinCols, sixteen.MinRows}, WantKeyboard: false, WantMouse: false, diff --git a/widgets/segmentdisplay/segmentdisplay_test.go b/widgets/segmentdisplay/segmentdisplay_test.go index 62a2adc..48dffb4 100644 --- a/widgets/segmentdisplay/segmentdisplay_test.go +++ b/widgets/segmentdisplay/segmentdisplay_test.go @@ -19,13 +19,26 @@ import ( "testing" "github.com/kylelemons/godebug/pretty" + "github.com/mum4k/termdash/align" "github.com/mum4k/termdash/canvas" + "github.com/mum4k/termdash/canvas/testcanvas" + "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/draw/segdisp/sixteen" + "github.com/mum4k/termdash/draw/segdisp/sixteen/testsixteen" "github.com/mum4k/termdash/terminal/faketerm" "github.com/mum4k/termdash/terminalapi" "github.com/mum4k/termdash/widgetapi" ) +// mustDrawChar draws the provided character in the area of the canvas or panics. +func mustDrawChar(cvs *canvas.Canvas, char rune, ar image.Rectangle, opts ...sixteen.Option) { + d := sixteen.New() + testsixteen.MustSetCharacter(d, char) + c := testcanvas.MustNew(ar) + testsixteen.MustDraw(d, c, opts...) + testcanvas.MustCopyTo(c, cvs) +} + func TestSegmentDisplay(t *testing.T) { tests := []struct { desc string @@ -33,28 +46,452 @@ func TestSegmentDisplay(t *testing.T) { update func(*SegmentDisplay) error // update gets called before drawing of the widget. canvas image.Rectangle want func(size image.Point) *faketerm.Terminal - wantNewErr bool wantUpdateErr bool // whether to expect an error on a call to the update function wantDrawErr bool - }{} + }{ + { + desc: "fails on area too small for a segment", + canvas: image.Rect(0, 0, sixteen.MinCols-1, sixteen.MinRows), + update: func(sd *SegmentDisplay) error { + return sd.Write(NewChunk("1")) + }, + wantDrawErr: true, + }, + { + desc: "write fails without chunks", + canvas: image.Rect(0, 0, sixteen.MinCols, sixteen.MinRows), + update: func(sd *SegmentDisplay) error { + return sd.Write() + }, + wantUpdateErr: true, + }, + { + desc: "write fails on unsupported characters when requested", + canvas: image.Rect(0, 0, sixteen.MinCols, sixteen.MinRows), + update: func(sd *SegmentDisplay) error { + return sd.Write(NewChunk(".", WriteErrOnUnsupported())) + }, + wantUpdateErr: true, + }, + { + desc: "draws empty without text", + canvas: image.Rect(0, 0, sixteen.MinCols, sixteen.MinRows), + }, + { + desc: "draws multiple segments, all fit exactly", + canvas: image.Rect(0, 0, sixteen.MinCols*3, sixteen.MinRows), + update: func(sd *SegmentDisplay) error { + return sd.Write(NewChunk("123")) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + for _, tc := range []struct { + char rune + area image.Rectangle + }{ + {'1', image.Rect(0, 0, sixteen.MinCols, sixteen.MinRows)}, + {'2', image.Rect(sixteen.MinCols, 0, sixteen.MinCols*2, sixteen.MinRows)}, + {'3', image.Rect(sixteen.MinCols*2, 0, sixteen.MinCols*3, sixteen.MinRows)}, + } { + mustDrawChar(cvs, tc.char, tc.area) + } + + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "write sanitizes text by default", + canvas: image.Rect(0, 0, sixteen.MinCols*2, sixteen.MinRows), + update: func(sd *SegmentDisplay) error { + return sd.Write(NewChunk(".1")) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + mustDrawChar(cvs, '1', image.Rect(sixteen.MinCols, 0, sixteen.MinCols*2, sixteen.MinRows)) + + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "write sanitizes text with option", + canvas: image.Rect(0, 0, sixteen.MinCols*2, sixteen.MinRows), + update: func(sd *SegmentDisplay) error { + return sd.Write(NewChunk(".1", WriteSanitize())) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + mustDrawChar(cvs, '1', image.Rect(sixteen.MinCols, 0, sixteen.MinCols*2, sixteen.MinRows)) + + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "aligns segment vertical middle by default", + canvas: image.Rect(0, 0, sixteen.MinCols, sixteen.MinRows+2), + update: func(sd *SegmentDisplay) error { + return sd.Write(NewChunk("1")) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + mustDrawChar(cvs, '1', image.Rect(0, 1, sixteen.MinCols, sixteen.MinRows+1)) + + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "subsequent calls to write overwrite previous text", + canvas: image.Rect(0, 0, sixteen.MinCols, sixteen.MinRows+2), + update: func(sd *SegmentDisplay) error { + if err := sd.Write(NewChunk("123")); err != nil { + return err + } + return sd.Write(NewChunk("1")) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + mustDrawChar(cvs, '1', image.Rect(0, 1, sixteen.MinCols, sixteen.MinRows+1)) + + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "sets cell options per text chunk", + canvas: image.Rect(0, 0, sixteen.MinCols*2, sixteen.MinRows), + update: func(sd *SegmentDisplay) error { + return sd.Write( + NewChunk("1", WriteCellOpts( + cell.FgColor(cell.ColorRed), + cell.BgColor(cell.ColorBlue), + )), + NewChunk("2", WriteCellOpts( + cell.FgColor(cell.ColorGreen), + cell.BgColor(cell.ColorYellow), + )), + ) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + mustDrawChar( + cvs, '1', + image.Rect(0, 0, sixteen.MinCols, sixteen.MinRows), + sixteen.CellOpts( + cell.FgColor(cell.ColorRed), + cell.BgColor(cell.ColorBlue), + ), + ) + mustDrawChar( + cvs, '2', + image.Rect(sixteen.MinCols, 0, sixteen.MinCols*2, sixteen.MinRows), + sixteen.CellOpts( + cell.FgColor(cell.ColorGreen), + cell.BgColor(cell.ColorYellow), + ), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "reset resets the text content", + canvas: image.Rect(0, 0, sixteen.MinCols, sixteen.MinRows+2), + update: func(sd *SegmentDisplay) error { + if err := sd.Write(NewChunk("123")); err != nil { + return err + } + sd.Reset() + return nil + }, + }, + { + desc: "reset resets provided cell options", + canvas: image.Rect(0, 0, sixteen.MinCols, sixteen.MinRows), + update: func(sd *SegmentDisplay) error { + if err := sd.Write( + NewChunk("1", WriteCellOpts( + cell.FgColor(cell.ColorRed), + cell.BgColor(cell.ColorBlue), + )), + ); err != nil { + return err + } + sd.Reset() + return sd.Write(NewChunk("1")) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + mustDrawChar(cvs, '1', image.Rect(0, 0, sixteen.MinCols, sixteen.MinRows)) + + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "aligns segment vertical middle with option", + opts: []Option{ + AlignVertical(align.VerticalMiddle), + }, + canvas: image.Rect(0, 0, sixteen.MinCols, sixteen.MinRows+2), + update: func(sd *SegmentDisplay) error { + return sd.Write(NewChunk("1")) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + mustDrawChar(cvs, '1', image.Rect(0, 1, sixteen.MinCols, sixteen.MinRows+1)) + + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "aligns segment vertical top with option", + opts: []Option{ + AlignVertical(align.VerticalTop), + }, + canvas: image.Rect(0, 0, sixteen.MinCols, sixteen.MinRows+2), + update: func(sd *SegmentDisplay) error { + return sd.Write(NewChunk("1")) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + mustDrawChar(cvs, '1', image.Rect(0, 0, sixteen.MinCols, sixteen.MinRows)) + + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "aligns segment vertical bottom with option", + opts: []Option{ + AlignVertical(align.VerticalBottom), + }, + canvas: image.Rect(0, 0, sixteen.MinCols, sixteen.MinRows+2), + update: func(sd *SegmentDisplay) error { + return sd.Write(NewChunk("1")) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + mustDrawChar(cvs, '1', image.Rect(0, 2, sixteen.MinCols, sixteen.MinRows+2)) + + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "aligns segment horizontal center by default", + canvas: image.Rect(0, 0, sixteen.MinCols+2, sixteen.MinRows), + update: func(sd *SegmentDisplay) error { + return sd.Write(NewChunk("8")) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + mustDrawChar(cvs, '8', image.Rect(1, 0, sixteen.MinCols+1, sixteen.MinRows)) + + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "aligns segment horizontal center with option", + opts: []Option{ + AlignHorizontal(align.HorizontalCenter), + }, + canvas: image.Rect(0, 0, sixteen.MinCols+2, sixteen.MinRows), + update: func(sd *SegmentDisplay) error { + return sd.Write(NewChunk("8")) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + mustDrawChar(cvs, '8', image.Rect(1, 0, sixteen.MinCols+1, sixteen.MinRows)) + + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "aligns segment horizontal left with option", + opts: []Option{ + AlignHorizontal(align.HorizontalLeft), + }, + canvas: image.Rect(0, 0, sixteen.MinCols+2, sixteen.MinRows), + update: func(sd *SegmentDisplay) error { + return sd.Write(NewChunk("8")) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + mustDrawChar(cvs, '8', image.Rect(0, 0, sixteen.MinCols, sixteen.MinRows)) + + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "aligns segment horizontal right with option", + opts: []Option{ + AlignHorizontal(align.HorizontalRight), + }, + canvas: image.Rect(0, 0, sixteen.MinCols+2, sixteen.MinRows), + update: func(sd *SegmentDisplay) error { + return sd.Write(NewChunk("8")) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + mustDrawChar(cvs, '8', image.Rect(2, 0, sixteen.MinCols+2, sixteen.MinRows)) + + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "draws multiple segments, not enough space, maximizes segment height with option", + opts: []Option{ + MaximizeSegmentHeight(), + }, + canvas: image.Rect(0, 0, sixteen.MinCols*2, sixteen.MinRows), + update: func(sd *SegmentDisplay) error { + return sd.Write(NewChunk("123")) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + for _, tc := range []struct { + char rune + area image.Rectangle + }{ + {'1', image.Rect(0, 0, sixteen.MinCols, sixteen.MinRows)}, + {'2', image.Rect(sixteen.MinCols, 0, sixteen.MinCols*2, sixteen.MinRows)}, + } { + mustDrawChar(cvs, tc.char, tc.area) + } + + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "draws multiple segments, not enough space, maximizes displayed text by default and fits all", + canvas: image.Rect(0, 0, sixteen.MinCols*3, sixteen.MinRows*4), + update: func(sd *SegmentDisplay) error { + return sd.Write(NewChunk("123")) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + for _, tc := range []struct { + char rune + area image.Rectangle + }{ + {'1', image.Rect(0, 7, 6, 12)}, + {'2', image.Rect(6, 7, 12, 12)}, + {'3', image.Rect(12, 7, 18, 12)}, + } { + mustDrawChar(cvs, tc.char, tc.area) + } + + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "draws multiple segments, not enough space, maximizes displayed text but cannot fit all", + canvas: image.Rect(0, 0, sixteen.MinCols*3, sixteen.MinRows*4), + update: func(sd *SegmentDisplay) error { + return sd.Write(NewChunk("1234")) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + for _, tc := range []struct { + char rune + area image.Rectangle + }{ + {'1', image.Rect(0, 7, 6, 12)}, + {'2', image.Rect(6, 7, 12, 12)}, + {'3', image.Rect(12, 7, 18, 12)}, + } { + mustDrawChar(cvs, tc.char, tc.area) + } + + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + { + desc: "draws multiple segments, not enough space, maximizes displayed text with option", + opts: []Option{ + MaximizeDisplayedText(), + }, + canvas: image.Rect(0, 0, sixteen.MinCols*3, sixteen.MinRows*4), + update: func(sd *SegmentDisplay) error { + return sd.Write(NewChunk("123")) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + for _, tc := range []struct { + char rune + area image.Rectangle + }{ + {'1', image.Rect(0, 7, 6, 12)}, + {'2', image.Rect(6, 7, 12, 12)}, + {'3', image.Rect(12, 7, 18, 12)}, + } { + mustDrawChar(cvs, tc.char, tc.area) + } + + testcanvas.MustApply(cvs, ft) + return ft + }, + }, + } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { - d, err := New(tc.opts...) - if (err != nil) != tc.wantNewErr { - t.Errorf("New => unexpected error: %v, wantNewErr: %v", err, tc.wantNewErr) - } - if err != nil { - return - } - + sd := New(tc.opts...) c, err := canvas.New(tc.canvas) if err != nil { t.Fatalf("canvas.New => unexpected error: %v", err) } if tc.update != nil { - err = tc.update(d) + err = tc.update(sd) if (err != nil) != tc.wantUpdateErr { t.Errorf("update => unexpected error: %v, wantUpdateErr: %v", err, tc.wantUpdateErr) @@ -64,7 +501,7 @@ func TestSegmentDisplay(t *testing.T) { } } - err = d.Draw(c) + err = sd.Draw(c) if (err != nil) != tc.wantDrawErr { t.Errorf("Draw => unexpected error: %v, wantDrawErr: %v", err, tc.wantDrawErr) } @@ -96,32 +533,22 @@ func TestSegmentDisplay(t *testing.T) { } func TestKeyboard(t *testing.T) { - d, err := New() - if err != nil { - t.Fatalf("New => unexpected error: %v", err) - } - if err := d.Keyboard(&terminalapi.Keyboard{}); err == nil { + sd := New() + if err := sd.Keyboard(&terminalapi.Keyboard{}); err == nil { t.Errorf("Keyboard => got nil err, wanted one") } } func TestMouse(t *testing.T) { - d, err := New() - if err != nil { - t.Fatalf("New => unexpected error: %v", err) - } - if err := d.Mouse(&terminalapi.Mouse{}); err == nil { + sd := New() + if err := sd.Mouse(&terminalapi.Mouse{}); err == nil { t.Errorf("Mouse => got nil err, wanted one") } } func TestOptions(t *testing.T) { - d, err := New() - if err != nil { - t.Fatalf("New => unexpected error: %v", err) - } - - got := d.Options() + sd := New() + got := sd.Options() want := widgetapi.Options{ MinimumSize: image.Point{sixteen.MinCols, sixteen.MinRows}, WantKeyboard: false, diff --git a/widgets/segmentdisplay/write_options.go b/widgets/segmentdisplay/write_options.go new file mode 100644 index 0000000..02c0d17 --- /dev/null +++ b/widgets/segmentdisplay/write_options.go @@ -0,0 +1,59 @@ +package segmentdisplay + +// write_options.go contains options used when writing content to the widget. + +import "github.com/mum4k/termdash/cell" + +// WriteOption is used to provide options to Write(). +type WriteOption interface { + // set sets the provided option. + set(*writeOptions) +} + +// writeOptions stores the provided options. +type writeOptions struct { + cellOpts []cell.Option + errOnUnsupported bool +} + +// newWriteOptions returns new writeOptions instance. +func newWriteOptions(wOpts ...WriteOption) *writeOptions { + wo := &writeOptions{} + for _, o := range wOpts { + o.set(wo) + } + return wo +} + +// writeOption implements WriteOption. +type writeOption func(*writeOptions) + +// set implements WriteOption.set. +func (wo writeOption) set(wOpts *writeOptions) { + wo(wOpts) +} + +// WriteCellOpts sets options on the cells that contain the text. +func WriteCellOpts(opts ...cell.Option) WriteOption { + return writeOption(func(wOpts *writeOptions) { + wOpts.cellOpts = opts + }) +} + +// WriteSanitize instructs Write to sanitize the text, replacing all characters +// the display doesn't support with a space ' ' character. +// This is the default behavior. +func WriteSanitize(opts ...cell.Option) WriteOption { + return writeOption(func(wOpts *writeOptions) { + wOpts.errOnUnsupported = false + }) +} + +// WriteErrOnUnsupported instructs Write to return an error when the text +// contains a character the display doesn't support. +// The default behavior is to sanitize the text, see WriteSanitize(). +func WriteErrOnUnsupported(opts ...cell.Option) WriteOption { + return writeOption(func(wOpts *writeOptions) { + wOpts.errOnUnsupported = true + }) +}