779460d87e
Upstream 0.3.0. The ruRU pack now takes upstream's locale path (Locale::from_language_country picks the column and the auth tag); our own part is the Data/ruRU mount, the name GetLocale answers and the UI's Russian name charsets. A language pack folder mounts without the base set that upstream now requires. moonwell-gfx shadows and per-pixel points move onto the per-vertex lit colour of upstream #2899; the retained static pass passes its own normal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
346 lines
15 KiB
Rust
346 lines
15 KiB
Rust
//! A text frame's hyperlink spans as the reference builds them: each span is a frame of its own, a
|
|
//! `CSimpleHyperlinkButton` (ctor `0x7a3240`) on the Button constructor (`0x7786a0`), so a
|
|
//! mouse-enabled child of the text frame with no name and no scripts. It takes the hover, the
|
|
//! press, the capture and the click like any Button, and its vtable (`0x81d5b0`) differs from the
|
|
//! Button's (`0x81c7f8`) in three code slots, each handing its link (`+0x4dc`) and text (`+0x4e0`)
|
|
//! to the text frame: the enter (`+0x4c`, `0x7a34b0`) to `OnHyperlinkEnter`, the leave (`+0x50`,
|
|
//! `0x7a34e0`) to `OnHyperlinkLeave`, and the click (`+0x94`, `0x7a3510`) to `OnHyperlinkClick`
|
|
//! (the parent's firers `0x7a3780`/`0x7a37b0`/`0x7a37e0`). The enter and the leave pass the link
|
|
//! alone (`"%s"`, `0x82e280`); the click passes the link, the text and the button name. Its click
|
|
//! mask is `LeftButtonUp` and `RightButtonUp` (`0x500`, `0x7a327d`).
|
|
//!
|
|
//! The text frame keeps a free list (`[+0x314]`) and a live list. A rebuild (`0x788c00`, and
|
|
//! SimpleHTML's at `0x78afdf`) compares nothing: it releases every live button (`0x7a3920`), which
|
|
//! hides, so a hovered one gets its leave with its old link and a held press loses its capture, and
|
|
//! goes to the free list's tail. Then each span takes the free list's head (`0x7a3880`, a new
|
|
//! button when it is empty), which is placed on its line and shown (`0x7a3350`); nothing is ever
|
|
//! freed. A SimpleHTML rebuilds from its update (`0x78a30b`) when `SetText` or an element
|
|
//! font raised its flag, or its layout callback did on a width or height change of 1e-5 or more
|
|
//! (`0x78a320`, `0x78a369`). A ScrollingMessageFrame rebuilds from its update (`0x788595`) whenever its
|
|
//! flag is up ([`crate::widget::ScrollingMessageState::links_dirty`]), and a change of its
|
|
//! on-screen width or height relayouts it (`0x7882f0` → `0x788310` → `0x788750`), a rescale
|
|
//! included, since the rect it compares is in screen units (`0x788342` scales the insets by
|
|
//! `+0x7c`). The buttons hang
|
|
//! off their line (`0x7a3441`), so a frame that only moves carries them along with no rebuild.
|
|
//! The app lays the text out, so its spans arrive here as screen rects every frame, and a frame
|
|
//! not rebuilt has its buttons re-seated in place.
|
|
|
|
use std::collections::VecDeque;
|
|
|
|
use mlua::{Lua, Value};
|
|
|
|
use crate::layout::{Anchor, Anchors, Point, Rect};
|
|
use crate::widget::{FrameHandle, FrameKind, KindState};
|
|
|
|
use super::{event, visibility, Model, UiScript, SCREEN};
|
|
|
|
/// The text frame and the `(link, text)` a hyperlink button carries, if `h` is one.
|
|
pub(super) fn owner_link(model: &Model, h: FrameHandle) -> Option<(FrameHandle, String, String)> {
|
|
let f = model.arena.frame(h)?;
|
|
match &f.kind_state {
|
|
KindState::Button(bs) => {
|
|
let (link, text) = bs.hyperlink.clone()?;
|
|
Some((f.parent?, link, text))
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Fire the text frame's `OnHyperlinkEnter` or `OnHyperlinkLeave` for the button `h`, with the link
|
|
/// alone; false when `h` is not a hyperlink button, whose own enter or leave then runs.
|
|
pub(super) fn hover(lua: &Lua, h: FrameHandle, enter: bool) -> bool {
|
|
let Some((owner, link, _)) = owner_link(&lua.app_data_ref::<Model>().expect("model"), h) else {
|
|
return false;
|
|
};
|
|
let script = if enter {
|
|
"OnHyperlinkEnter"
|
|
} else {
|
|
"OnHyperlinkLeave"
|
|
};
|
|
fire(lua, owner, script, &[&link]);
|
|
true
|
|
}
|
|
|
|
/// Fire the text frame's `OnHyperlinkClick(link, text, button)` for the button `h`; false when `h`
|
|
/// is not a hyperlink button, whose own `OnClick` then runs.
|
|
pub(super) fn click(lua: &Lua, h: FrameHandle, button: &str) -> bool {
|
|
let Some((owner, link, text)) = owner_link(&lua.app_data_ref::<Model>().expect("model"), h)
|
|
else {
|
|
return false;
|
|
};
|
|
fire(lua, owner, "OnHyperlinkClick", &[&link, &text, button]);
|
|
true
|
|
}
|
|
|
|
fn fire(lua: &Lua, owner: FrameHandle, script: &str, args: &[&str]) {
|
|
let id = {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
if model.arena.frame(owner).is_none() {
|
|
return;
|
|
}
|
|
model.frame_id(owner)
|
|
};
|
|
let args: mlua::Result<Vec<Value>> = args
|
|
.iter()
|
|
.map(|a| lua.create_string(a).map(Value::String))
|
|
.collect();
|
|
let result = args.and_then(|args| event::fire_widget_handler(lua, id, script, args));
|
|
if let Err(e) = result {
|
|
lua.app_data_mut::<Model>()
|
|
.expect("model")
|
|
.record_lua_error(&e);
|
|
}
|
|
}
|
|
|
|
/// One span: its y-up rect, link and text.
|
|
type Span = (Rect, String, String);
|
|
|
|
/// A text frame's hyperlink buttons: the live ones in placement order, each over the span at its
|
|
/// index, and the free list, taken from the head and released to the tail.
|
|
#[derive(Clone, Default)]
|
|
pub(crate) struct LinkPool {
|
|
live: Vec<FrameHandle>,
|
|
free: VecDeque<FrameHandle>,
|
|
/// The text frame's on-screen size at the last pass, for the resize relayout.
|
|
size: Option<(f32, f32)>,
|
|
}
|
|
|
|
/// Replace the spans on screen, `(text frame, y-up rect, link, text)`, a text frame's in the
|
|
/// order its rebuild places them. Only a ScrollingMessageFrame's and a SimpleHTML's take buttons;
|
|
/// a MessageFrame's stay text. A text frame due a rebuild ([`due`]) gets one: every live button
|
|
/// hides, in order, its leave reading the link it still carries, then each span takes a free
|
|
/// button, is placed, and shows. Any other has its live buttons re-seated on their spans with no hide and no show,
|
|
/// as buttons anchored to their lines follow a moved frame.
|
|
pub(super) fn set_spans(lua: &Lua, spans: Vec<(FrameHandle, Rect, String, String)>) {
|
|
let mut by_owner: Vec<(FrameHandle, Vec<Span>)> = Vec::new();
|
|
for (owner, rect, link, text) in spans {
|
|
match by_owner.iter_mut().find(|(o, _)| *o == owner) {
|
|
Some((_, list)) => list.push((rect, link, text)),
|
|
None => by_owner.push((owner, vec![(rect, link, text)])),
|
|
}
|
|
}
|
|
{
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
let model = &mut *model;
|
|
model
|
|
.link_buttons
|
|
.retain(|owner, _| model.arena.frame(*owner).is_some());
|
|
// Text frames with live buttons and no span now come after the ones with spans, in a
|
|
// fixed order over the hashed map.
|
|
let mut emptied: Vec<FrameHandle> = model
|
|
.link_buttons
|
|
.iter()
|
|
.filter(|(owner, pool)| {
|
|
!pool.live.is_empty() && !by_owner.iter().any(|(o, _)| o == *owner)
|
|
})
|
|
.map(|(owner, _)| *owner)
|
|
.collect();
|
|
emptied.sort_by_key(|h| h.fingerprint_bits());
|
|
// Only the SMF (`0x788ce7`) and SimpleHTML (`0x78b052`) take one.
|
|
by_owner.retain(|(owner, _)| {
|
|
model.simple_html.contains_key(owner)
|
|
|| model
|
|
.arena
|
|
.frame(*owner)
|
|
.is_some_and(|f| matches!(f.kind_state, KindState::ScrollingMessage(_)))
|
|
});
|
|
by_owner.extend(emptied.into_iter().map(|owner| (owner, Vec::new())));
|
|
}
|
|
let mut moved = false;
|
|
for (owner, list) in by_owner {
|
|
moved |= if due(lua, owner, &list) {
|
|
rebuild(lua, owner, &list)
|
|
} else {
|
|
follow(lua, owner, &list)
|
|
};
|
|
}
|
|
// The pointer hit-tests resolved rects, so a moved button is solved before the next input.
|
|
if moved {
|
|
UiScript::resolve_layout(&mut lua.app_data_mut::<Model>().expect("model"));
|
|
}
|
|
}
|
|
|
|
/// Whether `owner` rebuilds this pass: when its flag is up, which this spends, or its on-screen
|
|
/// size moved. A ScrollingMessageFrame compares its lines' box (`0x788394`, `0x7883b2`); a
|
|
/// SimpleHTML's layout callback (`0x78a320`) raises the flag when the frame's width or height
|
|
/// changed by [`HTML_RESIZE`] or more, or is NaN (`0x78a340`-`0x78a369`).
|
|
/// Deviation: a span count that no longer matches the live set always rebuilds, because that way a
|
|
/// state this model does not flag (a ring wiped by `SetMaxLines`, a hidden frame, a frame not yet
|
|
/// laid out, which the reference's rebuild skips at `0x78af7b`) leaves no stale button.
|
|
fn due(lua: &Lua, owner: FrameHandle, list: &[Span]) -> bool {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
let model = &mut *model;
|
|
let is_smf = matches!(
|
|
model.arena.frame(owner).map(|f| &f.kind_state),
|
|
Some(KindState::ScrollingMessage(_))
|
|
);
|
|
// A message frame's lines' box, which `0x788384`-`0x7883b2` compares; a SimpleHTML's rect.
|
|
let size = if is_smf {
|
|
super::messageframe::message_rect(model, owner)
|
|
} else {
|
|
model.frame_rect(owner)
|
|
}
|
|
.map(|r| (r.right - r.left, r.top - r.bottom));
|
|
let flagged = match model.arena.frame_mut(owner).map(|f| &mut f.kind_state) {
|
|
Some(KindState::ScrollingMessage(smf)) => std::mem::take(&mut smf.links_dirty),
|
|
_ => model
|
|
.simple_html
|
|
.get_mut(&owner)
|
|
.is_some_and(|st| std::mem::take(&mut st.links_dirty)),
|
|
};
|
|
let pool = model.link_buttons.entry(owner).or_default();
|
|
let was = std::mem::replace(&mut pool.size, size);
|
|
let resized = match (was, size) {
|
|
(Some(was), Some(now)) if is_smf => was != now,
|
|
(Some(was), Some(now)) => html_resized(was, now),
|
|
_ => false,
|
|
};
|
|
flagged || resized || pool.live.len() != list.len()
|
|
}
|
|
|
|
/// The width or height change below which a SimpleHTML keeps its link buttons (`0x80c5c8`).
|
|
const HTML_RESIZE: f32 = 1e-5;
|
|
|
|
/// `0x78a320`'s test on the `(width, height)` before and after: a change of [`HTML_RESIZE`] or
|
|
/// more, which a NaN counts as too (`fabs(d) < 1e-5` is false for it, `test ah,5` and `jp` at
|
|
/// `0x78a348`, `0x78a367`).
|
|
fn html_resized(was: (f32, f32), now: (f32, f32)) -> bool {
|
|
let moved = |a: f32, b: f32| {
|
|
let d = (a - b).abs();
|
|
d.is_nan() || d >= HTML_RESIZE
|
|
};
|
|
moved(was.0, now.0) || moved(was.1, now.1)
|
|
}
|
|
|
|
/// Re-seat `owner`'s live buttons on `list`, one each, with no hide and no show. True when a
|
|
/// button's anchors or size changed.
|
|
fn follow(lua: &Lua, owner: FrameHandle, list: &[Span]) -> bool {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
let model = &mut *model;
|
|
let live = model
|
|
.link_buttons
|
|
.get(&owner)
|
|
.map(|p| p.live.clone())
|
|
.unwrap_or_default();
|
|
let mut moved = false;
|
|
for (&h, (rect, link, text)) in live.iter().zip(list) {
|
|
moved |= place(model, h, *rect, link, text);
|
|
}
|
|
moved
|
|
}
|
|
|
|
/// One text frame's rebuild (`0x788c00`) onto `list`. True when a button's anchors or size
|
|
/// changed.
|
|
fn rebuild(lua: &Lua, owner: FrameHandle, list: &[Span]) -> bool {
|
|
let released = {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
let pool = model.link_buttons.entry(owner).or_default();
|
|
let released = std::mem::take(&mut pool.live);
|
|
pool.free.extend(released.iter().copied());
|
|
released
|
|
};
|
|
// `0x7a3920`: each hides before any is re-placed, so a hovered one leaves with its old link.
|
|
for &h in &released {
|
|
visibility::set_shown(lua, h, false);
|
|
}
|
|
let mut moved = false;
|
|
let mut shows = Vec::with_capacity(list.len());
|
|
{
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
let model = &mut *model;
|
|
// A leave handler may have destroyed the text frame.
|
|
if model.arena.frame(owner).is_none() {
|
|
model.link_buttons.remove(&owner);
|
|
return false;
|
|
}
|
|
for (rect, link, text) in list {
|
|
let free = model
|
|
.link_buttons
|
|
.get_mut(&owner)
|
|
.and_then(|p| p.free.pop_front());
|
|
let h = free.unwrap_or_else(|| build(model, owner));
|
|
moved |= place(model, h, *rect, link, text);
|
|
shows.push(h);
|
|
}
|
|
let pool = model.link_buttons.entry(owner).or_default();
|
|
pool.live = shows.clone();
|
|
}
|
|
for h in shows {
|
|
visibility::set_shown(lua, h, true);
|
|
}
|
|
moved
|
|
}
|
|
|
|
/// `0x7a3880`'s miss: a new button, a Button child of the text frame in its stratum one level up,
|
|
/// as a child is made, registered for `LeftButtonUp` and `RightButtonUp`. It is born hidden, so the
|
|
/// caller's show is its one transition, as `0x7a3350`'s `Show` is.
|
|
fn build(model: &mut Model, owner: FrameHandle) -> FrameHandle {
|
|
let h = model.arena.create(FrameKind::Button, None, Some(owner));
|
|
if let Some((strata, level)) = model.arena.frame(owner).map(|f| (f.strata, f.level)) {
|
|
model.arena.set_frame_strata(h, strata);
|
|
model.arena.set_frame_level(h, level + 1, false);
|
|
}
|
|
model.set_shown_silent(h, false);
|
|
if let Some(KindState::Button(bs)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
|
|
bs.registered_clicks = ["LeftButtonUp", "RightButtonUp"]
|
|
.into_iter()
|
|
.map(String::from)
|
|
.collect();
|
|
}
|
|
model.frame_id(h);
|
|
model.layout_inputs.entry(h).or_default();
|
|
h
|
|
}
|
|
|
|
/// `0x7a3350`: store the link and text, and seat the button over the span, one TOPLEFT on the
|
|
/// screen with an explicit size, in its own units. True when its anchors or size changed.
|
|
fn place(model: &mut Model, h: FrameHandle, rect: Rect, link: &str, text: &str) -> bool {
|
|
if let Some(KindState::Button(bs)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
|
|
if bs
|
|
.hyperlink
|
|
.as_ref()
|
|
.is_none_or(|(l, t)| l != link || t != text)
|
|
{
|
|
bs.hyperlink = Some((link.to_string(), text.to_string()));
|
|
}
|
|
}
|
|
let scale = f64::from(super::object::eff_scale(model, h));
|
|
let root = model.screen;
|
|
let x = ((f64::from(rect.left) - f64::from(root.left)) / scale) as f32;
|
|
let y = ((f64::from(rect.top) - f64::from(root.top)) / scale) as f32;
|
|
let w = ((f64::from(rect.right) - f64::from(rect.left)) / scale) as f32;
|
|
let ht = ((f64::from(rect.top) - f64::from(rect.bottom)) / scale) as f32;
|
|
let pin = Anchors::from([Anchor::new(Point::TopLeft, SCREEN, Point::TopLeft, x, y)]);
|
|
let input = model.layout_inputs.entry(h).or_default();
|
|
let changed = !input.anchors.bits_eq(&pin)
|
|
|| input.width.bits() != crate::layout::Len::Script(w).bits()
|
|
|| input.height.bits() != crate::layout::Len::Script(ht).bits();
|
|
if changed {
|
|
input.anchors = pin;
|
|
input.width = crate::layout::Len::Script(w);
|
|
input.height = crate::layout::Len::Script(ht);
|
|
model.note_authored_size(h);
|
|
}
|
|
// The points are cleared (`0x7a3415`), one set with the invalidate flag (`0x7a3441`), and the
|
|
// size set through the setters, which invalidate (`0x7a3457`, `0x7a346b`).
|
|
model.invalidate_frame(h, changed);
|
|
changed
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::html_resized;
|
|
|
|
/// A width or height change of 1e-5 or more, or a NaN, raises the SimpleHTML's flag; less
|
|
/// leaves it down (`0x78a340`-`0x78a369`).
|
|
#[test]
|
|
fn a_simple_html_resize_is_a_change_of_1e_5_or_a_nan() {
|
|
assert!(!html_resized((4.0, 4.0), (4.0, 4.0)));
|
|
assert!(!html_resized((4.0, 4.0), (4.000_004, 3.999_996)));
|
|
assert!(html_resized((4.0, 4.0), (4.000_02, 4.0)));
|
|
assert!(html_resized((4.0, 4.0), (4.0, 3.999_98)));
|
|
assert!(html_resized((4.0, 4.0), (f32::NAN, 4.0)));
|
|
assert!(html_resized((4.0, f32::NAN), (4.0, 4.0)));
|
|
}
|
|
}
|