2018-05-14 22:45:40 +01: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-14 22:16:14 +01:00
// Package text contains a widget that displays textual data.
package text
import (
"fmt"
"image"
2021-04-03 17:04:53 -04:00
"strings"
2018-05-14 22:16:14 +01:00
"sync"
2020-04-10 15:26:45 -04:00
"github.com/mum4k/termdash/private/canvas"
"github.com/mum4k/termdash/private/canvas/buffer"
2021-04-03 17:04:53 -04:00
"github.com/mum4k/termdash/private/runewidth"
2020-04-10 15:26:45 -04:00
"github.com/mum4k/termdash/private/wrap"
2019-02-24 01:27:17 -05:00
"github.com/mum4k/termdash/terminal/terminalapi"
2019-03-02 22:00:07 -05:00
"github.com/mum4k/termdash/widgetapi"
2018-05-14 22:16:14 +01: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 {
2019-02-28 00:50:16 -05:00
// 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
2018-05-14 22:16:14 +01: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 16:15:56 +01: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-14 22:16:14 +01:00
// mu protects the Text widget.
mu sync . Mutex
// opts are the provided options.
opts * options
}
// New returns a new text widget.
2019-02-15 00:20:20 -05:00
func New ( opts ... Option ) ( * Text , error ) {
2018-05-14 22:16:14 +01:00
opt := newOptions ( opts ... )
2019-02-15 00:20:20 -05:00
if err := opt . validate ( ) ; err != nil {
return nil , err
}
2018-05-14 22:16:14 +01:00
return & Text {
2019-02-28 00:50:16 -05:00
scroll : newScrollTracker ( opt ) ,
opts : opt ,
2019-02-15 00:20:20 -05:00
} , nil
2018-05-14 22:16:14 +01:00
}
// Reset resets the widget back to empty content.
func ( t * Text ) Reset ( ) {
t . mu . Lock ( )
defer t . mu . Unlock ( )
2019-02-15 00:40:15 -05:00
t . reset ( )
}
2018-05-14 22:16:14 +01:00
2019-02-15 00:40:15 -05:00
// reset implements Reset, caller must hold t.mu.
func ( t * Text ) reset ( ) {
2019-02-28 00:50:16 -05:00
t . content = nil
t . wrapped = nil
2018-05-14 22:16:14 +01:00
t . scroll = newScrollTracker ( t . opts )
t . lastWidth = 0
2018-05-27 16:15:56 +01:00
t . contentChanged = true
2018-05-14 22:16:14 +01:00
}
2021-04-03 17:04:53 -04:00
// contentCells calculates the number of cells the content takes to display on
// terminal.
func ( t * Text ) contentCells ( ) int {
cells := 0
for _ , c := range t . content {
cells += runewidth . RuneWidth ( c . Rune , runewidth . CountAsWidth ( '\n' , 1 ) )
}
return cells
}
2018-05-14 22:16:14 +01:00
// Write writes text for the widget to display. Multiple calls append
2019-02-03 23:39:29 -05:00
// additional text. The text contain cannot control characters
// (unicode.IsControl) or space character (unicode.IsSpace) other than:
2023-02-08 13:15:27 -05:00
//
// ' ', '\n'
//
2018-05-14 22:16:14 +01:00
// 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 ( )
2019-03-02 17:46:03 -05:00
if err := wrap . ValidText ( text ) ; err != nil {
2018-05-14 22:16:14 +01:00
return err
}
2019-02-15 00:40:15 -05:00
opts := newWriteOptions ( wOpts ... )
if opts . replace {
t . reset ( )
}
2021-04-03 17:04:53 -04:00
truncated := truncateToCells ( text , t . opts . maxTextCells )
textCells := runewidth . StringWidth ( truncated , runewidth . CountAsWidth ( '\n' , 1 ) )
contentCells := t . contentCells ( )
// If MaxTextCells has been set, limit the content if needed.
if t . opts . maxTextCells > 0 && contentCells + textCells > t . opts . maxTextCells {
diff := contentCells + textCells - t . opts . maxTextCells
t . content = t . content [ diff : ]
}
for _ , r := range truncated {
2019-02-28 00:50:16 -05:00
t . content = append ( t . content , buffer . NewCell ( r , opts . cellOpts ) )
2018-05-14 22:16:14 +01:00
}
2018-05-27 16:15:56 +01:00
t . contentChanged = true
2018-05-14 22:16:14 +01: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-20 22:51:38 +01: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 {
2020-10-13 00:42:25 -04:00
cells , err := cvs . SetCell ( cur , t . opts . scrollUp )
2018-05-20 22:51:38 +01:00
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 ( )
2019-02-28 00:50:16 -05:00
lines := len ( t . wrapped )
2018-05-20 22:51:38 +01:00
if cur . Y == height - 1 && height >= minLinesForMarkers && height < lines - fromLine {
2020-10-13 00:42:25 -04:00
cells , err := cvs . SetCell ( cur , t . opts . scrollDown )
2018-05-20 22:51:38 +01:00
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-14 22:16:14 +01:00
// draw draws the text context on the canvas starting at the specified line.
2019-02-28 00:50:16 -05:00
func ( t * Text ) draw ( cvs * canvas . Canvas ) error {
2018-05-14 22:16:14 +01:00
var cur image . Point // Tracks the current drawing position on the canvas.
height := cvs . Area ( ) . Dy ( )
2019-02-28 00:50:16 -05:00
fromLine := t . scroll . firstLine ( len ( t . wrapped ) , height )
2018-05-14 22:16:14 +01:00
2019-02-28 00:50:16 -05:00
for _ , line := range t . wrapped [ fromLine : ] {
2018-05-20 22:51:38 +01:00
// Scroll up marker.
scrlUp , err := t . drawScrollUp ( cvs , cur , fromLine )
if err != nil {
return err
}
if scrlUp {
2018-05-14 22:16:14 +01:00
cur = image . Point { 0 , cur . Y + 1 } // Move to the next line.
2019-02-28 00:50:16 -05:00
// Skip one line of text, the marker replaced it.
2018-05-14 22:16:14 +01:00
continue
}
2018-05-20 22:51:38 +01:00
// Scroll down marker.
scrlDown , err := t . drawScrollDown ( cvs , cur , fromLine )
if err != nil {
return err
2018-05-14 22:16:14 +01:00
}
2018-05-20 22:51:38 +01:00
if scrlDown || cur . Y >= height {
2019-02-28 00:50:16 -05:00
break // Skip all lines falling after (under) the canvas.
2018-05-14 22:16:14 +01:00
}
2019-02-28 00:50:16 -05:00
for _ , cell := range line {
tr , err := lineTrim ( cvs , cur , cell . Rune , t . opts )
if err != nil {
return err
}
cur = tr . curPoint
if tr . trimmed {
break // Skip over any characters trimmed on the current line.
}
2018-05-14 22:16:14 +01:00
2019-02-28 00:50:16 -05:00
cells , err := cvs . SetCell ( cur , cell . Rune , cell . Opts )
2019-02-04 21:41:04 -05:00
if err != nil {
return err
}
2019-02-28 00:50:16 -05:00
cur = image . Point { cur . X + cells , cur . Y } // Move within the same line.
2018-05-14 22:16:14 +01:00
}
2019-02-28 00:50:16 -05:00
cur = image . Point { 0 , cur . Y + 1 } // Move to the next line.
2018-05-14 22:16:14 +01:00
}
return nil
}
// Draw draws the text onto the canvas.
// Implements widgetapi.Widget.Draw.
2019-04-03 23:13:18 -04:00
func ( t * Text ) Draw ( cvs * canvas . Canvas , meta * widgetapi . Meta ) error {
2018-05-14 22:16:14 +01:00
t . mu . Lock ( )
defer t . mu . Unlock ( )
width := cvs . Area ( ) . Dx ( )
2019-03-02 17:56:37 -05:00
if len ( t . content ) > 0 && ( t . contentChanged || t . lastWidth != width ) {
2018-05-14 22:16:14 +01:00
// The previous text preprocessing (line wrapping) is invalidated when
// new text is added or the width of the canvas changed.
2019-03-02 17:46:03 -05:00
wr , err := wrap . Cells ( t . content , width , t . opts . wrapMode )
if err != nil {
return err
}
t . wrapped = wr
2018-05-14 22:16:14 +01:00
}
t . lastWidth = width
2019-02-28 00:50:16 -05:00
if len ( t . wrapped ) == 0 {
2018-05-14 22:16:14 +01:00
return nil // Nothing to draw if there's no text.
}
2019-02-28 00:50:16 -05:00
if err := t . draw ( cvs ) ; err != nil {
2018-05-14 22:16:14 +01:00
return err
}
2018-05-27 16:15:56 +01:00
t . contentChanged = false
2018-05-14 22:16:14 +01:00
return nil
}
2019-01-19 16:16:19 +01:00
// Keyboard implements widgetapi.Widget.Keyboard.
2020-11-24 22:03:58 -05:00
func ( t * Text ) Keyboard ( k * terminalapi . Keyboard , meta * widgetapi . EventMeta ) error {
2018-05-14 22:16:14 +01:00
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 16:16:19 +01:00
// Mouse implements widgetapi.Widget.Mouse.
2020-11-24 22:03:58 -05:00
func ( t * Text ) Mouse ( m * terminalapi . Mouse , meta * widgetapi . EventMeta ) error {
2018-05-14 22:16:14 +01:00
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 16:16:19 +01:00
// Options of the widget
2018-05-14 22:16:14 +01:00
func ( t * Text ) Options ( ) widgetapi . Options {
2019-02-22 00:33:55 -05:00
var ks widgetapi . KeyScope
2019-02-23 00:41:58 -05:00
var ms widgetapi . MouseScope
2019-02-22 00:33:55 -05:00
if t . opts . disableScrolling {
ks = widgetapi . KeyScopeNone
2019-02-23 00:41:58 -05:00
ms = widgetapi . MouseScopeNone
2019-02-22 00:33:55 -05:00
} else {
ks = widgetapi . KeyScopeFocused
2019-02-23 00:41:58 -05:00
ms = widgetapi . MouseScopeWidget
2019-02-22 00:33:55 -05:00
}
2018-05-14 22:16:14 +01:00
return widgetapi . Options {
2018-05-20 22:51:38 +01:00
// At least one line with at least one full-width rune.
2018-05-14 22:16:14 +01:00
MinimumSize : image . Point { 1 , 1 } ,
2019-02-23 00:41:58 -05:00
WantMouse : ms ,
2019-02-22 00:33:55 -05:00
WantKeyboard : ks ,
2018-05-14 22:16:14 +01:00
}
}
2021-04-03 17:04:53 -04:00
// truncateToCells truncates the beginning of text, so that it can be displayed
// in at most maxCells. Setting maxCells to zero disables truncating.
func truncateToCells ( text string , maxCells int ) string {
textCells := runewidth . StringWidth ( text , runewidth . CountAsWidth ( '\n' , 1 ) )
if maxCells == 0 || textCells <= maxCells {
return text
}
haveCells := 0
textRunes := [ ] rune ( text )
i := len ( textRunes ) - 1
for ; i >= 0 ; i -- {
haveCells += runewidth . RuneWidth ( textRunes [ i ] , runewidth . CountAsWidth ( '\n' , 1 ) )
if haveCells > maxCells {
break
}
}
var b strings . Builder
for j := i + 1 ; j < len ( textRunes ) ; j ++ {
b . WriteRune ( textRunes [ j ] )
}
return b . String ( )
}