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#[derive(Clone, Copy, Debug, Default)]
12pub struct Pen {
13    pub fg: Color,
14    pub bg: Color,
15    pub flags: CellFlags,
16    /// The underline colour (SGR 58, #520): what an underline / strikethrough draws
17    /// in, independent of `fg`. `Default` means "follow the fg". It is *not* packed
18    /// into the printed `Cell` (the 12-byte cell is full); the print path stamps a
19    /// non-default value into the row's ucolor map. See `term.rs::write_glyph`.
20    pub underline_color: Color,
21}
22
23impl Pen {
24    /// Reset to default appearance (SGR 0).
25    pub fn reset(&mut self) {
26        *self = Pen::default();
27    }
28
29    /// Build a cell carrying this pen's appearance and the given glyph.
30    pub fn cell(&self, c: char) -> Cell {
31        Cell::from_parts(c, self.fg, self.bg, self.flags)
32    }
33}
34
35/// The cursor's drawn shape (DECSCUSR / the renderer's caret glyph). The engine
36/// reports it on the frame; the renderer draws it. Default `Block` (#81).
37///
38/// **Deliberately exhaustive (#843) — and the reason is the wire, not the spec.**
39///
40/// An earlier draft of that sweep said "DECSCUSR's shape space, closed". **That is
41/// false**, and the counter-example is in this repository: `justerm-renderer` has
42/// carried a fourth shape, `HollowBlock`, since before the sweep
43/// (`justerm-renderer/src/cursor.rs:60`, wire id `3`), and no core frame can ask
44/// for it. The space is not closed by the spec; it has already been grown once,
45/// one crate over.
46///
47/// What actually decides it is that **this enum is mapped onto wire values by a
48/// `match` outside this crate** — `justerm-wasm-decode/src/lib.rs:198` turns each
49/// member into an int for the frame header. Marking it non-exhaustive would force
50/// a `_` arm there, converting a future compile error into a silently wrong wire
51/// value. That is the same construct that reddened `cargo test --workspace` for
52/// [`crate::MarkerKind`], where the rule is stated in full.
53#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
54pub enum CursorShape {
55    #[default]
56    Block,
57    Underline,
58    Bar,
59}
60
61/// The input position, its pending-wrap state, and the current pen.
62#[derive(Clone, Copy, Debug)]
63pub struct Cursor {
64    pub row: usize,
65    pub col: usize,
66    /// Deferred last-column wrap (xterm's "wrapnext"). Set when a print fills the
67    /// last column: the cursor stays put and the actual line wrap happens on the
68    /// *next* print. Eager wrapping here is the classic off-by-one that shifts
69    /// lines (see `docs/architecture.md` "Hidden VT state").
70    ///
71    /// # The lifecycle, and why it is written here (#848)
72    ///
73    /// **What the flag means:** *the cursor is logically one past the column it
74    /// sits on.* That sentence is what every site below is measured against — but
75    /// read the next paragraph before treating it as a rule you can derive a new
76    /// verb's behaviour from, because you cannot.
77    ///
78    /// **The clear is per-verb and is not derivable.** The first draft of this
79    /// comment said a verb clears iff it *acted*, with `HT` at the last column as
80    /// the one exception because it moves nothing. That predicate is false, and
81    /// the counter-example is one verb over: `CUF` at the last column also moves
82    /// nothing, also destroys the character that was there — and **all four
83    /// references clear anyway**, three of them unconditionally and one before it
84    /// has even computed the clamp (xterm `cursor.c:243`, alacritty
85    /// `term/mod.rs:1241`, ghostty `Terminal.zig:1739` under *"Always resets
86    /// pending wrap"*, xterm.js `InputHandler.ts:919` via `_restrictCursor`). So a
87    /// derived predicate would instruct the next author to *remove* a clear that
88    /// four engines agree on. What separates `HT` is not a property of the verb; it
89    /// is that on `HT` the references agree the other way, 3-1 (#848).
90    ///
91    /// The site-classes, which are what this comment can honestly enumerate:
92    ///
93    /// - **Armed** by the print path, when a glyph fills the last column and
94    ///   `DECAWM` is on — `Term::write_glyph`, `Term::promote_cluster_to_wide`,
95    ///   `Term::relocate_cluster_wide`.
96    /// - **Consumed** by the wrap machinery, which is not a clear: `Term::wrapline`
97    ///   performs the deferred wrap and only then puts the flag down.
98    /// - **Translated** by `Term::resize`: where a reflow leaves the cursor off the
99    ///   last column the logical position becomes representable, so the flag is
100    ///   dropped and `col` takes it instead. Neither an arm nor a clear.
101    /// - **Cleared** by the positioning verbs, `HT` excepted — checked verb by verb
102    ///   against the references and recorded in
103    ///   `docs/agents/reference-facts.md`, not inferred.
104    /// - **Restored** by `Term::restore_cursor` and by leaving the alt screen, each
105    ///   of which then calls `Term::settle_restored_wrap`: a restored park that is
106    ///   no longer at the last column becomes a column, the same translation
107    ///   `Term::resize` applies to the live cursor. Without it a `DECSC` / resize /
108    ///   `DECRC` round-trip installed a state the sentence at the top forbids.
109    /// - **Read as a `+1`** by `term::markers`, which adds the flag to `cursor.col`
110    ///   to get an exclusive bound. A change to when the flag survives changes that
111    ///   bound — measured for `HT` at the right edge and the recorded column does
112    ///   move (3 where it was 2 at four columns), but **no public output changed**:
113    ///   the extracted command text is identical either way, because the run that
114    ///   cleared the flag also let the next print overwrite the last cell, and the
115    ///   two shifts cancel. Recorded so the next change here starts from a
116    ///   measurement rather than from the assumption that a reader exists but does
117    ///   not matter. The column itself is not observable through any public API.
118    ///
119    /// **What the obvious check does not reach.** Grepping this crate for writes to
120    /// `cursor.col` / `cursor.row` finds **20** functions — and it is blind to the
121    /// row-shift and erase verbs, which write neither field. `IL` and `DL` now clear
122    /// (3-1); `SU` and `SD` deliberately do not, because ghostty saves and restores
123    /// the flag across those two on purpose (`Terminal.zig:2388`); and `ICH`, `DCH`,
124    /// `ECH`, `EL`, `ED` are **unmeasured**, except that alacritty alone makes
125    /// `EL 0` a no-op while parked (`term/mod.rs:1643`). A grep on the cursor fields
126    /// will not tell you any of that.
127    ///
128    /// One more site the field-grep misses: the print path itself reads
129    /// `self.autowrap` before consuming, because `DECAWM` can be turned off after
130    /// the flag is armed and the park must then be spent rather than wrapped.
131    ///
132    /// The rule is stated at the property because that is where it is true, the
133    /// same reason ADR-0025 D2 gives for the wrap link's per-verb table living in
134    /// `Term::end_wrap`'s doc-comment.
135    pub pending_wrap: bool,
136    pub pen: Pen,
137    /// Whether the cursor is shown (DEC ?25). The engine only reports it.
138    pub visible: bool,
139    /// The caret shape (DECSCUSR, #89) — reported on the frame, drawn by the
140    /// renderer.
141    pub shape: CursorShape,
142    /// Whether the caret blinks (att610 ?12, #81). The engine reports the *mode*;
143    /// the actual animation is the renderer's.
144    pub blink: bool,
145}
146
147impl Cursor {
148    /// The cursor's `(row, col)` position.
149    pub(crate) fn point(&self) -> (usize, usize) {
150        (self.row, self.col)
151    }
152
153    /// Set the position, clamped to a `rows` x `cols` screen.
154    pub(crate) fn set_point(&mut self, point: (usize, usize), rows: usize, cols: usize) {
155        self.row = point.0.min(rows - 1);
156        self.col = point.1.min(cols - 1);
157    }
158}
159
160impl Default for Cursor {
161    fn default() -> Self {
162        // The cursor starts visible; a manual impl is needed because `bool`'s
163        // derived default is `false`.
164        Cursor {
165            row: 0,
166            col: 0,
167            pending_wrap: false,
168            pen: Pen::default(),
169            visible: true,
170            shape: CursorShape::Block,
171            blink: false,
172        }
173    }
174}