Chat frames fade, hold their view and space messages as 1.12.1's (#1803)

A ScrollingMessageFrame now fades, keeps its view and spaces its messages as the client's does.

- Only the shown messages fade (`0x7884a5`-`0x788582`). A relayout off the bottom re-arms what it walks, including the first older message, before the fit test (`0x788af0` at `0x7888be`). That covers a scroll, a resize, a font change, a show and UpdateColorByID.
- A scrolled-up view stays on its messages as they arrive. It moves down a line only when a full ring overwrites the topmost shown message (`0x78819e`-`0x7881bb`).
- The gap between messages is the snapped unscaled spacing times the scale (`0x788954`-`0x788975`). Rows within one message snap their scaled gap (`0x5c2143`-`0x5c215c`). Each message must fit whole.
- ScrollToTop and ScrollToBottom do nothing on an empty frame (`0x788690`, `0x7886d0`).

Each change fails its test without it.

Fixes #1796
Fixes #1797
Fixes #1798

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-17 19:21:03 +07:00
committed by GitHub
parent b817db8df9
commit 746ad35176
8 changed files with 603 additions and 132 deletions
@@ -11,7 +11,7 @@
use crate::layout::Rect;
use crate::order::ZTarget;
use crate::script::object::frame_handle_of;
use crate::widget::{FrameHandle, InsertMode, KindState, MessageLine};
use crate::widget::{FrameHandle, InsertMode, KindState, MessageLine, Viewport};
use super::layout::FramePaint;
use super::{
@@ -33,16 +33,39 @@ pub(super) use scrolling::REG_SCROLLINGMESSAGEFRAME_METHODS;
/// [`MessageFrameState::trim_to_viewport`]: crate::widget::MessageFrameState::trim_to_viewport
pub(super) fn update(model: &mut Model, h: FrameHandle, elapsed: f32) {
let rows = UiScript::message_viewport_rows(model, h);
let viewport = UiScript::message_viewport(model, h);
let layout_key = UiScript::message_layout_key(model, h);
match model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
Some(KindState::Message(mf)) => {
mf.trim_to_viewport(rows);
mf.tick(elapsed);
}
Some(KindState::ScrollingMessage(smf)) => smf.tick(elapsed),
Some(KindState::ScrollingMessage(smf)) => {
// A resize or a font change relayouts at the current position (`0x788427`,
// `0x788086`), as a show does (`0x788451`, [`on_visibility`]).
if smf.laid.replace(layout_key) != Some(layout_key) {
smf.relayout(viewport);
}
smf.tick(elapsed, viewport)
}
_ => {}
}
}
/// A scrolling frame's show relayouts (`0x788451`): forget what it laid out against, so its next
/// update does.
pub(super) fn on_visibility(lua: &mlua::Lua, h: FrameHandle, visible: bool) {
if !visible {
return;
}
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(KindState::ScrollingMessage(smf)) =
model.arena.frame_mut(h).map(|f| &mut f.kind_state)
{
smf.laid = None;
}
}
/// `lua_isnumber` (`0x6f34d0`) then `lua_tonumber` (`0x6f3620`): a number, or a string Lua reads
/// as one. The message-frame verbs gate every colour channel and id on it.
pub(super) fn is_number(lua: &mlua::Lua, v: &mlua::Value) -> Option<f64> {
@@ -208,13 +231,32 @@ impl MessageFont {
self.height.unwrap_or(14.0)
}
/// The gap between a line string's rows and, in the scrolling class, between messages: each
/// line string inherits the frame's font (`0x7888fd`) through the clamping setter (`0x7708fd`
/// → `0x772240`), and the stack snaps its field (`0x78899e` → `0x766750`).
fn line_gap(&self, scale: f32) -> f32 {
/// The gap between the rows of one message: each line string inherits the frame's font
/// (`0x7888fd`) through the clamping setter (`0x7708fd` → `0x772240`), and the block height
/// forms the spacing times the scale (`0x772a07`-`0x772a0d`), which `GxuFontGetTextBlockHeight`
/// rounds up to a whole pixel (`0x5c2143`-`0x5c215c`, `+0.99999`).
fn row_gap(&self, scale: f32) -> f32 {
snap_px(self.spacing.max(0.0) * scale)
}
/// The scrolling class's gap between one message and the next: the stack snaps the unscaled
/// spacing (`0x78899e` → `0x766750`) and anchors the next line at that offset in frame units
/// (`0x788954`-`0x788975`), so on screen it is the snapped spacing times the scale.
fn message_gap(&self, scale: f32) -> f32 {
snap_px(self.spacing.max(0.0)) * scale
}
/// What the scrolling class's layout fits messages into: a frame `height` tall, drawn at
/// `scale`.
fn viewport(&self, height: f32, scale: f32) -> Viewport {
Viewport {
height,
line_h: self.height_or_default() * scale,
row_gap: self.row_gap(scale),
message_gap: self.message_gap(scale),
}
}
/// The plain class's pitch, its fit and its slot step (`0x785f03`-`0x785f2a`, `0x786184`): the
/// font's own spacing, unclamped (`0x785f06`), and the height, each snapped.
fn plain_pitch(&self, scale: f32) -> f32 {
@@ -292,22 +334,15 @@ impl UiScript {
})
}
/// How many rows frame `fh`'s resolved rect holds at its font's pitch: the plain class's
/// capacity (`+0x318`) and the height the scrolling class's layout spends in whole messages.
/// The plain class scales both the rect and the pitch (`0x785f0a`, `0x785f1f`) and holds
/// nothing at a pitch of 0 or less, which a negative spacing can make (`0x785f8f`). The
/// scrolling class divides the rect by the scale (`0x7887d4`) and charges each message its
/// height plus the snapped unscaled spacing (`0x78899e`-`0x7889ad`); scaled, that is this pitch.
/// How many rows the plain frame `fh`'s resolved rect holds at its font's pitch, its capacity
/// (`+0x318`). Both the rect and the pitch are scaled (`0x785f0a`, `0x785f1f`); a pitch of 0
/// or less, which a negative spacing can make, holds nothing (`0x785f8f`).
pub(super) fn message_viewport_rows(model: &Model, fh: FrameHandle) -> usize {
let font = Self::message_frame_font(model, fh);
let Some(frame) = model.arena.frame(fh) else {
return 0;
};
let scale = frame.effective_scale;
let pitch = match frame.kind_state {
KindState::Message(_) => font.plain_pitch(scale),
_ => font.height_or_default() * scale + font.line_gap(scale),
};
let pitch = font.plain_pitch(frame.effective_scale);
if pitch <= 0.0 {
return 0;
}
@@ -316,6 +351,35 @@ impl UiScript {
})
}
/// The viewport of the scrolling frame `fh`'s resolved rect. The relayout divides the rect by
/// the scale (`0x7887d4`) and the heights it spends are in the same units, so this is the same
/// quantity scaled; no rect yet is no height, which still shows the anchored message.
pub(super) fn message_viewport(model: &Model, fh: FrameHandle) -> Viewport {
let font = Self::message_frame_font(model, fh);
let scale = model.arena.frame(fh).map_or(1.0, |f| f.effective_scale);
let height = model
.resolved
.get(&fh)
.map_or(0.0, |r| (r.top - r.bottom).max(0.0));
font.viewport(height, scale)
}
/// What the scrolling frame `fh`'s relayout lays out against: its size and its font.
pub(super) fn message_layout_key(model: &Model, fh: FrameHandle) -> u64 {
use std::hash::{Hash, Hasher};
let font = Self::message_frame_font(model, fh);
let mut hasher = std::collections::hash_map::DefaultHasher::new();
if let Some(r) = model.resolved.get(&fh) {
(r.right - r.left).to_bits().hash(&mut hasher);
(r.top - r.bottom).to_bits().hash(&mut hasher);
}
font.path.hash(&mut hasher);
font.height.map(f32::to_bits).hash(&mut hasher);
font.spacing.to_bits().hash(&mut hasher);
(font.outline as u8).hash(&mut hasher);
hasher.finish()
}
/// Message lines whose wrapped row count needs a host measure. Call after
/// [`UiScript::resolve`]; answer with [`UiScript::set_message_line_rows`] before extract.
pub fn message_lines_needing_measure(&mut self) -> Vec<LineMeasureRequest> {
@@ -487,13 +551,14 @@ impl UiScript {
};
// The resolved rect is scaled and the font is frame-local, so the height and gap scale.
let line_h = font.height_or_default() * paint.scale;
let gap = font.line_gap(paint.scale);
// A row's advance: the scrolling class's height plus gap, the plain class's own pitch.
let row_step = match &frame.kind_state {
KindState::Message(_) => font.plain_pitch(paint.scale),
_ => line_h + gap,
let viewport = font.viewport(fr.top - fr.bottom, paint.scale);
// A message's advance to the next: the scrolling class's block and snapped gap, the plain
// class's own pitch per row.
let advance = |rows: u16| match &frame.kind_state {
KindState::Message(_) => f32::from(rows.max(1)) * font.plain_pitch(paint.scale),
_ => viewport.cost(rows),
};
if line_h <= 0.0 || row_step <= 0.0 || fr.top <= fr.bottom {
if line_h <= 0.0 || advance(1) <= 0.0 || fr.top <= fr.bottom {
return;
}
// Clip to the frame, intersected with any ScrollFrame ancestor's clip.
@@ -508,10 +573,7 @@ impl UiScript {
// [`super::hyperlink`] in the reference's placement order.
let first = out.len();
let (reverse, shown) = match &frame.kind_state {
KindState::ScrollingMessage(smf) => (
true,
smf.displayed_count(Self::message_viewport_rows(model, fh)),
),
KindState::ScrollingMessage(smf) => (true, smf.displayed_count(viewport)),
_ => (false, usize::MAX),
};
for idx in (0..=top_index).rev().take(shown) {
@@ -519,14 +581,13 @@ impl UiScript {
break; // the next band starts outside the frame
}
let line = &lines[idx];
let rows = f32::from(line.rows.max(1));
let band_h = rows * line_h + (rows - 1.0) * gap;
let band_h = viewport.block(line.rows);
let (bottom, top) = if from_top {
(fr.top - used - band_h, fr.top - used)
} else {
(fr.bottom + used, fr.bottom + used + band_h)
};
used += rows * row_step;
used += advance(line.rows);
if line.alpha <= 0.0 {
continue; // faded: holds its rows, draws nothing
}
@@ -9,7 +9,7 @@ use mlua::{Lua, MultiValue, Table, Value};
use super::{channel, is_number};
use crate::script::object::frame_handle_of;
use crate::script::{Model, UiScript};
use crate::widget::{KindState, ScrollingMessageState};
use crate::widget::{KindState, ScrollingMessageState, Viewport};
/// Registry key of the ScrollingMessageFrame method table (the MAXCSTACK discipline).
pub(crate) const REG_SCROLLINGMESSAGEFRAME_METHODS: &str =
@@ -40,11 +40,11 @@ fn with_smf<T>(
fn with_viewport<T>(
lua: &Lua,
this: &Table,
f: impl FnOnce(&mut ScrollingMessageState, usize) -> T,
f: impl FnOnce(&mut ScrollingMessageState, Viewport) -> T,
) -> mlua::Result<T> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let viewport_rows = UiScript::message_viewport_rows(&model, h);
let viewport_rows = UiScript::message_viewport(&model, h);
let frame = model
.arena
.frame_mut(h)
@@ -59,7 +59,7 @@ fn with_viewport<T>(
fn scroll(
lua: &Lua,
this: &Table,
op: impl FnOnce(&mut ScrollingMessageState, usize),
op: impl FnOnce(&mut ScrollingMessageState, Viewport),
) -> mlua::Result<()> {
with_viewport(lua, this, op)
}
@@ -94,7 +94,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
} else {
(1.0, 1.0, 1.0)
};
with_smf(lua, &this, |smf| smf.add_with_id(text, r, g, b, id))
with_viewport(lua, &this, |smf, rows| {
smf.add_with_id(text, r, g, b, id, rows);
})
},
)?,
)?;
@@ -116,8 +118,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let id =
crate::script::binding_abi::ftol(is_number(lua, &id).unwrap_or(0.0)) as u32;
let (r, g, b) = (channel(lua, &r), channel(lua, &g), channel(lua, &b));
with_smf(lua, &this, |smf| {
smf.update_color_by_id(id, r, g, b);
with_viewport(lua, &this, |smf, viewport| {
smf.update_color_by_id(id, r, g, b, viewport);
})
},
)?,
@@ -312,14 +314,14 @@ mod tests {
};
s.add("x".into(), 1.0, 1.0, 1.0);
// Phase 1: full alpha while timeVisible remains.
s.tick(0.5);
s.tick(0.5, 8);
assert_eq!(s.lines[0].alpha, 1.0);
// 0.5 more spends timeVisible; the ticks after ramp over fadeDuration.
s.tick(0.5);
s.tick(0.5, 8);
assert_eq!(s.lines[0].alpha, 1.0, "phase 1 just expired, ramp not yet");
s.tick(1.0); // fade_left 2.0 → 1.0 → alpha = trunc(1.0/2.0*255)/255 = 127/255
s.tick(1.0, 8); // fade_left 2.0 → 1.0 → alpha = trunc(1.0/2.0*255)/255 = 127/255
assert!((s.lines[0].alpha - 127.0 / 255.0).abs() < 1e-6);
s.tick(1.0); // fade_left → 0 → retired
s.tick(1.0, 8); // fade_left → 0 → retired
assert_eq!(s.lines[0].alpha, 0.0);
}
@@ -331,9 +333,9 @@ mod tests {
..Default::default()
};
s.add("x".into(), 1.0, 1.0, 1.0);
s.tick(0.6); // spends timeVisible, still full this tick
s.tick(0.6, 8); // spends timeVisible, still full this tick
assert_eq!(s.lines[0].alpha, 1.0);
s.tick(0.1); // past phase 1 with no ramp: straight to 0
s.tick(0.1, 8); // past phase 1 with no ramp: straight to 0
assert_eq!(s.lines[0].alpha, 0.0);
}
@@ -350,16 +352,16 @@ mod tests {
assert!(!s.links_dirty, "the ctor clears it");
s.add("x".into(), 1.0, 1.0, 1.0);
assert!(std::mem::take(&mut s.links_dirty), "AddMessage relayouts");
s.tick(0.6);
s.tick(0.5);
s.tick(0.6, 8);
s.tick(0.5, 8);
assert!(
s.lines[0].alpha > 0.0 && !s.links_dirty,
"mid-ramp: no flag"
);
s.tick(0.6);
s.tick(0.6, 8);
assert_eq!(s.lines[0].alpha, 0.0);
assert!(std::mem::take(&mut s.links_dirty), "the hide raises it");
s.tick(0.6);
s.tick(0.6, 8);
assert!(!s.links_dirty, "a hidden line is not hidden again");
s.scroll_down(4);
assert!(
@@ -368,7 +370,8 @@ mod tests {
);
}
/// With a one-row viewport the scroll re-arms one line; the lines either side show the freeze.
/// With a one-row viewport the scroll re-arms the line in view and the one past it; the line
/// on the other side shows the freeze.
#[test]
fn scrolled_up_freezes_the_fade() {
let mut s = ScrollingMessageState {
@@ -380,20 +383,22 @@ mod tests {
for n in 0..3 {
s.add(format!("l{n}"), 1.0, 1.0, 1.0);
}
s.tick(2.0); // half-way down the ramp: trunc(2.0/4.0*255) = 127
s.tick(2.0, 8); // half-way down the ramp: trunc(2.0/4.0*255) = 127
let half = 127.0 / 255.0;
assert_eq!(s.lines[0].alpha, half);
// Scrolled up, ticks stop; the scroll re-arms only l1, the line now in view.
// Scrolled up, ticks stop; the scroll re-arms l1, the line now in view, and l0, the one
// the relayout walked that did not fit (`0x7888be`).
s.scroll_up(1);
assert!(!s.at_bottom());
assert_eq!(s.lines[1].alpha, 1.0, "the displayed line was re-armed");
s.tick(2.0);
assert_eq!(s.lines[0].alpha, half, "frozen while scrolled up");
assert_eq!(s.lines[0].alpha, 1.0, "so was the first that did not fit");
s.tick(2.0, 8);
assert_eq!(s.lines[0].alpha, 1.0, "frozen while scrolled up");
assert_eq!(s.lines[2].alpha, half);
assert_eq!(s.lines[1].alpha, 1.0);
// Back at the bottom, the fade resumes.
s.scroll_to_bottom(1);
s.tick(2.0);
s.tick(2.0, 8);
assert!(s.lines[2].alpha < 1.0);
}
@@ -411,8 +416,8 @@ mod tests {
for n in 0..3 {
s.add(format!("l{n}"), 1.0, 1.0, 1.0);
}
s.tick(1.5); // spend phase 1
s.tick(1.5); // spend phase 2: all gone
s.tick(1.5, 8); // spend phase 1
s.tick(1.5, 8); // spend phase 2: all gone
assert!(s.lines.iter().all(|l| l.alpha == 0.0), "faded out first");
op(&mut s, 8);
s
@@ -441,6 +446,61 @@ mod tests {
}
}
/// The fade ticks the shown messages only (`0x788460`, bounded by `+0x34c`): a line out of view
/// keeps its fade where it stopped, and shows at that alpha when the window grows.
#[test]
fn the_fade_ticks_only_the_shown_messages() {
let mut s = ScrollingMessageState {
max_lines: 8,
time_visible: 0.0,
fade_duration: 4.0,
..Default::default()
};
for n in 0..5 {
s.add(format!("l{n}"), 1.0, 1.0, 1.0);
}
s.tick(2.0, 2); // l3 and l4 are in view
let half = 127.0 / 255.0;
assert_eq!(s.lines[4].alpha, half);
assert_eq!(s.lines[3].alpha, half);
for hidden in 0..3 {
assert_eq!(s.lines[hidden].alpha, 1.0, "l{hidden} is out of view");
assert_eq!(s.lines[hidden].fade_left, 4.0);
}
s.tick(2.0, 8); // the window grew: all five tick from where each stopped
assert_eq!(s.lines[0].alpha, half);
assert_eq!(s.lines[4].alpha, 0.0);
}
/// A moving scroll re-arms the messages its relayout walks, the shown ones and the first older
/// one that did not fit (`0x788af0` at `0x7888be`); a refused one re-arms the shown only
/// (`0x788b80`).
#[test]
fn a_moving_scroll_re_arms_what_the_relayout_walks() {
let faded = || {
let mut s = ScrollingMessageState {
max_lines: 8,
time_visible: 0.0,
fade_duration: 1.0,
..Default::default()
};
for n in 0..5 {
s.add(format!("l{n}"), 1.0, 1.0, 1.0);
}
s.tick(2.0, 8);
assert!(s.lines.iter().all(|l| l.alpha == 0.0));
s
};
let alphas =
|s: &ScrollingMessageState| s.lines.iter().map(|l| l.alpha).collect::<Vec<_>>();
let mut s = faded();
s.scroll_up(2); // shows l2 l3 (anchor l3), walks l1 as well
assert_eq!(alphas(&s), [0.0, 1.0, 1.0, 1.0, 0.0]);
let mut s = faded();
s.scroll_down(2); // refused at the bottom: l3 and l4 only
assert_eq!(alphas(&s), [0.0, 0.0, 0.0, 1.0, 1.0]);
}
/// `0x788b80` walks the lines on display, not the ring.
#[test]
fn the_re_arm_reaches_only_the_displayed_lines() {
@@ -453,8 +513,8 @@ mod tests {
for n in 0..3 {
s.add(format!("l{n}"), 1.0, 1.0, 1.0);
}
s.tick(1.5);
s.tick(1.5);
s.tick(1.5, 8);
s.tick(1.5, 8);
s.scroll_down(1); // a no-op scroll at the bottom, one row of viewport
assert_eq!(s.lines[2].alpha, 1.0, "the one displayed line came back");
assert_eq!(s.lines[1].alpha, 0.0, "off-screen lines are untouched");
@@ -506,6 +566,50 @@ mod tests {
);
}
/// A full ring writes over its oldest slot and leaves the anchor slot where it is
/// (`0x788150`): a scrolled-up view keeps its messages (`0x788220`), and scrolls one line
/// newer only when the write lands on the topmost shown one (`0x78819e`-`0x7881bb`,
/// `0x78823f`-`0x78824a`).
#[test]
fn a_full_ring_holds_a_scrolled_up_view_still() {
let full = || {
let mut s = ScrollingMessageState {
max_lines: 4,
..Default::default()
};
for n in 0..4 {
s.add(format!("l{n}"), 1.0, 1.0, 1.0);
}
s
};
let anchor =
|s: &ScrollingMessageState| s.lines[s.lines.len() - 1 - s.scroll_offset].text.clone();
// Two rows, one line back: l2 l1 are in view, the oldest is not.
let mut s = full();
s.scroll_up(2);
assert_eq!(anchor(&s), "l2");
s.add_with_id("l4".into(), 1.0, 1.0, 1.0, 0, 2);
assert_eq!(anchor(&s), "l2", "the same messages stay in view");
// Now l1 is the oldest and shows, so the next write lands on the topmost shown message.
s.add_with_id("l5".into(), 1.0, 1.0, 1.0, 0, 2);
assert_eq!(anchor(&s), "l3", "one line newer as the oldest went");
// The oldest shows (l1 l0), so the write lands on the topmost shown message.
let mut s = full();
s.scroll_up(2);
s.scroll_up(2);
assert_eq!(anchor(&s), "l1");
s.add_with_id("l4".into(), 1.0, 1.0, 1.0, 0, 2);
assert_eq!(
anchor(&s),
"l2",
"scrolled one line newer as the oldest went"
);
// At the bottom the view is the newest.
let mut s = full();
s.add_with_id("l4".into(), 1.0, 1.0, 1.0, 0, 2);
assert!(s.at_bottom());
}
#[test]
fn displayed_count_and_paging_respect_wrapped_rows() {
let mut s = ScrollingMessageState {
@@ -608,6 +712,17 @@ mod tests {
}
}
/// `ScrollToTop` and `ScrollToBottom` on an empty frame return at once (`0x788697`-`0x78869a`,
/// `0x7886d6`-`0x7886d9`): no re-arm, no link rebuild.
#[test]
fn scrolling_to_either_end_of_an_empty_frame_does_nothing() {
let mut s = ScrollingMessageState::default();
s.scroll_to_top(8);
s.scroll_to_bottom(8);
assert!(!s.links_dirty);
assert_eq!(s.scroll_offset, 0);
}
/// `ScrollToTop` on a single message anchors the newest (`0x788799`-`0x7887aa`); a frame
/// already at the bottom skips the re-arm (`0x788aff`), which two messages do not.
#[test]
@@ -621,7 +736,7 @@ mod tests {
for n in 0..count {
s.add(format!("l{n}"), 1.0, 1.0, 1.0);
}
s.tick(2.0);
s.tick(2.0, 8);
s.links_dirty = false;
s.scroll_to_top(8);
s
@@ -685,6 +800,77 @@ mod tests {
);
}
/// `CF` holds `l0`..`l5`, three rows high, scrolled up one line (shown `l4 l3 l2`, walked
/// `l1`), with every line's alpha knocked to 0.5; `bottom` scrolls it back down first.
fn faded_chat(bottom: bool) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
s.run(
"CF = CreateFrame('ScrollingMessageFrame', 'CF')\n\
CF:SetPoint('BOTTOMLEFT', 32, 90)\n\
CF:SetWidth(430)\n\
CF:SetHeight(44)\n\
for n = 0, 5 do CF:AddMessage('l' .. n, 1, 1, 1, 5) end",
)
.unwrap();
s.resolve();
s.tick(0.0); // the first relayout
s.run(if bottom { "" } else { "CF:ScrollUp()" }).unwrap();
let mut model = s.model_mut();
let fh = model.arena.lookup("CF").expect("CF");
let Some(crate::widget::KindState::ScrollingMessage(smf)) =
model.arena.frame_mut(fh).map(|f| &mut f.kind_state)
else {
unreachable!("a scrolling frame")
};
smf.lines.iter_mut().for_each(|l| l.alpha = 0.5);
drop(model);
s
}
fn alphas(s: &UiScript) -> Vec<f32> {
let model = s.model_ref();
let fh = model.arena.lookup("CF").expect("CF");
match model.arena.frame(fh).map(|f| &f.kind_state) {
Some(crate::widget::KindState::ScrollingMessage(smf)) => {
smf.lines.iter().map(|l| l.alpha).collect()
}
_ => unreachable!("a scrolling frame"),
}
}
/// A relayout that is not a scroll re-arms what it walks off the bottom, as a scroll does:
/// force 0 re-arms unless `+0x354` is set (`0x788aff`). A resize (`0x788427`), a font change
/// (`0x788086`), a show (`0x788451`) and `UpdateColorByID` (`0x78829e`) are such relayouts.
#[test]
fn a_relayout_without_a_scroll_re_arms_what_it_walks_off_the_bottom() {
let (half, full) = (0.5, 1.0);
let case = |action: &str, after: &[f32]| {
let mut s = faded_chat(false);
// Nothing relayouts on its own.
s.resolve();
s.tick(0.0);
assert_eq!(alphas(&s), [half; 6], "{action}: before");
s.run(action).unwrap();
s.resolve();
s.tick(0.0);
assert_eq!(alphas(&s), after, "{action}");
};
// Grown to six rows, the view places l1 and l0 as well: l5 is newer than the anchor.
case("CF:SetHeight(86)", &[full, full, full, full, full, half]);
// A new font: the three rows in view and the one that did not fit.
let four = [half, full, full, full, full, half];
case("CF:SetFont('Fonts\\\\FRIZQT__.TTF', 12)", &four);
case("CF:Hide() CF:Show()", &four);
case("CF:UpdateColorByID(5, 1, 0, 0)", &four);
// At the bottom a relayout re-arms nothing (`0x788aff`).
let mut s = faded_chat(true);
s.run("CF:SetHeight(86)").unwrap();
s.resolve();
s.tick(0.0);
assert_eq!(alphas(&s), [half; 6]);
}
/// A scrolling frame `CF` of the given height holding `messages`, bottom left at (32, 90),
/// after the host answered each message's row count with `rows(index)`: what it draws as
/// `(text, bottom, top)` in draw order, and the count its layout reports.
@@ -730,7 +916,7 @@ mod tests {
.collect();
let model = s.model_mut();
let fh = fh.expect("a line drew");
let viewport = UiScript::message_viewport_rows(&model, fh);
let viewport = UiScript::message_viewport(&model, fh);
let count = match &model.arena.frame(fh).expect("the frame").kind_state {
crate::widget::KindState::ScrollingMessage(smf) => smf.displayed_count(viewport),
_ => unreachable!("a scrolling frame"),
@@ -991,12 +1177,12 @@ mod tests {
#[test]
fn update_color_by_id_recolours_only_that_ids_lines() {
let mut smf = ScrollingMessageState::default();
smf.add_with_id("a".into(), 1.0, 1.0, 1.0, 11);
smf.add_with_id("b".into(), 1.0, 1.0, 1.0, 1);
smf.add_with_id("c".into(), 1.0, 1.0, 1.0, 11);
smf.add_with_id("a".into(), 1.0, 1.0, 1.0, 11, 8);
smf.add_with_id("b".into(), 1.0, 1.0, 1.0, 1, 8);
smf.add_with_id("c".into(), 1.0, 1.0, 1.0, 11, 8);
smf.add("d".into(), 1.0, 1.0, 1.0);
let gen = smf.lines_gen;
assert_eq!(smf.update_color_by_id(11, 0.0, 0.5, 1.0), 2);
assert_eq!(smf.update_color_by_id(11, 0.0, 0.5, 1.0, 8), 2);
let colors: Vec<[u8; 3]> = smf.lines.iter().map(|l| l.color).collect();
assert_eq!(
colors,
@@ -1008,12 +1194,20 @@ mod tests {
]
);
assert_ne!(smf.lines_gen, gen, "a recolour is a redraw");
assert_eq!(smf.update_color_by_id(11, 0.0, 0.5, 1.0), 0, "idempotent");
assert_eq!(smf.update_color_by_id(99, 0.0, 0.0, 0.0), 0, "no such id");
assert_eq!(
smf.update_color_by_id(11, 0.0, 0.5, 1.0, 8),
0,
"idempotent"
);
assert_eq!(
smf.update_color_by_id(99, 0.0, 0.0, 0.0, 8),
0,
"no such id"
);
// Id 0 matches nothing (`0x788250`). REPLY's chat type has id 0 and `UPDATE_CHAT_COLOR`
// mirrors WHISPER into it, so without the guard every id-less line turns whisper pink.
assert_eq!(
smf.update_color_by_id(0, 1.0, 0.5, 1.0),
smf.update_color_by_id(0, 1.0, 0.5, 1.0, 8),
0,
"id 0 never reaches the record walk"
);
+2 -1
View File
@@ -980,9 +980,10 @@ impl UiScript {
let Some(h) = model.arena.lookup(frame) else {
return false;
};
let rows = Self::message_viewport(&model, h);
match model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
Some(crate::widget::KindState::ScrollingMessage(smf)) => {
smf.add(text.to_string(), r, g, b);
smf.add_with_id(text.to_string(), r, g, b, 0, rows);
true
}
_ => false,
@@ -398,7 +398,8 @@ fn a_plain_message_frames_slots_step_by_the_raw_pitch() {
}
/// A scrolling frame charges each message its height plus the line string's spacing rounded up to
/// a whole pixel (`0x78899e` → `0x766750`), and a wrapped message's inner gap likewise.
/// a whole pixel (`0x78899e` → `0x766750`), and a wrapped message's inner gap likewise
/// (`0x5c2143`-`0x5c215c`).
#[test]
fn a_scrolling_frame_rounds_a_fractional_spacing_up() {
let mut s = script();
@@ -439,6 +440,73 @@ fn a_scrolling_frame_rounds_a_fractional_spacing_up() {
}
}
/// A scrolling frame at `scale`, spacing `spacing`, `height` tall, holding 'old', 'wrapped' (two
/// rows) and 'new', as `(text, bottom, top)` in draw order.
fn spaced_bands(scale: f32, spacing: f32, height: f32) -> Vec<(String, f32, f32)> {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(&format!(
"local f = CreateFrame('ScrollingMessageFrame', 'GapChat3')\n\
f:SetPoint('BOTTOMLEFT', 0, 90)\n\
f:SetWidth(400)\n\
f:SetHeight({height})\n\
f:SetScale({scale})\n\
f:SetFont('Fonts\\\\FRIZQT__.TTF', 14)\n\
f:SetSpacing({spacing})\n\
f:AddMessage('old', 1, 1, 1)\n\
f:AddMessage('wrapped', 1, 1, 1)\n\
f:AddMessage('new', 1, 1, 1)"
))
.unwrap();
s.resolve();
let answers: Vec<(u32, u32, u16, u64)> = s
.message_lines_needing_measure()
.iter()
.map(|r| (r.frame, r.index, if r.index == 1 { 2 } else { 1 }, r.key))
.collect();
s.set_message_line_rows(&answers);
bands(&s)
}
/// The gap between messages is the unscaled spacing snapped, then scaled (`0x788954`-`0x788975`);
/// the gap between the rows of one message is the spacing times the scale snapped up to a whole
/// pixel (`0x5c2143`-`0x5c215c`). At 0.75 with spacing 1, that is 0.75 between messages and 1
/// between rows, where snapping the scaled value for both gives 1 and 1.
#[test]
fn a_scrolling_frames_gaps_scale_rather_than_snap_scaled() {
let got = spaced_bands(0.75, 1.0, 100.0);
assert_eq!(got.len(), 3, "{got:?}");
let by = |t: &str| got.iter().find(|g| g.0 == t).expect(t).clone();
let (old, wrapped, new) = (by("old"), by("wrapped"), by("new"));
// 14 px type at 0.75 is 10.5 px a row: two rows and the snapped gap make 22.
assert!((wrapped.2 - wrapped.1 - 22.0).abs() < 0.01, "{wrapped:?}");
assert!(
(wrapped.1 - new.1 - 11.25).abs() < 0.01,
"{new:?} {wrapped:?}"
);
assert!(
(old.1 - wrapped.1 - 22.75).abs() < 0.01,
"{wrapped:?} {old:?}"
);
}
/// The fit is per message: each takes its own block height and the gap after it from what is left
/// (`0x7889a3`-`0x7889be`). At 0.75 with spacing 1 a message costs 10.5 + 0.75 and a wrapped one
/// 21 + 1 + 0.75, 34 together. A count of equal rows of 11.25 would hold 'wrapped' in 33.9 px, and
/// per message it does not; in 34.2 px both do.
#[test]
fn a_scrolling_frames_fit_is_per_message() {
let texts = |height_px: f32| -> Vec<String> {
// The frame is at 0.75, so its set height is the screen height over the scale.
spaced_bands(0.75, 1.0, height_px / 0.75)
.into_iter()
.map(|g| g.0)
.collect()
};
assert_eq!(texts(33.9), ["new"]);
assert_eq!(texts(34.2), ["wrapped", "new"]);
}
/// `spacing=` is read with `atof` (`0x64aaa0`): the numeric prefix, 0 for none.
#[test]
fn the_spacing_attribute_is_read_with_atof() {
@@ -275,6 +275,7 @@ fn notify(lua: &Lua, h: FrameHandle, visible: bool) {
record(lua, fired);
}
super::editbox::visibility_focus(lua, h, visible);
super::messageframe::on_visibility(lua, h, visible);
}
/// Open or close a frame's load window: the pre-load hook's `+0x114 = 1` (`0x7697c2`) and the
@@ -54,6 +54,10 @@ pub struct ScrollingMessageState {
/// relayout (`0x788a8e`), a line fading out (`0x788538`) and a fade re-arm (`0x788bf1`), and
/// spent by the per-frame rebuild (`0x788595` → `0x788c00`).
pub links_dirty: bool,
/// A key of what the last relayout laid out against: the frame's size and font. A change is
/// a resize (`0x788427`) or a font change (`0x788086`), and `None` after a show (`0x788451`):
/// each relayouts at the current position, which re-arms what it walks unless at the bottom.
pub laid: Option<u64>,
}
impl Default for ScrollingMessageState {
@@ -69,6 +73,7 @@ impl Default for ScrollingMessageState {
lines_gen: 0,
scroll_offset: 0,
links_dirty: false,
laid: None,
}
}
}
@@ -78,16 +83,72 @@ pub fn quantize_u8(x: f32) -> u8 {
(x.clamp(0.0, 1.0) * 255.0 + 0.5).trunc() as u8
}
/// The space a ScrollingMessageFrame's layout fits messages into, in screen pixels. The relayout
/// charges each message its block height, which spaces its rows by the spacing times the scale
/// snapped up to a whole pixel (`0x772a07`-`0x772a0d` forms it, `0x5c2143`-`0x5c215c` snaps it),
/// plus the gap to the next message, the spacing snapped unscaled and then scaled
/// (`0x788954`-`0x788975`, `0x7889a3`-`0x7889ad`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Viewport {
/// The frame's height.
pub height: f32,
/// One row's height.
pub line_h: f32,
/// The gap between the rows of one message, snapped up to a whole pixel.
pub row_gap: f32,
/// The gap between one message and the next.
pub message_gap: f32,
}
impl Viewport {
/// A viewport of `n` unit rows with no gaps, for a test that has no font.
#[cfg(test)]
pub fn rows(n: usize) -> Viewport {
Viewport {
height: n as f32,
line_h: 1.0,
row_gap: 0.0,
message_gap: 0.0,
}
}
/// The height of a message of `rows` wrapped rows, as `GxuFontGetTextBlockHeight` gives it:
/// `rows` lines and `rows - 1` row gaps (`0x5c2168`).
pub fn block(&self, rows: u16) -> f32 {
let rows = f32::from(rows.max(1));
rows * self.line_h + (rows - 1.0) * self.row_gap
}
/// What a message of `rows` wrapped rows takes from the frame: its block and the gap after it.
pub fn cost(&self, rows: u16) -> f32 {
self.block(rows) + self.message_gap
}
}
#[cfg(test)]
impl From<usize> for Viewport {
fn from(n: usize) -> Viewport {
Viewport::rows(n)
}
}
impl ScrollingMessageState {
/// `UpdateColorByID(id, r, g, b)` (`0x7932b0`): recolour every line tagged `id`, returning how
/// many changed. `id == 0` matches nothing (the guard at `0x788250`): a line printed without
/// an id stores 0 (`0x7929b7`), and `ChatFrame.lua:1357` recolours REPLY, whose id is 0.
pub fn update_color_by_id(&mut self, id: u32, r: f32, g: f32, b: f32) -> usize {
pub fn update_color_by_id(
&mut self,
id: u32,
r: f32,
g: f32,
b: f32,
viewport: impl Into<Viewport>,
) -> usize {
if id == 0 {
return 0;
}
// Past the guard the relayout runs whether or not a line matched (`0x78829e`).
self.links_dirty = true;
self.relayout(viewport);
let rgb = [quantize_u8(r), quantize_u8(g), quantize_u8(b)];
let mut moved = 0;
for line in self.lines.iter_mut().filter(|l| l.id == id) {
@@ -102,16 +163,34 @@ impl ScrollingMessageState {
moved
}
/// [`Self::add_with_id`] for a state that has no viewport yet, here eight rows high.
#[cfg(test)]
pub fn add(&mut self, text: String, r: f32, g: f32, b: f32) {
self.add_with_id(text, r, g, b, 0);
self.add_with_id(text, r, g, b, 0, 8);
}
/// `AddMessage(text, r, g, b, id)` (`0x792900`, `0x788150`): push the line and drop the oldest
/// past `max_lines`; a scrolled-up view stays on the same lines.
pub fn add_with_id(&mut self, text: String, r: f32, g: f32, b: f32, id: u32) {
/// past `max_lines`. The store leaves the anchor slot where it is, so a scrolled-up view
/// stays on the same messages (`0x788220`) and relayouts nothing, except that a full ring
/// whose write lands on the topmost shown message, the oldest, scrolls one line newer
/// (`0x78819e`-`0x7881bb`, `0x78823f`-`0x78824a`). At the bottom it relayouts at the newest
/// (`0x788234`).
pub fn add_with_id(
&mut self,
text: String,
r: f32,
g: f32,
b: f32,
id: u32,
viewport: impl Into<Viewport>,
) {
let viewport = viewport.into();
self.lines_gen = self.lines_gen.wrapping_add(1);
// Its relayout (`0x788234`).
self.links_dirty = true;
let scrolled_up = self.scroll_offset > 0;
// A full ring writes over the oldest message, which is the topmost shown one when the
// view reaches the top.
let overwrites_topmost =
scrolled_up && self.lines.len() >= self.max_lines && self.at_top(viewport);
let line = MessageLine {
text,
id,
@@ -123,14 +202,18 @@ impl ScrollingMessageState {
rows_key: 0,
};
self.lines.push_back(line);
// Scrolled up: keep the same lines in view as the ring grows below them.
if self.scroll_offset > 0 {
self.scroll_offset += 1;
}
while self.lines.len() > self.max_lines {
self.lines.pop_front();
// The dropped line was above the view: walk the anchor back down with it.
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
if !scrolled_up {
self.links_dirty = true;
} else if overwrites_topmost {
// The new message puts the anchor one further from the newest, and the scroll down
// (`0x788245`) takes it back one, so the offset holds.
self.rearm_walked(viewport);
} else {
// The same messages stay in view: the new one is one place newer than the anchor.
self.scroll_offset += 1;
}
self.clamp_scroll();
}
@@ -163,12 +246,13 @@ impl ScrollingMessageState {
/// (`0x787775`, `0x7882ce`, `0x787eb1`). This reads the layout live, while the reference caches
/// the flag per relayout, so the two differ on a hidden frame, whose relayout does nothing
/// (`0x78878e`), and after an `AddMessage` while scrolled up (`0x788220`-`0x78824a`).
pub fn at_top(&self, viewport_rows: usize) -> bool {
pub fn at_top(&self, viewport: impl Into<Viewport>) -> bool {
let viewport = viewport.into();
if self.lines.is_empty() {
return false;
}
let available = self.lines.len().saturating_sub(self.scroll_offset);
self.displayed_count(viewport_rows) == available
self.displayed_count(viewport) == available
}
/// The furthest the view can scroll: the oldest line as the anchor, where `ScrollToTop`
@@ -181,15 +265,44 @@ impl ScrollingMessageState {
self.scroll_offset = self.scroll_offset.min(self.max_scroll());
}
/// Re-arm the fade of the displayed lines only, on a frame with lines (`0x788b80`): full alpha and the frame's
/// current `timeVisible`/`fadeDuration`, so even a fully faded line comes back (expiry frees
/// nothing, `0x788525`). Engine-only, not a 1.12 Lua verb; every scroll entry reaches it.
pub fn reset_all_fade_times(&mut self, viewport_rows: usize) {
/// Re-arm the fade of the displayed lines only, on a frame with lines (`0x788b80`): full alpha
/// and the frame's current `timeVisible`/`fadeDuration`, so even a fully faded line comes back
/// (expiry frees nothing, `0x788525`). The refused scroll's re-arm; a scroll that moves
/// re-arms [`Self::rearm_walked`]. Engine-only, not a 1.12 Lua verb.
pub fn reset_all_fade_times(&mut self, viewport: impl Into<Viewport>) {
let viewport = viewport.into();
// Both a refused scroll's re-arm (`0x788bf1`) and a moving one's relayout (`0x788a8e`)
// mark the links for rebuild.
self.links_dirty = true;
self.rearm(self.displayed_range(viewport));
}
/// A relayout at the current position, with no scroll: it marks the links and, off the bottom,
/// re-arms what it walks (force 0 re-arms unless `+0x354` is set, `0x788aff`). `UpdateColorByID`
/// (`0x78829e`), a resize (`0x788427`), a font change (`0x788086`) and a show (`0x788451`) are
/// such relayouts.
pub fn relayout(&mut self, viewport: impl Into<Viewport>) {
if self.at_bottom() {
self.links_dirty = true;
} else {
self.rearm_walked(viewport);
}
}
/// A moving scroll's re-arm: the relayout re-arms each message it walks (`0x788af0` at
/// `0x7888be`, before the fit test at `0x7889b3`). That is the displayed messages and the
/// first older one that did not fit; a walk that ran out of messages has no extra one. The
/// extra message is re-armed but not shown, so it ticks only once a larger view places it.
pub fn rearm_walked(&mut self, viewport: impl Into<Viewport>) {
let viewport = viewport.into();
self.links_dirty = true;
let shown = self.displayed_range(viewport);
self.rearm(shown.start.saturating_sub(1)..shown.end);
}
fn rearm(&mut self, range: std::ops::Range<usize>) {
let (time_visible, fade_duration) = (self.time_visible, self.fade_duration);
for line in self.lines.range_mut(self.displayed_range(viewport_rows)) {
for line in self.lines.range_mut(range) {
line.time_left = time_visible;
line.fade_left = fade_duration;
line.alpha = 1.0;
@@ -197,8 +310,9 @@ impl ScrollingMessageState {
}
/// The ring indices the view shows, oldest first.
pub fn displayed_range(&self, viewport_rows: usize) -> std::ops::Range<usize> {
let count = self.displayed_count(viewport_rows);
pub fn displayed_range(&self, viewport: impl Into<Viewport>) -> std::ops::Range<usize> {
let viewport = viewport.into();
let count = self.displayed_count(viewport);
if count == 0 {
return 0..0;
}
@@ -213,80 +327,107 @@ impl ScrollingMessageState {
// (`0x788750`), whose helper `0x788af0` re-arms on landing off the bottom or back onto it.
/// `ScrollUp` (`0x788610`): one line older, then re-arm.
pub fn scroll_up(&mut self, viewport_rows: usize) {
self.step_up(viewport_rows);
pub fn scroll_up(&mut self, viewport: impl Into<Viewport>) {
let viewport = viewport.into();
self.step_up(viewport);
}
/// `ScrollDown` (`0x788650`): one line newer, then re-arm.
pub fn scroll_down(&mut self, viewport_rows: usize) {
self.step_down(viewport_rows);
pub fn scroll_down(&mut self, viewport: impl Into<Viewport>) {
let viewport = viewport.into();
self.step_down(viewport);
}
/// `0x788610`'s answer: whether the view moved. An empty frame answers 0 and touches nothing
/// (`0x788619`); one whose oldest message is on screen re-arms and answers 0 (`0x78861c`-
/// `0x788626`).
pub fn step_up(&mut self, viewport_rows: usize) -> bool {
pub fn step_up(&mut self, viewport: impl Into<Viewport>) -> bool {
let viewport = viewport.into();
if self.lines.is_empty() {
return false;
}
let moved = !self.at_top(viewport_rows);
let moved = !self.at_top(viewport);
if moved {
self.scroll_offset += 1;
self.rearm_walked(viewport);
} else {
self.reset_all_fade_times(viewport);
}
self.reset_all_fade_times(viewport_rows);
moved
}
/// `0x788650`'s answer: the same, toward the newest line (`0x788659`, `0x788666`).
pub fn step_down(&mut self, viewport_rows: usize) -> bool {
pub fn step_down(&mut self, viewport: impl Into<Viewport>) -> bool {
let viewport = viewport.into();
if self.lines.is_empty() {
return false;
}
let moved = !self.at_bottom();
self.scroll_offset = self.scroll_offset.saturating_sub(1);
self.reset_all_fade_times(viewport_rows);
if moved {
self.scroll_offset -= 1;
self.rearm_walked(viewport);
} else {
self.reset_all_fade_times(viewport);
}
moved
}
/// `ScrollToBottom` (`0x7886d0`): to the newest line, then re-arm.
pub fn scroll_to_bottom(&mut self, viewport_rows: usize) {
/// `ScrollToBottom` (`0x7886d0`): to the newest line, then re-arm. An empty frame returns
/// at once (`0x7886d6`-`0x7886d9`).
pub fn scroll_to_bottom(&mut self, viewport: impl Into<Viewport>) {
let viewport = viewport.into();
if self.lines.is_empty() {
return;
}
// At the bottom it is the refused re-arm (`0x7886e5`); else the relayout's.
let moved = !self.at_bottom();
self.scroll_offset = 0;
self.reset_all_fade_times(viewport_rows);
if moved {
self.rearm_walked(viewport);
} else {
self.reset_all_fade_times(viewport);
}
}
/// `ScrollToTop` (`0x788690`): the oldest line as the anchor, then re-arm, as its relayout
/// lands off the bottom. With exactly one message the anchor is the newest (`0x788799`-
/// `0x7887aa`), and a frame already at the bottom skips the re-arm (`0x788aff`); the links
/// still rebuild (`0x788a8e`).
pub fn scroll_to_top(&mut self, viewport_rows: usize) {
/// still rebuild (`0x788a8e`). An empty frame returns at once (`0x788697`-`0x78869a`).
pub fn scroll_to_top(&mut self, viewport: impl Into<Viewport>) {
let viewport = viewport.into();
if self.lines.is_empty() {
return;
}
let stays_at_bottom = self.at_bottom() && self.lines.len() == 1;
self.scroll_offset = self.max_scroll();
if stays_at_bottom {
self.links_dirty = true;
} else {
self.reset_all_fade_times(viewport_rows);
self.rearm_walked(viewport);
}
}
/// How many messages the layout places for a frame `viewport_rows` tall, the client's lines
/// shown (`+0x34c`, which paging reads at `0x7885b4`). From the anchor outward each message
/// costs its wrapped rows (`0x7889ad`); one that does not fit whole ends the walk
/// (`0x7889b3`-`0x7889be`, `0x788a2f`) unless it is the anchored one (`0x7889c0`-`0x7889c5`),
/// which always shows. A faded message is placed all the same (`0x7889c7`-`0x7889ee` only
/// hides it), so it holds its rows. The draw (`emit_message_lines`) walks this many.
pub fn displayed_count(&self, viewport_rows: usize) -> usize {
/// How many messages the layout places in `viewport`, the client's lines shown (`+0x34c`,
/// which paging reads at `0x7885b4`). From the anchor outward each message costs its block
/// height plus the gap to the next (`0x7889a3`-`0x7889ad`); one that does not fit whole in
/// what is left ends the walk (`0x7889b3`-`0x7889be`, `0x788a2f`) unless it is the anchored
/// one (`0x7889c0`-`0x7889c5`), which always shows. A faded message is placed all the same
/// (`0x7889c7`-`0x7889ee` only hides it), so it holds its rows. The draw
/// (`emit_message_lines`) walks this many.
pub fn displayed_count(&self, viewport: impl Into<Viewport>) -> usize {
let viewport = viewport.into();
if self.lines.is_empty() {
return 0;
}
let top_index = self.lines.len().saturating_sub(1 + self.scroll_offset);
let mut left = viewport_rows;
let mut left = viewport.height;
let mut count = 0usize;
for idx in (0..=top_index).rev() {
let rows = usize::from(self.lines[idx].rows.max(1));
if count > 0 && rows > left {
let cost = viewport.cost(self.lines[idx].rows);
if count > 0 && left < cost {
break;
}
left = left.saturating_sub(rows);
left -= cost;
count += 1;
}
count
@@ -295,36 +436,41 @@ impl ScrollingMessageState {
/// `PageUp` (`0x7885b0`): `ScrollUp` once per displayed line, the count read once up front
/// (`0x7885b4`); a step that cannot move ends the page there (`0x7885ca`), else one `ScrollDown`
/// follows (`0x7885d4`). A page is the displayed count less one, and one displayed line nets 0.
pub fn page_up(&mut self, viewport_rows: usize) {
for _ in 0..self.displayed_count(viewport_rows) {
if !self.step_up(viewport_rows) {
pub fn page_up(&mut self, viewport: impl Into<Viewport>) {
let viewport = viewport.into();
for _ in 0..self.displayed_count(viewport) {
if !self.step_up(viewport) {
return;
}
}
self.step_down(viewport_rows);
self.step_down(viewport);
}
/// `PageDown` (`0x7885e0`): the mirror, `ScrollDown` per displayed line (`0x7885f3`), a refusal
/// ending the page (`0x7885fa`), else one `ScrollUp` (`0x788604`).
pub fn page_down(&mut self, viewport_rows: usize) {
for _ in 0..self.displayed_count(viewport_rows) {
if !self.step_down(viewport_rows) {
pub fn page_down(&mut self, viewport: impl Into<Viewport>) {
let viewport = viewport.into();
for _ in 0..self.displayed_count(viewport) {
if !self.step_down(viewport) {
return;
}
}
self.step_up(viewport_rows);
self.step_up(viewport);
}
/// Advance the fade by `dt` (OnUpdate, `0x788460`), only at the bottom with fading on; the
/// scroll entries, not this, bring faded lines back. Both countdowns store `f32` while the
/// ramp reads the unrounded x87 value (`fst` without pop, `0x7884d7`, `0x788544`), and phase
/// 1's overshoot is dropped, so the fade starts whole on the next tick.
pub fn tick(&mut self, dt: f32) {
pub fn tick(&mut self, dt: f32, viewport: impl Into<Viewport>) {
let viewport = viewport.into();
if !self.fading_enabled || !self.at_bottom() {
return;
}
let dt = f64::from(dt);
for line in &mut self.lines {
// Only the shown messages: the loop is bounded by the shown count (`0x78849b`,
// `0x788582`), so a line out of view keeps its fade where it stopped.
for line in self.lines.range_mut(self.displayed_range(viewport)) {
if line.time_left > 0.0 {
line.time_left = ((f64::from(line.time_left) - dt).max(0.0)) as f32;
continue;
+4 -4
View File
@@ -134,10 +134,10 @@ pub use kinds::{
MessageFrameState, MessageLine, MinimapState, ModelFileFacts, ModelFog, ModelLight,
ModelPlayHead, ModelState, PaneArm, PaneCopy, RegionKind, ScrollFrameState,
ScrollingMessageState, SequenceFacts, ShownWrites, SliderState, StatusBarState, TooltipAnchor,
TooltipState, ANIM_SHUFFLE_LEFT, ANIM_SHUFFLE_RIGHT, ANIM_STAND, MINIMAP_DEFAULT_ARROW_MODEL,
MINIMAP_DEFAULT_MASK, MINIMAP_DEFAULT_PLAYER_MODEL, MINIMAP_DEFAULT_ZOOM,
MINIMAP_ENGINE_CHILDREN, MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP, TOOLTIP_FADE_SECS,
TOOLTIP_LINE_GAP, TOOLTIP_PAD, TOOLTIP_WRAP_WIDTH, TURN_HOLD_MS,
TooltipState, Viewport, ANIM_SHUFFLE_LEFT, ANIM_SHUFFLE_RIGHT, ANIM_STAND,
MINIMAP_DEFAULT_ARROW_MODEL, MINIMAP_DEFAULT_MASK, MINIMAP_DEFAULT_PLAYER_MODEL,
MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN, MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP,
TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD, TOOLTIP_WRAP_WIDTH, TURN_HOLD_MS,
};
// ── Frame and Region nodes ───────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -739,7 +739,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
- The drag selects from its anchor, as every OS's own text fields do, where the reference's applies `0x77cd10` to each mouse move's whole delta (`0x77a8a1`). ([`src/widget/kinds/editbox.rs:997`](../crates/benilla-ui/src/widget/kinds/editbox.rs#L997))
- A code point past U+FFFF is no space, where the client tests its low 16 bits, so U+10020 counts as a space there. ([`src/widget/kinds/editbox.rs:1127`](../crates/benilla-ui/src/widget/kinds/editbox.rs#L1127))
- The reference has one field, the font's, but our font is read off the first `<FontString>` region (`message_frame_font`), which a `CreateFrame` frame lacks; so that FontString's own `GetJustifyH` does not see this. ([`src/widget/kinds/messageframe.rs:33`](../crates/benilla-ui/src/widget/kinds/messageframe.rs#L33))
- Kept apart from the font's, as `ScrollingMessageState::justify` is. ([`src/widget/kinds/messageframe.rs:386`](../crates/benilla-ui/src/widget/kinds/messageframe.rs#L386))
- Kept apart from the font's, as `ScrollingMessageState::justify` is. ([`src/widget/kinds/messageframe.rs:532`](../crates/benilla-ui/src/widget/kinds/messageframe.rs#L532))
- `None` until then, not the ctor's 0, so that an unset row takes nothing rather than slot 0. ([`src/widget/kinds/mod.rs:859`](../crates/benilla-ui/src/widget/kinds/mod.rs#L859))
- On the thumb it is the grabbed point, where `CSimpleSlider` (`0x789ba0`/`0x789ca0`) always centers it, because that feels less surprising and a stock-sized thumb hides the difference. ([`src/widget/kinds/mod.rs:1203`](../crates/benilla-ui/src/widget/kinds/mod.rs#L1203))
- Levels pack contiguously, because the reference's resuming cursor (`0x764fd3`/`0x764fd8`) leaves a hole past a wide gap ({0, 4, 6} at bound 7 gives {0, 1, 3} at bound 4). ([`src/widget/propagation.rs:180`](../crates/benilla-ui/src/widget/propagation.rs#L180))