diff --git a/crates/benilla-app/assets/ui/GameMenuAdapters.xml b/crates/benilla-app/assets/ui/GameMenuAdapters.xml index 255688702..c13f37f9b 100644 --- a/crates/benilla-app/assets/ui/GameMenuAdapters.xml +++ b/crates/benilla-app/assets/ui/GameMenuAdapters.xml @@ -6,8 +6,10 @@ character-select AddOns screen is the only addon UI. --> - - + + diff --git a/crates/benilla-app/src/ui_script/reference_ui.rs b/crates/benilla-app/src/ui_script/reference_ui.rs index ff9ace6c8..d686bf140 100644 --- a/crates/benilla-app/src/ui_script/reference_ui.rs +++ b/crates/benilla-app/src/ui_script/reference_ui.rs @@ -2075,6 +2075,40 @@ mod tests { /// Seats a player before a probe loads the manifest, as the live client always has one by /// then: the stock macro window formats `UnitName("player")` into a tab label in its `OnLoad`, /// and without a player that raises like a load failure. + /// The reference fills an XML texture whose file does not load opaque green and logs it + /// (`0x770136`-`0x770171`). No stock texture the core or the glue load draws fails on an + /// install, so a line here is benilla's resolver missing a file the reference opens, which + /// would paint part of the stock interface green. + #[test] + fn every_texture_the_stock_interface_loads_opens() { + let data = benilla_formats::wow_data_or_skip!(); + let probe = |s: &mut UiScript| { + let chain = std::sync::Mutex::new(benilla_formats::open_chain(&data).expect("chain")); + let loose = benilla_assets::LooseFiles::install(); + s.set_texture_probe(Box::new(move |path| { + benilla_assets::sprite_found(&chain, &loose, path) + })); + }; + let failed = |s: &mut UiScript| -> Vec { + s.finish_ui_load_log(); + let mut lines: Vec = s.diagnostics().into_iter().map(|d| d.message).collect(); + lines.extend(s.take_load_log_writes().iter().map(|w| format!("{w:?}"))); + lines.retain(|l| l.contains("Unable to load texture file")); + lines + }; + let mut core = UiScript::new().expect("VM"); + core.set_screen_size(1024.0, 768.0); + probe(&mut core); + super::super::manifest::load_default_ui(&core); + assert_eq!(failed(&mut core), Vec::::new(), "the core"); + + let mut glue = UiScript::new_glue().expect("VM"); + glue.set_screen_size(1024.0, 768.0); + probe(&mut glue); + super::super::glue::load_glue(&mut glue); + assert_eq!(failed(&mut glue), Vec::::new(), "the glue"); + } + fn seat_a_player(s: &mut UiScript) { s.set_unit( "player", diff --git a/crates/benilla-ui/src/framexml.rs b/crates/benilla-ui/src/framexml.rs index 567dcb27a..0e1d0112a 100644 --- a/crates/benilla-ui/src/framexml.rs +++ b/crates/benilla-ui/src/framexml.rs @@ -141,7 +141,8 @@ pub struct Templates { #[derive(Debug, Clone)] struct Template { - element: Element, + /// Shared, so a load's per-level reads hold the node without copying it. + element: std::rc::Rc, /// The context word at registration (`[entry+0x1c]`, `0x6ee6be`); `None` is the reference's 0. context: Option, } @@ -169,7 +170,7 @@ impl Templates { context: Option<&str>, ) -> Registration { let template = Template { - element, + element: std::rc::Rc::new(element), context: context.map(str::to_owned), }; match self.entries.entry(name.to_ascii_lowercase()) { @@ -192,7 +193,32 @@ impl Templates { pub fn get(&self, name: &str) -> Option<&Element> { self.entries .get(&name.to_ascii_lowercase()) - .map(|t| &t.element) + .map(|t| &*t.element) + } + + /// The templates `element`'s load runs before its own body, in the order the reference runs + /// them: each level's `inherits` template is loaded through its own recursive load first + /// (`0x76fe5b`), the most distant first. [`expand`] flattens the same chain into one element, + /// losing the boundaries that per-level state (a texture's wrap flag, `0x76fec6`) follows. A + /// cycle stops at the repeated name. + pub fn inherited_levels(&self, element: &Element) -> Vec> { + let mut out: Vec> = Vec::new(); + let mut active: Vec = Vec::new(); + let mut name = element.attr("inherits").map(str::to_string); + while let Some(n) = name.filter(|n| !n.is_empty()) { + let key = n.to_ascii_lowercase(); + if active.contains(&key) { + break; + } + let Some(t) = self.entries.get(&key) else { + break; + }; + active.push(key); + name = t.element.attr("inherits").map(str::to_string); + out.push(t.element.clone()); + } + out.reverse(); + out } pub fn contains(&self, name: &str) -> bool { @@ -493,34 +519,6 @@ fn expand_inner( } } -/// The elements `element`'s load runs, in the order the reference runs them: each level's -/// `inherits` template is loaded through its own recursive load first (`0x76fe5b`), then the -/// level's own body. [`expand`] flattens the same chain into one element, losing the boundaries -/// that per-level state (a texture's wrap flag, `0x76fec6`) follows. -pub fn inherit_chain<'a>(element: &'a Element, templates: &'a dyn Inheritable) -> Vec<&'a Element> { - fn walk<'a>( - el: &'a Element, - templates: &'a dyn Inheritable, - active: &mut Vec, - out: &mut Vec<&'a Element>, - ) { - if let Some(name) = el.attr("inherits").filter(|n| !n.is_empty()) { - let key = name.to_ascii_lowercase(); - if !active.contains(&key) { - if let Some(template) = templates.find(name) { - active.push(key); - walk(template, templates, active, out); - active.pop(); - } - } - } - out.push(el); - } - let mut out = Vec::new(); - walk(element, templates, &mut Vec::new(), &mut out); - out -} - /// [`expand`]'s merge; `over`'s tag wins, and its body when it has one. `base`'s `name` and `id` /// are dropped: they belong to the node they are written on. fn merge(base: &Element, over: &Element) -> Element { @@ -584,6 +582,27 @@ pub fn resolve_name(raw: &str, parent_name: &str) -> String { } } +/// The client's integer parse `0x64ac60`, which the frame loader reads `frameLevel=` with +/// (`0x7699c5`): an optional `-`, then the leading digits, wrapping in 32 bits; no whitespace skip +/// and no `+`, so `" 5"` and `"+5"` are 0 and `"5a"` is 5. +pub(crate) fn atoi(s: &str) -> i32 { + let (negative, digits) = match s.strip_prefix('-') { + Some(rest) => (true, rest), + None => (false, s), + }; + let n = digits + .bytes() + .take_while(u8::is_ascii_digit) + .fold(0i32, |n, b| { + n.wrapping_mul(10).wrapping_add(i32::from(b - b'0')) + }); + if negative { + n.wrapping_neg() + } else { + n + } +} + /// C's `atof` (`0x64aaa0`), which the reference reads numeric attributes with (SimpleHTML's /// `width=` and `height=`, `spacing=`): the longest numeric prefix after leading whitespace, 0 when /// there is none, so `"12px"` is 12. @@ -970,7 +989,19 @@ print(x) #[cfg(test)] mod atof_tests { - use super::atof; + use super::{atof, atoi}; + + #[test] + fn atoi_takes_a_minus_and_the_leading_digits() { + assert_eq!(atoi("5"), 5); + assert_eq!(atoi("5a"), 5); + assert_eq!(atoi("-3"), -3); + assert_eq!(atoi("+5"), 0); + assert_eq!(atoi(" 5"), 0); + assert_eq!(atoi(""), 0); + assert_eq!(atoi("-"), 0); + assert_eq!(atoi("4294967297"), 1); + } #[test] fn atof_takes_the_numeric_prefix() { diff --git a/crates/benilla-ui/src/loader/geometry.rs b/crates/benilla-ui/src/loader/geometry.rs index e7ed4eee9..b00eb441f 100644 --- a/crates/benilla-ui/src/loader/geometry.rs +++ b/crates/benilla-ui/src/loader/geometry.rs @@ -2,13 +2,18 @@ use mlua::{ObjectLike, Table}; use crate::framexml::{self, Element}; -use super::regions::FontAttrs; -use super::{abs_dim, children_named, Loader}; +use super::{abs_dim, children_named, Levels, Loader}; impl Loader<'_> { /// A frame element's LoadXML attributes and simple children (`0x769820`), and a model pane's - /// own (`0x76cac0`). - pub(super) fn apply_attrs(&mut self, el: &Element, wrapper: &Table, dbg: &str) { + /// own (`0x76cac0`): from the merged `el`, and `frameLevel` from each of `levels`. + pub(super) fn apply_attrs( + &mut self, + el: &Element, + levels: &Levels, + wrapper: &Table, + dbg: &str, + ) { if el.attr_bool("hidden") { self.call(wrapper, "Hide", (), dbg); } @@ -24,9 +29,19 @@ impl Loader<'_> { .push(format!("{dbg}: Unknown frame strata: {strata}")); } } - if let Some(level) = el.attr("frameLevel") { - if let Ok(n) = level.parse::() { + // `frameLevel=` per level, read with `atoi` (`0x7699c5`): above 0 it is set (`0x76a4f0`, + // the setter `SetFrameLevel` calls), else logged (`0x878628`) and the level kept, so an + // instance's `"0"` leaves its template's level. + for level in levels { + let Some(value) = level.attr("frameLevel").filter(|v| !v.is_empty()) else { + continue; + }; + let n = framexml::atoi(value); + if n > 0 { self.call(wrapper, "SetFrameLevel", n, dbg); + } else { + let name = self.object_name(wrapper); + self.status_warning(format!("Frame {name}: Unknown frame level: {value}")); } } if let Some(alpha) = el.attr("alpha") { @@ -150,68 +165,78 @@ impl Loader<'_> { return; } }; - self.apply_region_layout(tr, ®ion, self_name, dbg, FontAttrs::Own); + // The title region's layout load is `0x767800` itself (`0x81c528[2]`, called at + // `0x769b8a`), which never re-enters `inherits`: the node alone. + self.apply_geometry(tr, ®ion, true, self_name, dbg); } - /// `` (`0x767800`). Every one applies in document order, and expansion puts an - /// instance's children after its template's, so the instance's own `` wins. - pub(super) fn apply_size(&mut self, el: &Element, wrapper: &Table, dbg: &str) { - for size in children_named(el, "Size") { - let (x, y) = abs_dim(size); - if let Some(w) = x { - self.call(wrapper, "SetWidth", w, dbg); - } - if let Some(h) = y { - self.call(wrapper, "SetHeight", h, dbg); - } - } - } - - /// `` (`0x767800`), after the `setAllPoints` shorthand: `relativePoint` defaults to - /// `point`, and `relativeTo` expands `$parent` to the nearest named ancestor's name, else - /// `Top` (`0x76c5b0`). - pub(super) fn apply_anchors( + /// One node's geometry, the CLayoutFrame part of every frame and region `LoadXML` (`0x767800`), + /// run once per level ([`Loader::levels`]): the node's first ``, then its first + /// ``, whose points are added, or, with none, `setAllPoints="true"` (compared whole, + /// `0x767879`) as `SetAllPoints`, which clears the points first (`0x767db0`). `setAllPoints` + /// beside the node's own `` is logged (`0x8784ac`) and ignored, so a template's + /// all-points and an instance's anchor both stand (`ActionButtonTemplate.xml`'s + /// `$parentCooldown` over `CooldownFrameTemplate`). `relativePoint` defaults to `point`, and + /// `relativeTo` expands `$parent` to `parent_name`, the nearest named ancestor's, else `Top` + /// (`0x76c5b0`). + pub(super) fn apply_geometry( &mut self, - el: &Element, + node: &Element, wrapper: &Table, + region: bool, parent_name: &str, dbg: &str, ) { - if el.attr_is_true("setAllPoints") { - self.call(wrapper, "SetAllPoints", (), dbg); - } - for anchors in children_named(el, "Anchors") { - for anchor in children_named(anchors, "Anchor") { - let Some(point) = anchor.attr("point") else { - self.report - .warnings - .push(format!("{dbg}: without a point; skipped")); - continue; - }; - let rel_point = anchor.attr("relativePoint").unwrap_or(point).to_string(); - let rel_to: Option = anchor - .attr("relativeTo") - .map(|r| framexml::resolve_name(r, parent_name)); - let (x, y) = children_named(anchor, "Offset") - .next() - .map(abs_dim) - .unwrap_or((None, None)); - let args = ( - point.to_string(), - rel_to, - rel_point, - x.unwrap_or(0.0), - y.unwrap_or(0.0), - ); - let d = super::DeferredAnchor { - wrapper: wrapper.clone(), - region: false, - args, - dbg: dbg.to_string(), - }; - // Deferred while the enclosing subtree has not built its target yet. - self.apply_anchor(d, true); + if let Some(size) = children_named(node, "Size").next() { + let (x, y) = abs_dim(size); + if let Some(w) = x { + self.call_on(wrapper, region, "SetWidth", w, dbg); } + if let Some(h) = y { + self.call_on(wrapper, region, "SetHeight", h, dbg); + } + } + let set_all_points = node.attr_is_true("setAllPoints"); + let Some(anchors) = children_named(node, "Anchors").next() else { + if set_all_points { + self.call_on(wrapper, region, "SetAllPoints", (), dbg); + } + return; + }; + if set_all_points { + self.status_warning("SETALLPOINTS set to true in frame with anchors (ignored)".into()); + } + for anchor in children_named(anchors, "Anchor") { + let Some(point) = anchor.attr("point") else { + let kind = if region { "region " } else { "" }; + self.report + .warnings + .push(format!("{dbg}: {kind} without a point; skipped")); + continue; + }; + let rel_point = anchor.attr("relativePoint").unwrap_or(point).to_string(); + let rel_to: Option = anchor + .attr("relativeTo") + .map(|r| framexml::resolve_name(r, parent_name)); + let (x, y) = children_named(anchor, "Offset") + .next() + .map(abs_dim) + .unwrap_or((None, None)); + let args = ( + point.to_string(), + rel_to, + rel_point, + x.unwrap_or(0.0), + y.unwrap_or(0.0), + ); + let d = super::DeferredAnchor { + wrapper: wrapper.clone(), + region, + args, + dbg: dbg.to_string(), + }; + // Deferred while the enclosing subtree has not built its target yet. + self.apply_anchor(d, true); } } } diff --git a/crates/benilla-ui/src/loader/level_tests.rs b/crates/benilla-ui/src/loader/level_tests.rs new file mode 100644 index 000000000..546680ee0 --- /dev/null +++ b/crates/benilla-ui/src/loader/level_tests.rs @@ -0,0 +1,484 @@ +//! The loader's per-level reads: what the reference reads on each `inherits` node in turn +//! ([`Loader::levels`]) rather than on the merged element. + +use crate::framexml; +use crate::loader::*; +use crate::script::UiScript; + +fn no_files(_: &str) -> Option> { + None +} + +fn ui() -> UiScript { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(800.0, 600.0); + s +} + +fn load_str(s: &UiScript, xml: &str) -> LoadReport { + let report = load(s, &framexml::parse(xml).expect("valid FrameXML"), &no_files); + assert!(report.errors.is_empty(), "{:?}", report.errors); + report +} + +fn has_line(report: &LoadReport, line: &str) -> bool { + report.status.lines().iter().any(|l| l == line) && report.warnings.iter().any(|w| w == line) +} + +/// `0x767800`: `setAllPoints` beside the same node's `` is logged and ignored; at another +/// level it stands, and `SetAllPoints` clears the points an earlier level added. +#[test] +fn set_all_points_yields_to_its_own_nodes_anchors_only() { + let mut s = ui(); + let report = load_str( + &s, + r#" + + + + + + + + + + + + + + + + + + + + + + + + "#, + ); + s.resolve(); + assert!(has_line( + &report, + "SETALLPOINTS set to true in frame with anchors (ignored)" + )); + let points = |name: &str| { + s.eval::(&format!("return {name}:GetNumPoints()")) + .unwrap() + }; + assert_eq!(points("Both"), 1, "the frame's own anchors alone"); + assert_eq!(points("HostBoth"), 1, "the region's own anchors alone"); + // The cooldown shape: the template's all-points, then the instance's anchor on top. + assert_eq!(points("TemplateAll"), 3); + assert_eq!( + s.eval::<(f32, f32)>("return TemplateAll:GetWidth(), TemplateAll:GetHeight()") + .unwrap(), + (100.0, 50.0) + ); + // The instance's all-points clears the template's CENTER. + assert_eq!(points("InstanceAll"), 2); +} + +/// `0x7699ad`: `frameLevel` is read with `atoi` per level and set only above 0; otherwise the line +/// `0x878628` is logged and the level stays. +#[test] +fn a_frame_level_of_zero_or_less_is_logged_and_kept_per_level() { + let s = ui(); + let report = load_str( + &s, + r#" + + + + + + + + + "#, + ); + let level = |name: &str| { + s.eval::(&format!("return {name}:GetFrameLevel()")) + .unwrap() + }; + let plain = level("Plain"); + assert_eq!(plain, 4, "a child sits one above its parent"); + assert_eq!(level("Zero"), plain); + assert_eq!(level("Negative"), plain); + assert_eq!(level("Plus"), plain); + assert_eq!(level("Trailing"), 5); + assert_eq!(level("OverSeven"), 7, "the template's level stands"); + for (name, value) in [ + ("Zero", "0"), + ("Negative", "-3"), + ("Plus", "+5"), + ("OverSeven", "0"), + ] { + assert!( + has_line( + &report, + &format!("Frame {name}: Unknown frame level: {value}") + ), + "{name}: {:?}", + report.warnings + ); + } +} + +/// `0x782ef0`: per level, the range needs both bounds (read with `atof`) and `defaultValue` follows +/// it; `orientation` is skipped when empty and logged, never raised, when unknown. +#[test] +fn a_status_bar_reads_its_range_and_orientation_per_level() { + let s = ui(); + let report = load_str( + &s, + r#" + + + + + + + + + "#, + ); + let state = |name: &str| { + s.eval::<(f32, f32, f32)>(&format!( + "local lo, hi = {name}:GetMinMaxValues() return lo, hi, {name}:GetValue()" + )) + .unwrap() + }; + assert_eq!(state("MaxOnly"), state("Fresh"), "one bound sets nothing"); + assert_eq!(state("Both"), (0.0, 100.0, 40.0)); + assert_eq!( + state("OverRange"), + (0.0, 10.0, 3.0), + "the template's pair stands" + ); + let orientation = |name: &str| { + s.eval::(&format!("return {name}:GetOrientation()")) + .unwrap() + }; + assert_eq!(orientation("Diagonal"), "HORIZONTAL"); + assert_eq!(orientation("Blank"), "HORIZONTAL"); + assert_eq!(orientation("Upright"), "VERTICAL"); + assert!(has_line( + &report, + "Frame Diagonal: Unknown orientation DIAGONAL in element StatusBar" + )); +} + +/// `0x76fe20`: every `` child applies in order, each over 0, 1, 0, 1 with its edges read +/// by `atof`, and the instance's level comes after its template's, so the last one wins whole. +#[test] +fn every_tex_coords_applies_in_order_over_the_default_edges() { + let s = ui(); + load_str( + &s, + r#" + + + + + + + + + + + + + + + + + + + "#, + ); + let coords = |name: &str| { + s.eval::>(&format!("return {{{name}:GetTexCoord()}}")) + .unwrap() + }; + // UL, LL, UR, LR: (l, t), (l, b), (r, t), (r, b). + let rect = |l: f32, r: f32, t: f32, b: f32| vec![l, t, l, b, r, t, r, b]; + assert_eq!(coords("Partial"), rect(0.5, 1.0, 0.0, 1.0)); + assert_eq!( + coords("Twice"), + rect(0.5, 1.0, 0.0, 1.0), + "the second, whole" + ); + assert_eq!(coords("Trailing"), rect(0.25, 0.75, 0.0, 1.0)); + assert_eq!( + coords("Cropped"), + rect(0.025, 0.535, 0.0, 0.75), + "the instance's crop" + ); +} + +/// `0x770102`-`0x770171`: a level's `file=` that fails to load logs `0x878d24` and fills the +/// texture opaque green, even over a template level whose file loaded; with no probe installed the +/// path is stored as before. +#[test] +fn an_xml_texture_whose_file_fails_to_load_fills_green() { + let xml = r#" + + + + + + + + + + + + "#; + let mut s = ui(); + s.set_texture_probe(Box::new(|path: &str| !path.contains("Nope"))); + let report = load_str(&s, xml); + let texture = |s: &UiScript, name: &str| { + s.eval::>(&format!("return {name}:GetTexture()")) + .unwrap() + }; + assert_eq!(texture(&s, "Bad").as_deref(), Some("Solid Texture")); + assert_eq!(texture(&s, "OverGood").as_deref(), Some("Solid Texture")); + assert_eq!(texture(&s, "Fine").as_deref(), Some("Interface\\Good")); + for line in [ + r"Texture Bad: Unable to load texture file Interface\Nope", + r"Texture OverGood: Unable to load texture file Interface\NopeToo", + r"Texture : Unable to load texture file Interface\Nope", + ] { + assert!(has_line(&report, line), "{line}: {:?}", report.warnings); + } + s.resolve(); + let green = s.extract().iter().any(|q| { + matches!( + &q.content, + crate::script::QuadContent::Texture { color: Some(c), .. } if *c == [0.0, 1.0, 0.0, 1.0] + ) + }); + assert!(green, "the failed texture draws opaque green"); + + let unprobed = ui(); + let report = load_str(&unprobed, xml); + assert_eq!( + texture(&unprobed, "Bad").as_deref(), + Some("Interface\\Nope") + ); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); +} + +const TEST_FONT: &str = r#" + +"#; + +/// `0x770f40`: a `font=` file needs the node's own ``, and a refused one returns before +/// `spacing`, the justifies and the colour (`0x771191`); an empty `font=` is absent (`0x7710f7`). +#[test] +fn a_fontstring_font_file_needs_its_own_nodes_height_and_a_refusal_stops_the_level() { + let s = ui(); + let report = load_str( + &s, + &format!( + r#"{TEST_FONT} + + + + + + + + + + + + + + + + + + + "# + ), + ); + let paint = |name: &str| { + s.eval::(&format!( + "local r, g, b = {name}:GetTextColor() \ + return table.concat({{{name}:GetJustifyH(), {name}:GetJustifyV(), \ + {name}:GetSpacing(), r, g, b}}, ' ')" + )) + .unwrap() + }; + assert_eq!(paint("Refused"), paint("Control"), "nothing after the font"); + let font = |name: &str| { + s.eval::<(String, f32)>(&format!("local f, h = {name}:GetFont() return f, h")) + .unwrap() + }; + assert_eq!(font("LeansOnTemplate"), ("Fonts\\ARIALN.TTF".into(), 20.0)); + assert_eq!(font("OwnWins"), ("Fonts\\MORPHEUS.TTF".into(), 16.0)); + assert_eq!(font("EmptyFont"), font("Control")); + for name in ["Refused", "LeansOnTemplate"] { + let line = format!("FontString {name}: Missing font height in FontString element"); + assert!(has_line(&report, &line), "{line}: {:?}", report.warnings); + } + assert_eq!( + report.warnings.len(), + 2, + "no other line: {:?}", + report.warnings + ); +} + +/// `0x7713ca`-`0x771454`: a FontString's own `` takes its first `` over opaque black +/// and its first ``, whose missing coordinate is 0 (`0x6f1ebe`), else (0.001, -0.001). +#[test] +fn a_fontstrings_own_shadow_element_is_read() { + let s = ui(); + load_str( + &s, + &format!( + r#"{TEST_FONT} + + + + + + + + + + + + + + + "# + ), + ); + let shadow = |name: &str| { + s.eval::>(&format!( + "local x, y = {name}:GetShadowOffset() \ + local r, g, b, a = {name}:GetShadowColor() return {{x, y, r, g, b, a}}" + )) + .unwrap() + }; + assert_eq!(shadow("Shadowed"), vec![2.0, -3.0, 1.0, 0.0, 0.0, 0.5]); + assert_eq!( + shadow("BareShadow"), + vec![0.001, -0.001, 0.0, 0.0, 0.0, 1.0] + ); + assert_eq!( + shadow("HalfOffset"), + vec![2.0, 0.0, 0.0, 0.0, 0.0, 1.0], + "an zeroes the coordinate it leaves out" + ); +} + +/// `0x771006`-`0x771032`, `0x778c07`-`0x778c31`: an empty `text=` is skipped, so a template's text +/// stands, and a global holding `""` shows its key. +#[test] +fn an_empty_text_is_skipped_and_an_empty_global_shows_its_key() { + let s = ui(); + s.eval::<()>("LEVEL_TEST_EMPTY = \"\"").unwrap(); + load_str( + &s, + &format!( + r#"{TEST_FONT} + + + + + + + + + + + "# + ), + ); + let text = |expr: &str| s.eval::>(&format!("return {expr}")).unwrap(); + assert_eq!( + text("KeyShown:GetText()").as_deref(), + Some("LEVEL_TEST_EMPTY") + ); + assert_eq!(text("Kept:GetText()").as_deref(), Some("A")); + assert_eq!( + text("KeyButton:GetText()").as_deref(), + Some("LEVEL_TEST_EMPTY") + ); + assert_eq!(text("KeptButton:GetText()").as_deref(), Some("A")); + assert_eq!(text("LabelKept:GetText()").as_deref(), Some("A")); +} + +/// `0x783c30`: a `` copies the object `inherits=` names, then reads `outline=` and +/// `monochrome=` only beside its own `font=` file, whose own `` it needs; a refused +/// height skips the rest of the node. +#[test] +fn a_font_element_reads_its_flags_only_beside_its_own_font_file() { + let s = ui(); + let report = load_str( + &s, + r#" + + + + + + + + + + + + + "#, + ); + let font = |name: &str| { + s.eval::(&format!( + "local f, h, flags = {name}:GetFont() local r, g, b = {name}:GetTextColor() \ + return table.concat({{f, h, flags or '', {name}:GetJustifyH(), r, g, b}}, ' ')" + )) + .unwrap() + }; + let parent = font("LevelParentFont"); + assert_eq!( + font("LevelOutlineOnly"), + parent, + "no file of its own, no flags of its own" + ); + assert_eq!( + font("LevelOwnFile"), + parent.replacen("Fonts\\FRIZQT__.TTF 12 OUTLINE", "Fonts\\ARIALN.TTF 14 ", 1), + "its own file sets the whole flags word" + ); + assert_eq!( + font("LevelNoHeight"), + parent, + "a refused height skips the node" + ); + assert!(has_line( + &report, + "Font LevelNoHeight: Missing font height in Font element" + )); + assert!(has_line( + &report, + "Couldn't find inherited font: LevelNoSuchFont" + )); +} diff --git a/crates/benilla-ui/src/loader/mod.rs b/crates/benilla-ui/src/loader/mod.rs index ea90159e1..27a2020d3 100644 --- a/crates/benilla-ui/src/loader/mod.rs +++ b/crates/benilla-ui/src/loader/mod.rs @@ -8,7 +8,7 @@ //! Lua handles are never accumulated breadth-wise (MAXCSTACK): a frame's wrapper and `OnLoad` //! live only across its own subtree build, and everything durable lives Lua-side. -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use mlua::{Function, ObjectLike, Table, Value}; @@ -199,13 +199,15 @@ pub fn apply_template(lua: &mlua::Lua, wrapper: &Table, kind: &str, template: &s loader.report.warnings.extend(notes); // The XML `inherits=` expansion; its warnings get the `CreateFrame` call as a prefix. + let node = framexml::inherits_node(kind, template); let first = loader.report.warnings.len(); - let expanded = loader.expand(&framexml::inherits_node(kind, template)); + let expanded = loader.expand(&node); for w in &mut loader.report.warnings[first..] { *w = format!("{dbg}: {w}"); } + let levels = loader.levels(&node); - loader.decorate(&expanded, wrapper, &self_name, &parent_name, &dbg); + loader.decorate(&expanded, &levels, wrapper, &self_name, &parent_name, &dbg); loader.report } @@ -273,7 +275,7 @@ struct Loader<'a> { pub(super) status_root: &'a str, // The template and font registries live on the `Model` and persist across loads: the // reference's template table is global (`0x6ee500`), so `MerchantFrame.xml` inherits a template - // `CharacterFrameTemplates.xml` registered. Fonts are their own namespace, stored flattened. + // `CharacterFrameTemplates.xml` registered. Fonts are their own namespace. pub(super) report: LoadReport, /// Warn-once keys. pub(super) warned: HashSet, @@ -284,6 +286,45 @@ struct Loader<'a> { pub(super) deferred_anchors: Vec, } +/// The nodes an element's `LoadXML` reads, in the order it reads them: the reference merges no +/// template into an instance, but re-enters `LoadXML` on each `inherits` node first (`0x76985c` +/// for a frame, `0x76fe5b` for a texture, `0x770f9e` for a FontString), then reads the node's own +/// attributes and its own first child of each kind (`0x6f2c50`). So an attribute whose rule is +/// local to a node (`setAllPoints` beside the node's own ``, a `font=` with its own +/// ``) is read level by level, the template first and the element last; +/// [`Loader::expand`]'s merged element serves the rest. The templates are shared with the +/// registry, not copied. +pub(super) struct Levels<'a> { + inherited: Vec>, + own: &'a Element, +} + +type LevelIter<'l> = std::iter::Chain< + std::iter::Map< + std::slice::Iter<'l, std::rc::Rc>, + fn(&std::rc::Rc) -> &Element, + >, + std::iter::Once<&'l Element>, +>; + +impl Levels<'_> { + pub(super) fn iter(&self) -> LevelIter<'_> { + let shared: fn(&std::rc::Rc) -> &Element = |e| e; + self.inherited + .iter() + .map(shared) + .chain(std::iter::once(self.own)) + } +} + +impl<'l> IntoIterator for &'l Levels<'_> { + type Item = &'l Element; + type IntoIter = LevelIter<'l>; + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + /// One `SetPoint` held until its target exists. pub(super) struct DeferredAnchor { pub(super) wrapper: Table, @@ -336,6 +377,23 @@ impl Loader<'_> { crate::script::event::invoke_with_globals(self.lua, wrapper.clone(), func, Vec::new()) } + /// An object's name as the reference's load lines print it: `GetName`, else `` + /// (`0x84c7f0`). + pub(super) fn object_name(&self, wrapper: &Table) -> String { + wrapper + .call_method::>("GetName", ()) + .ok() + .flatten() + .unwrap_or_else(|| "".to_string()) + } + + /// A severity-1 line into the document's record, the status object's `+0xc` slot every + /// `LoadXML` logs through, retained as a warning too. + pub(super) fn status_warning(&mut self, line: String) { + self.report.status.report(status::WARNING, line.clone()); + self.report.warnings.push(line); + } + pub(super) fn warn_once(&mut self, key: &str, msg: impl Into) { if self.warned.insert(key.to_string()) { self.report.warnings.push(msg.into()); @@ -343,12 +401,13 @@ impl Loader<'_> { } /// Resolve `text=` as a global-string key (`FrameScript_GetText 0x703bf0`), so - /// `GlobalStrings.lua` must run before any XML. A miss comes back empty (`0x882748`) and all - /// three `text=` readers then show the raw value (`0x778c07`, `0x771006`, `0x7292a6`). The - /// warning on a key-shaped miss is benilla's own. + /// `GlobalStrings.lua` must run before any XML. A miss comes back empty (`0x882748`), and all + /// three `text=` readers show the raw value for an empty answer (`0x771032`, `0x778c31`, + /// `0x7292a6`), so a global holding `""` shows its key. The warning on a key-shaped miss is + /// benilla's own. pub(super) fn resolve_text(&mut self, raw: &str, dbg: &str) -> String { if let Ok(s) = self.lua().globals().get::(raw) { - return s; + return if s.is_empty() { raw.to_string() } else { s }; } if is_global_string_key(raw) { self.warn_once( @@ -408,8 +467,7 @@ impl Loader<'_> { TopLevel::Font(el) => self.do_font(el), TopLevel::Template(el) => self.register_template(el), TopLevel::Instance(el) => { - let expanded = self.expand(el); - self.materialize(&expanded, None, framexml::DEFAULT_PARENT_NAME); + self.materialize(el, None, framexml::DEFAULT_PARENT_NAME); } } } @@ -516,20 +574,17 @@ impl Loader<'_> { out } - /// The `file=` loads of a texture region, in the order the reference runs them - /// (`0x76fe20`): each `inherits` level's own file with the wrap flag of that level's own - /// `` (the flag is a local of the level's load, zeroed before its children, - /// `0x76fec6`), the template first. A level without a `file=` loads nothing. - pub(super) fn texture_file_loads(&self, raw: &Element) -> Vec<(String, bool)> { - let model = self.model(); - let templates = model.framexml_templates.borrow(); - framexml::inherit_chain(raw, &*templates) - .into_iter() - .filter_map(|level| { - let file = level.attr("file").filter(|f| !f.is_empty())?; - Some((file.to_string(), tex_coords_wrap(level))) - }) - .collect() + /// The nodes `raw`'s `LoadXML` reads ([`Levels`]). + pub(super) fn levels<'a>(&self, raw: &'a Element) -> Levels<'a> { + let inherited = self + .model() + .framexml_templates + .borrow() + .inherited_levels(raw); + Levels { + inherited, + own: raw, + } } /// [`Loader::expand`] for a region, only when `inherits=` names a registered element template: @@ -546,27 +601,34 @@ impl Loader<'_> { } } - /// A top-level ``: flatten its `inherits=` chain (fonts inherit only fonts), - /// register the [`FontObject`] and store the flattened element for later fonts to inherit. + /// A top-level `` (`CSimpleFont::LoadXML 0x783c30`): the font object `inherits=` + /// names is copied (`0x783ce6`; a miss is logged, `0x87a51c`), then the node's own attributes + /// apply over it ([`font_object_leg`]). The object is registered and published whether or not + /// its own font was refused. pub(super) fn do_font(&mut self, el: &Element) { let Some(name) = el.name().map(str::to_string) else { return; }; - let merged = { - let model = self.model(); - let fonts = model.framexml_fonts.borrow(); - let view: HashMap<&str, &Element> = - fonts.iter().map(|(k, v)| (k.as_str(), v)).collect(); - let mut warns = Vec::new(); - let merged = framexml::expand(el, &view, &mut warns); - drop(view); - drop(fonts); - drop(model); - self.report.warnings.extend(warns); - merged + let lookup = |this: &Self, key: &str| { + this.model() + .font_objects_by_lower + .get(&key.to_ascii_lowercase()) + .cloned() }; - - let font = font_object_from_element(&merged); + let mut font = FontObject::default(); + if let Some(parent) = el.attr("inherits").filter(|n| !n.is_empty()) { + match lookup(self, parent) { + Some(f) => font = f, + None => self.status_warning(format!("Couldn't find inherited font: {parent}")), + } + } + let named = el + .attr("font") + .filter(|f| !f.is_empty()) + .and_then(|f| lookup(self, f)); + if !font_object_leg(el, &mut font, named) { + self.report_missing_font_height("Font", &name, &el.tag); + } { let mut model = self.model(); model @@ -575,7 +637,7 @@ impl Loader<'_> { model .framexml_fonts .borrow_mut() - .insert(name.clone(), merged); + .insert(name.clone(), el.clone()); } // In 1.12 a named `` is also a Lua global, the object `SetFontObject` takes. if let Err(e) = crate::script::font::publish(self.lua(), &name) { @@ -585,15 +647,17 @@ impl Loader<'_> { } } - /// Build one template-expanded frame element, its nested ``, then its `OnLoad` + /// Build one frame element, template-expanded, its nested ``, then its `OnLoad` /// (bottom-up, `0x76a060`). `parent` is the lexically enclosing frame, which `parent=` /// overrides; `None` when the frame type is unknown, which skips the subtree. pub(super) fn materialize( &mut self, - el: &Element, + raw: &Element, parent: Option<&Table>, parent_name: &str, ) -> Option { + let expanded = self.expand(raw); + let el = &expanded; // Only creation lives here: everything after `CreateFrame` is `decorate`, which // `apply_template` enters for a frame that already exists. @@ -695,17 +759,21 @@ impl Loader<'_> { // `$parent` for its contents; a nameless frame passes its nearest named ancestor on. let self_name = resolved_name.as_deref().unwrap_or(parent_name).to_string(); - self.decorate(el, &wrapper, &self_name, parent_name, &dbg_name); + let levels = self.levels(raw); + self.decorate(el, &levels, &wrapper, &self_name, parent_name, &dbg_name); Some(wrapper) } /// Everything a frame element does to an existing frame, from `LoadXML` attributes to its /// `OnLoad` (bottom-up, `0x76a060`) and the post-load OnShow; shared by [`Self::materialize`] and - /// [`apply_template`]. `$parent` is `self_name` in this frame's contents and `parent_name` in + /// [`apply_template`]. `el` is the template-expanded element and `levels` the nodes it merges + /// ([`Loader::levels`]). `$parent` is `self_name` in this frame's contents and `parent_name` in /// its own anchors (`0x76c5b0`). + #[allow(clippy::too_many_arguments)] pub(super) fn decorate( &mut self, el: &Element, + levels: &Levels, wrapper: &Table, self_name: &str, parent_name: &str, @@ -717,10 +785,12 @@ impl Loader<'_> { // hook, its own OnShow and OnHide fire nothing. crate::script::visibility::set_load_window(self.lua(), wrapper, true); // 2 · LoadXML attributes (`0x769820`). - self.apply_attrs(el, wrapper, dbg_name); - // 3 · and 4 · (the CLayoutFrame geometry base, `0x767800`). - self.apply_size(el, wrapper, dbg_name); - self.apply_anchors(el, wrapper, parent_name, dbg_name); + self.apply_attrs(el, levels, wrapper, dbg_name); + // 3 · and 4 · , each level's own (the CLayoutFrame geometry base, + // `0x767800`). + for level in levels { + self.apply_geometry(level, wrapper, false, parent_name, dbg_name); + } // 5 · regions (`0x769d70`) and the plate (`0x77e6c0`), whose nine // pieces are regions too, in document order: both are arms of the one child loop // (`0x769b1c`), so the order they link into `GetRegions` is the file's. A template's @@ -739,10 +809,10 @@ impl Loader<'_> { self.apply_title_region(el, wrapper, self_name, dbg_name); // 5b · per-kind LoadXML extras (EditBox `0x779fb0`), each gated on the element's own tag, // so a Frame wearing a
{ - let expanded = self.expand_region(raw); - self.build_region(raw, &expanded, wrapper, parent_name, level, true, dbg) + self.build_region(raw, wrapper, parent_name, level, true, dbg) } /// One `` region on `wrapper`, created on `level` and loaded: its name (`$parent` - /// expanded, published as a global), `hidden`, `alpha`, layout, font and visual, then the - /// implicit creation anchor, run after the region's LoadXML as the reference does (`0x7701c0`, - /// `0x771480`); an anchored region is left alone. `None` when the creation raised. + /// expanded, published as a global, read from `raw` alone, `0x6f2751`), then its `LoadXML` + /// level by level ([`Loader::levels`]; `inherits=` may name a region template, in the frames' + /// one registry, `0x6ee500`), then the implicit creation anchor, run after the region's + /// LoadXML as the reference does (`0x7701c0`, `0x771480`); an anchored region is left alone. + /// `None` when the creation raised. fn build_region( &mut self, raw: &Element, - region: &Element, wrapper: &Table, parent_name: &str, level: &str, is_texture: bool, dbg: &str, ) -> Option
{ - let rname: Option = region + let levels = self.levels(raw); + let rname: Option = raw .name() .map(|raw| framexml::resolve_name(raw, parent_name)); let method = if is_texture { @@ -93,28 +90,14 @@ impl Loader<'_> { return None; } }; - if region.attr_bool("hidden") { - if let Err(e) = region_wrapper.call_method::<()>("Hide", ()) { - self.report.fault(format!("{dbg}: region Hide: {e}")); + for node in &levels { + if is_texture { + self.load_texture_level(node, ®ion_wrapper, parent_name, dbg); + } else { + let attrs = FontAttrs::Own; + self.load_fontstring_level(node, ®ion_wrapper, parent_name, attrs, true, dbg); } } - if let Some(a) = region - .attr("alpha") - .and_then(|v| v.trim().parse::().ok()) - { - self.call_region(®ion_wrapper, "SetAlpha", a, dbg); - } - self.apply_region_layout(region, ®ion_wrapper, parent_name, dbg, FontAttrs::Own); - if !is_texture { - // Before the visual pass, so an explicit `` beats the font object's. - self.apply_fontstring_font(region, ®ion_wrapper, dbg); - } - let loads = if is_texture { - self.texture_file_loads(raw) - } else { - Vec::new() - }; - self.apply_region_visual(region, ®ion_wrapper, is_texture, &loads, dbg); if let Err(e) = crate::script::implicit_creation_anchor_lua(self.lua, ®ion_wrapper) { self.report.fault(format!("{dbg}: implicit anchor: {e}")); } @@ -139,7 +122,7 @@ impl Loader<'_> { if !region.tag.eq_ignore_ascii_case("FontString") { continue; } - let region = &self.expand_region(region); + let levels = self.levels(region); let rname: Option = region .name() .map(|raw| framexml::resolve_name(raw, parent_name)); @@ -173,9 +156,10 @@ impl Loader<'_> { } }, }; - self.apply_region_layout(region, ®ion_wrapper, parent_name, dbg, FontAttrs::Own); - self.apply_fontstring_font(region, ®ion_wrapper, dbg); - self.apply_region_visual(region, ®ion_wrapper, false, &[], dbg); + for node in &levels { + let attrs = FontAttrs::Own; + self.load_fontstring_level(node, ®ion_wrapper, parent_name, attrs, true, dbg); + } // The implicit creation anchor, as in `apply_layers`; an EditBox's adopt replaces it. if let Err(e) = crate::script::implicit_creation_anchor_lua(self.lua, ®ion_wrapper) { self.report.fault(format!("{dbg}: implicit anchor: {e}")); @@ -189,7 +173,7 @@ impl Loader<'_> { self.report .fault(format!("{dbg}: adopt special FontString: {e}")); } - if let Some(bytes) = region.attr("bytes") { + if let Some(bytes) = levels.iter().rev().find_map(|l| l.attr("bytes")) { if let Err(e) = crate::script::apply_xml_bytes(self.lua(), wrapper, bytes) { self.report.fault(format!("{dbg}: FontString bytes: {e}")); } @@ -227,169 +211,233 @@ impl Loader<'_> { } } - /// A region's geometry: ``, a FontString's justify when [`FontAttrs::Own`], - /// `setAllPoints` and ``, whose `$parent` is the owner's name, or a nameless owner's - /// nearest named ancestor's. - pub(super) fn apply_region_layout( + /// One level of a ``'s `LoadXML` (`0x770f40`), after its `inherits` level: an + /// `inherits` font object, the geometry, `hidden`, `text`, the wrap attributes, then, when the + /// string owns its font attributes ([`FontAttrs`], `0x7710db`), `font=`, `spacing`, + /// `justifyV`, `justifyH` and the `` and `` children, in that order. A `font=` + /// file needs this node's own first `` above 0 (`0x77112f`-`0x771154`); without + /// one the level reports `0x878e10` and returns (`0x771191`), so nothing after the font is + /// read on it, whatever height a template gave. `with_text` is false for a Button's label, + /// whose text the button sets. + pub(super) fn load_fontstring_level( &mut self, - region: &Element, + node: &Element, wrapper: &Table, parent_name: &str, + attrs: FontAttrs, + with_text: bool, dbg: &str, - font_attrs: FontAttrs, ) { - // Every `` in document order, as in `apply_size`. - for size in children_named(region, "Size") { - let (x, y) = abs_dim(size); - if let Some(w) = x { - self.call_region(wrapper, "SetWidth", w, dbg); - } - if let Some(h) = y { - self.call_region(wrapper, "SetHeight", h, dbg); + let own = attrs == FontAttrs::Own; + // `inherits=` names a font object first (`0x770f69`), linked only when the string owns + // its font attributes (`0x770f72`), else a template, loaded as a level of its own. Any + // other name passes through and is reported: `SetFontObject("")` would clear the font + // object where a bad name warns. + if let Some(name) = node.attr("inherits").filter(|n| !n.is_empty()) { + let template = self.model().framexml_templates.borrow().contains(name); + if own && (self.is_font_object(name) || !template) { + self.set_font_object(wrapper, "inherits", name, dbg); } } - if font_attrs == FontAttrs::Own { - if let Some(j) = region.attr("justifyH") { - self.call_region(wrapper, "SetJustifyH", j.to_string(), dbg); - } - if let Some(j) = region.attr("justifyV") { - self.call_region(wrapper, "SetJustifyV", j.to_string(), dbg); - } - // `spacing=` (`0x771257`) goes through the setter and clears inherit bit `0x10` - // (`0x771298`), so it outlasts the font object's. - if let Some(sp) = region.attr("spacing").filter(|v| !v.is_empty()) { - self.call_region(wrapper, "SetSpacing", crate::framexml::atof(sp), dbg); + self.apply_geometry(node, wrapper, true, parent_name, dbg); + match node.attr_bool_opt("hidden") { + Some(true) => self.call_region(wrapper, "Hide", (), dbg), + Some(false) => self.call_region(wrapper, "Show", (), dbg), + None => {} + } + if let Some(a) = node + .attr("alpha") + .and_then(|v| v.trim().parse::().ok()) + { + self.call_region(wrapper, "SetAlpha", a, dbg); + } + // An empty `text` is skipped (`0x77101b`), so a template's text stands. The string's own + // `SetText` (`0x77103a` → `0x771d80`), not Lua's, which raises on a string with no font + // (`0x79d7e5`); `LoadXML` sets the text before the font. + if let Some(raw) = node.attr("text").filter(|t| with_text && !t.is_empty()) { + let text = self.resolve_text(raw, dbg); + if let Err(e) = crate::script::region::store_text_of(self.lua, wrapper, Some(text)) { + self.report.fault(format!("{dbg}: region SetText: {e}")); } } - if region.attr_is_true("setAllPoints") { - self.call_region(wrapper, "SetAllPoints", (), dbg); + self.apply_wrap_attrs(node, wrapper, dbg); + if !own { + return; } - for anchors in children_named(region, "Anchors") { - for anchor in children_named(anchors, "Anchor") { - let Some(point) = anchor.attr("point") else { - self.report - .warnings - .push(format!("{dbg}: region without a point; skipped")); - continue; - }; - let rel_point = anchor.attr("relativePoint").unwrap_or(point).to_string(); - let rel_to: Option = anchor - .attr("relativeTo") - .map(|r| framexml::resolve_name(r, parent_name)); - let (x, y) = children_named(anchor, "Offset") + // `font=`, empty as absent (`0x7710f7`): a font object's name links it (`0x771114`), any + // other value is a file its own `` and `outline=` qualify (`0x77110b`), so + // `ZoneText.xml`'s `AutoFollowStatusText` draws at GameFontNormal's 12, not its own 20. + match node.attr("font").filter(|f| !f.is_empty()) { + Some(name) if self.is_font_object(name) => { + self.set_font_object(wrapper, "font", name, dbg); + } + Some(path) => { + let height = children_named(node, "FontHeight") .next() - .map(abs_dim) - .unwrap_or((None, None)); - let args = ( - point.to_string(), - rel_to, - rel_point, - x.unwrap_or(0.0), - y.unwrap_or(0.0), - ); - let d = super::DeferredAnchor { - wrapper: wrapper.clone(), - region: true, - args, - dbg: dbg.to_string(), + .and_then(abs_value) + .filter(|h| *h > 0.0); + let Some(height) = height else { + let name = self.object_name(wrapper); + self.report_missing_font_height("FontString", &name, &node.tag); + return; }; - // Deferred while the enclosing subtree has not built its target yet. - self.apply_anchor(d, true); + // The flags word, set whole on this leg (`0x771194`-`0x771214`): `outline=` is + // NORMAL or THICK, and any non-empty `monochrome=` adds the flag, even "false". + let flags = ( + node.attr("outline") + .map(crate::script::Outline::parse) + .unwrap_or_default(), + node.attr("monochrome").is_some_and(|v| !v.is_empty()), + ); + if let Err(e) = crate::script::apply_font_parts( + self.lua, + wrapper, + Some(path.to_string()), + Some(height), + Some(flags), + ) { + self.report.fault(format!("{dbg}: region font attrs: {e}")); + } + } + None => {} + } + // `spacing=` (`0x771257`) goes through the setter and clears inherit bit `0x10` + // (`0x771298`), so it outlasts the font object's. + if let Some(sp) = node.attr("spacing").filter(|v| !v.is_empty()) { + self.call_region(wrapper, "SetSpacing", crate::framexml::atof(sp), dbg); + } + if let Some(j) = node.attr("justifyV").filter(|v| !v.is_empty()) { + self.call_region(wrapper, "SetJustifyV", j.to_string(), dbg); + } + if let Some(j) = node.attr("justifyH").filter(|v| !v.is_empty()) { + self.call_region(wrapper, "SetJustifyH", j.to_string(), dbg); + } + // Each `` is the text colour (`0x77f750`) and each `` the shadow + // (`0x7722a0`), both local to the string, so they outlast the font object's. + for child in &node.children { + if child.tag.eq_ignore_ascii_case("Color") { + let c = color_of(child); + self.call_region(wrapper, "SetVertexColor", (c[0], c[1], c[2], c[3]), dbg); + } else if child.tag.eq_ignore_ascii_case("Shadow") { + let sh = super::shadow_of(child); + let c = sh.color; + self.call_region(wrapper, "SetShadowColor", (c[0], c[1], c[2], c[3]), dbg); + self.call_region( + wrapper, + "SetShadowOffset", + (sh.offset[0], sh.offset[1]), + dbg, + ); } } } - pub(super) fn apply_region_visual( + /// `SetFontObject` for an XML `=` naming `name`, a failure reported once. + fn set_font_object(&mut self, wrapper: &Table, attr: &str, name: &str, dbg: &str) { + if let Err(e) = wrapper.call_method::<()>("SetFontObject", name.to_string()) { + self.warn_once( + &format!("fontobj:{name}"), + format!("{dbg}: : {e}"), + ); + } + } + + /// One level of a ``'s `LoadXML` (`0x76fe20`), after its `inherits` level: the + /// geometry, `hidden`, each child in order, then `file=` and `alphaMode`. A `` sets + /// all eight coordinates from its own edges over 0, 1, 0, 1, so the last one applied wins whole + /// (`0x76fef5`, `0x76ffe8`), and any edge outside [0, 1] sets the level's wrap flag, which the + /// file load takes (`0x770071`, `0x77012d`); a later `SetTexCoord` leaves it, and `SetTexture` + /// loads with it off. A `` is a solid fill (`0x770360`), an 8×8 texture in the + /// reference, so its alpha multiplies with `SetVertexColor` rather than being replaced by it; + /// the level's `file=`, read after its children (`0x770102`), overwrites the same slot. + fn load_texture_level( &mut self, - region: &Element, - region_wrapper: &Table, - is_texture: bool, - file_loads: &[(String, bool)], + node: &Element, + wrapper: &Table, + parent_name: &str, dbg: &str, ) { - let color = children_named(region, "Color").next().map(color_of); - if is_texture { - if !file_loads.is_empty() { - // A `` beside `file=` is discarded, not a tint: `0x76fe20` applies it in its - // child loop, then the file, read after (`0x770102`), overwrites the same slot. - // Each `inherits` level loads its own file with the wrap flag of its own - // ``, fixed here: a later `SetTexCoord` leaves it, and `SetTexture` - // loads with it off (`0x77012d`). - for (file, wrap) in file_loads { - if let Err(e) = - crate::script::load_texture_file(self.lua, region_wrapper, file, *wrap) - { - self.report.fault(format!("{dbg}: region SetTexture: {e}")); - } - } - } else if let Some(c) = color { - // No file: a solid fill, an 8×8 texture in the reference, so its alpha multiplies - // with `SetVertexColor` rather than being replaced by it. - self.call_region(region_wrapper, "SetTexture", (c[0], c[1], c[2], c[3]), dbg); - } - // `` writes the vertex colours (`+0xb8`, `0x7700f2` → `0x77f910`), which - // `file=` leaves alone, so unlike `` it tints the art. `0x6f2630` stages both - // stops as 0 (`0x7700d8`), so a missing one is transparent black, and reads a missing - // orientation as HORIZONTAL; an unknown one stays HORIZONTAL and is reported. - if let Some(g) = children_named(region, "Gradient").next() { - let stop = |tag: &str| children_named(g, tag).next().map_or([0.0; 4], color_of); - let (min, max) = (stop("MinColor"), stop("MaxColor")); - let token = g.attr("orientation").unwrap_or_default(); - let vertical = match crate::script::region::orientation(token) { - Some(v) => v, - None if token.is_empty() => false, - None => { - let line = format!("Unknown orientation {token} in element {}", g.tag); - self.report.status.report(status::WARNING, line.clone()); - self.report.warnings.push(line); - false - } - }; - self.call_region( - region_wrapper, - "SetGradientAlpha", - ( - if vertical { "VERTICAL" } else { "HORIZONTAL" }, - min[0], - min[1], - min[2], - min[3], - max[0], - max[1], - max[2], - max[3], - ), - dbg, - ); - } - if let Some(tc) = tex_coords_of(region) { - self.call_region(region_wrapper, "SetTexCoord", tc, dbg); - } - if let Some(mode) = region.attr("alphaMode") { - self.call_region(region_wrapper, "SetBlendMode", mode.to_string(), dbg); - } - } else { - if let Some(text) = region.attr("text") { - // A global-string key, not a literal (`0x703bf0`). - let text = self.resolve_text(text, dbg); - // The string's own `SetText` (`0x77103a` → `0x771d80`), not Lua's, which raises on - // a string with no font (`0x79d7e5`); `LoadXML` sets the text before the font. - if let Err(e) = - crate::script::region::store_text_of(self.lua, region_wrapper, Some(text)) - { - self.report.fault(format!("{dbg}: region SetText: {e}")); - } - } - self.apply_wrap_attrs(region, region_wrapper, dbg); - if let Some(c) = color { - self.call_region( - region_wrapper, - "SetVertexColor", - (c[0], c[1], c[2], c[3]), - dbg, - ); + self.apply_geometry(node, wrapper, true, parent_name, dbg); + match node.attr_bool_opt("hidden") { + Some(true) => self.call_region(wrapper, "Hide", (), dbg), + Some(false) => self.call_region(wrapper, "Show", (), dbg), + None => {} + } + if let Some(a) = node + .attr("alpha") + .and_then(|v| v.trim().parse::().ok()) + { + self.call_region(wrapper, "SetAlpha", a, dbg); + } + let mut wrap = false; + for child in &node.children { + if child.tag.eq_ignore_ascii_case("TexCoords") { + let (l, r, t, b) = super::tex_coords(child); + // Not `contains`: a NaN edge is in range here. + #[allow(clippy::manual_range_contains)] + let out_of_range = |edge: f32| edge < 0.0 || edge > 1.0; + wrap |= [l, r, t, b].into_iter().any(out_of_range); + self.call_region(wrapper, "SetTexCoord", (l, r, t, b), dbg); + } else if child.tag.eq_ignore_ascii_case("Color") { + let c = color_of(child); + self.call_region(wrapper, "SetTexture", (c[0], c[1], c[2], c[3]), dbg); + } else if child.tag.eq_ignore_ascii_case("Gradient") { + self.apply_gradient(child, wrapper, dbg); } } + // A file that fails to load is logged (`0x878d24`) and the texture filled opaque green + // (`0xff00ff00` through `0x770360`, `0x77016a`), the reference's tell for a bad path. + if let Some(file) = node.attr("file").filter(|f| !f.is_empty()) { + match crate::script::load_texture_file(self.lua, wrapper, file, wrap) { + Ok(crate::script::FileLoad::Failed) => { + let name = self.object_name(wrapper); + self.status_warning(format!( + "Texture {name}: Unable to load texture file {file}" + )); + self.call_region(wrapper, "SetTexture", (0.0, 1.0, 0.0, 1.0), dbg); + } + Ok(_) => {} + Err(e) => self.report.fault(format!("{dbg}: region SetTexture: {e}")), + } + } + if let Some(mode) = node.attr("alphaMode") { + self.call_region(wrapper, "SetBlendMode", mode.to_string(), dbg); + } + } + + /// A texture's ``: it writes the vertex colours (`+0xb8`, `0x7700f2` → `0x77f910`), + /// which `file=` leaves alone, so unlike `` it tints the art. `0x6f2630` stages both + /// stops as 0 (`0x7700d8`), so a missing one is transparent black, and reads a missing + /// orientation as HORIZONTAL; an unknown one stays HORIZONTAL and is reported. + fn apply_gradient(&mut self, g: &Element, wrapper: &Table, dbg: &str) { + let stop = |tag: &str| children_named(g, tag).next().map_or([0.0; 4], color_of); + let (min, max) = (stop("MinColor"), stop("MaxColor")); + let token = g.attr("orientation").unwrap_or_default(); + let vertical = match crate::script::region::orientation(token) { + Some(v) => v, + None if token.is_empty() => false, + None => { + let line = format!("Unknown orientation {token} in element {}", g.tag); + self.status_warning(line); + false + } + }; + self.call_region( + wrapper, + "SetGradientAlpha", + ( + if vertical { "VERTICAL" } else { "HORIZONTAL" }, + min[0], + min[1], + min[2], + min[3], + max[0], + max[1], + max[2], + max[3], + ), + dbg, + ); } /// The font object a FontString's `inherits=` names, directly or through virtual FontString @@ -427,79 +475,6 @@ impl Loader<'_> { let fonts = model.framexml_fonts.borrow(); fonts.contains_key(name) || fonts.keys().any(|k| k.eq_ignore_ascii_case(name)) } - - /// A ``'s font: `inherits=` through [`Self::font_object_through_templates`], then - /// `font=`, a font object's name first and a file only on a miss (`0x770f40`, `0x771104`). - pub(super) fn apply_fontstring_font(&mut self, region: &Element, wrapper: &Table, dbg: &str) { - if let Some(name) = region.attr("inherits") { - // The expanded element keeps the instance's `inherits=`, a template's name, so the - // chain is walked to its font object. An unresolved name passes through unchanged: - // `SetFontObject("")` would clear the font object where a bad name warns. - let resolved = self - .font_object_through_templates(name) - .unwrap_or_else(|| name.to_string()); - if let Err(e) = wrapper.call_method::<()>("SetFontObject", resolved) { - self.warn_once( - &format!("fontobj:{name}"), - format!("{dbg}: : {e}"), - ); - } - } - // `font=` gates `` and `outline=` (`0x7710e1`-`0x771254`): a font object's - // name links it and skips them (`0x771114`, `0x771119`), any other value is a file they - // qualify (`0x77110b`), and with no `font=` they are never read (`0x7710fa`), so - // `ZoneText.xml`'s `AutoFollowStatusText` draws at GameFontNormal's 12, not its own 20. - match region.attr("font") { - Some(name) if self.is_font_object(name) => { - if let Err(e) = wrapper.call_method::<()>("SetFontObject", name.to_string()) { - self.warn_once( - &format!("fontobj:{name}"), - format!("{dbg}: : {e}"), - ); - } - } - Some(path) => { - // A file needs a first `` above 0 (`0x77112f`-`0x771154`); else the - // load reports `0x878e10` (`0x77117d`) and returns (`0x771191`), before `outline=` - // and with the string's font unset. - let height = children_named(region, "FontHeight") - .next() - .and_then(abs_value) - .filter(|h| *h > 0.0); - let Some(height) = height else { - let name = wrapper - .call_method::>("GetName", ()) - .ok() - .flatten(); - self.report_missing_font_height( - "FontString", - name.as_deref().unwrap_or(""), - ®ion.tag, - ); - return; - }; - // The flags word, set whole on this leg (`0x771194`-`0x771214`): `outline=` is - // NORMAL or THICK, and any non-empty `monochrome=` adds the flag, even "false". - let flags = ( - region - .attr("outline") - .map(crate::script::Outline::parse) - .unwrap_or_default(), - region.attr("monochrome").is_some_and(|v| !v.is_empty()), - ); - if let Err(e) = crate::script::apply_font_parts( - self.lua, - wrapper, - Some(path.to_string()), - Some(height), - Some(flags), - ) { - self.report.fault(format!("{dbg}: region font attrs: {e}")); - } - } - None => {} - } - } } /// The client's decimal parse `0x64ade0`: the leading digits of `s`, wrapping in 32 bits, and 0 diff --git a/crates/benilla-ui/src/loader/scripts.rs b/crates/benilla-ui/src/loader/scripts.rs index 36fa26b1c..23ac28b8b 100644 --- a/crates/benilla-ui/src/loader/scripts.rs +++ b/crates/benilla-ui/src/loader/scripts.rs @@ -151,6 +151,22 @@ impl Loader<'_> { } } + /// [`Self::call_region`] on a region wrapper, else [`Self::call`]. + pub(super) fn call_on( + &mut self, + wrapper: &Table, + region: bool, + method: &str, + args: impl mlua::IntoLuaMulti, + dbg: &str, + ) { + if region { + self.call_region(wrapper, method, args, dbg); + } else { + self.call(wrapper, method, args, dbg); + } + } + /// Call a region-wrapper method, recording (not propagating) any error. pub(super) fn call_region( &mut self, diff --git a/crates/benilla-ui/src/loader/tests.rs b/crates/benilla-ui/src/loader/tests.rs index ce02fa071..12ea4e1e2 100644 --- a/crates/benilla-ui/src/loader/tests.rs +++ b/crates/benilla-ui/src/loader/tests.rs @@ -1555,10 +1555,11 @@ mod loader_tests { assert_eq!(base.height, Some(12.0)); assert_eq!(base.color, Some([1.0, 0.82, 0.0, 1.0])); - // Leaf: Base's face through Mid, Mid's white, its own height of 10. + // Leaf: Base's face and height through Mid, and Mid's white; its own `` has + // no `font=` beside it, so it is never read (`0x783cf1`). let leaf = s.font_object("Leaf").expect("Leaf registered"); assert_eq!(leaf.font.as_deref(), Some("Fonts\\FRIZQT__.TTF")); - assert_eq!(leaf.height, Some(10.0)); + assert_eq!(leaf.height, Some(12.0)); assert_eq!(leaf.color, Some([1.0, 1.0, 1.0, 1.0])); } diff --git a/crates/benilla-ui/src/loader/widgets.rs b/crates/benilla-ui/src/loader/widgets.rs index 40f0fa418..66e1ba767 100644 --- a/crates/benilla-ui/src/loader/widgets.rs +++ b/crates/benilla-ui/src/loader/widgets.rs @@ -4,7 +4,7 @@ use crate::framexml::{self, Element}; use crate::script::LabelFont; use super::regions::FontAttrs; -use super::{abs_dim, abs_value, children_named, children_named_any, color_of, Loader}; +use super::{abs_dim, abs_value, children_named, children_named_any, color_of, Levels, Loader}; impl Loader<'_> { /// ``'s `` (`LoadXML 0x786bc0`): after the base load (`0x769820`) it @@ -32,8 +32,7 @@ impl Loader<'_> { )); continue; }; - let expanded = self.expand(child); - let Some(child_wrapper) = self.materialize(&expanded, Some(wrapper), self_name) else { + let Some(child_wrapper) = self.materialize(child, Some(wrapper), self_name) else { continue; // materialize already reported why }; if let Err(e) = wrapper.call_method::<()>("SetScrollChild", child_wrapper) { @@ -42,11 +41,13 @@ impl Loader<'_> { } } - /// `` (`0x782ef0`): a reversed `minValue`/`maxValue` pair is swapped, as - /// `SetMinMaxValues` also does; `drawLayer` is the bar texture's layer, ARTWORK by default. + /// `` (`0x782ef0`): the ``, built on `drawLayer` (ARTWORK by default), + /// and ``, then for each level ([`Loader::levels`]) its range, its value and its + /// orientation. pub(super) fn apply_statusbar( &mut self, el: &Element, + levels: &Levels, wrapper: &Table, self_name: &str, dbg: &str, @@ -54,19 +55,6 @@ impl Loader<'_> { if !el.tag.eq_ignore_ascii_case("StatusBar") { return; } - let parse = |attr: &str| el.attr(attr).and_then(|v| v.trim().parse::().ok()); - let (min, max) = (parse("minValue"), parse("maxValue")); - if min.is_some() || max.is_some() { - self.call( - wrapper, - "SetMinMaxValues", - (min.unwrap_or(0.0), max.unwrap_or(0.0)), - dbg, - ); - } - if let Some(o) = el.attr("orientation") { - self.call(wrapper, "SetOrientation", o.to_string(), dbg); - } let layer = el.attr("drawLayer"); for bar in children_named(el, "BarTexture") { // Built as any texture element is, and installed as the bar on `drawLayer`, ARTWORK @@ -80,8 +68,39 @@ impl Loader<'_> { let c = color_of(bc); self.call(wrapper, "SetStatusBarColor", (c[0], c[1], c[2], c[3]), dbg); } - if let Some(v) = parse("defaultValue") { - self.call(wrapper, "SetValue", v, dbg); + for level in levels { + // Each value with `atof`, present and non-empty: the range needs both bounds on the + // node, and `defaultValue` is read only after it (`0x782fb0`-`0x783021`). The setter + // `0x783270` clamps a min above the max down to it. + let value = |attr: &str| { + level + .attr(attr) + .filter(|v| !v.is_empty()) + .map(framexml::atof) + }; + if let (Some(min), Some(max)) = (value("minValue"), value("maxValue")) { + self.call(wrapper, "SetMinMaxValues", (min, max), dbg); + if let Some(v) = value("defaultValue") { + self.call(wrapper, "SetValue", v, dbg); + } + } + // `orientation` through the token parse `0x6f1a20`: empty is skipped, a miss logged + // (`0x87a3c4`) and never raised (`0x783064`), where the Lua setter raises. + if let Some(token) = level.attr("orientation").filter(|v| !v.is_empty()) { + match crate::script::region::orientation(token) { + Some(vertical) => { + let o = if vertical { "VERTICAL" } else { "HORIZONTAL" }; + self.call(wrapper, "SetOrientation", o, dbg); + } + None => { + let name = self.object_name(wrapper); + self.status_warning(format!( + "Frame {name}: Unknown orientation {token} in element {}", + level.tag + )); + } + } + } } } @@ -91,6 +110,7 @@ impl Loader<'_> { pub(super) fn apply_button( &mut self, el: &Element, + levels: &Levels, wrapper: &Table, self_name: &str, dbg: &str, @@ -137,11 +157,15 @@ impl Loader<'_> { FontAttrs::Own }; // `SetText` creates the label even with no text, so its layout lands on a real region; - // `text=` is a global-string key (`0x703bf0`). - let label = match bt.attr("text") { - Some(raw) => self.resolve_text(raw, dbg), - None => String::new(), - }; + // `text=` is each level's, a global-string key (`0x703bf0`), an empty one skipped + // (`0x77101b`). + let label_levels = self.levels(bt); + let mut label = String::new(); + for level in &label_levels { + if let Some(raw) = level.attr("text").filter(|t| !t.is_empty()) { + label = self.resolve_text(raw, dbg); + } + } // A named label is created as a named region, not aliased after `SetText`, so its // `GetName()` answers. let bt_name = bt.name().map(|raw| framexml::resolve_name(raw, self_name)); @@ -162,14 +186,12 @@ impl Loader<'_> { // `0x778b96`); the button's own adopter anchor never fires on them (`0x778d5f`). // On `` the justify is the Normal font's, so the implicit anchor reads // the ctor's CENTER (`0x212`); the justify still reaches the paint (`0x784111`). + // Both load as any FontString does (`0x6f2780` → `0x770f40`, `0x778b8f`), each + // level in turn; `` skips its font attributes (`0x778b7b`). self.call_region(®ion, "ClearAllPoints", (), dbg); - self.apply_region_layout(bt, ®ion, self_name, dbg, font_attrs); - // `` reads its own `inherits=` and `font=` as any FontString does - // (`0x6f2780` → `0x770f40`); `` skips them (`0x778b7b`). - if font_attrs == FontAttrs::Own { - self.apply_fontstring_font(bt, ®ion, dbg); + for level in &label_levels { + self.load_fontstring_level(level, ®ion, self_name, font_attrs, false, dbg); } - self.apply_wrap_attrs(bt, ®ion, dbg); if let Err(e) = crate::script::implicit_creation_anchor_lua(self.lua, ®ion) { self.report.fault(format!("{dbg}: implicit anchor: {e}")); } @@ -196,10 +218,13 @@ impl Loader<'_> { self.call(wrapper, "SetChecked", checked, dbg); } } - if let Some(text) = el.attr("text") { - // A global-string key (`0x703bf0`): `text="DELETE"` reads "Delete". - let text = self.resolve_text(text, dbg); - self.call(wrapper, "SetText", text, dbg); + // Each level's `text=`, a global-string key (`0x703bf0`, `text="DELETE"` reads "Delete"), + // an empty one skipped (`0x778c1a`). + for level in levels { + if let Some(text) = level.attr("text").filter(|t| !t.is_empty()) { + let text = self.resolve_text(text, dbg); + self.call(wrapper, "SetText", text, dbg); + } } // The per-state fonts, two spellings each; every occurrence applies, in document order. for (children, method, which) in [ @@ -479,7 +504,7 @@ impl Loader<'_> { } /// One `` font child's element font, with the same `font=` gate as - /// [`Self::apply_fontstring_font`]. + /// [`Self::load_fontstring_level`]. fn apply_element_font(&mut self, el: &Element, wrapper: &Table, elem: usize, dbg: &str) { let name = ["P", "H1", "H2", "H3"][elem]; if let Some(inherits) = el.attr("inherits").filter(|n| !n.is_empty()) { diff --git a/crates/benilla-ui/src/script/font.rs b/crates/benilla-ui/src/script/font.rs index 25f89c41a..3db7b59bc 100644 --- a/crates/benilla-ui/src/script/font.rs +++ b/crates/benilla-ui/src/script/font.rs @@ -15,7 +15,7 @@ //! that mask. `+0x38` is not it; its high bits mark a held value, which the inheriting merge also //! sets (`0x7709d3`). //! -//! Not built: a `` chain is flattened at load (`Loader::do_font`), so mutating +//! Not built: a `` parent is copied at load (`Loader::do_font`), so mutating //! `MasterFont` does not reach `GameFontNormal`, where the reference links it live: `LoadXML //! 0x783c30` calls the `SetFontObject` link `0x770c60` for `inherits=` (`0x783ce6`) and `font=` //! (`0x783d22`). No corpus addon mutates a declared font object. diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 2d1108261..5e5e40460 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -300,7 +300,7 @@ pub use quest::{ pub use quest_log::{QuestObjectiveView, QuestRecordView, QuestSlotView, QUEST_LOG_SLOTS}; pub(crate) use region::{ apply_font_parts, apply_wrap_attrs, implicit_creation_anchor_lua, install_loaded_texture, - load_texture_file, + load_texture_file, FileLoad, }; pub use reputation::{FactionEntry, ReputationSend, ReputationState}; pub use session::SessionRequest; diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index bba04a38a..a02a5ac54 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -440,7 +440,7 @@ mod layout; mod paint; mod text; -pub(crate) use paint::{load_texture_file, orientation}; +pub(crate) use paint::{load_texture_file, orientation, FileLoad}; pub(crate) use text::store_text_of; fn install_region_methods(lua: &Lua) -> mlua::Result<()> { diff --git a/crates/benilla-ui/src/script/region/paint.rs b/crates/benilla-ui/src/script/region/paint.rs index dae249ff0..cc62314e7 100644 --- a/crates/benilla-ui/src/script/region/paint.rs +++ b/crates/benilla-ui/src/script/region/paint.rs @@ -454,6 +454,17 @@ impl crate::script::UiScript { } } +/// What an XML `file=` load came to ([`load_texture_file`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FileLoad { + /// The host's texture probe found the file. + Loaded, + /// The probe did not: `0x770200` returned 0 and the texture kept what it had (`0x7702ac`). + Failed, + /// No probe is installed (a test harness): the path is stored, with no verdict. + Unprobed, +} + /// The XML `` load of a region (`0x76fe20` → `0x770200`): the path form of /// [`set_texture`] with the wrap flag the element's `` decide. pub(crate) fn load_texture_file( @@ -461,9 +472,14 @@ pub(crate) fn load_texture_file( this: &Table, file: &str, wrap: bool, -) -> mlua::Result<()> { +) -> mlua::Result { + let probed = lua + .app_data_ref::() + .expect("model") + .texture_probe + .is_some(); let path = Value::String(lua.create_string(file)?); - set_texture( + let answer = set_texture( lua, this.clone(), path, @@ -471,8 +487,12 @@ pub(crate) fn load_texture_file( Value::Nil, Value::Nil, wrap, - ) - .map(drop) + )?; + Ok(match (probed, answer) { + (false, _) => FileLoad::Unprobed, + (true, Value::Nil) => FileLoad::Failed, + (true, _) => FileLoad::Loaded, + }) } /// `Texture:SetTexture` (`0x79bb40`) and the XML `` load (`0x76fe20`), the two diff --git a/crates/benilla-ui/src/script/tests/font_object.rs b/crates/benilla-ui/src/script/tests/font_object.rs index ea9c05cd0..25eb52652 100644 --- a/crates/benilla-ui/src/script/tests/font_object.rs +++ b/crates/benilla-ui/src/script/tests/font_object.rs @@ -100,7 +100,7 @@ fn tablet_reads_the_tooltip_header_size_off_the_global() { - + "#, @@ -126,7 +126,7 @@ fn tablet_reads_the_tooltip_header_size_off_the_global() { assert_eq!((header, normal), (14.0, 12.0)); } -/// `inherits=` is flattened once, at load, where the reference links it live (`0x770c60`), and +/// `inherits=` is copied once, at load, where the reference links it live (`0x770c60`), and /// the published object carries the flattened values. #[test] fn an_inheriting_font_still_flattens_to_the_same_values() { @@ -139,7 +139,7 @@ fn an_inheriting_font_still_flattens_to_the_same_values() { - + "#, diff --git a/crates/benilla-ui/src/script/tests/line_spacing.rs b/crates/benilla-ui/src/script/tests/line_spacing.rs index 2d74d68f8..81d34fcd8 100644 --- a/crates/benilla-ui/src/script/tests/line_spacing.rs +++ b/crates/benilla-ui/src/script/tests/line_spacing.rs @@ -513,7 +513,9 @@ fn the_spacing_attribute_is_read_with_atof() { let s = script(); let doc = crate::framexml::parse( r#" - + + + diff --git a/crates/benilla-ui/src/script/types.rs b/crates/benilla-ui/src/script/types.rs index d21321970..c23029ca1 100644 --- a/crates/benilla-ui/src/script/types.rs +++ b/crates/benilla-ui/src/script/types.rs @@ -303,7 +303,7 @@ pub struct FontShadow { pub color: [f32; 4], } -/// A named `` object's paint, flattened through its `inherits=` chain; a field the chain +/// A named `` object's paint, its own over what its `inherits=` parent had; a field the chain /// never sets is `None`. #[derive(Clone, Debug, Default, PartialEq)] pub struct FontObject { diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 4719a49cb..df32351a0 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -634,8 +634,8 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it #### loader -- A `