Files
benilla-octo/crates/benilla-ui/src/markup.rs
T
SamandClaude Fable 5 f768725708 a chat link is one token now, and tooltips print true costs
- a chat link edits as one atomic token: the caret can't land inside it, its colors survive, one keypress removes it whole
- spell tooltips print the resolved mana cost through the reference's own table walk, and the "% of base mana" line dies
- the spirit healer's confirm comes back after a cancel and wears its warning icon
- the dressing room's pane is a live animating model, and a tried-on bow lands in the hand

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:02:39 +07:00

1309 lines
58 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! WoW's inline text markup — the grammar every 1.12.1 string carries (`|cAARRGGBB`, `|r`,
//! `|H…|h…|h`, `|n`, `||`), and the cursor law an edit box builds on top of it.
//!
//! Pure: `&str` in, indices out. No Lua, no Bevy, no engine types, no I/O — so the renderer and the
//! edit box can share **one** owner of the grammar instead of drifting apart on where an escape
//! starts and ends.
//!
//! ## Ground truth
//!
//! wow-5875-re `system/ui/scratch/rf87-editbox-markup.md` (RF-0087, a §5 trio arbitrated against the
//! bytes). Two client mechanisms are transcribed here, and every claim below carries its address:
//!
//! - **`0x5c2810`** (`font`'s diffed `decode_quoted_code`) — the token decoder. Given a pointer it
//! returns a class 0..6 and the token's byte length, dispatching off a byte remap at `0x5c2b10`
//! into the 7-arm jump table at `0x5c2af4`. That is [`token_at`] / [`tokens`].
//! - **`0x77ba90`** — the edit box's per-byte class array `E+0x330`, rebuilt in full after every
//! mutation, plus the five primitives that read it (`0x77bd10`, `0x77bd30`, `0x77bc80`,
//! `0x77bb30`, `0x77c510`, `0x77bee0`). That is [`ClassMap`].
//!
//! ## The seven classes (§1.1) — the client's own numbering
//!
//! - **0** `|cAARRGGBB` — 10 bytes. The `AA` is parsed and then *discarded*: `0x5c2ab2–0x5c2ace`
//! builds `0xFF << 24 | RR << 16 | GG << 8 | BB`. See [`Rgba`].
//! - **1** `|r` / `|R` — 2 bytes.
//! - **2** `\n` · `\r` · `\r\n` · `|n` / `|N` — 1 or 2 bytes.
//! - **3** `||` — 2 bytes, draws one literal `|`.
//! - **4** hyperlink OPEN — the **entire** `|H<payload>|h` prefix, however long.
//! - **5** `|h` close — 2 bytes.
//! - **6** an ordinary character — its UTF-8 length. Everything the remap doesn't claim lands here,
//! *including* a `|` followed by anything outside `{c C h H n N r R |}`.
//!
//! **1.12.1 has no `|T…|t` inline-texture escape at all.** The remap table at `0x5c2b10` (index =
//! `ch - 'C'`) sends every byte but `C`/`H`/`N`/`R` and their lowercase siblings to the
//! ordinary-character arm, so `|TInterface\Icons\Foo:16:16|t` draws literally, pipe and all. Inline
//! textures are a later-expansion feature; this module must not invent one.
//!
//! ## What the parse gates do *not* do (§1.3)
//!
//! `0x5c2810` takes a flags word `K` that can disable classes, and `0x44d670` translates the
//! FontString's `F` into it. An origination census over the whole image found **no writer anywhere**
//! for the bits behind `K & 0x100` / `0x400` / `0x800`, so `|c`, `|r`, `|H`, `|h` and `||` are parsed
//! unconditionally by every `CSimpleFontString` in build 5875. Only `|n` is switchable (`F & 0x4000`,
//! set on a single-line edit box by `SetMultiLine` `0x77a5e2`) — and `0x77ba90` builds the class map
//! with `K = 0` regardless, so the *cursor* model always sees `|n` as one token. This module
//! therefore takes no flags word: the one live gate belongs to the renderer's line breaker, not to
//! the grammar (the resulting caret drift in a single-line box carrying `|n` is the note's §7
//! anomaly 1, and is the renderer's to reproduce or to fix).
//!
//! ## The law this module exists to hold
//!
//! **The reachable cursor set is "a token boundary with every adjacent zero-width escape absorbed" —
//! after any trailing `|r`/`|h`, before any leading `|c`/`|H`** (§6.1). No index inside an escape is
//! representable as an output of [`ClassMap::advance`], at either atomicity, in either direction;
//! atomicity adds exactly one thing, that the visible text between `|H…|h` and its closing `|h`
//! becomes uncrossable in a single step. Every index taken or returned by anything here is a byte
//! offset into the original string, and every one lands on a UTF-8 character boundary.
//!
//! ## Inferred, not verified
//!
//! Flagged here as well as at each site, because the note is explicit about what it proved:
//!
//! - **`|H` opens and `|h` closes, discriminated by case.** The remap sends both to one arm and the
//! note does not disassemble the case test; §1.1's table (class 4 is the whole `|H<payload>|h`,
//! class 5 is `|h`) plus the empty-visible-text guard comparing against lowercase `0x68`
//! (`80 7e 01 68` @`0x5c2992`) is the whole basis.
//! - **The delimiter search is a plain forward byte scan for `|h`** that does not skip `||`. The
//! emitter's own scan (`0x5ccdc8`, against the literal at `0x84453c`) is described the same way;
//! the decoder arm's scan was not disassembled.
//! - **`0x77bc80`'s second argument is a byte budget**, not a token count — from the `(start, count)`
//! signature and the byte-budget shape of its sibling kernel `0x5c6940`.
//! - **The end-of-buffer guard on [`ClassMap::advance`] is ours.** The note's excerpt of `0x77bb30`
//! shows no bound, and its class-0/class-4 "keep skipping" arms would spin on the zero terminator.
//! - **The worked example (§6.5) is itself INFERRED in the note**, from the verified mechanism.
use std::ops::Range;
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The colour a `|c` token carries
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// The colour decoded from `|cAARRGGBB`, packed exactly as the client packs it —
/// `0xFF << 24 | RR << 16 | GG << 8 | BB` (`0x5c2ab2–0x5c2ace`).
///
/// **The `AA` nibbles are parsed and then thrown away** (§7 anomaly 4): there is no way to spell a
/// translucent colour in 1.12.1 markup. A type that cannot carry alpha is how this module refuses to
/// invent one — `|c00ff0000` and `|cffff0000` are the same opaque red.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub struct Rgba(u32);
impl Rgba {
/// The opaque colour with these components — the only way to build one.
pub const fn from_rgb(r: u8, g: u8, b: u8) -> Rgba {
Rgba(0xff00_0000 | ((r as u32) << 16) | ((g as u32) << 8) | b as u32)
}
/// The client's packed dword, `0xFFRRGGBB`.
pub const fn packed(self) -> u32 {
self.0
}
/// Red.
pub const fn r(self) -> u8 {
(self.0 >> 16) as u8
}
/// Green.
pub const fn g(self) -> u8 {
(self.0 >> 8) as u8
}
/// Blue.
pub const fn b(self) -> u8 {
self.0 as u8
}
/// Alpha — always `0xff`. Kept as an accessor so the discard is visible at the call site.
pub const fn a(self) -> u8 {
0xff
}
/// `[r, g, b, alpha]` in 0..1 — the escape's colour drawn at the **FontString's own alpha**.
///
/// The decoder forcing `0xff` is only half the law: the emitter then patches the FontString's
/// alpha byte over it (`mov cl,[edi+0x2f]; mov [ebp-0x39],cl` @`0x5cceb0`/`0x5cceb6`, written
/// into the outColor slot *before* `mov edx,[ebp-0x3c]` reads it — RF-0087 §7, corrected under
/// an emulation oracle). So a `|c` span fades with the string it sits in; drawing it opaque
/// would leave a chat link burning at full alpha while its own line faded out.
///
/// Taking `alpha` as an argument rather than storing one keeps both halves true: the *escape*
/// still cannot carry alpha (`|c00ff0000` and `|cffff0000` are the same colour), and the alpha
/// that reaches the vertex is always the string's.
pub fn to_f32_at(self, alpha: f32) -> [f32; 4] {
[
self.r() as f32 / 255.0,
self.g() as f32 / 255.0,
self.b() as f32 / 255.0,
alpha,
]
}
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Layer 1 — the token decoder (`0x5c2810`)
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// The token class, with the client's own numbering (§1.1) as the discriminant, so a `class` in the
/// RE note greps straight to a variant here. Returned by `0x5c2810` in `eax`, and stored in bits
/// 16..23 of every class-map entry.
#[repr(u8)]
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum TokenClass {
/// 0 — `|cAARRGGBB`, 10 bytes.
Color = 0,
/// 1 — `|r` / `|R`, 2 bytes.
ColorReset = 1,
/// 2 — `\n`, `\r`, `\r\n`, `|n` / `|N`.
LineBreak = 2,
/// 3 — `||`, 2 bytes, drawing one literal `|`.
EscapedPipe = 3,
/// 4 — hyperlink OPEN: the entire `|H<payload>|h` prefix.
LinkOpen = 4,
/// 5 — `|h`, the close, 2 bytes.
LinkClose = 5,
/// 6 — an ordinary character, its UTF-8 length.
Char = 6,
}
/// What a token *is*, with whatever it carries.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TokenKind<'a> {
/// Class 0 — switch the draw colour. Opaque by construction; see [`Rgba`].
Color(Rgba),
/// Class 1 — back to the string's base colour (`0x5cce99` restores `FontString+0x2c`).
ColorReset,
/// Class 2 — a line break, whichever of the four spellings produced it.
LineBreak,
/// Class 3 — `||`, which draws one `|` (`0x5ccec3` looks up the glyph).
EscapedPipe,
/// Class 4 — open a hyperlink over the visible text that follows. `payload` is the text between
/// `|H` and the delimiting `|h` (`item:12345:0:0:0`, `player:Bob`).
LinkOpen {
/// The link payload, delimiters excluded.
payload: &'a str,
},
/// Class 5 — the closing `|h`.
LinkClose,
/// Class 6 — an ordinary character. A `|` that opened nothing well-formed arrives here too.
Char(char),
}
impl TokenKind<'_> {
/// The client's class number for this token.
pub const fn class(&self) -> TokenClass {
match self {
TokenKind::Color(_) => TokenClass::Color,
TokenKind::ColorReset => TokenClass::ColorReset,
TokenKind::LineBreak => TokenClass::LineBreak,
TokenKind::EscapedPipe => TokenClass::EscapedPipe,
TokenKind::LinkOpen { .. } => TokenClass::LinkOpen,
TokenKind::LinkClose => TokenClass::LinkClose,
TokenKind::Char(_) => TokenClass::Char,
}
}
}
/// One decoded token: what it is, and how many bytes of the source it spans.
///
/// The length is not derivable from the kind — `\r\n` and `\n` are both [`TokenKind::LineBreak`],
/// and a [`TokenKind::LinkOpen`] spans its whole `|H<payload>|h` prefix — so `0x5c2810` returns it
/// separately (`*ioLen`), and so do we.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Token<'a> {
/// What this token is.
pub kind: TokenKind<'a>,
/// Its byte length. Always ≥ 1.
pub byte_len: usize,
}
impl Token<'_> {
/// The client's class number for this token.
pub const fn class(&self) -> TokenClass {
self.kind.class()
}
}
/// Decode the token starting at byte offset `at` — the whole of `0x5c2810`.
///
/// `None` at (or past) the end of the string. `at` must be a UTF-8 character boundary of `text`;
/// every offset this module produces is one.
///
/// The degradations are law, not tolerance: a `|` that opens nothing well-formed is an *ordinary
/// character* of length 1, exactly as the remap table's fall-through arm makes it.
pub fn token_at(text: &str, at: usize) -> Option<Token<'_>> {
let rest = &text[at..];
let b = rest.as_bytes();
let first = *b.first()?;
// The two bare line breaks. `\r\n` is ONE class-2 token; a lone `\r` is one as well.
if first == b'\r' {
let byte_len = if b.get(1) == Some(&b'\n') { 2 } else { 1 };
return Some(Token {
kind: TokenKind::LineBreak,
byte_len,
});
}
if first == b'\n' {
return Some(Token {
kind: TokenKind::LineBreak,
byte_len: 1,
});
}
if first == b'|' {
// The ordinary-character fall-through: a lone `|`, or one leading anything the remap table
// at `0x5c2b10` does not claim.
let literal_pipe = Token {
kind: TokenKind::Char('|'),
byte_len: 1,
};
return Some(match b.get(1) {
Some(b'c' | b'C') => match parse_color(b) {
Some(rgba) => Token {
kind: TokenKind::Color(rgba),
byte_len: 10,
},
// Fewer than 8 hex digits: not a colour token, so the `|` is just a `|`.
None => literal_pipe,
},
Some(b'r' | b'R') => Token {
kind: TokenKind::ColorReset,
byte_len: 2,
},
Some(b'n' | b'N') => Token {
kind: TokenKind::LineBreak,
byte_len: 2,
},
Some(b'|') => Token {
kind: TokenKind::EscapedPipe,
byte_len: 2,
},
// INFERRED (see the module doc): uppercase opens, lowercase closes. The remap sends both
// to one arm and the note does not disassemble the case test.
Some(b'H') => match parse_link_open(rest) {
Some((payload, byte_len)) => Token {
kind: TokenKind::LinkOpen { payload },
byte_len,
},
None => literal_pipe,
},
Some(b'h') => Token {
kind: TokenKind::LinkClose,
byte_len: 2,
},
_ => literal_pipe,
});
}
let c = rest.chars().next().expect("non-empty");
Some(Token {
kind: TokenKind::Char(c),
byte_len: c.len_utf8(),
})
}
/// `b` starts with `|c` or `|C`. Parse the 8 following hex digits as `AARRGGBB` and **discard the
/// alpha** (`0x5c2ab2–0x5c2ace`). `None` when fewer than 8 hex digits follow — a malformed colour is
/// an ordinary character, not a swallowed escape.
fn parse_color(b: &[u8]) -> Option<Rgba> {
let digits = b.get(2..10)?;
let mut v: u32 = 0;
for &d in digits {
v = (v << 4) | (d as char).to_digit(16)?;
}
Some(Rgba::from_rgb((v >> 16) as u8, (v >> 8) as u8, v as u8))
}
/// `rest` starts with `|H`. Returns the payload and the byte length of the **whole**
/// `|H<payload>|h` prefix, or `None` for each of the three degradations that make the `|H` a literal
/// `|` instead:
///
/// - no closing `|h` anywhere after it — the forward scan finds no delimiter;
/// - the span is exactly 4 bytes, i.e. an empty payload (`83 f8 04; 75 1b` @`0x5c2972`);
/// - the visible text is empty, i.e. the span is immediately followed by another `|h`
/// (`80 f9 7c; 75 21; 80 7e 01 68; 75 1b` @`0x5c2992`).
///
/// The scan is a plain forward byte search that does not skip `||` (INFERRED — the emitter's own
/// scan at `0x5ccdc8` is described the same way). Searching bytes is safe in UTF-8: `|` and `h` are
/// ASCII and can never appear inside a multi-byte character.
fn parse_link_open(rest: &str) -> Option<(&str, usize)> {
let b = rest.as_bytes();
let delim = (2..b.len().saturating_sub(1)).find(|&i| b[i] == b'|' && b[i + 1] == b'h')?;
let span = delim + 2;
if span == 4 {
return None;
}
if b.get(span) == Some(&b'|') && b.get(span + 1) == Some(&b'h') {
return None;
}
Some((&rest[2..delim], span))
}
/// Every token of `text`, each with the byte offset it starts at — `char_indices`, one grammar up.
pub fn tokens(text: &str) -> Tokens<'_> {
Tokens { text, at: 0 }
}
/// The iterator [`tokens`] returns.
#[derive(Clone, Debug)]
pub struct Tokens<'a> {
text: &'a str,
at: usize,
}
impl<'a> Iterator for Tokens<'a> {
type Item = (usize, Token<'a>);
fn next(&mut self) -> Option<(usize, Token<'a>)> {
let token = token_at(self.text, self.at)?;
let at = self.at;
self.at += token.byte_len;
Some((at, token))
}
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Layer 2 — the per-byte class map (`E+0x330`, built by `0x77ba90`)
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// One entry of the class map — the client's packed dword (§2.1), unpacked:
///
/// - bits 0..15, the token's **byte length**, non-zero only at the token's *first* byte;
/// - bits 16..23, the token class;
/// - bit 31, **"inside a hyperlink"**.
///
/// Every continuation byte, and the terminator past the end, is the all-zero dword: `0x77ba90`
/// `rep stos`-zeroes the array and then stores only at each token's first byte
/// (`mov [classArr + 4*ebx], ecx` @`0x77bb0f`). So [`Entry::class`] is `None` exactly where
/// [`Entry::byte_len`] is 0, and [`Entry::in_link`] reads false on a continuation byte even inside
/// a link — which is the client's own reading (`0x77c510` tests the whole dword's sign bit), and
/// harmless, since no cursor can be at one.
///
/// The client's length field is 16 bits (`and len,0xffff` @`0x77baf3`); ours is wider, so a
/// pathological `|H` span ≥ 64 KiB simply does not wrap.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Entry {
byte_len: u32,
class: Option<TokenClass>,
in_link: bool,
}
impl Entry {
/// A continuation byte, or the terminator: the client's zeroed dword.
pub const CONTINUATION: Entry = Entry {
byte_len: 0,
class: None,
in_link: false,
};
/// The token's byte length — 0 at a continuation byte and at the terminator.
pub const fn byte_len(&self) -> usize {
self.byte_len as usize
}
/// The token class, or `None` at a continuation byte / the terminator.
pub const fn class(&self) -> Option<TokenClass> {
self.class
}
/// Bit 31 — is this token part of a hyperlink? Raised on the class-4 open **inclusive**, lowered
/// only *after* the class-5 close, so the close is tagged too.
pub const fn in_link(&self) -> bool {
self.in_link
}
/// Does a token start here? (Equivalently: is this not a continuation byte?)
pub const fn is_token_start(&self) -> bool {
self.byte_len != 0
}
}
/// The per-byte token-class array the edit box keeps at `E+0x330`, and the cursor primitives that
/// read it.
///
/// One entry per byte of the source string plus a terminating entry
/// (`classArr[len] = 0` @`0x77c670`), so an index equal to the string length is always valid — the
/// cursor's home at the end of the buffer.
///
/// It holds no text: every primitive here reads only the entries, which is what lets an edit box own
/// its `String` and rebuild the map beside it after each mutation, exactly as `0x77ba90` is called
/// at the tail of every insert (`0x77c022`).
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct ClassMap {
entries: Vec<Entry>,
}
impl ClassMap {
/// Build the map for `text` — `0x77ba90`.
///
/// The client passes `K = 0` here (`push 0` @`0x77bad9`), so the cursor model always parses
/// *every* class, `|n` included, whatever the FontString's flags say (§2.1, and §7 anomaly 1 for
/// the consequence).
pub fn new(text: &str) -> ClassMap {
let mut entries = vec![Entry::CONTINUATION; text.len() + 1];
// The sticky link bit: raised BEFORE the store on a class-4 open (`or [ebp-4],0x80000000`
// @`0x77baec`), lowered AFTER the store on a class-5 close (`and [ebp-4],0x7fffffff`
// @`0x77bb1e`) — which is why the closing `|h` is tagged and the byte after it is not.
let mut in_link = false;
for (at, token) in tokens(text) {
let class = token.class();
if class == TokenClass::LinkOpen {
in_link = true;
}
entries[at] = Entry {
byte_len: token.byte_len as u32,
class: Some(class),
in_link,
};
if class == TokenClass::LinkClose {
in_link = false;
}
}
ClassMap { entries }
}
/// The byte length of the string this map was built from.
pub fn text_len(&self) -> usize {
self.entries.len() - 1
}
/// The entry for byte `at`. `at == text_len()` is the terminator; anything past it panics.
pub fn entry(&self, at: usize) -> Entry {
self.entries[at]
}
/// The next token's start — `0x77bd10`, which is `i + (classArr[i] & 0xffff)` and nothing else.
///
/// A fixed point at the terminator and at any continuation byte, where the length is 0; the
/// client's add is unguarded in the same way, so a forward walk tests `at < text_len()` rather
/// than relying on this to move.
pub fn next_token(&self, at: usize) -> usize {
at + self.entry(at).byte_len()
}
/// The previous token's start — `0x77bd30`: walk back from `at - 1` while the entry is a
/// continuation. `None` when there is none (the client's `-1`), i.e. at offset 0.
pub fn prev_token(&self, at: usize) -> Option<usize> {
let mut i = at.checked_sub(1)?;
while !self.entry(i).is_token_start() {
i = i.checked_sub(1)?;
}
Some(i)
}
/// How many **letters** the `byte_count` bytes from `start` hold — `0x77bc80`, which counts
/// classes 2, 3 and 6 only (`83 fa 02/03/06` @`0x77bcb0`).
///
/// This is what `GetNumLetters` reports and what `SetMaxLetters` caps, so **classes 0/1/4/5 are
/// not letters**: a 48-byte item link costs 14, its visible `[Chipped Claw]` and nothing more.
///
/// INFERRED: that the second argument is a byte budget (the note gives only the `(start, count)`
/// signature; the sibling kernel `0x5c6940` consumes bytes the same way). A token straddling the
/// end of the budget is counted whole and ends the walk — deliberately *not* the sibling
/// kernel's run-to-the-NUL behaviour (§7 anomaly 3), which is a latent trap with no upside here.
pub fn letters(&self, start: usize, byte_count: usize) -> usize {
let end = start.saturating_add(byte_count).min(self.text_len());
let mut at = start;
let mut letters = 0;
while at < end {
let entry = self.entry(at);
let Some(class) = entry.class() else { break };
if matches!(
class,
TokenClass::LineBreak | TokenClass::EscapedPipe | TokenClass::Char
) {
letters += 1;
}
at += entry.byte_len();
}
letters
}
/// The whole string's letter count — `GetNumLetters`.
pub fn num_letters(&self) -> usize {
self.letters(0, self.text_len())
}
/// Move the cursor `steps` token-steps from `from` — `0x77bb30`, the heart of the cursor model
/// (§6.1). Negative `steps` walk backward. Returns the resulting byte offset (the client returns
/// the *magnitude* of the delta and its caller `0x77c6b0` signs it; same thing, one fewer trap).
///
/// `atomic_links` is the whole of the difference between the keyboard and the mouse (§6.2): every
/// arrow, word-jump, HOME/END, BACKSPACE and DELETE path passes 1 and crosses a whole
/// `|H…|h[text]|h` in one step, while click, drag, UP/DOWN, the scroll-window sizing and the IME
/// span all pass 0 and stop on each visible character. Neither ever stops *inside* an escape.
///
/// **The end-of-buffer guard is a deliberate divergence: the real client HANGS here.** Its inner
/// skip loop re-enters at `0x77bb60`, past the zero guard at `0x77bb56`, and the terminator slot
/// decodes as class 0 with length 0 — a class the loop skips unconditionally — so a walk still
/// skipping at the end of the buffer spins forever consuming no bytes. Confirmed by executing
/// `0x77bb30` under an emulation oracle (RF-0087 §10). It is reachable: a trailing `|c` hangs in
/// both modes and an unclosed link hangs at `atomic_links = true`, so `SetText("|cffffffff")`
/// plus one RIGHT freezes 1.12.1. A user cannot type it (`|` becomes `||`) but any script can
/// set it. We bound the loop by the buffer and land the step on the far end instead —
/// reproducing a freeze is not fidelity.
pub fn advance(&self, from: usize, steps: isize, atomic_links: bool) -> usize {
let mut at = from;
for _ in 0..steps.unsigned_abs() {
at = if steps > 0 {
self.step_forward(at, atomic_links)
} else {
self.step_back(at, atomic_links)
};
}
at
}
/// One step forward — the inner loop at `0x77bb60`, then the post-step absorb at `0x77bb98`.
fn step_forward(&self, from: usize, atomic_links: bool) -> usize {
let end = self.text_len();
let mut at = from;
while at < end {
let entry = self.entry(at);
let Some(class) = entry.class() else { break };
at += entry.byte_len();
// `|c` and `|H…|h` are skipped whatever the atomicity: a cursor never sits after a
// leading escape (`74 da` @`0x77bb84`, `74 d5` @`0x77bb86`).
if matches!(class, TokenClass::Color | TokenClass::LinkOpen) {
continue;
}
if !atomic_links {
break; // `84 db; 74 09` — one visible token consumed
}
if !entry.in_link() {
break; // `85 d2; 79 05` — bit 31 clear
}
if class == TokenClass::LinkClose {
break; // the close is what ends the atomic skip
}
// Inside a link and not the close: keep skipping (`83 ff 05; 75 c8`) — the atomicity.
}
// Then absorb any immediately following `|r` / `|h`, so the cursor lands past them.
while at < end
&& matches!(
self.entry(at).class(),
Some(TokenClass::ColorReset | TokenClass::LinkClose)
)
{
at = self.next_token(at);
}
at
}
/// One step backward — the mirror at `0x77bc13` / `0x77bc1d` / `0x77bc59`.
fn step_back(&self, from: usize, atomic_links: bool) -> usize {
let mut at = from;
// Trailing `|r` / `|h` first (`83 fa 01; 74 ca` / `83 fa 05; 74 c5`).
while let Some(prev) = self.prev_token(at) {
match self.entry(prev).class() {
Some(TokenClass::ColorReset | TokenClass::LinkClose) => at = prev,
_ => break,
}
}
// One token back — extended, when atomic, over the whole link to its class-4 open
// (`8a 5d 10; 84 db; 74 09; 85 f6; 79 05; 83 fa 04; 75 b5`).
while let Some(prev) = self.prev_token(at) {
let entry = self.entry(prev);
at = prev;
if !atomic_links || !entry.in_link() || entry.class() == Some(TokenClass::LinkOpen) {
break;
}
}
// Then back over any preceding `|c` / `|H` (`85 f6; 74 05; 83 fe 04; 75 08`).
while let Some(prev) = self.prev_token(at) {
match self.entry(prev).class() {
Some(TokenClass::Color | TokenClass::LinkOpen) => at = prev,
_ => break,
}
}
at
}
/// Widen a half-open deletion range so it cannot cut a hyperlink in half — `0x77c510` (§6.3),
/// called by BACKSPACE/DELETE (`0x77c280`), by delete-selection (`0x77cd70`) and by Clear
/// (`0x77c500`).
///
/// If `start` is inside a link, walk back to the class-4 open and then over the whole preceding
/// `|c`/`|H` run; if `end` is inside a link, walk forward past the closing `|h` and any following
/// `|r`. So **any deletion touching any byte of a link removes the whole
/// `|c…|H…|h[text]|h|r` unit** — a second, independent guarantee on top of the atomic walk, and
/// what makes even a mouse-drag partial selection delete cleanly.
///
/// Two faithfully-kept edges. The endpoint tests read `classArr[start]` / `classArr[end]`
/// directly (`83 3c 96 00; 79 50` @`0x77c543`, `83 3c 86 00; 79 55` @`0x77c59d`), so an *end*
/// that merely abuts a link's first byte — the byte it does not include — still widens; and the
/// backward walk stops on class 0 as well as class 4 (both `jz`s @`0x77c550` land on the same
/// target), so a `|c` sitting *inside* a link's visible text ends it early. 1.12 content puts the
/// colour outside the link (`0x52adb0`'s `"%s|Hitem:%d:%d:%d:%d|h[%s]|h%s"`), so the second edge
/// is unreachable in practice; it is recorded rather than smoothed away.
pub fn snap_delete_range(&self, range: Range<usize>) -> Range<usize> {
let Range {
start: mut lo,
end: mut hi,
} = range;
if self.entry(lo).in_link() {
while !matches!(
self.entry(lo).class(),
Some(TokenClass::Color | TokenClass::LinkOpen)
) {
match self.prev_token(lo) {
Some(prev) => lo = prev,
None => break,
}
}
while let Some(prev) = self.prev_token(lo) {
match self.entry(prev).class() {
Some(TokenClass::Color | TokenClass::LinkOpen) => lo = prev,
_ => break,
}
}
}
if self.entry(hi).in_link() {
while hi < self.text_len() && self.entry(hi).class() != Some(TokenClass::LinkClose) {
hi = self.next_token(hi);
}
while hi < self.text_len()
&& matches!(
self.entry(hi).class(),
Some(TokenClass::ColorReset | TokenClass::LinkClose)
)
{
hi = self.next_token(hi);
}
}
lo..hi
}
/// May text be inserted at `at`? — the opening guard of `0x77bee0` (§6.4).
///
/// Refused exactly when the entry at the cursor carries bit 31 **and** the previous token's entry
/// carries it too (`79 16` @`0x77befb`, `0f 88 …` @`0x77bf0d`). That second test is what still
/// permits an insert *at* a link's leading edge, where the `|H` entry has the bit but its
/// predecessor — the `|c`, or whatever precedes it — does not.
///
/// A strictly-interior cursor is reachable only by mouse (§5, `atomic_links = 0`), and the client
/// then silently swallows the typing; INFERRED in the note, from the verified guard.
pub fn insert_allowed(&self, at: usize) -> bool {
if !self.entry(at).in_link() {
return true;
}
match self.prev_token(at) {
// `85 c0; 7e 12` — cursor <= 0 proceeds.
None => true,
Some(prev) => !self.entry(prev).in_link(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The note's §6.5 buffer: an epic item link exactly as `0x52adb0` formats one.
///
/// ```text
/// 0 10 30 44 46
/// |cffa335ee|Hitem:12345:0:0:0|h[Chipped Claw]|h|r
/// \--10---/ \-------20-------/ \-----14-----/ \2/\2/
/// ```
const LINK: &str = "|cffa335ee|Hitem:12345:0:0:0|h[Chipped Claw]|h|r";
/// The same link with ordinary text on both sides — every offset above shifted by 2.
const AROUND: &str = "ab|cffa335ee|Hitem:12345:0:0:0|h[Chipped Claw]|h|rcd";
fn kinds(text: &str) -> Vec<(usize, TokenKind<'_>, usize)> {
tokens(text)
.map(|(at, t)| (at, t.kind, t.byte_len))
.collect()
}
// ── Layer 1: the decoder ─────────────────────────────────────────────────────────────────
fn one(text: &str) -> Token<'_> {
token_at(text, 0).expect("a token")
}
#[test]
fn each_of_the_seven_classes_is_recognised_at_its_own_byte_length() {
let c = one("|cffa335ee");
assert_eq!(c.class(), TokenClass::Color);
assert_eq!(c.byte_len, 10);
assert_eq!(c.kind, TokenKind::Color(Rgba::from_rgb(0xa3, 0x35, 0xee)));
for reset in ["|r", "|R"] {
assert_eq!(one(reset).kind, TokenKind::ColorReset);
assert_eq!(one(reset).byte_len, 2);
}
// All four spellings of class 2, and `\r\n` as ONE token rather than two.
for (text, len) in [("\n", 1), ("\r", 1), ("\r\n", 2), ("|n", 2), ("|N", 2)] {
assert_eq!(one(text).kind, TokenKind::LineBreak, "{text:?}");
assert_eq!(one(text).byte_len, len, "{text:?}");
}
assert_eq!(one("||").kind, TokenKind::EscapedPipe);
assert_eq!(one("||").byte_len, 2);
let open = one("|Hitem:12345:0:0:0|h[Chipped Claw]|h");
assert_eq!(
open.kind,
TokenKind::LinkOpen {
payload: "item:12345:0:0:0"
}
);
assert_eq!(
open.byte_len, 20,
"the whole |H<payload>|h prefix, not just |H"
);
assert_eq!(one("|h").kind, TokenKind::LinkClose);
assert_eq!(one("|h").byte_len, 2);
assert_eq!(one("a").kind, TokenKind::Char('a'));
assert_eq!(one("a").byte_len, 1);
assert_eq!(one("é").kind, TokenKind::Char('é'));
assert_eq!(one("é").byte_len, 2);
assert_eq!(one("✚").byte_len, 3);
assert_eq!(token_at("", 0), None);
assert_eq!(token_at("ab", 2), None);
}
#[test]
fn a_pipe_leading_anything_unclaimed_is_an_ordinary_character() {
// The remap table at 0x5c2b10 claims only C/H/N/R and their lowercase siblings.
for text in ["|x", "|", "| ", "|1"] {
let t = token_at(text, 0).expect("a token");
assert_eq!(t.kind, TokenKind::Char('|'), "{text:?}");
assert_eq!(t.byte_len, 1, "{text:?}");
}
}
#[test]
fn there_is_no_inline_texture_escape_in_1_12_1() {
// `|T…|t` is a later-expansion feature. Here every byte of it is an ordinary character —
// the pipes draw, and a client that stripped the run would be inventing a 2.x escape.
let text = "|TInterface\\Icons\\Foo:16:16|t";
let first = token_at(text, 0).expect("a token");
assert_eq!(first.kind, TokenKind::Char('|'));
assert_eq!(first.byte_len, 1);
assert_eq!(
token_at(text, 1).expect("a token").kind,
TokenKind::Char('T')
);
// Nothing in the whole run is anything but an ordinary character.
assert!(tokens(text).all(|(_, t)| t.class() == TokenClass::Char));
}
#[test]
fn a_malformed_colour_escape_is_an_ordinary_character() {
for text in ["|cffzz0000", "|cff", "|c", "|cffff000"] {
let t = token_at(text, 0).expect("a token");
assert_eq!(t.kind, TokenKind::Char('|'), "{text:?}");
assert_eq!(t.byte_len, 1, "{text:?}");
}
}
#[test]
fn a_colour_escapes_alpha_is_parsed_and_then_discarded() {
let transparent = token_at("|c00ff0000", 0).expect("a token").kind;
let opaque = token_at("|cffff0000", 0).expect("a token").kind;
assert_eq!(
transparent, opaque,
"the AA nibbles cannot reach the colour"
);
assert_eq!(transparent, TokenKind::Color(Rgba::from_rgb(0xff, 0, 0)));
let TokenKind::Color(rgba) = transparent else {
panic!("a colour")
};
assert_eq!(rgba.a(), 0xff);
assert_eq!(rgba.packed(), 0xffff_0000);
// The alpha that reaches the vertex is the STRING's, never the escape's: `|c00ff0000`
// inside a half-faded FontString draws at 0.5, not 0.0 and not 1.0 (`0x5cceb0`).
assert_eq!(rgba.to_f32_at(1.0), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(rgba.to_f32_at(0.5), [1.0, 0.0, 0.0, 0.5]);
}
#[test]
fn a_link_open_degrades_to_a_literal_pipe_in_the_three_cases() {
let degraded = |text: &str| {
let t = token_at(text, 0).expect("a token");
assert_eq!(t.kind, TokenKind::Char('|'), "{text:?}");
assert_eq!(t.byte_len, 1, "{text:?}");
};
// 1. No closing `|h` anywhere after it.
degraded("|Hitem:12345:0:0:0[Chipped Claw]");
degraded("|H");
// 2. The span is exactly 4 bytes — an empty payload (0x5c2972).
degraded("|H|h[Chipped Claw]|h");
// 3. The visible text is empty — another `|h` immediately follows the span (0x5c2992).
degraded("|Hitem:12345:0:0:0|h|h");
// And the near-misses that must still parse: one visible character is enough, and an
// unterminated *link* (delimiter present, no close) still opens — the decoder is stateless.
assert_eq!(
token_at("|Hitem:1|h[|h", 0).expect("a token").kind,
TokenKind::LinkOpen { payload: "item:1" }
);
assert_eq!(
token_at("|Hitem:1|h[Chipped Claw]", 0)
.expect("a token")
.kind,
TokenKind::LinkOpen { payload: "item:1" }
);
}
#[test]
fn the_token_stream_covers_the_whole_string_exactly_once() {
let mut next = 0;
for (at, token) in tokens(AROUND) {
assert_eq!(at, next, "no gap, no overlap");
assert!(token.byte_len >= 1);
assert!(AROUND.is_char_boundary(at));
next = at + token.byte_len;
}
assert_eq!(next, AROUND.len());
// The link buffer's shape, spelled out — every index the rest of these tests cite.
let ks = kinds(LINK);
assert_eq!(ks[0].0, 0);
assert_eq!(ks[0].2, 10);
assert_eq!(
ks[1],
(
10,
TokenKind::LinkOpen {
payload: "item:12345:0:0:0"
},
20
)
);
assert_eq!(ks[2], (30, TokenKind::Char('['), 1));
assert_eq!(ks[16], (44, TokenKind::LinkClose, 2));
assert_eq!(ks[17], (46, TokenKind::ColorReset, 2));
assert_eq!(ks.len(), 18);
assert_eq!(LINK.len(), 48);
}
// ── Layer 2: the class map ───────────────────────────────────────────────────────────────
#[test]
fn the_link_bit_covers_the_open_through_the_closing_pipe_h_and_stops_there() {
let map = ClassMap::new(LINK);
for at in 0..=map.text_len() {
let entry = map.entry(at);
// Raised on the class-4 open (10) inclusive, lowered only after the class-5 close, whose
// own entry (44) is therefore still tagged. The `|r` at 46 is not.
let expected = entry.is_token_start() && (10..=44).contains(&at);
assert_eq!(entry.in_link(), expected, "bit 31 at {at}");
}
assert!(map.entry(10).in_link(), "the |H open itself");
assert!(map.entry(44).in_link(), "the closing |h");
assert!(!map.entry(46).in_link(), "the trailing |r is outside");
assert!(!map.entry(0).in_link(), "the leading |c is outside");
assert!(!map.entry(48).in_link(), "the terminator");
}
#[test]
fn a_continuation_byte_and_the_terminator_are_the_zero_entry() {
let map = ClassMap::new(LINK);
// Every byte inside the |H…|h span but its first.
for at in 11..30 {
assert_eq!(map.entry(at), Entry::CONTINUATION, "byte {at}");
assert_eq!(map.entry(at).class(), None);
assert!(!map.entry(at).is_token_start());
}
assert_eq!(map.entry(map.text_len()), Entry::CONTINUATION);
assert_eq!(ClassMap::new("").text_len(), 0);
assert_eq!(ClassMap::new("").entry(0), Entry::CONTINUATION);
}
#[test]
fn next_and_prev_token_walk_whole_tokens() {
let map = ClassMap::new(LINK);
assert_eq!(map.next_token(0), 10);
assert_eq!(map.next_token(10), 30);
assert_eq!(map.next_token(44), 46);
assert_eq!(map.next_token(46), 48);
// A fixed point at the terminator, exactly like the client's unguarded add.
assert_eq!(map.next_token(48), 48);
assert_eq!(map.prev_token(48), Some(46));
assert_eq!(map.prev_token(46), Some(44));
assert_eq!(map.prev_token(30), Some(10));
// From anywhere inside the |H span's continuation bytes, back to its start.
assert_eq!(map.prev_token(20), Some(10));
assert_eq!(map.prev_token(10), Some(0));
assert_eq!(map.prev_token(0), None);
}
#[test]
fn letters_counts_classes_two_three_and_six_and_nothing_else() {
// The whole 48-byte link is 14 letters: `[Chipped Claw]`, the escapes free.
let map = ClassMap::new(LINK);
assert_eq!(map.num_letters(), 14);
assert_eq!(map.letters(0, 10), 0, "the |c alone");
assert_eq!(map.letters(0, 30), 0, "the |c and the |H…|h");
assert_eq!(map.letters(30, 14), 14, "the visible text alone");
assert_eq!(map.letters(44, 4), 0, "the |h|r tail");
// A line break and an escaped pipe are each ONE letter; `\r\n` is one, not two.
let map = ClassMap::new("a||b\r\nc|nd");
assert_eq!(map.num_letters(), 7);
assert_eq!(ClassMap::new(AROUND).num_letters(), 18, "14 + `ab` + `cd`");
assert_eq!(ClassMap::new("").num_letters(), 0);
}
// ── The cursor law ───────────────────────────────────────────────────────────────────────
/// The note's §6.5, spelled out: cursor 0, one RIGHT, and the whole link is behind you.
/// **The oracle cross-check.** wow-re answered RF-0087 §10 by *executing* the real `0x77bb30`
/// under Unicorn on this exact buffer and reporting the reachable index sets. These are its
/// numbers, verbatim — the strongest evidence this module can carry, because they came from the
/// binary running rather than from anyone reading it.
///
/// Three things they pin that a reading could plausibly have got backwards: index **0 is**
/// reachable (the leading `|c`+`|H…|h` absorb into the first *step*, not off the origin); index
/// **30 is reachable in neither mode** — classes 0 and 4 are skipped unconditionally, so a step
/// arriving from the left consumes the `[` in the same step, and the cursor can never sit
/// between `|h` and `[`; and after the name the stop is **43 in both modes**, never 39 or 41,
/// because the trailing `|h|r` absorb **forward** rather than backward onto the `]`.
#[test]
fn the_reachable_sets_match_the_emulation_oracle() {
// `[` at 30, `]` at 38, the `d` of the typed text at 43; 51 bytes.
const BUF: &str = "|cffa335ee|Hitem:11684:0:0:0|h[Ironfoe]|h|rdsfsdfsd";
let map = ClassMap::new(BUF);
for (atomic, expected) in [
(true, vec![0, 43, 44, 45, 46, 47, 48, 49, 50, 51]),
(
false,
vec![
0, 31, 32, 33, 34, 35, 36, 37, 38, 43, 44, 45, 46, 47, 48, 49, 50, 51,
],
),
] {
let mut forward = vec![0usize];
let mut at = 0;
while at < BUF.len() {
let next = map.advance(at, 1, atomic);
assert!(next > at, "forward walk stalled at {at} (atomic={atomic})");
forward.push(next);
at = next;
}
assert_eq!(forward, expected, "forward, atomic={atomic}");
// Backward visits the identical set and returns exactly to 0.
let mut back = vec![BUF.len()];
let mut at = BUF.len();
while at > 0 {
let prev = map.advance(at, -1, atomic);
assert!(prev < at, "backward walk stalled at {at} (atomic={atomic})");
back.push(prev);
at = prev;
}
back.reverse();
assert_eq!(back, expected, "backward, atomic={atomic}");
}
// The refusal predicate, over every offset: exactly 30..=39 — the display text through the
// FIRST byte of the closing `|h`. The link's own boundaries accept (10, the `|H` slot, and
// 41, the `|r`), and so does 40, a token-interior byte whose slot is zero.
let refused: Vec<usize> = (0..=BUF.len())
.filter(|&i| !map.insert_allowed(i))
.collect();
assert_eq!(refused, (30..=39).collect::<Vec<_>>());
}
/// The client freezes where we stop (RF-0087 §10): its inner skip loop re-enters past the zero
/// guard, and the terminator decodes as a skip class, so a buffer whose last token is `|c` — or
/// an unclosed link, when atomic — spins forever. `SetText("|cffffffff")` plus one RIGHT hangs
/// 1.12.1. We land on the far end instead; reproducing a freeze is not fidelity.
#[test]
fn a_trailing_escape_lands_on_the_end_where_the_client_would_hang() {
// Each entry is (buffer, the offset where only skip-class tokens remain, whether the
// non-atomic walk hangs there too — an unclosed link only traps the atomic one).
for (buf, from, both_modes) in [
("|cffffffff", 0, true),
("ab|cffffffff", 2, true),
("|Hitem:1|h[Unclosed", 0, false),
] {
let map = ClassMap::new(buf);
assert_eq!(map.advance(from, 1, true), buf.len(), "{buf:?} atomic");
if both_modes {
assert_eq!(map.advance(from, 1, false), buf.len(), "{buf:?} non-atomic");
}
}
}
#[test]
fn one_atomic_step_crosses_the_entire_item_link() {
let map = ClassMap::new(LINK);
assert_eq!(
map.advance(0, 1, true),
48,
"past the whole link, not into it"
);
assert_eq!(map.advance(48, -1, true), 0, "and back again");
// Not ~50 presses: the visible text is 14 characters and the buffer is 48 bytes, yet the
// atomic walk has exactly two stops.
assert_eq!(map.advance(0, 5, true), 48, "clamped at the end");
assert_eq!(map.advance(48, -5, true), 0);
// With text on both sides, the link is still one step, and the plain text is not.
let map = ClassMap::new(AROUND);
assert_eq!(map.advance(0, 1, true), 1);
assert_eq!(map.advance(1, 1, true), 2);
assert_eq!(map.advance(2, 1, true), 50, "the whole |c…|H…|h|r unit");
assert_eq!(map.advance(50, 1, true), 51);
assert_eq!(map.advance(52, -1, true), 51);
assert_eq!(map.advance(50, -1, true), 2, "back over the whole unit");
}
/// The mouse path (`atomic_links = 0`, `0x77d2f6`): every visible character is its own stop, and
/// no index inside `|cffa335ee`, `|Hitem:…|h`, `|h` or `|r` ever is.
#[test]
fn a_non_atomic_walk_stops_on_each_visible_character_and_never_inside_an_escape() {
let map = ClassMap::new(LINK);
let mut at = 0;
let mut stops = vec![at];
while at < map.text_len() {
let next = map.advance(at, 1, false);
assert_ne!(next, at, "the walk must make progress");
at = next;
stops.push(at);
}
// After `[`, after each of the 12 letters of `Chipped Claw`, then straight past `]|h|r`.
let expected: Vec<usize> = std::iter::once(0).chain(31..=43).chain([48]).collect();
assert_eq!(stops, expected);
// And the same set walking back.
let mut at = map.text_len();
let mut back = vec![at];
while at > 0 {
at = map.advance(at, -1, false);
back.push(at);
}
back.reverse();
assert_eq!(back, expected);
// Spelled out: no stop lands inside any escape's byte span.
for &stop in &stops {
assert!(
!(1..10).contains(&stop)
&& !(11..30).contains(&stop)
&& stop != 45
&& stop != 47
&& stop != 10
&& stop != 30
&& stop != 44
&& stop != 46,
"{stop} is inside an escape"
);
}
}
/// The canonical reachable set of §6.1: **a token boundary with every adjacent zero-width escape
/// absorbed — after any trailing `|r`/`|h`, before any leading `|c`/`|H`.**
fn reachable_set(text: &str) -> Vec<usize> {
let map = ClassMap::new(text);
(0..=map.text_len())
.filter(|&at| {
let entry = map.entry(at);
if !(at == map.text_len() || entry.is_token_start()) {
return false; // not a token boundary at all
}
// A trailing `|r`/`|h` must have been absorbed, so you are never before one.
if matches!(
entry.class(),
Some(TokenClass::ColorReset | TokenClass::LinkClose)
) {
return false;
}
// A leading `|c`/`|H` must have been skipped, so you are never after one.
!matches!(
map.prev_token(at).map(|p| map.entry(p).class()),
Some(Some(TokenClass::Color | TokenClass::LinkOpen))
)
})
.collect()
}
/// The property sweep: whatever sequence of steps you take, in either direction, at either
/// atomicity, you can only ever land in the set above — so no index inside an escape is
/// representable as a cursor position. And non-atomically the whole set is reachable.
#[test]
fn every_index_any_walk_can_reach_is_a_boundary_with_its_escapes_absorbed() {
for text in [LINK, AROUND] {
let map = ClassMap::new(text);
let canonical = reachable_set(text);
let mut seen = vec![0, map.text_len()];
let mut frontier = seen.clone();
while let Some(at) = frontier.pop() {
for atomic in [false, true] {
for step in [-1, 1] {
let to = map.advance(at, step, atomic);
assert!(
text.is_char_boundary(to),
"{text:?}: {at} -{step}/{atomic} landed mid-character at {to}"
);
assert!(
canonical.contains(&to),
"{text:?}: {at} -{step}/{atomic} landed at {to}, outside the \
reachable set {canonical:?}"
);
if !seen.contains(&to) {
seen.push(to);
frontier.push(to);
}
}
}
}
seen.sort_unstable();
assert_eq!(
seen, canonical,
"{text:?}: the non-atomic walk reaches all of it"
);
}
}
/// The two degenerate ends the guard owns: a step that runs out of buffer while skipping escapes
/// lands on the far end rather than spinning (ours, not the note's — see [`ClassMap::advance`]).
#[test]
fn a_step_that_runs_out_of_buffer_lands_on_the_far_end() {
let map = ClassMap::new("ab|cffff0000");
assert_eq!(
map.advance(2, 1, true),
12,
"nothing follows the |c to stop on"
);
assert_eq!(map.advance(12, -1, true), 2);
let map = ClassMap::new("|rab");
assert_eq!(map.advance(2, -1, true), 0, "nothing precedes the |r");
assert_eq!(map.advance(0, 1, true), 2);
// And at the extremes nothing moves.
let map = ClassMap::new(LINK);
assert_eq!(map.advance(0, -1, true), 0);
assert_eq!(map.advance(48, 1, true), 48);
assert_eq!(map.advance(31, 0, false), 31, "zero steps is the identity");
}
// ── Deletion and insertion ───────────────────────────────────────────────────────────────
#[test]
fn deleting_any_byte_of_a_link_widens_to_the_whole_unit() {
let map = ClassMap::new(AROUND);
// Anything touching the visible text takes the |c, the |H…|h, the |h and the |r with it,
// leaving exactly `abcd`.
for range in [33..38, 32..46, 33..34, 2..40, 12..46] {
let snapped = map.snap_delete_range(range.clone());
assert_eq!(snapped, 2..50, "{range:?}");
let mut left = AROUND.to_owned();
left.replace_range(snapped, "");
assert_eq!(left, "abcd");
}
// A selection that touches no link byte is left exactly as it was.
for range in [0..2, 0..0, 50..52, 51..52, 2..2] {
assert_eq!(map.snap_delete_range(range.clone()), range, "{range:?}");
}
// Only one endpoint inside: the other stays put.
assert_eq!(map.snap_delete_range(0..35), 0..50);
assert_eq!(map.snap_delete_range(35..52), 2..52);
}
/// The endpoint tests read the class array directly, so an exclusive end that merely abuts the
/// link's first tagged byte still widens. Faithful to `83 3c 86 00` @`0x77c59d`.
#[test]
fn a_deletion_ending_on_the_links_open_still_takes_the_link() {
let map = ClassMap::new(AROUND);
assert_eq!(map.snap_delete_range(0..12), 0..50);
// Whereas ending on the `|c` — which is not tagged — does not.
assert_eq!(map.snap_delete_range(0..2), 0..2);
}
#[test]
fn insertion_is_refused_strictly_inside_a_links_visible_text() {
let map = ClassMap::new(LINK);
// Everywhere outside the link.
assert!(map.insert_allowed(0), "before the leading |c");
assert!(map.insert_allowed(46), "between the |h and the |r");
assert!(map.insert_allowed(48), "at the end of the buffer");
// The leading edge: the |H entry carries bit 31, its predecessor the |c does not.
assert!(map.insert_allowed(10), "at the link's leading edge");
// Strictly inside: refused at every byte from the first visible character through the
// closing |h, whose entry is still tagged.
for at in 30..=44 {
assert!(!map.insert_allowed(at), "inside the link at {at}");
}
// And in particular at every cursor position a mouse click can actually produce.
for at in reachable_set(LINK) {
assert_eq!(
map.insert_allowed(at),
!(31..=43).contains(&at),
"reachable position {at}"
);
}
let map = ClassMap::new(AROUND);
for at in [0, 1, 2, 50, 51, 52] {
assert!(map.insert_allowed(at), "outside the link at {at}");
}
}
// ── UTF-8 ────────────────────────────────────────────────────────────────────────────────
#[test]
fn no_primitive_can_return_an_index_inside_a_character() {
// Multi-byte text before, inside and after the link — 2- and 3-byte characters throughout.
let text = "héllo |cffa335ee|Hitem:1:0:0:0|h[Épée ✚]|h|r né";
let map = ClassMap::new(text);
assert_eq!(map.text_len(), text.len());
// Every token start, and every index any primitive yields, is a character boundary.
for (at, token) in tokens(text) {
assert!(text.is_char_boundary(at), "token start {at}");
assert!(text.is_char_boundary(at + token.byte_len), "token end {at}");
}
for at in 0..=map.text_len() {
if !map.entry(at).is_token_start() && at != map.text_len() {
continue;
}
for atomic in [false, true] {
for step in [-1isize, 1] {
let to = map.advance(at, step, atomic);
assert!(
text.is_char_boundary(to),
"advance({at},{step},{atomic}) -> {to}"
);
}
}
assert!(text.is_char_boundary(map.next_token(at)));
if let Some(prev) = map.prev_token(at) {
assert!(text.is_char_boundary(prev));
}
let snapped = map.snap_delete_range(at..map.text_len());
assert!(text.is_char_boundary(snapped.start));
assert!(text.is_char_boundary(snapped.end));
// `String::replace_range` panics unless both ends are character boundaries, so a
// snapped range that cut a character in half fails here rather than corrupting.
let mut left = text.to_owned();
left.replace_range(snapped, "");
}
// The multi-byte characters are letters like any other: `héllo ` = 6, `[Épée ✚]` = 8,
// ` né` = 3.
assert_eq!(map.num_letters(), 17);
// And an atomic step still crosses the whole link in one, from `héllo `'s end to ` né`'s
// start, whatever the byte widths in between.
let open = text.find("|cffa335ee").expect("the colour push");
let after = text.find("|r").expect("the reset") + 2;
assert_eq!(map.advance(open, 1, true), after);
assert_eq!(map.advance(after, -1, true), open);
}
}