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>
This commit is contained in:
Sam
2026-08-07 16:02:39 +07:00
co-authored by Claude Fable 5
parent d99ad8bc7d
commit cd58f90bec
46 changed files with 4840 additions and 1695 deletions
+6 -1
View File
@@ -65,7 +65,12 @@ pub(super) fn char_input(lua: &Lua, text: &str) -> bool {
highlight_text(lua, h, 0, -1);
} else if text.chars().any(|c| (c as u32) >= 0x20) {
// A printable char (or string); pure control input is consumed but not inserted.
insert(lua, h, text, true);
//
// A typed `|` goes in as `||` — OnChar `0x77c200` pushes the literal at `0x879cac`, which
// is `"||"` (decision 1077). That is why real markup can only enter a box through
// `SetText`, `Insert`, a paste or the C++ link-insert path: you cannot type an escape, and
// the doubled form draws as one `|` and counts as one letter.
insert(lua, h, &text.replace('|', "||"), true);
}
true
}
@@ -196,11 +196,15 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
Ok((ins[0], ins[1], ins[2], ins[3]))
})?,
)?;
// GetNumLetters: the CHARACTER count (not bytes) — `0x7992c0` walks the class array.
// GetNumLetters: the LETTER count — `0x7992c0` walks the class array (`0x77bc80`), which
// counts classes 2, 3 and 6 only, so escapes are free and a 43-byte item link reports 9
// (decision 1077). Not bytes, and not chars either.
m.set(
"GetNumLetters",
lua.create_function(|lua, this: Table| {
with_editbox(lua, &this, |eb| eb.text.chars().count() as i64)
with_editbox(lua, &this, |eb| {
crate::markup::ClassMap::new(&eb.text).num_letters() as i64
})
})?,
)?;
@@ -968,3 +968,130 @@ fn caret_blinks_on_tick_and_resets_on_edit() {
answer_advances(&mut s);
assert!(s.focused_editbox_text_ui().unwrap().caret_on);
}
// ── A hyperlink is ONE keypress (RF-0087 §6, decision 1077) ──────────────────────────────────
//
// The engine-level law lives in `markup`; these drive it the way a player does — through the
// focused box's public keyboard API — because that is the level the reported defect lived at:
// backspacing a shift-clicked item link chewed it one invisible byte at a time, and the byte that
// went first was the `r` of its trailing `|r`, which turned everything typed afterwards the item's
// colour.
/// An epic link exactly as the client's own builder formats one (`0x52adb0`).
const LINK: &str = "|cffa335ee|Hitem:11684:0:0:0|h[Ironfoe]|h|r";
fn box_with_link(suffix: &str) -> UiScript {
let s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetFocus()"#)
.unwrap();
s.run(&format!("E:Insert(\"{LINK}{suffix}\")")).unwrap();
s
}
fn text_of(s: &UiScript) -> String {
s.eval::<String>("return E:GetText()").unwrap()
}
#[test]
fn one_backspace_deletes_a_whole_item_link() {
let mut s = box_with_link("");
assert!(s.editbox_action(EditAction::Delete {
unit: EditUnit::Char,
back: true
}));
assert_eq!(text_of(&s), "", "colour prefix and trailing |r go with it");
// And from the far side of following text: the link survives until the text is gone, then goes
// whole — never half-eaten, which is what left a dangling `|c` colouring the rest of the line.
let mut s = box_with_link("ab");
for expected in ["|cffa335ee|Hitem:11684:0:0:0|h[Ironfoe]|h|ra", LINK, ""] {
s.editbox_action(EditAction::Delete {
unit: EditUnit::Char,
back: true,
});
assert_eq!(text_of(&s), expected);
}
}
#[test]
fn one_arrow_crosses_a_whole_item_link() {
let mut s = box_with_link("");
let cursor = |s: &mut UiScript| {
s.editbox_action(EditAction::Move {
unit: EditUnit::Edge,
back: true,
extend: false,
});
s.editbox_action(EditAction::Move {
unit: EditUnit::Char,
back: false,
extend: false,
});
};
cursor(&mut s);
// One RIGHT from the start lands past the entire link — so typing there appends, and does not
// land between the `]` and its `|h`.
s.char_input("x");
assert_eq!(text_of(&s), format!("{LINK}x"));
}
#[test]
fn shift_arrow_selects_the_whole_link_and_typing_replaces_it() {
let mut s = box_with_link("");
s.editbox_action(EditAction::Move {
unit: EditUnit::Char,
back: true,
extend: true,
});
assert_eq!(
s.editbox_copy().as_deref(),
Some(LINK),
"one Shift+LEFT takes the whole unit"
);
s.char_input("x");
assert_eq!(text_of(&s), "x");
}
#[test]
fn typing_strictly_inside_a_link_is_refused() {
// Only the mouse can put the caret there, so place it the way a click would.
let mut s = box_with_link("");
s.run("E:HighlightText(35, 35)").unwrap(); // mid-"Ironfoe"
s.char_input("x");
assert_eq!(
text_of(&s),
LINK,
"the client swallows it rather than splitting the link"
);
// The same keystroke at the link's leading edge is allowed — the guard tests the PREVIOUS
// token too, which is what keeps the boundary usable.
s.run("E:HighlightText(0, 0)").unwrap();
s.char_input("x");
assert_eq!(text_of(&s), format!("x{LINK}"));
}
#[test]
fn a_typed_pipe_is_doubled_and_costs_one_letter() {
let mut s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetFocus()"#)
.unwrap();
s.char_input("|");
assert_eq!(
text_of(&s),
"||",
"OnChar 0x77c200 inserts the literal at 0x879cac"
);
assert_eq!(s.eval::<i64>("return E:GetNumLetters()").unwrap(), 1);
}
#[test]
fn max_letters_counts_visible_letters_not_escape_bytes() {
let s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetFocus(); E:SetMaxLetters(20)"#)
.unwrap();
s.run(&format!("E:Insert(\"{LINK}\")")).unwrap();
// 43 bytes, but "[Ironfoe]" is 9 letters — comfortably under a 20-letter cap that a raw char
// count would have blown through, trimming the link's own tail off.
assert_eq!(text_of(&s), LINK);
assert_eq!(s.eval::<i64>("return E:GetNumLetters()").unwrap(), 9);
}
@@ -45,12 +45,14 @@ pub struct SpellTooltipView {
/// flag, so an undispellable aura (Stealth) carries `None`. Rendered GOLD, like the aura name
/// it shares its line with — not the spell variant's gray.
pub dispel_type: Option<String>,
/// "35 Mana" / "20 Rage" / "Next melee" — the cost cell.
/// "35 Mana" / "20 Rage" / "20 Health" / "11 Health, plus 5 per sec" — the cost cell: the
/// RESOLVED cost through the power-type key array with the health fallback (1074).
pub cost: Option<String>,
/// "30 yd range" — the range cell.
pub range: Option<String>,
/// "1.5 sec cast" / "Instant cast" / "Instant" — `None` = a passive spell: the whole
/// casttime|cooldown line is omitted (the verified law; never a "Passive" text line).
/// "1.5 sec cast" / "Instant cast" / "Instant" / "Next melee" / "Attack speed" /
/// "Channeled" — `None` = a passive spell: the whole casttime|cooldown line is omitted
/// (the verified law; never a "Passive" text line).
pub cast_time: Option<String>,
/// "15 sec cooldown" — the cooldown cell: `max(RecoveryTime, CategoryRecoveryTime)` (the
/// 0276 line law §3.4 — Charge's 15 s lives in the CATEGORY column).