diff --git a/README.md b/README.md index 436d9d3..1afa0cd 100644 --- a/README.md +++ b/README.md @@ -194,3 +194,69 @@ f.Close() fmt.Println(f.String()) ``` + +## Truncating trailing columns + +The `truncate` package lets you truncate lines of text to some maximum +printable width. Content beyond the specified width is dropped. + +```go +import "github.com/muesli/reflow/truncate" + +s := truncate.String("Hello, world", 5) +fmt.Println(s) +``` + +Result: `Hello` + +A "tail" may also be provided, e.g. to provide a visual indication of when a +line has been truncated: + +```go +s := truncate.StringWithTail("Hello, world", 6, "…") +fmt.Println(s) +``` + +Result: `Hello…` + +There is also a truncating Writer, which is compatible with the `io.Writer` +interface: + +```go +w := truncate.NewWriter(width, "…") +w.Write(b) +fmt.Println(f.String()) +``` + +## Skipping leading columns + +The `skip` package lets you skip some number of columns from the start of a +line of text. Content before the before the given width is dropped. + +```go +import "github.com/muesli/reflow/skip" + +s := skip.String("Hello, world", 7) +fmt.Println(s) +``` + +Result: `world` + +A prefix may also be provided, e.g. to provide a visual indication of when part +of a line has been skipped: + +```go +s := skip.StringWithPrefix("Hello, world", 5, "…") +fmt.Println(s) +``` + +Result: `… world` + +There is also a skipping Writer, which is compatible with the `io.Writer` +interface: + +```go +w := skip.NewWriter(width, "…") +w.Write(b) +fmt.Println(f.String()) +``` diff --git a/skip/skip.go b/skip/skip.go new file mode 100644 index 0000000..14de7ef --- /dev/null +++ b/skip/skip.go @@ -0,0 +1,136 @@ +package skip + +import ( + "bytes" + "io" + + "github.com/mattn/go-runewidth" + "github.com/muesli/reflow/ansi" +) + +// Writer drops some number of leading columns from a line of text, while +// leaving any ansi sequences intact. +type Writer struct { + width uint + prefix string + + ansiWriter ansi.Writer + buf bytes.Buffer + ansi bool +} + +// NewWriter returns a new writer that drops the given number of columns from a +// line of text, using the given prefix. Any visible content after the skipped +// portion will be preceded by the given prefix. The prefix is often used to +// provide some visual indication of when content has been scrolled. +func NewWriter(width uint, prefix string) *Writer { + w := &Writer{ + width: width, + prefix: prefix, + } + w.ansiWriter.Forward = &w.buf + return w +} + +// NewWriterPipe returns a new writer that forwards the result to the given +// writer instead of its internal buffer. +func NewWriterPipe(forward io.Writer, width uint, prefix string) *Writer { + return &Writer{ + width: width, + prefix: prefix, + ansiWriter: ansi.Writer{ + Forward: forward, + }, + } +} + +// Bytes drops the specified number of printed columns from the given byte +// slice, leaving any ansi sequences intact. +func Bytes(b []byte, width uint) []byte { + return BytesWithPrefix(b, width, nil) +} + +// BytesWithPrefix drops the specified number of printed columns from the given +// byte slice, leaving any any sequences intact. Any visible content after the +// skipped portion will be preceded by the given prefix. The prefix is often +// used to provide some visual indication of when content has been scrolled. +func BytesWithPrefix(b []byte, width uint, prefix []byte) []byte { + w := NewWriter(width, string(prefix)) + _, _ = w.Write(b) + + return w.Bytes() +} + +// String drops the specified number of printed columns from the given string, +// leaving any ansi sequences intact. +func String(s string, width uint) string { + return StringWithPrefix(s, width, "") +} + +// StringWithPrefix drops the specified number of printed columns from the +// given string, leaving any ansi sequences intact. Any visible content after +// the skipped portion will be preceded by the given prefix. The prefix is +// often used to provide some visual indication of when content has been +// scrolled. +func StringWithPrefix(s string, width uint, prefix string) string { + w := NewWriter(width, prefix) + _, _ = w.Write([]byte(s)) + + return w.String() +} + +func (w *Writer) Write(b []byte) (int, error) { + width := w.width + if width > 0 { + width += uint(ansi.PrintableRuneWidth(w.prefix)) + } + + var currentWidth uint + for _, r := range string(b) { + if r == ansi.Marker { + // ANSI escape sequence + w.ansi = true + } else if w.ansi { + if ansi.IsTerminator(r) { + w.ansi = false + } + } else if currentWidth < width { + rw := uint(runewidth.RuneWidth(r)) + if len(w.prefix) > 0 && currentWidth+rw >= width { + _, err := w.ansiWriter.Write([]byte(w.prefix)) + if err != nil { + return 0, err + } + } + + if currentWidth+rw > width { + // double-width rune across the skip boundary. + // Add spaces to preserve alignment. + for currentWidth < width { + _, _ = w.ansiWriter.Write([]byte(" ")) + currentWidth++ + } + } + currentWidth += rw + + continue + } + + _, err := w.ansiWriter.Write([]byte(string(r))) + if err != nil { + return 0, err + } + } + + return len(b), nil +} + +// Bytes returns the result as a byte slice. +func (w *Writer) Bytes() []byte { + return w.buf.Bytes() +} + +// String returns the result as a string +func (w *Writer) String() string { + return w.buf.String() +} diff --git a/skip/skip_test.go b/skip/skip_test.go new file mode 100644 index 0000000..655214f --- /dev/null +++ b/skip/skip_test.go @@ -0,0 +1,258 @@ +package skip_test + +import ( + "bytes" + "testing" + + "github.com/muesli/reflow/skip" +) + +func TestNewWriter(t *testing.T) { + for _, tt := range tests() { + t.Run(tt.name, func(t *testing.T) { + f := skip.NewWriter(tt.width, tt.prefix) + + _, err := f.Write([]byte(tt.give)) + if err != nil { + t.Error(err) + } + + got := f.String() + if got != tt.want { + t.Errorf("Expected:\n\n`%s`\n\nActual Output:\n\n`%s`", tt.want, got) + } + }) + } +} + +func TestNewWriterPipe(t *testing.T) { + for _, tt := range tests() { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + f := skip.NewWriterPipe(&buf, tt.width, tt.prefix) + + _, err := f.Write([]byte(tt.give)) + if err != nil { + t.Error(err) + } + + if f.String() != "" { + t.Errorf("Expected w.String() to return empty string, got `%s`", f.String()) + } + if len(f.Bytes()) > 0 { + t.Errorf("Expected w.Bytes() to return empty slice, got `%v`", f.Bytes()) + } + + got := buf.String() + if got != tt.want { + t.Errorf("Expected:\n\n`%s`\n\nActual Output:\n\n`%s`", tt.want, got) + } + }) + } +} + +func TestString(t *testing.T) { + for _, tt := range nonPrefixTests() { + t.Run(tt.name, func(t *testing.T) { + got := skip.String(tt.give, tt.width) + if got != tt.want { + t.Errorf("Expected:\n\n`%s`\n\nActual Output:\n\n`%s`", tt.want, got) + } + }) + } +} + +func BenchmarkString(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + b.ReportAllocs() + b.ResetTimer() + for pb.Next() { + skip.String("\x1B[38;2;249;38;114mhello你好\x1B[0m", 5) + } + }) +} + +func TestStringWithPrefix(t *testing.T) { + for _, tt := range tests() { + t.Run(tt.name, func(t *testing.T) { + got := skip.StringWithPrefix(tt.give, tt.width, tt.prefix) + if got != tt.want { + t.Errorf("Expected:\n\n`%s`\n\nActual Output:\n\n`%s`", tt.want, got) + } + }) + } +} + +func BenchmarkStringWithPrefix(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + b.ReportAllocs() + b.ResetTimer() + for pb.Next() { + skip.StringWithPrefix("\x1B[38;2;249;38;114mhello你好\x1B[0m", 5, "…") + } + }) +} +func TestBytes(t *testing.T) { + for _, tt := range nonPrefixTests() { + t.Run(tt.name, func(t *testing.T) { + got := string(skip.Bytes([]byte(tt.give), tt.width)) + if got != tt.want { + t.Errorf("Expected:\n\n`%s`\n\nActual Output:\n\n`%s`", tt.want, got) + } + }) + } +} + +func TestBytesWithPrefix(t *testing.T) { + for _, tt := range tests() { + t.Run(tt.name, func(t *testing.T) { + got := string(skip.BytesWithPrefix([]byte(tt.give), tt.width, []byte(tt.prefix))) + if got != tt.want { + t.Errorf("Expected:\n\n`%s`\n\nActual Output:\n\n`%s`", tt.want, got) + } + }) + } +} + +type test struct { + name string + width uint + prefix string + give string + want string +} + +func tests() []test { + return []test{ + { + name: "no-op", + width: 0, + give: "foo", + want: "foo", + }, + { + name: "no-op with prefix", + width: 0, + prefix: "…", + give: "foo", + want: "foo", + }, + { + name: "no-op with ansi", + width: 0, + give: "\x1B[7mfoo", + want: "\x1B[7mfoo", + }, + { + name: "no-op with ansi and prefix", + width: 0, + prefix: "…", + give: "\x1B[7mfoo", + want: "\x1B[7mfoo", + }, + { + name: "basic skip", + width: 3, + give: "foobar", + want: "bar", + }, + { + name: "basic skip with prefix", + width: 3, + prefix: "…", + give: "foobar", + want: "…ar", + }, + { + // corner case: prefix is honored even if it hides the only + // remaining visible rune + name: "width minus 1 with prefix", + width: 5, + prefix: "…", + give: "foobar", + want: "…", + }, + { + name: "same width", + width: 3, + give: "foo", + want: "", + }, + { + name: "same width with prefix", + width: 3, + prefix: "…", + give: "foo", + want: "", + }, + { + name: "spaces only", + width: 2, + give: " ", + want: " ", + }, + { + name: "spaces only with prefix", + width: 2, + prefix: "…", + give: " ", + want: "… ", + }, + { + name: "double-width runes", + width: 7, + give: "hello你好", + want: "好", + }, + { + name: "double-width rune chopped and replaced by space", + width: 6, + give: "hello你好", + want: " 好", + }, + { + name: "double-width rune chopped and replaced by prefix", + width: 6, + prefix: "…", + give: "hello你好", + want: "…好", + }, + { + name: "double-width rune replaced by prefix and space", + width: 5, + prefix: "…", + give: "hello你好", + want: "… 好", + }, + { + name: "double-width rune chopped and replaced by space, with ansi", + width: 6, + give: "\x1B[38;2;249;38;114mhello你好\x1B[0m", + want: "\x1B[38;2;249;38;114m 好\x1B[0m", + }, + { + name: "double-width rune chopped and replaced by prefix, with ansi", + width: 6, + prefix: "…", + give: "\x1B[38;2;249;38;114mhello你好\x1B[0m", + want: "\x1B[38;2;249;38;114m…好\x1B[0m", + }, + { + name: "double-width rune replaced by prefix and space, with ansi", + width: 5, + prefix: "…", + give: "\x1B[38;2;249;38;114mhello你好\x1B[0m", + want: "\x1B[38;2;249;38;114m… 好\x1B[0m", + }, + } +} + +func nonPrefixTests() []test { + var ts []test + for _, tt := range tests() { + if tt.prefix == "" { + ts = append(ts, tt) + } + } + return ts +}