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}