Cell canvas renderer with frame cache and a perceptual 256-colour quantizer; responsive geometry (large/medium/small/too-small); squarified treemap with nested previews, LOD grouping, category colouring and adaptive labels; spatial arrow navigation with stable NodeID selection; zoom and breadcrumbs; modal system (info, help, confirm, save); list, file-type, top-N, scan-info, duplicate and comparison views; live search/filter; mouse support; move-to-trash.
161 lines
3.7 KiB
Go
161 lines
3.7 KiB
Go
// Package textutil holds display helpers: sanitising untrusted strings for
|
|
// the terminal, width-aware truncation, and human-readable units.
|
|
package textutil
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/charmbracelet/x/ansi"
|
|
"github.com/rivo/uniseg"
|
|
)
|
|
|
|
// Sanitize makes an untrusted string (a filename, snapshot data) safe to
|
|
// write to a terminal. Control characters (C0, DEL, C1) and bidi overrides are
|
|
// replaced by visible escapes; invalid UTF-8 becomes U+FFFD. The original
|
|
// string should be kept for filesystem operations.
|
|
func Sanitize(s string) string {
|
|
clean := true
|
|
for i := 0; i < len(s); i++ {
|
|
if c := s[i]; c < 0x20 || c >= 0x7f {
|
|
clean = false
|
|
break
|
|
}
|
|
}
|
|
if clean {
|
|
return s
|
|
}
|
|
var b strings.Builder
|
|
b.Grow(len(s) + 8)
|
|
for i := 0; i < len(s); {
|
|
r, size := utf8.DecodeRuneInString(s[i:])
|
|
if r == utf8.RuneError && size <= 1 {
|
|
b.WriteRune(utf8.RuneError)
|
|
i++
|
|
continue
|
|
}
|
|
i += size
|
|
switch {
|
|
case r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f):
|
|
fmt.Fprintf(&b, `\x%02x`, r)
|
|
case isBidiControl(r):
|
|
fmt.Fprintf(&b, `\u%04x`, r)
|
|
default:
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func isBidiControl(r rune) bool {
|
|
return (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069) ||
|
|
r == 0x200e || r == 0x200f || r == 0x061c
|
|
}
|
|
|
|
// Clusters calls fn for each grapheme cluster of s with its display width.
|
|
// Clusters whose width terminals disagree on (per-rune wcwidth versus
|
|
// grapheme width, e.g. emoji ZWJ sequences) are replaced by U+FFFD with
|
|
// width 1, and zero-width clusters are dropped, so that measuring and
|
|
// drawing always agree with the terminal. fn returns false to stop.
|
|
func Clusters(s string, fn func(c string, w int) bool) {
|
|
state := -1
|
|
for s != "" {
|
|
var c string
|
|
var w int
|
|
c, s, w, state = uniseg.FirstGraphemeClusterInString(s, state)
|
|
if w == 0 {
|
|
continue
|
|
}
|
|
if len(c) > 1 && ansi.WcWidth.StringWidth(c) != w {
|
|
c, w = "\uFFFD", 1
|
|
}
|
|
if !fn(c, w) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Width returns the display width of s in terminal cells.
|
|
func Width(s string) int {
|
|
n := 0
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] < 0x20 || s[i] >= 0x7f {
|
|
Clusters(s, func(_ string, w int) bool { n += w; return true })
|
|
return n
|
|
}
|
|
}
|
|
return len(s)
|
|
}
|
|
|
|
// Truncate clips s to at most w cells, appending "…" if anything was cut.
|
|
func Truncate(s string, w int) string {
|
|
if w <= 0 {
|
|
return ""
|
|
}
|
|
if Width(s) <= w {
|
|
return s
|
|
}
|
|
return clip(s, w-1) + "…"
|
|
}
|
|
|
|
// TruncateLeft clips from the left, prefixing "…" (used for paths and
|
|
// breadcrumbs where the tail is the informative part).
|
|
func TruncateLeft(s string, w int) string {
|
|
if w <= 0 {
|
|
return ""
|
|
}
|
|
total := Width(s)
|
|
if total <= w {
|
|
return s
|
|
}
|
|
skip := total - (w - 1)
|
|
var b strings.Builder
|
|
Clusters(s, func(c string, cw int) bool {
|
|
if skip > 0 {
|
|
skip -= cw
|
|
if skip < 0 {
|
|
// A wide cluster overshot by one cell; pad to keep width exact.
|
|
b.WriteString(strings.Repeat(" ", -skip))
|
|
}
|
|
return true
|
|
}
|
|
b.WriteString(c)
|
|
return true
|
|
})
|
|
return "…" + b.String()
|
|
}
|
|
|
|
// clip returns the longest prefix of s that fits in w cells.
|
|
func clip(s string, w int) string {
|
|
var b strings.Builder
|
|
used := 0
|
|
Clusters(s, func(c string, cw int) bool {
|
|
if used+cw > w {
|
|
return false
|
|
}
|
|
used += cw
|
|
b.WriteString(c)
|
|
return true
|
|
})
|
|
return b.String()
|
|
}
|
|
|
|
// PadRight pads s with spaces to exactly w cells (truncating if longer).
|
|
func PadRight(s string, w int) string {
|
|
s = Truncate(s, w)
|
|
if d := w - Width(s); d > 0 {
|
|
s += strings.Repeat(" ", d)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// PadLeft right-aligns s in w cells.
|
|
func PadLeft(s string, w int) string {
|
|
s = Truncate(s, w)
|
|
if d := w - Width(s); d > 0 {
|
|
s = strings.Repeat(" ", d) + s
|
|
}
|
|
return s
|
|
}
|