/errors: the selected error is copyable, in a read-only EditBox (#730)

`/errors` showed the selected error in a FontString, so a player could not select or copy the message and traceback they came to report.

The detail pane now hosts a multi-line EditBox the way stock `MailFrame.xml` hosts the mail body:
- `autoFocus="false"`, so the keyboard stays with the game until a click;
- Escape clears focus;
- a mouse-enabled scroll child that focuses the box on a click below its text;
- `ScrollingEdit_OnTextChanged` for the range, since the box is as tall as its text.

The box is read-only: an edit is undone on `OnTextChanged`. Focus selects all, so Ctrl+C copies the whole error, and a new error arriving keeps the selection. The text keeps its font, its 6/6 inset and its 270 wrap.

Tests cover the whole text in the box, the undone edits, select-all and copy, a click below the text, Escape, and the range over a long traceback.

Fixes #703

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-30 19:18:52 +07:00
committed by GitHub
co-authored by Claude Sonnet 5.5
parent f2e71ccebd
commit 7e117809b2
2 changed files with 367 additions and 17 deletions
+50 -16
View File
@@ -127,7 +127,10 @@
BenillaScriptLog_UpdateDetail()
end
-- The selected message in full, traceback and all.
-- The selected message in full, traceback and all, in a box a player can select and copy
-- from. A text that has not changed is left alone: `SetText` collapses the selection, so a
-- new error arriving must not undo one about to be copied. A different text takes the
-- keyboard from the box, as a click on another row does.
function BenillaScriptLog_UpdateDetail()
local text = ""
if BenillaScriptLogSelected then
@@ -141,12 +144,24 @@
if text == "" then
text = "Select a row above to read it in full."
end
getglobal("BenillaScriptLogDetailText"):SetText(text)
local box = getglobal("BenillaScriptLogDetailText")
if text ~= box.shown then
box.shown = text
box:ClearFocus()
box:SetText(text)
end
end
-- Text rects resolve after Lua runs, so the detail child is re-measured every frame.
function BenillaScriptLogFrame_OnUpdate()
BenillaScroll_ResizeChild("BenillaScriptLogDetailScroll", { "BenillaScriptLogDetailText" })
-- The box is read-only: a player's edit is undone. The restore's own `SetText` raises
-- another change, which finds the text equal and stops; `SetText` of an equal text does
-- nothing. The selection an edit replaced is put back, so Ctrl+C still copies it all.
function BenillaScriptLogDetail_OnTextChanged()
local shown = this.shown or ""
if this:GetText() ~= shown then
this:SetText(shown)
this:HighlightText()
end
ScrollingEdit_OnTextChanged(getglobal("BenillaScriptLogDetailScroll"))
end
function BenillaScriptLogRow_OnClick(button)
@@ -304,18 +319,38 @@
</Anchors>
<Scripts><OnValueChanged>BenillaScrollBar_OnValueChanged(this, arg1)</OnValueChanged></Scripts>
</Slider>
<Frame name="$parentChild">
<!-- Mouse-enabled and the pane's full height, as SendMailScrollChildFrame is
(MailFrame.xml:604-659): a click below the text focuses the box. -->
<Frame name="$parentChild" enableMouse="true">
<Size><AbsDimension x="296" y="119"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<Layers>
<Layer level="ARTWORK">
<!-- 270 wide so the traceback wraps rather than clips. -->
<FontString name="BenillaScriptLogDetailText" inherits="GameFontHighlightSmall" justifyH="LEFT" justifyV="TOP" text="">
<Size><AbsDimension x="270" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="6" y="-6"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- A multi-line box in a scroll frame as MailFrame.xml:616-649 hosts the mail
body: `autoFocus="false"`, so the keyboard stays with the game until a
click, and Escape clears the focus. A multi-line box is as tall as its
insets and its wrapped text, so the scroll frame ranges over it as
ScrollingEdit_OnTextChanged (UIPanelTemplates.lua:307) recomputes. The
insets seat the text 6 in and 6 down, 270 wide, and leave 20 below
the last line. No `letters`, so the whole error goes in. Read-only
through OnTextChanged; a click selects it all, so Ctrl+C copies the
error. Not wired: the stock scroll-to-caret (UIPanelTemplates.lua:
314-342), since selecting all leaves the caret at the end and a
click would scroll a long traceback to its bottom. -->
<EditBox name="BenillaScriptLogDetailText" multiLine="true" autoFocus="false">
<Size><AbsDimension x="296" y="119"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<TextInsets><AbsInset left="6" right="20" top="6" bottom="20"/></TextInsets>
<Scripts>
<OnTextChanged>BenillaScriptLogDetail_OnTextChanged()</OnTextChanged>
<OnEditFocusGained>this:HighlightText()</OnEditFocusGained>
<OnEscapePressed>this:ClearFocus()</OnEscapePressed>
</Scripts>
<FontString inherits="GameFontHighlightSmall"/>
</EditBox>
</Frames>
<Scripts>
<OnMouseUp>BenillaScriptLogDetailText:SetFocus()</OnMouseUp>
</Scripts>
</Frame>
</Frames>
</ScrollFrame>
@@ -338,7 +373,6 @@
<!-- A spin bubbles up the parent chain; the detail pane's own handler wins there. -->
<OnMouseWheel>BenillaScriptLogFrame_OnMouseWheel(arg1)</OnMouseWheel>
<OnShow>BenillaScriptLog_Update()</OnShow>
<OnUpdate>BenillaScriptLogFrame_OnUpdate()</OnUpdate>
</Scripts>
</Frame>
<!-- The player's commands, as an addon registers them; after the stock ChatFrame.lua, which
+317 -1
View File
@@ -3,7 +3,7 @@
//! row, as 1.12.1 loads FrameXML (`UI_Init 0x48fbf0`: `FrameXML.toc` at `0x48ffed`, the addons at
//! `0x4900a3`, only the latter through `AddOn_Load`); and a dev build boots without it.
use benilla_ui::script::{ScriptValue, UiScript};
use benilla_ui::script::{EditAction, EditUnit, QuadContent, ScriptValue, UiScript};
use crate::local_state::test_env::{EnvGuard, ENV_LOCK};
@@ -738,3 +738,319 @@ fn the_error_log_lays_out_on_the_trainer_art_as_the_class_trainer_does() {
);
}
}
/// A traceback long enough to wrap the detail box and to pass any small `letters` cap, with a
/// newline and a lone `|` in it, which the box must hold byte for byte.
const LONG_ERROR: &str = "Interface/AddOns/Boom/Boom.lua:12: bad argument | #1\\nStack:\\n";
/// The `/errors` window open on the trainer's kit with `LONG_ERROR` logged and its row selected,
/// settled a few frames. Returns the script and the message as Lua holds it.
fn error_log_selected() -> (UiScript, String) {
let mut s = super::trainer_tests::trainer_script();
s.run("SlashCmdList = {}").unwrap();
super::test_ui::load_ui_strict(&s, "ScriptLogFrame.xml");
s.run("ShowUIPanel(BenillaScriptLogFrame)").unwrap();
s.run(&format!(
"MSG = \"{LONG_ERROR}\" .. string.rep(\" [C]: in function `Boom'\\n\", 60)"
))
.unwrap();
s.run("BenillaScriptLog_Record(MSG)").unwrap();
settle(&mut s);
s.run("BenillaScriptLogRow1:Click()").unwrap();
settle(&mut s);
let msg = s.eval::<String>("return MSG").unwrap();
assert!(msg.len() > 1500, "a message past any small letters cap");
(s, msg)
}
/// A few frames: the layout resolves after Lua runs, and an edit's `OnTextChanged` fires on the tick.
fn settle(s: &mut UiScript) {
for _ in 0..5 {
s.resolve();
s.tick(0.016);
}
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A left click near the detail box's top left, inside its pane whatever the text's height, which
/// focuses it as the game's mouse does.
fn click_detail(s: &mut UiScript) {
let (x, y) = s
.eval::<(f64, f64)>(
"return BenillaScriptLogDetailText:GetLeft() + 20, BenillaScriptLogDetailText:GetTop() - 8",
)
.unwrap();
s.mouse_button(x as f32, y as f32, "LeftButton", true);
s.mouse_button(x as f32, y as f32, "LeftButton", false);
settle(s);
assert_eq!(
s.focused_editbox_name().as_deref(),
Some("BenillaScriptLogDetailText"),
"the click focused the detail box"
);
}
fn detail_text(s: &UiScript) -> String {
s.eval("return BenillaScriptLogDetailText:GetText()")
.unwrap()
}
/// The kit's stand-in font (`trainer_script`'s `FixedWidthFont(7.0)`): every character this wide,
/// every wrapped line this tall.
const CHAR_W: f64 = 7.0;
const LINE_H: f64 = 12.0;
/// The detail box's `<TextInsets>` (left, right, top, bottom) and width, from the XML.
const DETAIL_INSETS: (f64, f64, f64, f64) = (6.0, 20.0, 6.0, 20.0);
const DETAIL_WIDTH: f64 = 296.0;
/// The height the reference gives a multi-line box (`0x77d4d0` @`0x77d8ad`): its insets plus its
/// text, wrapped at the box's width less its side insets, one line when short or empty.
fn detail_box_height(chars: usize) -> f64 {
let wrap = DETAIL_WIDTH - (DETAIL_INSETS.0 + DETAIL_INSETS.1);
let natural = chars as f64 * CHAR_W;
let lines = if natural > wrap {
(natural / wrap).ceil()
} else {
1.0
};
DETAIL_INSETS.2 + DETAIL_INSETS.3 + lines * LINE_H
}
fn detail_geometry(s: &UiScript) -> (f64, f64, f64, f64, f64) {
s.eval(
"return BenillaScriptLogDetailText:GetWidth(), BenillaScriptLogDetailText:GetHeight(), \
BenillaScriptLogDetailScrollChild:GetHeight(), \
BenillaScriptLogDetailScroll:GetHeight(), \
BenillaScriptLogDetailScroll:GetVerticalScrollRange()",
)
.unwrap()
}
/// The selected error's text is in a multi-line EditBox, whole: no `letters` cap, no change to a
/// newline or a `|`. Opening the window takes no focus, so the movement keys keep working until a
/// click (the stock mail body's `autoFocus="false"`, `MailFrame.xml:616`). The box is as tall as
/// its insets and its text, so the scroll frame ranges over the whole traceback and shows its bar;
/// the text draws once, where the FontString drew it.
#[test]
fn the_error_logs_detail_is_a_box_holding_the_whole_error() {
benilla_formats::wow_data_or_skip!();
let (mut s, msg) = error_log_selected();
assert_eq!(
s.eval::<String>("return BenillaScriptLogDetailText:GetObjectType()")
.unwrap(),
"EditBox"
);
assert_eq!(detail_text(&s), msg, "the full message, traceback and all");
assert_eq!(
s.eval::<i64>("return BenillaScriptLogDetailText:GetMaxLetters()")
.unwrap(),
0,
"no letters cap"
);
// Nothing took the keyboard on its own: a key with no box focused is not consumed.
assert!(!s.has_keyboard_focus());
assert!(!s.char_input("w"), "no autoFocus box waits for a key");
assert!(!s.has_keyboard_focus());
// The box sizes itself: insets plus the wrapped text, which is taller than the pane.
let want = detail_box_height(msg.chars().count());
let (width, height, child, pane, range) = detail_geometry(&s);
assert_eq!(width, DETAIL_WIDTH);
assert!(want > pane, "sanity: the text is taller than the pane");
assert_eq!(height, want, "insets + lines * line height");
// The scroll child stays the pane's size; the range is the box's overhang, 20 below the text.
assert_eq!(child, pane, "the child is not sized to the text");
assert_eq!(range, want - pane, "the range covers the whole traceback");
assert!(
s.eval::<bool>("return BenillaScriptLogDetailScrollBar:IsShown()")
.unwrap(),
"the bar shows for a range"
);
// The text draws once, at the 6-in, 6-down seat the FontString had.
let (left, top) = s
.eval::<(f64, f64)>(
"return BenillaScriptLogDetailScroll:GetLeft(), BenillaScriptLogDetailScroll:GetTop()",
)
.unwrap();
let drawn: Vec<_> = s
.extract()
.into_iter()
.filter(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if *t == msg))
.collect();
assert_eq!(drawn.len(), 1, "the message draws once: {drawn:#?}");
let rect = drawn[0].rect.expect("the drawn text has a rect");
assert_eq!(
(f64::from(rect.left), f64::from(rect.top)),
(left + DETAIL_INSETS.0, top - DETAIL_INSETS.2),
"6 in and 6 down from the pane's corner"
);
assert_eq!(
f64::from(rect.right - rect.left),
DETAIL_WIDTH - (DETAIL_INSETS.0 + DETAIL_INSETS.1),
"wrapped at 270"
);
// The wheel over the box scrolls the pane, a step a notch.
let (x, y) = (left + 40.0, top - 40.0);
s.mouse_wheel(x as f32, y as f32, -1.0);
assert_eq!(
s.eval::<f64>("return BenillaScriptLogDetailScroll:GetVerticalScroll()")
.unwrap(),
20.0
);
// Another error's text resizes it: one short line, no range, no bar.
s.run("BenillaScriptLog_Record('short') BenillaScriptLogRow2:Click()")
.unwrap();
settle(&mut s);
assert_eq!(detail_text(&s), "short");
let (_, height, child, pane, range) = detail_geometry(&s);
assert_eq!(height, detail_box_height(5));
assert_eq!(height, 38.0, "sanity: 6 + 12 + 20");
assert_eq!((child, range), (pane, 0.0));
assert!(
!s.eval::<bool>("return BenillaScriptLogDetailScrollBar:IsShown()")
.unwrap(),
"no range, no bar"
);
}
/// A click anywhere in the pane focuses the box and selects all of it, as a click on the text
/// does: below a short text the click lands on the scroll child, which hands focus to the box
/// (`SendMailScrollChildFrame`'s `OnMouseUp`, `MailFrame.xml:651`).
#[test]
fn a_click_below_the_detail_text_focuses_the_box_and_selects_it_all() {
benilla_formats::wow_data_or_skip!();
let (mut s, _) = error_log_selected();
s.run("BenillaScriptLog_Record('short') BenillaScriptLogRow2:Click()")
.unwrap();
settle(&mut s);
assert_eq!(detail_text(&s), "short");
assert_eq!(
detail_geometry(&s).1,
38.0,
"a box far shorter than the pane"
);
// The pane's foot, 6 up from its bottom edge: well below the box.
let (x, y) = s
.eval::<(f64, f64)>(
"return BenillaScriptLogDetailScroll:GetLeft() + 40, BenillaScriptLogDetailScroll:GetBottom() + 6",
)
.unwrap();
assert!(
y < s
.eval::<f64>("return BenillaScriptLogDetailText:GetBottom()")
.unwrap(),
"sanity: the click is below the box"
);
assert!(!s.has_keyboard_focus());
s.mouse_button(x as f32, y as f32, "LeftButton", true);
s.mouse_button(x as f32, y as f32, "LeftButton", false);
settle(&mut s);
assert_eq!(
s.focused_editbox_name().as_deref(),
Some("BenillaScriptLogDetailText"),
"the pane's foot focused the box"
);
assert_eq!(
s.editbox_copy().as_deref(),
Some("short"),
"all of it selected"
);
}
/// A click selects the whole error, so Ctrl+C (`UiScript::editbox_copy`) copies all of it; an
/// error arriving meanwhile leaves that selection alone, and another row takes the keyboard off
/// the box with the new text.
#[test]
fn a_click_on_the_detail_selects_all_and_copy_returns_the_whole_error() {
benilla_formats::wow_data_or_skip!();
let (mut s, msg) = error_log_selected();
assert_eq!(s.editbox_copy(), None, "nothing to copy before a click");
click_detail(&mut s);
assert_eq!(s.editbox_copy().as_deref(), Some(msg.as_str()));
// The window's update repaints on every new error, and must not collapse the selection.
s.run("BenillaScriptLog_Record('another error')").unwrap();
settle(&mut s);
assert_eq!(s.editbox_copy().as_deref(), Some(msg.as_str()));
// Another row: the text changes and the box lets go of the keyboard.
s.run("BenillaScriptLogRow2:Click()").unwrap();
settle(&mut s);
assert_eq!(detail_text(&s), "another error");
assert!(!s.has_keyboard_focus());
click_detail(&mut s);
assert_eq!(s.editbox_copy().as_deref(), Some("another error"));
}
/// Typing, pasting, Enter, Backspace and cut leave the text as it was, the selection is put back
/// so a copy still takes it all, and the restore's own change stops after one more round.
#[test]
fn an_edit_of_the_detail_box_is_undone() {
benilla_formats::wow_data_or_skip!();
let (mut s, msg) = error_log_selected();
click_detail(&mut s);
s.run(
"CHANGES = 0 \
local handler = BenillaScriptLogDetail_OnTextChanged \
BenillaScriptLogDetail_OnTextChanged = function() CHANGES = CHANGES + 1 handler() end",
)
.unwrap();
// With everything selected, a typed letter replaces the whole text until the tick restores it.
assert!(s.char_input("x"));
assert_eq!(
detail_text(&s),
"x",
"the edit lands first, as in the reference"
);
settle(&mut s);
assert_eq!(detail_text(&s), msg, "typing");
assert_eq!(
s.eval::<i64>("return CHANGES").unwrap(),
2,
"the edit, then its restore"
);
assert_eq!(
s.editbox_copy().as_deref(),
Some(msg.as_str()),
"still all selected"
);
assert!(s.paste("pasted\nlines"));
settle(&mut s);
assert_eq!(detail_text(&s), msg, "pasting");
assert!(s.key_input("ENTER"));
settle(&mut s);
assert_eq!(detail_text(&s), msg, "Enter");
s.editbox_action(EditAction::Delete {
unit: EditUnit::Char,
back: true,
});
settle(&mut s);
assert_eq!(detail_text(&s), msg, "Backspace");
// A cut still copies, then the box takes the text back.
assert_eq!(s.editbox_cut().as_deref(), Some(msg.as_str()));
settle(&mut s);
assert_eq!(detail_text(&s), msg, "cut");
assert_eq!(s.editbox_copy().as_deref(), Some(msg.as_str()));
}
/// Escape clears the box's focus (`MailFrame.xml:644`), and leaves its text.
#[test]
fn escape_clears_the_detail_boxs_focus() {
benilla_formats::wow_data_or_skip!();
let (mut s, msg) = error_log_selected();
click_detail(&mut s);
assert!(s.key_input("ESCAPE"));
settle(&mut s);
assert!(!s.has_keyboard_focus(), "Escape let go of the keyboard");
assert_eq!(detail_text(&s), msg);
}