`SetFont` raised on a `false` flags argument and wrote the height even when the load failed, and only SimpleHTML had `SetSpacing`/`GetSpacing`, so SpellAlert's message frame raised in its OnLoad. In 1.12.1 `SetFont` (`0x79f210`, the FontString's `0x79d4f0`) reads flags only when `lua_isstring` (`0x79f287`), writes them on every successful load (`0x79f334`), and writes nothing on a failed load or a height ≤ 0 (`0x79f361`, `0x79f2f4`, `0x771571`). Every text table has the spacing pair (`0x79fb40`/`0x79fbe0`; the FontString's clamps at `0x772240`); the line step adds the gap rounded up to a device pixel (`0x5c2070`, `0x5cdc20`), as do the message frames (`0x78899e`, `0x785f06`) and EditBox rows (`0x77d133`). All seven SetFont bindings share these gates and ask the host's font probe; spacing is stored, inherited, measured and drawn on all six tables. Tests bite on the parent for each. Fixes #873 Fixes #692 Fixes #945 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
253 lines
9.9 KiB
Rust
253 lines
9.9 KiB
Rust
//! The EditBox seam the host drives each frame: the advance-table round trip, the caret,
|
|
//! selection and scroll geometry derived from it, and the clipboard pair.
|
|
|
|
use mlua::Lua;
|
|
|
|
use crate::script::{types, Model, UiScript};
|
|
use crate::widget::{EditBoxState, FrameHandle, KindState, RegionHandle};
|
|
|
|
impl UiScript {
|
|
/// The focused EditBox's request to measure its display string's per-byte cumulative widths,
|
|
/// when its text, font, scale or wrap width changed; an empty display needs no trip. Answer it
|
|
/// with [`Self::set_editbox_advances`] before [`Self::focused_editbox_text_ui`].
|
|
pub fn editbox_advances_request(&mut self) -> Option<types::EditBoxAdvanceRequest> {
|
|
let h = self.model_mut().focused_editbox?;
|
|
// The lazy text region first: its font keys the table, and a new box has none until now.
|
|
let rh = super::ensure_text_region(self.lua(), h)?;
|
|
stale_advances(&mut self.model_mut(), h, rh)
|
|
}
|
|
|
|
/// Answer an [`Self::editbox_advances_request`]: the cumulative widths (len+1 entries from 0),
|
|
/// the wrapped-row starts and the row pitch, which only a multi-line box reads.
|
|
pub fn set_editbox_advances(
|
|
&mut self,
|
|
id: u32,
|
|
key: u64,
|
|
cum: Vec<f32>,
|
|
rows: Vec<usize>,
|
|
cell_h: f32,
|
|
) {
|
|
let mut model = self.model_mut();
|
|
let Some(h) = model.id_to_frame.get(&id).copied() else {
|
|
return;
|
|
};
|
|
if let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
|
|
store_advances(eb, key, cum, rows, cell_h);
|
|
}
|
|
}
|
|
|
|
/// The focused EditBox's text geometry this frame: its text quad, the scroll window and the
|
|
/// caret and selection spans in it, after scrolling the caret into view (`0x77da80`). `None`
|
|
/// until the advance table is answered: the text draws, without caret or highlight.
|
|
pub fn focused_editbox_text_ui(&mut self) -> Option<types::EditBoxTextUi> {
|
|
let h = {
|
|
let model = self.model_mut();
|
|
let h = model.focused_editbox?;
|
|
let f = model.arena.frame(h)?;
|
|
if !f.effective_visible || !matches!(f.kind_state, KindState::EditBox(_)) {
|
|
return None;
|
|
}
|
|
h
|
|
};
|
|
// Materialize the lazy text region, so an empty, just-focused box has a caret home.
|
|
let rh = super::ensure_text_region(self.lua(), h)?;
|
|
let mut model = self.model_mut();
|
|
// An unanchored text region draws over its owner, so the frame's own width stands in.
|
|
let avail = model
|
|
.region_resolved
|
|
.get(&rh)
|
|
.or_else(|| model.resolved.get(&h))
|
|
.map(|r| r.right - r.left)
|
|
.unwrap_or(0.0);
|
|
let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state)
|
|
else {
|
|
return None;
|
|
};
|
|
let display = eb.display();
|
|
if !display.is_empty() && eb.advances.len() != display.len() + 1 {
|
|
return None;
|
|
}
|
|
if display.is_empty() {
|
|
eb.advances = vec![0.0];
|
|
eb.rows = vec![0];
|
|
}
|
|
// Keep the caret inside the window with room at the right edge; a multi-line box wraps
|
|
// instead, its window pinned at 0.
|
|
eb.clamp_scroll((avail - 2.0).max(0.0));
|
|
let from = eb.scroll_start.min(display.len());
|
|
let origin = eb.advances[from];
|
|
let cursor_d = eb.text_to_display(eb.cursor).min(display.len());
|
|
let (a, b) = (eb.sel_start.min(eb.sel_end), eb.sel_start.max(eb.sel_end));
|
|
let (da, db) = (
|
|
eb.text_to_display(a).min(display.len()),
|
|
eb.text_to_display(b).min(display.len()),
|
|
);
|
|
let (caret_row, caret_x, selection) = if eb.multi_line {
|
|
let (row, x) = eb.caret_row_x(cursor_d);
|
|
// One span per touched row, its overlap with [da, db), measured from the row's origin.
|
|
let mut spans = Vec::new();
|
|
if da != db {
|
|
for i in 0..eb.rows.len() {
|
|
let (rs, re) = eb.row_range(i, display.len());
|
|
let (s0, s1) = (da.max(rs), db.min(re));
|
|
if s0 < s1 {
|
|
let ro = eb.advances[rs.min(display.len())];
|
|
spans.push((i, eb.advances[s0] - ro, eb.advances[s1] - ro));
|
|
}
|
|
}
|
|
}
|
|
(row, x, spans)
|
|
} else {
|
|
let spans = if da != db {
|
|
vec![(
|
|
0,
|
|
(eb.advances[da] - origin).max(0.0),
|
|
eb.advances[db] - origin,
|
|
)]
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
(0, eb.advances[cursor_d] - origin, spans)
|
|
};
|
|
Some(types::EditBoxTextUi {
|
|
target: crate::order::ZTarget::Region(rh),
|
|
display_from: from,
|
|
multi_line: eb.multi_line,
|
|
caret_x,
|
|
caret_row,
|
|
cell_h: eb.cell_h,
|
|
caret_on: eb.caret_shown,
|
|
selection,
|
|
highlight_color: eb.highlight_color,
|
|
})
|
|
}
|
|
|
|
/// Ctrl/Cmd+C: the focused EditBox's selection for the OS clipboard; a password box yields its
|
|
/// mask run, never the text, where the client copies the empty string (`0x882748`).
|
|
pub fn editbox_copy(&mut self) -> Option<String> {
|
|
super::copy_selection(self.lua())
|
|
}
|
|
|
|
/// Ctrl/Cmd+X for the focused EditBox: copy, then delete the selection.
|
|
pub fn editbox_cut(&mut self) -> Option<String> {
|
|
super::cut_selection(self.lua())
|
|
}
|
|
|
|
/// Push a sent line, as its canonical slash line, into the named box's history where the
|
|
/// reference calls `ChatEdit_AddHistory`. By name: the router runs after the box has cleared
|
|
/// and lost focus. `false` when `box_name` is no live EditBox.
|
|
pub fn editbox_add_history(&mut self, box_name: &str, line: &str) -> bool {
|
|
let lua = self.lua();
|
|
let Ok(t) = lua.globals().get::<mlua::Table>(box_name) else {
|
|
return false;
|
|
};
|
|
let Ok(h) = crate::script::object::frame_handle_of(lua, &t) else {
|
|
return false;
|
|
};
|
|
super::with_eb(lua, h, |eb| eb.add_history_line(line)).is_some()
|
|
}
|
|
}
|
|
|
|
/// Box `h`'s advance-table request, `None` when its table is current for the display string,
|
|
/// font, raster scale and, multi-line, wrap width; an empty display needs no trip and is settled
|
|
/// here.
|
|
fn stale_advances(
|
|
model: &mut Model,
|
|
h: FrameHandle,
|
|
rh: RegionHandle,
|
|
) -> Option<types::EditBoxAdvanceRequest> {
|
|
use std::hash::{Hash, Hasher};
|
|
let (display, multi_line) = match model.arena.frame(h).map(|f| &f.kind_state) {
|
|
Some(KindState::EditBox(eb)) => (eb.display(), eb.multi_line),
|
|
_ => return None,
|
|
};
|
|
let d = model.region_data.get(&rh);
|
|
let font = d.and_then(|d| d.font_path.clone());
|
|
let height = d.and_then(|d| d.font_height);
|
|
let outline = d.map(|d| d.outline).unwrap_or_default();
|
|
// A multi-line box steps its rows by the font height plus the gap (`0x77d133`-`0x77d161`).
|
|
let spacing = d.map_or(0.0, super::super::RegionData::line_gap);
|
|
// The host measures at the drawn raster size, so the advances land on the drawn glyphs.
|
|
let scale = model
|
|
.arena
|
|
.frame(h)
|
|
.map(|f| f.effective_scale)
|
|
.unwrap_or(1.0);
|
|
// A multi-line box wraps at the text region's width, as the draw does; a resize re-keys.
|
|
let wrap_width = multi_line
|
|
.then(|| {
|
|
model
|
|
.region_resolved
|
|
.get(&rh)
|
|
.or_else(|| model.resolved.get(&h))
|
|
.map(|r| r.right - r.left)
|
|
})
|
|
.flatten()
|
|
.filter(|w| *w > 0.0);
|
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
|
display.hash(&mut hasher);
|
|
font.hash(&mut hasher);
|
|
height.map(f32::to_bits).hash(&mut hasher);
|
|
(outline as u8).hash(&mut hasher);
|
|
spacing.to_bits().hash(&mut hasher);
|
|
wrap_width.map(f32::to_bits).hash(&mut hasher);
|
|
scale.to_bits().hash(&mut hasher);
|
|
let key = hasher.finish();
|
|
let id = model.frame_id(h);
|
|
let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) else {
|
|
return None;
|
|
};
|
|
if eb.advances_key == key && eb.advances.len() == display.len() + 1 {
|
|
return None; // cache warm
|
|
}
|
|
if display.is_empty() {
|
|
let cell_h = eb.cell_h;
|
|
store_advances(eb, key, vec![0.0], vec![0], cell_h);
|
|
return None;
|
|
}
|
|
Some(types::EditBoxAdvanceRequest {
|
|
id,
|
|
font,
|
|
height,
|
|
outline,
|
|
spacing,
|
|
scale,
|
|
text: display,
|
|
wrap_width,
|
|
key,
|
|
})
|
|
}
|
|
|
|
fn store_advances(eb: &mut EditBoxState, key: u64, cum: Vec<f32>, rows: Vec<usize>, cell_h: f32) {
|
|
eb.advances = cum;
|
|
eb.rows = if rows.is_empty() { vec![0] } else { rows };
|
|
eb.cell_h = cell_h;
|
|
eb.advances_key = key;
|
|
}
|
|
|
|
/// Whether box `h`'s advance table is current, measured here by the installed font engine when
|
|
/// its text or font moved, as the caret leg measures in the call (`0x772ae0`). `false` with no
|
|
/// engine, or one that leaves the table to the host's round trip.
|
|
pub(super) fn advances_current(lua: &Lua, h: FrameHandle) -> bool {
|
|
let Some(rh) = super::ensure_text_region(lua, h) else {
|
|
return false;
|
|
};
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
let Some(req) = stale_advances(&mut model, h, rh) else {
|
|
return true;
|
|
};
|
|
// Taken out and put back, as `ensure_measured` does: the engine cannot re-enter the VM.
|
|
let Some(mut engine) = model.measurer.take() else {
|
|
return false;
|
|
};
|
|
let answer = engine.editbox_advances(&req);
|
|
model.measurer = Some(engine);
|
|
let Some((cum, rows, cell_h)) = answer else {
|
|
return false;
|
|
};
|
|
if let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
|
|
store_advances(eb, req.key, cum, rows, cell_h);
|
|
}
|
|
true
|
|
}
|