Skip to main content

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}