2018-05-15 05:45:40 +08:00
// 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.
2018-05-15 05:16:14 +08:00
// Package text contains a widget that displays textual data.
package text
import (
"bytes"
"errors"
"fmt"
"image"
"sync"
"unicode"
2019-02-24 10:09:38 +08:00
"github.com/mum4k/termdash/internal/attrrange"
"github.com/mum4k/termdash/internal/canvas"
2019-02-24 10:19:16 +08:00
"github.com/mum4k/termdash/internal/terminalapi"
"github.com/mum4k/termdash/internal/widgetapi"
2018-05-15 05:16:14 +08:00
)
// 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.
2019-02-05 10:41:04 +08:00
givenWOpts [ ] * writeOptions
// wOptsTracker tracks the positions in a buff to which the givenWOpts apply.
wOptsTracker * attrrange . Tracker
2018-05-15 05:16:14 +08:00
// 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
2018-05-27 23:15:56 +08:00
// 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
2018-05-15 05:16:14 +08:00
// 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.
2019-02-15 13:20:20 +08:00
func New ( opts ... Option ) ( * Text , error ) {
2018-05-15 05:16:14 +08:00
opt := newOptions ( opts ... )
2019-02-15 13:20:20 +08:00
if err := opt . validate ( ) ; err != nil {
return nil , err
}
2018-05-15 05:16:14 +08:00
return & Text {
2019-02-05 10:41:04 +08:00
wOptsTracker : attrrange . NewTracker ( ) ,
scroll : newScrollTracker ( opt ) ,
opts : opt ,
2019-02-15 13:20:20 +08:00
} , nil
2018-05-15 05:16:14 +08:00
}
// Reset resets the widget back to empty content.
func ( t * Text ) Reset ( ) {
t . mu . Lock ( )
defer t . mu . Unlock ( )
2019-02-15 13:40:15 +08:00
t . reset ( )
}
2018-05-15 05:16:14 +08:00
2019-02-15 13:40:15 +08:00
// reset implements Reset, caller must hold t.mu.
func ( t * Text ) reset ( ) {
2018-05-15 05:16:14 +08:00
t . buff . Reset ( )
2019-02-05 10:41:04 +08:00
t . givenWOpts = nil
t . wOptsTracker = attrrange . NewTracker ( )
2018-05-15 05:16:14 +08:00
t . scroll = newScrollTracker ( t . opts )
t . lastWidth = 0
2018-05-27 23:15:56 +08:00
t . contentChanged = true
2018-05-15 05:16:14 +08:00
t . lines = nil
}
// Write writes text for the widget to display. Multiple calls append
2019-02-04 12:39:29 +08:00
// additional text. The text contain cannot control characters
// (unicode.IsControl) or space character (unicode.IsSpace) other than:
2018-05-15 05:16:14 +08:00
// ' ', '\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
}
2019-02-15 13:40:15 +08:00
opts := newWriteOptions ( wOpts ... )
if opts . replace {
t . reset ( )
}
2018-05-15 05:16:14 +08:00
pos := t . buff . Len ( )
2019-02-15 13:40:15 +08:00
t . givenWOpts = append ( t . givenWOpts , opts )
2019-02-05 10:41:04 +08:00
wOptsIdx := len ( t . givenWOpts ) - 1
if err := t . wOptsTracker . Add ( pos , pos + len ( text ) , wOptsIdx ) ; err != nil {
return err
}
2018-05-15 05:16:14 +08:00
if _ , err := t . buff . WriteString ( text ) ; err != nil {
return err
}
2018-05-27 23:15:56 +08:00
t . contentChanged = true
2018-05-15 05:16:14 +08:00
return nil
}
// minLinesForMarkers are the minimum amount of lines required on the canvas in
// order to draw the scroll markers ('⇧' and '⇩').
const minLinesForMarkers = 3
2018-05-21 05:51:38 +08:00
// 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
}
2018-05-15 05:16:14 +08:00
// draw draws the text context on the canvas starting at the specified line.
2018-05-21 05:51:38 +08:00
func ( t * Text ) draw ( text string , cvs * canvas . Canvas ) error {
2018-05-15 05:16:14 +08:00
var cur image . Point // Tracks the current drawing position on the canvas.
height := cvs . Area ( ) . Dy ( )
2018-05-21 05:51:38 +08:00
fromLine := t . scroll . firstLine ( len ( t . lines ) , height )
2019-02-05 10:41:04 +08:00
optRange , err := t . wOptsTracker . ForPosition ( 0 ) // Text options for the current byte.
if err != nil {
return err
}
2018-05-21 05:51:38 +08:00
startPos := t . lines [ fromLine ]
2018-05-15 05:16:14 +08:00
for i , r := range text {
if i < startPos {
continue
}
2018-05-21 05:51:38 +08:00
// Scroll up marker.
scrlUp , err := t . drawScrollUp ( cvs , cur , fromLine )
if err != nil {
return err
}
if scrlUp {
2018-05-15 05:16:14 +08:00
cur = image . Point { 0 , cur . Y + 1 } // Move to the next line.
2018-05-21 05:51:38 +08:00
startPos = t . lines [ fromLine + 1 ] // Skip one line of text, the marker replaced it.
2018-05-15 05:16:14 +08:00
continue
}
2018-05-21 05:51:38 +08:00
// Line wrapping.
2018-05-15 05:16:14 +08:00
if r == '\n' || wrapNeeded ( r , cur . X , cvs . Area ( ) . Dx ( ) , t . opts ) {
cur = image . Point { 0 , cur . Y + 1 } // Move to the next line.
}
2018-05-21 05:51:38 +08:00
// Scroll down marker.
scrlDown , err := t . drawScrollDown ( cvs , cur , fromLine )
if err != nil {
return err
2018-05-15 05:16:14 +08:00
}
2018-05-21 05:51:38 +08:00
if scrlDown || cur . Y >= height {
break // Trim all lines falling after the canvas.
2018-05-15 05:16:14 +08:00
}
2018-05-21 05:51:38 +08:00
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.
2018-05-15 05:16:14 +08:00
}
2018-05-21 05:51:38 +08:00
if r == '\n' {
continue // Don't print the newline runes, just interpret them above.
2018-05-15 05:16:14 +08:00
}
2019-02-05 10:41:04 +08:00
if i >= optRange . High { // Get the next write options.
or , err := t . wOptsTracker . ForPosition ( i )
if err != nil {
return err
}
optRange = or
2018-05-15 05:16:14 +08:00
}
2019-02-05 10:41:04 +08:00
wOpts := t . givenWOpts [ optRange . AttrIdx ]
cells , err := cvs . SetCell ( cur , r , wOpts . cellOpts )
2018-05-21 05:51:38 +08:00
if err != nil {
2018-05-15 05:16:14 +08:00
return err
}
2018-05-21 05:51:38 +08:00
cur = image . Point { cur . X + cells , cur . Y } // Move within the same line.
2018-05-15 05:16:14 +08:00
}
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 ( )
2018-05-27 23:15:56 +08:00
if t . contentChanged || t . lastWidth != width {
2018-05-15 05:16:14 +08:00
// 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.
}
2018-05-21 05:51:38 +08:00
if err := t . draw ( text , cvs ) ; err != nil {
2018-05-15 05:16:14 +08:00
return err
}
2018-05-27 23:15:56 +08:00
t . contentChanged = false
2018-05-15 05:16:14 +08:00
return nil
}
2019-01-19 23:16:19 +08:00
// Keyboard implements widgetapi.Widget.Keyboard.
2018-05-15 05:16:14 +08:00
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
}
2019-01-19 23:16:19 +08:00
// Mouse implements widgetapi.Widget.Mouse.
2018-05-15 05:16:14 +08:00
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
}
2019-01-19 23:16:19 +08:00
// Options of the widget
2018-05-15 05:16:14 +08:00
func ( t * Text ) Options ( ) widgetapi . Options {
2019-02-22 13:33:55 +08:00
var ks widgetapi . KeyScope
2019-02-23 13:41:58 +08:00
var ms widgetapi . MouseScope
2019-02-22 13:33:55 +08:00
if t . opts . disableScrolling {
ks = widgetapi . KeyScopeNone
2019-02-23 13:41:58 +08:00
ms = widgetapi . MouseScopeNone
2019-02-22 13:33:55 +08:00
} else {
ks = widgetapi . KeyScopeFocused
2019-02-23 13:41:58 +08:00
ms = widgetapi . MouseScopeWidget
2019-02-22 13:33:55 +08:00
}
2018-05-15 05:16:14 +08:00
return widgetapi . Options {
2018-05-21 05:51:38 +08:00
// At least one line with at least one full-width rune.
2018-05-15 05:16:14 +08:00
MinimumSize : image . Point { 1 , 1 } ,
2019-02-23 13:41:58 +08:00
WantMouse : ms ,
2019-02-22 13:33:55 +08:00
WantKeyboard : ks ,
2018-05-15 05:16:14 +08:00
}
}
// 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
}