1
0
mirror of https://github.com/mum4k/termdash.git synced 2025-04-25 13:48:50 +08:00

Implements a buffer limit for the Text widget. (#301)

See issue #293 where memory and performance can degrade with a high number of lines written to the Text widget. 

This is a very simplistic implementation to limit the possible length the text buffer can grow to with the `maxContent` option. 

Default value of -1 means there's no limit and therefore behaviour should remain standard.

It has been working in our test app and allows the use of the Text widget to monitor logs (ie tail) and therefore doesn't bloat over time, but happy to adjust as required.
This commit is contained in:
Jakub Sobon 2021-04-03 17:04:53 -04:00 committed by GitHub
parent 3cfeb8ad81
commit 4238ac6f76
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 396 additions and 7 deletions

View File

@ -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.

View File

@ -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/.
//
@ -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
}
@ -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
}

View File

@ -24,6 +24,7 @@ func TestRuneWidth(t *testing.T) {
tests := []struct {
desc string
runes []rune
opts []Option
eastAsian bool
want int
}{
@ -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{'セ', 'カ', 'イ', '☆'},
@ -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)
}
}
@ -119,6 +128,7 @@ func TestStringWidth(t *testing.T) {
tests := []struct {
desc string
str string
opts []Option
eastAsian bool
want int
}{
@ -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: "■㈱の世界①",
@ -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)
}
})

View File

@ -36,6 +36,7 @@ type options struct {
scrollDown rune
wrapMode wrap.Mode
rollContent bool
maxTextCells int
disableScrolling bool
mouseUpButton mouse.Button
mouseDownButton mouse.Button
@ -56,6 +57,7 @@ func newOptions(opts ...Option) *options {
keyDown: DefaultScrollKeyDown,
keyPgUp: DefaultScrollKeyPageUp,
keyPgDown: DefaultScrollKeyPageDown,
maxTextCells: DefaultMaxTextCells,
}
for _, o := range opts {
o.set(opt)
@ -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
}
@ -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
})
}

View File

@ -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"
@ -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:
@ -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
@ -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()
}

View File

@ -809,6 +809,176 @@ func TestTextDraws(t *testing.T) {
return ft
},
},
{
desc: "tests maxTextCells length being applied - multiline",
canvas: image.Rect(0, 0, 10, 3),
opts: []Option{
MaxTextCells(10),
RollContent(),
},
writes: func(widget *Text) error {
return widget.Write("line0\nline1\nline2\nline3\nline4")
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
c := testcanvas.MustNew(ft.Area())
// \n still counts as a chacter in the string length
testdraw.MustText(c, "ine3", image.Point{0, 0})
testdraw.MustText(c, "line4", image.Point{0, 1})
testcanvas.MustApply(c, ft)
return ft
},
},
{
desc: "tests maxTextCells - multiple writes - first one fits",
canvas: image.Rect(0, 0, 10, 3),
opts: []Option{
MaxTextCells(10),
RollContent(),
},
writes: func(widget *Text) error {
if err := widget.Write("line0\nline"); err != nil {
return err
}
return widget.Write("1\nline2\nline3\nline4")
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
c := testcanvas.MustNew(ft.Area())
// \n still counts as a chacter in the string length
testdraw.MustText(c, "ine3", image.Point{0, 0})
testdraw.MustText(c, "line4", image.Point{0, 1})
testcanvas.MustApply(c, ft)
return ft
},
},
{
desc: "tests maxTextCells - multiple writes - first one does not fit",
canvas: image.Rect(0, 0, 10, 3),
opts: []Option{
MaxTextCells(10),
RollContent(),
},
writes: func(widget *Text) error {
if err := widget.Write("line0\nline123"); err != nil {
return err
}
return widget.Write("1\nline2\nline3\nline4")
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
c := testcanvas.MustNew(ft.Area())
testdraw.MustText(c, "ine3", image.Point{0, 0})
testdraw.MustText(c, "line4", image.Point{0, 1})
testcanvas.MustApply(c, ft)
return ft
},
},
{
desc: "tests maxTextCells - accounts for pre-existing full-width runes on the content",
canvas: image.Rect(0, 0, 10, 3),
opts: []Option{
MaxTextCells(3),
RollContent(),
},
writes: func(widget *Text) error {
if err := widget.Write("界"); err != nil {
return err
}
return widget.Write("ab")
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
c := testcanvas.MustNew(ft.Area())
testdraw.MustText(c, "ab", image.Point{0, 0})
testcanvas.MustApply(c, ft)
return ft
},
},
{
desc: "tests maxTextCells exact length of 5",
canvas: image.Rect(0, 0, 10, 1),
opts: []Option{
RollContent(),
MaxTextCells(5),
},
writes: func(widget *Text) error {
return widget.Write("12345")
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
c := testcanvas.MustNew(ft.Area())
// Line return (\n) counts as one character
testdraw.MustText(
c,
"12345",
image.Point{0, 0},
)
testcanvas.MustApply(c, ft)
return ft
},
},
{
desc: "tests maxTextCells partial bufffer replacement",
canvas: image.Rect(0, 0, 10, 1),
opts: []Option{
RollContent(),
MaxTextCells(10),
},
writes: func(widget *Text) error {
return widget.Write("hello wor你12345678")
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
c := testcanvas.MustNew(ft.Area())
testdraw.MustText(
c,
"你12345678",
image.Point{0, 0},
)
testcanvas.MustApply(c, ft)
return ft
},
},
{
desc: "tests maxTextCells length not being limited",
canvas: image.Rect(0, 0, 72, 1),
opts: []Option{
RollContent(),
},
writes: func(widget *Text) error {
return widget.Write("1234567890abcdefghijklmnopqrstuvwxyz")
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
c := testcanvas.MustNew(ft.Area())
testdraw.MustText(
c,
"1234567890abcdefghijklmnopqrstuvwxyz",
image.Point{0, 0},
)
testcanvas.MustApply(c, ft)
return ft
},
},
{
desc: "tests maxTextCells length being applied - single line",
canvas: image.Rect(0, 0, 10, 3),
opts: []Option{
MaxTextCells(5),
RollContent(),
},
writes: func(widget *Text) error {
return widget.Write("1234567890abcdefghijklmnopqrstuvwxyz")
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
c := testcanvas.MustNew(ft.Area())
testdraw.MustText(c, "vwxyz", image.Point{0, 0})
testcanvas.MustApply(c, ft)
return ft
},
},
}
for _, tc := range tests {
@ -901,3 +1071,82 @@ func TestOptions(t *testing.T) {
})
}
}
func TestTruncateToCells(t *testing.T) {
tests := []struct {
desc string
text string
maxCells int
want string
}{
{
desc: "returns empty on empty text",
text: "",
maxCells: 0,
want: "",
},
{
desc: "no need to truncate, length matches max",
text: "a",
maxCells: 1,
want: "a",
},
{
desc: "no need to truncate, shorter than max",
text: "a",
maxCells: 2,
want: "a",
},
{
desc: "no need to truncate, maxCells set to zero",
text: "a",
maxCells: 0,
want: "a",
},
{
desc: "truncates single rune to enforce max cells",
text: "abc",
maxCells: 2,
want: "bc",
},
{
desc: "truncates multiple runes to enforce max cells",
text: "abcde",
maxCells: 3,
want: "cde",
},
{
desc: "accounts for cells taken by newline characters",
text: "a\ncde",
maxCells: 3,
want: "cde",
},
{
desc: "truncates full-width rune on its edge",
text: "世界",
maxCells: 2,
want: "界",
},
{
desc: "truncates full-width rune because only half of it fits",
text: "世界",
maxCells: 3,
want: "界",
},
{
desc: "full-width runes - truncating not needed",
text: "世界",
maxCells: 4,
want: "世界",
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
got := truncateToCells(tc.text, tc.maxCells)
if diff := pretty.Compare(tc.want, got); diff != "" {
t.Errorf("truncateToCells => unexpected diff (-want, +got):\n%s", diff)
}
})
}
}