Skip to content

Commit

Permalink
Merge c550f32 into 3cfeb8a
Browse files Browse the repository at this point in the history
  • Loading branch information
mum4k authored Apr 3, 2021
2 parents 3cfeb8a + c550f32 commit 25de821
Show file tree
Hide file tree
Showing 6 changed files with 397 additions and 7 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- The `Text` widget has a new option `MaxTextCells` which can be used to limit
the maximum number of cells the widget keeps in memory.

### Changed

- Bump github.com/mattn/go-runewidth from 0.0.10 to 0.0.12.
Expand Down
50 changes: 47 additions & 3 deletions private/runewidth/runewidth.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,41 @@ package runewidth

import runewidth "github.com/mattn/go-runewidth"

// Option is used to provide options.
type Option interface {
// set sets the provided option.
set(*options)
}

// options stores the provided options.
type options struct {
runeWidths map[rune]int
}

// newOptions create a new instance of options.
func newOptions() *options {
return &options{
runeWidths: map[rune]int{},
}
}

// option implements Option.
type option func(*options)

// set implements Option.set.
func (o option) set(opts *options) {
o(opts)
}

// CountAsWidth overrides the default behavior, counting the specified runes as
// the specified width. Can be provided multiple times to specify an override
// for multiple runes.
func CountAsWidth(r rune, width int) Option {
return option(func(opts *options) {
opts.runeWidths[r] = width
})
}

// RuneWidth returns the number of cells needed to draw r.
// Background in http://www.unicode.org/reports/tr11/.
//
Expand All @@ -29,7 +64,16 @@ import runewidth "github.com/mattn/go-runewidth"
// This should be safe, since even in locales where these runes have ambiguous
// width, we still place all the character content around them so they should
// have be half-width.
func RuneWidth(r rune) int {
func RuneWidth(r rune, opts ...Option) int {
o := newOptions()
for _, opt := range opts {
opt.set(o)
}

if w, ok := o.runeWidths[r]; ok {
return w
}

if inTable(r, exceptions) {
return 1
}
Expand All @@ -38,10 +82,10 @@ func RuneWidth(r rune) int {

// StringWidth is like RuneWidth, but returns the number of cells occupied by
// all the runes in the string.
func StringWidth(s string) int {
func StringWidth(s string, opts ...Option) int {
var width int
for _, r := range []rune(s) {
width += RuneWidth(r)
width += RuneWidth(r, opts...)
}
return width
}
Expand Down
25 changes: 22 additions & 3 deletions private/runewidth/runewidth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ func TestRuneWidth(t *testing.T) {
tests := []struct {
desc string
runes []rune
opts []Option
eastAsian bool
want int
}{
Expand All @@ -34,9 +35,17 @@ func TestRuneWidth(t *testing.T) {
},
{
desc: "non-printable characters from mattn/runewidth/runewidth_test",
runes: []rune{'\x00', '\x01', '\u0300', '\u2028', '\u2029'},
runes: []rune{'\x00', '\x01', '\u0300', '\u2028', '\u2029', '\n'},
want: 0,
},
{
desc: "override rune width with an option",
runes: []rune{'\n'},
opts: []Option{
CountAsWidth('\n', 3),
},
want: 3,
},
{
desc: "half-width runes from mattn/runewidth/runewidth_test",
runes: []rune{'セ', 'カ', 'イ', '☆'},
Expand Down Expand Up @@ -107,7 +116,7 @@ func TestRuneWidth(t *testing.T) {
}()

for _, r := range tc.runes {
if got := RuneWidth(r); got != tc.want {
if got := RuneWidth(r, tc.opts...); got != tc.want {
t.Errorf("RuneWidth(%c, %#x) => %v, want %v", r, r, got, tc.want)
}
}
Expand All @@ -119,6 +128,7 @@ func TestStringWidth(t *testing.T) {
tests := []struct {
desc string
str string
opts []Option
eastAsian bool
want int
}{
Expand All @@ -127,6 +137,15 @@ func TestStringWidth(t *testing.T) {
str: "hello",
want: 5,
},
{
desc: "override rune widths with an option",
str: "hello",
opts: []Option{
CountAsWidth('h', 5),
CountAsWidth('e', 5),
},
want: 13,
},
{
desc: "string from mattn/runewidth/runewidth_test",
str: "■㈱の世界①",
Expand Down Expand Up @@ -158,7 +177,7 @@ func TestStringWidth(t *testing.T) {
runewidth.DefaultCondition.EastAsianWidth = false
}()

if got := StringWidth(tc.str); got != tc.want {
if got := StringWidth(tc.str, tc.opts...); got != tc.want {
t.Errorf("StringWidth(%q) => %v, want %v", tc.str, got, tc.want)
}
})
Expand Down
25 changes: 25 additions & 0 deletions widgets/text/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type options struct {
scrollDown rune
wrapMode wrap.Mode
rollContent bool
maxTextCells int
disableScrolling bool
mouseUpButton mouse.Button
mouseDownButton mouse.Button
Expand All @@ -56,6 +57,7 @@ func newOptions(opts ...Option) *options {
keyDown: DefaultScrollKeyDown,
keyPgUp: DefaultScrollKeyPageUp,
keyPgDown: DefaultScrollKeyPageDown,
maxTextCells: DefaultMaxTextCells,
}
for _, o := range opts {
o.set(opt)
Expand All @@ -77,6 +79,9 @@ func (o *options) validate() error {
if o.mouseUpButton == o.mouseDownButton {
return fmt.Errorf("invalid ScrollMouseButtons(up:%v, down:%v), the buttons must be unique", o.mouseUpButton, o.mouseDownButton)
}
if o.maxTextCells < 0 {
return fmt.Errorf("invalid MaxTextCells(%d), must be zero or a positive integer", o.maxTextCells)
}
return nil
}

Expand Down Expand Up @@ -174,3 +179,23 @@ func ScrollKeys(up, down, pageUp, pageDown keyboard.Key) Option {
opts.keyPgDown = pageDown
})
}

// The default value for the MaxTextCells option.
// Use zero as no limit, for logs you may wish to try 10,000 or higher.
const (
DefaultMaxTextCells = 0
)

// MaxTextCells limits the text content to this number of terminal cells.
// This is useful when sending large amounts of text to the Text widget, e.g.
// when tailing logs as it will limit the memory usage.
// When the newly added content goes over this number of cells, the Text widget
// behaves as a circular buffer and drops earlier content to accommodate the
// new one.
// Note the count is in cells, not runes, some wide runes can take multiple
// terminal cells.
func MaxTextCells(max int) Option {
return option(func(opts *options) {
opts.maxTextCells = max
})
}
49 changes: 48 additions & 1 deletion widgets/text/text.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ 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"
Expand Down Expand Up @@ -90,6 +92,16 @@ func (t *Text) reset() {
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:
Expand All @@ -108,7 +120,17 @@ func (t *Text) Write(text string, wOpts ...WriteOption) error {
if opts.replace {
t.reset()
}
for _, r := range text {

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
Expand Down Expand Up @@ -284,3 +306,28 @@ func (t *Text) Options() widgetapi.Options {
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()
}
Loading

0 comments on commit 25de821

Please sign in to comment.