termdash/widgets/segmentdisplay/segmentdisplay.go

313 lines
9.0 KiB
Go

// Copyright 2019 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package segmentdisplay is a widget that displays text by simulating a
// segment display.
package segmentdisplay
import (
"errors"
"fmt"
"image"
"strings"
"sync"
"github.com/mum4k/termdash/private/alignfor"
"github.com/mum4k/termdash/private/attrrange"
"github.com/mum4k/termdash/private/canvas"
"github.com/mum4k/termdash/private/segdisp"
"github.com/mum4k/termdash/private/segdisp/dotseg"
"github.com/mum4k/termdash/private/segdisp/sixteen"
"github.com/mum4k/termdash/terminal/terminalapi"
"github.com/mum4k/termdash/widgetapi"
)
// SegmentDisplay displays ASCII content by simulating a segment display.
//
// Automatically determines the size of individual segments with goal of
// maximizing the segment size or with fitting the entire text depending on the
// provided options.
//
// Segment displays support only a subset of ASCII characters, provided options
// determine the behavior when an unsupported character is encountered.
//
// Implements widgetapi.Widget. This object is thread-safe.
type SegmentDisplay struct {
// buff contains the text to be displayed.
buff strings.Builder
// 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
// lastCanFit is the number of segments that could fit the area the last
// time Draw was called.
lastCanFit int
// dotChars are characters that are drawn using the dot segment.
// All other characters are draws using the 16-segment display.
dotChars map[rune]bool
// mu protects the widget.
mu sync.Mutex
// opts are the provided options.
opts *options
}
// New returns a new SegmentDisplay.
func New(opts ...Option) (*SegmentDisplay, error) {
opt := newOptions()
for _, o := range opts {
o.set(opt)
}
if err := opt.validate(); err != nil {
return nil, err
}
dotChars := map[rune]bool{}
for _, r := range dotseg.SupportedChars() {
dotChars[r] = true
}
return &SegmentDisplay{
wOptsTracker: attrrange.NewTracker(),
opts: opt,
dotChars: dotChars,
}, nil
}
// 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 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.
//
// Any provided options override options given to New.
func (sd *SegmentDisplay) Write(chunks []*TextChunk, opts ...Option) error {
sd.mu.Lock()
defer sd.mu.Unlock()
for _, o := range opts {
o.set(sd.opts)
}
if err := sd.opts.validate(); err != nil {
return err
}
if len(chunks) == 0 {
return errors.New("at least one text chunk must be specified")
}
sd.reset()
for i, tc := range chunks {
if tc.text == "" {
return fmt.Errorf("text chunk[%d] is empty, all chunks must contains some text", i)
}
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
}
// Capacity returns the number of characters that can fit into the canvas.
// This is essentially the number of individual segments that can fit on the
// canvas at the time the last call to draw. Returns zero if draw wasn't
// called.
//
// Note that this capacity changes each time the terminal resizes, so there is
// no guarantee this remains the same next time Draw is called.
// Should be used as a hint only.
func (sd *SegmentDisplay) Capacity() int {
sd.mu.Lock()
defer sd.mu.Unlock()
return sd.lastCanFit
}
// Reset resets the widget back to empty content.
func (sd *SegmentDisplay) Reset() {
sd.mu.Lock()
defer sd.mu.Unlock()
sd.reset()
}
// reset is the implementation of Reset.
// Caller must hold sd.mu.
func (sd *SegmentDisplay) reset() {
sd.buff.Reset()
sd.givenWOpts = nil
sd.wOptsTracker = attrrange.NewTracker()
}
// 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, the text that we can fit and
// size of gaps between segments in cells.
func (sd *SegmentDisplay) preprocess(cvsAr image.Rectangle) (*segArea, error) {
textLen := sd.buff.Len() // We're guaranteed by Write to only have ASCII characters.
segAr, err := newSegArea(cvsAr, textLen, sd.opts.gapPercent)
if err != nil {
return nil, err
}
need := sd.buff.Len()
if (need > 0 && need <= segAr.canFit) || sd.opts.maximizeSegSize {
return segAr, nil
}
bestAr, err := maximizeFit(cvsAr, textLen, sd.opts.gapPercent)
if err != nil {
return nil, err
}
return bestAr, nil
}
// Draw draws the SegmentDisplay widget onto the canvas.
// Implements widgetapi.Widget.Draw.
func (sd *SegmentDisplay) Draw(cvs *canvas.Canvas, meta *widgetapi.Meta) error {
sd.mu.Lock()
defer sd.mu.Unlock()
segAr, err := sd.preprocess(cvs.Area())
if err != nil {
return err
}
sd.lastCanFit = segAr.canFit
if sd.buff.Len() == 0 {
return nil
}
text := sd.buff.String()
aligned, err := alignfor.Rectangle(cvs.Area(), segAr.needArea(), sd.opts.hAlign, sd.opts.vAlign)
if err != nil {
return fmt.Errorf("alignfor.Rectangle => %v", err)
}
optRange, err := sd.wOptsTracker.ForPosition(0) // Text options for the current byte.
if err != nil {
return err
}
gaps := segAr.gaps
startX := aligned.Min.X
for i, c := range text {
if i >= segAr.canFit {
break
}
endX := startX + segAr.segment.Dx()
ar := image.Rect(startX, aligned.Min.Y, endX, aligned.Max.Y)
startX = endX
if gaps > 0 {
startX += segAr.gapPixels
gaps--
}
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 := sd.drawChar(dCvs, c, wOpts); err != nil {
return err
}
if err := dCvs.CopyTo(cvs); err != nil {
return fmt.Errorf("dCvs.CopyTo => %v", err)
}
}
return nil
}
// drawChar draws a single character onto the provided canvas.
func (sd *SegmentDisplay) drawChar(dCvs *canvas.Canvas, c rune, wOpts *writeOptions) error {
if sd.dotChars[c] {
disp := dotseg.New()
if err := disp.SetCharacter(c); err != nil {
return fmt.Errorf("dotseg.Display.SetCharacter => %v", err)
}
if err := disp.Draw(dCvs, dotseg.CellOpts(wOpts.cellOpts...)); err != nil {
return fmt.Errorf("dotseg.Display..Draw => %v", err)
}
return nil
}
disp := sixteen.New()
if err := disp.SetCharacter(c); err != nil {
return fmt.Errorf("sixteen.Display.SetCharacter => %v", err)
}
if err := disp.Draw(dCvs, sixteen.CellOpts(wOpts.cellOpts...)); err != nil {
return fmt.Errorf("sixteen.Display.Draw => %v", err)
}
return nil
}
// Keyboard input isn't supported on the SegmentDisplay widget.
func (*SegmentDisplay) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
return errors.New("the SegmentDisplay widget doesn't support keyboard events")
}
// Mouse input isn't supported on the SegmentDisplay widget.
func (*SegmentDisplay) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
return errors.New("the SegmentDisplay widget doesn't support mouse events")
}
// Options implements widgetapi.Widget.Options.
func (sd *SegmentDisplay) Options() widgetapi.Options {
return widgetapi.Options{
// The smallest supported size of a display segment.
MinimumSize: image.Point{segdisp.MinCols, segdisp.MinRows},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: widgetapi.MouseScopeNone,
}
}