Files
benilla-octo/crates/benilla-ui/src/script/simplehtml/mod.rs
T
Sam SungandClaude Opus 5.5 fa5568dcdc Creating or freeing a frame or region re-derives only what it touches (#915)
Any write that changed the layout graph's roster (a frame or region created, freed, re-owned or re-pointed) made the next resolve derive the whole graph: about 1.35 ms for one `CreateTexture` or `CreateFrame` on the stock UI, against 0.05 ms for a no-op.

A write now names the nodes it touched, and the resolve re-derives just those in the cached graph: a joining node is added with its edges, a leaving one is removed and its dependents seeded, a staying one has its edges and inputs re-synced. The full derivation stays the fallback. A region's owner and an art change are now layout inputs that name their region. Verify now also compares the graph itself.

A randomized test checks rects and graph against a scratch solve after every batch of creates, frees, re-owns and re-anchors. `CreateTexture` now costs 0.14 ms, `CreateFrame` 0.15 ms, a backdrop rebuild 0.22 ms.

Fixes #903

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:05:23 +07:00

777 lines
32 KiB
Rust

//! The `SimpleHTML` widget (`CSimpleHTML`, size `0x374`, ctor `0x789dd0`): the blocks [`parse`]
//! produces, built as regions, its four element fonts, and its Lua method table.
//!
//! `SetText` drops the previous blocks and builds the parsed ones. A text block is a FontString at
//! the frame's declared width with no height; block 0 hangs from the frame's TOPLEFT and block N
//! from block N-1's BOTTOMLEFT, `spacing` lower (0 by default).
//!
//! A block is LEFT unless its `align` says otherwise, never the FontString ctor's CENTER
//! (`0x78a7c8`, `0x78ae78`). An element font starts empty, and `H1` to `H3` use `P`'s while their
//! path is empty (`0x78ae30`), with no header scaling. There is no `GetContentHeight`: the hosting
//! ScrollFrame measures the blocks as regions (`0x786e30`).
//!
//! Deviation: the block step is not pixel-snapped (`0x766750`), because layout here is in logical
//! units; the two differ by under a pixel, and not at all at the default spacing of 0. Line spacing
//! inside a block is not applied, as nothing here models line spacing; `SetSpacing` sets the step
//! between blocks. Deviation: a block of width 0, in a frame sized by anchors, gets its text's
//! natural width where the reference keeps 0, because every width-less FontString here does.
//!
//! The method table (`0x87ba80`) has 19 entries, all installed; the first sixteen take an optional
//! leading element name (`0x795d80`). Build 5875 has no `GetText`.
use std::collections::HashMap;
use mlua::{FromLua, Lua, MultiValue, Table, Value};
use super::object::frame_handle_of;
use super::{FontObject, FontShadow, Model, Outline, RegionData};
use crate::justify::{self, Justify};
use crate::layout::{Anchor, Point};
use crate::order::DrawLayer;
use crate::widget::{FrameHandle, FrameKind, RegionHandle, RegionKind};
mod parse;
pub(crate) use parse::Block;
use parse::{ELEMENT_NAMES, ELEM_P};
/// Registry key of the SimpleHTML method table (the MAXCSTACK discipline).
pub(super) const REG_SIMPLEHTML_METHODS: &str = "__benilla_simplehtml_methods";
// ── State ────────────────────────────────────────────────────────────────────────────────────
/// One of the four element fonts (`CSimpleHTML+0x350`, P, H1, H2, H3; made at
/// `0x789e61`-`0x789ea3`): the font object it inherits, a live link as the reference's
/// `parentFontObject` is, plus what it set itself.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct ElementFont {
/// `inherits=` or `SetFontObject`: the object this element resolves through.
pub(crate) font_object: Option<String>,
/// What this element set itself, the region's severance mask (`FONTINSTANCE+0x038`), which a
/// block inherits.
pub(crate) explicit: super::FontExplicit,
/// `+0x3c`, empty at the ctor: the path `0x78ae30` tests for the fallback to `P`.
pub(crate) font_path: Option<String>,
/// `FieldBlock` height.
pub(crate) font_height: Option<f32>,
/// `SetFont`'s flags / XML `outline=`.
pub(crate) outline: Outline,
/// `SetTextColor` / `<Color>`.
pub(crate) color: Option<[f32; 4]>,
/// `SetShadowColor`/`SetShadowOffset` / `<Shadow>`.
pub(crate) shadow: Option<FontShadow>,
/// `CSimpleFont+0x54`, ctor `0x212` (`0x783a98`): CENTER, MIDDLE and the one-to-one bit.
pub(crate) justify: Justify,
/// `+0x50`, ctor 0 (`0x783a81`): the step between blocks and, in the reference, the line gap.
pub(crate) spacing: f32,
}
/// A `SimpleHTML` frame's state, the members `CSimpleHTML` adds; in a [`Model`] map, not
/// [`crate::widget::KindState`], because it holds script-layer types.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct SimpleHtmlState {
/// `+0x350` to `+0x35c`.
pub(crate) fonts: [ElementFont; 4],
/// `+0x360`, ctor `"|H%s|h%s|h"` (`0x87a838`).
pub(crate) hyperlink_format: String,
/// The content list (`+0x340`): every region the last `SetText` built, freed by the next.
pub(crate) blocks: Vec<RegionHandle>,
}
impl Default for SimpleHtmlState {
fn default() -> Self {
SimpleHtmlState {
fonts: Default::default(),
hyperlink_format: parse::DEFAULT_HYPERLINK_FORMAT.to_string(),
blocks: Vec::new(),
}
}
}
/// The paint a block copies from its element font: `SetFontObject` (`0x770c60`) pulls all five
/// property groups at once (`0x770d06`), with the element's own sets on top.
#[derive(Clone, Debug, Default)]
struct BlockPaint {
font_object: Option<String>,
explicit: super::FontExplicit,
font_path: Option<String>,
font_height: Option<f32>,
outline: Outline,
color: Option<[f32; 4]>,
shadow: Option<FontShadow>,
justify: Justify,
spacing: f32,
}
/// Flatten one element font against the (live) font object it inherits.
fn resolve_font(model: &Model, ef: &ElementFont) -> BlockPaint {
let fo: Option<FontObject> = ef
.font_object
.as_deref()
.and_then(|n| model.font_object(n))
.cloned();
let mut p = BlockPaint {
font_object: ef.font_object.clone(),
explicit: ef.explicit,
justify: Justify::default(),
spacing: ef.spacing,
..BlockPaint::default()
};
if let Some(fo) = &fo {
p.font_path = fo.font.clone();
p.font_height = fo.height;
p.outline = fo.outline;
p.color = fo.color;
p.shadow = fo.shadow;
if let Some(j) = fo.justify_h {
p.justify.set_h(j);
}
if let Some(j) = fo.justify_v {
p.justify.set_v(j);
}
}
if ef.explicit.face {
p.font_path = ef.font_path.clone();
}
if ef.explicit.height {
p.font_height = ef.font_height;
}
if ef.explicit.outline {
p.outline = ef.outline;
}
if ef.explicit.color {
p.color = ef.color;
}
if ef.explicit.shadow {
p.shadow = ef.shadow;
}
if ef.explicit.justify_h {
p.justify.0 = justify::set_axis(p.justify.0, justify::H_MASK, ef.justify.0);
}
if ef.explicit.justify_v {
p.justify.0 = justify::set_axis(p.justify.0, justify::V_MASK, ef.justify.0);
}
p
}
// ── SetText, the rebuild ─────────────────────────────────────────────────────────────────────
/// `CSimpleHTML::SetText` (`0x78a3a0`): free the old blocks, parse, build. Returns `usedMarkup`
/// (`0x78a519`), which the Lua shim drops.
pub(crate) fn set_text(model: &mut Model, fh: FrameHandle, raw: &str) -> bool {
// Free the previous parse's regions first, as the reference does, or a book's next page
// draws over the last.
let old = model
.simple_html
.get_mut(&fh)
.map(|s| std::mem::take(&mut s.blocks))
.unwrap_or_default();
for rh in old {
free_block(model, rh);
}
let (frame_name, hyperlink_format) = {
let name = model
.arena
.frame(fh)
.and_then(|f| f.name.clone())
.unwrap_or_default();
let fmt = model
.simple_html
.get(&fh)
.map(|s| s.hyperlink_format.clone())
.unwrap_or_else(|| parse::DEFAULT_HYPERLINK_FORMAT.to_string());
(name, fmt)
};
let parsed = parse::parse_markup(raw, &frame_name, &hyperlink_format);
// Deviation: the reference prints these to the console; here they are host warnings, because
// a malformed page is bad content, not a script error.
for e in &parsed.errors {
model.record_warning(e.clone());
}
build(model, fh, &parsed.blocks);
parsed.used_markup
}
/// The block loop of `AddTextBlock` (`0x78adb0`) and the `<IMG>` handler (`0x78ab40`): anchors,
/// fonts and the running `nextY`.
fn build(model: &mut Model, fh: FrameHandle, blocks: &[Block]) {
let frame_id = model.frame_id(fh);
// The frame's declared width, not its resolved rect (`0x78ae19`, `0x768420`); a later resize
// does not re-wrap (`OnRectChanged` `0x78a320`), as each block keeps the width it was built at.
let width = model
.layout_inputs
.get(&fh)
.map_or(0.0, |input| input.width);
// `prevBlock` (`+0x348`), the last text block, and `nextYOffset` (`+0x34c`), never positive.
let mut prev_block: Option<u32> = None;
let mut next_y = 0.0f32;
let mut made: Vec<RegionHandle> = Vec::with_capacity(blocks.len());
for block in blocks {
match block {
Block::Text { text, elem, align } => {
// An element with an empty path uses `P`'s font (`0x78ae29`-`0x78ae54`), so a
// lone `<FontString>` serves every element.
let paint = {
let st = model.simple_html.entry(fh).or_default().clone();
let own = resolve_font(model, &st.fonts[*elem]);
if own.font_path.as_deref().unwrap_or("").is_empty() {
resolve_font(model, &st.fonts[ELEM_P])
} else {
own
}
};
let Some(rh) =
model
.arena
.create_region(fh, RegionKind::FontString, DrawLayer::Artwork, 0)
else {
// Unreachable; stop, keeping what was built recorded for the next `SetText`.
break;
};
let id = model.region_id(rh);
let mut d = RegionData {
anchors: vec![anchor_for(prev_block, frame_id, Point::TopLeft, next_y)],
// No height: the rect takes the wrapped text's height from the measure round
// trip, as the client's font engine gives it (`0x7729b0`, `0x5c2070`).
size: Some((width, 0.0)),
text: Some(text.clone()),
font_object: paint.font_object.clone(),
font_explicit: paint.explicit,
font_path: paint.font_path.clone(),
font_height: paint.font_height,
outline: paint.outline,
vertex_color: paint.color,
font_shadow: paint.shadow,
..RegionData::default()
};
// The tag's `align` is written after `SetFontObject` (`0x78ae78`), so it beats
// the font's justifyH; justifyV stays the font's, inert in a rect its text fills.
d.justify = Justify(justify::set_axis(paint.justify.0, justify::H_MASK, *align));
// Neither axis is inherited again (`0x78ae68`, `0x78ae95` clear the inherit bits),
// so a later font-object change never re-justifies a built block.
d.font_explicit.justify_h = true;
d.font_explicit.justify_v = true;
model.region_data.insert(rh, d);
model.touch_measure(rh); // a text block arrives with its text
prev_block = Some(id);
next_y = -paint.spacing;
made.push(rh);
}
Block::Image {
src,
width: w,
height: h,
align,
floated,
} => {
let Some(rh) =
model
.arena
.create_region(fh, RegionKind::Texture, DrawLayer::Artwork, 0)
else {
break; // unreachable, as above
};
// Minted though nothing anchors to an image; the resolve and `free_block` use it.
model.region_id(rh);
// `align` picks the corner; 8, 16 and 32 get no anchor at all (`0x78ac61`, a jump
// to `0x78ace8`), so the image draws nothing, here as there.
let point = match *align {
parse::ALIGN_LEFT => Some(Point::TopLeft),
parse::ALIGN_CENTER => Some(Point::Top),
parse::ALIGN_RIGHT => Some(Point::TopRight),
_ => None,
};
let anchors = point
.map(|p| vec![anchor_for(prev_block, frame_id, p, next_y)])
.unwrap_or_default();
model.region_data.insert(
rh,
RegionData {
anchors,
size: Some((*w, *h)),
texture: src.clone(),
..RegionData::default()
},
);
// An unfloated image moves the flow down by its height (`0x78ad07`-`0x78ad1b`), but
// the next block still hangs off the last text block: the image path never writes
// `prevBlock`. Without `height=` it reserves its texture's texel height
// (`CSimpleTexture`'s `GetHeight`), from the probe the resolve sizes it by.
if !*floated {
let reserved = if *h == 0.0 {
src.as_deref()
.and_then(|p| {
super::layout::texel_span(model.texture_size_probe.as_ref(), p)
})
.map_or(0.0, |(_, th)| th)
} else {
*h
};
next_y -= reserved;
}
made.push(rh);
}
}
}
// Each new block joins the graph; the old ones' frees named theirs.
for &rh in &made {
model.touch_layout_region(rh);
}
model.simple_html.entry(fh).or_default().blocks = made;
}
/// A block's anchor (`SetPoint` `0x767c70`): block 0 to the frame, block N to the previous text
/// block's bottom edge, `nextYOffset` lower.
fn anchor_for(prev_block: Option<u32>, frame_id: u32, point: Point, next_y: f32) -> Anchor {
match prev_block {
None => Anchor::new(point, frame_id, point, 0.0, 0.0),
Some(prev) => {
let rel = match point {
Point::TopLeft => Point::BottomLeft,
Point::Top => Point::Bottom,
Point::TopRight => Point::BottomRight,
other => other,
};
Anchor::new(point, prev, rel, 0.0, next_y)
}
}
}
fn free_block(model: &mut Model, rh: RegionHandle) {
crate::script::region::free_region(model, rh);
}
// ── The loader's seam ────────────────────────────────────────────────────────────────────────
/// Apply the `font=`, `<FontHeight>` and `outline=` an XML `<FontString>` or `<FontStringHeaderN>`
/// child supplies, any of them absent. The reference applies these in C++ (`CSimpleFont::LoadXML`
/// `0x783c30`, from `0x78a1fe`), not through `SetFont`, which requires both path and height.
pub(crate) fn apply_element_font_parts(
lua: &Lua,
this: &Table,
elem: usize,
path: Option<String>,
height: Option<f32>,
flags: Option<String>,
) -> mlua::Result<()> {
let fh = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let ef = &mut model.simple_html.entry(fh).or_default().fonts[elem];
if let Some(p) = path.filter(|p| !p.is_empty()) {
ef.font_path = Some(p);
ef.explicit.face = true;
}
if let Some(h) = height {
ef.font_height = Some(h);
ef.explicit.height = true;
}
if let Some(f) = flags {
ef.outline = Outline::parse(&f);
ef.explicit.outline = true;
}
Ok(())
}
/// The element index an XML child tag names: `<FontString>` is `P`, `<FontStringHeader1|2|3>`
/// `H1`, `H2`, `H3` (names `0x8786cc`, `0x87a8b4`, `0x87a8a0`, `0x87a88c`; read at `0x78a1fe` to
/// `0x78a26e`).
pub(crate) fn element_of_xml_tag(tag: &str) -> Option<usize> {
if tag.eq_ignore_ascii_case("FontString") {
Some(0)
} else if tag.eq_ignore_ascii_case("FontStringHeader1") {
Some(1)
} else if tag.eq_ignore_ascii_case("FontStringHeader2") {
Some(2)
} else if tag.eq_ignore_ascii_case("FontStringHeader3") {
Some(3)
} else {
None
}
}
// ── The Lua surface ──────────────────────────────────────────────────────────────────────────
/// Resolve `this` to a live `SimpleHTML` frame; only a misapplied method table fails.
fn html_handle(lua: &Lua, this: &Table) -> mlua::Result<FrameHandle> {
let h = frame_handle_of(lua, this)?;
let model = lua.app_data_ref::<Model>().expect("model");
match model.arena.frame(h).map(|f| f.kind) {
Some(FrameKind::SimpleHtml) => Ok(h),
_ => Err(mlua::Error::runtime("not a SimpleHTML")),
}
}
/// The optional leading element name of the first sixteen methods (`0x795d80`). `P`, `H1`, `H2`
/// or `H3`, in any case, is removed and selects that element; anything else selects `P` and stays
/// (`0x795e50`), so `SetFont("h4", path, 15)` sets `P`'s font with `"h4"` as the path.
fn take_element(args: &mut MultiValue) -> usize {
let matched = match args.front() {
Some(Value::String(s)) => s.to_str().ok().and_then(|s| {
ELEMENT_NAMES
.iter()
.position(|e| s.as_ref().eq_ignore_ascii_case(e))
}),
_ => None,
};
match matched {
Some(i) => {
args.pop_front();
i
}
None => ELEM_P,
}
}
/// Read argument `i` of the post-element list, or `nil`.
fn arg(args: &MultiValue, i: usize) -> Value {
args.get(i).cloned().unwrap_or(Value::Nil)
}
/// Run `f` over one element font under a short borrow; it shapes the next `SetText`'s blocks.
fn edit_font<T>(
lua: &Lua,
this: &Table,
elem: usize,
f: impl FnOnce(&mut ElementFont) -> T,
) -> mlua::Result<T> {
let h = html_handle(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
Ok(f(&mut model.simple_html.entry(h).or_default().fonts[elem]))
}
/// One element font's resolved paint: what the getters answer and a block would be built with.
fn read_font(lua: &Lua, this: &Table, elem: usize) -> mlua::Result<BlockPaint> {
let h = html_handle(lua, this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let st = model.simple_html.get(&h);
Ok(match st {
Some(st) => resolve_font(&model, &st.fonts[elem]),
None => resolve_font(&model, &ElementFont::default()),
})
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
// ── 0/1 · the font object ────────────────────────────────────────────────────────────────
// SetFontObject([element,] font | "font" | nil) returns nothing and changes only the next
// `SetText`'s blocks; a built block is never re-fonted (`0x795d3e`).
m.set(
"SetFontObject",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let name = super::font::resolve("SetFontObject", &arg(&rest, 0))?;
if let Some(n) = &name {
let model = lua.app_data_ref::<Model>().expect("model");
if model.font_object(n).is_none() {
return Err(mlua::Error::runtime(format!(
"SetFontObject: no font object named '{n}' is registered"
)));
}
}
// nil severs the link and keeps the paint, as the reference stores a null parent; an
// element font resolves its object lazily, so the standing paint is pinned here.
let standing = name
.is_none()
.then(|| read_font(lua, &this, elem))
.transpose()?;
edit_font(lua, &this, elem, |ef| {
if let Some(p) = standing {
ef.font_path = p.font_path;
ef.font_height = p.font_height;
ef.outline = p.outline;
ef.color = p.color;
ef.shadow = p.shadow;
ef.justify = p.justify;
ef.explicit = super::FontExplicit {
face: ef.font_path.is_some(),
height: ef.font_height.is_some(),
outline: true,
color: ef.color.is_some(),
shadow: ef.shadow.is_some(),
justify_h: true,
justify_v: true,
};
}
// A re-point keeps the severance mask, as on a region: each local setter clears
// its bit of the inherit mask (`FONTINSTANCE+0x2c`) for good.
ef.font_object = name;
})
})?,
)?;
m.set(
"GetFontObject",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let h = html_handle(lua, &this)?;
let name = {
let model = lua.app_data_ref::<Model>().expect("model");
model
.simple_html
.get(&h)
.and_then(|s| s.fonts[elem].font_object.clone())
.filter(|n| model.font_object(n).is_some())
};
match name {
Some(n) => Ok(Value::Table(super::font::wrapper(lua, &n)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// ── 2/3 · the face ──────────────────────────────────────────────────────────────────────
// SetFont([element,] file, height [, flags]) returns 1, or nil on an empty path; the shared
// gate (`0x79f210`) takes numeric strings and raises `0x87c69c`'s usage line on anything else.
m.set(
"SetFont",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let (path, height) =
super::font_block::set_font_args(&arg(&rest, 0), &arg(&rest, 1), "SimpleHTML")?;
let flags = match arg(&rest, 2) {
Value::String(s) => Some(s.to_str()?.to_string()),
_ => None,
};
let ok = !path.is_empty();
edit_font(lua, &this, elem, |ef| {
if ok {
ef.font_path = Some(path);
ef.explicit.face = true;
}
ef.font_height = Some(height);
ef.explicit.height = true;
if let Some(f) = flags {
// The Lua flags spelling, not XML's `outline=` ([`Outline::flags`]).
ef.outline = Outline::flags(&f);
ef.explicit.outline = true;
}
})?;
Ok(if ok { Value::Number(1.0) } else { Value::Nil })
})?,
)?;
// GetFont([element]): path, height and a flags string, `""` rather than nil.
m.set(
"GetFont",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let p = read_font(lua, &this, elem)?;
let path = match p.font_path {
Some(path) => Value::String(lua.create_string(&path)?),
None => Value::Nil,
};
Ok((path, p.font_height, p.outline.as_str()))
})?,
)?;
// ── 4/5 · the text colour ───────────────────────────────────────────────────────────────
m.set(
"SetTextColor",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let c = rgba(lua, &rest)?;
edit_font(lua, &this, elem, |ef| {
ef.color = Some(c);
ef.explicit.color = true;
})
})?,
)?;
m.set(
"GetTextColor",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let c = read_font(lua, &this, elem)?
.color
.unwrap_or([1.0, 1.0, 1.0, 1.0]);
Ok((c[0], c[1], c[2], c[3]))
})?,
)?;
// ── 6..9 · the shadow ───────────────────────────────────────────────────────────────────
// `GetShadowColor` returns four values (`0x79f9b3`), `GetShadowOffset` two; each setter keeps
// the other half.
m.set(
"SetShadowColor",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let c = rgba(lua, &rest)?;
edit_font(lua, &this, elem, |ef| {
let offset = ef.shadow.map_or([0.0, 0.0], |s| s.offset);
ef.shadow = Some(FontShadow { offset, color: c });
ef.explicit.shadow = true;
})
})?,
)?;
m.set(
"GetShadowColor",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let c = read_font(lua, &this, elem)?
.shadow
.map_or([0.0, 0.0, 0.0, 1.0], |s| s.color);
Ok((c[0], c[1], c[2], c[3]))
})?,
)?;
// Both arguments are required: the shared code raises its usage line (`0x87c6e8`).
m.set(
"SetShadowOffset",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let x = f32::from_lua(arg(&rest, 0), lua)?;
let y = f32::from_lua(arg(&rest, 1), lua)?;
edit_font(lua, &this, elem, |ef| {
let color = ef.shadow.map_or([0.0, 0.0, 0.0, 1.0], |s| s.color);
ef.shadow = Some(FontShadow {
offset: [x, y],
color,
});
ef.explicit.shadow = true;
})
})?,
)?;
m.set(
"GetShadowOffset",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let o = read_font(lua, &this, elem)?
.shadow
.map_or([0.0, 0.0], |s| s.offset);
Ok((o[0], o[1]))
})?,
)?;
// ── 10/11 · spacing ─────────────────────────────────────────────────────────────────────
// SetSpacing([element,] n) clamps a negative to 0 (`0x772240`, `0x772246`); it is the only gap
// between blocks, which stack flush at the ctor default of 0.
m.set(
"SetSpacing",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let n = f32::from_lua(arg(&rest, 0), lua)?.max(0.0);
edit_font(lua, &this, elem, |ef| ef.spacing = n)
})?,
)?;
m.set(
"GetSpacing",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
Ok(read_font(lua, &this, elem)?.spacing)
})?,
)?;
// ── 12..15 · justification ──────────────────────────────────────────────────────────────
// Both parse through `align`'s table (`0x811ad0`), masked `&7` or `&0x38` ([`crate::justify`]).
// `SetJustifyH` never shows: `0x78adb0` overwrites it with each block's `align`.
m.set(
"SetJustifyH",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let s =
String::from_lua(arg(&rest, 0), lua).map_err(|_| justify::usage_h("SimpleHTML"))?;
let bits = justify::parse_bits(&s).ok_or_else(|| justify::usage_h("SimpleHTML"))?;
edit_font(lua, &this, elem, |ef| {
ef.justify.0 = justify::set_axis(ef.justify.0, justify::H_MASK, bits);
ef.explicit.justify_h = true;
})
})?,
)?;
m.set(
"GetJustifyH",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
Ok(read_font(lua, &this, elem)?.justify.name_h())
})?,
)?;
m.set(
"SetJustifyV",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let s =
String::from_lua(arg(&rest, 0), lua).map_err(|_| justify::usage_v("SimpleHTML"))?;
let bits = justify::parse_bits(&s).ok_or_else(|| justify::usage_v("SimpleHTML"))?;
edit_font(lua, &this, elem, |ef| {
ef.justify.0 = justify::set_axis(ef.justify.0, justify::V_MASK, bits);
ef.explicit.justify_v = true;
})
})?,
)?;
m.set(
"GetJustifyV",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
Ok(read_font(lua, &this, elem)?.justify.name_v())
})?,
)?;
// ── 16..18 · the three without an element argument ──────────────────────────────────────
// SetText(s) returns nothing (`0x796a90` drops `usedMarkup`); nil parses as an empty string,
// which takes the fallback a NULL takes.
m.set(
"SetText",
lua.create_function(|lua, (this, text): (Table, Value)| {
let raw = match &text {
// Bytes, lossily; never a raise.
Value::String(s) => s.to_string_lossy(),
Value::Number(_) | Value::Integer(_) => super::object::as_f32(&text).to_string(),
_ => String::new(),
};
let h = html_handle(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
set_text(&mut model, h, &raw);
Ok(())
})?,
)?;
// SetHyperlinkFormat(s) raises its usage line (`0x87bb40`) on a non-string
// (`0x796bcc`-`0x796c29`); it applies from the next parse.
m.set(
"SetHyperlinkFormat",
lua.create_function(|lua, (this, fmt): (Table, Value)| {
let Value::String(s) = &fmt else {
return Err(mlua::Error::runtime(
"Usage: <SimpleHTML>:SetHyperlinkFormat(\"format\")",
));
};
let fmt = s.to_str()?.to_string();
let h = html_handle(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.simple_html.entry(h).or_default().hyperlink_format = fmt;
Ok(())
})?,
)?;
m.set(
"GetHyperlinkFormat",
lua.create_function(|lua, this: Table| {
let h = html_handle(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model
.simple_html
.get(&h)
.map(|s| s.hyperlink_format.clone())
.unwrap_or_else(|| parse::DEFAULT_HYPERLINK_FORMAT.to_string()))
})?,
)?;
lua.set_named_registry_value(REG_SIMPLEHTML_METHODS, m)?;
Ok(())
}
/// `(r, g, b [, a])` after the element argument, as the shared colour setters take it: r, g and b
/// required, alpha 1 unless a number.
fn rgba(lua: &Lua, rest: &MultiValue) -> mlua::Result<[f32; 4]> {
let r = f32::from_lua(arg(rest, 0), lua)?;
let g = f32::from_lua(arg(rest, 1), lua)?;
let b = f32::from_lua(arg(rest, 2), lua)?;
let a = match arg(rest, 3) {
v @ (Value::Number(_) | Value::Integer(_)) => f32::from_lua(v, lua)?,
_ => 1.0,
};
Ok([r, g, b, a])
}
/// The per-frame [`SimpleHtmlState`] store `Model` holds.
pub(crate) type SimpleHtmlStates = HashMap<FrameHandle, SimpleHtmlState>;