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