1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
//! Where a combining mark attaches (#865) — the cell the last print landed in.
//!
//! Both the zero-width path (`push_combining`) and the mode-2027 join path
//! (`try_grapheme_join`) locate that cell through one helper, so they are tested
//! together here.
//!
//! The hard case **was** autowrap off (DECAWM `?7l`), and the reason it was hard has
//! since been removed. A print that fills the last column arms the deferred wrap and
//! the cursor is parked *on* the glyph; until #869 that arm folded the mode in, so with
//! `?7l` the cursor reached the last column two ways that shared every cursor field —
//! it *filled* the column, or it merely *advanced/was moved onto* it — and the mark
//! belongs to a different cell in each. #865 told them apart with `Term::repeat_anchor`;
//! #869 made the arm unconditional and that workaround was measured dead and removed.
//!
//! **These tests are kept exactly as they were**, and they still redden if the arm is
//! re-conditioned — which is the point: they now pin the *behaviour* against whichever
//! mechanism supplies it, rather than the mechanism they were written against.
//!
//! Reference behaviour, all four read at the pins in `docs/agents/thegraph.md`:
//! a mark after a print that filled the last column attaches **under the cursor** in
//! xterm (`charproc.c:3109` — `char_was_written ? last_written_col : cur_col`),
//! alacritty (`term/mod.rs:1073`, whose `input_needs_wrap` is armed unconditionally at
//! `:1136`), xterm.js (`InputHandler.ts:625` — its `x` runs to `cols`) and ghostty's
//! mode-2027 path (`Terminal.zig:1124` — *"if we do not have wraparound, the logic is
//! trickier"*). That makes it **3 of 4 against this engine, not 4 of 4**: mode 2027
//! carries no `.default = true` in ghostty either (`modes.zig:283`), so ghostty's
//! shipped answer is its plain zero-width path (`:1329`), which reads
//! `wraparound and pending_wrap` and therefore had exactly the defect fixed here.
//! Ghostty's two paths contradict each other; the 2027 one is the one that agrees
//! with the other three.
use justerm_core::Engine;
const ACUTE: &str = "\u{301}";
/// The first screen row as text, marks included — this is what `selection_text`,
/// `search` and the wire all read, so it is the surface the defect is visible on.
fn row0(t: &Engine) -> String {
t.accessible_text()
.lines()
.next()
.unwrap_or_default()
.trim_end()
.to_string()
}
#[test]
fn autowrap_off_a_mark_after_a_filled_row_attaches_to_the_last_column() {
// AC 1. `abc` exactly fills a 3-column row with `?7l`, pinning the cursor on `c`.
let mut t = Engine::new(3, 1);
t.feed(b"\x1b[?7labc");
t.feed(ACUTE.as_bytes());
assert_eq!(
row0(&t),
format!("abc{ACUTE}"),
"the mark belongs to `c`, the glyph the print just wrote"
);
}
#[test]
fn autowrap_off_a_mark_after_a_cursor_move_attaches_before_the_cursor() {
// AC 2 — the case a naive widening breaks. Nothing was printed since the move, so
// the cursor is *at* a cell rather than pinned on one, and the mark takes the cell
// before it. Unchanged from before #865, and deliberately so. The references do not
// agree and none of them agrees with two others: xterm puts it under the cursor,
// alacritty and ghostty put it here, and xterm.js puts it in a cell of its own
// (the intervening CSI zeroes its join state). This is the plurality's answer and
// reopening it is not this change's job.
let mut t = Engine::new(3, 1);
t.feed(b"\x1b[?7labc\x1b[1;3H");
t.feed(ACUTE.as_bytes());
assert_eq!(
row0(&t),
format!("ab{ACUTE}c"),
"a bare cursor move leaves the mark on the column before the cursor"
);
}
#[test]
fn autowrap_off_the_mode_2027_join_shares_the_attach_point() {
// AC 3. The join path locates its cluster through the same helper, so it moves
// with it — asserted through a mark that joins rather than one that merely
// attaches.
let mut t = Engine::new(3, 1);
t.feed(b"\x1b[?2027h\x1b[?7labc");
t.feed(ACUTE.as_bytes());
assert_eq!(
row0(&t),
format!("abc{ACUTE}"),
"mode 2027 joins into the same cell the zero-width path attaches to"
);
}
#[test]
fn autowrap_off_a_mark_after_a_filled_row_ending_in_a_wide_glyph_reaches_its_lead() {
// AC 4, `?7l` half. A wide glyph at columns 1-2 fills the row and pins the cursor
// on its *spacer*; the mark belongs to the lead at column 1, never the spacer.
let mut t = Engine::new(3, 1);
t.feed("\x1b[?7la\u{4e00}".as_bytes()); // 'a', then a width-2 CJK glyph
t.feed(ACUTE.as_bytes());
assert_eq!(
row0(&t),
format!("a\u{4e00}{ACUTE}"),
"the mark rides the wide lead, not its spacer"
);
}
#[test]
fn autowrap_on_a_mark_after_a_filled_row_ending_in_a_wide_glyph_reaches_its_lead() {
// AC 4, deferred-wrap half — the same shape with the wrap armed.
let mut t = Engine::new(3, 2);
t.feed("a\u{4e00}".as_bytes());
t.feed(ACUTE.as_bytes());
assert_eq!(
row0(&t),
format!("a\u{4e00}{ACUTE}"),
"the mark rides the wide lead under a deferred wrap too"
);
}
#[test]
fn autowrap_on_a_mark_after_a_filled_row_attaches_to_the_last_column() {
// Control. The deferred wrap makes the pin visible, so this path was always right;
// it is here so a regression cannot be read as "the edge case was always broken".
let mut t = Engine::new(3, 2);
t.feed(b"abc");
t.feed(ACUTE.as_bytes());
assert_eq!(row0(&t), format!("abc{ACUTE}"));
}
#[test]
fn a_mark_mid_row_attaches_to_the_glyph_before_the_cursor() {
// Control. Away from the right margin nothing is ambiguous, with the mode either
// way — the ordinary path must not move.
for prefix in [&b""[..], &b"\x1b[?7l"[..]] {
let mut t = Engine::new(5, 1);
t.feed(prefix);
t.feed(b"abc");
t.feed(ACUTE.as_bytes());
assert_eq!(row0(&t), format!("abc{ACUTE}"), "prefix {prefix:?}");
}
}
#[test]
fn autowrap_off_a_second_mark_stacks_on_the_same_pinned_cell() {
// The attach point survives its own output: a mark is itself a print, so the
// second one must find the first one's cell rather than falling back a column.
let mut t = Engine::new(3, 1);
t.feed(b"\x1b[?7labc");
t.feed(ACUTE.as_bytes());
t.feed("\u{308}".as_bytes()); // combining diaeresis
assert_eq!(row0(&t), format!("abc{ACUTE}\u{308}"));
}
#[test]
fn a_restored_deferred_wrap_still_attaches_under_the_cursor() {
// Written as the guard on collapsing #865's two readings into one: DECSC / move /
// DECRC restores the *park* while every escape in between clears the record of where
// the last print wrote, so a helper consulting only that record stepped a column left
// here. There is one reading again since #869, so this no longer separates two
// mechanisms — it pins that a restored park is still a park, which is
// `Term::settle_restored_wrap`'s contract and nothing else asserts.
let mut t = Engine::new(3, 2);
t.feed(b"abc"); // fills the row, arms the deferred wrap
t.feed(b"\x1b7"); // DECSC
t.feed(b"\x1b[2;1H"); // move away
t.feed(b"\x1b8"); // DECRC — the park comes back, the print record does not
t.feed(ACUTE.as_bytes());
assert_eq!(
row0(&t),
format!("abc{ACUTE}"),
"a restored park still names the cell under the cursor"
);
}
#[test]
fn a_mark_after_a_relocated_cluster_follows_it_rather_than_its_vacated_column() {
// Under mode 2027 a narrow base joined by VS16 grows to width 2. At the last column
// the pair cannot fit, so `relocate_cluster_wide` vacates that column and re-places
// the cluster on the next row — on a one-row grid, reached by *scrolling*, so it
// lands back on row 0 at column 0.
//
// **The width sweep is kept, and what it buys has changed.** It was written because
// #865's anchor branch keyed on the anchor naming the cursor's own column, so only
// the narrow grids made it fire — at 6 columns the vacated column is 5, the branch
// never fired, and the case was a vacuous window that passed whatever the helper did
// (the refuting pass measured what that missed: 3 columns dropped the mark onto a
// blank cell, 2 columns lost it entirely). That branch is gone since #869. The sweep
// now guards the thing that outlived it — `try_grapheme_join` anchoring on where a
// relocated cluster *landed* rather than where it was joined, which `REP` still
// depends on — and the narrow widths remain the ones where a wrong anchor collides
// with a live cell instead of a harmless one.
for cols in [2usize, 3, 4, 6] {
let filler = "abcdefgh"[..cols - 1].to_string();
let mut t = Engine::new(cols, 1);
t.feed(b"\x1b[?2027h");
t.feed(filler.as_bytes()); // base lands on the last column
t.feed("\u{25B6}".as_bytes()); // a width-1 base
t.feed("\u{FE0F}".as_bytes()); // VS16 -> width 2 -> cannot fit -> relocated
// The window itself, asserted before the behaviour inside it, so a build that
// stops relocating reports that rather than passing quietly.
assert_eq!(
t.grid().cell(0, 0).c(),
'\u{25B6}',
"precondition at {cols} columns: the cluster relocated to column 0"
);
assert!(
t.grid().cell(0, 1).is_wide_spacer(),
"precondition at {cols} columns: its spacer"
);
t.feed("\u{301}".as_bytes()); // a further mark, arriving after the relocation
// The relocation soft-wraps, so the logical line is the vacated half followed by
// the cluster's new home — the mark has to land on the second half.
assert_eq!(
t.accessible_text(),
format!("{filler}\u{25B6}\u{FE0F}{ACUTE}"),
"at {cols} columns the mark follows the cluster, not its vacated column"
);
}
}
#[test]
fn a_base_less_mark_at_column_zero_is_not_a_cluster_the_join_may_extend() {
// `push_combining` anchors a mark that opens a row at column 0 through its own
// `unwrap_or(0)`; the join path declines that case instead, and the two differ
// deliberately. `cursor_cluster_col`'s `col == 0 -> None` arm is what keeps them
// apart, and it records no reason of its own — this test is the record.
//
// Reached by the completeness pass on #865, where a helper branch briefly answered
// `Some(0)` here and reconciled them by accident; that branch is gone since #869,
// and the decision it threatened is still only stated here.
let mut t = Engine::new(6, 2);
t.feed(b"\x1b[?2027h");
t.feed("\u{25B6}".as_bytes());
t.feed(b"\r"); // back to column 0; the anchor is cleared by the C0
t.feed("\u{FE0F}\u{200D}".as_bytes()); // a base-less mark, then a joining scalar
assert!(
!t.grid().cell(0, 0).is_wide(),
"the join declined, so nothing promoted column 0 to a wide pair"
);
}
#[test]
fn a_mark_after_a_resize_still_reaches_the_glyph_the_print_filled() {
// The case #865 could not fix and #869 does, which is the sharpest evidence that
// the workaround was incomplete rather than merely redundant: `Term::resize` clears
// the anchor #865 consulted, so the pin became invisible again the moment the screen
// changed size. Fixing the flag instead of reading around it covers this for free.
//
// Measured on master before #869: `"ab\u{301}c"` — the mark on `b`.
let mut t = Engine::new(3, 2);
t.feed(b"\x1b[?7labc"); // fills the row; the cursor is pinned on `c`
t.resize(6, 2); // the anchor is cleared here; the park is translated
t.feed(ACUTE.as_bytes());
assert_eq!(
t.accessible_text(),
format!("abc{ACUTE}"),
"the mark belongs to `c` across a resize too"
);
}