justerm_core/cursor.rs
1//! The cursor and its drawing pen.
2
3use crate::cell::{Cell, CellFlags};
4use crate::color::Color;
5
6/// The current SGR state — the appearance copied into each printed cell.
7///
8/// Modelling it as a "template cell" mirrors Alacritty: a later slice can make
9/// erase (ED/EL) fill cleared cells with `bg` instead of `Default` and that
10/// *is* Background Color Erase (BCE), no structural change. See `term.rs`.
11///
12/// **No `#[non_exhaustive]` (#844): nothing outside this crate has a reason to build one.** No
13/// public function accepts it — the engine hands it out — and there are zero out-of-crate literal
14/// sites, so the attribute would bind nothing it does not already bind.
15#[derive(Clone, Copy, Debug, Default)]
16pub struct Pen {
17 pub fg: Color,
18 pub bg: Color,
19 pub flags: CellFlags,
20 /// The underline colour (SGR 58, #520): what an underline / strikethrough draws
21 /// in, independent of `fg`. `Default` means "follow the fg". It is *not* packed
22 /// into the printed `Cell` (the 12-byte cell is full); the print path stamps a
23 /// non-default value into the row's ucolor map. See `term.rs::write_glyph`.
24 pub underline_color: Color,
25}
26
27impl Pen {
28 /// Reset to default appearance (SGR 0).
29 pub fn reset(&mut self) {
30 *self = Pen::default();
31 }
32
33 /// Build a cell carrying this pen's appearance and the given glyph.
34 pub fn cell(&self, c: char) -> Cell {
35 Cell::from_parts(c, self.fg, self.bg, self.flags)
36 }
37}
38
39/// The cursor's drawn shape (DECSCUSR / the renderer's caret glyph). The engine
40/// reports it on the frame; the renderer draws it. Default `Block` (#81).
41///
42/// **Deliberately exhaustive (#843) — and the reason is the wire, not the spec.**
43///
44/// An earlier draft of that sweep said "DECSCUSR's shape space, closed". **That is
45/// false**, and the counter-example is in this repository: `justerm-renderer` has
46/// carried a fourth shape, `HollowBlock`, since before the sweep
47/// (`justerm-renderer/src/cursor.rs:60`, wire id `3`), and no core frame can ask
48/// for it. The space is not closed by the spec; it has already been grown once,
49/// one crate over.
50///
51/// What actually decides it is that **this enum is mapped onto wire values by a
52/// `match` outside this crate** — `justerm-wasm-decode/src/lib.rs:198` turns each
53/// member into an int for the frame header. Marking it non-exhaustive would force
54/// a `_` arm there, converting a future compile error into a silently wrong wire
55/// value. That is the same construct that reddened `cargo test --workspace` for
56/// [`crate::MarkerKind`], where the rule is stated in full.
57#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
58pub enum CursorShape {
59 #[default]
60 Block,
61 Underline,
62 Bar,
63}
64
65/// The input position, its pending-wrap state, and the current pen.
66///
67/// **No `#[non_exhaustive]` (#844): nothing outside this crate has a reason to build one.** No
68/// public function accepts it — the engine hands it out — and there are zero out-of-crate literal
69/// sites, so the attribute would bind nothing it does not already bind.
70#[derive(Clone, Copy, Debug)]
71pub struct Cursor {
72 pub row: usize,
73 pub col: usize,
74 /// Deferred last-column wrap (xterm's "wrapnext"). Set when a print fills the
75 /// last column: the cursor stays put and the actual line wrap happens on the
76 /// *next* print. Eager wrapping here is the classic off-by-one that shifts
77 /// lines (see `docs/architecture.md` "Hidden VT state").
78 ///
79 /// # The lifecycle, and why it is written here (#848)
80 ///
81 /// **What the flag means:** *the cursor is logically one past the column it
82 /// sits on.* That sentence is what every site below is measured against — but
83 /// read the next paragraph before treating it as a rule you can derive a new
84 /// verb's behaviour from, because you cannot.
85 ///
86 /// **The clear is per-verb and is not derivable.** The first draft of this
87 /// comment said a verb clears iff it *acted*, with `HT` at the last column as
88 /// the one exception because it moves nothing. That predicate is false, and
89 /// the counter-example is one verb over: `CUF` at the last column also moves
90 /// nothing, also destroys the character that was there — and **all four
91 /// references clear anyway**, three of them unconditionally and one before it
92 /// has even computed the clamp (xterm `cursor.c:243`, alacritty
93 /// `term/mod.rs:1241`, ghostty `Terminal.zig:1739` under *"Always resets
94 /// pending wrap"*, xterm.js `InputHandler.ts:919` via `_restrictCursor`). So a
95 /// derived predicate would instruct the next author to *remove* a clear that
96 /// four engines agree on. What separates `HT` is not a property of the verb; it
97 /// is that on `HT` the references agree the other way, 3-1 (#848).
98 ///
99 /// The site-classes, which are what this comment can honestly enumerate:
100 ///
101 /// - **Armed** by the print path, when a glyph fills the last column —
102 /// `Term::write_glyph`, `Term::promote_cluster_to_wide`,
103 /// `Term::relocate_cluster_wide`. **Unconditionally, since #869**: `DECAWM` is
104 /// tested where the park is *consumed*, not where it is armed, which is what the
105 /// three references that arm this state all do. Folding the mode into the arm
106 /// made the sentence at the top false for a whole mode — under `?7l` the cursor
107 /// was pinned with the flag clear — and cost two readers a correct answer
108 /// (#865, #869) before it was found.
109 /// - **Consumed**, which is not a clear — the flag is *spent* on work it owed.
110 /// Two sites, and they spend it in opposite directions: `Term::wrapline` performs
111 /// the deferred wrap and only then puts the flag down, and `Term::step_back`
112 /// under `?45` takes the park as the first unit of the move and therefore does
113 /// **not** decrement the column (#80). **`Term::step_back` is reached by two verbs
114 /// since #873** — `BS` and `CSI D`, the second n times per sequence — so a change
115 /// to that spend now moves cursor-left as well; that is the whole point of the
116 /// step being shared, and it is xterm's shape (one `CursorBack` from `CASE_BS`
117 /// and `CASE_CUB`). A consume site that cleared instead of
118 /// spending would be indistinguishable from a clear on the flag alone — the
119 /// difference shows up only in where the cursor lands, which is why both are
120 /// pinned against an unparked control at the same coordinate.
121 /// - **Translated** by `Term::resize`: where a reflow leaves the cursor off the
122 /// last column the logical position becomes representable, so the flag is
123 /// dropped and `col` takes it instead. Neither an arm nor a clear.
124 /// - **Cleared** by the positioning verbs. The exception is `HT`, and `CHT` which
125 /// repeats it: with no stop to move to they leave it armed (#848, #898). Checked
126 /// verb by verb against the references and recorded in
127 /// `docs/agents/reference-facts.md`, not inferred.
128 /// - **Restored** by `Term::restore_cursor` and by leaving the alt screen, each
129 /// of which then calls `Term::settle_restored_wrap`: a restored park that is
130 /// no longer at the last column becomes a column, the same translation
131 /// `Term::resize` applies to the live cursor. Without it a `DECSC` / resize /
132 /// `DECRC` round-trip installed a state the sentence at the top forbids.
133 /// - **Read as a `+1`** by `term::markers`, which adds the flag to `cursor.col`
134 /// to get an exclusive bound. A change to when the flag survives changes that
135 /// bound — measured for `HT` at the right edge and the recorded column does
136 /// move (3 where it was 2 at four columns), but **no public output changed**:
137 /// the extracted command text is identical either way, because the run that
138 /// cleared the flag also let the next print overwrite the last cell, and the
139 /// two shifts cancel. Recorded so the next change here starts from a
140 /// measurement rather than from the assumption that a reader exists but does
141 /// not matter. The column itself is not observable through any public API.
142 ///
143 /// **What the obvious check does not reach.** Grepping this crate for writes to
144 /// `cursor.col` / `cursor.row` finds **20** functions — and it is blind to the
145 /// row-shift and erase verbs, which write neither field. `IL` and `DL` now clear
146 /// (3-1); `SU` and `SD` deliberately do not, because ghostty saves and restores
147 /// the flag across those two on purpose (`Terminal.zig:2388`); and `ICH`, `DCH`,
148 /// `ECH`, `EL`, `ED` **were unmeasured until #869 and are now measured**: xterm
149 /// clears in every one of them. `ResetWrap` (`ptyx.h:3253`) puts down `do_wrap`
150 /// *and* `char_was_written` together, and `util.c` calls it from exactly seven
151 /// sites — `InsertLine` `:1295`, `DeleteLine` `:1388`, `InsertChar` `:1497`,
152 /// `DeleteChar` `:1582`, `ClearInLine2` `:1787`, `ClearRight` `:1873`,
153 /// `ClearScreen` `:1926`. This engine keeps the park across all seven, and #869
154 /// widened that divergence's reach from one mode to both. Not a defect on any
155 /// measurement so far, but no longer an unknown. alacritty alone additionally makes
156 /// `EL 0` a no-op while parked (`term/mod.rs:1643`). A grep on the cursor fields
157 /// will not tell you any of that.
158 ///
159 /// One more site the field-grep misses: the print path itself reads
160 /// `self.autowrap` before consuming, because `DECAWM` can be turned off after
161 /// the flag is armed and the park must then be spent rather than wrapped.
162 ///
163 /// **What this flag is again a general answer to, and what it cost to get there
164 /// (#865, #869).** It now answers *is the cursor parked on the glyph it just
165 /// wrote* in every mode, which is simply the sentence at the top being true. It
166 /// was not, for as long as the arm folded `DECAWM` in: under `?7l` a print that
167 /// filled the last column pinned the cursor and armed nothing, so a pin and a bare
168 /// move onto that column were identical in every field of this struct. Two readers
169 /// paid for that — `Term::cursor_cluster_col`, which grew a workaround in #865 and
170 /// lost it again in #869, and `term::markers`'s `+1` above, whose bound was one
171 /// short under `?7l` until the arm was fixed.
172 ///
173 /// **So a new reader may ask this flag *which cell did the last print land in*,
174 /// and the three arm sites owe that answer.** They are not free to re-introduce a
175 /// condition on the arm without repairing those readers; that is the obligation
176 /// the mode-gated arm carried invisibly for two releases.
177 ///
178 /// The rule is stated at the property because that is where it is true, the
179 /// same reason ADR-0025 D2 gives for the wrap link's per-verb table living in
180 /// `Term::end_wrap`'s doc-comment.
181 pub pending_wrap: bool,
182 pub pen: Pen,
183 /// Whether the cursor is shown (DEC ?25). The engine only reports it.
184 pub visible: bool,
185 /// The caret shape (DECSCUSR, #89) — reported on the frame, drawn by the
186 /// renderer.
187 pub shape: CursorShape,
188 /// Whether the caret blinks (att610 ?12, #81). The engine reports the *mode*;
189 /// the actual animation is the renderer's.
190 pub blink: bool,
191}
192
193impl Cursor {
194 /// The cursor's `(row, col)` position.
195 pub(crate) fn point(&self) -> (usize, usize) {
196 (self.row, self.col)
197 }
198
199 /// Set the position, clamped to a `rows` x `cols` screen.
200 pub(crate) fn set_point(&mut self, point: (usize, usize), rows: usize, cols: usize) {
201 self.row = point.0.min(rows - 1);
202 self.col = point.1.min(cols - 1);
203 }
204}
205
206impl Default for Cursor {
207 fn default() -> Self {
208 // The cursor starts visible; a manual impl is needed because `bool`'s
209 // derived default is `false`.
210 Cursor {
211 row: 0,
212 col: 0,
213 pending_wrap: false,
214 pen: Pen::default(),
215 visible: true,
216 shape: CursorShape::Block,
217 blink: false,
218 }
219 }
220}