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:
parent
3cfeb8ad81
commit
4238ac6f76
@ -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.
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
@ -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
|
||||
})
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user