Skip to main content

uncurses/ansi/
cost.rs

1//! Byte-length predictors for emitted ANSI sequences.
2//!
3//! ## Category
4//!
5//! Cost functions mirror writer functions in [`crate::ansi::cursor`] and
6//! [`crate::ansi::screen`]. They let render planning compare equivalent cursor,
7//! erase, scroll, and overwrite strategies without allocating formatted strings.
8//!
9//! ## Conventions
10//!
11//! Costs are byte counts for the exact 7-bit sequences emitted by this crate,
12//! including omitted default parameters such as `ESC [ A` for `CUU 1` and
13//! `ESC [ H` for home.
14//!
15//! ## Mode interaction
16//!
17//! The predictors do not change terminal modes. For mode-sensitive sequences,
18//! such as left/right margins requiring [`Mode::LEFT_RIGHT_MARGIN`](crate::ansi::mode::Mode::LEFT_RIGHT_MARGIN), the cost still describes only the bytes emitted.
19
20/// Bytes in a CSI introducer (`ESC [`).
21const CSI_LEN: usize = 2;
22
23/// Number of decimal digits in a `u16`. Branchy table — faster than
24/// `ilog10` on the small range we feed it (cursor coordinates,
25/// repeat counts).
26pub const fn digit_count(n: u16) -> usize {
27    if n < 10 {
28        1
29    } else if n < 100 {
30        2
31    } else if n < 1000 {
32        3
33    } else if n < 10000 {
34        4
35    } else {
36        5
37    }
38}
39
40/// Cost of a single-parameter CSI where `n == 1` omits the parameter
41/// (the writer emits `ESC [ X` instead of `ESC [ 1 X`). Final byte
42/// contributes 1.
43///
44/// Shape: `\x1b[X` for `n <= 1`, `\x1b[{n}X` otherwise.
45const fn csi_optional_param_cost(n: u16) -> usize {
46    if n <= 1 {
47        CSI_LEN + 1
48    } else {
49        CSI_LEN + digit_count(n) + 1
50    }
51}
52
53/// Cost of a single-parameter CSI whose parameter is dropped when
54/// the 0-based input value is `0` (the canonical "absolute
55/// position" shape: CHA, HPA, VPA all default to row/col 1, which
56/// corresponds to a 0-based input of 0).
57///
58/// Shape: `\x1b[X` for `arg == 0`, `\x1b[{arg+1}X` otherwise.
59const fn csi_absolute_position_cost(arg: u16) -> usize {
60    if arg == 0 {
61        CSI_LEN + 1
62    } else {
63        CSI_LEN + digit_count(arg + 1) + 1
64    }
65}
66
67/// Cost of an erase-style CSI where the default parameter is `0`
68/// (the writer emits `ESC [ X` for `n == 0` and `ESC [ {n} X`
69/// otherwise).
70///
71/// Shape: `\x1b[X` for `n == 0`, `\x1b[{n}X` otherwise.
72const fn csi_erase_cost(n: u16) -> usize {
73    if n == 0 {
74        CSI_LEN + 1
75    } else {
76        CSI_LEN + digit_count(n) + 1
77    }
78}
79
80// ---------- Cursor movement -------------------------------------------------
81
82/// Cost of a CUP sequence for the given 0-based position. The writer
83/// drops the column when it is `0`, and drops both parameters when
84/// the destination is the origin.
85pub const fn cup_cost(row: u16, col: u16) -> usize {
86    if row == 0 && col == 0 {
87        // \x1b[H
88        CSI_LEN + 1
89    } else if col == 0 {
90        // \x1b[{row+1}H
91        CSI_LEN + digit_count(row + 1) + 1
92    } else {
93        // \x1b[{row+1};{col+1}H
94        CSI_LEN + digit_count(row + 1) + 1 + digit_count(col + 1) + 1
95    }
96}
97
98/// Cost of CUU (`\x1b[{n?}A`).
99pub const fn cuu_cost(n: u16) -> usize {
100    csi_optional_param_cost(n)
101}
102
103/// Cost of CUD (`\x1b[{n?}B`).
104pub const fn cud_cost(n: u16) -> usize {
105    csi_optional_param_cost(n)
106}
107
108/// Cost of CUF (`\x1b[{n?}C`).
109pub const fn cuf_cost(n: u16) -> usize {
110    csi_optional_param_cost(n)
111}
112
113/// Cost of CUB (`\x1b[{n?}D`).
114pub const fn cub_cost(n: u16) -> usize {
115    csi_optional_param_cost(n)
116}
117
118/// Cost of CHA (`\x1b[{col+1?}G`). The writer drops the parameter
119/// when `col == 0`.
120pub const fn cha_cost(col: u16) -> usize {
121    csi_absolute_position_cost(col)
122}
123
124/// Cost of HPA (``\x1b[{col+1?}` ``). The writer drops the parameter
125/// when `col == 0`.
126pub const fn hpa_cost(col: u16) -> usize {
127    csi_absolute_position_cost(col)
128}
129
130/// Cost of VPA (`\x1b[{row+1?}d`). The writer drops the parameter
131/// when `row == 0`.
132pub const fn vpa_cost(row: u16) -> usize {
133    csi_absolute_position_cost(row)
134}
135
136/// Cost of CHT (`\x1b[{n?}I`).
137pub const fn cht_cost(n: u16) -> usize {
138    csi_optional_param_cost(n)
139}
140
141/// Cost of CBT (`\x1b[{n?}Z`).
142pub const fn cbt_cost(n: u16) -> usize {
143    csi_optional_param_cost(n)
144}
145
146/// Cost of Reverse Index (`\x1bM`).
147pub const RI_COST: usize = 2;
148
149// ---------- Cell operations -------------------------------------------------
150
151/// Cost of ICH (`\x1b[{n?}@`).
152pub const fn ich_cost(n: u16) -> usize {
153    csi_optional_param_cost(n)
154}
155
156/// Cost of DCH (`\x1b[{n?}P`).
157pub const fn dch_cost(n: u16) -> usize {
158    csi_optional_param_cost(n)
159}
160
161/// Cost of ECH (`\x1b[{n?}X`).
162pub const fn ech_cost(n: u16) -> usize {
163    csi_optional_param_cost(n)
164}
165
166/// Cost of REP (`\x1b[{n?}b`).
167pub const fn rep_cost(n: u16) -> usize {
168    csi_optional_param_cost(n)
169}
170
171// ---------- Line operations -------------------------------------------------
172
173/// Cost of IL (`\x1b[{n?}L`).
174pub const fn il_cost(n: u16) -> usize {
175    csi_optional_param_cost(n)
176}
177
178/// Cost of DL (`\x1b[{n?}M`).
179pub const fn dl_cost(n: u16) -> usize {
180    csi_optional_param_cost(n)
181}
182
183/// Cost of SU (`\x1b[{n?}S`).
184pub const fn su_cost(n: u16) -> usize {
185    csi_optional_param_cost(n)
186}
187
188/// Cost of SD (`\x1b[{n?}T`).
189pub const fn sd_cost(n: u16) -> usize {
190    csi_optional_param_cost(n)
191}
192
193// ---------- Erase -----------------------------------------------------------
194
195/// Cost of EL (`\x1b[{n?}K`). `n == 0` omits the parameter.
196pub const fn el_cost(n: u16) -> usize {
197    csi_erase_cost(n)
198}
199
200/// Cost of ED (`\x1b[{n?}J`). `n == 0` omits the parameter.
201pub const fn ed_cost(n: u16) -> usize {
202    csi_erase_cost(n)
203}
204
205// ---------- Scroll region ---------------------------------------------------
206
207/// Cost of DECSTBM (`\x1b[{top+1};{bottom+1}r`).
208pub const fn decstbm_cost(top: u16, bottom: u16) -> usize {
209    // CSI + d(top+1) + ';' + d(bottom+1) + 'r'
210    CSI_LEN + digit_count(top + 1) + 1 + digit_count(bottom + 1) + 1
211}
212
213/// Cost of resetting DECSTBM to the full screen (`\x1b[r`).
214pub const DECSTBM_RESET_COST: usize = 3;
215
216// ---------- C0 / fixed prefixes --------------------------------------------
217
218/// Cost of a carriage return (`\r`).
219pub const CR_COST: usize = 1;
220
221/// Cost of an absolute home jump (`\x1b[H`).
222pub const HOME_COST: usize = 3;
223
224/// Cost of `n` literal line-feed bytes.
225pub const fn lf_cost(n: u16) -> usize {
226    n as usize
227}
228
229/// Cost of `n` literal backspace bytes.
230pub const fn bs_cost(n: u16) -> usize {
231    n as usize
232}
233
234/// Cost of `n` literal tab bytes.
235pub const fn tab_cost(n: u16) -> usize {
236    n as usize
237}
238
239// ---------- Overwrite (re-emit row cells as a forward move) ----------------
240
241/// Whether `content` draws exactly `want` columns however the terminal
242/// measures it.
243///
244/// [`overwrite_cost`] and the emit pass both travel by drawing, and neither
245/// knows the width policy the row was painted under. Refusing a cell whose
246/// width moves with the policy costs a cursor sequence; accepting one lands
247/// the cursor somewhere the planner did not record.
248pub(crate) fn crossable_under_every_policy(content: &str, want: usize) -> bool {
249    use crate::text::WidthMode;
250    // Printable ASCII is one column under every policy, and it is what the
251    // planner meets on nearly every candidate, so answer it without four
252    // table lookups.
253    if want == 1 && content.len() == 1 {
254        let b = content.as_bytes()[0];
255        if b.is_ascii_graphic() || b == b' ' {
256            return true;
257        }
258    }
259    for mode in [WidthMode::Grapheme, WidthMode::Wc] {
260        for eaw_wide in [false, true] {
261            if usize::from(mode.grapheme_width(content, eaw_wide)) != want {
262                return false;
263            }
264        }
265    }
266    true
267}
268
269/// Byte cost of re-emitting the cells in `line[from..to]` as a
270/// forward-move candidate.
271///
272/// The cost is the summed byte length of the cells' content, which is what
273/// the move writes, so it compares directly against the byte cost of a
274/// cursor sequence.
275///
276/// Returns [`None`] when the range is not eligible. This move travels by
277/// drawing, so what it draws has to span exactly the columns it claims to
278/// cross, and the pen has to survive the trip:
279///
280/// - the range opens on a continuation, which begins inside a glyph whose
281///   bytes have already gone out, so the walk would arrive a column short
282/// - the last cluster in the range reaches past `to`, so drawing it would
283///   carry the cursor a column too far
284/// - an occupied cell has a style or link the active pen does not match,
285///   which would need an interleaved pen change
286/// - an occupied cell holds anything other than exactly one code point,
287///   which keeps the byte cost equal to the columns crossed and keeps the
288///   candidate off sequences whose rendered width a terminal may disagree
289///   about
290///
291/// The renderer decides eligibility the same way when it emits the move,
292/// and the two have to agree or the planner picks a move it cannot
293/// produce.
294pub fn overwrite_cost(
295    line: &[crate::cell::Cell],
296    style: &crate::style::Style,
297    from: u16,
298    to: u16,
299) -> Option<usize> {
300    let from = from as usize;
301    let to = to as usize;
302    if to > line.len() {
303        return None;
304    }
305    // This candidate travels by drawing, so it is only usable when what it
306    // draws spans exactly the columns it claims to cross. Opening inside a
307    // glyph leaves nothing to draw for that column, and a last cluster
308    // reaching past `to` draws a column too many; either way the cursor
309    // stops somewhere other than the planner records. The same rule decides
310    // eligibility in the emit pass, and the two have to agree.
311    if line
312        .get(from)
313        .is_some_and(crate::cell::Cell::is_continuation)
314    {
315        return None;
316    }
317    let mut i = from;
318    let mut cost = 0usize;
319    while i < to {
320        let cell = &line[i];
321        if !cell.is_continuation() {
322            if &cell.style != style {
323                return None;
324            }
325            let content = cell.content();
326            // A cell with nothing to write still owns its column, so drawing
327            // it advances the cursor by nothing while the planner records a
328            // column crossed. A cluster of several code points has the
329            // opposite problem: it costs what it takes to write, which is
330            // its bytes, and a joined emoji is closer to thirty of those
331            // than to the two columns it occupies. Taking one code point at
332            // a time keeps the price and the distance equal, and keeps the
333            // move off sequences whose rendered width a terminal may not
334            // agree about. Asking for a second code point answers both
335            // without walking the rest of a long one.
336            // The cell has to draw the columns the row credits it with, or
337            // the walk arrives somewhere the planner did not record. A cell
338            // can claim a width its content does not have: a combining mark
339            // or a control character draws nothing, a narrow cell can hold a
340            // wide glyph, and a wide one can hold a narrow glyph. Measuring
341            // the content answers that directly, where counting code points
342            // only guessed at it.
343            // The policy the row was measured under is not known here, so a
344            // cell is crossable only when its content draws the same number
345            // of columns under every policy a terminal may be using. A cell
346            // whose width is policy-dependent -- an East Asian Ambiguous
347            // glyph, or a regional indicator that `Wc` and `Grapheme`
348            // disagree about -- is refused rather than guessed at, because
349            // guessing wrong here accepts the move instead of declining it.
350            if !crossable_under_every_policy(content, usize::from(cell.width().max(1))) {
351                return None;
352            }
353            // Beyond that, one code point at a time, which keeps the move
354            // off sequences whose rendered width a terminal may not agree
355            // about however the table measures them.
356            let mut code_points = content.chars();
357            if code_points.next().is_none() || code_points.next().is_some() {
358                return None;
359            }
360            cost += content.len();
361            let w = cell.width().max(1) as usize;
362            i += w;
363            continue;
364        }
365        // Reaching a continuation here means nothing stepped over it, so no
366        // cell owns it and nothing will draw its column, while the walk
367        // would still count the column as crossed.
368        return None;
369    }
370    if i != to {
371        return None;
372    }
373    Some(cost)
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    /// A cell whose rendered width moves with the terminal's width policy
381    /// cannot be crossed by drawing, because the planner records
382    /// `cell.width()` columns either way.
383    #[test]
384    fn an_overwrite_declines_a_cell_whose_width_depends_on_the_policy() {
385        use crate::cell::Cell;
386        use crate::style::Style;
387        use crate::text::WidthMode;
388
389        let dot = "\u{00b7}";
390        assert_eq!(WidthMode::Grapheme.grapheme_width(dot, false), 1);
391        assert_eq!(WidthMode::Grapheme.grapheme_width(dot, true), 2);
392
393        let flag = "\u{1f1ef}";
394        assert_eq!(WidthMode::Grapheme.grapheme_width(flag, false), 2);
395        assert_eq!(WidthMode::Wc.grapheme_width(flag, false), 1);
396
397        let style = Style::default();
398        assert_eq!(
399            overwrite_cost(&[Cell::new(dot, 1), Cell::new("a", 1)], &style, 0, 2),
400            None,
401            "ambiguous width draws two columns in a CJK locale"
402        );
403        assert_eq!(
404            overwrite_cost(&[Cell::new(flag, 2), Cell::CONTINUATION], &style, 0, 2),
405            None,
406            "a lone regional indicator draws one column under wcwidth"
407        );
408        // A cell every policy agrees on is still crossable.
409        assert!(
410            overwrite_cost(&[Cell::new("a", 1), Cell::new("b", 1)], &style, 0, 2).is_some(),
411            "plain ASCII must remain crossable"
412        );
413    }
414
415    use crate::ansi::cursor::{
416        write_backtab, write_cha, write_cht, write_cub, write_cud, write_cuf, write_cup, write_cuu,
417        write_hpa, write_reverse_index, write_vpa,
418    };
419    use crate::ansi::screen::{
420        write_dch, write_delete_lines, write_ech, write_ed, write_el, write_ich,
421        write_insert_lines, write_rep, write_reset_scroll_region, write_scroll_down,
422        write_scroll_region, write_scroll_up,
423    };
424
425    /// Representative values exercising every branch of `digit_count`:
426    /// the `n == 0` / `n == 1` shortcuts, and 1..5 digit widths.
427    const NS: &[u16] = &[0, 1, 2, 9, 10, 99, 100, 999, 1000, 9999, 10000, u16::MAX];
428
429    fn write_to_vec(f: impl FnOnce(&mut Vec<u8>)) -> Vec<u8> {
430        let mut v = Vec::new();
431        f(&mut v);
432        v
433    }
434
435    #[test]
436    fn digit_count_matches_decimal_width() {
437        for &n in NS {
438            assert_eq!(digit_count(n), n.to_string().len(), "digit_count({n})");
439        }
440    }
441
442    #[test]
443    fn cup_cost_round_trips() {
444        let coords = [
445            (0, 0),
446            (0, 5),
447            (5, 0),
448            (5, 7),
449            (99, 99),
450            (999, 9),
451            (10, 1000),
452        ];
453        for (r, c) in coords {
454            let bytes = write_to_vec(|v| write_cup(v, r, c).unwrap());
455            assert_eq!(
456                cup_cost(r, c),
457                bytes.len(),
458                "cup_cost({r},{c}) vs {bytes:?}"
459            );
460        }
461    }
462
463    /// Drive the optional-param shape through every parameterized
464    /// writer that consumes it. Each call asserts that the cost
465    /// helper agrees with the writer's actual output.
466    #[test]
467    fn optional_param_costs_round_trip() {
468        for &n in NS {
469            // `write_cuu` and friends are no-ops for `n == 0`; that
470            // is the writer's own contract and we don't predict a
471            // cost for the empty emission.
472            if n == 0 {
473                continue;
474            }
475
476            let pairs: &[(&str, usize, Vec<u8>)] = &[
477                (
478                    "cuu",
479                    cuu_cost(n),
480                    write_to_vec(|v| write_cuu(v, n).unwrap()),
481                ),
482                (
483                    "cud",
484                    cud_cost(n),
485                    write_to_vec(|v| write_cud(v, n).unwrap()),
486                ),
487                (
488                    "cuf",
489                    cuf_cost(n),
490                    write_to_vec(|v| write_cuf(v, n).unwrap()),
491                ),
492                (
493                    "cub",
494                    cub_cost(n),
495                    write_to_vec(|v| write_cub(v, n).unwrap()),
496                ),
497                (
498                    "cht",
499                    cht_cost(n),
500                    write_to_vec(|v| write_cht(v, n).unwrap()),
501                ),
502                (
503                    "cbt",
504                    cbt_cost(n),
505                    write_to_vec(|v| write_backtab(v, n).unwrap()),
506                ),
507                (
508                    "ich",
509                    ich_cost(n),
510                    write_to_vec(|v| write_ich(v, n).unwrap()),
511                ),
512                (
513                    "dch",
514                    dch_cost(n),
515                    write_to_vec(|v| write_dch(v, n).unwrap()),
516                ),
517                (
518                    "ech",
519                    ech_cost(n),
520                    write_to_vec(|v| write_ech(v, n).unwrap()),
521                ),
522                (
523                    "rep",
524                    rep_cost(n),
525                    write_to_vec(|v| write_rep(v, n).unwrap()),
526                ),
527                (
528                    "il",
529                    il_cost(n),
530                    write_to_vec(|v| write_insert_lines(v, n).unwrap()),
531                ),
532                (
533                    "dl",
534                    dl_cost(n),
535                    write_to_vec(|v| write_delete_lines(v, n).unwrap()),
536                ),
537                (
538                    "su",
539                    su_cost(n),
540                    write_to_vec(|v| write_scroll_up(v, n).unwrap()),
541                ),
542                (
543                    "sd",
544                    sd_cost(n),
545                    write_to_vec(|v| write_scroll_down(v, n).unwrap()),
546                ),
547            ];
548            for (label, cost, bytes) in pairs {
549                assert_eq!(*cost, bytes.len(), "{label}({n}) bytes={bytes:?}");
550            }
551        }
552    }
553
554    #[test]
555    fn required_param_costs_round_trip() {
556        // u16::MAX overflows the writers' `arg + 1`; cap at 9999 (5
557        // digits exercise the wide branch). The `digit_count` test
558        // above already covers u16::MAX.
559        for &col in NS.iter().filter(|&&n| n <= 9999) {
560            let cha = write_to_vec(|v| write_cha(v, col).unwrap());
561            assert_eq!(cha_cost(col), cha.len(), "cha_cost({col})");
562            let hpa = write_to_vec(|v| write_hpa(v, col).unwrap());
563            assert_eq!(hpa_cost(col), hpa.len(), "hpa_cost({col})");
564            let vpa = write_to_vec(|v| write_vpa(v, col).unwrap());
565            assert_eq!(vpa_cost(col), vpa.len(), "vpa_cost({col})");
566        }
567    }
568
569    #[test]
570    fn erase_costs_round_trip() {
571        for n in 0u16..=3 {
572            let el = write_to_vec(|v| write_el(v, n).unwrap());
573            assert_eq!(el_cost(n), el.len(), "el_cost({n})");
574            let ed = write_to_vec(|v| write_ed(v, n).unwrap());
575            assert_eq!(ed_cost(n), ed.len(), "ed_cost({n})");
576        }
577    }
578
579    #[test]
580    fn decstbm_cost_round_trips() {
581        let cases = [(0u16, 0u16), (0, 23), (4, 19), (99, 199), (999, 9999)];
582        for (top, bottom) in cases {
583            let bytes = write_to_vec(|v| write_scroll_region(v, top, bottom).unwrap());
584            assert_eq!(
585                decstbm_cost(top, bottom),
586                bytes.len(),
587                "decstbm({top},{bottom})"
588            );
589        }
590    }
591
592    #[test]
593    fn decstbm_reset_cost_matches_writer() {
594        let bytes = write_to_vec(|v| write_reset_scroll_region(v).unwrap());
595        assert_eq!(DECSTBM_RESET_COST, bytes.len());
596    }
597
598    #[test]
599    fn ri_cost_matches_writer() {
600        let bytes = write_to_vec(|v| write_reverse_index(v).unwrap());
601        assert_eq!(RI_COST, bytes.len());
602    }
603}