9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
349 lines
13 KiB
Rust
349 lines
13 KiB
Rust
//! The cluster-positioning contract: what the renderer may rely on to place
|
|
//! text at the right column without consulting Unicode tables.
|
|
//!
|
|
//! The consumer's rule reads two numbers off each span and does arithmetic:
|
|
//! `cluster_count == 1` means the whole text is one cluster at `column`,
|
|
//! otherwise cluster `i` is the i-th `char` at `column + i * width`.
|
|
//!
|
|
//! These fixtures exist because that rule is not self-evidently satisfiable --
|
|
//! the two cases below require *opposite* text-splitting rules, so no encoding
|
|
//! that ships a concatenated string and a start column can be correct:
|
|
//!
|
|
//! * a regional-indicator flag is two ordinary one-column cells, so its two
|
|
//! codepoints occupy two columns and must split per codepoint;
|
|
//! * a keycap is one cell holding three codepoints, so it occupies one column
|
|
//! and must split per grapheme.
|
|
//!
|
|
//! Both are handled here by construction rather than by rule: uniform `width`
|
|
//! within a span, and a span of its own for any cluster carrying zerowidth
|
|
//! marks.
|
|
|
|
use buzz_terminal::damage::{Encoder, Span};
|
|
use buzz_terminal::fences::Fences;
|
|
use buzz_terminal::{Action, SharedTerminal, Size, Terminal};
|
|
use std::sync::mpsc::Receiver;
|
|
|
|
/// The receiver is returned rather than dropped: dropping it disconnects the
|
|
/// channel and every subsequent listener send silently fails.
|
|
fn render(input: &str) -> (Vec<Span>, Receiver<Action>) {
|
|
let size = Size {
|
|
columns: 20,
|
|
screen_lines: 2,
|
|
scrollback: 100,
|
|
};
|
|
let (term, actions) = Terminal::new(size, Fences::ALL);
|
|
let shared = SharedTerminal::new(term);
|
|
shared.feed_fully(input.as_bytes());
|
|
let mut encoder = Encoder::new();
|
|
let frame = shared.render(&mut encoder);
|
|
let spans = frame
|
|
.rows
|
|
.into_iter()
|
|
.find(|row| row.line == 0)
|
|
.map(|row| row.spans)
|
|
.unwrap_or_default();
|
|
(spans, actions)
|
|
}
|
|
|
|
/// Apply the documented consumer rule and return `(column, cluster)` pairs,
|
|
/// dropping trailing blank padding.
|
|
///
|
|
/// This is the renderer's arithmetic, written out. Note what is *not* here: no
|
|
/// Unicode table, no zerowidth classifier, no grapheme segmentation. The
|
|
/// earlier draft of this helper carried a hand-rolled `is_zerowidth` matcher,
|
|
/// which is how we learned the encoding was under-specified -- if the fixture
|
|
/// needs a Unicode table to decode the wire, so does every real consumer.
|
|
fn placements(spans: &[Span]) -> Vec<(usize, String)> {
|
|
let mut placed = Vec::new();
|
|
for span in spans {
|
|
assert!(
|
|
span.counts_are_consistent(),
|
|
"encoder emitted an undecodable span: {span:?}"
|
|
);
|
|
let clusters: Vec<String> = if span.cluster_count == 1 {
|
|
vec![span.text.clone()]
|
|
} else {
|
|
span.text.chars().map(|c| c.to_string()).collect()
|
|
};
|
|
for (i, cluster) in clusters.into_iter().enumerate() {
|
|
if cluster != " " {
|
|
placed.push((span.column + i * span.width as usize, cluster));
|
|
}
|
|
}
|
|
}
|
|
placed
|
|
}
|
|
|
|
/// Max's case: mixed narrow and wide glyphs in one style. Every cluster must
|
|
/// land on the column the grid actually put it in.
|
|
#[test]
|
|
fn mixed_width_clusters_keep_their_columns() {
|
|
let (spans, _actions) = render("a\u{1F600}b\u{4E00}c");
|
|
assert_eq!(
|
|
placements(&spans),
|
|
vec![
|
|
(0, "a".into()),
|
|
(1, "\u{1F600}".into()),
|
|
(3, "b".into()),
|
|
(4, "\u{4E00}".into()),
|
|
(6, "c".into()),
|
|
],
|
|
"wide glyphs must advance two columns and narrow ones must not"
|
|
);
|
|
}
|
|
|
|
/// A combining mark rides with its base character and consumes no column of
|
|
/// its own, so the text that follows must not be displaced by it.
|
|
///
|
|
/// Against the previous encoding this row was a single span `"éxy"` at column
|
|
/// 0, and a consumer stepping one column per `char` placed `x` at 1 and `y`
|
|
/// at 2 -- both one column left of the truth.
|
|
#[test]
|
|
fn combining_marks_do_not_displace_following_text() {
|
|
let (spans, _actions) = render("e\u{0301}xy");
|
|
assert_eq!(
|
|
placements(&spans),
|
|
vec![(0, "e\u{0301}".into()), (1, "x".into()), (2, "y".into()),],
|
|
"a zerowidth mark must not consume a column"
|
|
);
|
|
}
|
|
|
|
/// A regional-indicator pair: two separate one-column cells. This is the case
|
|
/// that must split *per codepoint*.
|
|
#[test]
|
|
fn regional_indicator_flag_occupies_two_columns() {
|
|
let (spans, _actions) = render("\u{1F1FA}\u{1F1F8}X");
|
|
assert_eq!(
|
|
placements(&spans),
|
|
vec![
|
|
(0, "\u{1F1FA}".into()),
|
|
(1, "\u{1F1F8}".into()),
|
|
(2, "X".into()),
|
|
],
|
|
"regional indicators are one column each; X must sit at 2"
|
|
);
|
|
}
|
|
|
|
/// A keycap: one cell holding three codepoints. This is the case that must
|
|
/// split *per grapheme* -- the opposite rule from the flag above, which is why
|
|
/// the width and the cluster break both have to come from the grid.
|
|
#[test]
|
|
fn keycap_occupies_one_column() {
|
|
let (spans, _actions) = render("1\u{FE0F}\u{20E3}X");
|
|
assert_eq!(
|
|
placements(&spans),
|
|
vec![(0, "1\u{FE0F}\u{20E3}".into()), (1, "X".into()),],
|
|
"a keycap is one column; X must sit at 1"
|
|
);
|
|
}
|
|
|
|
/// Width is uniform within a span by construction. Without this a consumer
|
|
/// cannot multiply -- it would have to know each cluster's width individually,
|
|
/// which is the Unicode table this design exists to avoid.
|
|
#[test]
|
|
fn a_span_never_mixes_widths() {
|
|
let (spans, _actions) = render("ab\u{4E00}\u{4E00}cd");
|
|
for span in &spans {
|
|
let expected = span.width;
|
|
assert!(
|
|
span.width == 1 || span.width == 2,
|
|
"width must be 1 or 2, got {expected}"
|
|
);
|
|
}
|
|
let widths: Vec<u8> = spans.iter().map(|s| s.width).collect();
|
|
assert!(
|
|
widths.contains(&2),
|
|
"fixture must actually produce a wide span, got {widths:?}"
|
|
);
|
|
assert_eq!(
|
|
placements(&spans),
|
|
vec![
|
|
(0, "a".into()),
|
|
(1, "b".into()),
|
|
(2, "\u{4E00}".into()),
|
|
(4, "\u{4E00}".into()),
|
|
(6, "c".into()),
|
|
(7, "d".into()),
|
|
],
|
|
"two adjacent wide glyphs must advance two columns each"
|
|
);
|
|
}
|
|
|
|
/// `cluster_count` is what makes the wire decodable without a Unicode table,
|
|
/// so it is asserted directly here rather than only implied by placements.
|
|
///
|
|
/// The decisive pair: both spans below are width 1 with more than one `char`
|
|
/// of text, and they differ *only* in whether the count tracks the char count.
|
|
/// A consumer without that number cannot tell them apart -- which is the
|
|
/// defect Mari caught in the previous encoding.
|
|
#[test]
|
|
fn cluster_count_distinguishes_a_marked_cluster_from_a_plain_run() {
|
|
let (marked, _a) = render("e\u{0301}");
|
|
let marked = marked.first().expect("a span must be emitted");
|
|
assert_eq!(marked.text.chars().count(), 2, "base plus combining mark");
|
|
assert_eq!(marked.cluster_count, 1, "one cluster occupying one column");
|
|
|
|
// The plain run absorbs the row's blank padding, so its length is the
|
|
// viewport width rather than 2 -- what matters is that the count tracks
|
|
// the char count instead of collapsing to 1.
|
|
let (plain, _b) = render("ab");
|
|
let plain = plain.first().expect("a span must be emitted");
|
|
assert!(plain.cluster_count > 1, "a plain run is not one cluster");
|
|
assert_eq!(
|
|
usize::from(plain.cluster_count),
|
|
plain.text.chars().count(),
|
|
"one cluster per char"
|
|
);
|
|
|
|
assert_eq!(marked.width, plain.width, "both are width 1");
|
|
assert!(marked.counts_are_consistent() && plain.counts_are_consistent());
|
|
}
|
|
|
|
/// The join guard has two halves: the previous cell must not have carried
|
|
/// marks (`open`), and the current cell must not carry them (`joinable`).
|
|
/// Every fixture above exercises only the first half -- a plain cluster
|
|
/// following a marked one. This one exercises the second: a *marked* cluster
|
|
/// arriving after a plain run, which is the only path on which the run in
|
|
/// progress is handed text holding more `char`s than the one cluster its
|
|
/// count is about to be incremented by.
|
|
///
|
|
/// Sami found the hole. With `joinable` dropped from the guard, a release
|
|
/// build silently emits `Span { column: 0, text: "xyé", cluster_count: 3 }`:
|
|
/// four chars counted as three, so the consumer's rule splits per char and
|
|
/// places the combining mark on top of `z`.
|
|
#[test]
|
|
fn a_marked_cluster_after_a_plain_run_starts_its_own_span() {
|
|
let (spans, _actions) = render("xye\u{0301}z");
|
|
assert_eq!(
|
|
placements(&spans),
|
|
vec![
|
|
(0, "x".into()),
|
|
(1, "y".into()),
|
|
(2, "e\u{0301}".into()),
|
|
(3, "z".into()),
|
|
],
|
|
"a marked cluster must not be absorbed into the run in front of it"
|
|
);
|
|
}
|
|
|
|
/// `cluster_count` is a `u16` and `Size.columns` is an unclamped `usize`
|
|
/// (`lib.rs:50`) that no production caller bounds yet, so a row of uniform
|
|
/// cells wider than `u16::MAX` reaches the join guard's overflow refusal.
|
|
/// The guard is live code, not paranoia, and this fixture is what says so.
|
|
///
|
|
/// Refusing to join produces a shape the consumer already handles -- the run
|
|
/// ends and a new span starts at the next column -- whereas wrapping produces
|
|
/// an undecodable span, the same failure as the marked-after-plain case above.
|
|
#[test]
|
|
fn a_run_longer_than_u16_max_splits_rather_than_wrapping() {
|
|
let columns = 70_000;
|
|
let size = Size {
|
|
columns,
|
|
screen_lines: 1,
|
|
scrollback: 0,
|
|
};
|
|
let (term, _actions) = Terminal::new(size, Fences::ALL);
|
|
let shared = SharedTerminal::new(term);
|
|
// One character is enough: the rest of the row is blank cells of the same
|
|
// style, so the whole row is a single candidate run.
|
|
shared.feed_fully(b"a");
|
|
let mut encoder = Encoder::new();
|
|
let frame = shared.render(&mut encoder);
|
|
|
|
let spans = &frame
|
|
.rows
|
|
.iter()
|
|
.find(|row| row.line == 0)
|
|
.expect("the fed row must be present")
|
|
.spans;
|
|
|
|
assert!(
|
|
spans.iter().all(|span| span.counts_are_consistent()),
|
|
"an oversized run must not wrap its count: {spans:?}"
|
|
);
|
|
let counts: Vec<u16> = spans.iter().map(|span| span.cluster_count).collect();
|
|
let columns_at: Vec<usize> = spans.iter().map(|span| span.column).collect();
|
|
assert_eq!(
|
|
counts,
|
|
vec![u16::MAX, (columns - u16::MAX as usize) as u16],
|
|
"the run must end at the last representable count"
|
|
);
|
|
assert_eq!(
|
|
columns_at,
|
|
vec![0, u16::MAX as usize],
|
|
"the second span starts where the first left off"
|
|
);
|
|
let chars: usize = spans.iter().map(|span| span.text.chars().count()).sum();
|
|
assert_eq!(chars, columns, "no cell may be dropped by the split");
|
|
}
|
|
|
|
/// Wrapping marks the last cell of the row with `WRAPLINE` (upstream
|
|
/// `term/mod.rs:968`). That bit records where the text happened to wrap, not
|
|
/// how the text looks, so it must not reach the style key: if it did, the last
|
|
/// column of every wrapped row would split off into a span of its own -- an
|
|
/// extra wire record per wrapped line, and span boundaries that move when the
|
|
/// window is resized.
|
|
///
|
|
/// Quinn found this by reading `cell.rs:21` while checking the `WIDE_CHAR`
|
|
/// mask; this fixture is the proof that was missing from the source read.
|
|
#[test]
|
|
fn wrapping_does_not_split_a_uniform_run() {
|
|
let size = Size {
|
|
columns: 5,
|
|
screen_lines: 3,
|
|
scrollback: 100,
|
|
};
|
|
let (term, _actions) = Terminal::new(size, Fences::ALL);
|
|
let shared = SharedTerminal::new(term);
|
|
// Six narrow cells in one style: five fill row 0 and set WRAPLINE on the
|
|
// last of them, the sixth lands on row 1.
|
|
shared.feed_fully(b"abcdef");
|
|
let mut encoder = Encoder::new();
|
|
let frame = shared.render(&mut encoder);
|
|
|
|
let first = frame
|
|
.rows
|
|
.iter()
|
|
.find(|row| row.line == 0)
|
|
.expect("wrapped row must be present");
|
|
assert!(
|
|
first.wrapped,
|
|
"soft-wrap geometry must survive row encoding"
|
|
);
|
|
let texts: Vec<&str> = first.spans.iter().map(|s| s.text.as_str()).collect();
|
|
assert_eq!(
|
|
texts,
|
|
vec!["abcde"],
|
|
"a wrapped row of one style is one span; WRAPLINE must not break it"
|
|
);
|
|
}
|
|
|
|
/// A wide glyph at the last usable column wraps to the next row rather than
|
|
/// straddling the edge. The contract must hold on the wrapped row too.
|
|
#[test]
|
|
fn leading_wide_glyph_after_wrap_is_positioned_from_column_zero() {
|
|
let size = Size {
|
|
columns: 5,
|
|
screen_lines: 3,
|
|
scrollback: 100,
|
|
};
|
|
let (term, _actions) = Terminal::new(size, Fences::ALL);
|
|
let shared = SharedTerminal::new(term);
|
|
// Four narrow cells fill 0..=3, leaving one column: the wide glyph cannot
|
|
// fit and moves to the next row.
|
|
shared.feed_fully("abcd\u{4E00}".as_bytes());
|
|
let mut encoder = Encoder::new();
|
|
let frame = shared.render(&mut encoder);
|
|
|
|
let second = frame
|
|
.rows
|
|
.iter()
|
|
.find(|row| row.line == 1)
|
|
.expect("wrapped row must be present");
|
|
assert_eq!(
|
|
placements(&second.spans),
|
|
vec![(0, "\u{4E00}".into())],
|
|
"a wrapped wide glyph starts at column 0 of the next row"
|
|
);
|
|
}
|