1
0
mirror of https://github.com/mum4k/termdash.git synced 2025-04-25 13:48:50 +08:00
Jakub Sobon 94d07aea18
Format files with gofmt from Golang 1.20.
Signed-off-by: Jakub Sobon <jakub.sobon@elohim.sk>
2023-02-08 13:15:27 -05:00

336 lines
9.4 KiB
Go

// 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 (
"fmt"
"image"
"strings"
"sync"
"github.com/mum4k/termdash/private/canvas"
"github.com/mum4k/termdash/private/canvas/buffer"
"github.com/mum4k/termdash/private/runewidth"
"github.com/mum4k/termdash/private/wrap"
"github.com/mum4k/termdash/terminal/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 {
// 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
// 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
// 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{
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.reset()
}
// reset implements Reset, caller must hold t.mu.
func (t *Text) reset() {
t.content = nil
t.wrapped = nil
t.scroll = newScrollTracker(t.opts)
t.lastWidth = 0
t.contentChanged = true
}
// 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
}
// 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 := wrap.ValidText(text); err != nil {
return err
}
opts := newWriteOptions(wOpts...)
if opts.replace {
t.reset()
}
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 {
t.content = append(t.content, buffer.NewCell(r, opts.cellOpts))
}
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, t.opts.scrollUp)
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.wrapped)
if cur.Y == height-1 && height >= minLinesForMarkers && height < lines-fromLine {
cells, err := cvs.SetCell(cur, t.opts.scrollDown)
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(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.wrapped), height)
for _, line := range t.wrapped[fromLine:] {
// 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.
// Skip one line of text, the marker replaced it.
continue
}
// Scroll down marker.
scrlDown, err := t.drawScrollDown(cvs, cur, fromLine)
if err != nil {
return err
}
if scrlDown || cur.Y >= height {
break // Skip all lines falling after (under) the canvas.
}
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.
}
cells, err := cvs.SetCell(cur, cell.Rune, cell.Opts)
if err != nil {
return err
}
cur = image.Point{cur.X + cells, cur.Y} // Move within the same line.
}
cur = image.Point{0, cur.Y + 1} // Move to the next line.
}
return nil
}
// Draw draws the text onto the canvas.
// Implements widgetapi.Widget.Draw.
func (t *Text) Draw(cvs *canvas.Canvas, meta *widgetapi.Meta) error {
t.mu.Lock()
defer t.mu.Unlock()
width := cvs.Area().Dx()
if len(t.content) > 0 && (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.
wr, err := wrap.Cells(t.content, width, t.opts.wrapMode)
if err != nil {
return err
}
t.wrapped = wr
}
t.lastWidth = width
if len(t.wrapped) == 0 {
return nil // Nothing to draw if there's no text.
}
if err := t.draw(cvs); err != nil {
return err
}
t.contentChanged = false
return nil
}
// Keyboard implements widgetapi.Widget.Keyboard.
func (t *Text) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) 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, meta *widgetapi.EventMeta) 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 {
var ks widgetapi.KeyScope
var ms widgetapi.MouseScope
if t.opts.disableScrolling {
ks = widgetapi.KeyScopeNone
ms = widgetapi.MouseScopeNone
} else {
ks = widgetapi.KeyScopeFocused
ms = widgetapi.MouseScopeWidget
}
return widgetapi.Options{
// At least one line with at least one full-width rune.
MinimumSize: image.Point{1, 1},
WantMouse: ms,
WantKeyboard: ks,
}
}
// 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()
}