detached e3beccd

feat(vt,renderer): true double-width character support

Replace the width-2 '?' placeholder collapse with real double-width cell
handling for CJK/emoji across pkg/vt and pkg/renderer.

- renderer.Cell gains a Continuation flag (right half of a wide pair);
  Cell.Equal compares it and writeRun emits nothing for continuation cells.
- vt putPrintable writes {rune}+{continuation} for width-2 runes, wraps a
  wide rune that would straddle the right edge (clearing the abandoned cell),
  and degrades gracefully on sub-2-column screens.
- Half-cell integrity: overwrites, erase-in-line/display, insert/delete
  chars clear the whole pair via clearWidePairAt/clearRow/repairRow so no
  orphan half survives. Whole-row scroll preserves pairs.
- runeWidth extends emoji coverage (Symbols & Pictographs Extended-A).
  Combining marks (width 0) and ZWJ sequences remain out of scope.
commit
e3beccd8b0e5263632e0e13394348bf44f239116
author
brice · · 2026-07-02 09:36 UTC
parent
7b0740b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +459 -100
M pkg/renderer/cell.go +10 -3
@@ -1,12 +1,19 @@
11 package renderer
22
3+// Cell is a single terminal grid cell.
4+//
5+// A double-width rune (CJK, emoji) occupies TWO adjacent cells: the left cell
6+// holds the rune ({Rune: r, Style: s}) and the right cell is a continuation
7+// marker ({Continuation: true, Rune: 0}). Continuation cells are never emitted
8+// by the renderer — the terminal advances two columns for the wide rune itself.
39 type Cell struct {
4- Rune rune
5- Style Style
10+ Rune rune
11+ Style Style
12+ Continuation bool
613 }
714
815 func BlankCell() Cell { return Cell{Rune: ' ', Style: DefaultStyle()} }
916
1017 func (c Cell) Equal(other Cell) bool {
11- return c.Rune == other.Rune && c.Style.Equal(other.Style)
18+ return c.Rune == other.Rune && c.Continuation == other.Continuation && c.Style.Equal(other.Style)
1219 }
@@ -1,12 +1,19 @@
1 package renderer1 package renderer
2 2
3+// Cell is a single terminal grid cell.
4+//
5+// A double-width rune (CJK, emoji) occupies TWO adjacent cells: the left cell
6+// holds the rune ({Rune: r, Style: s}) and the right cell is a continuation
7+// marker ({Continuation: true, Rune: 0}). Continuation cells are never emitted
8+// by the renderer — the terminal advances two columns for the wide rune itself.
3 type Cell struct {9 type Cell struct {
4- Rune rune10+ Rune rune
5- Style Style11+ Style Style
12+ Continuation bool
6 }13 }
7 14
8 func BlankCell() Cell { return Cell{Rune: ' ', Style: DefaultStyle()} }15 func BlankCell() Cell { return Cell{Rune: ' ', Style: DefaultStyle()} }
9 16
10 func (c Cell) Equal(other Cell) bool {17 func (c Cell) Equal(other Cell) bool {
11- return c.Rune == other.Rune && c.Style.Equal(other.Style)18+ return c.Rune == other.Rune && c.Continuation == other.Continuation && c.Style.Equal(other.Style)
12 }19 }
M pkg/renderer/renderer.go +5 -0
@@ -180,6 +180,11 @@ func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage,
180180 func (r *Renderer) writeRun(out *bytes.Buffer, frame Frame, y, x, width int, style *Style) {
181181 for col := x; col < x+width; col++ {
182182 cell := frame.At(col, y)
183+ // Continuation cells are the right half of a wide rune. Emit nothing:
184+ // the terminal already advanced two columns for the left cell's rune.
185+ if cell.Continuation {
186+ continue
187+ }
183188 if !cell.Style.Equal(*style) {
184189 writeStyle(out, cell.Style)
185190 *style = cell.Style
@@ -180,6 +180,11 @@ func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage,
180 func (r *Renderer) writeRun(out *bytes.Buffer, frame Frame, y, x, width int, style *Style) {180 func (r *Renderer) writeRun(out *bytes.Buffer, frame Frame, y, x, width int, style *Style) {
181 for col := x; col < x+width; col++ {181 for col := x; col < x+width; col++ {
182 cell := frame.At(col, y)182 cell := frame.At(col, y)
183+ // Continuation cells are the right half of a wide rune. Emit nothing:
184+ // the terminal already advanced two columns for the left cell's rune.
185+ if cell.Continuation {
186+ continue
187+ }
183 if !cell.Style.Equal(*style) {188 if !cell.Style.Equal(*style) {
184 writeStyle(out, cell.Style)189 writeStyle(out, cell.Style)
185 *style = cell.Style190 *style = cell.Style
M pkg/renderer/renderer_test.go +106 -0
@@ -816,6 +816,18 @@ func TestCellEqual(t *testing.T) {
816816 b: BlankCell(),
817817 want: false,
818818 },
819+ {
820+ name: "continuation flag participates in equality",
821+ a: Cell{Continuation: true},
822+ b: Cell{},
823+ want: false,
824+ },
825+ {
826+ name: "two continuation cells are equal",
827+ a: Cell{Continuation: true},
828+ b: Cell{Continuation: true},
829+ want: true,
830+ },
819831 }
820832
821833 for _, tt := range tests {
@@ -1029,3 +1041,97 @@ func TestDrawFallbackAndClampingEdgeCases(t *testing.T) {
10291041 t.Run(tt.name, tt.run)
10301042 }
10311043 }
1044+
1045+// ---------------------------------------------------------------------------
1046+// Wide (double-width) character rendering
1047+// ---------------------------------------------------------------------------
1048+
1049+func TestWideCharRendering(t *testing.T) {
1050+ // A wide left cell {Rune: r} followed by a continuation cell
1051+ // {Continuation: true}. The renderer must emit the rune once and emit
1052+ // nothing at all for the continuation cell (the terminal advances two
1053+ // columns by itself). It must never emit a space for a continuation.
1054+ tests := []struct {
1055+ name string
1056+ build func() Frame
1057+ want string
1058+ }{
1059+ {
1060+ name: "single wide char followed by ascii",
1061+ build: func() Frame {
1062+ f := NewFrame(4, 1)
1063+ f.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
1064+ f.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
1065+ f.Set(2, 0, Cell{Rune: 'A', Style: DefaultStyle()})
1066+ return f
1067+ },
1068+ // row 0: cursor home, 你, (skip continuation), A, trailing space, reset
1069+ want: "\x1b[1;1H你A \x1b[0m",
1070+ },
1071+ {
1072+ name: "two adjacent wide chars then ascii",
1073+ build: func() Frame {
1074+ f := NewFrame(6, 1)
1075+ f.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
1076+ f.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
1077+ f.Set(2, 0, Cell{Rune: '好', Style: DefaultStyle()})
1078+ f.Set(3, 0, Cell{Continuation: true, Style: DefaultStyle()})
1079+ f.Set(4, 0, Cell{Rune: 'X', Style: DefaultStyle()})
1080+ return f
1081+ },
1082+ want: "\x1b[1;1H你好X \x1b[0m",
1083+ },
1084+ }
1085+
1086+ for _, tt := range tests {
1087+ t.Run(tt.name, func(t *testing.T) {
1088+ r := New(Capabilities{})
1089+ frame := tt.build()
1090+ out, err := r.Draw(frame, []Damage{FullRedraw()})
1091+ if err != nil {
1092+ t.Fatal(err)
1093+ }
1094+ if string(out) != tt.want {
1095+ t.Errorf("output = %q, want %q", string(out), tt.want)
1096+ }
1097+ // No continuation must ever be rendered as a space: the visible
1098+ // glyph count is exactly the number of non-continuation cells.
1099+ // A second draw with no damage must be a no-op, proving the shadow
1100+ // matches the frame (continuation cells included).
1101+ out2, err := r.Draw(frame, nil)
1102+ if err != nil {
1103+ t.Fatal(err)
1104+ }
1105+ if len(out2) != 0 {
1106+ t.Errorf("second draw not a no-op: %q (shadow inconsistent)", string(out2))
1107+ }
1108+ })
1109+ }
1110+}
1111+
1112+func TestWideCharDamageEmission(t *testing.T) {
1113+ // Populate the shadow with a blank frame, then place a wide char and draw
1114+ // with a text-damage rect covering the pair. The continuation cell must
1115+ // not produce a space.
1116+ r := New(Capabilities{})
1117+ blank := NewFrame(4, 1)
1118+ if _, err := r.Draw(blank, []Damage{FullRedraw()}); err != nil {
1119+ t.Fatal(err)
1120+ }
1121+
1122+ frame := NewFrame(4, 1)
1123+ frame.Set(0, 0, Cell{Rune: '好', Style: DefaultStyle()})
1124+ frame.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
1125+
1126+ out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1, Count: 1}})
1127+ if err != nil {
1128+ t.Fatal(err)
1129+ }
1130+ got := string(out)
1131+ if !strings.Contains(got, "好") {
1132+ t.Errorf("output %q missing wide rune", got)
1133+ }
1134+ if strings.Contains(got, "好 ") {
1135+ t.Errorf("output %q emitted a space for the continuation cell", got)
1136+ }
1137+}
@@ -816,6 +816,18 @@ func TestCellEqual(t *testing.T) {
816 b: BlankCell(),816 b: BlankCell(),
817 want: false,817 want: false,
818 },818 },
819+ {
820+ name: "continuation flag participates in equality",
821+ a: Cell{Continuation: true},
822+ b: Cell{},
823+ want: false,
824+ },
825+ {
826+ name: "two continuation cells are equal",
827+ a: Cell{Continuation: true},
828+ b: Cell{Continuation: true},
829+ want: true,
830+ },
819 }831 }
820 832
821 for _, tt := range tests {833 for _, tt := range tests {
@@ -1029,3 +1041,97 @@ func TestDrawFallbackAndClampingEdgeCases(t *testing.T) {
1029 t.Run(tt.name, tt.run)1041 t.Run(tt.name, tt.run)
1030 }1042 }
1031 }1043 }
1044+
1045+// ---------------------------------------------------------------------------
1046+// Wide (double-width) character rendering
1047+// ---------------------------------------------------------------------------
1048+
1049+func TestWideCharRendering(t *testing.T) {
1050+ // A wide left cell {Rune: r} followed by a continuation cell
1051+ // {Continuation: true}. The renderer must emit the rune once and emit
1052+ // nothing at all for the continuation cell (the terminal advances two
1053+ // columns by itself). It must never emit a space for a continuation.
1054+ tests := []struct {
1055+ name string
1056+ build func() Frame
1057+ want string
1058+ }{
1059+ {
1060+ name: "single wide char followed by ascii",
1061+ build: func() Frame {
1062+ f := NewFrame(4, 1)
1063+ f.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
1064+ f.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
1065+ f.Set(2, 0, Cell{Rune: 'A', Style: DefaultStyle()})
1066+ return f
1067+ },
1068+ // row 0: cursor home, 你, (skip continuation), A, trailing space, reset
1069+ want: "\x1b[1;1H你A \x1b[0m",
1070+ },
1071+ {
1072+ name: "two adjacent wide chars then ascii",
1073+ build: func() Frame {
1074+ f := NewFrame(6, 1)
1075+ f.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
1076+ f.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
1077+ f.Set(2, 0, Cell{Rune: '好', Style: DefaultStyle()})
1078+ f.Set(3, 0, Cell{Continuation: true, Style: DefaultStyle()})
1079+ f.Set(4, 0, Cell{Rune: 'X', Style: DefaultStyle()})
1080+ return f
1081+ },
1082+ want: "\x1b[1;1H你好X \x1b[0m",
1083+ },
1084+ }
1085+
1086+ for _, tt := range tests {
1087+ t.Run(tt.name, func(t *testing.T) {
1088+ r := New(Capabilities{})
1089+ frame := tt.build()
1090+ out, err := r.Draw(frame, []Damage{FullRedraw()})
1091+ if err != nil {
1092+ t.Fatal(err)
1093+ }
1094+ if string(out) != tt.want {
1095+ t.Errorf("output = %q, want %q", string(out), tt.want)
1096+ }
1097+ // No continuation must ever be rendered as a space: the visible
1098+ // glyph count is exactly the number of non-continuation cells.
1099+ // A second draw with no damage must be a no-op, proving the shadow
1100+ // matches the frame (continuation cells included).
1101+ out2, err := r.Draw(frame, nil)
1102+ if err != nil {
1103+ t.Fatal(err)
1104+ }
1105+ if len(out2) != 0 {
1106+ t.Errorf("second draw not a no-op: %q (shadow inconsistent)", string(out2))
1107+ }
1108+ })
1109+ }
1110+}
1111+
1112+func TestWideCharDamageEmission(t *testing.T) {
1113+ // Populate the shadow with a blank frame, then place a wide char and draw
1114+ // with a text-damage rect covering the pair. The continuation cell must
1115+ // not produce a space.
1116+ r := New(Capabilities{})
1117+ blank := NewFrame(4, 1)
1118+ if _, err := r.Draw(blank, []Damage{FullRedraw()}); err != nil {
1119+ t.Fatal(err)
1120+ }
1121+
1122+ frame := NewFrame(4, 1)
1123+ frame.Set(0, 0, Cell{Rune: '好', Style: DefaultStyle()})
1124+ frame.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
1125+
1126+ out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1, Count: 1}})
1127+ if err != nil {
1128+ t.Fatal(err)
1129+ }
1130+ got := string(out)
1131+ if !strings.Contains(got, "好") {
1132+ t.Errorf("output %q missing wide rune", got)
1133+ }
1134+ if strings.Contains(got, "好 ") {
1135+ t.Errorf("output %q emitted a space for the continuation cell", got)
1136+ }
1137+}
M pkg/vt/screen.go +159 -32
@@ -141,9 +141,10 @@ func (s *Screen) putRune(r rune) {
141141 // Returns 2 for CJK, fullwidth, and wide emoji characters.
142142 // Returns 1 for all other printable characters.
143143 //
144-// This is a conservative implementation for MVP: wide/ambiguous characters
145-// are reported as width 2 and rendered as a placeholder
146-// so that the model cursor always stays aligned with terminal cells.
144+// Wide runes (width 2) occupy two grid cells: a left cell holding the rune and
145+// a right continuation cell. Combining marks (Mn/Me) and zero-width joiner
146+// sequences are out of scope: combining marks report width 0 (current
147+// behavior), ZWJ sequences are not coalesced (each rune is measured on its own).
147148 func runeWidth(r rune) int {
148149 switch {
149150 case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F):
@@ -237,6 +238,9 @@ func runeWidth(r rune) int {
237238 // Supplemental Symbols and Pictographs
238239 case r >= 0x1F900 && r <= 0x1F9FF:
239240 return 2
241+ // Symbols and Pictographs Extended-A (emoji added in recent Unicode)
242+ case r >= 0x1FA70 && r <= 0x1FAFF:
243+ return 2
240244 // CJK Unified Ideographs Extension B and beyond
241245 case r >= 0x20000 && r <= 0x2FFFF:
242246 return 2
@@ -254,16 +258,10 @@ func (s *Screen) putPrintable(r rune) {
254258 if w == 0 {
255259 return
256260 }
257- // Conservative normalization: treat wide/emoji as a single-cell '?' placeholder
258- // so cursor and damage tracking are always consistent. The real terminal width
259- // is ignored in favour of a safe single-cell replacement.
260- if w > 1 {
261- r = '?'
262- w = 1
263- }
264261 if s.Frame.Width == 0 || s.Frame.Height == 0 {
265262 return
266263 }
264+ // Deferred wrap: cursor sits past the last column.
267265 if s.Col >= s.Frame.Width {
268266 s.Col = 0
269267 s.index()
@@ -271,11 +269,135 @@ func (s *Screen) putPrintable(r rune) {
271269 if s.Row >= s.Frame.Height {
272270 s.Row = s.Frame.Height - 1
273271 }
272+
273+ // A wide rune must never straddle the right edge. If it does not fit on the
274+ // current line, clear the abandoned last cell and wrap to the next line.
275+ if w == 2 && s.Col+1 >= s.Frame.Width {
276+ if s.Frame.Width < 2 {
277+ // The screen is too narrow to ever hold a wide rune; degrade to a
278+ // single narrow cell so the cursor stays aligned and we never write
279+ // a continuation cell out of bounds.
280+ w = 1
281+ } else {
282+ // The abandoned cell may itself be the continuation of a pair whose
283+ // left half sits one column back; report damage over the full span.
284+ cx := s.Col
285+ if s.Col > 0 && s.Frame.At(s.Col, s.Row).Continuation {
286+ cx = s.Col - 1
287+ }
288+ s.clearWidePairAt(s.Col, s.Row)
289+ s.Frame.Set(s.Col, s.Row, renderer.BlankCell())
290+ s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1})
291+ s.Col = 0
292+ s.index()
293+ if s.Row >= s.Frame.Height {
294+ s.Row = s.Frame.Height - 1
295+ }
296+ }
297+ }
298+
299+ // Determine the range of cells actually modified, extending over any wide
300+ // pair the write lands on so no orphaned half is left behind.
301+ lo, hi := s.Col, s.Col+w-1
302+ if s.Frame.At(s.Col, s.Row).Continuation {
303+ lo = s.Col - 1
304+ }
305+ if right := s.Col + w; right < s.Frame.Width && s.Frame.At(right, s.Row).Continuation {
306+ hi = right
307+ }
308+ for x := lo; x <= hi; x++ {
309+ s.Frame.Set(x, s.Row, renderer.BlankCell())
310+ }
274311 s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style})
275- s.record(renderer.Damage{Kind: renderer.DamageText, X: s.Col, Y: s.Row, Width: w, Height: 1, Count: 1})
312+ if w == 2 {
313+ s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style})
314+ }
315+ s.record(renderer.Damage{Kind: renderer.DamageText, X: lo, Y: s.Row, Width: hi - lo + 1, Height: 1, Count: 1})
276316 s.Col += w
277317 }
278318
319+// clearWidePairAt blanks both halves of a wide-character pair when the cell at
320+// (x,y) is either the left (wide) half or the right (continuation) half. It is
321+// O(1) and assumes the pair invariant (a continuation is preceded by its wide
322+// left half) holds — which the writer maintains.
323+func (s *Screen) clearWidePairAt(x, y int) {
324+ if x < 0 || x >= s.Frame.Width || y < 0 || y >= s.Frame.Height {
325+ return
326+ }
327+ if s.Frame.At(x, y).Continuation {
328+ if x-1 >= 0 {
329+ s.Frame.Set(x-1, y, renderer.BlankCell())
330+ }
331+ s.Frame.Set(x, y, renderer.BlankCell())
332+ return
333+ }
334+ if x+1 < s.Frame.Width && s.Frame.At(x+1, y).Continuation {
335+ s.Frame.Set(x, y, renderer.BlankCell())
336+ s.Frame.Set(x+1, y, renderer.BlankCell())
337+ }
338+}
339+
340+// clearRow blanks cells [x0,x1) on row y, extending the range to swallow either
341+// half of a wide pair that straddles a boundary so no orphan half is left. It
342+// returns the actual modified span [start, start+width).
343+func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
344+ if y < 0 || y >= s.Frame.Height {
345+ return x0, 0
346+ }
347+ if x0 < 0 {
348+ x0 = 0
349+ }
350+ if x1 > s.Frame.Width {
351+ x1 = s.Frame.Width
352+ }
353+ if x0 >= x1 {
354+ return x0, 0
355+ }
356+ // Left boundary: a continuation at x0 means its wide left half sits at x0-1.
357+ if x0 > 0 && s.Frame.At(x0, y).Continuation {
358+ x0--
359+ }
360+ // Right boundary: a continuation at x1 means its wide left half (at x1-1) is
361+ // inside the range and will be blanked, so swallow the continuation too.
362+ if x1 < s.Frame.Width && s.Frame.At(x1, y).Continuation {
363+ x1++
364+ }
365+ for x := x0; x < x1; x++ {
366+ s.Frame.Set(x, y, renderer.BlankCell())
367+ }
368+ return x0, x1 - x0
369+}
370+
371+// repairRow blanks any orphaned wide-character halves on row y: a continuation
372+// cell not preceded by its wide left half, or a wide rune not followed by its
373+// continuation. Used after erase/insert/delete operations that may split a pair
374+// at a boundary or by shifting cells.
375+func (s *Screen) repairRow(y int) {
376+ if y < 0 || y >= s.Frame.Height {
377+ return
378+ }
379+ w := s.Frame.Width
380+ for x := 0; x < w; x++ {
381+ c := s.Frame.At(x, y)
382+ if c.Continuation {
383+ if x == 0 {
384+ s.Frame.Set(x, y, renderer.BlankCell())
385+ continue
386+ }
387+ left := s.Frame.At(x-1, y)
388+ if left.Continuation || runeWidth(left.Rune) != 2 {
389+ s.Frame.Set(x, y, renderer.BlankCell())
390+ }
391+ continue
392+ }
393+ if runeWidth(c.Rune) == 2 {
394+ if x+1 >= w || !s.Frame.At(x+1, y).Continuation {
395+ s.Frame.Set(x, y, renderer.BlankCell())
396+ }
397+ }
398+ }
399+}
400+
279401 func (s *Screen) index() {
280402 if s.Frame.Height == 0 {
281403 return
@@ -780,13 +902,21 @@ func (s *Screen) insertChars(n int) {
780902 n = w - s.Col
781903 }
782904 rowStart := s.Row * w
905+ // A wide left half at Col-1 whose continuation sits at Col will be orphaned
906+ // by the shift; its repair falls outside the default damage rect.
907+ leftSplit := s.Col > 0 && s.Frame.At(s.Col, s.Row).Continuation
783908 for x := w - 1; x >= s.Col+n; x-- {
784909 s.Frame.Cells[rowStart+x] = s.Frame.Cells[rowStart+x-n]
785910 }
786911 for x := s.Col; x < s.Col+n; x++ {
787912 s.Frame.Cells[rowStart+x] = renderer.BlankCell()
788913 }
789- s.record(renderer.Damage{Kind: renderer.DamageText, X: s.Col, Y: s.Row, Width: w - s.Col, Height: 1, Count: 1})
914+ s.repairRow(s.Row)
915+ dmgX := s.Col
916+ if leftSplit {
917+ dmgX = s.Col - 1
918+ }
919+ s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1})
790920 }
791921
792922 func (s *Screen) deleteChars(n int) {
@@ -798,13 +928,21 @@ func (s *Screen) deleteChars(n int) {
798928 n = w - s.Col
799929 }
800930 rowStart := s.Row * w
931+ // A wide left half at Col-1 whose continuation sits at Col will be orphaned
932+ // by the shift; its repair falls outside the default damage rect.
933+ leftSplit := s.Col > 0 && s.Frame.At(s.Col, s.Row).Continuation
801934 for x := s.Col; x < w-n; x++ {
802935 s.Frame.Cells[rowStart+x] = s.Frame.Cells[rowStart+x+n]
803936 }
804937 for x := w - n; x < w; x++ {
805938 s.Frame.Cells[rowStart+x] = renderer.BlankCell()
806939 }
807- s.record(renderer.Damage{Kind: renderer.DamageText, X: s.Col, Y: s.Row, Width: w - s.Col, Height: 1, Count: 1})
940+ s.repairRow(s.Row)
941+ dmgX := s.Col
942+ if leftSplit {
943+ dmgX = s.Col - 1
944+ }
945+ s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1})
808946 }
809947
810948 func (s *Screen) insertLines(n int) {
@@ -830,9 +968,7 @@ func (s *Screen) clearScreenMode(mode int) {
830968 if y == s.Row {
831969 end = min(s.Col+1, s.Frame.Width)
832970 }
833- for x := 0; x < end; x++ {
834- s.Frame.Set(x, y, renderer.BlankCell())
835- }
971+ s.clearRow(y, 0, end)
836972 }
837973 s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: min(s.Row+1, s.Frame.Height), Count: 1})
838974 case 2, 3:
@@ -846,9 +982,7 @@ func (s *Screen) clearScreenMode(mode int) {
846982 if y == s.Row {
847983 start = s.Col
848984 }
849- for x := start; x < s.Frame.Width; x++ {
850- s.Frame.Set(x, y, renderer.BlankCell())
851- }
985+ s.clearRow(y, start, s.Frame.Width)
852986 }
853987 s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: s.Frame.Width, Height: s.Frame.Height - s.Row, Count: 1})
854988 }
@@ -858,21 +992,14 @@ func (s *Screen) clearLineMode(mode int) {
858992 s.clampCursor()
859993 switch mode {
860994 case 1:
861- end := min(s.Col+1, s.Frame.Width)
862- for x := 0; x < end; x++ {
863- s.Frame.Set(x, s.Row, renderer.BlankCell())
864- }
865- s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: end, Height: 1, Count: 1})
995+ start, width := s.clearRow(s.Row, 0, min(s.Col+1, s.Frame.Width))
996+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
866997 case 2:
867- for x := 0; x < s.Frame.Width; x++ {
868- s.Frame.Set(x, s.Row, renderer.BlankCell())
869- }
870- s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: s.Frame.Width, Height: 1, Count: 1})
998+ start, width := s.clearRow(s.Row, 0, s.Frame.Width)
999+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
8711000 default:
872- for x := s.Col; x < s.Frame.Width; x++ {
873- s.Frame.Set(x, s.Row, renderer.BlankCell())
874- }
875- s.record(renderer.Damage{Kind: renderer.DamageClear, X: s.Col, Y: s.Row, Width: s.Frame.Width - s.Col, Height: 1, Count: 1})
1001+ start, width := s.clearRow(s.Row, s.Col, s.Frame.Width)
1002+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
8761003 }
8771004 }
8781005
@@ -141,9 +141,10 @@ func (s *Screen) putRune(r rune) {
141 // Returns 2 for CJK, fullwidth, and wide emoji characters.141 // Returns 2 for CJK, fullwidth, and wide emoji characters.
142 // Returns 1 for all other printable characters.142 // Returns 1 for all other printable characters.
143 //143 //
144-// This is a conservative implementation for MVP: wide/ambiguous characters144+// Wide runes (width 2) occupy two grid cells: a left cell holding the rune and
145-// are reported as width 2 and rendered as a placeholder145+// a right continuation cell. Combining marks (Mn/Me) and zero-width joiner
146-// so that the model cursor always stays aligned with terminal cells.146+// sequences are out of scope: combining marks report width 0 (current
147+// behavior), ZWJ sequences are not coalesced (each rune is measured on its own).
147 func runeWidth(r rune) int {148 func runeWidth(r rune) int {
148 switch {149 switch {
149 case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F):150 case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F):
@@ -237,6 +238,9 @@ func runeWidth(r rune) int {
237 // Supplemental Symbols and Pictographs238 // Supplemental Symbols and Pictographs
238 case r >= 0x1F900 && r <= 0x1F9FF:239 case r >= 0x1F900 && r <= 0x1F9FF:
239 return 2240 return 2
241+ // Symbols and Pictographs Extended-A (emoji added in recent Unicode)
242+ case r >= 0x1FA70 && r <= 0x1FAFF:
243+ return 2
240 // CJK Unified Ideographs Extension B and beyond244 // CJK Unified Ideographs Extension B and beyond
241 case r >= 0x20000 && r <= 0x2FFFF:245 case r >= 0x20000 && r <= 0x2FFFF:
242 return 2246 return 2
@@ -254,16 +258,10 @@ func (s *Screen) putPrintable(r rune) {
254 if w == 0 {258 if w == 0 {
255 return259 return
256 }260 }
257- // Conservative normalization: treat wide/emoji as a single-cell '?' placeholder
258- // so cursor and damage tracking are always consistent. The real terminal width
259- // is ignored in favour of a safe single-cell replacement.
260- if w > 1 {
261- r = '?'
262- w = 1
263- }
264 if s.Frame.Width == 0 || s.Frame.Height == 0 {261 if s.Frame.Width == 0 || s.Frame.Height == 0 {
265 return262 return
266 }263 }
264+ // Deferred wrap: cursor sits past the last column.
267 if s.Col >= s.Frame.Width {265 if s.Col >= s.Frame.Width {
268 s.Col = 0266 s.Col = 0
269 s.index()267 s.index()
@@ -271,11 +269,135 @@ func (s *Screen) putPrintable(r rune) {
271 if s.Row >= s.Frame.Height {269 if s.Row >= s.Frame.Height {
272 s.Row = s.Frame.Height - 1270 s.Row = s.Frame.Height - 1
273 }271 }
272+
273+ // A wide rune must never straddle the right edge. If it does not fit on the
274+ // current line, clear the abandoned last cell and wrap to the next line.
275+ if w == 2 && s.Col+1 >= s.Frame.Width {
276+ if s.Frame.Width < 2 {
277+ // The screen is too narrow to ever hold a wide rune; degrade to a
278+ // single narrow cell so the cursor stays aligned and we never write
279+ // a continuation cell out of bounds.
280+ w = 1
281+ } else {
282+ // The abandoned cell may itself be the continuation of a pair whose
283+ // left half sits one column back; report damage over the full span.
284+ cx := s.Col
285+ if s.Col > 0 && s.Frame.At(s.Col, s.Row).Continuation {
286+ cx = s.Col - 1
287+ }
288+ s.clearWidePairAt(s.Col, s.Row)
289+ s.Frame.Set(s.Col, s.Row, renderer.BlankCell())
290+ s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1})
291+ s.Col = 0
292+ s.index()
293+ if s.Row >= s.Frame.Height {
294+ s.Row = s.Frame.Height - 1
295+ }
296+ }
297+ }
298+
299+ // Determine the range of cells actually modified, extending over any wide
300+ // pair the write lands on so no orphaned half is left behind.
301+ lo, hi := s.Col, s.Col+w-1
302+ if s.Frame.At(s.Col, s.Row).Continuation {
303+ lo = s.Col - 1
304+ }
305+ if right := s.Col + w; right < s.Frame.Width && s.Frame.At(right, s.Row).Continuation {
306+ hi = right
307+ }
308+ for x := lo; x <= hi; x++ {
309+ s.Frame.Set(x, s.Row, renderer.BlankCell())
310+ }
274 s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style})311 s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style})
275- s.record(renderer.Damage{Kind: renderer.DamageText, X: s.Col, Y: s.Row, Width: w, Height: 1, Count: 1})312+ if w == 2 {
313+ s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style})
314+ }
315+ s.record(renderer.Damage{Kind: renderer.DamageText, X: lo, Y: s.Row, Width: hi - lo + 1, Height: 1, Count: 1})
276 s.Col += w316 s.Col += w
277 }317 }
278 318
319+// clearWidePairAt blanks both halves of a wide-character pair when the cell at
320+// (x,y) is either the left (wide) half or the right (continuation) half. It is
321+// O(1) and assumes the pair invariant (a continuation is preceded by its wide
322+// left half) holds — which the writer maintains.
323+func (s *Screen) clearWidePairAt(x, y int) {
324+ if x < 0 || x >= s.Frame.Width || y < 0 || y >= s.Frame.Height {
325+ return
326+ }
327+ if s.Frame.At(x, y).Continuation {
328+ if x-1 >= 0 {
329+ s.Frame.Set(x-1, y, renderer.BlankCell())
330+ }
331+ s.Frame.Set(x, y, renderer.BlankCell())
332+ return
333+ }
334+ if x+1 < s.Frame.Width && s.Frame.At(x+1, y).Continuation {
335+ s.Frame.Set(x, y, renderer.BlankCell())
336+ s.Frame.Set(x+1, y, renderer.BlankCell())
337+ }
338+}
339+
340+// clearRow blanks cells [x0,x1) on row y, extending the range to swallow either
341+// half of a wide pair that straddles a boundary so no orphan half is left. It
342+// returns the actual modified span [start, start+width).
343+func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
344+ if y < 0 || y >= s.Frame.Height {
345+ return x0, 0
346+ }
347+ if x0 < 0 {
348+ x0 = 0
349+ }
350+ if x1 > s.Frame.Width {
351+ x1 = s.Frame.Width
352+ }
353+ if x0 >= x1 {
354+ return x0, 0
355+ }
356+ // Left boundary: a continuation at x0 means its wide left half sits at x0-1.
357+ if x0 > 0 && s.Frame.At(x0, y).Continuation {
358+ x0--
359+ }
360+ // Right boundary: a continuation at x1 means its wide left half (at x1-1) is
361+ // inside the range and will be blanked, so swallow the continuation too.
362+ if x1 < s.Frame.Width && s.Frame.At(x1, y).Continuation {
363+ x1++
364+ }
365+ for x := x0; x < x1; x++ {
366+ s.Frame.Set(x, y, renderer.BlankCell())
367+ }
368+ return x0, x1 - x0
369+}
370+
371+// repairRow blanks any orphaned wide-character halves on row y: a continuation
372+// cell not preceded by its wide left half, or a wide rune not followed by its
373+// continuation. Used after erase/insert/delete operations that may split a pair
374+// at a boundary or by shifting cells.
375+func (s *Screen) repairRow(y int) {
376+ if y < 0 || y >= s.Frame.Height {
377+ return
378+ }
379+ w := s.Frame.Width
380+ for x := 0; x < w; x++ {
381+ c := s.Frame.At(x, y)
382+ if c.Continuation {
383+ if x == 0 {
384+ s.Frame.Set(x, y, renderer.BlankCell())
385+ continue
386+ }
387+ left := s.Frame.At(x-1, y)
388+ if left.Continuation || runeWidth(left.Rune) != 2 {
389+ s.Frame.Set(x, y, renderer.BlankCell())
390+ }
391+ continue
392+ }
393+ if runeWidth(c.Rune) == 2 {
394+ if x+1 >= w || !s.Frame.At(x+1, y).Continuation {
395+ s.Frame.Set(x, y, renderer.BlankCell())
396+ }
397+ }
398+ }
399+}
400+
279 func (s *Screen) index() {401 func (s *Screen) index() {
280 if s.Frame.Height == 0 {402 if s.Frame.Height == 0 {
281 return403 return
@@ -780,13 +902,21 @@ func (s *Screen) insertChars(n int) {
780 n = w - s.Col902 n = w - s.Col
781 }903 }
782 rowStart := s.Row * w904 rowStart := s.Row * w
905+ // A wide left half at Col-1 whose continuation sits at Col will be orphaned
906+ // by the shift; its repair falls outside the default damage rect.
907+ leftSplit := s.Col > 0 && s.Frame.At(s.Col, s.Row).Continuation
783 for x := w - 1; x >= s.Col+n; x-- {908 for x := w - 1; x >= s.Col+n; x-- {
784 s.Frame.Cells[rowStart+x] = s.Frame.Cells[rowStart+x-n]909 s.Frame.Cells[rowStart+x] = s.Frame.Cells[rowStart+x-n]
785 }910 }
786 for x := s.Col; x < s.Col+n; x++ {911 for x := s.Col; x < s.Col+n; x++ {
787 s.Frame.Cells[rowStart+x] = renderer.BlankCell()912 s.Frame.Cells[rowStart+x] = renderer.BlankCell()
788 }913 }
789- s.record(renderer.Damage{Kind: renderer.DamageText, X: s.Col, Y: s.Row, Width: w - s.Col, Height: 1, Count: 1})914+ s.repairRow(s.Row)
915+ dmgX := s.Col
916+ if leftSplit {
917+ dmgX = s.Col - 1
918+ }
919+ s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1})
790 }920 }
791 921
792 func (s *Screen) deleteChars(n int) {922 func (s *Screen) deleteChars(n int) {
@@ -798,13 +928,21 @@ func (s *Screen) deleteChars(n int) {
798 n = w - s.Col928 n = w - s.Col
799 }929 }
800 rowStart := s.Row * w930 rowStart := s.Row * w
931+ // A wide left half at Col-1 whose continuation sits at Col will be orphaned
932+ // by the shift; its repair falls outside the default damage rect.
933+ leftSplit := s.Col > 0 && s.Frame.At(s.Col, s.Row).Continuation
801 for x := s.Col; x < w-n; x++ {934 for x := s.Col; x < w-n; x++ {
802 s.Frame.Cells[rowStart+x] = s.Frame.Cells[rowStart+x+n]935 s.Frame.Cells[rowStart+x] = s.Frame.Cells[rowStart+x+n]
803 }936 }
804 for x := w - n; x < w; x++ {937 for x := w - n; x < w; x++ {
805 s.Frame.Cells[rowStart+x] = renderer.BlankCell()938 s.Frame.Cells[rowStart+x] = renderer.BlankCell()
806 }939 }
807- s.record(renderer.Damage{Kind: renderer.DamageText, X: s.Col, Y: s.Row, Width: w - s.Col, Height: 1, Count: 1})940+ s.repairRow(s.Row)
941+ dmgX := s.Col
942+ if leftSplit {
943+ dmgX = s.Col - 1
944+ }
945+ s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1})
808 }946 }
809 947
810 func (s *Screen) insertLines(n int) {948 func (s *Screen) insertLines(n int) {
@@ -830,9 +968,7 @@ func (s *Screen) clearScreenMode(mode int) {
830 if y == s.Row {968 if y == s.Row {
831 end = min(s.Col+1, s.Frame.Width)969 end = min(s.Col+1, s.Frame.Width)
832 }970 }
833- for x := 0; x < end; x++ {971+ s.clearRow(y, 0, end)
834- s.Frame.Set(x, y, renderer.BlankCell())
835- }
836 }972 }
837 s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: min(s.Row+1, s.Frame.Height), Count: 1})973 s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: min(s.Row+1, s.Frame.Height), Count: 1})
838 case 2, 3:974 case 2, 3:
@@ -846,9 +982,7 @@ func (s *Screen) clearScreenMode(mode int) {
846 if y == s.Row {982 if y == s.Row {
847 start = s.Col983 start = s.Col
848 }984 }
849- for x := start; x < s.Frame.Width; x++ {985+ s.clearRow(y, start, s.Frame.Width)
850- s.Frame.Set(x, y, renderer.BlankCell())
851- }
852 }986 }
853 s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: s.Frame.Width, Height: s.Frame.Height - s.Row, Count: 1})987 s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: s.Frame.Width, Height: s.Frame.Height - s.Row, Count: 1})
854 }988 }
@@ -858,21 +992,14 @@ func (s *Screen) clearLineMode(mode int) {
858 s.clampCursor()992 s.clampCursor()
859 switch mode {993 switch mode {
860 case 1:994 case 1:
861- end := min(s.Col+1, s.Frame.Width)995+ start, width := s.clearRow(s.Row, 0, min(s.Col+1, s.Frame.Width))
862- for x := 0; x < end; x++ {996+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
863- s.Frame.Set(x, s.Row, renderer.BlankCell())
864- }
865- s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: end, Height: 1, Count: 1})
866 case 2:997 case 2:
867- for x := 0; x < s.Frame.Width; x++ {998+ start, width := s.clearRow(s.Row, 0, s.Frame.Width)
868- s.Frame.Set(x, s.Row, renderer.BlankCell())999+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
869- }
870- s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: s.Frame.Width, Height: 1, Count: 1})
871 default:1000 default:
872- for x := s.Col; x < s.Frame.Width; x++ {1001+ start, width := s.clearRow(s.Row, s.Col, s.Frame.Width)
873- s.Frame.Set(x, s.Row, renderer.BlankCell())1002+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
874- }
875- s.record(renderer.Damage{Kind: renderer.DamageClear, X: s.Col, Y: s.Row, Width: s.Frame.Width - s.Col, Height: 1, Count: 1})
876 }1003 }
877 }1004 }
878 1005
M pkg/vt/screen_test.go +179 -65
@@ -38,6 +38,31 @@ func assertCell(t *testing.T, s *Screen, x, y int, expected rune) {
3838 if c.Rune != expected {
3939 t.Errorf("cell(%d,%d) rune = %q, want %q", x, y, c.Rune, expected)
4040 }
41+ if c.Continuation {
42+ t.Errorf("cell(%d,%d) unexpectedly marked as continuation", x, y)
43+ }
44+}
45+
46+// assertContinuation asserts the cell at (x,y) is the right half of a
47+// wide-character pair (Continuation set, Rune 0).
48+func assertContinuation(t *testing.T, s *Screen, x, y int) {
49+ t.Helper()
50+ c := cellAt(s, x, y)
51+ if !c.Continuation {
52+ t.Errorf("cell(%d,%d) expected continuation, got rune=%q continuation=%v", x, y, c.Rune, c.Continuation)
53+ }
54+ if c.Rune != 0 {
55+ t.Errorf("cell(%d,%d) continuation rune = %q, want 0", x, y, c.Rune)
56+ }
57+}
58+
59+// assertBlank asserts the cell at (x,y) is a blank default cell.
60+func assertBlank(t *testing.T, s *Screen, x, y int) {
61+ t.Helper()
62+ c := cellAt(s, x, y)
63+ if c.Rune != ' ' || c.Continuation {
64+ t.Errorf("cell(%d,%d) = {rune:%q cont:%v}, want blank space", x, y, c.Rune, c.Continuation)
65+ }
4166 }
4267
4368 func lineText(s *Screen, y int) string {
@@ -1226,17 +1251,19 @@ func TestWideAndZeroWidthRunes(t *testing.T) {
12261251 run func(t *testing.T)
12271252 }{
12281253 {
1229- name: "CJK wide characters render as single-cell placeholders",
1254+ name: "CJK writes wide left cell plus continuation",
12301255 run: func(t *testing.T) {
12311256 s := NewScreen(10, 3)
1232- // Write two CJK characters that are width 2 in a real terminal.
1233- // They should be rendered as '?' placeholders, advancing cursor by 1 each.
1234- s.Write([]byte("\xe4\xbd\xa0\xe5\xa5\xbd")) // 你好
1235-
1236- assertCell(t, s, 0, 0, '?')
1237- assertCell(t, s, 1, 0, '?')
1238- if s.Col != 2 || s.Row != 0 {
1239- t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
1257+ // Two CJK characters, each width 2: left cell holds the rune,
1258+ // right cell is a continuation marker.
1259+ s.Write([]byte("你好"))
1260+
1261+ assertCell(t, s, 0, 0, '你')
1262+ assertContinuation(t, s, 1, 0)
1263+ assertCell(t, s, 2, 0, '好')
1264+ assertContinuation(t, s, 3, 0)
1265+ if s.Col != 4 || s.Row != 0 {
1266+ t.Errorf("cursor at col=%d row=%d, want col=4 row=0", s.Col, s.Row)
12401267 }
12411268 },
12421269 },
@@ -1244,27 +1271,27 @@ func TestWideAndZeroWidthRunes(t *testing.T) {
12441271 name: "CJK mixed with ASCII",
12451272 run: func(t *testing.T) {
12461273 s := NewScreen(10, 3)
1247- s.Write([]byte("A\xe4\xbd\xa0B")) // A你B
1274+ s.Write([]byte("A你B"))
12481275
12491276 assertCell(t, s, 0, 0, 'A')
1250- assertCell(t, s, 1, 0, '?')
1251- assertCell(t, s, 2, 0, 'B')
1252- if s.Col != 3 {
1253- t.Errorf("cursor at col=%d, want 3", s.Col)
1277+ assertCell(t, s, 1, 0, '你')
1278+ assertContinuation(t, s, 2, 0)
1279+ assertCell(t, s, 3, 0, 'B')
1280+ if s.Col != 4 {
1281+ t.Errorf("cursor at col=%d, want 4", s.Col)
12541282 }
12551283 },
12561284 },
12571285 {
1258- name: "emoji rendered as placeholder",
1286+ name: "emoji writes wide pair",
12591287 run: func(t *testing.T) {
12601288 s := NewScreen(10, 3)
1261- em := []byte("\U0001f600") // 😀 grinning face
1262- s.Write(em)
1289+ s.Write([]byte("\U0001f600")) // 😀 grinning face
12631290
1264- // Wide emoji is rendered as '?' placeholder.
1265- assertCell(t, s, 0, 0, '?')
1266- if s.Col != 1 {
1267- t.Errorf("cursor at col=%d, want 1", s.Col)
1291+ assertCell(t, s, 0, 0, '\U0001f600')
1292+ assertContinuation(t, s, 1, 0)
1293+ if s.Col != 2 {
1294+ t.Errorf("cursor at col=%d, want 2", s.Col)
12681295 }
12691296 },
12701297 },
@@ -1300,86 +1327,167 @@ func TestWideAndZeroWidthRunes(t *testing.T) {
13001327 name: "CJK surrounded by ASCII on both sides",
13011328 run: func(t *testing.T) {
13021329 s := NewScreen(10, 3)
1303- s.Write([]byte("a\xe4\xbd\xa0b\xe5\xa5\xbdc")) // a你b好c
1330+ s.Write([]byte("a你b好c"))
13041331
13051332 assertCell(t, s, 0, 0, 'a')
1306- assertCell(t, s, 1, 0, '?')
1307- assertCell(t, s, 2, 0, 'b')
1308- assertCell(t, s, 3, 0, '?')
1309- assertCell(t, s, 4, 0, 'c')
1310- if s.Col != 5 {
1311- t.Errorf("cursor at col=%d, want 5", s.Col)
1333+ assertCell(t, s, 1, 0, '你')
1334+ assertContinuation(t, s, 2, 0)
1335+ assertCell(t, s, 3, 0, 'b')
1336+ assertCell(t, s, 4, 0, '好')
1337+ assertContinuation(t, s, 5, 0)
1338+ assertCell(t, s, 6, 0, 'c')
1339+ if s.Col != 7 {
1340+ t.Errorf("cursor at col=%d, want 7", s.Col)
13121341 }
13131342 },
13141343 },
13151344 {
1316- name: "CJK at the edge of the line wraps via scroll on a 1-row screen",
1345+ name: "wide char at last column wraps to next line, abandoned cell cleared",
13171346 run: func(t *testing.T) {
1318- s := NewScreen(2, 1)
1319- // Write two ASCII chars to fill the line.
1320- s.Write([]byte("AB")) // Cells = [A B], Col=2
1321- // CJK at col 2 (edge) triggers newline → scroll on 1-row screen.
1322- // scrollUp blanks the only row, then '?' is written at col 0.
1323- s.Write([]byte("\xe4\xbd\xa0")) // 你
1347+ s := NewScreen(3, 2)
1348+ // Fill the first two columns; cursor lands on the last column.
1349+ s.Write([]byte("AB")) // cells [A B _], Col=2
1350+ // A wide char at the last column cannot straddle the edge: it
1351+ // wraps to the next line, clearing the abandoned last cell.
1352+ s.Write([]byte("你"))
13241353
1325- assertCell(t, s, 0, 0, '?') // CJK placeholder after scroll+blank
1326- assertCell(t, s, 1, 0, ' ') // untouched
1327- if s.Col != 1 || s.Row != 0 {
1328- t.Errorf("cursor at col=%d row=%d, want col=1 row=0", s.Col, s.Row)
1354+ assertCell(t, s, 0, 0, 'A')
1355+ assertCell(t, s, 1, 0, 'B')
1356+ assertBlank(t, s, 2, 0) // abandoned last cell cleared
1357+ assertCell(t, s, 0, 1, '你')
1358+ assertContinuation(t, s, 1, 1)
1359+ if s.Col != 2 || s.Row != 1 {
1360+ t.Errorf("cursor at col=%d row=%d, want col=2 row=1", s.Col, s.Row)
13291361 }
13301362 },
13311363 },
13321364 {
1333- name: "CJK damage width is normalized to 1",
1365+ name: "CJK damage width is 2",
13341366 run: func(t *testing.T) {
13351367 s := NewScreen(10, 3)
13361368 s.ClearDamage()
1337- s.Write([]byte("\xe4\xbd\xa0")) // 你
1369+ s.Write([]byte("你"))
13381370
13391371 d := s.Damage()
13401372 if len(d) == 0 {
13411373 t.Fatal("expected damage")
13421374 }
1343- // After normalization, damage width should be 1, not 2.
1344- if d[0].Width != 1 {
1345- t.Errorf("damage width = %d, want 1 (normalized)", d[0].Width)
1375+ if d[0].Width != 2 {
1376+ t.Errorf("damage width = %d, want 2", d[0].Width)
13461377 }
13471378 },
13481379 },
13491380 {
1350- name: "emoji damage width is normalized to 1",
1381+ name: "mixed ASCII, CJK, emoji, and combining marks keep cursor aligned",
13511382 run: func(t *testing.T) {
13521383 s := NewScreen(10, 3)
1353- s.ClearDamage()
1354- s.Write([]byte("\U0001f600")) // 😀
1384+ // ASCII 'a', CJK '你', emoji '😀', ASCII 'b', combining acute.
1385+ s.Write([]byte("a你\U0001f600b\xcc\x81"))
13551386
1356- d := s.Damage()
1357- if len(d) == 0 {
1358- t.Fatal("expected damage")
1359- }
1360- if d[0].Width != 1 {
1361- t.Errorf("damage width = %d, want 1 (normalized)", d[0].Width)
1387+ assertCell(t, s, 0, 0, 'a')
1388+ assertCell(t, s, 1, 0, '你')
1389+ assertContinuation(t, s, 2, 0)
1390+ assertCell(t, s, 3, 0, '\U0001f600')
1391+ assertContinuation(t, s, 4, 0)
1392+ assertCell(t, s, 5, 0, 'b')
1393+ // Combining mark skipped, no cell at col 6.
1394+ if s.Col != 6 || s.Row != 0 {
1395+ t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
13621396 }
13631397 },
13641398 },
13651399 {
1366- name: "mixed ASCII, CJK, emoji, and combining marks keep cursor aligned",
1400+ name: "overwrite left half of wide pair with narrow clears both",
13671401 run: func(t *testing.T) {
13681402 s := NewScreen(10, 3)
1369- // ASCII 'a', CJK '你', emoji '😀', ASCII 'b', combining acute '◌́'
1370- s.Write([]byte("a\xe4\xbd\xa0\U0001f600b\xcc\x81"))
1371-
1372- assertCell(t, s, 0, 0, 'a') // 'a'
1373- assertCell(t, s, 1, 0, '?') // 你 normalized
1374- assertCell(t, s, 2, 0, '?') // 😀 normalized
1375- assertCell(t, s, 3, 0, 'b') // 'b'
1376- // Combining mark skipped, no cell at col 4.
1377- // Cursor at col 4 (advanced by a, CJK, emoji, b).
1378- if s.Col != 4 || s.Row != 0 {
1379- t.Errorf("cursor at col=%d row=%d, want col=4 row=0", s.Col, s.Row)
1403+ s.Write([]byte("你")) // (0)=你 (1)=cont
1404+ s.Write([]byte("\x1b[1;1H")) // cursor home
1405+ s.Write([]byte("X")) // overwrite the wide left half
1406+
1407+ assertCell(t, s, 0, 0, 'X')
1408+ assertBlank(t, s, 1, 0) // orphaned continuation cleared
1409+ },
1410+ },
1411+ {
1412+ name: "overwrite right half (continuation) with narrow clears both",
1413+ run: func(t *testing.T) {
1414+ s := NewScreen(10, 3)
1415+ s.Write([]byte("你")) // (0)=你 (1)=cont
1416+ s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (continuation)
1417+ s.Write([]byte("X")) // overwrite the continuation
1418+
1419+ assertBlank(t, s, 0, 0) // orphaned wide left cleared
1420+ assertCell(t, s, 1, 0, 'X')
1421+ },
1422+ },
1423+ {
1424+ name: "overwrite half of wide pair with a new wide char",
1425+ run: func(t *testing.T) {
1426+ s := NewScreen(10, 3)
1427+ s.Write([]byte("你好")) // (0)你 (1)cont (2)好 (3)cont
1428+ s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (cont of 你)
1429+ s.Write([]byte("学")) // wide write at cols 1,2
1430+
1431+ assertBlank(t, s, 0, 0) // 你 left half orphaned → cleared
1432+ assertCell(t, s, 1, 0, '学')
1433+ assertContinuation(t, s, 2, 0)
1434+ assertBlank(t, s, 3, 0) // 好 continuation orphaned → cleared
1435+ },
1436+ },
1437+ {
1438+ name: "erase to end of line covering a continuation clears its wide left half",
1439+ run: func(t *testing.T) {
1440+ s := NewScreen(10, 3)
1441+ s.Write([]byte("A你B")) // A(0) 你(1) cont(2) B(3)
1442+ s.Write([]byte("\x1b[1;3H")) // cursor to col 2 (continuation)
1443+ s.Write([]byte("\x1b[K")) // erase from col 2 to end of line
1444+
1445+ assertCell(t, s, 0, 0, 'A')
1446+ assertBlank(t, s, 1, 0) // 你 left half orphaned by erase → cleared
1447+ assertBlank(t, s, 2, 0)
1448+ assertBlank(t, s, 3, 0)
1449+ },
1450+ },
1451+ {
1452+ name: "erase to start of line covering a wide left clears its continuation",
1453+ run: func(t *testing.T) {
1454+ s := NewScreen(10, 3)
1455+ s.Write([]byte("A你B")) // A(0) 你(1) cont(2) B(3)
1456+ s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (wide left)
1457+ s.Write([]byte("\x1b[1K")) // erase from start to col 1 inclusive
1458+
1459+ assertBlank(t, s, 0, 0)
1460+ assertBlank(t, s, 1, 0)
1461+ assertBlank(t, s, 2, 0) // continuation orphaned by erase → cleared
1462+ assertCell(t, s, 3, 0, 'B')
1463+ },
1464+ },
1465+ {
1466+ name: "wide char on a width-1 screen degrades to a single cell",
1467+ run: func(t *testing.T) {
1468+ s := NewScreen(1, 2)
1469+ // A wide rune cannot fit on a 1-column screen; it must not write
1470+ // an out-of-bounds continuation cell.
1471+ s.Write([]byte("你"))
1472+
1473+ assertCell(t, s, 0, 0, '你')
1474+ if s.Col != 1 {
1475+ t.Errorf("cursor at col=%d, want 1", s.Col)
13801476 }
13811477 },
13821478 },
1479+ {
1480+ name: "wide pair survives a scroll",
1481+ run: func(t *testing.T) {
1482+ s := NewScreen(4, 2)
1483+ s.Write([]byte("你\r\n好")) // row0: 你, row1: 好
1484+ s.Write([]byte("\r\n")) // bottom row → scroll up
1485+
1486+ // After scrolling up, row1's 好 moves to row0 intact.
1487+ assertCell(t, s, 0, 0, '好')
1488+ assertContinuation(t, s, 1, 0)
1489+ },
1490+ },
13831491 {
13841492 name: "combining mark alone writes no cell and produces no damage",
13851493 run: func(t *testing.T) {
@@ -1440,6 +1548,12 @@ func TestRuneWidth(t *testing.T) {
14401548 {"Fullwidth Cent Sign", 0xFFE0, 2},
14411549 {"grinning face emoji", 0x1F600, 2},
14421550 {"rocket emoji", 0x1F680, 2},
1551+ {"hiragana a", 0x3042, 2},
1552+ {"katakana a", 0x30A2, 2},
1553+ {"CJK Extension B", 0x20000, 2},
1554+ {"supplemental symbols (robot)", 0x1F916, 2},
1555+ {"symbols extended-A (sari)", 0x1FA71, 2},
1556+ {"ideographic full stop", 0x3002, 2},
14431557 {"NUL", '\x00', 0},
14441558 {"SOH", '\x01', 0},
14451559 {"ESC", '\x1b', 0},
@@ -38,6 +38,31 @@ func assertCell(t *testing.T, s *Screen, x, y int, expected rune) {
38 if c.Rune != expected {38 if c.Rune != expected {
39 t.Errorf("cell(%d,%d) rune = %q, want %q", x, y, c.Rune, expected)39 t.Errorf("cell(%d,%d) rune = %q, want %q", x, y, c.Rune, expected)
40 }40 }
41+ if c.Continuation {
42+ t.Errorf("cell(%d,%d) unexpectedly marked as continuation", x, y)
43+ }
44+}
45+
46+// assertContinuation asserts the cell at (x,y) is the right half of a
47+// wide-character pair (Continuation set, Rune 0).
48+func assertContinuation(t *testing.T, s *Screen, x, y int) {
49+ t.Helper()
50+ c := cellAt(s, x, y)
51+ if !c.Continuation {
52+ t.Errorf("cell(%d,%d) expected continuation, got rune=%q continuation=%v", x, y, c.Rune, c.Continuation)
53+ }
54+ if c.Rune != 0 {
55+ t.Errorf("cell(%d,%d) continuation rune = %q, want 0", x, y, c.Rune)
56+ }
57+}
58+
59+// assertBlank asserts the cell at (x,y) is a blank default cell.
60+func assertBlank(t *testing.T, s *Screen, x, y int) {
61+ t.Helper()
62+ c := cellAt(s, x, y)
63+ if c.Rune != ' ' || c.Continuation {
64+ t.Errorf("cell(%d,%d) = {rune:%q cont:%v}, want blank space", x, y, c.Rune, c.Continuation)
65+ }
41 }66 }
42 67
43 func lineText(s *Screen, y int) string {68 func lineText(s *Screen, y int) string {
@@ -1226,17 +1251,19 @@ func TestWideAndZeroWidthRunes(t *testing.T) {
1226 run func(t *testing.T)1251 run func(t *testing.T)
1227 }{1252 }{
1228 {1253 {
1229- name: "CJK wide characters render as single-cell placeholders",1254+ name: "CJK writes wide left cell plus continuation",
1230 run: func(t *testing.T) {1255 run: func(t *testing.T) {
1231 s := NewScreen(10, 3)1256 s := NewScreen(10, 3)
1232- // Write two CJK characters that are width 2 in a real terminal.1257+ // Two CJK characters, each width 2: left cell holds the rune,
1233- // They should be rendered as '?' placeholders, advancing cursor by 1 each.1258+ // right cell is a continuation marker.
1234- s.Write([]byte("\xe4\xbd\xa0\xe5\xa5\xbd")) // 你好1259+ s.Write([]byte("你好"))
1235-1260+
1236- assertCell(t, s, 0, 0, '?')1261+ assertCell(t, s, 0, 0, '你')
1237- assertCell(t, s, 1, 0, '?')1262+ assertContinuation(t, s, 1, 0)
1238- if s.Col != 2 || s.Row != 0 {1263+ assertCell(t, s, 2, 0, '好')
1239- t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)1264+ assertContinuation(t, s, 3, 0)
1265+ if s.Col != 4 || s.Row != 0 {
1266+ t.Errorf("cursor at col=%d row=%d, want col=4 row=0", s.Col, s.Row)
1240 }1267 }
1241 },1268 },
1242 },1269 },
@@ -1244,27 +1271,27 @@ func TestWideAndZeroWidthRunes(t *testing.T) {
1244 name: "CJK mixed with ASCII",1271 name: "CJK mixed with ASCII",
1245 run: func(t *testing.T) {1272 run: func(t *testing.T) {
1246 s := NewScreen(10, 3)1273 s := NewScreen(10, 3)
1247- s.Write([]byte("A\xe4\xbd\xa0B")) // A你B1274+ s.Write([]byte("A你B"))
1248 1275
1249 assertCell(t, s, 0, 0, 'A')1276 assertCell(t, s, 0, 0, 'A')
1250- assertCell(t, s, 1, 0, '?')1277+ assertCell(t, s, 1, 0, '你')
1251- assertCell(t, s, 2, 0, 'B')1278+ assertContinuation(t, s, 2, 0)
1252- if s.Col != 3 {1279+ assertCell(t, s, 3, 0, 'B')
1253- t.Errorf("cursor at col=%d, want 3", s.Col)1280+ if s.Col != 4 {
1281+ t.Errorf("cursor at col=%d, want 4", s.Col)
1254 }1282 }
1255 },1283 },
1256 },1284 },
1257 {1285 {
1258- name: "emoji rendered as placeholder",1286+ name: "emoji writes wide pair",
1259 run: func(t *testing.T) {1287 run: func(t *testing.T) {
1260 s := NewScreen(10, 3)1288 s := NewScreen(10, 3)
1261- em := []byte("\U0001f600") // 😀 grinning face1289+ s.Write([]byte("\U0001f600")) // 😀 grinning face
1262- s.Write(em)
1263 1290
1264- // Wide emoji is rendered as '?' placeholder.1291+ assertCell(t, s, 0, 0, '\U0001f600')
1265- assertCell(t, s, 0, 0, '?')1292+ assertContinuation(t, s, 1, 0)
1266- if s.Col != 1 {1293+ if s.Col != 2 {
1267- t.Errorf("cursor at col=%d, want 1", s.Col)1294+ t.Errorf("cursor at col=%d, want 2", s.Col)
1268 }1295 }
1269 },1296 },
1270 },1297 },
@@ -1300,86 +1327,167 @@ func TestWideAndZeroWidthRunes(t *testing.T) {
1300 name: "CJK surrounded by ASCII on both sides",1327 name: "CJK surrounded by ASCII on both sides",
1301 run: func(t *testing.T) {1328 run: func(t *testing.T) {
1302 s := NewScreen(10, 3)1329 s := NewScreen(10, 3)
1303- s.Write([]byte("a\xe4\xbd\xa0b\xe5\xa5\xbdc")) // a你b好c1330+ s.Write([]byte("a你b好c"))
1304 1331
1305 assertCell(t, s, 0, 0, 'a')1332 assertCell(t, s, 0, 0, 'a')
1306- assertCell(t, s, 1, 0, '?')1333+ assertCell(t, s, 1, 0, '你')
1307- assertCell(t, s, 2, 0, 'b')1334+ assertContinuation(t, s, 2, 0)
1308- assertCell(t, s, 3, 0, '?')1335+ assertCell(t, s, 3, 0, 'b')
1309- assertCell(t, s, 4, 0, 'c')1336+ assertCell(t, s, 4, 0, '好')
1310- if s.Col != 5 {1337+ assertContinuation(t, s, 5, 0)
1311- t.Errorf("cursor at col=%d, want 5", s.Col)1338+ assertCell(t, s, 6, 0, 'c')
1339+ if s.Col != 7 {
1340+ t.Errorf("cursor at col=%d, want 7", s.Col)
1312 }1341 }
1313 },1342 },
1314 },1343 },
1315 {1344 {
1316- name: "CJK at the edge of the line wraps via scroll on a 1-row screen",1345+ name: "wide char at last column wraps to next line, abandoned cell cleared",
1317 run: func(t *testing.T) {1346 run: func(t *testing.T) {
1318- s := NewScreen(2, 1)1347+ s := NewScreen(3, 2)
1319- // Write two ASCII chars to fill the line.1348+ // Fill the first two columns; cursor lands on the last column.
1320- s.Write([]byte("AB")) // Cells = [A B], Col=21349+ s.Write([]byte("AB")) // cells [A B _], Col=2
1321- // CJK at col 2 (edge) triggers newline → scroll on 1-row screen.1350+ // A wide char at the last column cannot straddle the edge: it
1322- // scrollUp blanks the only row, then '?' is written at col 0.1351+ // wraps to the next line, clearing the abandoned last cell.
1323- s.Write([]byte("\xe4\xbd\xa0")) // 你1352+ s.Write([]byte("你"))
1324 1353
1325- assertCell(t, s, 0, 0, '?') // CJK placeholder after scroll+blank1354+ assertCell(t, s, 0, 0, 'A')
1326- assertCell(t, s, 1, 0, ' ') // untouched1355+ assertCell(t, s, 1, 0, 'B')
1327- if s.Col != 1 || s.Row != 0 {1356+ assertBlank(t, s, 2, 0) // abandoned last cell cleared
1328- t.Errorf("cursor at col=%d row=%d, want col=1 row=0", s.Col, s.Row)1357+ assertCell(t, s, 0, 1, '你')
1358+ assertContinuation(t, s, 1, 1)
1359+ if s.Col != 2 || s.Row != 1 {
1360+ t.Errorf("cursor at col=%d row=%d, want col=2 row=1", s.Col, s.Row)
1329 }1361 }
1330 },1362 },
1331 },1363 },
1332 {1364 {
1333- name: "CJK damage width is normalized to 1",1365+ name: "CJK damage width is 2",
1334 run: func(t *testing.T) {1366 run: func(t *testing.T) {
1335 s := NewScreen(10, 3)1367 s := NewScreen(10, 3)
1336 s.ClearDamage()1368 s.ClearDamage()
1337- s.Write([]byte("\xe4\xbd\xa0")) // 你1369+ s.Write([]byte("你"))
1338 1370
1339 d := s.Damage()1371 d := s.Damage()
1340 if len(d) == 0 {1372 if len(d) == 0 {
1341 t.Fatal("expected damage")1373 t.Fatal("expected damage")
1342 }1374 }
1343- // After normalization, damage width should be 1, not 2.1375+ if d[0].Width != 2 {
1344- if d[0].Width != 1 {1376+ t.Errorf("damage width = %d, want 2", d[0].Width)
1345- t.Errorf("damage width = %d, want 1 (normalized)", d[0].Width)
1346 }1377 }
1347 },1378 },
1348 },1379 },
1349 {1380 {
1350- name: "emoji damage width is normalized to 1",1381+ name: "mixed ASCII, CJK, emoji, and combining marks keep cursor aligned",
1351 run: func(t *testing.T) {1382 run: func(t *testing.T) {
1352 s := NewScreen(10, 3)1383 s := NewScreen(10, 3)
1353- s.ClearDamage()1384+ // ASCII 'a', CJK '你', emoji '😀', ASCII 'b', combining acute.
1354- s.Write([]byte("\U0001f600")) // 😀1385+ s.Write([]byte("a你\U0001f600b\xcc\x81"))
1355 1386
1356- d := s.Damage()1387+ assertCell(t, s, 0, 0, 'a')
1357- if len(d) == 0 {1388+ assertCell(t, s, 1, 0, '你')
1358- t.Fatal("expected damage")1389+ assertContinuation(t, s, 2, 0)
1359- }1390+ assertCell(t, s, 3, 0, '\U0001f600')
1360- if d[0].Width != 1 {1391+ assertContinuation(t, s, 4, 0)
1361- t.Errorf("damage width = %d, want 1 (normalized)", d[0].Width)1392+ assertCell(t, s, 5, 0, 'b')
1393+ // Combining mark skipped, no cell at col 6.
1394+ if s.Col != 6 || s.Row != 0 {
1395+ t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
1362 }1396 }
1363 },1397 },
1364 },1398 },
1365 {1399 {
1366- name: "mixed ASCII, CJK, emoji, and combining marks keep cursor aligned",1400+ name: "overwrite left half of wide pair with narrow clears both",
1367 run: func(t *testing.T) {1401 run: func(t *testing.T) {
1368 s := NewScreen(10, 3)1402 s := NewScreen(10, 3)
1369- // ASCII 'a', CJK '你', emoji '😀', ASCII 'b', combining acute '◌́'1403+ s.Write([]byte("你")) // (0)=你 (1)=cont
1370- s.Write([]byte("a\xe4\xbd\xa0\U0001f600b\xcc\x81"))1404+ s.Write([]byte("\x1b[1;1H")) // cursor home
1371-1405+ s.Write([]byte("X")) // overwrite the wide left half
1372- assertCell(t, s, 0, 0, 'a') // 'a'1406+
1373- assertCell(t, s, 1, 0, '?') // 你 normalized1407+ assertCell(t, s, 0, 0, 'X')
1374- assertCell(t, s, 2, 0, '?') // 😀 normalized1408+ assertBlank(t, s, 1, 0) // orphaned continuation cleared
1375- assertCell(t, s, 3, 0, 'b') // 'b'1409+ },
1376- // Combining mark skipped, no cell at col 4.1410+ },
1377- // Cursor at col 4 (advanced by a, CJK, emoji, b).1411+ {
1378- if s.Col != 4 || s.Row != 0 {1412+ name: "overwrite right half (continuation) with narrow clears both",
1379- t.Errorf("cursor at col=%d row=%d, want col=4 row=0", s.Col, s.Row)1413+ run: func(t *testing.T) {
1414+ s := NewScreen(10, 3)
1415+ s.Write([]byte("你")) // (0)=你 (1)=cont
1416+ s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (continuation)
1417+ s.Write([]byte("X")) // overwrite the continuation
1418+
1419+ assertBlank(t, s, 0, 0) // orphaned wide left cleared
1420+ assertCell(t, s, 1, 0, 'X')
1421+ },
1422+ },
1423+ {
1424+ name: "overwrite half of wide pair with a new wide char",
1425+ run: func(t *testing.T) {
1426+ s := NewScreen(10, 3)
1427+ s.Write([]byte("你好")) // (0)你 (1)cont (2)好 (3)cont
1428+ s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (cont of 你)
1429+ s.Write([]byte("学")) // wide write at cols 1,2
1430+
1431+ assertBlank(t, s, 0, 0) // 你 left half orphaned → cleared
1432+ assertCell(t, s, 1, 0, '学')
1433+ assertContinuation(t, s, 2, 0)
1434+ assertBlank(t, s, 3, 0) // 好 continuation orphaned → cleared
1435+ },
1436+ },
1437+ {
1438+ name: "erase to end of line covering a continuation clears its wide left half",
1439+ run: func(t *testing.T) {
1440+ s := NewScreen(10, 3)
1441+ s.Write([]byte("A你B")) // A(0) 你(1) cont(2) B(3)
1442+ s.Write([]byte("\x1b[1;3H")) // cursor to col 2 (continuation)
1443+ s.Write([]byte("\x1b[K")) // erase from col 2 to end of line
1444+
1445+ assertCell(t, s, 0, 0, 'A')
1446+ assertBlank(t, s, 1, 0) // 你 left half orphaned by erase → cleared
1447+ assertBlank(t, s, 2, 0)
1448+ assertBlank(t, s, 3, 0)
1449+ },
1450+ },
1451+ {
1452+ name: "erase to start of line covering a wide left clears its continuation",
1453+ run: func(t *testing.T) {
1454+ s := NewScreen(10, 3)
1455+ s.Write([]byte("A你B")) // A(0) 你(1) cont(2) B(3)
1456+ s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (wide left)
1457+ s.Write([]byte("\x1b[1K")) // erase from start to col 1 inclusive
1458+
1459+ assertBlank(t, s, 0, 0)
1460+ assertBlank(t, s, 1, 0)
1461+ assertBlank(t, s, 2, 0) // continuation orphaned by erase → cleared
1462+ assertCell(t, s, 3, 0, 'B')
1463+ },
1464+ },
1465+ {
1466+ name: "wide char on a width-1 screen degrades to a single cell",
1467+ run: func(t *testing.T) {
1468+ s := NewScreen(1, 2)
1469+ // A wide rune cannot fit on a 1-column screen; it must not write
1470+ // an out-of-bounds continuation cell.
1471+ s.Write([]byte("你"))
1472+
1473+ assertCell(t, s, 0, 0, '你')
1474+ if s.Col != 1 {
1475+ t.Errorf("cursor at col=%d, want 1", s.Col)
1380 }1476 }
1381 },1477 },
1382 },1478 },
1479+ {
1480+ name: "wide pair survives a scroll",
1481+ run: func(t *testing.T) {
1482+ s := NewScreen(4, 2)
1483+ s.Write([]byte("你\r\n好")) // row0: 你, row1: 好
1484+ s.Write([]byte("\r\n")) // bottom row → scroll up
1485+
1486+ // After scrolling up, row1's 好 moves to row0 intact.
1487+ assertCell(t, s, 0, 0, '好')
1488+ assertContinuation(t, s, 1, 0)
1489+ },
1490+ },
1383 {1491 {
1384 name: "combining mark alone writes no cell and produces no damage",1492 name: "combining mark alone writes no cell and produces no damage",
1385 run: func(t *testing.T) {1493 run: func(t *testing.T) {
@@ -1440,6 +1548,12 @@ func TestRuneWidth(t *testing.T) {
1440 {"Fullwidth Cent Sign", 0xFFE0, 2},1548 {"Fullwidth Cent Sign", 0xFFE0, 2},
1441 {"grinning face emoji", 0x1F600, 2},1549 {"grinning face emoji", 0x1F600, 2},
1442 {"rocket emoji", 0x1F680, 2},1550 {"rocket emoji", 0x1F680, 2},
1551+ {"hiragana a", 0x3042, 2},
1552+ {"katakana a", 0x30A2, 2},
1553+ {"CJK Extension B", 0x20000, 2},
1554+ {"supplemental symbols (robot)", 0x1F916, 2},
1555+ {"symbols extended-A (sari)", 0x1FA71, 2},
1556+ {"ideographic full stop", 0x3002, 2},
1443 {"NUL", '\x00', 0},1557 {"NUL", '\x00', 0},
1444 {"SOH", '\x01', 0},1558 {"SOH", '\x01', 0},
1445 {"ESC", '\x1b', 0},1559 {"ESC", '\x1b', 0},

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close