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:
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user