// Copyright 2018 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 text contains a widget that displays textual data. package text import ( "bytes" "errors" "fmt" "image" "sync" "unicode" "github.com/mum4k/termdash/attrrange" "github.com/mum4k/termdash/canvas" "github.com/mum4k/termdash/terminalapi" "github.com/mum4k/termdash/widgetapi" ) // Text displays a block of text. // // Each line of the text is either trimmed or wrapped according to the provided // options. The entire text content is either trimmed or rolled up through the // canvas according to the provided options. // // By default the widget supports scrolling of content with either the keyboard // or mouse. See the options for the default keys and mouse buttons. // // 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 // scroll tracks scrolling the position. scroll *scrollTracker // lastWidth stores the width of the last canvas the widget drew on. // Used to determine if the previous line wrapping was invalidated. lastWidth int // contentChanged indicates if the text content of the widget changed since // 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. lines []int // mu protects the Text widget. mu sync.Mutex // opts are the provided options. opts *options } // New returns a new text widget. func New(opts ...Option) (*Text, error) { opt := newOptions(opts...) if err := opt.validate(); err != nil { return nil, err } return &Text{ wOptsTracker: attrrange.NewTracker(), scroll: newScrollTracker(opt), opts: opt, }, nil } // Reset resets the widget back to empty content. func (t *Text) Reset() { t.mu.Lock() defer t.mu.Unlock() t.buff.Reset() t.givenWOpts = nil t.wOptsTracker = attrrange.NewTracker() t.scroll = newScrollTracker(t.opts) t.lastWidth = 0 t.contentChanged = true t.lines = nil } // Write writes text for the widget to display. Multiple calls append // additional text. The text contain cannot control characters // (unicode.IsControl) or space character (unicode.IsSpace) other than: // ' ', '\n' // Any newline ('\n') characters are interpreted as newlines when displaying // the text. func (t *Text) Write(text string, wOpts ...WriteOption) error { t.mu.Lock() defer t.mu.Unlock() if err := validText(text); err != nil { return err } pos := t.buff.Len() t.givenWOpts = append(t.givenWOpts, newWriteOptions(wOpts...)) 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 return nil } // minLinesForMarkers are the minimum amount of lines required on the canvas in // order to draw the scroll markers ('⇧' and '⇩'). const minLinesForMarkers = 3 // drawScrollUp draws the scroll up marker on the first line if there is more // text "above" the canvas due to the scrolling position. Returns true if the // marker was drawn. func (t *Text) drawScrollUp(cvs *canvas.Canvas, cur image.Point, fromLine int) (bool, error) { height := cvs.Area().Dy() if cur.Y == 0 && height >= minLinesForMarkers && fromLine > 0 { cells, err := cvs.SetCell(cur, '⇧') if err != nil { return false, err } if cells != 1 { panic(fmt.Errorf("invalid scroll up marker, it occupies %d cells, the implementation only supports scroll markers that occupy exactly one cell", cells)) } return true, nil } return false, nil } // drawScrollDown draws the scroll down marker on the last line if there is // more text "below" the canvas due to the scrolling position. Returns true if // 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) if cur.Y == height-1 && height >= minLinesForMarkers && height < lines-fromLine { cells, err := cvs.SetCell(cur, '⇩') if err != nil { return false, err } if cells != 1 { panic(fmt.Errorf("invalid scroll down marker, it occupies %d cells, the implementation only supports scroll markers that occupy exactly one cell", cells)) } return true, nil } return false, nil } // draw draws the text context on the canvas starting at the specified line. func (t *Text) draw(text string, 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] for i, r := range text { if i < startPos { continue } // Scroll up marker. scrlUp, err := t.drawScrollUp(cvs, cur, fromLine) if err != nil { return err } 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. continue } // Line wrapping. if r == '\n' || wrapNeeded(r, cur.X, cvs.Area().Dx(), t.opts) { 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. } 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) if err != nil { return err } optRange = or } 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. } return nil } // Draw draws the text onto the canvas. // Implements widgetapi.Widget.Draw. 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 = findLines(text, width, t.opts) } t.lastWidth = width if len(t.lines) == 0 { return nil // Nothing to draw if there's no text. } if err := t.draw(text, cvs); err != nil { return err } t.contentChanged = false return nil } // Keyboard implements widgetapi.Widget.Keyboard. func (t *Text) Keyboard(k *terminalapi.Keyboard) error { t.mu.Lock() defer t.mu.Unlock() switch { case k.Key == t.opts.keyUp: t.scroll.upOneLine() case k.Key == t.opts.keyDown: t.scroll.downOneLine() case k.Key == t.opts.keyPgUp: t.scroll.upOnePage() case k.Key == t.opts.keyPgDown: t.scroll.downOnePage() } return nil } // Mouse implements widgetapi.Widget.Mouse. func (t *Text) Mouse(m *terminalapi.Mouse) error { t.mu.Lock() defer t.mu.Unlock() switch b := m.Button; { case b == t.opts.mouseUpButton: t.scroll.upOneLine() case b == t.opts.mouseDownButton: t.scroll.downOneLine() } return nil } // Options of the widget func (t *Text) Options() widgetapi.Options { return widgetapi.Options{ // At least one line with at least one full-width rune. MinimumSize: image.Point{1, 1}, WantMouse: !t.opts.disableScrolling, WantKeyboard: !t.opts.disableScrolling, } } // validText validates the provided text. func validText(text string) error { if text == "" { return errors.New("the text cannot be empty") } for _, c := range text { if c == ' ' || c == '\n' { // Allowed space and control runes. continue } if unicode.IsControl(c) { return fmt.Errorf("the provided text %q cannot contain control characters, found: %q", text, c) } if unicode.IsSpace(c) { return fmt.Errorf("the provided text %q cannot contain space character %q", text, c) } } return nil }