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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +459 -100
M pkg/renderer/cell.go +10 -3
| @@ -1,12 +1,19 @@ | |||
| 1 | 1 | 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 | 9 | type Cell struct { | |
| 4 | - | Rune rune | |
| 5 | - | Style Style | |
| 10 | + | Rune rune | |
| 11 | + | Style Style | |
| 12 | + | Continuation bool | |
| 6 | 13 | } | |
| 7 | 14 | ||
| 8 | 15 | func BlankCell() Cell { return Cell{Rune: ' ', Style: DefaultStyle()} } | |
| 9 | 16 | ||
| 10 | 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 | } | |
| @@ -1,12 +1,19 @@ | |||||
| 1 | package renderer | 1 | 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 rune | 10 | + | Rune rune |
| 5 | - | Style Style | 11 | + | 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, | |||
| 180 | 180 | func (r *Renderer) writeRun(out *bytes.Buffer, frame Frame, y, x, width int, style *Style) { | |
| 181 | 181 | for col := x; col < x+width; col++ { | |
| 182 | 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 | 188 | if !cell.Style.Equal(*style) { | |
| 184 | 189 | writeStyle(out, cell.Style) | |
| 185 | 190 | *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.Style | 190 | *style = cell.Style | ||
M pkg/renderer/renderer_test.go +106 -0
| @@ -816,6 +816,18 @@ func TestCellEqual(t *testing.T) { | |||
| 816 | 816 | b: BlankCell(), | |
| 817 | 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 | 833 | for _, tt := range tests { | |
| @@ -1029,3 +1041,97 @@ func TestDrawFallbackAndClampingEdgeCases(t *testing.T) { | |||
| 1029 | 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 | + | } | |
| @@ -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) { | |||
| 141 | 141 | // Returns 2 for CJK, fullwidth, and wide emoji characters. | |
| 142 | 142 | // Returns 1 for all other printable characters. | |
| 143 | 143 | // | |
| 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). | |
| 147 | 148 | func runeWidth(r rune) int { | |
| 148 | 149 | switch { | |
| 149 | 150 | case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F): | |
| @@ -237,6 +238,9 @@ func runeWidth(r rune) int { | |||
| 237 | 238 | // Supplemental Symbols and Pictographs | |
| 238 | 239 | case r >= 0x1F900 && r <= 0x1F9FF: | |
| 239 | 240 | return 2 | |
| 241 | + | // Symbols and Pictographs Extended-A (emoji added in recent Unicode) | |
| 242 | + | case r >= 0x1FA70 && r <= 0x1FAFF: | |
| 243 | + | return 2 | |
| 240 | 244 | // CJK Unified Ideographs Extension B and beyond | |
| 241 | 245 | case r >= 0x20000 && r <= 0x2FFFF: | |
| 242 | 246 | return 2 | |
| @@ -254,16 +258,10 @@ func (s *Screen) putPrintable(r rune) { | |||
| 254 | 258 | if w == 0 { | |
| 255 | 259 | 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 | 261 | if s.Frame.Width == 0 || s.Frame.Height == 0 { | |
| 265 | 262 | return | |
| 266 | 263 | } | |
| 264 | + | // Deferred wrap: cursor sits past the last column. | |
| 267 | 265 | if s.Col >= s.Frame.Width { | |
| 268 | 266 | s.Col = 0 | |
| 269 | 267 | s.index() | |
| @@ -271,11 +269,135 @@ func (s *Screen) putPrintable(r rune) { | |||
| 271 | 269 | if s.Row >= s.Frame.Height { | |
| 272 | 270 | 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 | 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 | 316 | 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 | 401 | func (s *Screen) index() { | |
| 280 | 402 | if s.Frame.Height == 0 { | |
| 281 | 403 | return | |
| @@ -780,13 +902,21 @@ func (s *Screen) insertChars(n int) { | |||
| 780 | 902 | n = w - s.Col | |
| 781 | 903 | } | |
| 782 | 904 | 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 | 908 | for x := w - 1; x >= s.Col+n; x-- { | |
| 784 | 909 | s.Frame.Cells[rowStart+x] = s.Frame.Cells[rowStart+x-n] | |
| 785 | 910 | } | |
| 786 | 911 | for x := s.Col; x < s.Col+n; x++ { | |
| 787 | 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 | 922 | func (s *Screen) deleteChars(n int) { | |
| @@ -798,13 +928,21 @@ func (s *Screen) deleteChars(n int) { | |||
| 798 | 928 | n = w - s.Col | |
| 799 | 929 | } | |
| 800 | 930 | 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 | 934 | for x := s.Col; x < w-n; x++ { | |
| 802 | 935 | s.Frame.Cells[rowStart+x] = s.Frame.Cells[rowStart+x+n] | |
| 803 | 936 | } | |
| 804 | 937 | for x := w - n; x < w; x++ { | |
| 805 | 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 | 948 | func (s *Screen) insertLines(n int) { | |
| @@ -830,9 +968,7 @@ func (s *Screen) clearScreenMode(mode int) { | |||
| 830 | 968 | if y == s.Row { | |
| 831 | 969 | end = min(s.Col+1, s.Frame.Width) | |
| 832 | 970 | } | |
| 833 | - | for x := 0; x < end; x++ { | |
| 834 | - | s.Frame.Set(x, y, renderer.BlankCell()) | |
| 835 | - | } | |
| 971 | + | s.clearRow(y, 0, end) | |
| 836 | 972 | } | |
| 837 | 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 | 974 | case 2, 3: | |
| @@ -846,9 +982,7 @@ func (s *Screen) clearScreenMode(mode int) { | |||
| 846 | 982 | if y == s.Row { | |
| 847 | 983 | start = s.Col | |
| 848 | 984 | } | |
| 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) | |
| 852 | 986 | } | |
| 853 | 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 | 992 | s.clampCursor() | |
| 859 | 993 | switch mode { | |
| 860 | 994 | 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}) | |
| 866 | 997 | 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}) | |
| 871 | 1000 | 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}) | |
| 876 | 1003 | } | |
| 877 | 1004 | } | |
| 878 | 1005 | ||
| @@ -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 characters | 144 | + | // 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 placeholder | 145 | + | // 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 Pictographs | 238 | // Supplemental Symbols and Pictographs | ||
| 238 | case r >= 0x1F900 && r <= 0x1F9FF: | 239 | case r >= 0x1F900 && r <= 0x1F9FF: | ||
| 239 | return 2 | 240 | 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 beyond | 244 | // CJK Unified Ideographs Extension B and beyond | ||
| 241 | case r >= 0x20000 && r <= 0x2FFFF: | 245 | case r >= 0x20000 && r <= 0x2FFFF: | ||
| 242 | return 2 | 246 | return 2 | ||
| @@ -254,16 +258,10 @@ func (s *Screen) putPrintable(r rune) { | |||||
| 254 | if w == 0 { | 258 | if w == 0 { | ||
| 255 | return | 259 | 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 | return | 262 | 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 = 0 | 266 | 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 - 1 | 270 | 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 += w | 316 | 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 | return | 403 | return | ||
| @@ -780,13 +902,21 @@ func (s *Screen) insertChars(n int) { | |||||
| 780 | n = w - s.Col | 902 | n = w - s.Col | ||
| 781 | } | 903 | } | ||
| 782 | rowStart := s.Row * w | 904 | 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.Col | 928 | n = w - s.Col | ||
| 799 | } | 929 | } | ||
| 800 | rowStart := s.Row * w | 930 | 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.Col | 983 | 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) { | |||
| 38 | 38 | if c.Rune != expected { | |
| 39 | 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 | 68 | func lineText(s *Screen, y int) string { | |
| @@ -1226,17 +1251,19 @@ func TestWideAndZeroWidthRunes(t *testing.T) { | |||
| 1226 | 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 | 1255 | run: func(t *testing.T) { | |
| 1231 | 1256 | 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) | |
| 1240 | 1267 | } | |
| 1241 | 1268 | }, | |
| 1242 | 1269 | }, | |
| @@ -1244,27 +1271,27 @@ func TestWideAndZeroWidthRunes(t *testing.T) { | |||
| 1244 | 1271 | name: "CJK mixed with ASCII", | |
| 1245 | 1272 | run: func(t *testing.T) { | |
| 1246 | 1273 | s := NewScreen(10, 3) | |
| 1247 | - | s.Write([]byte("A\xe4\xbd\xa0B")) // A你B | |
| 1274 | + | s.Write([]byte("A你B")) | |
| 1248 | 1275 | ||
| 1249 | 1276 | 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) | |
| 1254 | 1282 | } | |
| 1255 | 1283 | }, | |
| 1256 | 1284 | }, | |
| 1257 | 1285 | { | |
| 1258 | - | name: "emoji rendered as placeholder", | |
| 1286 | + | name: "emoji writes wide pair", | |
| 1259 | 1287 | run: func(t *testing.T) { | |
| 1260 | 1288 | s := NewScreen(10, 3) | |
| 1261 | - | em := []byte("\U0001f600") // 😀 grinning face | |
| 1262 | - | s.Write(em) | |
| 1289 | + | s.Write([]byte("\U0001f600")) // 😀 grinning face | |
| 1263 | 1290 | ||
| 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) | |
| 1268 | 1295 | } | |
| 1269 | 1296 | }, | |
| 1270 | 1297 | }, | |
| @@ -1300,86 +1327,167 @@ func TestWideAndZeroWidthRunes(t *testing.T) { | |||
| 1300 | 1327 | name: "CJK surrounded by ASCII on both sides", | |
| 1301 | 1328 | run: func(t *testing.T) { | |
| 1302 | 1329 | 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")) | |
| 1304 | 1331 | ||
| 1305 | 1332 | 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) | |
| 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 | 1346 | 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("你")) | |
| 1324 | 1353 | ||
| 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) | |
| 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 | 1366 | run: func(t *testing.T) { | |
| 1335 | 1367 | s := NewScreen(10, 3) | |
| 1336 | 1368 | s.ClearDamage() | |
| 1337 | - | s.Write([]byte("\xe4\xbd\xa0")) // 你 | |
| 1369 | + | s.Write([]byte("你")) | |
| 1338 | 1370 | ||
| 1339 | 1371 | d := s.Damage() | |
| 1340 | 1372 | if len(d) == 0 { | |
| 1341 | 1373 | t.Fatal("expected damage") | |
| 1342 | 1374 | } | |
| 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) | |
| 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 | 1382 | run: func(t *testing.T) { | |
| 1352 | 1383 | 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")) | |
| 1355 | 1386 | ||
| 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) | |
| 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 | 1401 | run: func(t *testing.T) { | |
| 1368 | 1402 | 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) | |
| 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 | 1492 | name: "combining mark alone writes no cell and produces no damage", | |
| 1385 | 1493 | run: func(t *testing.T) { | |
| @@ -1440,6 +1548,12 @@ func TestRuneWidth(t *testing.T) { | |||
| 1440 | 1548 | {"Fullwidth Cent Sign", 0xFFE0, 2}, | |
| 1441 | 1549 | {"grinning face emoji", 0x1F600, 2}, | |
| 1442 | 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 | 1557 | {"NUL", '\x00', 0}, | |
| 1444 | 1558 | {"SOH", '\x01', 0}, | |
| 1445 | 1559 | {"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你B | 1274 | + | 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 face | 1289 | + | 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好c | 1330 | + | 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=2 | 1349 | + | 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+blank | 1354 | + | assertCell(t, s, 0, 0, 'A') |
| 1326 | - | assertCell(t, s, 1, 0, ' ') // untouched | 1355 | + | 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, '?') // 你 normalized | 1407 | + | assertCell(t, s, 0, 0, 'X') |
| 1374 | - | assertCell(t, s, 2, 0, '?') // 😀 normalized | 1408 | + | 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}, | ||