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}