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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user