rowText cut each column's piece of a cell's text with ansi.Cut, which rescans the text from its start, so drawing long text cost O(columns x length) per row. textCutter walks the text once, cutting exactly what ansi.Cut would (tested against it with wide, combining and emoji clusters). View/longtext-8192x500/80x24 1.24 ms -> 0.22 ms View/longtext-8192x500/200x60 9.08 ms -> 0.73 ms View/longtext-8192x500/400x120 40.5 ms -> 2.0 ms
- commit
- e1daf4c58fb2e7ed65e21bad36c0053d21be80d0
- parent
- 484093772d17b89d1dd7402805f1ecd886468054
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 46 | +++++++++++++++++++++++++++++- |
| A | internal/ | 33 | ++++++++++++++++++++++ |
@@ -325,6 +325,7 @@ func (m *Model) rowText(row, lo, ncols, minCol, maxCol int) []span {
325325 }326326 }327327 claimed = to328+ cut := textCutter{s: text}328329 for k := max(first, lo); k <= min(last, hi); k++ {329330 kx0, kx1 := col(k)330331 if kx1 <= from || kx0 >= to {@@ -332,7 +333,7 @@ func (m *Model) rowText(row, lo, ncols, minCol, maxCol int) []span {
332333 }333334 sp := span{trail: kx1 - kx0, style: cell.Style, owner: c}334335 if seg0, seg1 := max(start, kx0, from), min(start+tw, kx1, to); seg1 > seg0 {335- sp.lead, sp.text, sp.trail = seg0-kx0, ansi.Cut(text, seg0-start, seg1-start), kx1-seg1336+ sp.lead, sp.text, sp.trail = seg0-kx0, cut.cut(seg0-start, seg1-start), kx1-seg1336337 }337338 out[k-lo] = sp338339 }@@ -349,6 +350,49 @@ func (m *Model) rowText(row, lo, ncols, minCol, maxCol int) []span {
349350 return out350351 }351352 353+// textCutter cuts successive column ranges, left to right, out of one354+// line of text in a single pass, as ansi.Cut would one at a time: a355+// cluster is in [l, r) when its right edge is past l and not past r.356+// Cutting each column's piece with ansi.Cut rescanned the text from its357+// start, so a screen of 500-character text took 40 ms to draw.358+type textCutter struct {359+ s string360+ i int // byte offset of the next cluster361+ right int // right edge, in columns, of the text before s[i:]362+}363+364+func (c *textCutter) cut(l, r int) string {365+ for c.i < len(c.s) {366+ n, w := nextCluster(c.s[c.i:])367+ if c.right+w > l {368+ break369+ }370+ c.i, c.right = c.i+n, c.right+w371+ }372+ from := c.i373+ for c.i < len(c.s) {374+ n, w := nextCluster(c.s[c.i:])375+ if c.right+w > r {376+ break377+ }378+ c.i, c.right = c.i+n, c.right+w379+ }380+ return c.s[from:c.i]381+}382+383+// nextCluster returns the length in bytes and width in columns of the384+// grapheme cluster s starts with, measured as ansi.Cut measures it.385+func nextCluster(s string) (n, w int) {386+ switch b := s[0]; {387+ case b >= 0x20 && b < 0x7f:388+ return 1, 1389+ case b < 0x80:390+ return 1, 0391+ }392+ g, w := ansi.FirstGraphemeCluster(s, ansi.GraphemeWidth)393+ return len(g), w394+}395+352396 // keyLabel formats a key binding for display, e.g. "ctrl+s" -> "Ctrl+S".353397 func keyLabel(k string) string {354398 switch k {@@ -0,0 +1,33 @@
1+package ui2+3+import (4+ "testing"5+6+ "github.com/charmbracelet/x/ansi"7+)8+9+// textCutter must cut exactly what ansi.Cut does, wide and combining10+// characters included, for every way of splitting a line into columns.11+func TestTextCutterMatchesCut(t *testing.T) {12+ texts := []string{13+ "plain ascii text",14+ "Ça déjà vu, naïve café",15+ "東京都の天気は晴れ",16+ "é combining, 🙂🚀 emoji, 🇫🇷 flag, 👍🏽 tone",17+ "a東b京c🙂d",18+ "",19+ }20+ for _, s := range texts {21+ tw := ansi.StringWidth(s)22+ for step := 1; step <= 4; step++ {23+ for off := range step {24+ c := textCutter{s: s}25+ for l := off; l < tw+step; l += step {26+ if got, want := c.cut(l, l+step), ansi.Cut(s, l, l+step); got != want {27+ t.Errorf("%q [%d,%d): got %q, want %q", s, l, l+step, got, want)28+ }29+ }30+ }31+ }32+ }33+}