Files
benilla-octo/crates/benilla-ui/src/script/layout_cache.rs
T
Sam SungandClaude Opus 5.5 0c58d85cf9 Layout file saves the rect; bubbles re-sort on change; resize Deviation (#981)
- **The layout file** now saves what the reference's writer does (`0x490e60`): `X`, `Y`, `W`, `H` and `FrameLevel` from the frame's held rect, rows in creation order, and only frames that are user-placed and movable or resizable. The restore (`0x4905e0`) pins TOPLEFT to the screen, sets the size and level, and resolves each frame as it goes (`0x490710`). Old anchor-form files still load and are rewritten once.
- **Chat bubbles** re-rank only on a camera move or a new line (`0x4b11ff`), and a repeat speaker moves to the tail, as `0x4b1870` links it.
- **The live re-layout on a window resize** gets its `Deviation:` line. 1.12.1 reloads the UI on an aspect change of 0.001 or more (`0x492f26`).

Tests cover parent/child level restore, the held rect, the hidden-frame resolve, the junk lines, the migration and the bubble tie order.

Fixes #954
Fixes #955
Fixes #847

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

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

302 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The layout cache: the reference writes a per-character `layout-cache.txt` at logout and seats
//! the user-placed frames from it at load (userPlaced is `frame+0xb4 & 0x1000`). This is the
//! engine half, a snapshot out, a restore in and a dirty bit; the app owns the file
//! (`benilla_app::ui_layout`).
//!
//! A saved frame is its rect, as the reference's writer (`0x490e60`) takes it: the frame level,
//! the top-left against the screen's top-left and the extent, in the frame's own units and rounded
//! to integers. The apply (`0x4905e0`) pins TOPLEFT on the screen there and sets the size, so the
//! frame's authored anchors do not survive it.
use crate::layout::{Anchor, Anchors, Point};
use crate::widget::FrameHandle;
use super::object::point_from_str;
use super::object::{eff_scale, settle_frame};
use super::SCREEN;
/// One user-placed frame's persisted geometry, one field per line of the reference's file. A
/// field is `None` where its line is absent.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct FrameLayout {
/// The frame's global name, which addresses the row across sessions (`Frame:`).
pub name: String,
/// The frame level (`FrameLevel:`, `frame+0xc4`), written for every row (`0x490f55`).
pub level: Option<u16>,
/// The rect's left, against the screen's left (`X:`), written for a movable frame
/// (`0x490f99`).
pub x: Option<i32>,
/// The rect's top, against the screen's top (`Y:`), so zero or below.
pub y: Option<i32>,
/// The rect's width (`W:`), written for a resizable frame (`0x49109f`).
pub width: Option<i32>,
/// The rect's height (`H:`).
pub height: Option<i32>,
}
/// One anchor of a frame saved in benilla's earlier anchor form, in the spelling
/// `GetPoint`/`SetPoint` use.
#[derive(Clone, Debug, PartialEq)]
pub struct LayoutPoint {
/// The point on the saved frame (`"BOTTOMLEFT"`, …).
pub point: String,
/// The target frame's name; `None` is the screen root, which `GetPoint` reports as `nil`.
pub relative_to: Option<String>,
/// The point on the target.
pub relative_point: String,
/// `CAnchor+0x4`, in the frame's own pre-scale units.
pub x: f32,
/// `CAnchor+0x8`.
pub y: f32,
}
/// A row of the anchor form benilla wrote before it saved the reference's rect: the authored
/// size and the whole anchor list. Read once, to carry a player's windows into the rect form.
#[derive(Clone, Debug, PartialEq)]
pub struct AnchorLayout {
/// The frame's global name.
pub name: String,
/// The frame's authored width (`LayoutInput::width`).
pub width: f32,
/// The frame's authored height.
pub height: f32,
/// Every anchor the frame carried, in `GetPoint` order.
pub points: Vec<LayoutPoint>,
}
impl super::UiScript {
/// Snapshot, in creation order as the writer walks the list of every frame (`0x490e24`,
/// `[root+0xcc4]`, appended by the constructor at `0x76930d`), every frame it writes: a
/// non-empty name (`0x490e79`, `0x490e82`), `userPlaced` (`0x490e8e`, `0x1000`) and `movable`
/// or `resizable` (`0x490e97`, `0x100|0x200`), with a rect to read. Every drag stamps
/// `userPlaced` (`0x7652b0` at `0x7652e5`), so the last test is what drops the row of a stock
/// frame an addon once moved, once that addon is gone.
///
/// The rect is the one the frame holds, stale or not (`0x768320`, its valid bit): only a frame
/// holding none is resolved now (`0x490eed`, `0x7680e0(1)`) and read again, and still none
/// writes no row (`0x490f00`). The root's rect is the screen, which is always held, so the
/// second bail (`0x490f17`) never takes here. The level and the two flags are read after the
/// rect (`0x490f55`, `0x490f93`, `0x491099`), since a resolve can run script.
///
/// `X:`/`Y:` round half away from zero (`0x490fc4`–`0x490fdf`, then `0x40a2b0`'s truncating
/// `fistp`); `W:`/`H:` add a half and truncate (`0x4910cd`–`0x4910d3`).
pub fn user_placed_layouts(&self) -> Vec<FrameLayout> {
let mut candidates: Vec<(u64, FrameHandle, String)> = self
.model_ref()
.arena
.iter_frames()
.filter(|(_, f)| f.user_placed && (f.movable || f.resizable))
.filter_map(|(h, f)| {
let name = f.name.clone().filter(|n| !n.is_empty())?;
Some((f.created(), h, name))
})
.collect();
candidates.sort_unstable_by_key(|c| c.0);
let mut out = Vec::with_capacity(candidates.len());
for (_, h, name) in candidates {
if !self.model_ref().resolved.contains_key(&h) {
settle_frame(self.lua(), h);
}
let model = self.model_ref();
let Some(r) = model.resolved.get(&h).copied() else {
continue;
};
let Some((movable, resizable, level)) = model
.arena
.frame(h)
.map(|f| (f.movable, f.resizable, f.level))
else {
continue; // the resolve's script destroyed it
};
let root = model.screen;
let scale = f64::from(eff_scale(&model, h));
let own = |v: f32| f64::from(v) / scale;
let (x, y) = if movable {
(
Some(round_offset(own(r.left - root.left))),
Some(round_offset(own(r.top - root.top))),
)
} else {
(None, None)
};
let (width, height) = if resizable {
(
Some(round_extent(own(r.right - r.left))),
Some(round_extent(own(r.top - r.bottom))),
)
} else {
(None, None)
};
out.push(FrameLayout {
name,
level: Some(level),
x,
y,
width,
height,
});
}
out
}
/// Seat saved rows back onto the frames that exist, as the apply (`0x4905e0`) does. A level of
/// zero or more is set, carried to same-strata children (`0x4905ee`–`0x4905f5`). The position
/// needs `movable` (`0x490600`) and a non-zero `X` or `Y` (`0x490607`–`0x490613`): it clears
/// every anchor (`0x49061a`) and pins TOPLEFT on the screen's TOPLEFT there (`0x490670`). The
/// size needs `resizable` (`0x490689`) and a non-zero `W` or `H` (`0x49068e`–`0x49069a`), and
/// sets each non-zero one (`0x4906c7`, `0x4906fa`). Each arm that applies stamps `userPlaced`
/// (`0x49067e`, `0x490706`), which keeps `UIParent_ManageFramePositions` off the frame. Each
/// row ends in a forced resolve of its frame, shown or hidden (`0x490710`, `0x7680e0(1)`), so
/// its `OnSizeChanged` fires before the next row applies. Marks nothing dirty: the values came
/// from the file.
pub fn restore_user_placed_layouts(&mut self, layouts: impl IntoIterator<Item = FrameLayout>) {
for l in layouts {
if let Some(h) = self.apply_layout_row(l) {
settle_frame(self.lua(), h);
}
}
}
/// One row of [`Self::restore_user_placed_layouts`], up to its invalidate; the frame it
/// applied to.
fn apply_layout_row(&mut self, l: FrameLayout) -> Option<FrameHandle> {
let mut model = self.model_mut();
// A window this session does not have applies nothing (`0x4905e6`).
let h = model.arena.lookup(&l.name)?;
if let Some(level) = l.level {
model.arena.set_frame_level(h, level, true);
}
let (movable, resizable) = model.arena.frame(h).map(|f| (f.movable, f.resizable))?;
// An absent line reads as the reference's cleared accumulator, zero.
let (x, y) = (l.x.unwrap_or(0), l.y.unwrap_or(0));
let (w, ht) = (l.width.unwrap_or(0), l.height.unwrap_or(0));
let seat_position = movable && (x != 0 || y != 0);
let seat_size = resizable && (w != 0 || ht != 0);
let input = model.layout_inputs.entry(h).or_default();
let mut changed = false;
if seat_position {
let pin = Anchors::from([Anchor::new(
Point::TopLeft,
SCREEN,
Point::TopLeft,
x as f32,
y as f32,
)]);
changed |= !input.anchors.bits_eq(&pin);
input.anchors = pin;
}
if seat_size && w != 0 {
changed |= input.width.to_bits() != (w as f32).to_bits();
input.width = w as f32;
}
if seat_size && ht != 0 {
changed |= input.height.to_bits() != (ht as f32).to_bits();
input.height = ht as f32;
}
if seat_size {
model.note_authored_size(h);
}
if seat_position || seat_size {
if let Some(f) = model.arena.frame_mut(h) {
f.user_placed = true;
}
}
// Every row that names a frame ends in an invalidate (`0x490710`).
model.invalidate_frame(h, changed);
Some(h)
}
/// Seat rows of benilla's earlier anchor form, each whole or not at all, under the apply's arm
/// gates (`0x490600`, `0x490689`) and stamps (`0x49067e`, `0x490706`). The next save writes
/// the frames back in the rect form.
pub fn restore_anchor_layouts(&mut self, layouts: impl IntoIterator<Item = AnchorLayout>) {
let mut model = self.model_mut();
for l in layouts {
let Some(h) = model.arena.lookup(&l.name) else {
continue;
};
let mut anchors = Anchors::new();
let mut usable = true;
for p in &l.points {
// `frame_id` mints the id lazily, so a target nothing has anchored to yet resolves.
let rel = match &p.relative_to {
None => Some(SCREEN),
Some(n) => model.arena.lookup(n).map(|t| model.frame_id(t)),
};
match (
point_from_str(&p.point),
rel,
point_from_str(&p.relative_point),
) {
(Some(point), Some(rel), Some(rp)) => {
anchors.insert(Anchor::new(point, rel, rp, p.x, p.y));
}
_ => {
usable = false;
break;
}
}
}
if !usable {
continue;
}
let Some((movable, resizable)) = model.arena.frame(h).map(|f| (f.movable, f.resizable))
else {
continue;
};
let seat_position = movable && !anchors.is_empty();
let seat_size = resizable && (l.width != 0.0 || l.height != 0.0);
if !seat_position && !seat_size {
continue;
}
let input = model.layout_inputs.entry(h).or_default();
let mut changed = false;
if seat_position {
changed |= input.anchors != anchors;
input.anchors = anchors;
}
if seat_size {
changed |= input.width.to_bits() != l.width.to_bits()
|| input.height.to_bits() != l.height.to_bits();
input.width = l.width;
input.height = l.height;
}
if let Some(f) = model.arena.frame_mut(h) {
f.user_placed = true;
}
model.invalidate_frame(h, changed);
}
}
/// Whether a user-placed frame moved or resized since the last call: the host's cue to persist.
pub fn take_user_placed_change(&mut self) -> bool {
std::mem::take(&mut self.model_mut().user_placed_changed)
}
}
/// `X:`/`Y:`'s rounding: a half added, or taken off below zero (`fcom 0.0; test ah,0x5; jp`),
/// then truncated toward zero (`0x40a2b0`), so half rounds away from zero.
fn round_offset(v: f64) -> i32 {
(if v < 0.0 { v - 0.5 } else { v + 0.5 }).trunc() as i32
}
/// `W:`/`H:`'s rounding: a half added with no sign test, then truncated toward zero.
fn round_extent(v: f64) -> i32 {
(v + 0.5).trunc() as i32
}
#[cfg(test)]
mod tests {
use super::{round_extent, round_offset};
#[test]
fn offsets_round_half_away_from_zero_and_extents_add_a_half() {
assert_eq!(
[2.5, 2.49, -2.5, -2.49, 0.0, -0.4].map(round_offset),
[3, 2, -3, -2, 0, 0]
);
// No sign test on an extent: a negative half truncates toward zero.
assert_eq!([2.5, 2.49, 0.0, -0.7].map(round_extent), [3, 2, 0, 0]);
}
}